mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
119afd7281 | ||
|
|
e58725ee39 | ||
|
|
36cdfaf516 | ||
|
|
c5442ef0a2 | ||
|
|
0748149ec9 | ||
|
|
8c67415fdb | ||
|
|
6fe320055d | ||
|
|
2ef119c24b | ||
|
|
79be6d8dba | ||
|
|
42e3f63dec |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.100 - 2026-06-24
|
||||
|
||||
### Added
|
||||
|
||||
- Cycle agent modes with Shift+Tab
|
||||
- Select a custom Copilot agent when starting or mid-session ([#1700](https://github.com/getpaseo/paseo/pull/1700))
|
||||
|
||||
### Improved
|
||||
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude no longer sends an extra API request after each message ([#1701](https://github.com/getpaseo/paseo/pull/1701))
|
||||
- OpenCode no longer leaves stray background servers running after sessions end ([#1697](https://github.com/getpaseo/paseo/pull/1697))
|
||||
- Slash commands and skills now load in OMP agents ([#1698](https://github.com/getpaseo/paseo/pull/1698))
|
||||
|
||||
## 0.1.99 - 2026-06-23
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -10,6 +10,8 @@ Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-age
|
||||
|
||||
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
|
||||
|
||||
Copilot custom agents are exposed through ACP session config, not the slash-command list. When custom agents are available, Copilot returns a select config option with `id: "agent"` and `category: "_agent"`; Paseo maps that to the `agent` provider feature. Copilot uses the agent display name as the option value, and the blank value means the default Copilot agent.
|
||||
|
||||
### Direct
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
@@ -26,6 +28,8 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
@@ -42,6 +46,8 @@ Provider session import has its own contract. The picker calls `listImportableSe
|
||||
|
||||
Provider-owned helper processes that can outlive an individual agent session must be recorded in the daemon's managed-process registry. Store provider/kind metadata, the PID, launch command/args, and process identity captured from the platform process table. Remove the record on normal exit or shutdown.
|
||||
|
||||
If a helper process has a readiness phase, the provider's lifecycle model must own the process immediately after `spawn`, before readiness succeeds. Startup timeout, startup exit, and daemon shutdown must all clean up through that owned generation. Do not keep a spawned helper only inside a readiness promise; that creates a live process outside the manager/reaper contract.
|
||||
|
||||
Daemon bootstrap reconciles that ledger in the background, without blocking startup: dead PIDs are deleted, PID identity mismatches are deleted without killing anything, only positively matched Paseo-owned leftovers are terminated, and a record whose process cannot be inspected is left in place for the next reconcile rather than deleted. Do not add broad process-name sweepers for provider cleanup; cleanup starts from records Paseo previously wrote.
|
||||
|
||||
---
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-+rWSD0ZcNJlY5JO3Pb3LslBMu9al3rN1I6i0k5bG1d8=
|
||||
sha256-c3FItM+qFwZ/B21jOJ0W33LFZn1LUk4qNRbBekVT5vU=
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35243,7 +35243,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -35566,12 +35566,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.99",
|
||||
"@getpaseo/protocol": "0.1.99",
|
||||
"@getpaseo/server": "0.1.99",
|
||||
"@getpaseo/client": "0.1.100",
|
||||
"@getpaseo/protocol": "0.1.100",
|
||||
"@getpaseo/server": "0.1.100",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35817,10 +35817,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.99",
|
||||
"@getpaseo/relay": "0.1.99",
|
||||
"@getpaseo/protocol": "0.1.100",
|
||||
"@getpaseo/relay": "0.1.100",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -35831,7 +35831,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36074,7 +36074,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -36970,7 +36970,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37201,7 +37201,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37213,7 +37213,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -37431,15 +37431,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.99",
|
||||
"@getpaseo/highlight": "0.1.99",
|
||||
"@getpaseo/protocol": "0.1.99",
|
||||
"@getpaseo/relay": "0.1.99",
|
||||
"@getpaseo/client": "0.1.100",
|
||||
"@getpaseo/highlight": "0.1.100",
|
||||
"@getpaseo/protocol": "0.1.100",
|
||||
"@getpaseo/relay": "0.1.100",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -37848,7 +37848,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Bot, ShieldAlert, ShieldCheck, ShieldOff, ShieldQuestionMark } from "lu
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
@@ -24,7 +26,12 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { formatAgentModeLabel } from "@/composer/agent-controls/utils";
|
||||
import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { resolveNextAgentModeId } from "@/composer/agent-controls/mode";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
@@ -106,7 +113,10 @@ function AgentModeControlView({
|
||||
}: AgentModeControlViewProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const cycleShortcutKeys = useShortcutKeys("cycle-agent-mode");
|
||||
const anchorRef = useRef<View>(null);
|
||||
const keyboardHandlerIdRef = useRef(`mode-control:${Math.random().toString(36).slice(2)}`);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -146,6 +156,26 @@ function AgentModeControlView({
|
||||
[onSelectMode, handleOpenChange],
|
||||
);
|
||||
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (action.id !== "message-input.mode-cycle") return false;
|
||||
if (disabled || !isActiveComposer) return false;
|
||||
const nextModeId = resolveNextAgentModeId({ modeOptions, selectedMode: selectedModeId });
|
||||
if (!nextModeId) return false;
|
||||
onSelectMode(nextModeId);
|
||||
return true;
|
||||
},
|
||||
[disabled, isActiveComposer, modeOptions, onSelectMode, selectedModeId],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: keyboardHandlerIdRef.current,
|
||||
actions: ["message-input.mode-cycle"],
|
||||
enabled: isActiveComposer && !disabled && modeOptions.length > 1,
|
||||
priority: 200,
|
||||
handle: handleKeyboardAction,
|
||||
});
|
||||
|
||||
const renderOption = useCallback(
|
||||
(args: {
|
||||
option: ComboboxOption;
|
||||
@@ -194,21 +224,31 @@ function AgentModeControlView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComboboxTrigger
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
<Text style={labelStyle}>{selectedModeLabel}</Text>
|
||||
</ComboboxTrigger>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<ComboboxTrigger
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
<Text style={labelStyle}>{selectedModeLabel}</Text>
|
||||
</ComboboxTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("mode"))}</Text>
|
||||
{isActiveComposer && cycleShortcutKeys ? <Shortcut chord={cycleShortcutKeys} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedMode.id}
|
||||
@@ -373,4 +413,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAgentControlsMode } from "./mode";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
import { resolveAgentControlsMode, resolveNextAgentModeId } from "./mode";
|
||||
|
||||
const PLAN_MODE = { id: "plan", label: "Plan" } satisfies AgentMode;
|
||||
|
||||
const MODES = [
|
||||
PLAN_MODE,
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "full-access", label: "Full Access" },
|
||||
] satisfies AgentMode[];
|
||||
|
||||
describe("resolveAgentControlsMode", () => {
|
||||
it("uses ready mode when no controlled agent controls are provided", () => {
|
||||
@@ -29,3 +38,32 @@ describe("resolveAgentControlsMode", () => {
|
||||
).toBe("draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveNextAgentModeId", () => {
|
||||
it("cycles from the selected mode to the next mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "build" })).toBe(
|
||||
"full-access",
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps from the last mode to the first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "full-access" })).toBe(
|
||||
"plan",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an empty selection as the visible first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "" })).toBe("build");
|
||||
});
|
||||
|
||||
it("treats a stale selection as the visible first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "deleted-mode" })).toBe(
|
||||
"build",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when there are fewer than two modes", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: [], selectedMode: "" })).toBeNull();
|
||||
expect(resolveNextAgentModeId({ modeOptions: [PLAN_MODE], selectedMode: "plan" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export function resolveNextAgentModeId({
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
}: {
|
||||
modeOptions: readonly AgentMode[];
|
||||
selectedMode: string | null | undefined;
|
||||
}): string | null {
|
||||
if (modeOptions.length < 2) return null;
|
||||
|
||||
const selectedIndex = modeOptions.findIndex((mode) => mode.id === selectedMode);
|
||||
const currentIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
const nextIndex = (currentIndex + 1) % modeOptions.length;
|
||||
return modeOptions[nextIndex]?.id ?? null;
|
||||
}
|
||||
|
||||
export function resolveAgentControlsMode(agentControls?: DraftAgentControlsProps) {
|
||||
return agentControls ? "draft" : "ready";
|
||||
|
||||
@@ -89,6 +89,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { submitAgentInput } from "@/composer/submit";
|
||||
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
@@ -1746,8 +1747,15 @@ export function Composer({
|
||||
);
|
||||
|
||||
const leftContent = useMemo(
|
||||
() => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }),
|
||||
[agentId, focusInput, serverId, agentControls, isCompactLayout],
|
||||
() =>
|
||||
renderLeftContent({
|
||||
agentControls,
|
||||
agentId,
|
||||
serverId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
}),
|
||||
[agentControls, agentId, focusInput, isCompactLayout, serverId],
|
||||
);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
@@ -1860,90 +1868,92 @@ export function Composer({
|
||||
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
|
||||
|
||||
return (
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
</ComposerKeyboardScopeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
31
packages/app/src/composer/keyboard-scope.tsx
Normal file
31
packages/app/src/composer/keyboard-scope.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
|
||||
interface ComposerKeyboardScopeValue {
|
||||
isActiveComposer: boolean;
|
||||
}
|
||||
|
||||
const ComposerKeyboardScopeContext = createContext<ComposerKeyboardScopeValue>({
|
||||
isActiveComposer: false,
|
||||
});
|
||||
|
||||
export function ComposerKeyboardScopeProvider({
|
||||
isActiveComposer,
|
||||
children,
|
||||
}: PropsWithChildren<ComposerKeyboardScopeValue>): ReactElement {
|
||||
const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]);
|
||||
return (
|
||||
<ComposerKeyboardScopeContext.Provider value={value}>
|
||||
{children}
|
||||
</ComposerKeyboardScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useComposerKeyboardScope(): ComposerKeyboardScopeValue {
|
||||
return useContext(ComposerKeyboardScopeContext);
|
||||
}
|
||||
@@ -68,10 +68,10 @@ const CATALOG_DATA = [
|
||||
id: "codebuddy-code",
|
||||
title: "Codebuddy Code",
|
||||
description: "Tencent Cloud's official intelligent coding tool",
|
||||
version: "2.109.2",
|
||||
version: "2.109.3",
|
||||
iconId: "codebuddy-code",
|
||||
installLink: "https://www.codebuddy.cn/cli/",
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.109.2", "--acp"],
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.109.3", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codewhale",
|
||||
@@ -150,19 +150,19 @@ const CATALOG_DATA = [
|
||||
title: "Dirac",
|
||||
description:
|
||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||
version: "0.4.6",
|
||||
version: "0.4.7",
|
||||
iconId: "dirac",
|
||||
installLink: "https://dirac.run",
|
||||
command: ["npx", "-y", "dirac-cli@0.4.6", "--acp"],
|
||||
command: ["npx", "-y", "dirac-cli@0.4.7", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.156.2",
|
||||
version: "0.157.1",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.156.2", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.157.1", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.18",
|
||||
version: "1.1.19",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.18", "acp"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.19", "acp"],
|
||||
},
|
||||
{
|
||||
id: "poolside",
|
||||
|
||||
@@ -183,6 +183,7 @@ describe("translation resources", () => {
|
||||
expect(en.composer.github.title).toBe("Attach issue or PR");
|
||||
expect(en.agentControls.provider.fallback).toBe("Provider");
|
||||
expect(en.agentControls.hints.model).toBe("Change model");
|
||||
expect(en.agentControls.hints.mode).toBe("Change mode");
|
||||
expect(en.agentControls.features.title).toBe("Features");
|
||||
expect(en.agentControls.mode.title).toBe("Mode");
|
||||
expect(en.agentStream.permission.required).toBe("Permission Required");
|
||||
@@ -289,6 +290,7 @@ describe("translation resources", () => {
|
||||
expect(en.settings.shortcuts.sections.tabsPanes).toBe("Tabs & Panes");
|
||||
expect(en.settings.shortcuts.help.toggleCommandCenter).toBe("Toggle command center");
|
||||
expect(en.settings.shortcuts.help.newWorkspace).toBe("New workspace");
|
||||
expect(en.settings.shortcuts.help.cycleAgentMode).toBe("Cycle agent mode");
|
||||
expect(en.settings.shortcuts.helpNotes.showKeyboardShortcuts).toBe(
|
||||
"Available when focus is not in a text field or terminal.",
|
||||
);
|
||||
|
||||
@@ -165,7 +165,7 @@ export const ar: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "وضع التفكير",
|
||||
model: "تغيير النموذج",
|
||||
mode: "تغيير وضع الإذن",
|
||||
mode: "تغيير الوضع",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -1567,6 +1567,7 @@ export const ar: TranslationResources = {
|
||||
toggleFocusMode: "تبديل وضع التركيز",
|
||||
cycleTheme: "موضوع الدورة",
|
||||
focusMessageInput: "التركيز على إدخال الرسالة",
|
||||
cycleAgentMode: "تبديل وضع الوكيل",
|
||||
toggleVoiceMode: "تبديل الوضع الصوتي",
|
||||
startStopDictation: "بدء إملاء /stop",
|
||||
interruptAgent: "عامل المقاطعة",
|
||||
|
||||
@@ -163,7 +163,7 @@ export const en = {
|
||||
hints: {
|
||||
thinking: "Thinking mode",
|
||||
model: "Change model",
|
||||
mode: "Change permission mode",
|
||||
mode: "Change mode",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -1573,6 +1573,7 @@ export const en = {
|
||||
toggleFocusMode: "Toggle focus mode",
|
||||
cycleTheme: "Cycle theme",
|
||||
focusMessageInput: "Focus message input",
|
||||
cycleAgentMode: "Cycle agent mode",
|
||||
toggleVoiceMode: "Toggle voice mode",
|
||||
startStopDictation: "Start/stop dictation",
|
||||
interruptAgent: "Interrupt agent",
|
||||
|
||||
@@ -165,7 +165,7 @@ export const es: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Modo de pensamiento",
|
||||
model: "Cambiar modelo",
|
||||
mode: "Cambiar modo de permiso",
|
||||
mode: "Cambiar modo",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -1605,6 +1605,7 @@ export const es: TranslationResources = {
|
||||
toggleFocusMode: "Alternar modo de enfoque",
|
||||
cycleTheme: "Tema del ciclo",
|
||||
focusMessageInput: "Entrada de mensaje de enfoque",
|
||||
cycleAgentMode: "Alternar modo del agente",
|
||||
toggleVoiceMode: "Alternar modo de voz",
|
||||
startStopDictation: "Iniciar dictado/stop",
|
||||
interruptAgent: "agente de interrupción",
|
||||
|
||||
@@ -166,7 +166,7 @@ export const fr: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Mode réflexion",
|
||||
model: "Changer de modèle",
|
||||
mode: "Changer le mode d'autorisation",
|
||||
mode: "Changer de mode",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -1609,6 +1609,7 @@ export const fr: TranslationResources = {
|
||||
toggleFocusMode: "Basculer le mode de mise au point",
|
||||
cycleTheme: "Thème du cycle",
|
||||
focusMessageInput: "Saisie du message de focus",
|
||||
cycleAgentMode: "Parcourir les modes de l'agent",
|
||||
toggleVoiceMode: "Changer le mode vocal",
|
||||
startStopDictation: "Démarrer la dictée/stop",
|
||||
interruptAgent: "Agent d'interruption",
|
||||
|
||||
@@ -165,7 +165,7 @@ export const ru: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Режим мышления",
|
||||
model: "Изменить модель",
|
||||
mode: "Изменить режим разрешений",
|
||||
mode: "Изменить режим",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -1597,6 +1597,7 @@ export const ru: TranslationResources = {
|
||||
toggleFocusMode: "Переключить режим фокусировки",
|
||||
cycleTheme: "Циклическая тема",
|
||||
focusMessageInput: "Фокус ввода сообщения",
|
||||
cycleAgentMode: "Переключить режим агента",
|
||||
toggleVoiceMode: "Переключить голосовой режим",
|
||||
startStopDictation: "Начать диктовку /stop",
|
||||
interruptAgent: "Агент прерываний",
|
||||
|
||||
@@ -165,7 +165,7 @@ export const zhCN: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Thinking mode",
|
||||
model: "切换 Model",
|
||||
mode: "切换权限 Mode",
|
||||
mode: "更改模式",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -1548,6 +1548,7 @@ export const zhCN: TranslationResources = {
|
||||
toggleFocusMode: "切换专注模式",
|
||||
cycleTheme: "循环切换主题",
|
||||
focusMessageInput: "聚焦消息输入框",
|
||||
cycleAgentMode: "循环切换代理模式",
|
||||
toggleVoiceMode: "切换语音模式",
|
||||
startStopDictation: "开始/停止听写",
|
||||
interruptAgent: "中断 Agent",
|
||||
|
||||
@@ -12,7 +12,8 @@ export type MessageInputKeyboardActionKind =
|
||||
| "dictation-cancel"
|
||||
| "dictation-confirm"
|
||||
| "voice-toggle"
|
||||
| "voice-mute-toggle";
|
||||
| "voice-mute-toggle"
|
||||
| "mode-cycle";
|
||||
|
||||
export type KeyboardActionId =
|
||||
| "agent.interrupt"
|
||||
|
||||
@@ -9,6 +9,7 @@ export type KeyboardActionId =
|
||||
| "message-input.dictation-confirm"
|
||||
| "message-input.voice-toggle"
|
||||
| "message-input.voice-mute-toggle"
|
||||
| "message-input.mode-cycle"
|
||||
| "workspace.tab.new"
|
||||
| "workspace.tab.close-current"
|
||||
| "workspace.tab.navigate-index"
|
||||
@@ -39,6 +40,7 @@ export type KeyboardActionDefinition =
|
||||
| { id: "message-input.dictation-confirm"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.voice-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.mode-cycle"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
|
||||
|
||||
@@ -299,6 +299,13 @@ describe("keyboard-shortcuts", () => {
|
||||
action: "message-input.action",
|
||||
payload: { kind: "dictation-toggle" },
|
||||
},
|
||||
{
|
||||
name: "routes Shift+Tab to cycle agent mode from the message input",
|
||||
event: { key: "Tab", code: "Tab", shiftKey: true },
|
||||
context: { focusScope: "message-input" },
|
||||
action: "message-input.action",
|
||||
payload: { kind: "mode-cycle" },
|
||||
},
|
||||
{
|
||||
name: "routes space to voice mute toggle outside editable scopes",
|
||||
event: { key: " ", code: "Space" },
|
||||
@@ -427,6 +434,16 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "d", code: "KeyD", metaKey: true },
|
||||
context: { isMac: true, focusScope: "terminal" },
|
||||
},
|
||||
{
|
||||
name: "does not cycle agent mode outside the message input",
|
||||
event: { key: "Tab", code: "Tab", shiftKey: true },
|
||||
context: { focusScope: "other" },
|
||||
},
|
||||
{
|
||||
name: "does not repeat agent mode cycling while Shift+Tab is held",
|
||||
event: { key: "Tab", code: "Tab", shiftKey: true, repeat: true },
|
||||
context: { focusScope: "message-input" },
|
||||
},
|
||||
{
|
||||
name: "does not bind Cmd+Enter as a rebindable message queue shortcut",
|
||||
event: { key: "Enter", code: "Enter", metaKey: true },
|
||||
@@ -570,6 +587,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
"workspace-tab-close-current": ["alt", "shift", "W"],
|
||||
"workspace-pane-split-right": ["mod", "\\"],
|
||||
"workspace-pane-close": ["mod", "shift", "W"],
|
||||
"cycle-agent-mode": ["shift", "Tab"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -617,12 +635,14 @@ describe("keyboard-shortcut help sections", () => {
|
||||
const projects = sections.find((section) => section.id === "projects");
|
||||
const panels = sections.find((section) => section.id === "panels");
|
||||
const openProject = findRow(sections, "new-agent");
|
||||
const cycleAgentMode = findRow(sections, "cycle-agent-mode");
|
||||
const showShortcuts = findRow(sections, "show-shortcuts");
|
||||
|
||||
expect(projects?.titleKey).toBe("settings.shortcuts.sections.projects");
|
||||
expect(panels?.titleKey).toBe("settings.shortcuts.sections.panels");
|
||||
expect(openProject?.labelKey).toBe("settings.shortcuts.help.openProject");
|
||||
expect(openProject?.label).toBe("Open project");
|
||||
expect(cycleAgentMode?.labelKey).toBe("settings.shortcuts.help.cycleAgentMode");
|
||||
expect(showShortcuts?.noteKey).toBe("settings.shortcuts.helpNotes.showKeyboardShortcuts");
|
||||
});
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||
"toggle-focus": "settings.shortcuts.help.toggleFocusMode",
|
||||
"cycle-theme": "settings.shortcuts.help.cycleTheme",
|
||||
"focus-message-input": "settings.shortcuts.help.focusMessageInput",
|
||||
"cycle-agent-mode": "settings.shortcuts.help.cycleAgentMode",
|
||||
"voice-toggle": "settings.shortcuts.help.toggleVoiceMode",
|
||||
"dictation-toggle": "settings.shortcuts.help.startStopDictation",
|
||||
"agent-interrupt": "settings.shortcuts.help.interruptAgent",
|
||||
@@ -910,6 +911,20 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
keys: ["mod", "L"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-mode-cycle-shift-tab",
|
||||
action: "message-input.action",
|
||||
combo: "Shift+Tab",
|
||||
repeat: false,
|
||||
when: { commandCenter: false, focusScope: "message-input" },
|
||||
payload: { type: "message-input", kind: "mode-cycle" },
|
||||
help: {
|
||||
id: "cycle-agent-mode",
|
||||
section: "agent-input",
|
||||
label: "Cycle agent mode",
|
||||
keys: ["shift", "Tab"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-voice-toggle-cmd-shift-d-mac",
|
||||
action: "message-input.action",
|
||||
|
||||
@@ -276,6 +276,7 @@ describe("routeKeyboardShortcut — message-input.action", () => {
|
||||
["dictation-confirm", "message-input.dictation-confirm"],
|
||||
["voice-toggle", "message-input.voice-toggle"],
|
||||
["voice-mute-toggle", "message-input.voice-mute-toggle"],
|
||||
["mode-cycle", "message-input.mode-cycle"],
|
||||
] as const)("kind=%s → dispatch %s", (kind, id) => {
|
||||
expect(
|
||||
routeKeyboardShortcut({ action: "message-input.action", payload: { kind } }, makeCtx()),
|
||||
|
||||
@@ -82,6 +82,7 @@ const MESSAGE_INPUT_DISPATCH: Record<
|
||||
"dictation-confirm": { id: "message-input.dictation-confirm", scope: "message-input" },
|
||||
"voice-toggle": { id: "message-input.voice-toggle", scope: "message-input" },
|
||||
"voice-mute-toggle": { id: "message-input.voice-mute-toggle", scope: "message-input" },
|
||||
"mode-cycle": { id: "message-input.mode-cycle", scope: "message-input" },
|
||||
};
|
||||
|
||||
function hasPayloadKey<K extends "index" | "delta" | "kind">(
|
||||
|
||||
@@ -8,6 +8,12 @@ describe("formatShortcut", () => {
|
||||
expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E");
|
||||
});
|
||||
|
||||
it("spells out Shift in shortcut labels", () => {
|
||||
expect(formatShortcut(["shift", "Tab"], "mac")).toBe("Shift+Tab");
|
||||
expect(formatShortcut(["mod", "shift", "P"], "mac")).toBe("Shift+⌘+P");
|
||||
expect(formatShortcut(["shift", "Tab"], "non-mac")).toBe("Shift+Tab");
|
||||
});
|
||||
|
||||
it("uses Ctrl+ on non-mac platforms", () => {
|
||||
expect(formatShortcut(["mod", "B"], "non-mac")).toBe("Ctrl+B");
|
||||
expect(formatShortcut(["mod", "E"], "non-mac")).toBe("Ctrl+E");
|
||||
|
||||
@@ -27,18 +27,22 @@ export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
|
||||
const order = ["ctrl", "alt", "shift", "mod", "meta"];
|
||||
const symbols: Record<string, string> = {
|
||||
mod: "⌘",
|
||||
shift: "⇧",
|
||||
alt: "⌥",
|
||||
ctrl: "⌃",
|
||||
meta: "⌘",
|
||||
};
|
||||
|
||||
const modifierSet = new Set(normalized);
|
||||
const mods = order.filter((k) => modifierSet.has(k)).map((k) => symbols[k] ?? "");
|
||||
const mods = order
|
||||
.filter((k) => modifierSet.has(k))
|
||||
.map((k) => (k === "shift" ? "Shift" : (symbols[k] ?? "")));
|
||||
const main = normalized
|
||||
.filter((k) => !order.includes(k))
|
||||
.map(normalizeKey)
|
||||
.join("");
|
||||
if (mods.includes("Shift")) {
|
||||
return [...mods, main].filter(Boolean).join("+");
|
||||
}
|
||||
return `${mods.join("")}${main}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.99",
|
||||
"@getpaseo/protocol": "0.1.99",
|
||||
"@getpaseo/server": "0.1.99",
|
||||
"@getpaseo/client": "0.1.100",
|
||||
"@getpaseo/protocol": "0.1.100",
|
||||
"@getpaseo/server": "0.1.100",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.99",
|
||||
"@getpaseo/relay": "0.1.99",
|
||||
"@getpaseo/protocol": "0.1.100",
|
||||
"@getpaseo/relay": "0.1.100",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -65,10 +65,10 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.99",
|
||||
"@getpaseo/highlight": "0.1.99",
|
||||
"@getpaseo/protocol": "0.1.99",
|
||||
"@getpaseo/relay": "0.1.99",
|
||||
"@getpaseo/client": "0.1.100",
|
||||
"@getpaseo/highlight": "0.1.100",
|
||||
"@getpaseo/protocol": "0.1.100",
|
||||
"@getpaseo/relay": "0.1.100",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -12,6 +12,7 @@ const mockState = vi.hoisted(() => {
|
||||
interface ConstructorEntry {
|
||||
runtimeSettings?: unknown;
|
||||
providerParams?: unknown;
|
||||
commandsRpcType?: unknown;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -214,12 +215,20 @@ vi.mock("./providers/pi/agent.js", () => ({
|
||||
readonly provider = "pi";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(options: { runtimeSettings?: unknown; providerParams?: unknown }) {
|
||||
constructor(options: {
|
||||
runtimeSettings?: unknown;
|
||||
providerParams?: unknown;
|
||||
commandsRpcType?: unknown;
|
||||
}) {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
mockState.constructorArgs.pi.push({
|
||||
const entry: ConstructorEntry = {
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
providerParams: options.providerParams,
|
||||
});
|
||||
};
|
||||
if (options.commandsRpcType !== undefined) {
|
||||
entry.commandsRpcType = options.commandsRpcType;
|
||||
}
|
||||
mockState.constructorArgs.pi.push(entry);
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
@@ -458,6 +467,7 @@ test("OMP is a disabled built-in backed by the Pi adapter", () => {
|
||||
providerParams: {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
commandsRpcType: "get_available_commands",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -488,11 +498,10 @@ test("new provider extending claude appears in registry", () => {
|
||||
expect(registry.zai.createClient(logger).provider).toBe("zai");
|
||||
});
|
||||
|
||||
test("new provider extending pi passes params to the base provider constructor", () => {
|
||||
test("built-in OMP override passes params to the Pi adapter constructor", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
omp: {
|
||||
extends: "pi",
|
||||
label: "OMP",
|
||||
command: ["omp"],
|
||||
params: {
|
||||
@@ -515,6 +524,7 @@ test("new provider extending pi passes params to the base provider constructor",
|
||||
providerParams: {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
commandsRpcType: "get_available_commands",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
providerParams: options?.providerParams ?? {
|
||||
sessionDir: "~/.omp/agent/sessions",
|
||||
},
|
||||
commandsRpcType: "get_available_commands",
|
||||
}),
|
||||
mock: (logger) => new MockLoadTestAgentClient(logger),
|
||||
"mock-slow": () => new MockSlowProviderClient(),
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "./acp-agent.js";
|
||||
import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js";
|
||||
import {
|
||||
COPILOT_AGENT_FEATURE_OPTION,
|
||||
COPILOT_ALLOW_ALL_MODE_ID,
|
||||
COPILOT_MODES,
|
||||
CopilotACPAgentClient,
|
||||
@@ -205,12 +206,16 @@ function selectConfigOption(
|
||||
};
|
||||
}
|
||||
|
||||
function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession {
|
||||
function createCopilotSessionWithConfig(
|
||||
modeId?: string | null,
|
||||
featureValues?: Record<string, unknown>,
|
||||
): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
provider: "copilot",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
modeId: modeId ?? undefined,
|
||||
...(featureValues ? { featureValues } : {}),
|
||||
},
|
||||
{
|
||||
provider: "copilot",
|
||||
@@ -219,6 +224,7 @@ function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession
|
||||
defaultModes: COPILOT_MODES,
|
||||
sessionResponseTransformer: transformCopilotSessionResponse,
|
||||
configOptionsTransformer: transformCopilotConfigOptions,
|
||||
configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION],
|
||||
modeIdTransformer: transformCopilotModeId,
|
||||
providerModeWriter: writeCopilotProviderMode,
|
||||
beforeModeWriter: beforeCopilotModeWriter,
|
||||
@@ -272,6 +278,27 @@ function copilotAllowAllConfigOption(currentValue: "on" | "off"): SessionConfigO
|
||||
};
|
||||
}
|
||||
|
||||
function copilotAgentConfigOption(currentValue: string): SessionConfigOption {
|
||||
return {
|
||||
id: "agent",
|
||||
name: "Agent",
|
||||
category: "_agent",
|
||||
type: "select",
|
||||
currentValue,
|
||||
options: [
|
||||
{
|
||||
value: "",
|
||||
name: "",
|
||||
},
|
||||
{
|
||||
value: "Probe Agent",
|
||||
name: "Probe Agent",
|
||||
description: "Temporary probe agent",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string {
|
||||
if (category === "mode") {
|
||||
return "Mode";
|
||||
@@ -1137,6 +1164,90 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
]);
|
||||
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
||||
});
|
||||
|
||||
test("exposes Copilot custom agents as a select feature", () => {
|
||||
const session = createCopilotSessionWithConfig();
|
||||
const internals = asInternals<ACPSessionInternals>(session);
|
||||
internals.configOptions = [copilotAgentConfigOption("")];
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "select",
|
||||
id: "agent",
|
||||
label: "Agent",
|
||||
description: "Use a Copilot custom agent profile",
|
||||
tooltip: "Select Copilot agent",
|
||||
icon: undefined,
|
||||
value: "",
|
||||
options: [
|
||||
{
|
||||
id: "",
|
||||
label: "Default",
|
||||
description: undefined,
|
||||
isDefault: true,
|
||||
metadata: undefined,
|
||||
},
|
||||
{
|
||||
id: "Probe Agent",
|
||||
label: "Probe Agent",
|
||||
description: "Temporary probe agent",
|
||||
isDefault: false,
|
||||
metadata: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("applies configured Copilot custom agent before the first turn", async () => {
|
||||
const setSessionConfigOption = vi.fn(async () => ({
|
||||
configOptions: [copilotAgentConfigOption("Probe Agent")],
|
||||
}));
|
||||
const session = createCopilotSessionWithConfig(null, { agent: "Probe Agent" });
|
||||
const { internals } = prepareConfiguredOverrideSession(session, {
|
||||
configOptions: [copilotAgentConfigOption("")],
|
||||
connection: { setSessionConfigOption },
|
||||
});
|
||||
|
||||
await internals.applyConfiguredOverrides();
|
||||
|
||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
configId: "agent",
|
||||
value: "Probe Agent",
|
||||
});
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("sets Copilot custom agent through ACP config options", async () => {
|
||||
const setSessionConfigOption = vi.fn(async () => ({
|
||||
configOptions: [copilotAgentConfigOption("Probe Agent")],
|
||||
}));
|
||||
const session = createCopilotSessionWithConfig();
|
||||
prepareConfiguredOverrideSession(session, {
|
||||
configOptions: [copilotAgentConfigOption("")],
|
||||
connection: { setSessionConfigOption },
|
||||
});
|
||||
|
||||
await session.setFeature("agent", "Probe Agent");
|
||||
|
||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
configId: "agent",
|
||||
value: "Probe Agent",
|
||||
});
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveModelDefinitionsFromACP", () => {
|
||||
@@ -1285,6 +1396,51 @@ describe("ACPAgentClient modelTransformer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentClient config features", () => {
|
||||
test("derives features from configured ACP select options", async () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
return {
|
||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||
connection: {
|
||||
newSession: vi.fn().mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
configOptions: [copilotAgentConfigOption("Probe Agent")],
|
||||
}),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
} as SpawnedACPProcess;
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
}
|
||||
|
||||
const client = new TestACPAgentClient({
|
||||
provider: "copilot",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["copilot", "--acp"],
|
||||
configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
provider: "copilot",
|
||||
cwd: "/tmp/acp-features",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
type: "select",
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
options: [
|
||||
expect.objectContaining({ id: "", label: "Default", isDefault: false }),
|
||||
expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentClient sessionResponseTransformer", () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
@@ -318,6 +319,7 @@ interface ACPAgentClientOptions {
|
||||
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
||||
configFeatureOptions?: ACPConfigFeatureOption[];
|
||||
modeIdTransformer?: (modeId: string) => string | null;
|
||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
providerModeWriter?: (
|
||||
@@ -344,6 +346,7 @@ interface ACPAgentSessionOptions {
|
||||
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||
configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[];
|
||||
configFeatureOptions?: ACPConfigFeatureOption[];
|
||||
modeIdTransformer?: (modeId: string) => string | null;
|
||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
providerModeWriter?: (
|
||||
@@ -426,6 +429,17 @@ interface ConfigOptionSelector {
|
||||
metadata?: AgentMetadata;
|
||||
}
|
||||
|
||||
export interface ACPConfigFeatureOption {
|
||||
id: string;
|
||||
configId: string;
|
||||
category: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
emptyOptionLabel?: string;
|
||||
}
|
||||
|
||||
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
|
||||
interface SelectConfigChoice {
|
||||
value: string;
|
||||
@@ -585,6 +599,31 @@ export function deriveModelDefinitionsFromACP(
|
||||
}));
|
||||
}
|
||||
|
||||
export function deriveFeaturesFromACP(
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
featureOptions: ACPConfigFeatureOption[],
|
||||
): AgentFeature[] {
|
||||
return featureOptions.flatMap((featureOption) => {
|
||||
const option = findSelectConfigFeatureOption(configOptions, featureOption);
|
||||
if (!option) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
id: featureOption.id,
|
||||
label: featureOption.label,
|
||||
description: featureOption.description,
|
||||
tooltip: featureOption.tooltip,
|
||||
icon: featureOption.icon,
|
||||
value: option.currentValue ?? null,
|
||||
options: deriveConfigFeatureSelectOptions(option, featureOption),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export class ACPAgentClient implements AgentClient {
|
||||
readonly provider: string;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
@@ -600,6 +639,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
private readonly configOptionsTransformer?: (
|
||||
configOptions: SessionConfigOption[],
|
||||
) => SessionConfigOption[];
|
||||
private readonly configFeatureOptions: ACPConfigFeatureOption[];
|
||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
private readonly providerModeWriter?: (
|
||||
@@ -631,6 +671,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
this.modelTransformer = options.modelTransformer;
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
||||
this.configFeatureOptions = options.configFeatureOptions ?? [];
|
||||
this.modeIdTransformer = options.modeIdTransformer;
|
||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||
this.providerModeWriter = options.providerModeWriter;
|
||||
@@ -656,6 +697,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
configOptionsTransformer: this.configOptionsTransformer,
|
||||
configFeatureOptions: this.configFeatureOptions,
|
||||
modeIdTransformer: this.modeIdTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
providerModeWriter: this.providerModeWriter,
|
||||
@@ -702,6 +744,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
configOptionsTransformer: this.configOptionsTransformer,
|
||||
configFeatureOptions: this.configFeatureOptions,
|
||||
modeIdTransformer: this.modeIdTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
providerModeWriter: this.providerModeWriter,
|
||||
@@ -748,6 +791,27 @@ export class ACPAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
if (this.configFeatureOptions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.assertProvider(config);
|
||||
const probe = await this.spawnProcess(PROBE_ENV);
|
||||
try {
|
||||
const response = await this.runACPRequest(() =>
|
||||
probe.connection.newSession({
|
||||
cwd: config.cwd,
|
||||
mcpServers: [],
|
||||
}),
|
||||
);
|
||||
const transformed = this.transformSessionResponse(response);
|
||||
return deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions);
|
||||
} finally {
|
||||
await this.closeProbe(probe);
|
||||
}
|
||||
}
|
||||
|
||||
async listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]> {
|
||||
@@ -965,6 +1029,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private readonly configOptionsTransformer?: (
|
||||
configOptions: SessionConfigOption[],
|
||||
) => SessionConfigOption[];
|
||||
private readonly configFeatureOptions: ACPConfigFeatureOption[];
|
||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
private readonly providerModeWriter?: (
|
||||
@@ -1027,6 +1092,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.modelTransformer = options.modelTransformer;
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
||||
this.configFeatureOptions = options.configFeatureOptions ?? [];
|
||||
this.modeIdTransformer = options.modeIdTransformer;
|
||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||
this.providerModeWriter = options.providerModeWriter;
|
||||
@@ -1211,6 +1277,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions);
|
||||
}
|
||||
|
||||
private ensureCommandsReadyDeferred(): void {
|
||||
if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) {
|
||||
return;
|
||||
@@ -1543,6 +1613,44 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
});
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (!this.connection || !this.sessionId) {
|
||||
throw new Error("ACP session not initialized");
|
||||
}
|
||||
|
||||
const featureOption = this.configFeatureOptions.find((option) => option.id === featureId);
|
||||
if (!featureOption) {
|
||||
throw new Error(`Unknown ${this.provider} feature: ${featureId}`);
|
||||
}
|
||||
|
||||
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
|
||||
if (!option) {
|
||||
throw new Error(`${this.provider} does not expose ACP feature '${featureId}'`);
|
||||
}
|
||||
|
||||
const requestedValue = normalizeConfigFeatureValue(value);
|
||||
const choice = findSelectConfigChoice({ option, value: requestedValue });
|
||||
if (!choice) {
|
||||
throw new Error(
|
||||
`${this.provider} feature '${featureId}' does not include option '${requestedValue}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.connection.setSessionConfigOption({
|
||||
sessionId: this.sessionId,
|
||||
configId: option.id,
|
||||
value: requestedValue,
|
||||
});
|
||||
const currentValue = this.applyConfigOptionResponse({
|
||||
response,
|
||||
configId: option.id,
|
||||
category: featureOption.category,
|
||||
requestedValue,
|
||||
label: featureOption.label,
|
||||
});
|
||||
this.config.featureValues = { ...this.config.featureValues, [featureId]: currentValue };
|
||||
}
|
||||
|
||||
private applyConfigOptionResponse({
|
||||
response,
|
||||
configId,
|
||||
@@ -2003,6 +2111,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
|
||||
await this.setThinkingOption(this.config.thinkingOptionId);
|
||||
}
|
||||
const configuredFeatureValues = this.config.featureValues ?? {};
|
||||
for (const featureOption of this.configFeatureOptions) {
|
||||
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
|
||||
continue;
|
||||
}
|
||||
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
|
||||
}
|
||||
}
|
||||
|
||||
private warnInvalidSelection(value: string, message: string): void {
|
||||
@@ -2362,6 +2477,19 @@ function findSelectConfigOption({
|
||||
return option ?? null;
|
||||
}
|
||||
|
||||
function findSelectConfigFeatureOption(
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
featureOption: ACPConfigFeatureOption,
|
||||
): SelectConfigOption | null {
|
||||
const option = configOptions?.find(
|
||||
(entry): entry is SelectConfigOption =>
|
||||
entry.type === "select" &&
|
||||
entry.id === featureOption.configId &&
|
||||
entry.category === featureOption.category,
|
||||
);
|
||||
return option ?? null;
|
||||
}
|
||||
|
||||
function findSelectConfigChoice({
|
||||
option,
|
||||
value,
|
||||
@@ -2389,6 +2517,43 @@ function flattenSelectOptions(options: SelectConfigOption["options"]): SelectCon
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function deriveConfigFeatureSelectOptions(
|
||||
option: SelectConfigOption,
|
||||
featureOption: ACPConfigFeatureOption,
|
||||
): ConfigOptionSelector[] {
|
||||
return flattenSelectOptions(option.options).map((choice) => ({
|
||||
id: choice.value,
|
||||
label: normalizeConfigFeatureOptionLabel(choice, featureOption),
|
||||
description: choice.description ?? undefined,
|
||||
isDefault: choice.value === option.currentValue,
|
||||
metadata: choice.group ? { group: choice.group } : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeConfigFeatureOptionLabel(
|
||||
choice: SelectConfigChoice,
|
||||
featureOption: ACPConfigFeatureOption,
|
||||
): string {
|
||||
const name = choice.name.trim();
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
if (choice.value === "" && featureOption.emptyOptionLabel) {
|
||||
return featureOption.emptyOptionLabel;
|
||||
}
|
||||
return choice.value;
|
||||
}
|
||||
|
||||
function normalizeConfigFeatureValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (value === null) {
|
||||
return "";
|
||||
}
|
||||
throw new Error(`ACP feature value must be a string`);
|
||||
}
|
||||
|
||||
function deriveSelectorOptions(
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
category: string,
|
||||
|
||||
@@ -1046,7 +1046,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
interface QueryFactoryForTurnsOptions {
|
||||
currentContextUsageByTurn?: Array<Record<string, unknown> | undefined>;
|
||||
getContextUsage?: ReturnType<typeof vi.fn>;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
@@ -1091,8 +1091,8 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
const queuedMessages: Array<Record<string, unknown>> = [];
|
||||
const waiters: Array<() => void> = [];
|
||||
let turnIndex = 0;
|
||||
let contextUsageIndex = 0;
|
||||
const closedRef = { value: false };
|
||||
const getContextUsage = options?.getContextUsage ?? vi.fn(async () => undefined);
|
||||
|
||||
function wakeNextWaiter() {
|
||||
const waiter = waiters.shift();
|
||||
@@ -1140,11 +1140,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
getContextUsage: vi.fn(async () => {
|
||||
const usage = options?.currentContextUsageByTurn?.[contextUsageIndex];
|
||||
contextUsageIndex += 1;
|
||||
return usage;
|
||||
}),
|
||||
getContextUsage,
|
||||
supportedModels: vi.fn(async () => []),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
@@ -1165,26 +1161,6 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createClaudeCurrentContextUsage(
|
||||
totalTokens: number,
|
||||
maxTokens: number,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
categories: [],
|
||||
totalTokens,
|
||||
maxTokens,
|
||||
rawMaxTokens: maxTokens,
|
||||
percentage: totalTokens / maxTokens,
|
||||
gridRows: [],
|
||||
model: "claude-sonnet-4-6",
|
||||
memoryFiles: [],
|
||||
mcpTools: [],
|
||||
agents: [],
|
||||
isAutoCompactEnabled: true,
|
||||
apiUsage: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createSuccessResult(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
type: "result",
|
||||
@@ -1282,6 +1258,21 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createCompactBoundary(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "compact_boundary",
|
||||
compact_metadata: {
|
||||
trigger: "manual",
|
||||
pre_tokens: 14_990,
|
||||
post_tokens: 704,
|
||||
},
|
||||
uuid: "compact-boundary-1",
|
||||
session_id: "session-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("passes persistSession through to the Claude SDK query options", async () => {
|
||||
const createResultTurn = (sessionId: string) => [
|
||||
{
|
||||
@@ -1556,7 +1547,10 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("reports Claude's current context usage after an Agent subagent runs", async () => {
|
||||
test("does not probe current context usage after an Agent subagent runs", async () => {
|
||||
const getContextUsage = vi.fn(async () => {
|
||||
throw new Error("getContextUsage should not be called during result handling");
|
||||
});
|
||||
const session = await createSessionForTurns(
|
||||
[
|
||||
[
|
||||
@@ -1575,21 +1569,20 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}),
|
||||
],
|
||||
],
|
||||
{
|
||||
currentContextUsageByTurn: [createClaudeCurrentContextUsage(12_345, 200_000)],
|
||||
},
|
||||
{ getContextUsage },
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await session.run("turn");
|
||||
|
||||
expect(getContextUsage).not.toHaveBeenCalled();
|
||||
expect(result.usage).toEqual({
|
||||
inputTokens: 9_000,
|
||||
cachedInputTokens: 700,
|
||||
outputTokens: 400,
|
||||
totalCostUsd: 0.25,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 12_345,
|
||||
contextWindowUsedTokens: 175,
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
@@ -1637,6 +1630,121 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("uses parent request usage after a real subagent tool result", async () => {
|
||||
const getContextUsage = vi.fn(async () => {
|
||||
throw new Error("getContextUsage should not be called during result handling");
|
||||
});
|
||||
const session = await createSessionForTurns(
|
||||
[
|
||||
[
|
||||
createInitMessage(),
|
||||
createMessageStartEvent({
|
||||
input_tokens: 3,
|
||||
cache_creation_input_tokens: 16_999,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
createAgentToolStartEvent(),
|
||||
createMessageDeltaEvent(163),
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu-agent-1",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "SUBAGENT_OK" }],
|
||||
usage: {
|
||||
input_tokens: 3,
|
||||
cache_creation_input_tokens: 1_182,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 8,
|
||||
},
|
||||
},
|
||||
uuid: "subagent-assistant-1",
|
||||
session_id: "session-1",
|
||||
},
|
||||
{
|
||||
...createSubagentTaskNotification(),
|
||||
status: "completed",
|
||||
summary: "Probe subagent test",
|
||||
usage: {
|
||||
total_tokens: 1_193,
|
||||
tool_uses: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu-agent-1",
|
||||
content: [
|
||||
{ type: "text", text: "SUBAGENT_OK" },
|
||||
{
|
||||
type: "text",
|
||||
text: "agentId: subagent-1\n<usage>subagent_tokens: 1194\ntool_uses: 0</usage>",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
uuid: "subagent-tool-result-1",
|
||||
session_id: "session-1",
|
||||
},
|
||||
createMessageStartEvent({
|
||||
input_tokens: 1,
|
||||
cache_creation_input_tokens: 253,
|
||||
cache_read_input_tokens: 16_999,
|
||||
}),
|
||||
createMessageDeltaEvent(8),
|
||||
createSuccessResult({
|
||||
usage: {
|
||||
input_tokens: 4,
|
||||
cache_creation_input_tokens: 17_252,
|
||||
cache_read_input_tokens: 16_999,
|
||||
output_tokens: 171,
|
||||
iterations: [
|
||||
{
|
||||
input_tokens: 1,
|
||||
cache_creation_input_tokens: 253,
|
||||
cache_read_input_tokens: 16_999,
|
||||
output_tokens: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
modelUsage: {
|
||||
"claude-sonnet-4-6": {
|
||||
inputTokens: 7,
|
||||
outputTokens: 180,
|
||||
cacheReadInputTokens: 16_999,
|
||||
cacheCreationInputTokens: 18_434,
|
||||
contextWindow: 200_000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
],
|
||||
{ getContextUsage },
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await session.run("turn");
|
||||
|
||||
expect(getContextUsage).not.toHaveBeenCalled();
|
||||
expect(result.usage).toEqual({
|
||||
inputTokens: 4,
|
||||
cachedInputTokens: 16_999,
|
||||
outputTokens: 171,
|
||||
totalCostUsd: 0.25,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 17_261,
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to the active result iteration when current and stream usage are unavailable", async () => {
|
||||
const session = await createSessionForTurns([
|
||||
[
|
||||
@@ -1842,6 +1950,165 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("manual compact boundary updates context usage from post tokens", async () => {
|
||||
const session = await createSessionForTurns([
|
||||
[
|
||||
createInitMessage(),
|
||||
createMessageStartEvent(),
|
||||
createMessageDeltaEvent(25),
|
||||
createCompactBoundary(),
|
||||
createSuccessResult({
|
||||
total_cost_usd: 0.04,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
const events = await collectStreamEvents(session, "/compact");
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowUsedTokens: 704,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCostUsd: 0.04,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 704,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("zero-token stream events after compact keep post-token usage", async () => {
|
||||
const session = await createSessionForTurns([
|
||||
[
|
||||
createInitMessage(),
|
||||
createMessageStartEvent(),
|
||||
createMessageDeltaEvent(25),
|
||||
createCompactBoundary(),
|
||||
createMessageStartEvent({
|
||||
input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}),
|
||||
createMessageDeltaEvent(0),
|
||||
createSuccessResult({
|
||||
total_cost_usd: 0.04,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
const events = await collectStreamEvents(session, "/compact");
|
||||
|
||||
expect(
|
||||
events.filter(
|
||||
(event) => event.type === "usage_updated" && event.usage.contextWindowUsedTokens === 0,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCostUsd: 0.04,
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 704,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("starting a new turn clears interrupted compact usage", async () => {
|
||||
const session = await createSessionForTurns([
|
||||
[
|
||||
createSuccessResult({
|
||||
total_cost_usd: 0.04,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents(
|
||||
createCompactBoundary(),
|
||||
);
|
||||
expect(compactEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowUsedTokens: 704,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const events = await collectStreamEvents(session, "next turn");
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
usage: expect.objectContaining({
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalCostUsd: 0.04,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.type === "turn_completed" && event.usage.contextWindowUsedTokens !== undefined,
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type PermissionResult,
|
||||
type PermissionUpdate,
|
||||
type Query,
|
||||
type SDKControlGetContextUsageResponse,
|
||||
type SDKMessage,
|
||||
type SDKPartialAssistantMessage,
|
||||
type SDKResultMessage,
|
||||
@@ -1682,31 +1681,6 @@ function readLegacyResultUsageTokens(usage: unknown): number | undefined {
|
||||
return usageRecord ? readUsageTokenTotal(usageRecord) : undefined;
|
||||
}
|
||||
|
||||
interface ClaudeCurrentContextUsage {
|
||||
totalTokens: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
function readCurrentContextUsage(
|
||||
value: SDKControlGetContextUsageResponse | unknown,
|
||||
): ClaudeCurrentContextUsage | undefined {
|
||||
const record = toObjectRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const totalTokens = record.totalTokens;
|
||||
if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const maxTokens = record.maxTokens;
|
||||
return {
|
||||
totalTokens,
|
||||
...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0
|
||||
? { maxTokens }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isClaudeSubagentToolName(name: string | undefined): boolean {
|
||||
return name === "Task" || name === "Agent";
|
||||
}
|
||||
@@ -1715,6 +1689,7 @@ class ClaudeContextUsageState {
|
||||
private contextWindowMaxTokens: number | undefined;
|
||||
private streamRequestInputTokens: number | undefined;
|
||||
private streamRequestOutputTokens: number | undefined;
|
||||
private compactedContextWindowUsedTokens: number | undefined;
|
||||
private completedResultTurns = 0;
|
||||
|
||||
constructor(initialContextWindowMaxTokens?: number) {
|
||||
@@ -1724,6 +1699,7 @@ class ClaudeContextUsageState {
|
||||
beginTurn(): void {
|
||||
this.streamRequestInputTokens = undefined;
|
||||
this.streamRequestOutputTokens = undefined;
|
||||
this.compactedContextWindowUsedTokens = undefined;
|
||||
}
|
||||
|
||||
setInitialContextWindowMaxTokens(contextWindowMaxTokens: number | undefined): void {
|
||||
@@ -1738,12 +1714,6 @@ class ClaudeContextUsageState {
|
||||
return this.contextWindowMaxTokens;
|
||||
}
|
||||
|
||||
recordCurrentContextUsage(usage: ClaudeCurrentContextUsage | undefined): void {
|
||||
if (usage?.maxTokens !== undefined) {
|
||||
this.contextWindowMaxTokens = usage.maxTokens;
|
||||
}
|
||||
}
|
||||
|
||||
buildStreamUsageEvent(event: unknown): AgentStreamEvent | null {
|
||||
const streamEvent = toObjectRecord(event);
|
||||
if (!streamEvent) {
|
||||
@@ -1774,11 +1744,7 @@ class ClaudeContextUsageState {
|
||||
return this.createUsageUpdatedEvent(usedTokens);
|
||||
}
|
||||
|
||||
buildResultUsage(
|
||||
message: SDKResultMessage,
|
||||
modelUsage: unknown,
|
||||
currentContextUsage: ClaudeCurrentContextUsage | undefined,
|
||||
): AgentUsage | undefined {
|
||||
buildResultUsage(message: SDKResultMessage, modelUsage: unknown): AgentUsage | undefined {
|
||||
try {
|
||||
if (!message.usage) {
|
||||
return undefined;
|
||||
@@ -1791,7 +1757,6 @@ class ClaudeContextUsageState {
|
||||
};
|
||||
|
||||
const modelContextWindowMaxTokens = this.recordModelUsage(modelUsage ?? message.modelUsage);
|
||||
this.recordCurrentContextUsage(currentContextUsage);
|
||||
if (this.contextWindowMaxTokens !== undefined) {
|
||||
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
|
||||
} else if (modelContextWindowMaxTokens !== undefined) {
|
||||
@@ -1802,12 +1767,13 @@ class ClaudeContextUsageState {
|
||||
readActiveUsageTokens(message.usage) ??
|
||||
(this.completedResultTurns === 0 ? readLegacyResultUsageTokens(message.usage) : undefined);
|
||||
const usedTokens =
|
||||
currentContextUsage?.totalTokens ?? this.streamUsedTokens() ?? activeResultUsageTokens;
|
||||
this.streamUsedTokens() ?? activeResultUsageTokens ?? this.compactedContextWindowUsedTokens;
|
||||
if (usedTokens !== undefined) {
|
||||
usage.contextWindowUsedTokens = usedTokens;
|
||||
}
|
||||
return usage;
|
||||
} finally {
|
||||
this.compactedContextWindowUsedTokens = undefined;
|
||||
this.completedResultTurns += 1;
|
||||
}
|
||||
}
|
||||
@@ -1819,7 +1785,8 @@ class ClaudeContextUsageState {
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return this.streamRequestInputTokens + this.streamRequestOutputTokens;
|
||||
const usedTokens = this.streamRequestInputTokens + this.streamRequestOutputTokens;
|
||||
return usedTokens > 0 ? usedTokens : undefined;
|
||||
}
|
||||
|
||||
private createUsageUpdatedEvent(contextWindowUsedTokens: number): AgentStreamEvent {
|
||||
@@ -1835,6 +1802,24 @@ class ClaudeContextUsageState {
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
buildCompactionUsageEvent(postTokens: number | undefined): AgentStreamEvent {
|
||||
this.streamRequestInputTokens = undefined;
|
||||
this.streamRequestOutputTokens = undefined;
|
||||
this.compactedContextWindowUsedTokens = postTokens;
|
||||
const usage: AgentUsage = {};
|
||||
if (this.contextWindowMaxTokens !== undefined) {
|
||||
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
|
||||
}
|
||||
if (postTokens !== undefined) {
|
||||
usage.contextWindowUsedTokens = postTokens;
|
||||
}
|
||||
return {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ClaudeAgentSession implements AgentSession {
|
||||
@@ -3282,7 +3267,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) {
|
||||
return true;
|
||||
}
|
||||
await this.routeSdkMessageFromPump(message, activeQuery);
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
return false;
|
||||
};
|
||||
const drainActiveQuery = async (): Promise<boolean> => {
|
||||
@@ -3358,7 +3343,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private async routeSdkMessageFromPump(message: SDKMessage, activeQuery: Query): Promise<void> {
|
||||
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
|
||||
if (this.shouldSuppressStaleResult(message)) {
|
||||
return;
|
||||
}
|
||||
@@ -3388,12 +3373,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
"provider.claude.parsed_event",
|
||||
);
|
||||
|
||||
const events = await this.buildPumpedMessageEvents(
|
||||
message,
|
||||
activeQuery,
|
||||
identifiers.messageId,
|
||||
turnId,
|
||||
);
|
||||
const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
|
||||
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
@@ -3430,18 +3410,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
private async buildPumpedMessageEvents(
|
||||
message: SDKMessage,
|
||||
activeQuery: Query,
|
||||
messageIdHint: string | null,
|
||||
turnId: string | null,
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const currentContextUsage =
|
||||
message.type === "result" && message.subtype === "success"
|
||||
? await this.queryCurrentContextUsage(activeQuery)
|
||||
: undefined;
|
||||
const messageEvents = this.translateMessageToEvents(message, {
|
||||
suppressAssistantText: true,
|
||||
suppressReasoning: true,
|
||||
currentContextUsage,
|
||||
});
|
||||
const assistantTimelineEvents = this.timelineAssembler
|
||||
.consume({
|
||||
@@ -3461,18 +3435,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return [...messageEvents, ...assistantTimelineEvents];
|
||||
}
|
||||
|
||||
private async queryCurrentContextUsage(
|
||||
activeQuery: Query,
|
||||
): Promise<ClaudeCurrentContextUsage | undefined> {
|
||||
try {
|
||||
const usage = await withTimeout(activeQuery.getContextUsage(), 3_000, "timeout");
|
||||
return readCurrentContextUsage(usage);
|
||||
} catch (error) {
|
||||
this.logger.debug({ err: error }, "Claude context usage query failed");
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMissingResumedConversation(
|
||||
message: SDKMessage,
|
||||
activeQuery: Query,
|
||||
@@ -3540,7 +3502,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
options?: {
|
||||
suppressAssistantText?: boolean;
|
||||
suppressReasoning?: boolean;
|
||||
currentContextUsage?: ClaudeCurrentContextUsage;
|
||||
},
|
||||
): AgentStreamEvent[] {
|
||||
const parentToolUseId =
|
||||
@@ -3591,9 +3552,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.appendStreamEventEvents(message, events, options);
|
||||
break;
|
||||
case "result":
|
||||
this.appendResultEvents(message, events, {
|
||||
currentContextUsage: options?.currentContextUsage,
|
||||
});
|
||||
this.appendResultEvents(message, events);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -3667,6 +3626,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
},
|
||||
provider: "claude",
|
||||
});
|
||||
events.push(this.contextUsage.buildCompactionUsageEvent(compactMetadata?.postTokens));
|
||||
return;
|
||||
}
|
||||
if (message.subtype === "task_notification") {
|
||||
@@ -3794,9 +3754,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private appendResultEvents(
|
||||
message: Extract<SDKMessage, { type: "result" }>,
|
||||
events: AgentStreamEvent[],
|
||||
options?: { currentContextUsage?: ClaudeCurrentContextUsage },
|
||||
): void {
|
||||
const usage = this.convertUsage(message, message.modelUsage, options?.currentContextUsage);
|
||||
const usage = this.convertUsage(message, message.modelUsage);
|
||||
if (message.subtype === "success") {
|
||||
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
||||
// run client-side in the Claude CLI with no model turn — output_tokens
|
||||
@@ -3963,12 +3922,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return null;
|
||||
}
|
||||
|
||||
private convertUsage(
|
||||
message: SDKResultMessage,
|
||||
modelUsage?: unknown,
|
||||
currentContextUsage?: ClaudeCurrentContextUsage,
|
||||
): AgentUsage | undefined {
|
||||
return this.contextUsage.buildResultUsage(message, modelUsage, currentContextUsage);
|
||||
private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
|
||||
return this.contextUsage.buildResultUsage(message, modelUsage);
|
||||
}
|
||||
|
||||
private handlePermissionRequest: CanUseTool = async (
|
||||
@@ -4869,7 +4824,9 @@ function hasToolLikeBlock(block?: ClaudeContentChunk | null): boolean {
|
||||
return type.includes("tool");
|
||||
}
|
||||
|
||||
function readCompactionMetadata(source: unknown): { trigger?: string; preTokens?: number } | null {
|
||||
function readCompactionMetadata(
|
||||
source: unknown,
|
||||
): { trigger?: string; preTokens?: number; postTokens?: number } | null {
|
||||
const sourceRecord = toObjectRecord(source);
|
||||
if (!sourceRecord) {
|
||||
return null;
|
||||
@@ -4887,7 +4844,9 @@ function readCompactionMetadata(source: unknown): { trigger?: string; preTokens?
|
||||
const trigger = typeof metadata.trigger === "string" ? metadata.trigger : undefined;
|
||||
const preTokensRaw = metadata.preTokens ?? metadata.pre_tokens;
|
||||
const preTokens = typeof preTokensRaw === "number" ? preTokensRaw : undefined;
|
||||
return { trigger, preTokens };
|
||||
const postTokensRaw = metadata.postTokens ?? metadata.post_tokens;
|
||||
const postTokens = typeof postTokensRaw === "number" ? postTokensRaw : undefined;
|
||||
return { trigger, preTokens, postTokens };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../provider-launch-config.js";
|
||||
import {
|
||||
ACPAgentClient,
|
||||
type ACPConfigFeatureOption,
|
||||
type ACPBeforeModeWriteResult,
|
||||
type ACPProviderModeWriteResult,
|
||||
type ACPProviderModeWriterContext,
|
||||
@@ -44,6 +45,16 @@ const COPILOT_ALLOW_ALL_ON = "on";
|
||||
const COPILOT_ALLOW_ALL_OFF = "off";
|
||||
type SelectConfigOption = Extract<SessionConfigOption, { type: "select" }>;
|
||||
|
||||
export const COPILOT_AGENT_FEATURE_OPTION: ACPConfigFeatureOption = {
|
||||
id: "agent",
|
||||
configId: "agent",
|
||||
category: "_agent",
|
||||
label: "Agent",
|
||||
description: "Use a Copilot custom agent profile",
|
||||
tooltip: "Select Copilot agent",
|
||||
emptyOptionLabel: "Default",
|
||||
};
|
||||
|
||||
export const COPILOT_MODES: AgentMode[] = [
|
||||
{
|
||||
id: COPILOT_AGENT_MODE_ID,
|
||||
@@ -77,6 +88,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
defaultModes: COPILOT_MODES,
|
||||
sessionResponseTransformer: transformCopilotSessionResponse,
|
||||
configOptionsTransformer: transformCopilotConfigOptions,
|
||||
configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION],
|
||||
modeIdTransformer: transformCopilotModeId,
|
||||
providerModeWriter: writeCopilotProviderMode,
|
||||
beforeModeWriter: beforeCopilotModeWriter,
|
||||
|
||||
@@ -6,8 +6,8 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
idleEvent,
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
|
||||
interface MockOpenCodeClientOptions {
|
||||
agents?: unknown[];
|
||||
@@ -15,7 +15,7 @@ interface MockOpenCodeClientOptions {
|
||||
}
|
||||
|
||||
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.appAgentsResponse = { data: options.agents ?? [] };
|
||||
openCodeClient.sessionPromptAsyncEvents = options.events ?? [idleEvent()];
|
||||
@@ -77,7 +77,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
|
||||
|
||||
expect(modes.map((mode) => mode.id)).toEqual(["build", "paseo-custom"]);
|
||||
@@ -86,7 +89,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
test("falls back to default OpenCode modes when discovery returns no modes", async () => {
|
||||
const { runtime } = mockOpenCodeClient({ agents: [] });
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
|
||||
|
||||
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan"]);
|
||||
@@ -95,7 +101,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
test("lists auto accept as a provider feature", async () => {
|
||||
const { runtime } = mockOpenCodeClient();
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const enabledFeatures = await client.listFeatures({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -121,7 +130,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
test("keeps legacy full-access as an alias for build plus auto accept", async () => {
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient();
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -251,7 +263,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -279,7 +294,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -322,7 +340,10 @@ describe("OpenCode auto_accept feature", () => {
|
||||
});
|
||||
const receivedEvents: AgentStreamEvent[] = [];
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
|
||||
@@ -4,8 +4,8 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -14,7 +14,7 @@ afterEach(() => {
|
||||
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListImplementation = () =>
|
||||
new Promise((resolve) => {
|
||||
@@ -40,7 +40,10 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
|
||||
});
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const modelsPromise = client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
@@ -57,8 +60,8 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
|
||||
expect(openCodeClient.calls.providerList).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("passes explicit refresh force through server acquisition", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
test("uses a new server for explicit catalog refresh", async () => {
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -68,17 +71,20 @@ test("passes explicit refresh force through server acquisition", async () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: true });
|
||||
|
||||
expect(runtime.acquisitions).toEqual([{ force: true, releaseCount: 1 }]);
|
||||
expect(runtime.acquisitions).toEqual([{ kind: "new", releaseCount: 1 }]);
|
||||
});
|
||||
|
||||
test("includes models from api-source providers not in connected", async () => {
|
||||
// Providers with source "api" are managed by the OpenCode console/subscription.
|
||||
// They don't appear in `connected` but are fully usable.
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -100,7 +106,10 @@ test("includes models from api-source providers not in connected", async () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const { models } = await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
|
||||
|
||||
expect(models).toMatchObject([
|
||||
@@ -113,7 +122,7 @@ test("includes models from api-source providers not in connected", async () => {
|
||||
});
|
||||
|
||||
test("throws when no providers are accessible (neither connected nor api-source)", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -132,7 +141,10 @@ test("throws when no providers are accessible (neither connected nor api-source)
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow(
|
||||
"OpenCode has no connected providers",
|
||||
@@ -140,7 +152,7 @@ test("throws when no providers are accessible (neither connected nor api-source)
|
||||
});
|
||||
|
||||
test("does not throw when only api-source providers are present with no connected providers", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -159,7 +171,10 @@ test("does not throw when only api-source providers are present with no connecte
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }),
|
||||
|
||||
@@ -5,11 +5,11 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
idleEvent,
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
|
||||
function mockOpenCodeClient(events: unknown[]) {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.sessionPromptAsyncEvents = events;
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
@@ -35,7 +35,10 @@ function toolPermissionEvent(): unknown {
|
||||
describe("OpenCode permission actions", () => {
|
||||
test("allow always sends OpenCode's always reply", async () => {
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]);
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
@@ -63,7 +66,10 @@ describe("OpenCode permission actions", () => {
|
||||
|
||||
test("plain allow keeps the backward-compatible once reply", async () => {
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient([toolPermissionEvent(), idleEvent()]);
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
|
||||
@@ -5,8 +5,8 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import {
|
||||
idleEvent,
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
@@ -24,11 +24,14 @@ function createDeferred<T>(): {
|
||||
|
||||
describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
test("lists only OpenCode built-in slash commands Paseo can execute", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
await expect(session.listCommands?.()).resolves.toEqual(
|
||||
@@ -49,11 +52,14 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
});
|
||||
|
||||
test("executes compact through the OpenCode summarize endpoint", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
await expect(session.run("/compact")).resolves.toMatchObject({
|
||||
@@ -70,7 +76,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
|
||||
test("waits for SSE completion when slash commands hit a header timeout", async () => {
|
||||
const idleEventGate = createDeferred<void>();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
openCodeClient.sessionCommandError = new Error("fetch failed: Headers Timeout Error");
|
||||
openCodeClient.commandListResponse = {
|
||||
@@ -85,7 +91,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
})();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
const runPromise = session.run("/help");
|
||||
@@ -101,7 +110,7 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
});
|
||||
|
||||
test("leaves successful slash command turns open until OpenCode emits idle", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = createOpenCodeClientWithConnectedProvider();
|
||||
openCodeClient.sessionCommandEvents = [];
|
||||
openCodeClient.commandListResponse = {
|
||||
@@ -109,7 +118,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
|
||||
|
||||
const runPromise = session.run("/help");
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
import {
|
||||
TestOpenCodeClient,
|
||||
TestOpenCodeRuntime,
|
||||
} from "./opencode/test-utils/test-opencode-runtime.js";
|
||||
TestOpenCodeHarness,
|
||||
} from "./opencode/test-utils/test-opencode-harness.js";
|
||||
import type {
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
@@ -182,9 +182,12 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("creates a session with valid id and provider", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
runtime.enqueueClient(new TestOpenCodeClient());
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
expect(typeof session.id).toBe("string");
|
||||
@@ -197,11 +200,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("single turn completes with streaming deltas", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.sessionPromptAsyncEvents = assistantTurnEvents();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
const iterator = streamSession(session, "Say hello");
|
||||
@@ -230,11 +236,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("manual compact hides the generated summary text", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.sessionSummarizeEvents = manualCompactEvents();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
@@ -264,7 +273,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
}, 120_000);
|
||||
|
||||
test("fetchCatalog returns models with required fields", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.providerListResponse = {
|
||||
data: {
|
||||
@@ -296,7 +305,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
],
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const cwd = os.homedir();
|
||||
const catalog = await client.fetchCatalog({ cwd, force: false });
|
||||
|
||||
@@ -331,7 +343,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
}, 60_000);
|
||||
|
||||
test("limits concurrent OpenCode metadata requests across clients", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
let activeProviderListCalls = 0;
|
||||
let maxActiveProviderListCalls = 0;
|
||||
const response = {
|
||||
@@ -364,7 +376,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
}
|
||||
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from({ length: 12 }, (_, index) =>
|
||||
client.fetchCatalog({ cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), force: false }),
|
||||
@@ -376,9 +391,12 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("available modes include build and plan", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
runtime.enqueueClient(new TestOpenCodeClient());
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
const modes = await session.getAvailableModes();
|
||||
@@ -392,7 +410,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("custom agents defined in opencode.json appear in available modes", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.appAgentsResponse = {
|
||||
data: [
|
||||
@@ -408,7 +426,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
|
||||
const modes = await session.getAvailableModes();
|
||||
@@ -431,7 +452,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
|
||||
test("plan and build modes are sent to OpenCode as distinct runtime agents", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const planOpenCodeClient = new TestOpenCodeClient();
|
||||
planOpenCodeClient.sessionPromptAsyncEvents = assistantTurnEvents({ text: "Plan response" });
|
||||
const buildOpenCodeClient = new TestOpenCodeClient();
|
||||
@@ -468,7 +489,10 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
];
|
||||
runtime.enqueueClient(planOpenCodeClient);
|
||||
runtime.enqueueClient(buildOpenCodeClient);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
const planSession = await client.createSession({
|
||||
...buildConfig(cwd),
|
||||
@@ -861,11 +885,14 @@ describe("OpenCode adapter context-window normalization", () => {
|
||||
|
||||
describe("OpenCode adapter startTurn error handling", () => {
|
||||
test("dynamically adds injected MCP servers without config-backed connect", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
@@ -901,7 +928,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
|
||||
test("fails the turn when OpenCode reports MCP add failure in data payload", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
openCodeClient.mcpAddResponse = {
|
||||
data: {
|
||||
@@ -913,7 +940,10 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
@@ -1665,11 +1695,14 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
|
||||
describe("OpenCodeAgentClient env", () => {
|
||||
test("passes launch-context env to env-specific server acquisition", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await client.createSession(
|
||||
@@ -1686,7 +1719,7 @@ describe("OpenCodeAgentClient env", () => {
|
||||
await session.close();
|
||||
|
||||
expect(runtime.acquisitions[0]).toMatchObject({
|
||||
force: false,
|
||||
kind: "dedicated",
|
||||
env: {
|
||||
CHUNK14_PROBE: "expected",
|
||||
},
|
||||
@@ -1848,7 +1881,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
});
|
||||
|
||||
test("listImportableSessions returns rows without hydrating session messages", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
const cwd = "/workspace/repo";
|
||||
const otherCwd = "/workspace/other";
|
||||
@@ -1945,7 +1978,10 @@ describe("OpenCode persisted sessions", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const sessions = await client.listImportableSessions({ cwd, limit: 1 });
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
@@ -1965,7 +2001,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
});
|
||||
|
||||
test("importSession reads only the selected OpenCode session without listing", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const metadataClient = new TestOpenCodeClient();
|
||||
const resumedClient = new TestOpenCodeClient();
|
||||
const cwd = "/workspace/repo";
|
||||
@@ -2004,7 +2040,10 @@ describe("OpenCode persisted sessions", () => {
|
||||
runtime.enqueueClient(metadataClient);
|
||||
runtime.enqueueClient(resumedClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const imported = await client.importSession(
|
||||
{ providerHandleId: "ses_selected", cwd },
|
||||
{
|
||||
@@ -2041,7 +2080,7 @@ describe("OpenCode persisted sessions", () => {
|
||||
});
|
||||
|
||||
test("listImportableSessions matches Windows cwd paths with forward slashes", async () => {
|
||||
const runtime = new TestOpenCodeRuntime();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCodeClient = new TestOpenCodeClient();
|
||||
const requestedCwd = "C:/Users/Administrator/GhostFactory";
|
||||
const storedCwd = "C:\\Users\\Administrator\\GhostFactory";
|
||||
@@ -2064,7 +2103,10 @@ describe("OpenCode persisted sessions", () => {
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const sessions = await client.listImportableSessions({ cwd: requestedCwd, limit: 1 });
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage as OpenCodeAssistantMessage,
|
||||
type Event as OpenCodeEvent,
|
||||
type FilePartInput as OpenCodeFilePartInput,
|
||||
type GlobalSession as OpenCodeGlobalSession,
|
||||
type Message as OpenCodeMessage,
|
||||
type OpencodeClient,
|
||||
type OpencodeClientConfig,
|
||||
type Part as OpenCodePart,
|
||||
type Session as OpenCodeSession,
|
||||
type TextPartInput as OpenCodeTextPartInput,
|
||||
@@ -64,7 +66,10 @@ import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||
import { OpenCodeServerManager } from "./opencode/server-manager.js";
|
||||
import {
|
||||
OpenCodeServerManager,
|
||||
type OpenCodeServerManagerLike,
|
||||
} from "./opencode/server-manager.js";
|
||||
import {
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
@@ -75,11 +80,6 @@ import {
|
||||
import { runProviderTurn } from "./provider-runner.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||
import {
|
||||
createSdkOpenCodeClient,
|
||||
type OpenCodeRuntime,
|
||||
type OpenCodeServerAcquisition,
|
||||
} from "./opencode/runtime.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
|
||||
import type { ManagedProcessRegistry } from "../../managed-processes/managed-processes.js";
|
||||
@@ -1212,31 +1212,15 @@ export const __openCodeInternals = {
|
||||
};
|
||||
|
||||
interface OpenCodeAgentClientDeps {
|
||||
runtime?: OpenCodeRuntime;
|
||||
serverManager?: OpenCodeServerManagerLike;
|
||||
createClient?: OpenCodeClientFactory;
|
||||
managedProcesses?: ManagedProcessRegistry;
|
||||
}
|
||||
|
||||
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
|
||||
constructor(private readonly serverManager: OpenCodeServerManager) {}
|
||||
type OpenCodeClientFactory = (options: { baseUrl: string; directory: string }) => OpencodeClient;
|
||||
|
||||
async acquireServer(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
return this.serverManager.acquire(options);
|
||||
}
|
||||
|
||||
async ensureServerRunning(): Promise<{ port: number; url: string }> {
|
||||
return this.serverManager.ensureRunning();
|
||||
}
|
||||
|
||||
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
|
||||
return createSdkOpenCodeClient(options);
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await this.serverManager.shutdown();
|
||||
}
|
||||
function createSdkOpenCodeClient(options: { baseUrl: string; directory: string }): OpencodeClient {
|
||||
return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string });
|
||||
}
|
||||
|
||||
export class OpenCodeAgentClient implements AgentClient {
|
||||
@@ -1245,7 +1229,8 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
readonly resolveCreateConfig = resolveOpenCodeCreateConfig;
|
||||
readonly isCreateConfigUnattended = isOpenCodeCreateConfigUnattended;
|
||||
|
||||
private readonly runtime: OpenCodeRuntime;
|
||||
private readonly serverManager: OpenCodeServerManagerLike;
|
||||
private readonly createOpenCodeClient: OpenCodeClientFactory;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly modelContextWindows = new Map<string, number>();
|
||||
@@ -1257,13 +1242,12 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
) {
|
||||
this.logger = logger.child({ module: "agent", provider: "opencode" });
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
this.runtime =
|
||||
deps.runtime ??
|
||||
new ProductionOpenCodeRuntime(
|
||||
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
|
||||
managedProcesses: deps.managedProcesses,
|
||||
}),
|
||||
);
|
||||
this.serverManager =
|
||||
deps.serverManager ??
|
||||
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
|
||||
managedProcesses: deps.managedProcesses,
|
||||
});
|
||||
this.createOpenCodeClient = deps.createClient ?? createSdkOpenCodeClient;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -1272,12 +1256,11 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.runtime.acquireServer({
|
||||
force: false,
|
||||
env: launchContext?.env,
|
||||
});
|
||||
const acquisition = launchContext?.env
|
||||
? await this.serverManager.acquireDedicated(launchContext.env)
|
||||
: await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -1334,9 +1317,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
cwd,
|
||||
};
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -1361,10 +1344,12 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
const acquisition = await this.runtime.acquireServer({ force: options.force });
|
||||
const acquisition = options.force
|
||||
? await this.serverManager.acquireNew()
|
||||
: await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const directory = options.cwd;
|
||||
const client = this.runtime.createClient({ baseUrl: url, directory });
|
||||
const client = this.createOpenCodeClient({ baseUrl: url, directory });
|
||||
|
||||
try {
|
||||
const [models, modes] = await Promise.all([
|
||||
@@ -1379,9 +1364,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
|
||||
async listCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
|
||||
const openCodeConfig = this.assertConfig(config);
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
@@ -1400,9 +1385,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
async listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]> {
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: options?.cwd ?? "",
|
||||
});
|
||||
@@ -1415,9 +1400,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
|
||||
const acquisition = await this.runtime.acquireServer({ force: false });
|
||||
const acquisition = await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const client = this.runtime.createClient({
|
||||
const client = this.createOpenCodeClient({
|
||||
baseUrl: url,
|
||||
directory: input.cwd,
|
||||
});
|
||||
@@ -1460,7 +1445,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await this.runtime.shutdown();
|
||||
await this.serverManager.shutdown();
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type {
|
||||
@@ -17,12 +17,16 @@ import {
|
||||
type OpenCodeServerProcessSpawner,
|
||||
} from "./opencode/server-manager.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("OpenCodeServerManager generations", () => {
|
||||
test("rotation creates a new current server without killing a referenced old server", async () => {
|
||||
const { manager, runtime } = createTestManager([4101, 4102]);
|
||||
|
||||
const oldAcquisition = await manager.acquire({ force: false });
|
||||
const newAcquisition = await manager.acquire({ force: true });
|
||||
const oldAcquisition = await manager.acquireCurrent();
|
||||
const newAcquisition = await manager.acquireNew();
|
||||
|
||||
expect(oldAcquisition.server.url).toBe("http://127.0.0.1:4101");
|
||||
expect(newAcquisition.server.url).toBe("http://127.0.0.1:4102");
|
||||
@@ -37,11 +41,11 @@ describe("OpenCodeServerManager generations", () => {
|
||||
test("new acquisitions after rotation use the new server", async () => {
|
||||
const { manager, runtime } = createTestManager([4201, 4202]);
|
||||
|
||||
const oldAcquisition = await manager.acquire({ force: false });
|
||||
const rotatedAcquisition = await manager.acquire({ force: true });
|
||||
const oldAcquisition = await manager.acquireCurrent();
|
||||
const rotatedAcquisition = await manager.acquireNew();
|
||||
rotatedAcquisition.release();
|
||||
|
||||
const nextAcquisition = await manager.acquire({ force: false });
|
||||
const nextAcquisition = await manager.acquireCurrent();
|
||||
|
||||
expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4202");
|
||||
expect(runtime.terminatedPorts).toEqual([]);
|
||||
@@ -50,15 +54,15 @@ describe("OpenCodeServerManager generations", () => {
|
||||
oldAcquisition.release();
|
||||
});
|
||||
|
||||
test("concurrent forced acquisitions share one fresh generation", async () => {
|
||||
test("concurrent new-server acquisitions share one fresh generation", async () => {
|
||||
const { manager, runtime } = createTestManager([4251, 4252, 4253]);
|
||||
|
||||
const initialAcquisition = await manager.acquire({ force: false });
|
||||
const initialAcquisition = await manager.acquireCurrent();
|
||||
initialAcquisition.release();
|
||||
|
||||
const [modelsAcquisition, modesAcquisition] = await Promise.all([
|
||||
manager.acquire({ force: true }),
|
||||
manager.acquire({ force: true }),
|
||||
manager.acquireNew(),
|
||||
manager.acquireNew(),
|
||||
]);
|
||||
|
||||
expect(modelsAcquisition.server.url).toBe("http://127.0.0.1:4252");
|
||||
@@ -72,8 +76,8 @@ describe("OpenCodeServerManager generations", () => {
|
||||
test("release is idempotent", async () => {
|
||||
const { manager, runtime } = createTestManager([4301, 4302]);
|
||||
|
||||
const oldAcquisition = await manager.acquire({ force: false });
|
||||
const newAcquisition = await manager.acquire({ force: true });
|
||||
const oldAcquisition = await manager.acquireCurrent();
|
||||
const newAcquisition = await manager.acquireNew();
|
||||
newAcquisition.release();
|
||||
|
||||
oldAcquisition.release();
|
||||
@@ -85,8 +89,8 @@ describe("OpenCodeServerManager generations", () => {
|
||||
test("shutdown kills current and retired servers", async () => {
|
||||
const { manager, runtime } = createTestManager([4401, 4402]);
|
||||
|
||||
await manager.acquire({ force: false });
|
||||
await manager.acquire({ force: true });
|
||||
await manager.acquireCurrent();
|
||||
await manager.acquireNew();
|
||||
|
||||
await manager.shutdown();
|
||||
|
||||
@@ -96,7 +100,7 @@ describe("OpenCodeServerManager generations", () => {
|
||||
test("shutdown still signals a process after an earlier kill signal if it has not exited", async () => {
|
||||
const { manager, runtime } = createTestManager([4451]);
|
||||
|
||||
await manager.acquire({ force: false });
|
||||
await manager.acquireCurrent();
|
||||
runtime.processForPort(4451).markKillSignalSent();
|
||||
|
||||
await manager.shutdown();
|
||||
@@ -104,13 +108,64 @@ describe("OpenCodeServerManager generations", () => {
|
||||
expect(runtime.terminatedPorts).toEqual([4451]);
|
||||
});
|
||||
|
||||
test("startup timeout kills the spawned server and removes its managed-process record", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { manager, runtime } = createTestManager([4471], { autoAnnounce: false });
|
||||
|
||||
const acquisition = manager.acquireCurrent();
|
||||
const failure = expect(acquisition).rejects.toThrow("OpenCode server startup timeout");
|
||||
await runtime.settle();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
await failure;
|
||||
expect(runtime.terminatedPorts).toEqual([4471]);
|
||||
expect(await runtime.managedProcesses.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("shutdown kills a server that is still starting", async () => {
|
||||
const { manager, runtime } = createTestManager([4472], { autoAnnounce: false });
|
||||
|
||||
const acquisition = manager.acquireCurrent();
|
||||
await runtime.settle();
|
||||
|
||||
await manager.shutdown();
|
||||
|
||||
await expect(acquisition).rejects.toThrow("OpenCode server exited with code null");
|
||||
expect(runtime.terminatedPorts).toEqual([4472]);
|
||||
expect(await runtime.managedProcesses.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("dedicated server startup is protected from retired cleanup", async () => {
|
||||
const { manager, runtime } = createTestManager([4473, 4474], { autoAnnounce: false });
|
||||
|
||||
const currentStart = manager.acquireCurrent();
|
||||
await runtime.settle();
|
||||
runtime.processForPort(4473).announceListening();
|
||||
const currentAcquisition = await currentStart;
|
||||
|
||||
const dedicatedStart = manager.acquireDedicated({ TEST_ENV: "custom" });
|
||||
await runtime.settle();
|
||||
|
||||
currentAcquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([]);
|
||||
|
||||
runtime.processForPort(4474).announceListening();
|
||||
const dedicatedAcquisition = await dedicatedStart;
|
||||
|
||||
expect(dedicatedAcquisition.server.url).toBe("http://127.0.0.1:4474");
|
||||
|
||||
dedicatedAcquisition.release();
|
||||
expect(runtime.terminatedPorts).toEqual([4474]);
|
||||
});
|
||||
|
||||
test("repeated rotations leave zero unreferenced retired servers", async () => {
|
||||
const { manager, runtime } = createTestManager([4501, 4502, 4503]);
|
||||
|
||||
const firstAcquisition = await manager.acquire({ force: false });
|
||||
const secondAcquisition = await manager.acquire({ force: true });
|
||||
const firstAcquisition = await manager.acquireCurrent();
|
||||
const secondAcquisition = await manager.acquireNew();
|
||||
secondAcquisition.release();
|
||||
const thirdAcquisition = await manager.acquire({ force: true });
|
||||
const thirdAcquisition = await manager.acquireNew();
|
||||
thirdAcquisition.release();
|
||||
firstAcquisition.release();
|
||||
|
||||
@@ -122,7 +177,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
|
||||
test("records helper server starts and removes the record on process exit", async () => {
|
||||
const { manager, runtime } = createTestManager([4601]);
|
||||
|
||||
await manager.acquire({ force: false });
|
||||
await manager.acquireCurrent();
|
||||
|
||||
expect(await runtime.managedProcesses.list()).toEqual([
|
||||
{
|
||||
@@ -146,7 +201,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
|
||||
test("removes helper server records on shutdown", async () => {
|
||||
const { manager, runtime } = createTestManager([4602]);
|
||||
|
||||
await manager.acquire({ force: false });
|
||||
await manager.acquireCurrent();
|
||||
|
||||
await manager.shutdown();
|
||||
|
||||
@@ -155,11 +210,16 @@ describe("OpenCodeServerManager managed process ledger", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createTestManager(ports: number[]): {
|
||||
function createTestManager(
|
||||
ports: number[],
|
||||
options: { autoAnnounce?: boolean } = {},
|
||||
): {
|
||||
manager: OpenCodeServerManager;
|
||||
runtime: FakeOpenCodeServerRuntime;
|
||||
} {
|
||||
const runtime = new FakeOpenCodeServerRuntime(ports);
|
||||
const runtime = new FakeOpenCodeServerRuntime(ports, {
|
||||
autoAnnounce: options.autoAnnounce ?? true,
|
||||
});
|
||||
return {
|
||||
manager: new OpenCodeServerManager({
|
||||
logger: createTestLogger(),
|
||||
@@ -177,11 +237,13 @@ class FakeOpenCodeServerRuntime {
|
||||
readonly managedProcesses = new FakeManagedProcesses();
|
||||
readonly terminatedPorts: number[] = [];
|
||||
private readonly ports: number[];
|
||||
private readonly autoAnnounce: boolean;
|
||||
private readonly processesByChild = new Map<ChildProcess, FakeOpenCodeProcess>();
|
||||
private readonly processesByPort = new Map<number, FakeOpenCodeProcess>();
|
||||
|
||||
constructor(ports: number[]) {
|
||||
constructor(ports: number[], options: { autoAnnounce: boolean }) {
|
||||
this.ports = [...ports];
|
||||
this.autoAnnounce = options.autoAnnounce;
|
||||
}
|
||||
|
||||
get launchedPorts(): number[] {
|
||||
@@ -206,7 +268,9 @@ class FakeOpenCodeServerRuntime {
|
||||
const process = new FakeOpenCodeProcess({ port, pid: 10_000 + port });
|
||||
this.processesByChild.set(process.child, process);
|
||||
this.processesByPort.set(port, process);
|
||||
queueMicrotask(() => process.announceListening());
|
||||
if (this.autoAnnounce) {
|
||||
queueMicrotask(() => process.announceListening());
|
||||
}
|
||||
return process.child;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type OpencodeClient,
|
||||
type OpencodeClientConfig,
|
||||
} from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
export interface OpenCodeServerAcquisition {
|
||||
server: { port: number; url: string };
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
export interface OpenCodeRuntime {
|
||||
acquireServer(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition>;
|
||||
ensureServerRunning(): Promise<{ port: number; url: string }>;
|
||||
createClient(options: { baseUrl: string; directory: string }): OpencodeClient;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createSdkOpenCodeClient(options: {
|
||||
baseUrl: string;
|
||||
directory: string;
|
||||
}): OpencodeClient {
|
||||
return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string });
|
||||
}
|
||||
@@ -23,10 +23,10 @@ export interface OpenCodeServerAcquisition {
|
||||
|
||||
export interface OpenCodeServerManagerLike {
|
||||
ensureRunning(): Promise<{ port: number; url: string }>;
|
||||
acquire(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition>;
|
||||
acquireCurrent(): Promise<OpenCodeServerAcquisition>;
|
||||
acquireNew(): Promise<OpenCodeServerAcquisition>;
|
||||
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface OpenCodeServerGeneration {
|
||||
@@ -35,7 +35,9 @@ export interface OpenCodeServerGeneration {
|
||||
url: string;
|
||||
refCount: number;
|
||||
retired: boolean;
|
||||
ready: Promise<void>;
|
||||
managedProcessId?: string;
|
||||
managedProcessRecord?: Promise<{ id: string } | null>;
|
||||
}
|
||||
|
||||
export type OpenCodePortAllocator = () => Promise<number>;
|
||||
@@ -62,7 +64,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
private currentServer: OpenCodeServerGeneration | null = null;
|
||||
private retiredServers = new Set<OpenCodeServerGeneration>();
|
||||
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private newServerPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly runtimeSettingsKey: string;
|
||||
@@ -127,26 +129,35 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
}
|
||||
|
||||
async ensureRunning(): Promise<{ port: number; url: string }> {
|
||||
const acquisition = await this.acquire({ force: false });
|
||||
const acquisition = await this.acquireCurrent();
|
||||
acquisition.release();
|
||||
return acquisition.server;
|
||||
}
|
||||
|
||||
async acquire(options: {
|
||||
force: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
if (options.env) {
|
||||
const server = await this.startDedicatedServer(options.env);
|
||||
return this.acquireServer(server);
|
||||
}
|
||||
|
||||
const server = options.force
|
||||
? await this.getForcedRefreshServer()
|
||||
: await this.getCurrentServer();
|
||||
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
|
||||
const server = await this.getCurrentServer();
|
||||
return this.acquireServer(server);
|
||||
}
|
||||
|
||||
async acquireNew(): Promise<OpenCodeServerAcquisition> {
|
||||
const server = await this.getNewServer();
|
||||
return this.acquireServer(server);
|
||||
}
|
||||
|
||||
async acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition> {
|
||||
const server = await this.startServer(env);
|
||||
server.retired = true;
|
||||
this.retiredServers.add(server);
|
||||
const acquisition = this.acquireServer(server);
|
||||
try {
|
||||
await server.ready;
|
||||
return acquisition;
|
||||
} catch (error) {
|
||||
acquisition.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition {
|
||||
server.refCount += 1;
|
||||
let released = false;
|
||||
@@ -163,41 +174,57 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
};
|
||||
}
|
||||
|
||||
private async getForcedRefreshServer(): Promise<OpenCodeServerGeneration> {
|
||||
if (this.forcedRefreshPromise) {
|
||||
return this.forcedRefreshPromise;
|
||||
private async getNewServer(): Promise<OpenCodeServerGeneration> {
|
||||
if (this.newServerPromise) {
|
||||
return this.newServerPromise;
|
||||
}
|
||||
|
||||
this.forcedRefreshPromise = Promise.resolve()
|
||||
this.newServerPromise = Promise.resolve()
|
||||
.then(async () => {
|
||||
await this.rotateCurrentServer();
|
||||
return this.getCurrentServer();
|
||||
const server = await this.startServer();
|
||||
if (!server.retired) {
|
||||
this.currentServer = server;
|
||||
}
|
||||
await server.ready;
|
||||
return server;
|
||||
})
|
||||
.finally(() => {
|
||||
this.forcedRefreshPromise = null;
|
||||
this.newServerPromise = null;
|
||||
});
|
||||
return this.forcedRefreshPromise;
|
||||
return this.newServerPromise;
|
||||
}
|
||||
|
||||
private async getCurrentServer(): Promise<OpenCodeServerGeneration> {
|
||||
if (this.newServerPromise) {
|
||||
return this.newServerPromise;
|
||||
}
|
||||
|
||||
if (this.startPromise) {
|
||||
return this.startPromise;
|
||||
const server = await this.startPromise;
|
||||
await server.ready;
|
||||
return server;
|
||||
}
|
||||
|
||||
if (this.currentServer && !this.currentServer.process.killed) {
|
||||
await this.currentServer.ready;
|
||||
return this.currentServer;
|
||||
}
|
||||
|
||||
this.startPromise = this.startServer();
|
||||
try {
|
||||
const result = await this.startPromise;
|
||||
if (!result.retired) {
|
||||
this.currentServer = result;
|
||||
this.startPromise = this.startServer().then((server) => {
|
||||
if (!server.retired) {
|
||||
this.currentServer = server;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
this.startPromise = null;
|
||||
}
|
||||
return server;
|
||||
});
|
||||
const currentStart = this.startPromise;
|
||||
const result = await currentStart.finally(() => {
|
||||
if (this.startPromise === currentStart) {
|
||||
this.startPromise = null;
|
||||
}
|
||||
});
|
||||
await result.ready;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async rotateCurrentServer(): Promise<void> {
|
||||
@@ -217,15 +244,6 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
}
|
||||
}
|
||||
|
||||
private async startDedicatedServer(
|
||||
env: Record<string, string>,
|
||||
): Promise<OpenCodeServerGeneration> {
|
||||
const server = await this.startServer(env);
|
||||
server.retired = true;
|
||||
this.retiredServers.add(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
private async startServer(launchEnv?: Record<string, string>): Promise<OpenCodeServerGeneration> {
|
||||
const port = await this.portAllocator();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
@@ -233,69 +251,86 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
const serverArgs = [...launchPrefix.args, "serve", "--port", String(port)];
|
||||
const serverCwd = os.homedir();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, {
|
||||
cwd: serverCwd,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
}),
|
||||
});
|
||||
const managedProcessRecord = this.recordManagedServerProcess({
|
||||
process: serverProcess,
|
||||
command: launchPrefix.command,
|
||||
args: serverArgs,
|
||||
port,
|
||||
});
|
||||
const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, {
|
||||
cwd: serverCwd,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
}),
|
||||
});
|
||||
const managedProcessRecord = this.recordManagedServerProcess({
|
||||
process: serverProcess,
|
||||
command: launchPrefix.command,
|
||||
args: serverArgs,
|
||||
port,
|
||||
});
|
||||
const server: OpenCodeServerGeneration = {
|
||||
process: serverProcess,
|
||||
port,
|
||||
url,
|
||||
refCount: 0,
|
||||
retired: false,
|
||||
ready: Promise.resolve(),
|
||||
managedProcessRecord,
|
||||
};
|
||||
void managedProcessRecord.then((record) => {
|
||||
if (record && server.managedProcessRecord === managedProcessRecord) {
|
||||
server.managedProcessId = record.id;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
let started = false;
|
||||
let stderrBuffer = "";
|
||||
let stdoutBuffer = "";
|
||||
const STARTUP_BUFFER_CAP = 8192;
|
||||
const appendCapped = (current: string, chunk: string): string => {
|
||||
if (current.length >= STARTUP_BUFFER_CAP) {
|
||||
return current;
|
||||
let started = false;
|
||||
let settled = false;
|
||||
let stderrBuffer = "";
|
||||
let stdoutBuffer = "";
|
||||
const STARTUP_BUFFER_CAP = 8192;
|
||||
const appendCapped = (current: string, chunk: string): string => {
|
||||
if (current.length >= STARTUP_BUFFER_CAP) {
|
||||
return current;
|
||||
}
|
||||
const remaining = STARTUP_BUFFER_CAP - current.length;
|
||||
return current + chunk.slice(0, remaining);
|
||||
};
|
||||
const buildStartupErrorMessage = (headline: string): string => {
|
||||
const sections = [headline];
|
||||
const stderrTrimmed = stderrBuffer.trim();
|
||||
if (stderrTrimmed.length > 0) {
|
||||
sections.push(`stderr: ${stderrTrimmed}`);
|
||||
}
|
||||
const stdoutTrimmed = stdoutBuffer.trim();
|
||||
if (stdoutTrimmed.length > 0) {
|
||||
sections.push(`stdout: ${stdoutTrimmed}`);
|
||||
}
|
||||
return sections.join("\n");
|
||||
};
|
||||
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
const failStartup = (error: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
const remaining = STARTUP_BUFFER_CAP - current.length;
|
||||
return current + chunk.slice(0, remaining);
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
};
|
||||
const buildStartupErrorMessage = (headline: string): string => {
|
||||
const sections = [headline];
|
||||
const stderrTrimmed = stderrBuffer.trim();
|
||||
if (stderrTrimmed.length > 0) {
|
||||
sections.push(`stderr: ${stderrTrimmed}`);
|
||||
}
|
||||
const stdoutTrimmed = stdoutBuffer.trim();
|
||||
if (stdoutTrimmed.length > 0) {
|
||||
sections.push(`stdout: ${stdoutTrimmed}`);
|
||||
}
|
||||
return sections.join("\n");
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
timeout = setTimeout(() => {
|
||||
if (!started) {
|
||||
reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
|
||||
failStartup(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
serverProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stdoutBuffer = appendCapped(stdoutBuffer, output);
|
||||
if (output.includes("listening on") && !started) {
|
||||
if (output.includes("listening on") && !settled) {
|
||||
started = true;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
void (async () => {
|
||||
const record = await managedProcessRecord;
|
||||
resolve({
|
||||
process: serverProcess,
|
||||
port,
|
||||
url,
|
||||
refCount: 0,
|
||||
retired: false,
|
||||
...(record ? { managedProcessId: record.id } : {}),
|
||||
});
|
||||
})();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -306,17 +341,16 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
});
|
||||
|
||||
serverProcess.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
this.removeManagedProcessRecordWhenResolved(managedProcessRecord);
|
||||
const headline = error instanceof Error ? error.message : String(error);
|
||||
reject(new Error(buildStartupErrorMessage(headline)));
|
||||
failStartup(new Error(buildStartupErrorMessage(headline)));
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
this.removeManagedProcessRecordWhenResolved(managedProcessRecord);
|
||||
this.removeManagedServerRecord(server);
|
||||
if (!started) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)));
|
||||
failStartup(
|
||||
new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)),
|
||||
);
|
||||
}
|
||||
if (this.currentServer?.process === serverProcess) {
|
||||
this.currentServer = null;
|
||||
@@ -328,6 +362,17 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.ready = ready.catch(async (error) => {
|
||||
await this.killServer(server);
|
||||
if (this.currentServer === server) {
|
||||
this.currentServer = null;
|
||||
}
|
||||
this.retiredServers.delete(server);
|
||||
throw error;
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
@@ -375,6 +420,9 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
if (server.managedProcessId) {
|
||||
await this.removeManagedProcessId(server.managedProcessId);
|
||||
server.managedProcessId = undefined;
|
||||
server.managedProcessRecord = undefined;
|
||||
} else {
|
||||
this.removeManagedServerRecord(server);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +463,19 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
});
|
||||
}
|
||||
|
||||
private removeManagedServerRecord(server: OpenCodeServerGeneration): void {
|
||||
const record = server.managedProcessRecord;
|
||||
server.managedProcessRecord = undefined;
|
||||
if (server.managedProcessId) {
|
||||
void this.removeManagedProcessId(server.managedProcessId);
|
||||
server.managedProcessId = undefined;
|
||||
return;
|
||||
}
|
||||
if (record) {
|
||||
this.removeManagedProcessRecordWhenResolved(record);
|
||||
}
|
||||
}
|
||||
|
||||
private async removeManagedProcessId(id: string): Promise<void> {
|
||||
try {
|
||||
await this.managedProcesses?.remove(id);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js";
|
||||
|
||||
export interface TestOpenCodeServerAcquisition {
|
||||
force: boolean;
|
||||
kind: "current" | "new" | "dedicated";
|
||||
env?: Record<string, string>;
|
||||
released: boolean;
|
||||
}
|
||||
@@ -16,14 +16,26 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
async acquire(options: {
|
||||
force: boolean;
|
||||
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "current" });
|
||||
}
|
||||
|
||||
async acquireNew(): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "new" });
|
||||
}
|
||||
|
||||
async acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "dedicated", env });
|
||||
}
|
||||
|
||||
private recordAcquisition(input: {
|
||||
kind: TestOpenCodeServerAcquisition["kind"];
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
}): OpenCodeServerAcquisition {
|
||||
const acquisition: TestOpenCodeServerAcquisition = {
|
||||
force: options.force,
|
||||
env: options.env,
|
||||
kind: input.kind,
|
||||
released: false,
|
||||
...(input.env ? { env: input.env } : {}),
|
||||
};
|
||||
this.acquisitions.push(acquisition);
|
||||
return {
|
||||
@@ -33,6 +45,8 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {}
|
||||
}
|
||||
|
||||
export function createTestOpenCodeServerManager(): TestOpenCodeServerManager {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import type { OpenCodeRuntime, OpenCodeServerAcquisition } from "../runtime.js";
|
||||
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "../server-manager.js";
|
||||
|
||||
interface OpenCodeResponse {
|
||||
data?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export class TestOpenCodeRuntime implements OpenCodeRuntime {
|
||||
export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
|
||||
readonly acquisitions: Array<{
|
||||
force: boolean;
|
||||
kind: "current" | "new" | "dedicated";
|
||||
env?: Record<string, string>;
|
||||
releaseCount: number;
|
||||
}> = [];
|
||||
@@ -22,11 +22,27 @@ export class TestOpenCodeRuntime implements OpenCodeRuntime {
|
||||
this.clients.push(client);
|
||||
}
|
||||
|
||||
async acquireServer(options: {
|
||||
force: boolean;
|
||||
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "current" });
|
||||
}
|
||||
|
||||
async acquireNew(): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "new" });
|
||||
}
|
||||
|
||||
async acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition> {
|
||||
return this.recordAcquisition({ kind: "dedicated", env });
|
||||
}
|
||||
|
||||
private recordAcquisition(input: {
|
||||
kind: "current" | "new" | "dedicated";
|
||||
env?: Record<string, string>;
|
||||
}): Promise<OpenCodeServerAcquisition> {
|
||||
const acquisition = { force: options.force, env: options.env, releaseCount: 0 };
|
||||
}): OpenCodeServerAcquisition {
|
||||
const acquisition = {
|
||||
kind: input.kind,
|
||||
releaseCount: 0,
|
||||
...(input.env ? { env: input.env } : {}),
|
||||
};
|
||||
this.acquisitions.push(acquisition);
|
||||
return {
|
||||
server: this.server,
|
||||
@@ -36,15 +52,15 @@ export class TestOpenCodeRuntime implements OpenCodeRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
async ensureServerRunning(): Promise<{ port: number; url: string }> {
|
||||
async ensureRunning(): Promise<{ port: number; url: string }> {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
|
||||
readonly createClient = (options: { baseUrl: string; directory: string }): OpencodeClient => {
|
||||
this.clientCreations.push(options);
|
||||
const client = this.clients.shift() ?? new TestOpenCodeClient();
|
||||
return client.asSdkClient();
|
||||
}
|
||||
};
|
||||
|
||||
async shutdown(): Promise<void> {}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import type { PiRuntime, PiRuntimeSession } from "./runtime.js";
|
||||
import type {
|
||||
PiAgentSessionEvent,
|
||||
PiAgentMessage,
|
||||
PiCommandsRpcType,
|
||||
PiImageContent,
|
||||
PiModel,
|
||||
PiRpcSlashCommand,
|
||||
@@ -154,6 +155,7 @@ interface PiRpcAgentClientOptions {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
providerParams?: unknown;
|
||||
commandsRpcType?: PiCommandsRpcType;
|
||||
runtime?: PiRuntime;
|
||||
}
|
||||
|
||||
@@ -967,8 +969,12 @@ function mapPiModel(model: PiModel): AgentModelDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntime(logger: Logger, runtimeSettings?: ProviderRuntimeSettings): PiRuntime {
|
||||
return new PiCliRuntime({ logger, runtimeSettings });
|
||||
function createRuntime(
|
||||
logger: Logger,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
commandsRpcType?: PiCommandsRpcType,
|
||||
): PiRuntime {
|
||||
return new PiCliRuntime({ logger, runtimeSettings, commandsRpcType });
|
||||
}
|
||||
|
||||
export class PiRpcAgentSession implements AgentSession {
|
||||
@@ -1866,7 +1872,9 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
this.logger = options.logger;
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.providerParams = PiProviderParamsSchema.parse(options.providerParams ?? {});
|
||||
this.runtime = options.runtime ?? createRuntime(options.logger, options.runtimeSettings);
|
||||
this.runtime =
|
||||
options.runtime ??
|
||||
createRuntime(options.logger, options.runtimeSettings, options.commandsRpcType);
|
||||
}
|
||||
|
||||
async createSession(
|
||||
|
||||
@@ -5,6 +5,7 @@ import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { PiCliRuntime } from "./cli-runtime.js";
|
||||
import type { PiCommandsRpcType } from "./rpc-types.js";
|
||||
import type { PiRuntimeLaunch } from "./runtime.js";
|
||||
|
||||
type PiChild = ChildProcessWithoutNullStreams & {
|
||||
@@ -31,10 +32,15 @@ function createPiChild(): PiChild {
|
||||
return child;
|
||||
}
|
||||
|
||||
function createRuntime(child: PiChild, launches: PiRuntimeLaunch[] = []): PiCliRuntime {
|
||||
function createRuntime(
|
||||
child: PiChild,
|
||||
launches: PiRuntimeLaunch[] = [],
|
||||
options?: { commandsRpcType?: PiCommandsRpcType },
|
||||
): PiCliRuntime {
|
||||
return new PiCliRuntime({
|
||||
logger: pino({ level: "silent" }),
|
||||
command: ["pi"],
|
||||
commandsRpcType: options?.commandsRpcType,
|
||||
spawnProcess: (launch) => {
|
||||
launches.push(launch);
|
||||
return child;
|
||||
@@ -184,6 +190,42 @@ describe("PiCliRuntime", () => {
|
||||
expect(events).toEqual([{ type: "turn_start" }]);
|
||||
});
|
||||
|
||||
test("lists commands through the default Pi get_commands RPC", async () => {
|
||||
const child = createPiChild();
|
||||
const commandTypes: string[] = [];
|
||||
replyToCommands(child, (command) => {
|
||||
commandTypes.push(String(command.type));
|
||||
return {
|
||||
commands: [{ name: "review", description: "Review changes", source: "extension" }],
|
||||
};
|
||||
});
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
await expect(session.getCommands()).resolves.toEqual([
|
||||
{ name: "review", description: "Review changes", source: "extension" },
|
||||
]);
|
||||
expect(commandTypes).toEqual(["get_commands"]);
|
||||
});
|
||||
|
||||
test("lists commands through the OMP-compatible get_available_commands RPC", async () => {
|
||||
const child = createPiChild();
|
||||
const commandTypes: string[] = [];
|
||||
replyToCommands(child, (command) => {
|
||||
commandTypes.push(String(command.type));
|
||||
return {
|
||||
commands: [{ name: "skill:ctx-stats", description: "Show context stats", source: "skill" }],
|
||||
};
|
||||
});
|
||||
const session = await createRuntime(child, [], {
|
||||
commandsRpcType: "get_available_commands",
|
||||
}).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
await expect(session.getCommands()).resolves.toEqual([
|
||||
{ name: "skill:ctx-stats", description: "Show context stats", source: "skill" },
|
||||
]);
|
||||
expect(commandTypes).toEqual(["get_available_commands"]);
|
||||
});
|
||||
|
||||
test("keeps unicode line separators inside one JSONL record", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, () => ({}));
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "./runtime.js";
|
||||
import type {
|
||||
PiAgentMessage,
|
||||
PiCommandsRpcType,
|
||||
PiModel,
|
||||
PiRpcCommand,
|
||||
PiRpcResponse,
|
||||
@@ -26,6 +27,7 @@ const DEFAULT_PI_COMMAND: [string, ...string[]] = [
|
||||
process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi",
|
||||
];
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_COMMANDS_RPC_TYPE: PiCommandsRpcType = "get_commands";
|
||||
const STDERR_BUFFER_LIMIT = 8192;
|
||||
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
@@ -48,15 +50,18 @@ export interface PiCliRuntimeOptions {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
command?: [string, ...string[]];
|
||||
commandsRpcType?: PiCommandsRpcType;
|
||||
spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
|
||||
}
|
||||
|
||||
export class PiCliRuntime implements PiRuntime {
|
||||
private readonly command: [string, ...string[]];
|
||||
private readonly commandsRpcType: PiCommandsRpcType;
|
||||
private readonly spawnProcess: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
|
||||
|
||||
constructor(private readonly options: PiCliRuntimeOptions) {
|
||||
this.command = options.command ?? DEFAULT_PI_COMMAND;
|
||||
this.commandsRpcType = options.commandsRpcType ?? DEFAULT_COMMANDS_RPC_TYPE;
|
||||
this.spawnProcess =
|
||||
options.spawnProcess ??
|
||||
((launch) => {
|
||||
@@ -77,7 +82,12 @@ export class PiCliRuntime implements PiRuntime {
|
||||
runtimeSettings: this.options.runtimeSettings,
|
||||
session: input,
|
||||
});
|
||||
return new PiCliRuntimeSession(launch, this.spawnProcess(launch), this.options.logger);
|
||||
return new PiCliRuntimeSession(
|
||||
launch,
|
||||
this.spawnProcess(launch),
|
||||
this.options.logger,
|
||||
this.commandsRpcType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +103,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
_launch: PiRuntimeLaunch,
|
||||
private readonly child: ChildProcessWithoutNullStreams,
|
||||
private readonly logger: Logger,
|
||||
private readonly commandsRpcType: PiCommandsRpcType,
|
||||
) {
|
||||
child.stdout.on("data", (chunk) => {
|
||||
this.handleStdoutChunk(chunk.toString());
|
||||
@@ -173,7 +184,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
}
|
||||
|
||||
async getCommands(): Promise<PiRpcSlashCommand[]> {
|
||||
const data = (await this.request({ type: "get_commands" })) as {
|
||||
const data = (await this.request({ type: this.commandsRpcType })) as {
|
||||
commands?: PiRpcSlashCommand[];
|
||||
};
|
||||
return data.commands ?? [];
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface PiRpcSlashCommand {
|
||||
sourceInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PiCommandsRpcType = "get_commands" | "get_available_commands";
|
||||
|
||||
export type PiRpcCommand =
|
||||
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
|
||||
| { id?: string; type: "compact"; customInstructions?: string }
|
||||
@@ -121,7 +123,7 @@ export type PiRpcCommand =
|
||||
| { id?: string; type: "set_model"; provider: string; modelId: string }
|
||||
| { id?: string; type: "set_thinking_level"; level: PiThinkingLevel }
|
||||
| { id?: string; type: "get_session_stats" }
|
||||
| { id?: string; type: "get_commands" };
|
||||
| { id?: string; type: PiCommandsRpcType };
|
||||
|
||||
export interface PiRpcResponse {
|
||||
id?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.99",
|
||||
"version": "0.1.100",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user