Compare commits

...

10 Commits

Author SHA1 Message Date
Mohamed Boudra
119afd7281 chore(release): cut 0.1.100 2026-06-24 17:42:47 +07:00
Mohamed Boudra
e58725ee39 docs(changelog): 0.1.100 2026-06-24 17:41:17 +07:00
Mohamed Boudra
36cdfaf516 chore(acp): update provider catalog to latest registry versions 2026-06-24 17:41:13 +07:00
Mohamed Boudra
c5442ef0a2 Stop Claude context meter from doubling requests (#1701)
* fix(claude): stop probing context usage after turns

Use Claude stream/result usage for parent context tracking, and update the meter from compact boundary post tokens. Subagent usage reports stay out of the parent context meter.

* fix(claude): clear compact context fallback between turns
2026-06-24 17:44:39 +08:00
Mohamed Boudra
0748149ec9 feat(providers): expose custom agent selection (#1700) 2026-06-24 16:39:48 +08:00
Mohamed Boudra
8c67415fdb Merge branch 'main' of github.com:getpaseo/paseo 2026-06-24 14:42:26 +07:00
Mohamed Boudra
6fe320055d feat(app): cycle agent modes with Shift+Tab 2026-06-24 14:41:14 +07:00
Mohamed Boudra
2ef119c24b Fix OMP slash commands and skills loading (#1698)
* Fix OMP slash command discovery

Keep Pi on get_commands while allowing the OMP-backed provider to use get_available_commands. Both providers reject the other command without echoing an id, so the adapter needs an explicit command-list RPC setting instead of a timeout-based fallback.

* Address OMP command test review
2026-06-24 15:11:05 +08:00
Mohamed Boudra
79be6d8dba Stop OpenCode helper servers from leaking (#1697)
* Fix OpenCode helper server ownership

Own OpenCode helper generations as soon as spawn returns so startup timeout and shutdown clean up through the manager/reaper path. Collapse the OpenCode runtime wrapper so the provider talks to the server manager directly.

* Protect dedicated OpenCode server startup
2026-06-24 14:44:43 +08:00
paseo-ai[bot]
42e3f63dec fix: update lockfile signatures and Nix hash [skip ci] 2026-06-23 15:52:14 +00:00
58 changed files with 1639 additions and 572 deletions

View File

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

View File

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

View File

@@ -1 +1 @@
sha256-+rWSD0ZcNJlY5JO3Pb3LslBMu9al3rN1I6i0k5bG1d8=
sha256-c3FItM+qFwZ/B21jOJ0W33LFZn1LUk4qNRbBekVT5vU=

42
package-lock.json generated
View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.99",
"version": "0.1.100",
"private": true,
"main": "index.ts",
"scripts": {

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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: "عامل المقاطعة",

View File

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

View File

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

View File

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

View File

@@ -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: "Агент прерываний",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.99",
"version": "0.1.100",
"files": [
"dist",
"!dist/**/*.map"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.99",
"version": "0.1.100",
"private": true,
"type": "module",
"scripts": {