mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: provider profiles — custom provider definitions (#290)
* feat: add provider profiles for custom provider definitions Users can define custom providers in config.json that appear as first-class entries alongside built-ins. A provider can override a built-in (custom binary, env, models) or create a new one by extending a base via `extends`. Generic ACP transport supported via `extends: "acp"`. Providers can be hidden with `enabled: false`. Hardcoded models merge with runtime-fetched ones. - Config schema with Zod validation, auto-migration from old format - Dynamic provider registry replaces static provider lists - GenericACPAgentClient for user-defined ACP providers - Snapshot entries carry label/description/defaultModeId over the wire - MCP tools accept dynamic provider IDs - App derives provider definitions from snapshot with static fallback - CLI `provider ls` calls daemon with label column - Schedule/session rehydration validates providers against registry * fix: accept any provider status in CLI provider ls test The test now connects to a real daemon where providers may be loading or unavailable, not just the static "available" fallback. * ci: re-trigger CI checks * style: fix checkout-git.ts formatting to match CI Biome version * fix: relax provider ls test assertions for daemon-backed responses The daemon snapshot may not include all 5 built-in providers in CI (some require external binaries). Assert at least the core 3 (claude, codex, opencode) instead of all 5. * fix app combobox dropdown positioning flash * refactor: stop merging models in provider registry, use override models directly Override models now replace instead of merge with base provider models. Also add icon/colorTier fallback from definition modes in fetchModes. * refactor: make provider definitions fully dynamic from server snapshots Remove static AGENT_PROVIDER_DEFINITIONS fallbacks from the client — providers, modes, icons, and color tiers now flow entirely from runtime snapshots. Add icon and colorTier to AgentMode schema so the server can advertise mode visuals directly. Fix setAgentMode to persist modeId in agent config so the selected mode survives session reload. Simplify model merging so profile models replace runtime models instead of prepending. * docs: add ad-hoc daemon testing guide
This commit is contained in:
159
docs/AD-HOC-DAEMON-TESTING.md
Normal file
159
docs/AD-HOC-DAEMON-TESTING.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Ad-hoc daemon testing
|
||||
|
||||
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
|
||||
|
||||
## Quick start
|
||||
|
||||
```typescript
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import pino from "pino";
|
||||
import { createPaseoDaemon } from "./bootstrap.js";
|
||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
|
||||
const logger = pino({ level: "warn" });
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-test-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
|
||||
const daemon = await createPaseoDaemon(
|
||||
{
|
||||
listen: "127.0.0.1:0", // OS picks a free port
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
allowedHosts: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: {},
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: false,
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
// Add custom config here, e.g.:
|
||||
// providerOverrides: { ... },
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
await daemon.start();
|
||||
const target = daemon.getListenTarget();
|
||||
const port = target!.type === "tcp" ? target!.port : null;
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
appVersion: "0.1.54", // see gotcha #1
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
|
||||
// ... do your testing ...
|
||||
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
npx tsx packages/server/src/server/your-script.ts
|
||||
```
|
||||
|
||||
## Using the test helper
|
||||
|
||||
For simpler cases, `createTestPaseoDaemon` + `DaemonClient` handles temp dirs and port selection:
|
||||
|
||||
```typescript
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.54",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
|
||||
// ... test ...
|
||||
|
||||
await client.close();
|
||||
await daemon.close(); // stops daemon + cleans up temp dirs
|
||||
```
|
||||
|
||||
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
|
||||
|
||||
## Common client methods
|
||||
|
||||
```typescript
|
||||
// Provider discovery
|
||||
const snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||
const models = await client.listProviderModels("claude");
|
||||
const modes = await client.listProviderModes("claude");
|
||||
|
||||
// Agent lifecycle
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: "/tmp" });
|
||||
await client.sendMessage(agent.id, "Hello");
|
||||
const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "idle");
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### 1. appVersion gates provider visibility
|
||||
|
||||
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
|
||||
|
||||
Always pass `appVersion`:
|
||||
```typescript
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
appVersion: "0.1.54",
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Provider snapshots are async
|
||||
|
||||
After the daemon starts, providers are probed in the background. The first `getProvidersSnapshot()` call will likely return `status: "loading"` for most providers. Poll until the provider you care about is no longer loading:
|
||||
|
||||
```typescript
|
||||
let snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const entry = snapshot.entries.find((e) => e.provider === "gemini");
|
||||
if (entry && entry.status !== "loading") break;
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||
}
|
||||
```
|
||||
|
||||
### 3. fetchAgents is required before most operations
|
||||
|
||||
Call `client.fetchAgents()` after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like `get_providers_snapshot_request` will silently hang.
|
||||
|
||||
### 4. listen: "127.0.0.1:0" for port allocation
|
||||
|
||||
Always use port `0` so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs.
|
||||
|
||||
### 5. Script must live inside packages/server
|
||||
|
||||
The test utilities use relative imports through the TypeScript project. Place your script somewhere under `packages/server/src/` and import from there. Scripts outside the repo will fail with module resolution errors.
|
||||
|
||||
### 6. Cleanup on failure
|
||||
|
||||
Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
// ... test logic ...
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
}
|
||||
```
|
||||
|
||||
### 7. ACP providers spawn real processes
|
||||
|
||||
When testing ACP providers (e.g., Gemini with `extends: "acp"`), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider.
|
||||
@@ -557,7 +557,11 @@ export function AgentConfigRow({
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
||||
|
||||
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
|
||||
const selectedModeVisuals = getModeVisuals(
|
||||
selectedProvider,
|
||||
effectiveSelectedMode,
|
||||
providerDefinitions,
|
||||
);
|
||||
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
||||
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
import {
|
||||
buildFavoriteModelKey,
|
||||
mergeProviderPreferences,
|
||||
@@ -40,7 +41,6 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
getModeVisuals,
|
||||
type AgentModeColorTier,
|
||||
type AgentModeIcon,
|
||||
@@ -60,10 +60,6 @@ type StatusOption = {
|
||||
|
||||
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
||||
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
|
||||
type ControlledAgentStatusBarProps = {
|
||||
provider: string;
|
||||
providerOptions?: StatusOption[];
|
||||
@@ -81,7 +77,7 @@ type ControlledAgentStatusBarProps = {
|
||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||
disabled?: boolean;
|
||||
isModelLoading?: boolean;
|
||||
providerDefinitions?: AgentProviderDefinition[];
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
||||
canSelectModelProvider?: (providerId: string) => boolean;
|
||||
favoriteKeys?: Set<string>;
|
||||
@@ -252,7 +248,9 @@ function ControlledStatusBar({
|
||||
thinkingOptions?.[0]?.label ?? "Unknown",
|
||||
);
|
||||
|
||||
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
||||
const modeVisuals = selectedModeId
|
||||
? getModeVisuals(provider, selectedModeId, providerDefinitions)
|
||||
: undefined;
|
||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
@@ -300,9 +298,7 @@ function ControlledStatusBar({
|
||||
);
|
||||
return map;
|
||||
}, [modelOptions, provider]);
|
||||
const effectiveProviderDefinitions =
|
||||
providerDefinitions ??
|
||||
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
|
||||
const effectiveProviderDefinitions = providerDefinitions;
|
||||
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
||||
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
|
||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||
@@ -322,7 +318,7 @@ function ControlledStatusBar({
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => {
|
||||
const visuals = getModeVisuals(provider, option.id);
|
||||
const visuals = getModeVisuals(provider, option.id, providerDefinitions);
|
||||
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||
return (
|
||||
<ComboboxItem
|
||||
@@ -334,7 +330,7 @@ function ControlledStatusBar({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[provider, theme.colors.foreground],
|
||||
[provider, providerDefinitions, theme.colors.foreground],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
@@ -745,7 +741,7 @@ function ControlledStatusBar({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{modeOptions.map((mode) => {
|
||||
const visuals = getModeVisuals(provider, mode.id);
|
||||
const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
|
||||
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
@@ -896,9 +892,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
const models = snapshotModels;
|
||||
|
||||
const agentProviderDefinitions = useMemo(() => {
|
||||
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
|
||||
const definition = agent?.provider
|
||||
? resolveProviderDefinition(agent.provider, snapshotEntries)
|
||||
: undefined;
|
||||
return definition ? [definition] : [];
|
||||
}, [agent?.provider]);
|
||||
}, [agent?.provider, snapshotEntries]);
|
||||
|
||||
const agentProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
@@ -1128,6 +1126,7 @@ export function DraftAgentStatusBar({
|
||||
/>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
|
||||
interface ProviderDiagnosticSheetProps {
|
||||
provider: string;
|
||||
@@ -21,11 +22,11 @@ export function ProviderDiagnosticSheet({
|
||||
}: ProviderDiagnosticSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const providerLabel =
|
||||
AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
|
||||
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
||||
|
||||
const fetchDiagnostic = useCallback(async () => {
|
||||
if (!client || !provider) return;
|
||||
|
||||
@@ -268,7 +268,7 @@ export function Combobox({
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
||||
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const hasPresentedBottomSheetRef = useRef(false);
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
@@ -276,11 +276,13 @@ export function Combobox({
|
||||
null,
|
||||
);
|
||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
||||
const [referenceLeft, setReferenceLeft] = useState<number | null>(null);
|
||||
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
||||
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
||||
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
|
||||
|
||||
const isControlled = typeof open === "boolean";
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
@@ -324,7 +326,7 @@ export function Combobox({
|
||||
|
||||
const middleware = useMemo(
|
||||
() => [
|
||||
floatingOffset(isWeb ? 0 : 4),
|
||||
floatingOffset(isWeb ? 5 : 4),
|
||||
...(isWeb ? [] : [flip({ padding: collisionPadding })]),
|
||||
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
||||
floatingSize({
|
||||
@@ -338,6 +340,9 @@ export function Combobox({
|
||||
});
|
||||
setReferenceWidth((prev) => {
|
||||
const next = rects.reference.width;
|
||||
if (!(next > 0)) {
|
||||
return prev;
|
||||
}
|
||||
if (prev === next) return prev;
|
||||
return next;
|
||||
});
|
||||
@@ -359,15 +364,18 @@ export function Combobox({
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
setAvailableSize(null);
|
||||
setDesktopContentWidth(null);
|
||||
setReferenceLeft(null);
|
||||
setReferenceWidth(null);
|
||||
return;
|
||||
}
|
||||
const raf = requestAnimationFrame(() => void update());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [desktopPlacement, isMobile, update, isOpen]);
|
||||
}, [desktopPlacement, isMobile, isOpen, update]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
setReferenceLeft(null);
|
||||
setReferenceAtOrigin(false);
|
||||
setReferenceTop(null);
|
||||
return;
|
||||
@@ -381,9 +389,16 @@ export function Combobox({
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
referenceEl.measureInWindow((x, y) => {
|
||||
referenceEl.measureInWindow((x, y, width, height) => {
|
||||
setReferenceLeft((prev) => (prev === x ? prev : x));
|
||||
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
|
||||
setReferenceTop((prev) => (prev === y ? prev : y));
|
||||
setReferenceWidth((prev) => {
|
||||
if (!(width > 0)) {
|
||||
return prev;
|
||||
}
|
||||
return prev === width ? prev : width;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -398,32 +413,46 @@ export function Combobox({
|
||||
isDesktopAboveSearch && referenceTop !== null
|
||||
? Math.max(windowHeight - referenceTop, collisionPadding)
|
||||
: null;
|
||||
const hasResolvedDesktopPosition =
|
||||
referenceWidth !== null &&
|
||||
floatingLeft !== null &&
|
||||
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
|
||||
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
|
||||
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
||||
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
||||
// For top-placed popups: once position resolves, use bottom-based CSS positioning
|
||||
// so height changes grow upward naturally without floating-ui needing to reposition.
|
||||
const useStableBottom =
|
||||
const hasNonZeroFloatingPosition = (floatingTop ?? 0) !== 0 || floatingLeft !== 0;
|
||||
const useMeasuredTopStartPosition =
|
||||
!isDesktopAboveSearch &&
|
||||
IS_WEB &&
|
||||
!isMobile &&
|
||||
hasResolvedDesktopPosition &&
|
||||
desktopPlacement.startsWith("top") &&
|
||||
referenceTop !== null;
|
||||
desktopPlacement === "top-start" &&
|
||||
referenceTop !== null &&
|
||||
referenceLeft !== null &&
|
||||
desktopContentWidth !== null;
|
||||
const clampedMeasuredTopStartLeft = useMeasuredTopStartPosition
|
||||
? Math.max(
|
||||
collisionPadding,
|
||||
Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft),
|
||||
)
|
||||
: null;
|
||||
const measuredTopStartBottom = useMeasuredTopStartPosition
|
||||
? Math.max(windowHeight - referenceTop + 5, collisionPadding)
|
||||
: null;
|
||||
const hasResolvedDesktopPosition =
|
||||
referenceWidth !== null &&
|
||||
referenceWidth > 0 &&
|
||||
(isDesktopAboveSearch
|
||||
? floatingLeft !== null && desktopAboveSearchBottom !== null
|
||||
: useMeasuredTopStartPosition
|
||||
? clampedMeasuredTopStartLeft !== null && measuredTopStartBottom !== null
|
||||
: floatingLeft !== null &&
|
||||
floatingTop !== null &&
|
||||
(hasNonZeroFloatingPosition || !referenceAtOrigin));
|
||||
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
||||
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
||||
|
||||
const desktopPositionStyle = isDesktopAboveSearch
|
||||
? {
|
||||
left: floatingLeft ?? 0,
|
||||
bottom: desktopAboveSearchBottom ?? 0,
|
||||
}
|
||||
: useStableBottom
|
||||
: useMeasuredTopStartPosition
|
||||
? {
|
||||
left: floatingLeft ?? 0,
|
||||
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
|
||||
left: clampedMeasuredTopStartLeft ?? 0,
|
||||
bottom: measuredTopStartBottom ?? 0,
|
||||
}
|
||||
: floatingStyles;
|
||||
|
||||
@@ -728,7 +757,13 @@ export function Combobox({
|
||||
]}
|
||||
ref={refs.setFloating}
|
||||
collapsable={false}
|
||||
onLayout={() => update()}
|
||||
onLayout={(event) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setDesktopContentWidth((prev) => (prev === width ? prev : width));
|
||||
if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
|
||||
void update();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children ? (
|
||||
<>
|
||||
|
||||
@@ -1,12 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-agent-form-state";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "Codex test provider",
|
||||
defaultModeId: "auto",
|
||||
modes: [
|
||||
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
|
||||
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||
],
|
||||
};
|
||||
|
||||
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Claude test provider",
|
||||
defaultModeId: "default",
|
||||
modes: [
|
||||
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
|
||||
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
|
||||
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
|
||||
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
|
||||
],
|
||||
};
|
||||
|
||||
function makeProviderMap(
|
||||
...definitions: AgentProviderDefinition[]
|
||||
): Map<AgentProvider, AgentProviderDefinition> {
|
||||
return new Map(definitions.map((d) => [d.id as AgentProvider, d]));
|
||||
}
|
||||
|
||||
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
|
||||
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
|
||||
|
||||
describe("useAgentFormState", () => {
|
||||
describe("buildProviderDefinitions", () => {
|
||||
it("returns empty array when snapshot data is unavailable", () => {
|
||||
expect(buildProviderDefinitions(undefined)).toEqual([]);
|
||||
expect(buildProviderDefinitions([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds custom provider definitions from snapshot metadata", () => {
|
||||
const entries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "zai",
|
||||
status: "ready",
|
||||
label: "ZAI",
|
||||
description: "Claude with ZAI config",
|
||||
defaultModeId: "default",
|
||||
modes: [
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
description: "Safe mode",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "safe",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
label: "Claude",
|
||||
description: "Anthropic Claude",
|
||||
defaultModeId: "default",
|
||||
modes: [{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" }],
|
||||
},
|
||||
];
|
||||
|
||||
const definitions = buildProviderDefinitions(entries);
|
||||
|
||||
expect(definitions).toEqual([
|
||||
{
|
||||
id: "zai",
|
||||
label: "ZAI",
|
||||
description: "Claude with ZAI config",
|
||||
defaultModeId: "default",
|
||||
modes: [
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
description: "Safe mode",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "safe",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Anthropic Claude",
|
||||
defaultModeId: "default",
|
||||
modes: [
|
||||
{
|
||||
id: "default",
|
||||
label: "Always Ask",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "safe",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.combineInitialValues", () => {
|
||||
it("returns undefined when no initial values and no initial server id", () => {
|
||||
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
|
||||
@@ -78,6 +183,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
@@ -106,6 +212,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
@@ -134,6 +241,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
@@ -161,6 +269,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
@@ -188,6 +297,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
@@ -215,6 +325,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
codexProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||
@@ -256,6 +367,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
claudeProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.model).toBe("default");
|
||||
@@ -263,11 +375,6 @@ describe("useAgentFormState", () => {
|
||||
});
|
||||
|
||||
it("resolves provider only from allowed provider map", () => {
|
||||
const allowedProviderMap = new Map<AgentProvider, AgentProviderDefinition>(
|
||||
AGENT_PROVIDER_DEFINITIONS.filter((definition) => definition.id === "claude").map(
|
||||
(definition) => [definition.id as AgentProvider, definition],
|
||||
),
|
||||
);
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
@@ -289,7 +396,7 @@ describe("useAgentFormState", () => {
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>(),
|
||||
allowedProviderMap,
|
||||
claudeProviderMap,
|
||||
);
|
||||
|
||||
expect(resolved.provider).toBe("claude");
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
@@ -10,6 +7,7 @@ import type {
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
import {
|
||||
useFormPreferences,
|
||||
@@ -99,13 +97,8 @@ type UseAgentFormStateResult = {
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
};
|
||||
|
||||
const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS;
|
||||
const allProviderDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
|
||||
allProviderDefinitions.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
const fallbackDefinition = allProviderDefinitions[0];
|
||||
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "";
|
||||
const DEFAULT_PROVIDER: AgentProvider = "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
|
||||
|
||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||
@@ -180,7 +173,7 @@ function resolveFormState(
|
||||
userModified: UserModifiedFields,
|
||||
currentState: FormState,
|
||||
validServerIds: Set<string>,
|
||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap,
|
||||
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
|
||||
): FormState {
|
||||
// Start with current state - we only update non-user-modified fields
|
||||
const result = { ...currentState };
|
||||
@@ -376,10 +369,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
} = useProvidersSnapshot(formState.serverId);
|
||||
|
||||
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
|
||||
const snapshotProviderDefinitions = useMemo(() => {
|
||||
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
|
||||
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
|
||||
}, [snapshotEntries]);
|
||||
const snapshotProviderDefinitions = useMemo(
|
||||
() => buildProviderDefinitions(snapshotEntries),
|
||||
[snapshotEntries],
|
||||
);
|
||||
const snapshotProviderDefinitionMap = useMemo(
|
||||
() =>
|
||||
new Map<AgentProvider, AgentProviderDefinition>(
|
||||
@@ -388,17 +381,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
[snapshotProviderDefinitions],
|
||||
);
|
||||
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
|
||||
if (!snapshotEntries?.length) {
|
||||
return snapshotProviderDefinitionMap;
|
||||
}
|
||||
const readyProviders = new Set(
|
||||
(snapshotEntries ?? [])
|
||||
.filter((entry) => entry.status === "ready")
|
||||
.map((entry) => entry.provider),
|
||||
snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider),
|
||||
);
|
||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||
snapshotProviderDefinitions
|
||||
.filter((definition) => readyProviders.has(definition.id))
|
||||
.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
}, [snapshotEntries, snapshotProviderDefinitions]);
|
||||
}, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
|
||||
const snapshotAllProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
for (const entry of snapshotEntries ?? []) {
|
||||
|
||||
@@ -49,7 +49,6 @@ import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentSessionConfig,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
@@ -68,10 +67,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
};
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
|
||||
function getParamValue(value: string | string[] | undefined) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
@@ -92,16 +87,14 @@ function getValidProvider(value: string | undefined) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) ? (value as AgentProvider) : undefined;
|
||||
return value as AgentProvider;
|
||||
}
|
||||
|
||||
function getValidMode(provider: AgentProvider | undefined, value: string | undefined) {
|
||||
if (!provider || !value) {
|
||||
return undefined;
|
||||
}
|
||||
const definition = PROVIDER_DEFINITION_MAP.get(provider);
|
||||
const modes = definition?.modes ?? [];
|
||||
return modes.some((mode) => mode.id === value) ? value : undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
type DraftAgentParams = {
|
||||
|
||||
@@ -68,10 +68,10 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -523,6 +523,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
const isConnected = useHostRuntimeIsConnected(routeServerId);
|
||||
const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId);
|
||||
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(null);
|
||||
const providerDefinitions = buildProviderDefinitions(entries);
|
||||
|
||||
const hasServer = routeServerId.length > 0;
|
||||
|
||||
@@ -558,7 +559,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
|
||||
</View>
|
||||
) : (
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
{AGENT_PROVIDER_DEFINITIONS.map((def) => {
|
||||
{providerDefinitions.map((def) => {
|
||||
const entry = entries?.find((e) => e.provider === def.id);
|
||||
const status = entry?.status ?? "unavailable";
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
|
||||
47
packages/app/src/utils/provider-definitions.ts
Normal file
47
packages/app/src/utils/provider-definitions.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ProviderSnapshotEntry, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
type AgentModeColorTier,
|
||||
type AgentModeIcon,
|
||||
type AgentProviderDefinition,
|
||||
type AgentProviderModeDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
|
||||
function buildProviderModes(entry: ProviderSnapshotEntry): AgentProviderModeDefinition[] {
|
||||
const entryModes = entry.modes ?? [];
|
||||
|
||||
return entryModes.map((mode) => ({
|
||||
...mode,
|
||||
icon: (mode.icon ?? "ShieldCheck") as AgentModeIcon,
|
||||
colorTier: (mode.colorTier ?? "moderate") as AgentModeColorTier,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildProviderDefinitions(
|
||||
snapshotEntries: ProviderSnapshotEntry[] | undefined,
|
||||
): AgentProviderDefinition[] {
|
||||
if (!snapshotEntries?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return snapshotEntries.map((entry) => ({
|
||||
id: entry.provider,
|
||||
label: entry.label ?? entry.provider,
|
||||
description: entry.description ?? "",
|
||||
defaultModeId: entry.defaultModeId ?? null,
|
||||
modes: buildProviderModes(entry),
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveProviderLabel(
|
||||
provider: string,
|
||||
snapshotEntries: ProviderSnapshotEntry[] | undefined,
|
||||
): string {
|
||||
return snapshotEntries?.find((entry) => entry.provider === provider)?.label ?? provider;
|
||||
}
|
||||
|
||||
export function resolveProviderDefinition(
|
||||
provider: string,
|
||||
snapshotEntries: ProviderSnapshotEntry[] | undefined,
|
||||
): AgentProviderDefinition | undefined {
|
||||
return buildProviderDefinitions(snapshotEntries).find((definition) => definition.id === provider);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/server";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
|
||||
/** Provider list item for display */
|
||||
export interface ProviderListItem {
|
||||
provider: string;
|
||||
label: string;
|
||||
status: string;
|
||||
defaultMode: string;
|
||||
modes: string;
|
||||
@@ -13,16 +15,22 @@ export interface ProviderListItem {
|
||||
/** Derive provider list from the manifest — single source of truth */
|
||||
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
|
||||
provider: def.id,
|
||||
label: def.label,
|
||||
status: "available",
|
||||
defaultMode: def.defaultModeId ?? "default",
|
||||
modes: def.modes.map((m) => m.label).join(", "),
|
||||
}));
|
||||
|
||||
function getStaticProviders(): ProviderListItem[] {
|
||||
return PROVIDERS;
|
||||
}
|
||||
|
||||
/** Schema for provider ls output */
|
||||
export const providerLsSchema: OutputSchema<ProviderListItem> = {
|
||||
idField: "provider",
|
||||
columns: [
|
||||
{ header: "PROVIDER", field: "provider", width: 12 },
|
||||
{ header: "LABEL", field: "label", width: 16 },
|
||||
{
|
||||
header: "STATUS",
|
||||
field: "status",
|
||||
@@ -45,13 +53,39 @@ export interface ProviderLsOptions extends CommandOptions {
|
||||
}
|
||||
|
||||
export async function runLsCommand(
|
||||
_options: ProviderLsOptions,
|
||||
options: ProviderLsOptions,
|
||||
_command: Command,
|
||||
): Promise<ProviderLsResult> {
|
||||
// Provider data is static - no daemon connection needed
|
||||
return {
|
||||
type: "list",
|
||||
data: PROVIDERS,
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
const client = await tryConnectToDaemon({ host: options.host });
|
||||
|
||||
if (!client) {
|
||||
return {
|
||||
type: "list",
|
||||
data: getStaticProviders(),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await client.getProvidersSnapshot();
|
||||
return {
|
||||
type: "list",
|
||||
data: snapshot.entries.map((entry) => ({
|
||||
provider: entry.provider,
|
||||
label: entry.label ?? entry.provider,
|
||||
status: entry.status === "ready" ? "available" : entry.status,
|
||||
defaultMode: entry.defaultModeId ?? "default",
|
||||
modes: (entry.modes ?? []).map((mode) => mode.label).join(", "),
|
||||
})),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
type: "list",
|
||||
data: getStaticProviders(),
|
||||
schema: providerLsSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,12 @@ try {
|
||||
assert(result.stdout.includes("claude"), "output should include claude");
|
||||
assert(result.stdout.includes("codex"), "output should include codex");
|
||||
assert(result.stdout.includes("opencode"), "output should include opencode");
|
||||
assert(result.stdout.includes("available"), "output should show available status");
|
||||
assert(
|
||||
result.stdout.includes("available") ||
|
||||
result.stdout.includes("loading") ||
|
||||
result.stdout.includes("unavailable"),
|
||||
"output should show a provider status",
|
||||
);
|
||||
console.log("✓ provider ls lists all providers\n");
|
||||
}
|
||||
|
||||
@@ -140,7 +145,7 @@ try {
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const data = JSON.parse(result.stdout.trim());
|
||||
assert(Array.isArray(data), "output should be an array");
|
||||
assert.strictEqual(data.length, 5, "should have 5 providers");
|
||||
assert(data.length >= 3, `should have at least 3 providers, got ${data.length}`);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "claude"),
|
||||
"should include claude",
|
||||
@@ -153,14 +158,6 @@ try {
|
||||
data.some((p: { provider: string }) => p.provider === "opencode"),
|
||||
"should include opencode",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "copilot"),
|
||||
"should include copilot",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "pi"),
|
||||
"should include pi",
|
||||
);
|
||||
console.log("✓ provider ls --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
@@ -170,12 +167,10 @@ try {
|
||||
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
assert.strictEqual(lines.length, 5, "should have 5 lines");
|
||||
assert(lines.length >= 3, `should have at least 3 lines, got ${lines.length}`);
|
||||
assert(lines.includes("claude"), "should include claude");
|
||||
assert(lines.includes("codex"), "should include codex");
|
||||
assert(lines.includes("opencode"), "should include opencode");
|
||||
assert(lines.includes("copilot"), "should include copilot");
|
||||
assert(lines.includes("pi"), "should include pi");
|
||||
console.log("✓ provider ls --quiet outputs provider names only\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||
import type { ScheduleService } from "../schedule/service.js";
|
||||
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS, type ProviderDefinition } from "./provider-registry.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import {
|
||||
AgentModelSchema,
|
||||
AgentProviderEnum,
|
||||
@@ -832,7 +832,7 @@ export async function createAgentManagementMcpServer(
|
||||
async () => ({
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
providers: AGENT_PROVIDER_DEFINITIONS.map((provider) => ({
|
||||
providers: Object.values(providerRegistry ?? {}).map((provider) => ({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
modes: provider.modes.map((mode) => ({
|
||||
|
||||
@@ -273,6 +273,156 @@ describe("AgentManager", () => {
|
||||
expect(snapshot.config.modeId).toBe("auto");
|
||||
});
|
||||
|
||||
test("setAgentMode persists the selected mode across session reload", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class ModeAwareSession implements AgentSession {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
readonly id = randomUUID();
|
||||
private currentMode: string | null;
|
||||
|
||||
constructor(private readonly config: AgentSessionConfig) {
|
||||
this.currentMode = config.modeId ?? null;
|
||||
}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||
}
|
||||
|
||||
async startTurn(): Promise<{ turnId: string }> {
|
||||
return { turnId: "turn-1" };
|
||||
}
|
||||
|
||||
subscribe(): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: this.config.model ?? null,
|
||||
modeId: this.currentMode,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCurrentMode() {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
this.currentMode = modeId;
|
||||
}
|
||||
|
||||
getPendingPermissions() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(): Promise<void> {}
|
||||
|
||||
describePersistence() {
|
||||
return { provider: this.provider, sessionId: this.id };
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {}
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
class ModeAwareClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new ModeAwareSession(config);
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
return new ModeAwareSession({
|
||||
provider: "codex",
|
||||
cwd: config?.cwd ?? workdir,
|
||||
modeId: config?.modeId,
|
||||
model: config?.model,
|
||||
});
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
return [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }];
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new ModeAwareClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000301",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
modeId: "auto",
|
||||
});
|
||||
|
||||
await manager.setAgentMode(snapshot.id, "full-access");
|
||||
|
||||
const beforeReload = manager.getAgent(snapshot.id);
|
||||
expect(beforeReload?.config.modeId).toBe("full-access");
|
||||
expect(beforeReload?.currentModeId).toBe("full-access");
|
||||
|
||||
const reloaded = await manager.reloadAgentSession(snapshot.id);
|
||||
expect(reloaded.config.modeId).toBe("full-access");
|
||||
expect(reloaded.currentModeId).toBe("full-access");
|
||||
});
|
||||
|
||||
test("listProviderAvailability uses registered client keys, including custom providers", async () => {
|
||||
const customClient: AgentClient = {
|
||||
provider: "zai",
|
||||
capabilities: TEST_CAPABILITIES,
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async createSession() {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
async resumeSession() {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
zai: customClient,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(manager.listProviderAvailability()).resolves.toEqual([
|
||||
{
|
||||
provider: "zai",
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("createAgent passes daemon launch env through the provider launch context", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -33,7 +33,7 @@ import type {
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { AGENT_PROVIDER_IDS, getAgentProviderDefinition } from "./provider-manifest.js";
|
||||
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
||||
|
||||
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
|
||||
|
||||
@@ -362,6 +362,10 @@ export class AgentManager {
|
||||
this.clients.set(provider, client);
|
||||
}
|
||||
|
||||
getRegisteredProviderIds(): AgentProvider[] {
|
||||
return Array.from(this.clients.keys());
|
||||
}
|
||||
|
||||
setAgentAttentionCallback(callback: AgentAttentionCallback): void {
|
||||
this.onAgentAttention = callback;
|
||||
}
|
||||
@@ -501,8 +505,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async listProviderAvailability(): Promise<ProviderAvailability[]> {
|
||||
const checks = AGENT_PROVIDER_IDS.map(async (providerId) => {
|
||||
const provider = providerId as AgentProvider;
|
||||
const checks = Array.from(this.clients.keys()).map(async (provider) => {
|
||||
const client = this.clients.get(provider);
|
||||
if (!client) {
|
||||
return {
|
||||
@@ -950,6 +953,7 @@ export class AgentManager {
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
await agent.session.setMode(modeId);
|
||||
agent.config.modeId = modeId;
|
||||
agent.currentModeId = modeId;
|
||||
// Update runtimeInfo to reflect the new mode
|
||||
if (agent.runtimeInfo) {
|
||||
|
||||
@@ -43,6 +43,8 @@ export type AgentMode = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
colorTier?: string;
|
||||
};
|
||||
|
||||
export type ProviderStatus = "ready" | "loading" | "error" | "unavailable";
|
||||
@@ -73,6 +75,9 @@ export interface ProviderSnapshotEntry {
|
||||
models?: AgentModelDefinition[];
|
||||
modes?: AgentMode[];
|
||||
fetchedAt?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
defaultModeId?: string | null;
|
||||
}
|
||||
|
||||
export type AgentFeatureToggle = {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
|
||||
type TestDeps = {
|
||||
agentManager: AgentManager;
|
||||
@@ -53,6 +54,20 @@ function createTestDeps(): TestDeps {
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderDefinition(overrides: Partial<ProviderDefinition>): ProviderDefinition {
|
||||
return {
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Test provider",
|
||||
defaultModeId: "default",
|
||||
modes: [],
|
||||
createClient: vi.fn() as ProviderDefinition["createClient"],
|
||||
fetchModels: vi.fn().mockResolvedValue([]),
|
||||
fetchModes: vi.fn().mockResolvedValue([]),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("create_agent MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
const existingCwd = process.cwd();
|
||||
@@ -213,6 +228,22 @@ describe("create_agent MCP tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts custom provider IDs in create_agent input validation", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
cwd: existingCwd,
|
||||
title: "Custom provider agent",
|
||||
initialMode: "default",
|
||||
agentType: "zai",
|
||||
initialPrompt: "Do work",
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("allows caller agents to override cwd and applies caller context labels", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const baseDir = await mkdtemp(join(tmpdir(), "paseo-mcp-test-"));
|
||||
@@ -302,6 +333,52 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider listing MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("returns providers from the registry, including custom providers", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const providerRegistry = {
|
||||
claude: createProviderDefinition({
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
|
||||
}),
|
||||
zai: createProviderDefinition({
|
||||
id: "zai",
|
||||
label: "ZAI",
|
||||
description: "Custom Claude profile",
|
||||
defaultModeId: "default",
|
||||
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
|
||||
}),
|
||||
};
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerRegistry,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["list_providers"];
|
||||
const response = await tool.callback({});
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
providers: [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
|
||||
},
|
||||
{
|
||||
id: "zai",
|
||||
label: "ZAI",
|
||||
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("speak MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import { captureTerminalLines } from "../../terminal/terminal.js";
|
||||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
||||
import type { ScheduleService } from "../schedule/service.js";
|
||||
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS, type ProviderDefinition } from "./provider-registry.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import { deletePaseoWorktree, listPaseoWorktrees } from "../../utils/worktree.js";
|
||||
import {
|
||||
AgentModelSchema,
|
||||
@@ -1362,7 +1362,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
async () => ({
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
providers: AGENT_PROVIDER_DEFINITIONS.map((provider) => ({
|
||||
providers: Object.values(providerRegistry ?? {}).map((provider) => ({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
modes: provider.modes.map((mode) => ({
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
|
||||
import type { AgentPromptInput, AgentPermissionRequest } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { serializeAgentSnapshot } from "../messages.js";
|
||||
import { StoredScheduleSchema } from "../schedule/types.js";
|
||||
|
||||
export const AgentProviderEnum = z.enum(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
|
||||
AgentProvider,
|
||||
...AgentProvider[],
|
||||
],
|
||||
);
|
||||
export const AgentProviderEnum = z.string();
|
||||
|
||||
export const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
|
||||
|
||||
@@ -22,6 +16,8 @@ export const ProviderModeSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
colorTier: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ProviderSummarySchema = z.object({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
resolveProviderCommandPrefix,
|
||||
applyProviderEnv,
|
||||
migrateProviderSettings,
|
||||
ProviderOverrideSchema,
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "./provider-launch-config.js";
|
||||
|
||||
@@ -103,3 +105,175 @@ describe("applyProviderEnv", () => {
|
||||
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderOverrideSchema", () => {
|
||||
test("accepts built-in override fields", () => {
|
||||
const parsed = ProviderOverrideSchema.parse({
|
||||
command: ["custom-claude", "--json"],
|
||||
env: {
|
||||
FOO: "bar",
|
||||
},
|
||||
enabled: false,
|
||||
order: 2,
|
||||
});
|
||||
|
||||
expect(parsed.command).toEqual(["custom-claude", "--json"]);
|
||||
expect(parsed.env?.FOO).toBe("bar");
|
||||
expect(parsed.enabled).toBe(false);
|
||||
expect(parsed.order).toBe(2);
|
||||
});
|
||||
|
||||
test("accepts models with thinking options", () => {
|
||||
const parsed = ProviderOverrideSchema.parse({
|
||||
models: [
|
||||
{
|
||||
id: "zai-fast",
|
||||
label: "ZAI Fast",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{
|
||||
id: "deep",
|
||||
label: "Deep",
|
||||
description: "Higher effort",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.models).toEqual([
|
||||
{
|
||||
id: "zai-fast",
|
||||
label: "ZAI Fast",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{
|
||||
id: "deep",
|
||||
label: "Deep",
|
||||
description: "Higher effort",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateProviderSettings", () => {
|
||||
const builtinProviderIds = ["claude", "codex", "copilot", "opencode", "pi"];
|
||||
|
||||
test("passes through entries already in the new format", () => {
|
||||
const migrated = migrateProviderSettings(
|
||||
{
|
||||
zai: {
|
||||
extends: "claude",
|
||||
label: "ZAI",
|
||||
command: ["zai"],
|
||||
env: {
|
||||
ZAI_KEY: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
builtinProviderIds,
|
||||
);
|
||||
|
||||
expect(migrated).toEqual({
|
||||
zai: {
|
||||
extends: "claude",
|
||||
label: "ZAI",
|
||||
command: ["zai"],
|
||||
env: {
|
||||
ZAI_KEY: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("migrates mode replace to command argv", () => {
|
||||
const migrated = migrateProviderSettings(
|
||||
{
|
||||
claude: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["docker", "run", "--rm", "claude"],
|
||||
},
|
||||
},
|
||||
},
|
||||
builtinProviderIds,
|
||||
);
|
||||
|
||||
expect(migrated).toEqual({
|
||||
claude: {
|
||||
command: ["docker", "run", "--rm", "claude"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("migrates mode default by dropping command", () => {
|
||||
const migrated = migrateProviderSettings(
|
||||
{
|
||||
codex: {
|
||||
command: {
|
||||
mode: "default",
|
||||
},
|
||||
env: {
|
||||
FOO: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
builtinProviderIds,
|
||||
);
|
||||
|
||||
expect(migrated).toEqual({
|
||||
codex: {
|
||||
env: {
|
||||
FOO: "bar",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("drops append mode entries because they cannot be auto-migrated", () => {
|
||||
const migrated = migrateProviderSettings(
|
||||
{
|
||||
claude: {
|
||||
command: {
|
||||
mode: "append",
|
||||
args: ["--debug"],
|
||||
},
|
||||
env: {
|
||||
FOO: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
builtinProviderIds,
|
||||
);
|
||||
|
||||
expect(migrated).toEqual({});
|
||||
});
|
||||
|
||||
test("preserves legacy env while migrating old entries", () => {
|
||||
const migrated = migrateProviderSettings(
|
||||
{
|
||||
opencode: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["opencode"],
|
||||
},
|
||||
env: {
|
||||
PATH: "/custom/bin",
|
||||
},
|
||||
},
|
||||
},
|
||||
builtinProviderIds,
|
||||
);
|
||||
|
||||
expect(migrated).toEqual({
|
||||
opencode: {
|
||||
command: ["opencode"],
|
||||
env: {
|
||||
PATH: "/custom/bin",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,38 @@ export const ProviderRuntimeSettingsSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ProviderProfileThinkingOptionSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ProviderProfileModelSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
thinkingOptions: z.array(ProviderProfileThinkingOptionSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ProviderOverrideSchema = z
|
||||
.object({
|
||||
extends: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
command: z.array(z.string().min(1)).min(1).optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
models: z.array(ProviderProfileModelSchema).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
order: z.number().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const AgentProviderRuntimeSettingsMapSchema = z.record(
|
||||
AgentProviderSchema,
|
||||
ProviderRuntimeSettingsSchema,
|
||||
@@ -44,6 +76,8 @@ export const AgentProviderRuntimeSettingsMapSchema = z.record(
|
||||
|
||||
export type ProviderCommand = z.infer<typeof ProviderCommandSchema>;
|
||||
export type ProviderRuntimeSettings = z.infer<typeof ProviderRuntimeSettingsSchema>;
|
||||
export type ProviderProfileModel = z.infer<typeof ProviderProfileModelSchema>;
|
||||
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
||||
export type AgentProviderRuntimeSettingsMap = Partial<
|
||||
Record<AgentProvider, ProviderRuntimeSettings>
|
||||
>;
|
||||
@@ -77,6 +111,48 @@ export async function resolveProviderCommandPrefix(
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateProviderSettings(
|
||||
raw: Record<string, unknown>,
|
||||
builtinProviderIds: string[],
|
||||
): Record<string, ProviderOverride> {
|
||||
const migrated: Record<string, ProviderOverride> = {};
|
||||
const builtinProviderIdSet = new Set(builtinProviderIds);
|
||||
|
||||
for (const [providerId, value] of Object.entries(raw)) {
|
||||
const parsedNew = ProviderOverrideSchema.safeParse(value);
|
||||
if (parsedNew.success) {
|
||||
migrated[providerId] = parsedNew.data;
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedOld = ProviderRuntimeSettingsSchema.safeParse(value);
|
||||
if (!parsedOld.success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextEntry: ProviderOverride = {};
|
||||
const command = parsedOld.data.command;
|
||||
if (command?.mode === "append") {
|
||||
console.warn(
|
||||
`[Config] Skipping legacy agents.providers.${providerId}.command append mode during provider override migration because it cannot be auto-migrated.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (command?.mode === "replace") {
|
||||
nextEntry.command = command.argv;
|
||||
}
|
||||
if (parsedOld.data.env) {
|
||||
nextEntry.env = parsedOld.data.env;
|
||||
}
|
||||
if (!builtinProviderIdSet.has(providerId) && nextEntry.extends === undefined) {
|
||||
delete nextEntry.extends;
|
||||
}
|
||||
migrated[providerId] = nextEntry;
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
// Env vars that indicate a running Claude Code session. If the daemon itself is
|
||||
// launched from inside Claude Code (e.g. by a Paseo agent), these leak into
|
||||
// child processes and cause "cannot be launched inside another session" errors.
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface AgentModeVisuals {
|
||||
colorTier: AgentModeColorTier;
|
||||
}
|
||||
|
||||
export interface AgentProviderModeDefinition extends AgentMode, AgentModeVisuals {}
|
||||
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> & AgentModeVisuals;
|
||||
|
||||
// TODO: `modes` should not be static. Providers (especially ACP) report their
|
||||
// own modes at runtime via session/new. We should fetch modes from the provider
|
||||
@@ -168,24 +168,35 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function getAgentProviderDefinition(provider: string): AgentProviderDefinition {
|
||||
const definition = AGENT_PROVIDER_DEFINITIONS.find((entry) => entry.id === provider);
|
||||
export function getAgentProviderDefinition(
|
||||
provider: string,
|
||||
definitions: AgentProviderDefinition[] = AGENT_PROVIDER_DEFINITIONS,
|
||||
): AgentProviderDefinition {
|
||||
const definition = definitions.find((entry) => entry.id === provider);
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown agent provider: ${provider}`);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id);
|
||||
export const BUILTIN_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id);
|
||||
export const AGENT_PROVIDER_IDS = BUILTIN_PROVIDER_IDS;
|
||||
|
||||
export const AgentProviderSchema = z.string();
|
||||
|
||||
export function isValidAgentProvider(value: string): boolean {
|
||||
return AGENT_PROVIDER_IDS.includes(value);
|
||||
export function isValidAgentProvider(
|
||||
value: string,
|
||||
validIds: Iterable<string> = BUILTIN_PROVIDER_IDS,
|
||||
): boolean {
|
||||
return Array.isArray(validIds) ? validIds.includes(value) : new Set(validIds).has(value);
|
||||
}
|
||||
|
||||
export function getModeVisuals(provider: string, modeId: string): AgentModeVisuals | undefined {
|
||||
const definition = AGENT_PROVIDER_DEFINITIONS.find((entry) => entry.id === provider);
|
||||
export function getModeVisuals(
|
||||
provider: string,
|
||||
modeId: string,
|
||||
definitions: AgentProviderDefinition[],
|
||||
): AgentModeVisuals | undefined {
|
||||
const definition = definitions.find((entry) => entry.id === provider);
|
||||
const mode = definition?.modes.find((m) => m.id === modeId);
|
||||
if (!mode) return undefined;
|
||||
return { icon: mode.icon, colorTier: mode.colorTier };
|
||||
|
||||
584
packages/server/src/server/agent/provider-registry.test.ts
Normal file
584
packages/server/src/server/agent/provider-registry.test.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type { AgentModelDefinition } from "./agent-sdk-types.js";
|
||||
|
||||
const mockState = vi.hoisted(() => {
|
||||
type ConstructorEntry = {
|
||||
runtimeSettings?: unknown;
|
||||
};
|
||||
|
||||
return {
|
||||
constructorArgs: {
|
||||
claude: [] as ConstructorEntry[],
|
||||
codex: [] as ConstructorEntry[],
|
||||
copilot: [] as ConstructorEntry[],
|
||||
opencode: [] as ConstructorEntry[],
|
||||
pi: [] as ConstructorEntry[],
|
||||
genericAcp: [] as Array<{
|
||||
command: string[];
|
||||
env?: Record<string, string>;
|
||||
}>,
|
||||
},
|
||||
runtimeModels: new Map<string, AgentModelDefinition[]>(),
|
||||
reset() {
|
||||
for (const key of Object.keys(this.constructorArgs) as Array<
|
||||
keyof typeof this.constructorArgs
|
||||
>) {
|
||||
this.constructorArgs[key] = [];
|
||||
}
|
||||
this.runtimeModels.clear();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./providers/claude-agent.js", () => ({
|
||||
ClaudeAgentClient: class ClaudeAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
readonly provider = "claude";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(options: { runtimeSettings?: unknown }) {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
mockState.constructorArgs.claude.push({
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
});
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/codex-app-server-agent.js", () => ({
|
||||
CodexAppServerAgentClient: class CodexAppServerAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
readonly provider = "codex";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(_logger: unknown, runtimeSettings?: unknown) {
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
mockState.constructorArgs.codex.push({ runtimeSettings });
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/copilot-acp-agent.js", () => ({
|
||||
CopilotACPAgentClient: class CopilotACPAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
readonly provider = "copilot";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(options: { runtimeSettings?: unknown }) {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
mockState.constructorArgs.copilot.push({
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
});
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/opencode-agent.js", () => ({
|
||||
OpenCodeAgentClient: class OpenCodeAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
readonly provider = "opencode";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(_logger: unknown, runtimeSettings?: unknown) {
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
mockState.constructorArgs.opencode.push({ runtimeSettings });
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
OpenCodeServerManager: {
|
||||
getInstance: vi.fn(() => ({
|
||||
shutdown: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/pi-acp-agent.js", () => ({
|
||||
PiACPAgentClient: class PiACPAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
readonly provider = "pi";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(options: { runtimeSettings?: unknown }) {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
mockState.constructorArgs.pi.push({
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
});
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/generic-acp-agent.js", () => ({
|
||||
GenericACPAgentClient: class GenericACPAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
readonly provider = "acp";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(options: { command: string[]; env?: Record<string, string> }) {
|
||||
this.runtimeSettings = {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: options.command,
|
||||
},
|
||||
env: options.env,
|
||||
};
|
||||
mockState.constructorArgs.genericAcp.push({
|
||||
command: options.command,
|
||||
env: options.env,
|
||||
});
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async listModels(): Promise<AgentModelDefinition[]> {
|
||||
return mockState.runtimeModels.get(this.provider) ?? [];
|
||||
}
|
||||
|
||||
async listModes(): Promise<[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { AGENT_PROVIDER_DEFINITIONS, buildProviderRegistry } from "./provider-registry.js";
|
||||
|
||||
describe("buildProviderRegistry", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
beforeEach(() => {
|
||||
mockState.reset();
|
||||
});
|
||||
|
||||
test("builds registry with no overrides — same as built-in count", () => {
|
||||
const registry = buildProviderRegistry(logger);
|
||||
|
||||
expect(Object.keys(registry)).toHaveLength(AGENT_PROVIDER_DEFINITIONS.length);
|
||||
});
|
||||
|
||||
test("built-in override applies command", () => {
|
||||
buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
command: ["/opt/custom-claude", "--verbose"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockState.constructorArgs.claude[0]).toEqual({
|
||||
runtimeSettings: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["/opt/custom-claude", "--verbose"],
|
||||
},
|
||||
env: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("built-in override applies env", () => {
|
||||
buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
env: {
|
||||
CLAUDE_CONFIG_DIR: "/tmp/claude",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockState.constructorArgs.claude[0]).toEqual({
|
||||
runtimeSettings: {
|
||||
command: undefined,
|
||||
env: {
|
||||
CLAUDE_CONFIG_DIR: "/tmp/claude",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("new provider extending claude appears in registry", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
zai: {
|
||||
extends: "claude",
|
||||
label: "ZAI",
|
||||
description: "Claude with ZAI defaults",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.zai).toBeDefined();
|
||||
expect(registry.zai.label).toBe("ZAI");
|
||||
expect(registry.zai.description).toBe("Claude with ZAI defaults");
|
||||
expect(registry.zai.createClient(logger).provider).toBe("zai");
|
||||
});
|
||||
|
||||
test("new provider extending acp uses GenericACPAgentClient", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
"my-agent": {
|
||||
extends: "acp",
|
||||
label: "My Agent",
|
||||
command: ["my-agent", "--acp"],
|
||||
env: {
|
||||
ACP_TOKEN: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry["my-agent"].createClient(logger).provider).toBe("my-agent");
|
||||
expect(mockState.constructorArgs.genericAcp).toEqual([
|
||||
{
|
||||
command: ["my-agent", "--acp"],
|
||||
env: {
|
||||
ACP_TOKEN: "secret",
|
||||
},
|
||||
},
|
||||
{
|
||||
command: ["my-agent", "--acp"],
|
||||
env: {
|
||||
ACP_TOKEN: "secret",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('extends: "acp" without command throws', () => {
|
||||
expect(() =>
|
||||
buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
"my-agent": {
|
||||
extends: "acp",
|
||||
label: "My Agent",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrowError("ACP provider 'my-agent' requires a command");
|
||||
});
|
||||
|
||||
test("custom provider without label throws", () => {
|
||||
expect(() =>
|
||||
buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
zai: {
|
||||
extends: "claude",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrowError("Custom provider 'zai' requires a label");
|
||||
});
|
||||
|
||||
test("enabled: false excludes provider from registry", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.claude).toBeUndefined();
|
||||
});
|
||||
|
||||
test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => {
|
||||
buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
command: ["/opt/custom-claude"],
|
||||
},
|
||||
zai: {
|
||||
extends: "claude",
|
||||
label: "ZAI",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockState.constructorArgs.claude).toHaveLength(2);
|
||||
expect(
|
||||
mockState.constructorArgs.claude.every(
|
||||
(entry) =>
|
||||
(entry.runtimeSettings as { command?: { argv?: string[] } }).command?.argv?.[0] ===
|
||||
"/opt/custom-claude",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("model merging", () => {
|
||||
test("profile models replace runtime models", async () => {
|
||||
mockState.runtimeModels.set("claude", [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "runtime-pro",
|
||||
label: "Runtime Pro",
|
||||
},
|
||||
]);
|
||||
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
models: [
|
||||
{
|
||||
id: "profile-fast",
|
||||
label: "Profile Fast",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.claude.fetchModels();
|
||||
|
||||
expect(models.map((model) => model.id)).toEqual(["profile-fast"]);
|
||||
});
|
||||
|
||||
test("profile models exclude runtime models entirely", async () => {
|
||||
mockState.runtimeModels.set("claude", [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "shared-model",
|
||||
label: "Runtime Label",
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "runtime-only",
|
||||
label: "Runtime Only",
|
||||
},
|
||||
]);
|
||||
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
models: [
|
||||
{
|
||||
id: "shared-model",
|
||||
label: "Profile Label",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.claude.fetchModels();
|
||||
|
||||
expect(models).toEqual([
|
||||
{
|
||||
provider: "claude",
|
||||
id: "shared-model",
|
||||
label: "Profile Label",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("profile isDefault preserved without runtime models", async () => {
|
||||
mockState.runtimeModels.set("claude", [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "runtime-default",
|
||||
label: "Runtime Default",
|
||||
isDefault: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
models: [
|
||||
{
|
||||
id: "profile-default",
|
||||
label: "Profile Default",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const models = await registry.claude.fetchModels();
|
||||
|
||||
expect(models).toEqual([
|
||||
{
|
||||
provider: "claude",
|
||||
id: "profile-default",
|
||||
label: "Profile Default",
|
||||
isDefault: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("no profile models — runtime models returned as-is", async () => {
|
||||
mockState.runtimeModels.set("claude", [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "runtime-default",
|
||||
label: "Runtime Default",
|
||||
isDefault: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const registry = buildProviderRegistry(logger);
|
||||
const models = await registry.claude.fetchModels();
|
||||
|
||||
expect(models).toEqual([
|
||||
{
|
||||
provider: "claude",
|
||||
id: "runtime-default",
|
||||
label: "Runtime Default",
|
||||
isDefault: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,34 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
AgentRuntimeInfo,
|
||||
AgentSession,
|
||||
AgentStreamEvent,
|
||||
ListModelsOptions,
|
||||
ListModesOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
ProviderProfileModel,
|
||||
ProviderRuntimeSettings,
|
||||
} from "./provider-launch-config.js";
|
||||
import { ClaudeAgentClient } from "./providers/claude-agent.js";
|
||||
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
|
||||
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
|
||||
import { PiACPAgentClient } from "./providers/pi-acp-agent.js";
|
||||
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
BUILTIN_PROVIDER_IDS,
|
||||
getAgentProviderDefinition,
|
||||
type AgentProviderDefinition,
|
||||
} from "./provider-manifest.js";
|
||||
@@ -31,30 +43,42 @@ export interface ProviderDefinition extends AgentProviderDefinition {
|
||||
fetchModes: (options?: ListModesOptions) => Promise<AgentMode[]>;
|
||||
}
|
||||
|
||||
type BuildProviderRegistryOptions = {
|
||||
export type BuildProviderRegistryOptions = {
|
||||
runtimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||
providerOverrides?: Record<string, ProviderOverride>;
|
||||
};
|
||||
|
||||
type ProviderClientFactory = (
|
||||
logger: Logger,
|
||||
runtimeSettings?: AgentProviderRuntimeSettingsMap,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
) => AgentClient;
|
||||
|
||||
type ResolvedProvider = {
|
||||
definition: AgentProviderDefinition;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
profileModels: ProviderProfileModel[];
|
||||
enabled: boolean;
|
||||
createBaseClient: (logger: Logger) => AgentClient;
|
||||
};
|
||||
|
||||
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
claude: (logger, runtimeSettings) =>
|
||||
new ClaudeAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.claude,
|
||||
runtimeSettings,
|
||||
}),
|
||||
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
|
||||
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings),
|
||||
copilot: (logger, runtimeSettings) =>
|
||||
new CopilotACPAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.copilot,
|
||||
runtimeSettings,
|
||||
}),
|
||||
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
|
||||
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
|
||||
pi: (logger, runtimeSettings) =>
|
||||
new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }),
|
||||
new PiACPAgentClient({
|
||||
logger,
|
||||
runtimeSettings,
|
||||
}),
|
||||
};
|
||||
|
||||
function getProviderClientFactory(provider: string): ProviderClientFactory {
|
||||
@@ -65,31 +89,366 @@ function getProviderClientFactory(provider: string): ProviderClientFactory {
|
||||
return factory;
|
||||
}
|
||||
|
||||
function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings | undefined {
|
||||
if (!override?.command && !override?.env) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
command: override.command
|
||||
? {
|
||||
mode: "replace",
|
||||
argv: override.command,
|
||||
}
|
||||
: undefined,
|
||||
env: override.env,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeRuntimeSettings(
|
||||
base: ProviderRuntimeSettings | undefined,
|
||||
override: ProviderRuntimeSettings | undefined,
|
||||
): ProviderRuntimeSettings | undefined {
|
||||
if (!base && !override) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
command: override?.command ?? base?.command,
|
||||
env:
|
||||
base?.env || override?.env
|
||||
? {
|
||||
...(base?.env ?? {}),
|
||||
...(override?.env ?? {}),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function applyOverrideToDefinition(
|
||||
definition: AgentProviderDefinition,
|
||||
override?: ProviderOverride,
|
||||
): AgentProviderDefinition {
|
||||
if (!override) {
|
||||
return definition;
|
||||
}
|
||||
|
||||
return {
|
||||
...definition,
|
||||
label: override.label ?? definition.label,
|
||||
description: override.description ?? definition.description,
|
||||
};
|
||||
}
|
||||
|
||||
function createDerivedDefinition(
|
||||
providerId: string,
|
||||
baseDefinition: AgentProviderDefinition,
|
||||
override: ProviderOverride,
|
||||
): AgentProviderDefinition {
|
||||
if (!override.label) {
|
||||
throw new Error(`Custom provider '${providerId}' requires a label`);
|
||||
}
|
||||
|
||||
return {
|
||||
...baseDefinition,
|
||||
id: providerId,
|
||||
label: override.label,
|
||||
description: override.description ?? baseDefinition.description,
|
||||
};
|
||||
}
|
||||
|
||||
function mapPersistenceHandle(
|
||||
provider: AgentProvider,
|
||||
handle: AgentPersistenceHandle | null,
|
||||
): AgentPersistenceHandle | null {
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...handle,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRuntimeInfo(provider: AgentProvider, runtimeInfo: AgentRuntimeInfo): AgentRuntimeInfo {
|
||||
return {
|
||||
...runtimeInfo,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
function mapStreamEvent(provider: AgentProvider, event: AgentStreamEvent): AgentStreamEvent {
|
||||
return {
|
||||
...event,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
function mapPersistedAgentDescriptor(
|
||||
provider: AgentProvider,
|
||||
descriptor: PersistedAgentDescriptor,
|
||||
): PersistedAgentDescriptor {
|
||||
return {
|
||||
...descriptor,
|
||||
provider,
|
||||
persistence: {
|
||||
...descriptor.persistence,
|
||||
provider,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapModel(provider: AgentProvider, model: AgentModelDefinition): AgentModelDefinition {
|
||||
return {
|
||||
...model,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeModels(
|
||||
provider: AgentProvider,
|
||||
profileModels: ProviderProfileModel[],
|
||||
runtimeModels: AgentModelDefinition[],
|
||||
): AgentModelDefinition[] {
|
||||
if (profileModels.length === 0) {
|
||||
return runtimeModels.map((model) => mapModel(provider, model));
|
||||
}
|
||||
|
||||
return profileModels.map((model) => ({
|
||||
...model,
|
||||
provider,
|
||||
}));
|
||||
}
|
||||
|
||||
function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession {
|
||||
return {
|
||||
provider,
|
||||
id: inner.id,
|
||||
capabilities: inner.capabilities,
|
||||
get features() {
|
||||
return inner.features;
|
||||
},
|
||||
run: (prompt, options) => inner.run(prompt, options),
|
||||
startTurn: (prompt, options) => inner.startTurn(prompt, options),
|
||||
subscribe: (callback) => inner.subscribe((event) => callback(mapStreamEvent(provider, event))),
|
||||
async *streamHistory() {
|
||||
for await (const event of inner.streamHistory()) {
|
||||
yield mapStreamEvent(provider, event);
|
||||
}
|
||||
},
|
||||
getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()),
|
||||
getAvailableModes: () => inner.getAvailableModes(),
|
||||
getCurrentMode: () => inner.getCurrentMode(),
|
||||
setMode: (modeId) => inner.setMode(modeId),
|
||||
getPendingPermissions: () => inner.getPendingPermissions(),
|
||||
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
|
||||
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
|
||||
interrupt: () => inner.interrupt(),
|
||||
close: () => inner.close(),
|
||||
listCommands: inner.listCommands?.bind(inner),
|
||||
setModel: inner.setModel?.bind(inner),
|
||||
setThinkingOption: inner.setThinkingOption?.bind(inner),
|
||||
setFeature: inner.setFeature?.bind(inner),
|
||||
};
|
||||
}
|
||||
|
||||
function wrapClientProvider(provider: AgentProvider, inner: AgentClient): AgentClient {
|
||||
const listPersistedAgents = inner.listPersistedAgents?.bind(inner);
|
||||
|
||||
return {
|
||||
provider,
|
||||
capabilities: inner.capabilities,
|
||||
createSession: async (config, launchContext) =>
|
||||
wrapSessionProvider(
|
||||
provider,
|
||||
await inner.createSession(
|
||||
{
|
||||
...config,
|
||||
provider: inner.provider,
|
||||
},
|
||||
launchContext,
|
||||
),
|
||||
),
|
||||
resumeSession: async (handle, overrides, launchContext) =>
|
||||
wrapSessionProvider(
|
||||
provider,
|
||||
await inner.resumeSession(
|
||||
{
|
||||
...handle,
|
||||
provider: inner.provider,
|
||||
},
|
||||
overrides
|
||||
? {
|
||||
...overrides,
|
||||
provider: inner.provider,
|
||||
}
|
||||
: undefined,
|
||||
launchContext,
|
||||
),
|
||||
),
|
||||
listModels: async (options) =>
|
||||
(await inner.listModels(options)).map((model) => mapModel(provider, model)),
|
||||
listModes: inner.listModes?.bind(inner),
|
||||
listPersistedAgents: listPersistedAgents
|
||||
? async (options?: ListPersistedAgentsOptions) =>
|
||||
(await listPersistedAgents(options)).map((descriptor) =>
|
||||
mapPersistedAgentDescriptor(provider, descriptor),
|
||||
)
|
||||
: undefined,
|
||||
isAvailable: () => inner.isAvailable(),
|
||||
getDiagnostic: inner.getDiagnostic?.bind(inner),
|
||||
};
|
||||
}
|
||||
|
||||
function createRegistryEntry(
|
||||
logger: Logger,
|
||||
provider: AgentProvider,
|
||||
resolved: ResolvedProvider,
|
||||
): ProviderDefinition {
|
||||
const modelClient = resolved.createBaseClient(logger);
|
||||
|
||||
return {
|
||||
...resolved.definition,
|
||||
createClient: (providerLogger: Logger) => {
|
||||
const inner = resolved.createBaseClient(providerLogger);
|
||||
return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
|
||||
},
|
||||
fetchModels: async (options?: ListModelsOptions) =>
|
||||
mergeModels(provider, resolved.profileModels, await modelClient.listModels(options)),
|
||||
fetchModes: async (options?: ListModesOptions) => {
|
||||
const modes = modelClient.listModes
|
||||
? await modelClient.listModes(options)
|
||||
: resolved.definition.modes;
|
||||
return modes.map((mode) => {
|
||||
if (mode.icon && mode.colorTier) return mode;
|
||||
const definitionMode = resolved.definition.modes.find((d) => d.id === mode.id);
|
||||
if (!definitionMode) return mode;
|
||||
return {
|
||||
...mode,
|
||||
icon: mode.icon ?? definitionMode.icon,
|
||||
colorTier: mode.colorTier ?? definitionMode.colorTier,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildResolvedBuiltinProviders(
|
||||
providerOverrides: Record<string, ProviderOverride>,
|
||||
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
|
||||
): Map<string, ResolvedProvider> {
|
||||
const resolvedProviders = new Map<string, ResolvedProvider>();
|
||||
|
||||
for (const definition of AGENT_PROVIDER_DEFINITIONS) {
|
||||
const override = providerOverrides[definition.id];
|
||||
const factory = getProviderClientFactory(definition.id);
|
||||
const mergedRuntimeSettings = mergeRuntimeSettings(
|
||||
runtimeSettings?.[definition.id],
|
||||
toRuntimeSettings(override),
|
||||
);
|
||||
|
||||
resolvedProviders.set(definition.id, {
|
||||
definition: applyOverrideToDefinition(definition, override),
|
||||
runtimeSettings: mergedRuntimeSettings,
|
||||
profileModels: override?.models ?? [],
|
||||
enabled: override?.enabled !== false,
|
||||
createBaseClient: (logger) => factory(logger, mergedRuntimeSettings),
|
||||
});
|
||||
}
|
||||
|
||||
return resolvedProviders;
|
||||
}
|
||||
|
||||
function addDerivedProviders(
|
||||
resolvedProviders: Map<string, ResolvedProvider>,
|
||||
providerOverrides: Record<string, ProviderOverride>,
|
||||
): void {
|
||||
for (const [providerId, override] of Object.entries(providerOverrides)) {
|
||||
if (BUILTIN_PROVIDER_IDS.includes(providerId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!override.extends) {
|
||||
throw new Error(`Custom provider '${providerId}' requires an extends value`);
|
||||
}
|
||||
|
||||
if (override.extends === "acp") {
|
||||
if (!override.command) {
|
||||
throw new Error(`ACP provider '${providerId}' requires a command`);
|
||||
}
|
||||
|
||||
resolvedProviders.set(providerId, {
|
||||
definition: createDerivedDefinition(
|
||||
providerId,
|
||||
{
|
||||
id: providerId,
|
||||
label: override.label ?? providerId,
|
||||
description: override.description ?? "Custom ACP provider",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
override,
|
||||
),
|
||||
runtimeSettings: toRuntimeSettings(override),
|
||||
profileModels: override.models ?? [],
|
||||
enabled: override.enabled !== false,
|
||||
createBaseClient: (logger) =>
|
||||
new GenericACPAgentClient({
|
||||
logger,
|
||||
command: override.command!,
|
||||
env: override.env,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseProvider = resolvedProviders.get(override.extends);
|
||||
if (!baseProvider) {
|
||||
throw new Error(
|
||||
`Custom provider '${providerId}' extends unknown provider '${override.extends}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const mergedRuntimeSettings = mergeRuntimeSettings(
|
||||
baseProvider.runtimeSettings,
|
||||
toRuntimeSettings(override),
|
||||
);
|
||||
const baseDefinition = baseProvider.definition;
|
||||
const baseFactory = getProviderClientFactory(override.extends);
|
||||
|
||||
resolvedProviders.set(providerId, {
|
||||
definition: createDerivedDefinition(providerId, baseDefinition, override),
|
||||
runtimeSettings: mergedRuntimeSettings,
|
||||
profileModels: override.models ?? [],
|
||||
enabled: override.enabled !== false,
|
||||
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function buildProviderRegistry(
|
||||
logger: Logger,
|
||||
options?: BuildProviderRegistryOptions,
|
||||
): Record<AgentProvider, ProviderDefinition> {
|
||||
const runtimeSettings = options?.runtimeSettings;
|
||||
const providerOverrides = options?.providerOverrides ?? {};
|
||||
const resolvedProviders = buildResolvedBuiltinProviders(providerOverrides, runtimeSettings);
|
||||
addDerivedProviders(resolvedProviders, providerOverrides);
|
||||
|
||||
return Object.fromEntries(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => {
|
||||
const createClient = getProviderClientFactory(definition.id);
|
||||
const modelClient = createClient(logger, runtimeSettings);
|
||||
return [
|
||||
definition.id,
|
||||
{
|
||||
...definition,
|
||||
createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings),
|
||||
fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions),
|
||||
fetchModes: (listOptions?: ListModesOptions) =>
|
||||
modelClient.listModes
|
||||
? modelClient.listModes(listOptions)
|
||||
: Promise.resolve(definition.modes),
|
||||
} satisfies ProviderDefinition,
|
||||
];
|
||||
}),
|
||||
[...resolvedProviders.entries()]
|
||||
.filter(([, resolved]) => resolved.enabled)
|
||||
.map(([provider, resolved]) => [provider, createRegistryEntry(logger, provider, resolved)]),
|
||||
) as Record<AgentProvider, ProviderDefinition>;
|
||||
}
|
||||
|
||||
export function getProviderIds(
|
||||
registry: Record<AgentProvider, ProviderDefinition>,
|
||||
): AgentProvider[] {
|
||||
return Object.keys(registry);
|
||||
}
|
||||
|
||||
// Deprecated: Use buildProviderRegistry instead
|
||||
export const PROVIDER_REGISTRY: Record<AgentProvider, ProviderDefinition> = null as any;
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ type Deferred<T> = {
|
||||
|
||||
type MockProviderOptions = {
|
||||
provider: AgentProvider;
|
||||
label?: string;
|
||||
description?: string;
|
||||
defaultModeId?: string | null;
|
||||
isAvailable?: () => Promise<boolean>;
|
||||
fetchModels?: (cwd?: string) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes?: (cwd?: string) => Promise<AgentMode[]>;
|
||||
@@ -58,10 +61,21 @@ describe("ProviderSnapshotManager", () => {
|
||||
|
||||
const snapshot = manager.getSnapshot("/tmp/project");
|
||||
|
||||
expect(snapshot).toEqual([
|
||||
{ provider: "claude", status: "loading" },
|
||||
{ provider: "codex", status: "loading" },
|
||||
]);
|
||||
expect(snapshot.map((entry) => entry.provider)).toEqual(["codex", "claude"]);
|
||||
expect(getProviderEntry(snapshot, "claude")).toMatchObject({
|
||||
provider: "claude",
|
||||
status: "loading",
|
||||
label: "claude",
|
||||
description: "claude test provider",
|
||||
defaultModeId: null,
|
||||
});
|
||||
expect(getProviderEntry(snapshot, "codex")).toMatchObject({
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
defaultModeId: null,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1);
|
||||
@@ -101,12 +115,18 @@ describe("ProviderSnapshotManager", () => {
|
||||
status: "ready",
|
||||
models: [createModel("codex", "gpt-5.2")],
|
||||
modes: [createMode("auto")],
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
defaultModeId: null,
|
||||
});
|
||||
expect(getProviderEntry(snapshot, "claude")).toMatchObject({
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
models: [createModel("claude", "sonnet")],
|
||||
modes: [createMode("default")],
|
||||
label: "claude",
|
||||
description: "claude test provider",
|
||||
defaultModeId: null,
|
||||
});
|
||||
expect(getProviderEntry(snapshot, "codex")?.fetchedAt).toEqual(expect.any(String));
|
||||
|
||||
@@ -126,7 +146,13 @@ describe("ProviderSnapshotManager", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getSnapshot("/tmp/project")).toEqual([
|
||||
{ provider: "codex", status: "unavailable" },
|
||||
{
|
||||
provider: "codex",
|
||||
status: "unavailable",
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
defaultModeId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -155,6 +181,9 @@ describe("ProviderSnapshotManager", () => {
|
||||
provider: "codex",
|
||||
status: "error",
|
||||
error: "model lookup failed",
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
defaultModeId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -236,7 +265,15 @@ describe("ProviderSnapshotManager", () => {
|
||||
});
|
||||
|
||||
manager.refresh("/tmp/project");
|
||||
expect(manager.getSnapshot("/tmp/project")).toEqual([{ provider: "codex", status: "loading" }]);
|
||||
expect(manager.getSnapshot("/tmp/project")).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
defaultModeId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe(
|
||||
@@ -265,7 +302,15 @@ describe("ProviderSnapshotManager", () => {
|
||||
|
||||
manager.refresh("/tmp/project");
|
||||
|
||||
expect(manager.getSnapshot("/tmp/project")).toEqual([{ provider: "codex", status: "loading" }]);
|
||||
expect(manager.getSnapshot("/tmp/project")).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
defaultModeId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
manager.refresh("/tmp/project");
|
||||
manager.refresh("/tmp/project");
|
||||
@@ -361,6 +406,89 @@ describe("ProviderSnapshotManager", () => {
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("snapshot includes user-defined providers from the registry", async () => {
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({ provider: "claude" }),
|
||||
createMockProvider({
|
||||
provider: "zai",
|
||||
label: "ZAI",
|
||||
description: "Custom Claude profile",
|
||||
defaultModeId: "default",
|
||||
fetchModes: async () => [createMode("default")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot("/tmp/project");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")?.status).toBe("ready");
|
||||
});
|
||||
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")).toMatchObject({
|
||||
provider: "zai",
|
||||
status: "ready",
|
||||
label: "ZAI",
|
||||
description: "Custom Claude profile",
|
||||
defaultModeId: "default",
|
||||
});
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("enabled false providers are omitted when absent from the registry", () => {
|
||||
const { registry } = createRegistry([createMockProvider({ provider: "claude" })]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
const snapshot = manager.getSnapshot("/tmp/project");
|
||||
|
||||
expect(snapshot.map((entry) => entry.provider)).toEqual(["claude"]);
|
||||
expect(getProviderEntry(snapshot, "zai")).toBeUndefined();
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("snapshot entries include label and description from the registry", async () => {
|
||||
const models = deferred<AgentModelDefinition[]>();
|
||||
const modes = deferred<AgentMode[]>();
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "zai",
|
||||
label: "ZAI",
|
||||
description: "Custom Claude profile",
|
||||
defaultModeId: "plan",
|
||||
fetchModels: async () => models.promise,
|
||||
fetchModes: async () => modes.promise,
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
expect(manager.getSnapshot("/tmp/project")).toEqual([
|
||||
{
|
||||
provider: "zai",
|
||||
status: "loading",
|
||||
label: "ZAI",
|
||||
description: "Custom Claude profile",
|
||||
defaultModeId: "plan",
|
||||
},
|
||||
]);
|
||||
|
||||
models.resolve([createModel("zai", "zai-fast")]);
|
||||
modes.resolve([createMode("plan")]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")).toMatchObject({
|
||||
provider: "zai",
|
||||
status: "ready",
|
||||
label: "ZAI",
|
||||
description: "Custom Claude profile",
|
||||
defaultModeId: "plan",
|
||||
});
|
||||
});
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
@@ -403,9 +531,9 @@ function createMockProvider(options: MockProviderOptions): MockProviderHandle {
|
||||
|
||||
const definition: ProviderDefinition = {
|
||||
id: options.provider,
|
||||
label: options.provider,
|
||||
description: `${options.provider} test provider`,
|
||||
defaultModeId: null,
|
||||
label: options.label ?? options.provider,
|
||||
description: options.description ?? `${options.provider} test provider`,
|
||||
defaultModeId: options.defaultModeId ?? null,
|
||||
modes: [],
|
||||
createClient: () =>
|
||||
({
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import { AGENT_PROVIDER_IDS } from "./provider-manifest.js";
|
||||
|
||||
const DEFAULT_CWD_KEY = "__default__";
|
||||
|
||||
@@ -63,9 +62,13 @@ export class ProviderSnapshotManager {
|
||||
private createLoadingEntries(): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||
const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
|
||||
for (const provider of this.getProviderIds()) {
|
||||
const definition = this.providerRegistry[provider];
|
||||
entries.set(provider, {
|
||||
provider,
|
||||
status: "loading",
|
||||
label: definition?.label,
|
||||
description: definition?.description,
|
||||
defaultModeId: definition?.defaultModeId ?? null,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
@@ -107,6 +110,9 @@ export class ProviderSnapshotManager {
|
||||
snapshot.set(provider, {
|
||||
provider,
|
||||
status: "loading",
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -116,6 +122,9 @@ export class ProviderSnapshotManager {
|
||||
snapshot.set(provider, {
|
||||
provider,
|
||||
status: "unavailable",
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
});
|
||||
this.emitChange(cwdKey);
|
||||
return;
|
||||
@@ -132,6 +141,9 @@ export class ProviderSnapshotManager {
|
||||
models,
|
||||
modes,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
});
|
||||
this.emitChange(cwdKey);
|
||||
} catch (error) {
|
||||
@@ -139,6 +151,9 @@ export class ProviderSnapshotManager {
|
||||
provider,
|
||||
status: "error",
|
||||
error: toErrorMessage(error),
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
});
|
||||
this.logger.warn(
|
||||
{ err: error, provider, cwd: cwdKey },
|
||||
@@ -180,7 +195,7 @@ export class ProviderSnapshotManager {
|
||||
}
|
||||
|
||||
private getProviderIds(): AgentProvider[] {
|
||||
return AGENT_PROVIDER_IDS.filter((provider) => this.providerRegistry[provider]);
|
||||
return Object.keys(this.providerRegistry) as AgentProvider[];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { ACPAgentClient } from "./acp-agent.js";
|
||||
|
||||
type GenericACPAgentClientOptions = {
|
||||
logger: Logger;
|
||||
command: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export class GenericACPAgentClient extends ACPAgentClient {
|
||||
private readonly command: [string, ...string[]];
|
||||
|
||||
constructor(options: GenericACPAgentClientOptions) {
|
||||
if (options.command.length === 0) {
|
||||
throw new Error("Generic ACP provider requires a non-empty command");
|
||||
}
|
||||
|
||||
super({
|
||||
provider: "acp",
|
||||
logger: options.logger,
|
||||
runtimeSettings: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: options.command,
|
||||
},
|
||||
env: options.env,
|
||||
},
|
||||
defaultCommand: options.command as [string, ...string[]],
|
||||
});
|
||||
|
||||
this.command = options.command as [string, ...string[]];
|
||||
}
|
||||
|
||||
protected override async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> {
|
||||
return {
|
||||
command: this.command[0],
|
||||
args: this.command.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
override async isAvailable(): Promise<boolean> {
|
||||
return isCommandAvailable(this.command[0]);
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,10 @@ import { startRelayTransport, type RelayTransportController } from "./relay-tran
|
||||
import { getOrCreateServerId } from "./server-id.js";
|
||||
import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
|
||||
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
||||
@@ -177,6 +180,7 @@ export type PaseoDaemonConfig = {
|
||||
dictationFinalTimeoutMs?: number;
|
||||
downloadTokenTtlMs?: number;
|
||||
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
||||
providerOverrides?: Record<string, ProviderOverride>;
|
||||
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
|
||||
};
|
||||
|
||||
@@ -351,6 +355,7 @@ export async function createPaseoDaemon(
|
||||
clients: {
|
||||
...createAllClients(logger, {
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
providerOverrides: config.providerOverrides,
|
||||
}),
|
||||
...config.agentClients,
|
||||
},
|
||||
@@ -359,6 +364,7 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
const providerRegistry = buildProviderRegistry(logger, {
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
providerOverrides: config.providerOverrides,
|
||||
});
|
||||
|
||||
const terminalManager = createTerminalManager();
|
||||
@@ -598,6 +604,7 @@ export async function createPaseoDaemon(
|
||||
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
||||
},
|
||||
config.agentProviderSettings,
|
||||
config.providerOverrides,
|
||||
daemonVersion,
|
||||
(intent) => {
|
||||
try {
|
||||
@@ -677,6 +684,7 @@ export async function createPaseoDaemon(
|
||||
await agentStorage.flush().catch(() => undefined);
|
||||
await shutdownProviders(logger, {
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
providerOverrides: config.providerOverrides,
|
||||
});
|
||||
terminalManager.killAll();
|
||||
speechService.stop();
|
||||
|
||||
@@ -4,6 +4,11 @@ import { z } from "zod";
|
||||
import type { PaseoDaemonConfig } from "./bootstrap.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
||||
import { AgentProviderSchema } from "./agent/provider-manifest.js";
|
||||
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
|
||||
import {
|
||||
@@ -52,6 +57,55 @@ function parseOptionalVoiceLlmProvider(value: unknown): AgentProvider | null {
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
function extractProviderOverrides(
|
||||
providers: Record<string, unknown> | undefined,
|
||||
): Record<string, ProviderOverride> | undefined {
|
||||
if (!providers) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const providerOverrides = Object.entries(providers).flatMap(([providerId, provider]) => {
|
||||
const parsed = ProviderOverrideSchema.safeParse(provider);
|
||||
return parsed.success ? [[providerId, parsed.data] as const] : [];
|
||||
});
|
||||
|
||||
return providerOverrides.length > 0 ? Object.fromEntries(providerOverrides) : undefined;
|
||||
}
|
||||
|
||||
function extractAgentProviderSettings(
|
||||
providerOverrides: Record<string, ProviderOverride> | undefined,
|
||||
): AgentProviderRuntimeSettingsMap | undefined {
|
||||
if (!providerOverrides) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const runtimeSettings = Object.entries(providerOverrides).flatMap(([providerId, provider]) => {
|
||||
const parsedProviderId = AgentProviderSchema.safeParse(providerId);
|
||||
if (!parsedProviderId.success || (!provider.command && !provider.env)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
parsedProviderId.data,
|
||||
{
|
||||
command: provider.command
|
||||
? {
|
||||
mode: "replace" as const,
|
||||
argv: provider.command,
|
||||
}
|
||||
: undefined,
|
||||
env: provider.env,
|
||||
},
|
||||
] as const,
|
||||
];
|
||||
});
|
||||
|
||||
return runtimeSettings.length > 0
|
||||
? (Object.fromEntries(runtimeSettings) as AgentProviderRuntimeSettingsMap)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function loadConfig(
|
||||
paseoHome: string,
|
||||
options?: {
|
||||
@@ -117,6 +171,9 @@ export function loadConfig(
|
||||
const voiceLlmProviderExplicit =
|
||||
envVoiceLlmProvider !== null || persistedVoiceLlmProvider !== null;
|
||||
const voiceLlmModel = persisted.features?.voiceMode?.llm?.model ?? null;
|
||||
const providerOverrides = extractProviderOverrides(
|
||||
persisted.agents?.providers as Record<string, unknown> | undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
listen,
|
||||
@@ -140,6 +197,7 @@ export function loadConfig(
|
||||
voiceLlmProvider,
|
||||
voiceLlmProviderExplicit,
|
||||
voiceLlmModel,
|
||||
agentProviderSettings: persisted.agents?.providers,
|
||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||
providerOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ export {
|
||||
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
|
||||
|
||||
// Provider binary resolution
|
||||
export { applyProviderEnv } from "./agent/provider-launch-config.js";
|
||||
export {
|
||||
applyProviderEnv,
|
||||
type ProviderOverride,
|
||||
type ProviderProfileModel,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
export {
|
||||
findExecutable,
|
||||
executableExists,
|
||||
@@ -42,6 +46,7 @@ export { execCommand, spawnProcess } from "../utils/spawn.js";
|
||||
// Provider manifest (source of truth for provider definitions)
|
||||
export {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
BUILTIN_PROVIDER_IDS,
|
||||
type AgentProviderDefinition,
|
||||
} from "./agent/provider-manifest.js";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
|
||||
import { PersistedConfigSchema } from "./persisted-config.js";
|
||||
|
||||
describe("PersistedConfigSchema agent provider runtime settings", () => {
|
||||
test("accepts provider command append args and env", () => {
|
||||
test("legacy append entries are skipped during migration", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
providers: {
|
||||
@@ -20,8 +20,7 @@ describe("PersistedConfigSchema agent provider runtime settings", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.providers?.claude?.command?.mode).toBe("append");
|
||||
expect(parsed.agents?.providers?.claude?.env?.FOO).toBe("bar");
|
||||
expect(parsed.agents?.providers).toEqual({});
|
||||
});
|
||||
|
||||
test("accepts provider command replace argv", () => {
|
||||
@@ -38,7 +37,12 @@ describe("PersistedConfigSchema agent provider runtime settings", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.providers?.codex?.command?.mode).toBe("replace");
|
||||
expect(parsed.agents?.providers?.codex?.command).toEqual([
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"my-codex-wrapper",
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects replace command without argv", () => {
|
||||
@@ -58,6 +62,286 @@ describe("PersistedConfigSchema agent provider runtime settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider overrides (new format)", () => {
|
||||
test("override built-in provider with command and env", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
providers: {
|
||||
claude: {
|
||||
command: ["/opt/custom/claude"],
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: "sk-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.providers?.claude).toEqual({
|
||||
command: ["/opt/custom/claude"],
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: "sk-test",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("new provider extending claude with label", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
providers: {
|
||||
zai: {
|
||||
extends: "claude",
|
||||
label: "ZAI",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.providers?.zai).toEqual({
|
||||
extends: "claude",
|
||||
label: "ZAI",
|
||||
});
|
||||
});
|
||||
|
||||
test("new provider extending acp with command", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
providers: {
|
||||
"my-agent": {
|
||||
extends: "acp",
|
||||
label: "My Agent",
|
||||
command: ["my-agent", "--acp"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.providers?.["my-agent"]).toEqual({
|
||||
extends: "acp",
|
||||
label: "My Agent",
|
||||
command: ["my-agent", "--acp"],
|
||||
});
|
||||
});
|
||||
|
||||
test("enabled: false accepted", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
providers: {
|
||||
claude: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.providers?.claude?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("models array accepted", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
providers: {
|
||||
zai: {
|
||||
extends: "claude",
|
||||
label: "ZAI",
|
||||
models: [
|
||||
{
|
||||
id: "zai-fast",
|
||||
label: "ZAI Fast",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.providers?.zai?.models).toEqual([
|
||||
{
|
||||
id: "zai-fast",
|
||||
label: "ZAI Fast",
|
||||
isDefault: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("order field accepted", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
providers: {
|
||||
claude: {
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.providers?.claude?.order).toBe(1);
|
||||
});
|
||||
|
||||
test("new provider without extends → error", () => {
|
||||
const result = PersistedConfigSchema.safeParse({
|
||||
agents: {
|
||||
providers: {
|
||||
zai: {
|
||||
label: "ZAI",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("new provider without label → error", () => {
|
||||
const result = PersistedConfigSchema.safeParse({
|
||||
agents: {
|
||||
providers: {
|
||||
zai: {
|
||||
extends: "claude",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("extends: acp without command → error", () => {
|
||||
const result = PersistedConfigSchema.safeParse({
|
||||
agents: {
|
||||
providers: {
|
||||
"my-agent": {
|
||||
extends: "acp",
|
||||
label: "My Agent",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("extends unknown provider → error", () => {
|
||||
const result = PersistedConfigSchema.safeParse({
|
||||
agents: {
|
||||
providers: {
|
||||
zai: {
|
||||
extends: "unknown",
|
||||
label: "ZAI",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("invalid provider ID format → error", () => {
|
||||
const result = PersistedConfigSchema.safeParse({
|
||||
agents: {
|
||||
providers: {
|
||||
ZAI: {
|
||||
extends: "claude",
|
||||
label: "ZAI",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("old format with mode: replace auto-migrates", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
providers: {
|
||||
claude: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["docker", "run", "--rm", "claude"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.providers?.claude).toEqual({
|
||||
command: ["docker", "run", "--rm", "claude"],
|
||||
});
|
||||
});
|
||||
|
||||
test("old format with mode: default auto-migrates", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
providers: {
|
||||
claude: {
|
||||
command: {
|
||||
mode: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.providers?.claude).toEqual({});
|
||||
});
|
||||
|
||||
test("old format env preserved during migration", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
providers: {
|
||||
claude: {
|
||||
command: {
|
||||
mode: "default",
|
||||
},
|
||||
env: {
|
||||
FOO: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.providers?.claude).toEqual({
|
||||
env: {
|
||||
FOO: "bar",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("mixed old and new format entries both work", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
providers: {
|
||||
claude: {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["custom-claude"],
|
||||
},
|
||||
},
|
||||
zai: {
|
||||
extends: "claude",
|
||||
label: "ZAI",
|
||||
command: ["zai"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.providers).toEqual({
|
||||
claude: {
|
||||
command: ["custom-claude"],
|
||||
},
|
||||
zai: {
|
||||
extends: "claude",
|
||||
label: "ZAI",
|
||||
command: ["zai"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema logging config", () => {
|
||||
test("accepts destination-specific logging config", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
|
||||
@@ -2,7 +2,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js";
|
||||
import {
|
||||
AgentProviderRuntimeSettingsMapSchema,
|
||||
migrateProviderSettings,
|
||||
ProviderOverrideSchema,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
|
||||
const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
|
||||
const LogFormatSchema = z.enum(["pretty", "json"]);
|
||||
@@ -113,6 +118,107 @@ const FeatureVoiceModeSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const;
|
||||
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
const ProviderOverridesSchema = z
|
||||
.record(z.string(), ProviderOverrideSchema)
|
||||
.superRefine((providers, ctx) => {
|
||||
const builtinProviderIdSet = new Set<string>(BUILTIN_PROVIDER_IDS);
|
||||
const validExtendsValues = new Set<string>([...BUILTIN_PROVIDER_IDS, "acp"]);
|
||||
|
||||
for (const [providerId, provider] of Object.entries(providers)) {
|
||||
if (!PROVIDER_ID_PATTERN.test(providerId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [providerId],
|
||||
message: `Provider ID "${providerId}" must match ${PROVIDER_ID_PATTERN}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const isBuiltinProvider = builtinProviderIdSet.has(providerId);
|
||||
if (!isBuiltinProvider && !provider.extends) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [providerId, "extends"],
|
||||
message: `Custom provider "${providerId}" must declare extends.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isBuiltinProvider && !provider.label) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [providerId, "label"],
|
||||
message: `Custom provider "${providerId}" must declare label.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (provider.extends && !validExtendsValues.has(provider.extends)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [providerId, "extends"],
|
||||
message: `Provider "${providerId}" extends unknown provider "${provider.extends}".`,
|
||||
});
|
||||
}
|
||||
|
||||
if (provider.extends === "acp" && !provider.command) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [providerId, "command"],
|
||||
message: `Provider "${providerId}" extending "acp" must declare command.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function isLegacyProviderEntry(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const command = (value as Record<string, unknown>).command;
|
||||
if (!command || typeof command !== "object" || Array.isArray(command)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof (command as Record<string, unknown>).mode === "string";
|
||||
}
|
||||
|
||||
function normalizeAgentProviders(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const rawProviders = value as Record<string, unknown>;
|
||||
const hasLegacyEntries = Object.values(rawProviders).some((entry) =>
|
||||
isLegacyProviderEntry(entry),
|
||||
);
|
||||
if (!hasLegacyEntries) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const legacyEntries: Record<string, unknown> = {};
|
||||
const normalizedEntries: Record<string, unknown> = {};
|
||||
|
||||
for (const [providerId, providerValue] of Object.entries(rawProviders)) {
|
||||
if (isLegacyProviderEntry(providerValue)) {
|
||||
legacyEntries[providerId] = providerValue;
|
||||
continue;
|
||||
}
|
||||
normalizedEntries[providerId] = providerValue;
|
||||
}
|
||||
|
||||
const parsedLegacyEntries = AgentProviderRuntimeSettingsMapSchema.safeParse(legacyEntries);
|
||||
if (!parsedLegacyEntries.success) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return {
|
||||
...normalizedEntries,
|
||||
...migrateProviderSettings(parsedLegacyEntries.data, [...BUILTIN_PROVIDER_IDS]),
|
||||
};
|
||||
}
|
||||
|
||||
export const PersistedConfigSchema = z
|
||||
.object({
|
||||
// v1 schema marker
|
||||
@@ -158,7 +264,7 @@ export const PersistedConfigSchema = z
|
||||
providers: ProvidersSchema.optional(),
|
||||
agents: z
|
||||
.object({
|
||||
providers: AgentProviderRuntimeSettingsMapSchema.optional(),
|
||||
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
@@ -174,10 +280,16 @@ export const PersistedConfigSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type PersistedConfig = z.infer<typeof PersistedConfigSchema>;
|
||||
type PersistedConfigSchemaOutput = z.infer<typeof PersistedConfigSchema>;
|
||||
|
||||
export type PersistedConfig = Omit<PersistedConfigSchemaOutput, "agents"> & {
|
||||
agents?: Omit<NonNullable<PersistedConfigSchemaOutput["agents"]>, "providers"> & {
|
||||
providers?: AgentProviderRuntimeSettingsMap;
|
||||
};
|
||||
};
|
||||
|
||||
const CONFIG_FILENAME = "config.json";
|
||||
const DEFAULT_PERSISTED_CONFIG: PersistedConfig = PersistedConfigSchema.parse({
|
||||
const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({
|
||||
version: 1,
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
@@ -191,7 +303,7 @@ const DEFAULT_PERSISTED_CONFIG: PersistedConfig = PersistedConfigSchema.parse({
|
||||
app: {
|
||||
baseUrl: "https://app.paseo.sh",
|
||||
},
|
||||
});
|
||||
}) as PersistedConfig;
|
||||
|
||||
type LoggerLike = {
|
||||
child(bindings: Record<string, unknown>): LoggerLike;
|
||||
@@ -275,7 +387,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
|
||||
}
|
||||
|
||||
log?.info(`Loaded from ${configPath}`);
|
||||
return result.data;
|
||||
return result.data as PersistedConfig;
|
||||
}
|
||||
|
||||
export function savePersistedConfig(
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
const testLogger = {
|
||||
child: () => testLogger,
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
} as any;
|
||||
|
||||
type ManagedAgentOverrides = Omit<
|
||||
@@ -208,4 +209,22 @@ describe("persistence hooks", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSessionConfig skips records whose provider is missing from the registry", () => {
|
||||
const record = createRecord({
|
||||
id: "agent-missing-provider",
|
||||
provider: "zai",
|
||||
});
|
||||
|
||||
expect(
|
||||
buildSessionConfig(record, {
|
||||
validProviders: ["claude", "codex"],
|
||||
logger: testLogger,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(testLogger.warn).toHaveBeenCalledWith(
|
||||
{ agentId: "agent-missing-provider", provider: "zai" },
|
||||
"Skipping persisted agent with unknown provider 'zai'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import { isValidAgentProvider } from "./agent/provider-manifest.js";
|
||||
|
||||
type LoggerLike = {
|
||||
child(bindings: Record<string, unknown>): LoggerLike;
|
||||
error(...args: any[]): void;
|
||||
warn(...args: any[]): void;
|
||||
};
|
||||
|
||||
function getLogger(logger: LoggerLike): LoggerLike {
|
||||
@@ -15,6 +15,11 @@ function getLogger(logger: LoggerLike): LoggerLike {
|
||||
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
|
||||
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
|
||||
|
||||
type BuildSessionConfigOptions = {
|
||||
validProviders?: Iterable<AgentProvider>;
|
||||
logger?: LoggerLike;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach AgentStorage persistence to an AgentManager instance so every
|
||||
* agent_state snapshot is flushed to disk.
|
||||
@@ -51,9 +56,18 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfig {
|
||||
if (!isValidAgentProvider(record.provider)) {
|
||||
throw new Error(`Unknown provider '${record.provider}'`);
|
||||
export function buildSessionConfig(
|
||||
record: StoredAgentRecord,
|
||||
options?: BuildSessionConfigOptions,
|
||||
): AgentSessionConfig | null {
|
||||
const validProviders = options?.validProviders;
|
||||
const isValidProvider = validProviders ? new Set(validProviders).has(record.provider) : true;
|
||||
if (!isValidProvider) {
|
||||
options?.logger?.warn(
|
||||
{ agentId: record.id, provider: record.provider },
|
||||
`Skipping persisted agent with unknown provider '${record.provider}'`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const overrides = buildConfigOverrides(record);
|
||||
return {
|
||||
|
||||
@@ -470,7 +470,14 @@ export class ScheduleService {
|
||||
extractTimestamps(record),
|
||||
);
|
||||
} else {
|
||||
snapshot = await this.agentManager.createAgent(buildSessionConfig(record), agentId, {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,7 +71,10 @@ import type { DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
|
||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import type {
|
||||
@@ -109,7 +112,6 @@ import type {
|
||||
ProviderSnapshotEntry,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
||||
import {
|
||||
buildProjectPlacementForCwd,
|
||||
detectStaleWorkspaces,
|
||||
@@ -185,7 +187,7 @@ import {
|
||||
const execAsync = promisify(exec);
|
||||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||||
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
||||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
|
||||
const DEFAULT_AGENT_PROVIDER = "claude";
|
||||
|
||||
// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings.
|
||||
// Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject
|
||||
@@ -416,6 +418,7 @@ export type SessionOptions = {
|
||||
getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||
};
|
||||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||
providerOverrides?: Record<string, ProviderOverride>;
|
||||
};
|
||||
|
||||
export type SessionLifecycleIntent =
|
||||
@@ -487,8 +490,20 @@ function convertPCMToWavBuffer(
|
||||
return wavBuffer;
|
||||
}
|
||||
|
||||
function coerceAgentProvider(logger: pino.Logger, value: string, agentId?: string): AgentProvider {
|
||||
if (isValidAgentProvider(value)) {
|
||||
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(
|
||||
@@ -500,13 +515,14 @@ function coerceAgentProvider(logger: pino.Logger, value: string, agentId?: strin
|
||||
|
||||
function toAgentPersistenceHandle(
|
||||
logger: pino.Logger,
|
||||
providerRegistry: ReturnType<typeof buildProviderRegistry>,
|
||||
handle: StoredAgentRecord["persistence"],
|
||||
): AgentPersistenceHandle | null {
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
const provider = handle.provider;
|
||||
if (!isValidAgentProvider(provider)) {
|
||||
if (!isRegisteredProvider(providerRegistry, provider)) {
|
||||
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
|
||||
return null;
|
||||
}
|
||||
@@ -619,6 +635,7 @@ export class Session {
|
||||
private readonly unregisterVoiceCallerContext?: (agentId: string) => void;
|
||||
private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
|
||||
private voiceModeAgentId: string | null = null;
|
||||
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
|
||||
|
||||
@@ -652,6 +669,7 @@ export class Session {
|
||||
voiceBridge,
|
||||
dictation,
|
||||
agentProviderRuntimeSettings,
|
||||
providerOverrides,
|
||||
} = options;
|
||||
this.clientId = clientId;
|
||||
this.appVersion = appVersion;
|
||||
@@ -707,6 +725,7 @@ export class Session {
|
||||
this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext;
|
||||
this.getSpeechReadiness = dictation?.getSpeechReadiness;
|
||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||
this.providerOverrides = providerOverrides;
|
||||
this.abortController = new AbortController();
|
||||
this.sessionLogger = logger.child({
|
||||
module: "session",
|
||||
@@ -715,6 +734,7 @@ export class Session {
|
||||
});
|
||||
this.providerRegistry = buildProviderRegistry(this.sessionLogger, {
|
||||
runtimeSettings: this.agentProviderRuntimeSettings,
|
||||
providerOverrides: this.providerOverrides,
|
||||
});
|
||||
|
||||
// Initialize per-session managers
|
||||
@@ -1071,10 +1091,20 @@ export class Session {
|
||||
const updatedAt = new Date(this.resolveStoredAgentPayloadUpdatedAt(record));
|
||||
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
|
||||
|
||||
const provider = coerceAgentProvider(this.sessionLogger, record.provider, record.id);
|
||||
const provider = coerceAgentProvider(
|
||||
this.sessionLogger,
|
||||
this.providerRegistry,
|
||||
record.provider,
|
||||
record.id,
|
||||
);
|
||||
const runtimeInfo = record.runtimeInfo
|
||||
? {
|
||||
provider: coerceAgentProvider(this.sessionLogger, record.runtimeInfo.provider, record.id),
|
||||
provider: coerceAgentProvider(
|
||||
this.sessionLogger,
|
||||
this.providerRegistry,
|
||||
record.runtimeInfo.provider,
|
||||
record.id,
|
||||
),
|
||||
sessionId: record.runtimeInfo.sessionId,
|
||||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "model")
|
||||
? { model: record.runtimeInfo.model ?? null }
|
||||
@@ -1107,7 +1137,11 @@ export class Session {
|
||||
currentModeId: record.lastModeId ?? null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence),
|
||||
persistence: toAgentPersistenceHandle(
|
||||
this.sessionLogger,
|
||||
this.providerRegistry,
|
||||
record.persistence,
|
||||
),
|
||||
lastUsage: undefined,
|
||||
lastError: undefined,
|
||||
title: record.title ?? record.config?.title ?? null,
|
||||
@@ -1153,7 +1187,11 @@ export class Session {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence);
|
||||
const handle = toAgentPersistenceHandle(
|
||||
this.sessionLogger,
|
||||
this.providerRegistry,
|
||||
record.persistence,
|
||||
);
|
||||
let snapshot: ManagedAgent;
|
||||
if (handle) {
|
||||
snapshot = await this.agentManager.resumeAgentFromPersistence(
|
||||
@@ -1167,7 +1205,13 @@ export class Session {
|
||||
"Agent resumed from persistence",
|
||||
);
|
||||
} else {
|
||||
const config = buildSessionConfig(record);
|
||||
const config = buildSessionConfig(record, {
|
||||
validProviders: Object.keys(this.providerRegistry),
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
if (!config) {
|
||||
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
|
||||
}
|
||||
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels });
|
||||
this.sessionLogger.info(
|
||||
{ agentId, provider: record.provider },
|
||||
@@ -3065,7 +3109,11 @@ export class Session {
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence);
|
||||
const handle = toAgentPersistenceHandle(
|
||||
this.sessionLogger,
|
||||
this.providerRegistry,
|
||||
record.persistence,
|
||||
);
|
||||
if (!handle) {
|
||||
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`);
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"1.2.3-test",
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -180,6 +180,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
TEST_DAEMON_VERSION,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -27,7 +27,10 @@ import type { AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
import { isHostAllowed } from "./allowed-hosts.js";
|
||||
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
||||
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
@@ -249,6 +252,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly voiceSpeakHandlers = new Map<string, VoiceSpeakHandler>();
|
||||
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
|
||||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
|
||||
private readonly providerSnapshotManager: ProviderSnapshotManager;
|
||||
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
|
||||
private serverCapabilities: ServerCapabilities | undefined;
|
||||
@@ -294,6 +298,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
finalTimeoutMs?: number;
|
||||
},
|
||||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap,
|
||||
providerOverrides?: Record<string, ProviderOverride>,
|
||||
daemonVersion?: string,
|
||||
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void,
|
||||
projectRegistry?: ProjectRegistry,
|
||||
@@ -341,10 +346,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.terminalManager = terminalManager ?? null;
|
||||
this.dictation = dictation ?? null;
|
||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||
this.providerOverrides = providerOverrides;
|
||||
const providerSnapshotLogger = this.logger.child({ module: "provider-snapshot-manager" });
|
||||
this.providerSnapshotManager = new ProviderSnapshotManager(
|
||||
buildProviderRegistry(providerSnapshotLogger, {
|
||||
runtimeSettings: this.agentProviderRuntimeSettings,
|
||||
providerOverrides: this.providerOverrides,
|
||||
}),
|
||||
providerSnapshotLogger,
|
||||
);
|
||||
@@ -672,6 +679,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
: undefined,
|
||||
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
|
||||
providerOverrides: this.providerOverrides,
|
||||
});
|
||||
|
||||
connection = {
|
||||
|
||||
@@ -93,6 +93,8 @@ const AgentModeSchema: z.ZodType<AgentMode> = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
colorTier: z.string().optional(),
|
||||
});
|
||||
|
||||
const ProviderStatusSchema: z.ZodType<ProviderStatus> = z.enum([
|
||||
@@ -154,6 +156,9 @@ const ProviderSnapshotEntrySchema: z.ZodType<ProviderSnapshotEntry> = z.object({
|
||||
models: z.array(AgentModelDefinitionSchema).optional(),
|
||||
modes: z.array(AgentModeSchema).optional(),
|
||||
fetchedAt: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
defaultModeId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
|
||||
|
||||
Reference in New Issue
Block a user