mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Let plugins provide coding agents (#4314)
Allow trusted local plugins to register complete coding-agent providers through the existing agent lifecycle. Includes direct and ACP implementations, SVG provider icons, custom timeline items, runnable examples, and public authoring documentation. Closes #3547 Closes #3556
This commit is contained in:
@@ -165,6 +165,8 @@ See `public-docs/plugins/v0.8/reference.md`.
|
||||
| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks |
|
||||
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
||||
| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` |
|
||||
| `@getpaseo/plugin/provider` | provider registration, connection, session, input, event, and timeline contracts |
|
||||
| `@getpaseo/plugin/acp` | command-backed ACP adapter and focused transformer hooks |
|
||||
|
||||
The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:`
|
||||
import reachable from client code. Shared modules cannot import runtime-owned modules. A relative
|
||||
@@ -253,6 +255,50 @@ optional client-owned agent and workspace navigation; its absence is the compati
|
||||
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
|
||||
Plugins do not receive Expo Router or workspace-layout store access.
|
||||
|
||||
## Contribute a provider
|
||||
|
||||
Register a provider from `index.server.ts`. The provider connection is callback-based and owns all
|
||||
of its sessions; plugin RPC is not part of the provider data path.
|
||||
|
||||
```ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import type { ProviderRegistration } from "@getpaseo/plugin/provider";
|
||||
import { createProvider } from "./server/provider";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(createProvider() satisfies ProviderRegistration);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
|
||||
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
|
||||
Route messages, structured commands, steering, and command side effects through `session.prompt`.
|
||||
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
|
||||
the opaque `providerOptions` config object.
|
||||
|
||||
Agent refresh closes the current provider session and opens it again with current configuration and
|
||||
persistence. Providers re-read credentials, environment, global configuration, and MCP servers on
|
||||
`session.open`; there is no provider reload input.
|
||||
|
||||
For an ACP command, register `runAcpProvider({ id, label, command })` from
|
||||
`@getpaseo/plugin/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
|
||||
whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct`
|
||||
and `plugin-examples/provider-acp-transformer`.
|
||||
|
||||
Provider-emitted plugin timeline items use the same renderer registration as transformed and
|
||||
daemon-appended plugin items. The direct example includes both sides. The renderer-only
|
||||
`plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a
|
||||
provider implementation. The public [provider plugin guide](../public-docs/plugins/v0.8/providers.md)
|
||||
owns author workflow, lifecycle, testing, and distribution guidance.
|
||||
|
||||
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
|
||||
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
|
||||
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
|
||||
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
|
||||
## Contribute composer pills
|
||||
|
||||
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-R5NuJTwMOYPo0i60qhF0lZU+OMHwjnIKf9gI1fCJ9/M=
|
||||
sha256-1kiHsXpooSqC38MwZP2E7rQK+Zhuv0fUOxUHfnM30wA=
|
||||
|
||||
Generated
+10
@@ -38799,6 +38799,7 @@
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -38818,6 +38819,15 @@
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/plugin/node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.4.0.tgz",
|
||||
"integrity": "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.7.2",
|
||||
|
||||
@@ -96,7 +96,8 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
provider: controls.provider ?? null,
|
||||
labels: buildAgentControlContributionLabels(t),
|
||||
icons: {
|
||||
provider: (provider) => getCommandCenterIcon(getProviderIcon(provider)),
|
||||
provider: (provider) =>
|
||||
getCommandCenterIcon(getProviderIcon(provider, controls.serverId)),
|
||||
thinking: getCommandCenterIcon(ThinkingIcon),
|
||||
planMode: getCommandCenterIcon(PlanModeIcon),
|
||||
mode: (modeId) =>
|
||||
|
||||
@@ -232,7 +232,7 @@ function SessionRow({
|
||||
const projectName = agent.projectPlacement?.projectName ?? "";
|
||||
const branch = agent.projectPlacement?.checkout.currentBranch ?? "";
|
||||
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
|
||||
const ProviderIcon = getProviderIcon(agent.provider);
|
||||
const ProviderIcon = getProviderIcon(agent.provider, agent.serverId);
|
||||
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
|
||||
const rangesFor = useCallback(
|
||||
(field: AgentSearchMatch["field"]) =>
|
||||
|
||||
@@ -290,12 +290,14 @@ function SheetEmptyState({ title }: { title: string }) {
|
||||
}
|
||||
|
||||
function ImportSessionSheetRow({
|
||||
serverId,
|
||||
entry,
|
||||
disabled,
|
||||
importing,
|
||||
folder,
|
||||
onImportSession,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
entry: FetchRecentProviderSessionEntry;
|
||||
disabled: boolean;
|
||||
importing: boolean;
|
||||
@@ -308,7 +310,7 @@ function ImportSessionSheetRow({
|
||||
const title = getSessionTitle(entry);
|
||||
const promptPreview = getPromptPreview(entry);
|
||||
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
|
||||
const ProviderIcon = getProviderIcon(entry.providerId);
|
||||
const ProviderIcon = getProviderIcon(entry.providerId, serverId);
|
||||
const accessibilityState = useMemo(
|
||||
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
|
||||
[disabled],
|
||||
@@ -364,12 +366,14 @@ function ImportSessionSheetRow({
|
||||
}
|
||||
|
||||
function SessionRows({
|
||||
serverId,
|
||||
entries,
|
||||
disabled,
|
||||
importingSessionKey,
|
||||
resolveFolder,
|
||||
onImportSession,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
|
||||
disabled: boolean;
|
||||
importingSessionKey: string | null;
|
||||
@@ -381,6 +385,7 @@ function SessionRows({
|
||||
{entries.map((entry) => (
|
||||
<ImportSessionSheetRow
|
||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
||||
serverId={serverId}
|
||||
entry={entry}
|
||||
disabled={disabled}
|
||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||
@@ -552,11 +557,11 @@ export function ImportSessionSheet({
|
||||
const map = new Map<string, React.ReactNode>();
|
||||
map.set(ALL_FILTER_VALUE, <Layers size={14} color={theme.colors.foregroundMuted} />);
|
||||
for (const provider of filterProviders) {
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
const ProviderIcon = getProviderIcon(provider, serverId);
|
||||
map.set(provider, <ProviderIcon size={14} color={theme.colors.foregroundMuted} />);
|
||||
}
|
||||
return map;
|
||||
}, [filterProviders, theme.colors.foregroundMuted]);
|
||||
}, [filterProviders, serverId, theme.colors.foregroundMuted]);
|
||||
|
||||
const renderFilterOption = useCallback(
|
||||
({
|
||||
@@ -736,7 +741,7 @@ export function ImportSessionSheet({
|
||||
<Layers size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
(() => {
|
||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||
const ProviderIcon = getProviderIcon(selectedProvider, serverId);
|
||||
return <ProviderIcon size={14} color={theme.colors.foregroundMuted} />;
|
||||
})()
|
||||
)}
|
||||
@@ -773,6 +778,7 @@ export function ImportSessionSheet({
|
||||
) : null}
|
||||
{visibleEntries.length > 0 ? (
|
||||
<SessionRows
|
||||
serverId={serverId}
|
||||
entries={visibleEntries}
|
||||
disabled={importMutation.isPending}
|
||||
importingSessionKey={importingSessionKey}
|
||||
|
||||
@@ -154,6 +154,7 @@ interface ModelBrowserInput {
|
||||
}
|
||||
|
||||
export interface ModelBrowserState {
|
||||
serverId: string | null;
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
@@ -192,6 +193,7 @@ interface ModelBrowserProps {
|
||||
}
|
||||
|
||||
interface ModelBrowserContentProps extends Omit<ModelBrowserProps, "state" | "scrolling"> {
|
||||
serverId: string | null;
|
||||
view: ModelBrowserView;
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
@@ -209,14 +211,16 @@ type ProviderGlyphTone = "muted" | "foreground";
|
||||
|
||||
export function ModelProviderGlyph({
|
||||
provider,
|
||||
serverId,
|
||||
size,
|
||||
tone = "muted",
|
||||
}: {
|
||||
provider: string;
|
||||
serverId?: string | null;
|
||||
size: number;
|
||||
tone?: ProviderGlyphTone;
|
||||
}) {
|
||||
const Icon = getProviderIcon(provider);
|
||||
const Icon = getProviderIcon(provider, serverId);
|
||||
const color =
|
||||
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
|
||||
return <Icon size={size} color={color} />;
|
||||
@@ -330,7 +334,12 @@ export function useModelBrowser({
|
||||
return {
|
||||
title: view.providerLabel,
|
||||
leading: (
|
||||
<ModelProviderGlyph provider={view.providerId} size={ICON_SIZE.md} tone="foreground" />
|
||||
<ModelProviderGlyph
|
||||
provider={view.providerId}
|
||||
serverId={serverId}
|
||||
size={ICON_SIZE.md}
|
||||
tone="foreground"
|
||||
/>
|
||||
),
|
||||
back: singleProviderView ? undefined : { onPress: showAll },
|
||||
actions: (
|
||||
@@ -389,6 +398,7 @@ export function useModelBrowser({
|
||||
);
|
||||
|
||||
return {
|
||||
serverId,
|
||||
providers,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
@@ -652,6 +662,7 @@ function ModelRowProfileAction({
|
||||
|
||||
function ModelRow({
|
||||
row,
|
||||
serverId,
|
||||
isSelected,
|
||||
showProviderLabel = false,
|
||||
onPress,
|
||||
@@ -661,6 +672,7 @@ function ModelRow({
|
||||
onEditProfiles,
|
||||
}: {
|
||||
row: ProviderSelectionModelRow;
|
||||
serverId: string | null;
|
||||
isSelected: boolean;
|
||||
showProviderLabel?: boolean;
|
||||
onPress: () => void;
|
||||
@@ -672,8 +684,8 @@ function ModelRow({
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const leadingSlot = useMemo(
|
||||
() => <ModelProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />,
|
||||
[row.provider],
|
||||
() => <ModelProviderGlyph provider={row.provider} serverId={serverId} size={ICON_SIZE.sm} />,
|
||||
[row.provider, serverId],
|
||||
);
|
||||
|
||||
const description = showProviderLabel ? buildProviderQualifiedDescription(row) : row.description;
|
||||
@@ -810,6 +822,7 @@ function ModelRow({
|
||||
|
||||
function SelectableModelRow({
|
||||
row,
|
||||
serverId,
|
||||
isSelected,
|
||||
showProviderLabel,
|
||||
onSelect,
|
||||
@@ -819,6 +832,7 @@ function SelectableModelRow({
|
||||
onEditProfiles,
|
||||
}: {
|
||||
row: ProviderSelectionModelRow;
|
||||
serverId: string | null;
|
||||
isSelected: boolean;
|
||||
showProviderLabel?: boolean;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
@@ -833,6 +847,7 @@ function SelectableModelRow({
|
||||
return (
|
||||
<ModelRow
|
||||
row={row}
|
||||
serverId={serverId}
|
||||
isSelected={isSelected}
|
||||
showProviderLabel={showProviderLabel}
|
||||
onPress={handlePress}
|
||||
@@ -948,9 +963,11 @@ function AgentProfilesPickerContent({
|
||||
|
||||
function GroupProviderButton({
|
||||
provider,
|
||||
serverId,
|
||||
onDrillDown,
|
||||
}: {
|
||||
provider: ProviderSelectorProvider;
|
||||
serverId: string | null;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -988,8 +1005,8 @@ function GroupProviderButton({
|
||||
);
|
||||
}, [selection, t]);
|
||||
const leadingSlot = useMemo(
|
||||
() => <ModelProviderGlyph provider={provider.id} size={ICON_SIZE.sm} />,
|
||||
[provider.id],
|
||||
() => <ModelProviderGlyph provider={provider.id} serverId={serverId} size={ICON_SIZE.sm} />,
|
||||
[provider.id, serverId],
|
||||
);
|
||||
const trailingSlot = useMemo(
|
||||
() => (
|
||||
@@ -1016,9 +1033,11 @@ function GroupProviderButton({
|
||||
|
||||
function GroupedProviderRows({
|
||||
providers,
|
||||
serverId,
|
||||
onDrillDown,
|
||||
}: {
|
||||
providers: ProviderSelectorProvider[];
|
||||
serverId: string | null;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -1026,7 +1045,7 @@ function GroupedProviderRows({
|
||||
{providers.map((provider, index) => (
|
||||
<View key={provider.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
<GroupProviderButton provider={provider} onDrillDown={onDrillDown} />
|
||||
<GroupProviderButton provider={provider} serverId={serverId} onDrillDown={onDrillDown} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -1111,6 +1130,7 @@ function IndependentProviderList({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function ModelRowList({
|
||||
rows,
|
||||
serverId,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
@@ -1123,6 +1143,7 @@ function ModelRowList({
|
||||
onEditProfiles,
|
||||
}: {
|
||||
rows: ProviderSelectionModelRow[];
|
||||
serverId: string | null;
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
@@ -1139,6 +1160,7 @@ function ModelRowList({
|
||||
({ item }: { item: ProviderSelectionModelRow }) => (
|
||||
<SelectableModelRow
|
||||
row={item}
|
||||
serverId={serverId}
|
||||
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
||||
showProviderLabel={showProviderLabel}
|
||||
onSelect={onSelect}
|
||||
@@ -1156,6 +1178,7 @@ function ModelRowList({
|
||||
profiledLookup,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
serverId,
|
||||
showProviderLabel,
|
||||
],
|
||||
);
|
||||
@@ -1229,6 +1252,7 @@ function ModelSearchEmptyState() {
|
||||
}
|
||||
|
||||
function ProviderModelBrowserContent({
|
||||
serverId,
|
||||
view,
|
||||
provider,
|
||||
profiles,
|
||||
@@ -1246,6 +1270,7 @@ function ProviderModelBrowserContent({
|
||||
isRetryingProvider,
|
||||
scrolling,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
view: Extract<ModelBrowserView, { kind: "provider" }>;
|
||||
provider: ProviderSelectorProvider | null;
|
||||
profiles: AgentProfilePicker | null;
|
||||
@@ -1318,6 +1343,7 @@ function ProviderModelBrowserContent({
|
||||
}
|
||||
return (
|
||||
<ModelRowList
|
||||
serverId={serverId}
|
||||
rows={visibleRows}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
@@ -1333,6 +1359,7 @@ function ProviderModelBrowserContent({
|
||||
}
|
||||
|
||||
function ModelBrowserContent({
|
||||
serverId,
|
||||
view,
|
||||
providers,
|
||||
selectedProvider,
|
||||
@@ -1380,6 +1407,7 @@ function ModelBrowserContent({
|
||||
if (view.kind === "provider") {
|
||||
return (
|
||||
<ProviderModelBrowserContent
|
||||
serverId={serverId}
|
||||
view={view}
|
||||
provider={selectedViewProvider}
|
||||
profiles={profiles}
|
||||
@@ -1414,6 +1442,7 @@ function ModelBrowserContent({
|
||||
if (allView.kind === "searchResults") {
|
||||
return (
|
||||
<ModelRowList
|
||||
serverId={serverId}
|
||||
rows={allView.rows}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
@@ -1445,7 +1474,11 @@ function ModelBrowserContent({
|
||||
<Text style={styles.sectionHeadingText}>{t("modelSelector.providers")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<GroupedProviderRows providers={providers} onDrillDown={onDrillDown} />
|
||||
<GroupedProviderRows
|
||||
providers={providers}
|
||||
serverId={serverId}
|
||||
onDrillDown={onDrillDown}
|
||||
/>
|
||||
</View>
|
||||
) : null)}
|
||||
{!hasResults ? <ModelSearchEmptyState /> : null}
|
||||
@@ -1487,6 +1520,7 @@ export function ModelBrowser({
|
||||
}: ModelBrowserProps) {
|
||||
return (
|
||||
<ModelBrowserContent
|
||||
serverId={state.serverId}
|
||||
view={state.view}
|
||||
providers={state.providers}
|
||||
selectedProvider={state.selectedProvider}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
TERMINAL_PROFILE_ICON_NAMES,
|
||||
} from "@getpaseo/protocol/provider-icon-names";
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
import { resolveProviderIconName } from "./provider-icon-name";
|
||||
import { replaceProviderSnapshotIcons, resolveProviderIconName } from "./provider-icon-name";
|
||||
|
||||
describe("resolveProviderIconName", () => {
|
||||
it("returns the built-in identifier for known provider ids", () => {
|
||||
@@ -25,6 +25,48 @@ describe("resolveProviderIconName", () => {
|
||||
it("falls back to the bot icon for unknown custom providers", () => {
|
||||
expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" });
|
||||
});
|
||||
|
||||
it("resolves a snapshot SVG for a custom provider", () => {
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider", iconSvg: svg }]);
|
||||
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "svg", svg });
|
||||
});
|
||||
|
||||
it("replaces each host snapshot without leaking icons across hosts", () => {
|
||||
const secondSvg = "<svg id='second' />";
|
||||
replaceProviderSnapshotIcons("server-1", [
|
||||
{ provider: "removed-provider", iconSvg: "<svg />" },
|
||||
{ provider: "snapshot-provider", iconSvg: "<svg />" },
|
||||
{ provider: "claude", iconSvg: "<svg />" },
|
||||
]);
|
||||
replaceProviderSnapshotIcons("server-2", [
|
||||
{ provider: "snapshot-provider", iconSvg: secondSvg },
|
||||
]);
|
||||
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({
|
||||
kind: "svg",
|
||||
svg: "<svg />",
|
||||
});
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({
|
||||
kind: "svg",
|
||||
svg: secondSvg,
|
||||
});
|
||||
expect(resolveProviderIconName("claude", "server-1")).toEqual({
|
||||
kind: "builtin",
|
||||
id: "claude",
|
||||
});
|
||||
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider" }]);
|
||||
|
||||
expect(resolveProviderIconName("removed-provider", "server-1")).toEqual({ kind: "bot" });
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "bot" });
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({
|
||||
kind: "svg",
|
||||
svg: secondSvg,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("known provider icon names", () => {
|
||||
|
||||
@@ -2,19 +2,44 @@ import {
|
||||
BUILTIN_PROVIDER_ICON_NAMES,
|
||||
KNOWN_PROVIDER_ICON_NAMES,
|
||||
} from "@getpaseo/protocol/provider-icon-names";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export type ProviderIconName =
|
||||
| { kind: "builtin"; id: string }
|
||||
| { kind: "catalog"; id: string }
|
||||
| { kind: "svg"; svg: string }
|
||||
| { kind: "bot" };
|
||||
|
||||
const BUILTIN_PROVIDER_IDS = new Set(BUILTIN_PROVIDER_ICON_NAMES);
|
||||
const KNOWN_PROVIDER_IDS = new Set(KNOWN_PROVIDER_ICON_NAMES);
|
||||
const providerSnapshotIconSvgsByServer = new Map<string, ReadonlyMap<string, string>>();
|
||||
|
||||
export function resolveProviderIconName(provider: string): ProviderIconName {
|
||||
export function replaceProviderSnapshotIcons(
|
||||
serverId: string,
|
||||
entries: readonly Pick<ProviderSnapshotEntry, "provider" | "iconSvg">[],
|
||||
): void {
|
||||
const icons = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.iconSvg) {
|
||||
icons.set(entry.provider, entry.iconSvg);
|
||||
}
|
||||
}
|
||||
providerSnapshotIconSvgsByServer.set(serverId, icons);
|
||||
}
|
||||
|
||||
export function resolveProviderIconName(
|
||||
provider: string,
|
||||
serverId?: string | null,
|
||||
): ProviderIconName {
|
||||
if (BUILTIN_PROVIDER_IDS.has(provider)) {
|
||||
return { kind: "builtin", id: provider };
|
||||
}
|
||||
const iconSvg = serverId
|
||||
? providerSnapshotIconSvgsByServer.get(serverId)?.get(provider)
|
||||
: undefined;
|
||||
if (iconSvg) {
|
||||
return { kind: "svg", svg: iconSvg };
|
||||
}
|
||||
if (KNOWN_PROVIDER_IDS.has(provider)) {
|
||||
return { kind: "catalog", id: provider };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { replaceProviderSnapshotIcons } from "./provider-icon-name";
|
||||
import { getProviderIcon, type ProviderIconComponent } from "./provider-icons";
|
||||
|
||||
function renderIcon(Component: ProviderIconComponent) {
|
||||
if (typeof Component !== "function") throw new Error("Expected a function component");
|
||||
return (Component as (props: { size: number; color: string }) => unknown)({
|
||||
size: 18,
|
||||
color: "#123456",
|
||||
});
|
||||
}
|
||||
|
||||
describe("getProviderIcon", () => {
|
||||
it("renders registered snapshot SVG metadata with the requested size and color", () => {
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]);
|
||||
|
||||
const rendered = renderIcon(getProviderIcon("rendered-provider", "server-1"));
|
||||
|
||||
expect(rendered).toMatchObject({
|
||||
type: SvgXml,
|
||||
props: { xml: svg, width: 18, height: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the normal Bot fallback without snapshot SVG metadata", () => {
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "plain-provider" }]);
|
||||
|
||||
expect(getProviderIcon("plain-provider", "server-1")).toBe(Bot);
|
||||
});
|
||||
});
|
||||
@@ -34,17 +34,18 @@ const CATALOG_ICON_SVGS = new Map(
|
||||
);
|
||||
|
||||
const catalogIconComponents = new Map<string, ProviderIconComponent>();
|
||||
const snapshotIconComponents = new Map<string, { svg: string; component: ProviderIconComponent }>();
|
||||
|
||||
function createCatalogIcon(provider: string, iconSvg: string): ProviderIconComponent {
|
||||
const CatalogProviderIcon: ProviderIconComponent = ({ size, color }) =>
|
||||
function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent {
|
||||
const SvgProviderIcon: ProviderIconComponent = ({ size, color }) =>
|
||||
createElement(SvgXml, {
|
||||
xml: iconSvg,
|
||||
width: size,
|
||||
height: size,
|
||||
color,
|
||||
});
|
||||
CatalogProviderIcon.displayName = `CatalogProviderIcon(${provider})`;
|
||||
return CatalogProviderIcon;
|
||||
SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`;
|
||||
return SvgProviderIcon;
|
||||
}
|
||||
|
||||
function getCatalogProviderIcon(provider: string): ProviderIconComponent {
|
||||
@@ -56,18 +57,29 @@ function getCatalogProviderIcon(provider: string): ProviderIconComponent {
|
||||
if (!iconSvg) {
|
||||
return Bot;
|
||||
}
|
||||
const icon = createCatalogIcon(provider, iconSvg);
|
||||
const icon = createSvgIcon(provider, iconSvg);
|
||||
catalogIconComponents.set(provider, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function getProviderIcon(provider: string): ProviderIconComponent {
|
||||
const name = resolveProviderIconName(provider);
|
||||
function getSnapshotProviderIcon(provider: string, svg: string): ProviderIconComponent {
|
||||
const cached = snapshotIconComponents.get(provider);
|
||||
if (cached?.svg === svg) return cached.component;
|
||||
const component = createSvgIcon(provider, svg);
|
||||
snapshotIconComponents.set(provider, { svg, component });
|
||||
return component;
|
||||
}
|
||||
|
||||
export function getProviderIcon(provider: string, serverId?: string | null): ProviderIconComponent {
|
||||
const name = resolveProviderIconName(provider, serverId);
|
||||
if (name.kind === "builtin") {
|
||||
return BUILTIN_PROVIDER_ICONS[name.id];
|
||||
}
|
||||
if (name.kind === "catalog") {
|
||||
return getCatalogProviderIcon(name.id);
|
||||
}
|
||||
if (name.kind === "svg") {
|
||||
return getSnapshotProviderIcon(`${serverId}:${provider}`, name.svg);
|
||||
}
|
||||
return Bot;
|
||||
}
|
||||
|
||||
@@ -661,8 +661,8 @@ function ScheduleTargetFields({
|
||||
[],
|
||||
);
|
||||
const modelTriggerLeading = useMemo(
|
||||
() => <ProviderGlyph provider={state.selectedProvider} />,
|
||||
[state.selectedProvider],
|
||||
() => <ProviderGlyph provider={state.selectedProvider} serverId={state.selectedServerId} />,
|
||||
[state.selectedProvider, state.selectedServerId],
|
||||
);
|
||||
const renderModelTrigger = useCallback(
|
||||
({
|
||||
@@ -1019,11 +1019,17 @@ function ThinkingOptionItem({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null {
|
||||
function ProviderGlyph({
|
||||
provider,
|
||||
serverId,
|
||||
}: {
|
||||
provider: string | null;
|
||||
serverId: string | null;
|
||||
}): ReactElement | null {
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
const Icon = getProviderIcon(provider);
|
||||
const Icon = getProviderIcon(provider, serverId);
|
||||
return <Icon size={16} color={styles.providerIcon.color} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface ScheduleRowActions {
|
||||
}
|
||||
|
||||
interface ScheduleRowProps extends ScheduleRowActions {
|
||||
serverId: string;
|
||||
schedule: ScheduleSummary;
|
||||
/** Client-derived target line (agent title / project / shortened path). */
|
||||
targetLabel: string;
|
||||
@@ -120,11 +121,17 @@ function buildMeta(
|
||||
|
||||
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
||||
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
|
||||
function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null {
|
||||
function ProviderGlyph({
|
||||
provider,
|
||||
serverId,
|
||||
}: {
|
||||
provider: string | null;
|
||||
serverId: string;
|
||||
}): ReactElement | null {
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
const Icon = getProviderIcon(provider);
|
||||
const Icon = getProviderIcon(provider, serverId);
|
||||
return <Icon size={PROVIDER_ICON_SIZE} color={styles.providerIcon.color} />;
|
||||
}
|
||||
|
||||
@@ -138,6 +145,7 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement
|
||||
* highlights without reflow.
|
||||
*/
|
||||
export function ScheduleRow({
|
||||
serverId,
|
||||
schedule,
|
||||
targetLabel,
|
||||
provider,
|
||||
@@ -189,7 +197,7 @@ export function ScheduleRow({
|
||||
>
|
||||
<View style={styles.main}>
|
||||
<View style={styles.leading}>
|
||||
<ProviderGlyph provider={provider} />
|
||||
<ProviderGlyph provider={provider} serverId={serverId} />
|
||||
</View>
|
||||
<View style={styles.textGroup}>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
|
||||
@@ -129,6 +129,7 @@ function SchedulesTableRow({
|
||||
|
||||
return (
|
||||
<ScheduleRow
|
||||
serverId={row.schedule.serverId}
|
||||
schedule={schedule}
|
||||
targetLabel={row.targetLabel}
|
||||
provider={row.provider}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function CompactModelSheet({
|
||||
serverId,
|
||||
});
|
||||
const ProviderIcon =
|
||||
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider) : null;
|
||||
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider, serverId) : null;
|
||||
const ModelIcon = ProviderIcon ?? Bot;
|
||||
const rootHeader = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name";
|
||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
|
||||
@@ -194,6 +195,7 @@ export function applyProvidersSnapshotUpdate(input: {
|
||||
if (input.message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
replaceProviderSnapshotIcons(input.serverId, input.message.payload.entries);
|
||||
const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd);
|
||||
input.queryClient.setQueryData(queryKey, {
|
||||
entries: input.message.payload.entries,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
|
||||
import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
|
||||
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||
import { resolveProviderIconName } from "@/components/provider-icon-name";
|
||||
import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router";
|
||||
import {
|
||||
fetchProvidersSnapshot,
|
||||
@@ -109,6 +110,24 @@ describe("providersSnapshotQueryKey", () => {
|
||||
});
|
||||
|
||||
describe("fetchProvidersSnapshot", () => {
|
||||
it("registers provider icons from the applied snapshot", async () => {
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
const client = createClient({
|
||||
snapshots: [
|
||||
providersSnapshot([
|
||||
{ provider: "snapshot-icon-provider", status: "ready", enabled: true, iconSvg: svg },
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
await fetchProvidersSnapshot({ client, serverId, cwd: null, cache: createCache() });
|
||||
|
||||
expect(resolveProviderIconName("snapshot-icon-provider", serverId)).toEqual({
|
||||
kind: "svg",
|
||||
svg,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends no cwd for the home scope", async () => {
|
||||
const client = createClient({ snapshots: [providersSnapshot([])] });
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
providersSnapshotQueryRoot,
|
||||
providersSnapshotRequestOptions,
|
||||
} from "@/data/providers-snapshot";
|
||||
import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name";
|
||||
|
||||
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
|
||||
type RefreshProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["refreshProvidersSnapshot"]>>;
|
||||
@@ -47,8 +48,10 @@ export async function fetchProvidersSnapshot(input: {
|
||||
if (!cached) {
|
||||
throw new ProviderSnapshotCacheMissError();
|
||||
}
|
||||
replaceProviderSnapshotIcons(input.serverId, cached.entries);
|
||||
return { ...snapshot, entries: cached.entries };
|
||||
}
|
||||
replaceProviderSnapshotIcons(input.serverId, snapshot.entries);
|
||||
if (snapshot.compactSnapshot && snapshot.snapshotHash) {
|
||||
await cache.write({
|
||||
serverId: input.serverId,
|
||||
|
||||
@@ -362,7 +362,7 @@ function useAgentPanelDescriptor(
|
||||
);
|
||||
const provider = descriptorState.provider;
|
||||
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
||||
const icon = getProviderIcon(provider);
|
||||
const icon = getProviderIcon(provider, context.serverId);
|
||||
|
||||
return {
|
||||
label: label ?? "",
|
||||
|
||||
@@ -128,6 +128,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
<ComposerTrackBar>
|
||||
<AgentTaskList tasks={tasks} />
|
||||
<SubagentsTrack
|
||||
serverId={serverId}
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
|
||||
@@ -56,7 +56,7 @@ function useProviderSubagentDescriptor(
|
||||
subagentType && subagentType !== label ? `${subagentType} · ${providerLabel}` : providerLabel,
|
||||
tooltip: label,
|
||||
titleState: descriptor ? "ready" : "loading",
|
||||
icon: getProviderIcon(provider),
|
||||
icon: getProviderIcon(provider, context.serverId),
|
||||
statusBucket: descriptor
|
||||
? deriveSidebarStateBucket({
|
||||
status: providerSubagentLifecycleStatus(descriptor.status),
|
||||
|
||||
@@ -77,6 +77,7 @@ function getProviderStatus(
|
||||
}
|
||||
|
||||
interface ProviderRowProps {
|
||||
serverId: string;
|
||||
def: ProviderDefinition;
|
||||
entry: ProviderEntry;
|
||||
enabled: boolean;
|
||||
@@ -167,6 +168,7 @@ function ProviderActionsMenu({
|
||||
}
|
||||
|
||||
function ProviderRow({
|
||||
serverId,
|
||||
def,
|
||||
entry,
|
||||
enabled,
|
||||
@@ -181,7 +183,7 @@ function ProviderRow({
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const ProviderIcon = getProviderIcon(def.id);
|
||||
const ProviderIcon = getProviderIcon(def.id, serverId);
|
||||
const providerError =
|
||||
enabled &&
|
||||
entry.status === "error" &&
|
||||
@@ -436,6 +438,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
return (
|
||||
<ProviderRow
|
||||
key={def.id}
|
||||
serverId={serverId}
|
||||
def={def}
|
||||
entry={entry}
|
||||
enabled={entry.enabled ?? true}
|
||||
|
||||
@@ -30,6 +30,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
});
|
||||
|
||||
export interface SubagentsTrackProps {
|
||||
serverId: string;
|
||||
rows: SubagentRow[];
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
@@ -44,17 +45,18 @@ const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" };
|
||||
/** Leading and action glyphs share one size so rows keep a single icon column. */
|
||||
const ROW_ICON_SIZE = 14;
|
||||
|
||||
function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
|
||||
function buildRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation {
|
||||
const data = buildSubagentRowPresentationData(row);
|
||||
return {
|
||||
...data,
|
||||
tooltip: data.label,
|
||||
modified: false,
|
||||
icon: getProviderIcon(row.provider),
|
||||
icon: getProviderIcon(row.provider, serverId),
|
||||
};
|
||||
}
|
||||
|
||||
export function SubagentsTrack({
|
||||
serverId,
|
||||
rows,
|
||||
onOpenSubagent,
|
||||
onOpenProviderSubagent,
|
||||
@@ -95,6 +97,7 @@ export function SubagentsTrack({
|
||||
<SubagentsTrackRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
serverId={serverId}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||
onArchiveSubagent={onArchiveSubagent}
|
||||
@@ -164,6 +167,7 @@ function ArchiveFinishedRow({
|
||||
}
|
||||
|
||||
interface SubagentsTrackRowProps {
|
||||
serverId: string;
|
||||
row: SubagentRow;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
@@ -172,6 +176,7 @@ interface SubagentsTrackRowProps {
|
||||
}
|
||||
|
||||
function SubagentsTrackRow({
|
||||
serverId,
|
||||
row,
|
||||
onOpenSubagent,
|
||||
onOpenProviderSubagent,
|
||||
@@ -180,7 +185,7 @@ function SubagentsTrackRow({
|
||||
}: SubagentsTrackRowProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const presentation = useMemo(() => buildRowPresentation(row), [row]);
|
||||
const presentation = useMemo(() => buildRowPresentation(row, serverId), [row, serverId]);
|
||||
const displayLabel =
|
||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||
const handlePress = useCallback(() => {
|
||||
|
||||
@@ -120,7 +120,7 @@ function seedPaseoHome(paseoHome, listen, workspaceRoot) {
|
||||
daemon: {
|
||||
listen,
|
||||
relay: { enabled: false },
|
||||
mcp: { enabled: true, injectIntoAgents: false },
|
||||
mcp: { enabled: true, injectIntoAgents: true },
|
||||
browserTools: { enabled: true },
|
||||
cors: { allowedOrigins: ["*"] },
|
||||
},
|
||||
|
||||
@@ -18,6 +18,16 @@
|
||||
"source": "./src/server.ts",
|
||||
"default": "./dist/server.js"
|
||||
},
|
||||
"./provider": {
|
||||
"types": "./dist/provider.d.ts",
|
||||
"source": "./src/provider.ts",
|
||||
"default": "./dist/provider.js"
|
||||
},
|
||||
"./acp": {
|
||||
"types": "./dist/acp.d.ts",
|
||||
"source": "./src/acp.ts",
|
||||
"default": "./dist/acp.js"
|
||||
},
|
||||
"./react-native": {
|
||||
"types": "./dist/react-native.d.ts",
|
||||
"source": "./src/react-native.ts",
|
||||
@@ -41,6 +51,7 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,985 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { AgentSideConnection, PROTOCOL_VERSION, type Agent } from "@agentclientprotocol/sdk";
|
||||
import type { ProviderConnection, ProviderEvent, ProviderInput } from "./provider.js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runAcpProvider, type AcpStreamMessage } from "./acp.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function fakeAcp(source: string): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-acp-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const executable = path.join(directory, "agent.cjs");
|
||||
await writeFile(executable, source, "utf8");
|
||||
return executable;
|
||||
}
|
||||
|
||||
async function connect(
|
||||
executable: string,
|
||||
capabilities: string[],
|
||||
transformers?: Parameters<typeof runAcpProvider>[0]["transformers"],
|
||||
): Promise<{ connection: ProviderConnection; events: ProviderEvent[] }> {
|
||||
const registration = runAcpProvider({
|
||||
id: "test-acp",
|
||||
label: "Test ACP",
|
||||
command: [process.execPath, executable],
|
||||
transformers,
|
||||
});
|
||||
const connection = await registration.connect({ versions: [1], capabilities });
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
return { connection, events };
|
||||
}
|
||||
|
||||
function openInput(
|
||||
settings: Record<string, string> = {},
|
||||
): Extract<ProviderInput, { type: "session.open" }> {
|
||||
return {
|
||||
type: "session.open",
|
||||
requestId: "open-1",
|
||||
sessionId: "session-1",
|
||||
config: { cwd: "/repo", env: {}, mcpServers: {}, settings, persist: true },
|
||||
history: "skip",
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForEvent(
|
||||
events: ProviderEvent[],
|
||||
predicate: (event: ProviderEvent) => boolean,
|
||||
): Promise<ProviderEvent> {
|
||||
await expect.poll(() => events.find(predicate), { timeout: 3_000 }).toBeTruthy();
|
||||
return events.find(predicate)!;
|
||||
}
|
||||
|
||||
type AcpRequestMessage = Extract<AcpStreamMessage, { id: string | number | null; method: string }>;
|
||||
|
||||
interface ConnectorInstance {
|
||||
readonly requests: AcpRequestMessage[];
|
||||
readsCanceled: boolean;
|
||||
writesClosed: boolean;
|
||||
closeFromAgent(): void;
|
||||
request(method: string, params: unknown): Promise<unknown>;
|
||||
respond(request: AcpRequestMessage, result: unknown): void;
|
||||
notify(method: string, params: unknown): void;
|
||||
}
|
||||
|
||||
interface ConnectorResponse {
|
||||
resolve(value: unknown): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
interface ConnectorHarnessOptions {
|
||||
capabilities?: Record<string, unknown>;
|
||||
configOptions?: unknown[];
|
||||
handleMessage?(instance: ConnectorInstance, message: AcpStreamMessage): boolean;
|
||||
handleRequest?(instance: ConnectorInstance, request: AcpRequestMessage): boolean;
|
||||
}
|
||||
|
||||
function countRequests(instance: ConnectorInstance, method: string): number {
|
||||
return instance.requests.filter((request) => request.method === method).length;
|
||||
}
|
||||
|
||||
function receiveConnectorMessage(
|
||||
instance: ConnectorInstance,
|
||||
message: AcpStreamMessage,
|
||||
options?: ConnectorHarnessOptions,
|
||||
): void {
|
||||
if (!("method" in message)) return;
|
||||
if ("id" in message) instance.requests.push(message as AcpRequestMessage);
|
||||
if (options?.handleMessage?.(instance, message)) return;
|
||||
if (!("id" in message)) return;
|
||||
const request = message as AcpRequestMessage;
|
||||
if (options?.handleRequest?.(instance, request)) return;
|
||||
respondToConnectorRequest(instance, request, options);
|
||||
}
|
||||
|
||||
function respondToConnectorRequest(
|
||||
instance: ConnectorInstance,
|
||||
request: AcpRequestMessage,
|
||||
options?: ConnectorHarnessOptions,
|
||||
): void {
|
||||
if (request.method === "initialize") {
|
||||
instance.respond(request, {
|
||||
protocolVersion: request.params
|
||||
? (request.params as { protocolVersion: number }).protocolVersion
|
||||
: PROTOCOL_VERSION,
|
||||
agentCapabilities: options?.capabilities ?? {},
|
||||
});
|
||||
} else if (request.method === "session/new" || request.method === "session/load") {
|
||||
instance.respond(request, {
|
||||
sessionId: "connector-session",
|
||||
modes: null,
|
||||
configOptions: options?.configOptions ?? [],
|
||||
});
|
||||
} else if (request.method === "session/prompt") {
|
||||
instance.respond(request, { stopReason: "end_turn" });
|
||||
} else if (request.method === "session/close") {
|
||||
instance.respond(request, {});
|
||||
} else if (request.method === "session/set_config_option") {
|
||||
const params = request.params as { configId: string; value: string | boolean };
|
||||
instance.respond(request, {
|
||||
configOptions: (options?.configOptions ?? []).map((option) =>
|
||||
(option as { id?: string }).id === params.configId
|
||||
? Object.assign({}, option, { currentValue: params.value })
|
||||
: option,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function connectorHarness(options?: ConnectorHarnessOptions) {
|
||||
const instances: ConnectorInstance[] = [];
|
||||
const connector = () => {
|
||||
let controller!: ReadableStreamDefaultController<AcpStreamMessage>;
|
||||
let requestSequence = 0;
|
||||
const responses = new Map<number, ConnectorResponse>();
|
||||
const instance: ConnectorInstance = {
|
||||
requests: [],
|
||||
readsCanceled: false,
|
||||
writesClosed: false,
|
||||
closeFromAgent: () => controller.close(),
|
||||
request: (method, params) => {
|
||||
const response = new Promise<unknown>((resolve, reject) => {
|
||||
requestSequence += 1;
|
||||
responses.set(requestSequence, { resolve, reject });
|
||||
controller.enqueue({ jsonrpc: "2.0", id: requestSequence, method, params });
|
||||
});
|
||||
void response.catch(() => undefined);
|
||||
return response;
|
||||
},
|
||||
respond: (request, result) => controller.enqueue({ jsonrpc: "2.0", id: request.id, result }),
|
||||
notify: (method, params) => controller.enqueue({ jsonrpc: "2.0", method, params }),
|
||||
};
|
||||
instances.push(instance);
|
||||
const readable = new ReadableStream<AcpStreamMessage>({
|
||||
start(value) {
|
||||
controller = value;
|
||||
},
|
||||
cancel() {
|
||||
instance.readsCanceled = true;
|
||||
},
|
||||
});
|
||||
const writable = new WritableStream<AcpStreamMessage>({
|
||||
write(message) {
|
||||
if (!("method" in message)) {
|
||||
if (typeof message.id !== "number") return;
|
||||
const response = responses.get(message.id);
|
||||
if (!response) return;
|
||||
responses.delete(message.id);
|
||||
if ("error" in message) {
|
||||
response.reject(
|
||||
new Error(
|
||||
message.error.data === undefined
|
||||
? message.error.message
|
||||
: `${message.error.message}: ${JSON.stringify(message.error.data)}`,
|
||||
),
|
||||
);
|
||||
} else response.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
receiveConnectorMessage(instance, message, options);
|
||||
},
|
||||
close() {
|
||||
instance.writesClosed = true;
|
||||
},
|
||||
abort() {
|
||||
instance.writesClosed = true;
|
||||
},
|
||||
});
|
||||
return { readable, writable };
|
||||
};
|
||||
return { connector, instances };
|
||||
}
|
||||
|
||||
const basicAgent = `const readline = require("node:readline");
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
lines.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
|
||||
else if (message.method === "session/new") setTimeout(() => send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }), 150);
|
||||
else if (message.method === "session/prompt") send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
|
||||
});`;
|
||||
|
||||
describe("runAcpProvider", () => {
|
||||
it("rejects connection cleanly when the ACP executable does not exist", async () => {
|
||||
const registration = runAcpProvider({
|
||||
id: "missing-acp",
|
||||
label: "Missing ACP",
|
||||
command: [path.join(tmpdir(), `missing-acp-${process.pid}`)],
|
||||
acpOptions: { startupTimeoutMs: 250 },
|
||||
});
|
||||
|
||||
await expect(
|
||||
registration.connect({ versions: [1], capabilities: ["prompt.message"] }),
|
||||
).rejects.toThrow(/ENOENT|spawn/u);
|
||||
});
|
||||
|
||||
it("accepts an official ACP SDK stream connector instead of a command", async () => {
|
||||
const connector = () => {
|
||||
const clientToAgent = new TransformStream();
|
||||
const agentToClient = new TransformStream();
|
||||
const agent: Agent = {
|
||||
initialize: async () => ({ protocolVersion: PROTOCOL_VERSION, agentCapabilities: {} }),
|
||||
newSession: async () => ({ sessionId: "sdk-session", modes: null, configOptions: [] }),
|
||||
prompt: async () => ({ stopReason: "end_turn" }),
|
||||
cancel: async () => undefined,
|
||||
};
|
||||
const agentConnection = new AgentSideConnection(() => agent, {
|
||||
writable: agentToClient.writable,
|
||||
readable: clientToAgent.readable,
|
||||
});
|
||||
void agentConnection;
|
||||
return {
|
||||
writable: clientToAgent.writable,
|
||||
readable: agentToClient.readable,
|
||||
};
|
||||
};
|
||||
const registration = runAcpProvider({
|
||||
id: "sdk-acp",
|
||||
label: "SDK ACP",
|
||||
connector,
|
||||
});
|
||||
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send(openInput());
|
||||
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
expect(connection.capabilities).toEqual(["prompt.message"]);
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("maps permission behavior to the first matching ACP option", async () => {
|
||||
const harness = connectorHarness();
|
||||
const registration = runAcpProvider({
|
||||
id: "sdk-acp",
|
||||
label: "SDK ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message", "permission"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
const allow = harness.instances[1]!.request("session/request_permission", {
|
||||
sessionId: "connector-session",
|
||||
toolCall: {
|
||||
toolCallId: "tool-allow",
|
||||
title: "Allow tool",
|
||||
kind: "execute",
|
||||
status: "pending",
|
||||
content: [],
|
||||
rawInput: {},
|
||||
},
|
||||
options: [
|
||||
{ optionId: "allow-always", name: "Always allow", kind: "allow_always" },
|
||||
{ optionId: "allow-once", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) =>
|
||||
event.type === "session.permission" && event.request.id === "permission:tool-allow",
|
||||
);
|
||||
await connection.send({
|
||||
type: "session.permission",
|
||||
sessionId: "session-1",
|
||||
permissionId: "permission:tool-allow",
|
||||
response: { behavior: "allow" },
|
||||
});
|
||||
await expect(allow).resolves.toEqual({
|
||||
outcome: { outcome: "selected", optionId: "allow-always" },
|
||||
});
|
||||
|
||||
const deny = harness.instances[1]!.request("session/request_permission", {
|
||||
sessionId: "connector-session",
|
||||
toolCall: {
|
||||
toolCallId: "tool-deny",
|
||||
title: "Deny tool",
|
||||
kind: "execute",
|
||||
status: "pending",
|
||||
content: [],
|
||||
rawInput: {},
|
||||
},
|
||||
options: [
|
||||
{ optionId: "allow-once", name: "Allow", kind: "allow_once" },
|
||||
{ optionId: "reject-always", name: "Always reject", kind: "reject_always" },
|
||||
{ optionId: "reject-once", name: "Reject once", kind: "reject_once" },
|
||||
],
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "session.permission" && event.request.id === "permission:tool-deny",
|
||||
);
|
||||
await connection.send({
|
||||
type: "session.permission",
|
||||
sessionId: "session-1",
|
||||
permissionId: "permission:tool-deny",
|
||||
response: { behavior: "deny" },
|
||||
});
|
||||
await expect(deny).resolves.toEqual({
|
||||
outcome: { outcome: "selected", optionId: "reject-always" },
|
||||
});
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("rejects an explicit ACP permission option with the wrong behavior", async () => {
|
||||
const harness = connectorHarness();
|
||||
const registration = runAcpProvider({
|
||||
id: "sdk-acp",
|
||||
label: "SDK ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message", "permission"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
const permission = harness.instances[1]!.request("session/request_permission", {
|
||||
sessionId: "connector-session",
|
||||
toolCall: {
|
||||
toolCallId: "tool-explicit",
|
||||
title: "Explicit tool",
|
||||
kind: "execute",
|
||||
status: "pending",
|
||||
content: [],
|
||||
rawInput: {},
|
||||
},
|
||||
options: [
|
||||
{ optionId: "allow-once", name: "Allow", kind: "allow_once" },
|
||||
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) =>
|
||||
event.type === "session.permission" && event.request.id === "permission:tool-explicit",
|
||||
);
|
||||
await connection.send({
|
||||
type: "session.permission",
|
||||
sessionId: "session-1",
|
||||
permissionId: "permission:tool-explicit",
|
||||
response: { behavior: "allow", selectedActionId: "missing" },
|
||||
});
|
||||
await expect(
|
||||
waitForEvent(
|
||||
events,
|
||||
(event) =>
|
||||
event.type === "session.runtime_failed" && event.error.message.includes("'missing'"),
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
await connection.send({
|
||||
type: "session.permission",
|
||||
sessionId: "session-1",
|
||||
permissionId: "permission:tool-explicit",
|
||||
response: { behavior: "allow", selectedActionId: "reject-once" },
|
||||
});
|
||||
await expect(
|
||||
waitForEvent(
|
||||
events,
|
||||
(event) =>
|
||||
event.type === "session.runtime_failed" &&
|
||||
event.error.message.includes("'reject-once'") &&
|
||||
event.error.message.includes("does not match 'allow' behavior"),
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
await connection.send({
|
||||
type: "session.permission",
|
||||
sessionId: "session-1",
|
||||
permissionId: "permission:tool-explicit",
|
||||
response: { behavior: "allow", selectedActionId: "allow-once" },
|
||||
});
|
||||
await expect(permission).resolves.toEqual({
|
||||
outcome: { outcome: "selected", optionId: "allow-once" },
|
||||
});
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("closes both connector directions for the probe and live session", async () => {
|
||||
const harness = connectorHarness();
|
||||
const registration = runAcpProvider({
|
||||
id: "owned-acp",
|
||||
label: "Owned ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
expect(harness.instances).toHaveLength(1);
|
||||
expect(harness.instances[0]).toMatchObject({ readsCanceled: true, writesClosed: true });
|
||||
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
await connection.close();
|
||||
|
||||
expect(harness.instances).toHaveLength(2);
|
||||
expect(harness.instances[1]).toMatchObject({ readsCanceled: true, writesClosed: true });
|
||||
});
|
||||
|
||||
it("publishes runtime failure on unexpected connector EOF", async () => {
|
||||
const harness = connectorHarness();
|
||||
const registration = runAcpProvider({
|
||||
id: "eof-acp",
|
||||
label: "EOF ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
harness.instances[1]!.closeFromAgent();
|
||||
|
||||
await expect(
|
||||
waitForEvent(events, (event) => event.type === "session.runtime_failed"),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: "session-1",
|
||||
error: { message: expect.stringContaining("closed") },
|
||||
});
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("settles an active auto prompt before admitting its auto replacement", async () => {
|
||||
const order: string[] = [];
|
||||
let firstPrompt: AcpRequestMessage | null = null;
|
||||
let activePrompts = 0;
|
||||
let maximumActivePrompts = 0;
|
||||
const harness = connectorHarness({
|
||||
handleMessage(instance, message) {
|
||||
if (!("method" in message)) return false;
|
||||
const request = "id" in message ? (message as AcpRequestMessage) : null;
|
||||
if (message.method === "session/prompt" && request) {
|
||||
const promptNumber = firstPrompt ? "second" : "first";
|
||||
order.push(`prompt:${promptNumber}`);
|
||||
activePrompts += 1;
|
||||
maximumActivePrompts = Math.max(maximumActivePrompts, activePrompts);
|
||||
if (!firstPrompt) {
|
||||
firstPrompt = request;
|
||||
} else {
|
||||
activePrompts -= 1;
|
||||
instance.respond(request, { stopReason: "end_turn" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (message.method === "session/cancel") {
|
||||
order.push("cancel");
|
||||
activePrompts -= 1;
|
||||
instance.respond(firstPrompt!, { stopReason: "cancelled" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "overlap-acp",
|
||||
label: "Overlap ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "first",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "first" }] },
|
||||
},
|
||||
});
|
||||
await expect.poll(() => countRequests(harness.instances[1]!, "session/prompt")).toBe(1);
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "second",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "second" }] },
|
||||
},
|
||||
});
|
||||
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) =>
|
||||
event.type === "session.turn" &&
|
||||
event.turnId === "acp:second" &&
|
||||
event.state === "completed",
|
||||
);
|
||||
expect(order).toEqual(["prompt:first", "cancel", "prompt:second"]);
|
||||
expect(maximumActivePrompts).toBe(1);
|
||||
const firstTerminal = events.findIndex(
|
||||
(event) =>
|
||||
event.type === "session.turn" && event.turnId === "acp:first" && event.state === "canceled",
|
||||
);
|
||||
const secondAdmission = events.findIndex(
|
||||
(event) => event.type === "session.prompt_result" && event.clientMessageId === "second",
|
||||
);
|
||||
expect(firstTerminal).toBeGreaterThan(-1);
|
||||
expect(firstTerminal).toBeLessThan(secondAdmission);
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("serializes configuration mutations", async () => {
|
||||
let releaseMutation: (() => void) | null = null;
|
||||
const configOptions = [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "a",
|
||||
options: [
|
||||
{ name: "A", value: "a" },
|
||||
{ name: "B", value: "b" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const harness = connectorHarness({
|
||||
capabilities: { loadSession: true },
|
||||
configOptions,
|
||||
handleRequest(instance, request) {
|
||||
if (request.method !== "session/set_config_option") return false;
|
||||
const value = (request.params as { value: string }).value;
|
||||
if (value !== "first") return false;
|
||||
releaseMutation = () =>
|
||||
instance.respond(request, {
|
||||
configOptions: configOptions.map((option) =>
|
||||
Object.assign({}, option, { currentValue: value }),
|
||||
),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "lane-acp",
|
||||
label: "Lane ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message", "session.configure"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
await connection.send({
|
||||
type: "session.configure",
|
||||
requestId: "configure-first",
|
||||
sessionId: "session-1",
|
||||
changes: { model: "first" },
|
||||
});
|
||||
await connection.send({
|
||||
type: "session.configure",
|
||||
requestId: "configure-second",
|
||||
sessionId: "session-1",
|
||||
changes: { model: "second" },
|
||||
});
|
||||
await expect
|
||||
.poll(() => countRequests(harness.instances[1]!, "session/set_config_option"))
|
||||
.toBe(1);
|
||||
releaseMutation!();
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "request.completed" && event.requestId === "configure-second",
|
||||
);
|
||||
expect(
|
||||
events
|
||||
.filter(
|
||||
(event) => event.type === "request.completed" && event.requestId.startsWith("configure-"),
|
||||
)
|
||||
.map((event) => (event as Extract<ProviderEvent, { type: "request.completed" }>).requestId),
|
||||
).toEqual(["configure-first", "configure-second"]);
|
||||
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("does not claim exact permission policy, listing, image, or configuration support", async () => {
|
||||
const executable = await fakeAcp(basicAgent);
|
||||
const { connection, events } = await connect(executable, [
|
||||
"prompt.message",
|
||||
"prompt.image",
|
||||
"session.configure",
|
||||
"session.list",
|
||||
"permission.tool_policy",
|
||||
]);
|
||||
|
||||
expect(connection.capabilities).toEqual(["prompt.message", "session.configure"]);
|
||||
await connection.send(openInput());
|
||||
const opened = await waitForEvent(events, (event) => event.type === "session.opened");
|
||||
expect(opened).toMatchObject({ capabilities: ["prompt.message"] });
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
await expect(
|
||||
connection.send({
|
||||
type: "session.configure",
|
||||
requestId: "configure-unsupported",
|
||||
sessionId: "session-1",
|
||||
changes: { model: "not-advertised" },
|
||||
}),
|
||||
).rejects.toThrow("session.configure");
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("derives optional connection capabilities from ACP initialization", async () => {
|
||||
const executable = await fakeAcp(`const readline = require("node:readline");
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
lines.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: {
|
||||
protocolVersion: message.params.protocolVersion,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: { image: true },
|
||||
sessionCapabilities: { list: {} },
|
||||
},
|
||||
} });
|
||||
});`);
|
||||
const { connection } = await connect(executable, [
|
||||
"prompt.message",
|
||||
"prompt.image",
|
||||
"session.list",
|
||||
"session.persistence",
|
||||
]);
|
||||
|
||||
expect(connection.capabilities).toEqual([
|
||||
"prompt.message",
|
||||
"prompt.image",
|
||||
"session.list",
|
||||
"session.persistence",
|
||||
]);
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("acknowledges admission before session creation completes", async () => {
|
||||
const executable = await fakeAcp(basicAgent);
|
||||
const { connection, events } = await connect(executable, ["prompt.message"]);
|
||||
let ready = false;
|
||||
const admission = connection.send(openInput()).then(() => {
|
||||
ready = true;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await admission;
|
||||
expect(ready).toBe(true);
|
||||
expect(events).toEqual([]);
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("rejects provider inputs after the ACP connection closes", async () => {
|
||||
const harness = connectorHarness();
|
||||
const registration = runAcpProvider({
|
||||
id: "closed-acp",
|
||||
label: "Closed ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({ versions: [1], capabilities: [] });
|
||||
|
||||
await connection.close();
|
||||
await expect(connection.send({ type: "catalog", requestId: "too-late" })).rejects.toThrow(
|
||||
"ACP provider connection is closed",
|
||||
);
|
||||
expect(harness.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("waits for delayed session creation and closes its connector after connection close", async () => {
|
||||
let delayedNew: AcpRequestMessage | null = null;
|
||||
const harness = connectorHarness({
|
||||
handleRequest(_instance, request) {
|
||||
if (request.method !== "session/new") return false;
|
||||
delayedNew = request;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "delayed-open-acp",
|
||||
label: "Delayed open ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send(openInput());
|
||||
await expect.poll(() => delayedNew).not.toBeNull();
|
||||
|
||||
let closeSettled = false;
|
||||
const closing = connection.close().then(() => {
|
||||
closeSettled = true;
|
||||
return undefined;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(closeSettled).toBe(false);
|
||||
harness.instances[1]!.respond(delayedNew!, {
|
||||
sessionId: "late-session",
|
||||
modes: null,
|
||||
configOptions: [],
|
||||
});
|
||||
await closing;
|
||||
|
||||
expect(harness.instances[1]).toMatchObject({ readsCanceled: true, writesClosed: true });
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses unsupported steering before sending an ACP prompt", async () => {
|
||||
const executable = await fakeAcp(basicAgent);
|
||||
const { connection, events } = await connect(executable, ["prompt.message"]);
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
await expect(
|
||||
connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "steer-1",
|
||||
delivery: "steer",
|
||||
input: { type: "message", content: [{ type: "text", text: "change course" }] },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("prompt.steer");
|
||||
expect(events.some((event) => event.type === "session.prompt_result")).toBe(false);
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("keeps configuration observable state atomic when a later field fails", async () => {
|
||||
const executable = await fakeAcp(`const readline = require("node:readline");
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
let model = "a";
|
||||
const options = () => [
|
||||
{ id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "A", value: "a" }, { name: "B", value: "b" }] },
|
||||
{ id: "verbosity", name: "Verbosity", type: "select", currentValue: "quiet", options: [{ name: "Quiet", value: "quiet" }, { name: "Loud", value: "loud" }] },
|
||||
];
|
||||
lines.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
|
||||
else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: options() } });
|
||||
else if (message.method === "session/set_config_option" && message.params.configId === "verbosity") send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "verbosity rejected" } });
|
||||
else if (message.method === "session/set_config_option") { model = message.params.value; send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } }); }
|
||||
});`);
|
||||
const { connection, events } = await connect(executable, [
|
||||
"prompt.message",
|
||||
"session.configure",
|
||||
]);
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
await connection.send({
|
||||
type: "session.configure",
|
||||
requestId: "configure-1",
|
||||
sessionId: "session-1",
|
||||
changes: { model: "b", settings: { verbosity: "loud" } },
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "request.failed" && event.requestId === "configure-1",
|
||||
);
|
||||
const configs = events.filter((event) => event.type === "session.config");
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0]).toMatchObject({ config: { model: "a" } });
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("commits transformer config notifications once and discards them on rollback", async () => {
|
||||
const executable = await fakeAcp(`const readline = require("node:readline");
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
let model = "a";
|
||||
const options = () => [
|
||||
{ id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "A", value: "a" }, { name: "B", value: "b" }] },
|
||||
{ id: "late", name: "Late", type: "select", currentValue: "ok", options: [{ name: "Ok", value: "ok" }, { name: "Fail", value: "fail" }] },
|
||||
];
|
||||
lines.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
|
||||
else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: options() } });
|
||||
else if (message.method === "session/set_config_option" && message.params.configId === "late") send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "late rejected" } });
|
||||
else if (message.method === "session/set_config_option") {
|
||||
model = message.params.value;
|
||||
send({ jsonrpc: "2.0", method: "vendor/config", params: { model } });
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } });
|
||||
}
|
||||
});`);
|
||||
const transformer = {
|
||||
notification(notification: { method: string; params: unknown }) {
|
||||
if (notification.method !== "vendor/config") return null;
|
||||
const params = notification.params as { model: string };
|
||||
return {
|
||||
type: "config" as const,
|
||||
config: {
|
||||
model: params.model,
|
||||
models: [{ id: params.model, label: `Transformer ${params.model}` }],
|
||||
modes: [],
|
||||
thinkingOptions: [],
|
||||
settings: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const { connection, events } = await connect(
|
||||
executable,
|
||||
["prompt.message", "session.configure"],
|
||||
[transformer],
|
||||
);
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
const initialConfigCount = events.filter((event) => event.type === "session.config").length;
|
||||
|
||||
await connection.send({
|
||||
type: "session.configure",
|
||||
requestId: "transformer-success",
|
||||
sessionId: "session-1",
|
||||
changes: { model: "b" },
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "request.completed" && event.requestId === "transformer-success",
|
||||
);
|
||||
let configs = events.filter((event) => event.type === "session.config");
|
||||
expect(configs).toHaveLength(initialConfigCount + 1);
|
||||
expect(configs.at(-1)).toMatchObject({
|
||||
config: { model: "b", models: [{ label: "Transformer b" }] },
|
||||
});
|
||||
|
||||
await connection.send({
|
||||
type: "session.configure",
|
||||
requestId: "transformer-failure",
|
||||
sessionId: "session-1",
|
||||
changes: { model: "a", settings: { late: "fail" } },
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "request.failed" && event.requestId === "transformer-failure",
|
||||
);
|
||||
configs = events.filter((event) => event.type === "session.config");
|
||||
expect(configs).toHaveLength(initialConfigCount + 1);
|
||||
expect(configs.at(-1)).toMatchObject({ config: { model: "b" } });
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("gives discovery transformers the discovered session configuration facade", async () => {
|
||||
const executable = await fakeAcp(`const readline = require("node:readline");
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
lines.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
|
||||
else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "catalog-native", modes: null, configOptions: [{ id: "flavor", name: "Flavor", type: "select", currentValue: "mint", options: [{ name: "Mint", value: "mint" }] }] } });
|
||||
});`);
|
||||
let discovered: Readonly<Record<string, unknown>> | null = null;
|
||||
const { connection, events } = await connect(
|
||||
executable,
|
||||
[],
|
||||
[
|
||||
{
|
||||
async discover(catalog, context) {
|
||||
discovered = await context.config.read();
|
||||
return catalog;
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await connection.send({ type: "catalog", requestId: "catalog-1" });
|
||||
await waitForEvent(events, (event) => event.type === "catalog");
|
||||
expect(discovered).toEqual({ flavor: "mint" });
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("drains heavy ACP stderr while initializing", async () => {
|
||||
const executable = await fakeAcp(
|
||||
`process.stderr.write("x".repeat(2 * 1024 * 1024));\n${basicAgent}`,
|
||||
);
|
||||
const { connection, events } = await connect(executable, ["prompt.message"]);
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("publishes runtime failure when the ACP process dies", async () => {
|
||||
const executable = await fakeAcp(`const readline = require("node:readline");
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
lines.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
|
||||
else if (message.method === "session/new") {
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } });
|
||||
setTimeout(() => process.exit(17), 20);
|
||||
}
|
||||
});`);
|
||||
const { connection, events } = await connect(executable, ["prompt.message"]);
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
const failure = await waitForEvent(events, (event) => event.type === "session.runtime_failed");
|
||||
expect(failure).toMatchObject({
|
||||
sessionId: "session-1",
|
||||
error: { message: expect.stringContaining("17") },
|
||||
});
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("escalates shutdown when an ACP process ignores SIGTERM", async () => {
|
||||
const executable = await fakeAcp(`const readline = require("node:readline");
|
||||
process.on("SIGTERM", () => {});
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
lines.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
|
||||
else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } });
|
||||
});`);
|
||||
const { connection, events } = await connect(executable, ["prompt.message"]);
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
|
||||
const startedAt = Date.now();
|
||||
await connection.close();
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(900);
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
ProviderCatalog,
|
||||
ProviderCommand,
|
||||
ProviderConfigState,
|
||||
ProviderNotice,
|
||||
ProviderRegistration,
|
||||
ProviderTimelineItem,
|
||||
} from "./provider.js";
|
||||
import { createAcpProviderConnection } from "./acp-internal/connection.js";
|
||||
|
||||
interface RunAcpProviderBaseOptions {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
acpOptions?: AcpOptions;
|
||||
transformers?: readonly AcpTransformer[];
|
||||
}
|
||||
|
||||
export type RunAcpProviderOptions = RunAcpProviderBaseOptions &
|
||||
(
|
||||
| { command: readonly [string, ...string[]]; connector?: never }
|
||||
| { connector: AcpConnector; command?: never }
|
||||
);
|
||||
|
||||
export type AcpStreamMessage =
|
||||
| { jsonrpc: "2.0"; id: string | number | null; method: string; params?: unknown }
|
||||
| { jsonrpc: "2.0"; method: string; params?: unknown }
|
||||
| { jsonrpc: "2.0"; id: string | number | null; result: unknown }
|
||||
| {
|
||||
jsonrpc: "2.0";
|
||||
id: string | number | null;
|
||||
error: { code: number; message: string; data?: unknown };
|
||||
};
|
||||
|
||||
export interface AcpStream {
|
||||
writable: WritableStream<AcpStreamMessage>;
|
||||
readable: ReadableStream<AcpStreamMessage>;
|
||||
}
|
||||
|
||||
export type AcpConnector = () => AcpStream | Promise<AcpStream>;
|
||||
|
||||
export interface AcpOptions {
|
||||
protocol?: "auto" | "v1";
|
||||
startupTimeoutMs?: number;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface AcpContext {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface AcpConfigAccess {
|
||||
read(): Promise<Readonly<Record<string, JsonValue>>>;
|
||||
set(id: string, value: JsonValue): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AcpDiscoveryContext extends AcpContext {
|
||||
config: AcpConfigAccess;
|
||||
}
|
||||
|
||||
export interface AcpConfigureContext extends AcpContext {
|
||||
config: AcpConfigAccess;
|
||||
}
|
||||
|
||||
export type AcpConfigChange =
|
||||
| { target: "model"; value: string | null }
|
||||
| { target: "mode"; value: string | null }
|
||||
| { target: "thinking"; value: string | null }
|
||||
| { target: "setting"; id: string; value: JsonValue };
|
||||
|
||||
export interface AcpToolCallSnapshot {
|
||||
id: string;
|
||||
name?: string;
|
||||
title: string;
|
||||
kind?: string;
|
||||
status: "pending" | "in_progress" | "completed" | "failed";
|
||||
input: JsonValue;
|
||||
output: JsonValue;
|
||||
locations: readonly string[];
|
||||
}
|
||||
|
||||
export type AcpVendorUpdate =
|
||||
| { type: "commands"; commands: readonly ProviderCommand[] }
|
||||
| { type: "config"; config: ProviderConfigState }
|
||||
| { type: "timeline"; item: ProviderTimelineItem }
|
||||
| { type: "notice"; notice: ProviderNotice };
|
||||
|
||||
export interface AcpTransformer {
|
||||
discover?(
|
||||
catalog: ProviderCatalog,
|
||||
context: AcpDiscoveryContext,
|
||||
): Promise<ProviderCatalog> | ProviderCatalog;
|
||||
configure?(change: AcpConfigChange, context: AcpConfigureContext): Promise<"handled" | "pass">;
|
||||
notification?(
|
||||
notification: { method: string; params: JsonValue },
|
||||
context: AcpContext,
|
||||
): AcpVendorUpdate | readonly AcpVendorUpdate[] | null;
|
||||
toolCall?(toolCall: AcpToolCallSnapshot, context: AcpContext): AcpToolCallSnapshot;
|
||||
}
|
||||
|
||||
export function runAcpProvider(options: RunAcpProviderOptions): ProviderRegistration {
|
||||
const hasCommand = options.command !== undefined;
|
||||
const hasConnector = options.connector !== undefined;
|
||||
if (hasCommand === hasConnector) {
|
||||
throw new Error("ACP provider requires exactly one command or SDK connector");
|
||||
}
|
||||
if (options.command && (options.command.length === 0 || options.command[0].trim().length === 0)) {
|
||||
throw new Error("ACP provider command must contain an executable");
|
||||
}
|
||||
return {
|
||||
id: options.id,
|
||||
label: options.label,
|
||||
description: options.description,
|
||||
icon: options.icon,
|
||||
connect(request) {
|
||||
return createAcpProviderConnection(options, request);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
import type { PluginRpcContract } from "./rpc.js";
|
||||
import type { ProviderRegistration } from "./provider.js";
|
||||
|
||||
export interface PluginTheme {
|
||||
readonly colors: {
|
||||
@@ -317,6 +318,7 @@ export interface PluginServerContext {
|
||||
context: PluginHandlerContext,
|
||||
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
|
||||
): void;
|
||||
registerProvider(provider: ProviderRegistration): void;
|
||||
}
|
||||
|
||||
export type PluginCleanup = () => void | Promise<void>;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"types": ["node"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface ProviderSnapshotEntry {
|
||||
fetchedAt?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
iconSvg?: string;
|
||||
defaultModeId?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -377,6 +377,7 @@ export const ProviderSnapshotEntrySchema = z.object({
|
||||
fetchedAt: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
iconSvg: z.string().optional(),
|
||||
defaultModeId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ function providerEntry(): ProviderSnapshotEntry {
|
||||
provider: "pi",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
iconSvg: '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>',
|
||||
models: [
|
||||
{
|
||||
provider: "pi",
|
||||
|
||||
@@ -125,6 +125,7 @@ export interface ProviderSnapshotEntry {
|
||||
fetchedAt?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
iconSvg?: string;
|
||||
defaultModeId?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import type {
|
||||
ProviderConnection,
|
||||
ProviderEvent,
|
||||
ProviderInput,
|
||||
ProviderRegistration,
|
||||
} from "@getpaseo/plugin/provider";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "./agent-sdk-types.js";
|
||||
import { PluginAgentClientRegistry } from "./plugin-provider.js";
|
||||
|
||||
const CAPABILITIES = [
|
||||
"prompt.message",
|
||||
"session.configure",
|
||||
"session.persistence",
|
||||
"session.subsession",
|
||||
"permission",
|
||||
] as const;
|
||||
|
||||
interface ProviderHarnessOptions {
|
||||
capabilities?: ProviderConnection["capabilities"];
|
||||
completeTurn?: boolean;
|
||||
}
|
||||
|
||||
function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
let listener: ((event: ProviderEvent) => void) | null = null;
|
||||
let closeCount = 0;
|
||||
const inputs: ProviderInput[] = [];
|
||||
const emit = (event: ProviderEvent) => listener?.(event);
|
||||
const capabilities = options.capabilities ?? CAPABILITIES;
|
||||
|
||||
const connection: ProviderConnection = {
|
||||
version: 1,
|
||||
capabilities,
|
||||
async send(input) {
|
||||
inputs.push(input);
|
||||
if (input.type === "catalog") {
|
||||
emit({
|
||||
type: "catalog",
|
||||
requestId: input.requestId,
|
||||
catalog: {
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [{ id: "deep", label: "Deep" }],
|
||||
defaultModel: "plugin-model",
|
||||
defaultMode: "build",
|
||||
defaultThinkingOption: "deep",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (input.type === "session.open") {
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities,
|
||||
restoration: "core",
|
||||
persistence: { version: 1, data: { token: "root" } },
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
emit({
|
||||
type: "session.config",
|
||||
sessionId: input.sessionId,
|
||||
config: {
|
||||
model: "plugin-model",
|
||||
mode: "build",
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
settings: [
|
||||
{
|
||||
type: "select",
|
||||
id: "voice",
|
||||
label: "Voice",
|
||||
value: "direct",
|
||||
options: [{ label: "Direct", value: "direct" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
emit({
|
||||
type: "session.opened",
|
||||
sessionId: "child-1",
|
||||
parentSessionId: input.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
title: "Plugin child",
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: "child-1",
|
||||
item: { type: "assistant_message", id: "child-message", text: "Child result" },
|
||||
});
|
||||
emit({
|
||||
type: "session.turn",
|
||||
sessionId: "child-1",
|
||||
turnId: "child-turn",
|
||||
state: "completed",
|
||||
});
|
||||
emit({ type: "session.ready", sessionId: "child-1" });
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return;
|
||||
}
|
||||
if (input.type === "session.prompt") {
|
||||
emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "turn", turnId: "turn-1" },
|
||||
});
|
||||
emit({
|
||||
type: "session.turn",
|
||||
sessionId: input.sessionId,
|
||||
turnId: "turn-1",
|
||||
state: "started",
|
||||
});
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: { type: "assistant_message", id: "answer", text: "Hel" },
|
||||
});
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: { type: "assistant_message", id: "answer", text: "Hello" },
|
||||
});
|
||||
emit({
|
||||
type: "session.permission",
|
||||
sessionId: input.sessionId,
|
||||
request: { id: "permission-1", name: "write", kind: "tool" },
|
||||
});
|
||||
if (options.completeTurn !== false) {
|
||||
emit({
|
||||
type: "session.turn",
|
||||
sessionId: input.sessionId,
|
||||
turnId: "turn-1",
|
||||
state: "completed",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (input.type === "session.permission") {
|
||||
emit({
|
||||
type: "session.permission_resolved",
|
||||
sessionId: input.sessionId,
|
||||
permissionId: input.permissionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (input.type === "session.configure") {
|
||||
emit({
|
||||
type: "session.config",
|
||||
sessionId: input.sessionId,
|
||||
config: {
|
||||
model: input.changes.model ?? "plugin-model",
|
||||
mode: "build",
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
settings: [],
|
||||
},
|
||||
});
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
return;
|
||||
}
|
||||
if (input.type === "session.close") {
|
||||
emit({ type: "session.closed", sessionId: input.sessionId });
|
||||
}
|
||||
if ("requestId" in input) {
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
}
|
||||
},
|
||||
onEvent(nextListener) {
|
||||
listener = nextListener;
|
||||
return () => {
|
||||
if (listener === nextListener) listener = null;
|
||||
};
|
||||
},
|
||||
async close() {
|
||||
closeCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const registration: ProviderRegistration = {
|
||||
id: "plugin-direct",
|
||||
label: "Plugin direct",
|
||||
async connect() {
|
||||
return connection;
|
||||
},
|
||||
};
|
||||
|
||||
return { registration, inputs, closeCount: () => closeCount };
|
||||
}
|
||||
|
||||
function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]) {
|
||||
return events.filter((event) => event.type === type);
|
||||
}
|
||||
|
||||
describe("PluginAgentClientRegistry", () => {
|
||||
test("gates persistence operations on negotiated provider capabilities", async () => {
|
||||
const harness = createProviderHarness({ capabilities: ["session.persistence"] });
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
|
||||
await expect(client.isAvailable()).resolves.toBe(true);
|
||||
expect(client.capabilities).toMatchObject({
|
||||
supportsSessionPersistence: true,
|
||||
supportsSessionListing: false,
|
||||
});
|
||||
await expect(client.listImportableSessions?.()).resolves.toEqual([]);
|
||||
|
||||
const persistence = {
|
||||
provider: harness.registration.id,
|
||||
sessionId: 'plugin:{"version":1,"data":{"token":"root"}}',
|
||||
metadata: { pluginProviderPersistence: { version: 1, data: { token: "root" } } },
|
||||
};
|
||||
await expect(client.archiveNativeSession?.(persistence)).resolves.toBeUndefined();
|
||||
await expect(client.unarchiveNativeSession?.(persistence)).resolves.toBeUndefined();
|
||||
expect(harness.inputs).toEqual([]);
|
||||
await registry.shutdown();
|
||||
});
|
||||
|
||||
test("terminalizes an active turn exactly once when its plugin provider is removed", async () => {
|
||||
const harness = createProviderHarness({ completeTurn: false });
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: "/workspace",
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
const run = session.run("hello", { clientMessageId: "active-message" });
|
||||
void run.catch(() => undefined);
|
||||
await expect.poll(() => eventsOfType(events, "turn_started")).toHaveLength(1);
|
||||
|
||||
registry.replace([]);
|
||||
|
||||
await expect
|
||||
.poll(() => eventsOfType(events, "turn_failed"))
|
||||
.toEqual([
|
||||
expect.objectContaining({
|
||||
type: "turn_failed",
|
||||
provider: harness.registration.id,
|
||||
turnId: "turn-1",
|
||||
error: "Provider connection closed",
|
||||
}),
|
||||
]);
|
||||
await expect(run).rejects.toThrow("Provider connection closed");
|
||||
await expect.poll(harness.closeCount).toBe(1);
|
||||
|
||||
registry.replace([]);
|
||||
expect(eventsOfType(events, "turn_failed")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("adapts callback providers into the existing AgentClient and AgentSession path", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id];
|
||||
expect(client).toBeDefined();
|
||||
|
||||
await expect(client!.fetchCatalog({ scope: "global", force: false })).resolves.toMatchObject({
|
||||
models: [
|
||||
{
|
||||
provider: "plugin-direct",
|
||||
id: "plugin-model",
|
||||
isDefault: true,
|
||||
thinkingOptions: [{ id: "deep", label: "Deep" }],
|
||||
defaultThinkingOptionId: "deep",
|
||||
},
|
||||
],
|
||||
modes: [{ id: "build" }],
|
||||
defaultModeId: "build",
|
||||
});
|
||||
|
||||
const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" });
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "select",
|
||||
id: "voice",
|
||||
options: [{ id: "direct", label: "Direct", value: "direct" }],
|
||||
}),
|
||||
]);
|
||||
expect(session.describePersistence()).toMatchObject({
|
||||
provider: "plugin-direct",
|
||||
metadata: { pluginProviderPersistence: { version: 1, data: { token: "root" } } },
|
||||
});
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) history.push(event);
|
||||
expect(history).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: expect.objectContaining({ type: "timeline", id: "child-1" }),
|
||||
}),
|
||||
);
|
||||
expect(history).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: expect.objectContaining({ type: "upsert", id: "child-1", status: "completed" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => events.push(event));
|
||||
await expect(
|
||||
session.startTurn("hello", { clientMessageId: "client-message" }),
|
||||
).resolves.toEqual({ turnId: "turn-1" });
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.type === "timeline")
|
||||
.map((event) => (event.type === "timeline" ? event.item : null)),
|
||||
).toEqual([
|
||||
{ type: "assistant_message", text: "Hel", messageId: "answer" },
|
||||
{ type: "assistant_message", text: "lo", messageId: "answer" },
|
||||
]);
|
||||
expect(session.getPendingPermissions()).toEqual([
|
||||
expect.objectContaining({ id: "permission-1", provider: "plugin-direct" }),
|
||||
]);
|
||||
|
||||
await session.respondToPermission("permission-1", { behavior: "allow" });
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "permission_resolved",
|
||||
requestId: "permission-1",
|
||||
resolution: { behavior: "allow" },
|
||||
}),
|
||||
);
|
||||
await session.setModel?.("plugin-model");
|
||||
expect(await session.getRuntimeInfo()).toMatchObject({
|
||||
model: "plugin-model",
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
await session.close();
|
||||
registry.replace([]);
|
||||
await expect.poll(harness.closeCount).toBe(1);
|
||||
expect(harness.inputs.map((input) => input.type)).toContain("session.close");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,7 @@ export type { AgentProviderDefinition };
|
||||
export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
|
||||
|
||||
export interface ProviderDefinition extends AgentProviderDefinition {
|
||||
iconSvg?: string;
|
||||
enabled: boolean;
|
||||
/**
|
||||
* The id of another *registered* provider this one extends (e.g. a Z.AI
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type {
|
||||
@@ -105,6 +106,29 @@ async function runTestCatalogActivities(
|
||||
}
|
||||
|
||||
describe("ProviderSnapshotManager public surface", () => {
|
||||
test("carries a plugin provider icon in snapshot metadata", () => {
|
||||
const iconSvg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
const registration: ProviderRegistration = {
|
||||
id: "icon-provider",
|
||||
label: "Icon Provider",
|
||||
icon: iconSvg,
|
||||
async connect() {
|
||||
throw new Error("not opened by this test");
|
||||
},
|
||||
};
|
||||
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
|
||||
|
||||
try {
|
||||
manager.replacePluginProviders([registration]);
|
||||
|
||||
expect(manager.getSnapshot("/tmp/project")).toContainEqual(
|
||||
expect.objectContaining({ provider: "icon-provider", iconSvg }),
|
||||
);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("validates complete Hub agent configurations through the current provider contract", async () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
@@ -1708,6 +1732,55 @@ describe("ProviderSnapshotManager cwd routing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("registers and unregisters plugin providers without rebuilding built-in clients", async () => {
|
||||
let listener: ((event: ProviderEvent) => void) | null = null;
|
||||
const registration: ProviderRegistration = {
|
||||
id: "plugin-provider",
|
||||
label: "Plugin provider",
|
||||
async connect() {
|
||||
return {
|
||||
version: 1,
|
||||
capabilities: [],
|
||||
async send(input) {
|
||||
if (input.type !== "catalog") return;
|
||||
listener?.({
|
||||
type: "catalog",
|
||||
requestId: input.requestId,
|
||||
catalog: { models: [{ id: "plugin-model", label: "Plugin model" }], modes: [] },
|
||||
});
|
||||
},
|
||||
onEvent(nextListener) {
|
||||
listener = nextListener;
|
||||
return () => {
|
||||
if (listener === nextListener) listener = null;
|
||||
};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
|
||||
try {
|
||||
const state = manager.replacePluginProviders([registration]);
|
||||
expect(state.clients[registration.id]?.provider).toBe(registration.id);
|
||||
await expect(
|
||||
manager.getProvider({ provider: registration.id, wait: true }),
|
||||
).resolves.toMatchObject({
|
||||
provider: registration.id,
|
||||
source: "custom",
|
||||
status: "ready",
|
||||
models: [{ provider: registration.id, id: "plugin-model" }],
|
||||
});
|
||||
|
||||
const withoutPlugin = manager.replacePluginProviders([]);
|
||||
expect(manager.hasProvider(registration.id)).toBe(false);
|
||||
expect(withoutPlugin.clients[registration.id]).toBeUndefined();
|
||||
} finally {
|
||||
await manager.shutdown();
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveSnapshotCwd normalizes pure drive letters to append backslash on Windows", () => {
|
||||
const resolved = resolveSnapshotCwd("C:");
|
||||
if (process.platform === "win32") {
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
type AgentConfigurationValidationInput,
|
||||
validateAgentConfigurationAgainstProvider,
|
||||
} from "./agent-configuration-validator.js";
|
||||
import type { ProviderRegistration } from "@getpaseo/plugin/provider";
|
||||
import { PluginAgentClientRegistry } from "./plugin-provider.js";
|
||||
|
||||
const DEFAULT_REFRESH_TIMEOUT_MS = 120_000;
|
||||
const MAX_REFRESH_TIMEOUT_MS = 2_147_483_647;
|
||||
@@ -219,9 +221,13 @@ export class ProviderSnapshotManager {
|
||||
private providerRegistry: Record<AgentProvider, ProviderDefinition>;
|
||||
private providerClients: Record<AgentProvider, AgentClient>;
|
||||
private readonly ownedClients = new Set<AgentClient>();
|
||||
private readonly pluginProviders: PluginAgentClientRegistry;
|
||||
|
||||
constructor(options: ProviderSnapshotManagerOptions) {
|
||||
this.logger = options.logger;
|
||||
this.pluginProviders = new PluginAgentClientRegistry(
|
||||
options.logger.child({ module: "plugin-providers" }),
|
||||
);
|
||||
this.workspaceGitService = options.workspaceGitService;
|
||||
this.managedProcesses = options.managedProcesses;
|
||||
this.openCodeBridge = options.openCodeBridge;
|
||||
@@ -236,7 +242,10 @@ export class ProviderSnapshotManager {
|
||||
this.refreshTimeoutMs,
|
||||
);
|
||||
this.providerRegistry = this.buildRegistry();
|
||||
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
|
||||
this.providerClients = {
|
||||
...this.extraClients,
|
||||
...this.pluginProviders.clients(),
|
||||
} as Record<AgentProvider, AgentClient>;
|
||||
for (const client of Object.values(this.providerClients)) this.ownedClients.add(client);
|
||||
}
|
||||
|
||||
@@ -322,6 +331,42 @@ export class ProviderSnapshotManager {
|
||||
return { providerDefinitions, clients };
|
||||
}
|
||||
|
||||
replacePluginProviders(
|
||||
registrations: readonly ProviderRegistration[],
|
||||
): AgentManagerProviderState {
|
||||
for (const registration of registrations) {
|
||||
if (
|
||||
(this.providerRegistry[registration.id] || this.extraClients[registration.id]) &&
|
||||
!this.pluginProviders.has(registration.id)
|
||||
) {
|
||||
throw new Error(
|
||||
`Plugin provider '${registration.id}' conflicts with a configured provider`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.pluginProviders.replace(registrations);
|
||||
this.providerRegistry = this.buildRegistry();
|
||||
this.providerClients = {
|
||||
...this.extraClients,
|
||||
...this.pluginProviders.clients(),
|
||||
} as Record<AgentProvider, AgentClient>;
|
||||
for (const client of Object.values(this.providerClients)) this.ownedClients.add(client);
|
||||
|
||||
for (const cwd of this.snapshots.keys()) {
|
||||
this.providerLoads.delete(cwd);
|
||||
this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd));
|
||||
this.emitChange(cwd);
|
||||
const target =
|
||||
cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY
|
||||
? createGlobalSnapshotTarget()
|
||||
: createWorkspaceSnapshotTarget(cwd);
|
||||
const providers = this.resolveProvidersToWarm(cwd);
|
||||
if (providers.length > 0) void this.warmUp(target, providers);
|
||||
}
|
||||
|
||||
return this.getAgentManagerProviderState();
|
||||
}
|
||||
|
||||
private ensureClient(provider: AgentProvider, definition: ProviderDefinition): AgentClient {
|
||||
const existing = this.providerClients[provider];
|
||||
if (existing) {
|
||||
@@ -503,7 +548,10 @@ export class ProviderSnapshotManager {
|
||||
// startup-derived runtime settings here would retain removed command/env fields.
|
||||
if (options.replace) this.runtimeSettings = undefined;
|
||||
this.providerRegistry = this.buildRegistry();
|
||||
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
|
||||
this.providerClients = {
|
||||
...this.extraClients,
|
||||
...this.pluginProviders.clients(),
|
||||
} as Record<AgentProvider, AgentClient>;
|
||||
|
||||
for (const cwd of this.snapshots.keys()) {
|
||||
this.providerLoads.delete(cwd);
|
||||
@@ -597,6 +645,13 @@ export class ProviderSnapshotManager {
|
||||
isDev: this.isDev,
|
||||
});
|
||||
|
||||
for (const [provider, definition] of Object.entries(this.pluginProviders.definitions())) {
|
||||
if (registry[provider]) {
|
||||
throw new Error(`Plugin provider '${provider}' conflicts with a configured provider`);
|
||||
}
|
||||
registry[provider] = definition;
|
||||
}
|
||||
|
||||
for (const [provider, client] of Object.entries(this.extraClients) as Array<
|
||||
[AgentProvider, AgentClient]
|
||||
>) {
|
||||
@@ -680,6 +735,7 @@ export class ProviderSnapshotManager {
|
||||
source: this.getProviderSource(provider),
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
iconSvg: definition.iconSvg,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
error: toErrorMessage(error),
|
||||
};
|
||||
@@ -712,6 +768,7 @@ export class ProviderSnapshotManager {
|
||||
}
|
||||
|
||||
private getProviderSource(provider: AgentProvider): ProviderSnapshotEntry["source"] {
|
||||
if (this.pluginProviders.has(provider)) return "custom";
|
||||
const isBuiltin = BUILTIN_PROVIDER_IDS.includes(provider);
|
||||
return !isBuiltin && this.providerOverrides?.[provider]?.extends ? "custom" : "builtin";
|
||||
}
|
||||
@@ -727,6 +784,7 @@ export class ProviderSnapshotManager {
|
||||
source: this.getProviderSource(provider),
|
||||
label: definition?.label,
|
||||
description: definition?.description,
|
||||
iconSvg: definition?.iconSvg,
|
||||
defaultModeId: definition?.defaultModeId ?? null,
|
||||
});
|
||||
}
|
||||
@@ -746,6 +804,7 @@ export class ProviderSnapshotManager {
|
||||
source: this.getProviderSource(provider),
|
||||
label: definition?.label,
|
||||
description: definition?.description,
|
||||
iconSvg: definition?.iconSvg,
|
||||
defaultModeId: definition?.defaultModeId ?? null,
|
||||
};
|
||||
|
||||
@@ -914,6 +973,7 @@ export class ProviderSnapshotManager {
|
||||
source: this.getProviderSource(provider),
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
iconSvg: definition.iconSvg,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
};
|
||||
const setEntry = (entry: ProviderSnapshotEntry) => {
|
||||
|
||||
@@ -90,7 +90,7 @@ if (${JSON.stringify(launchMode)} === "piRuntime") {
|
||||
});
|
||||
process.stdin.on("end", () => process.exit(0));
|
||||
} else {
|
||||
console.log("ARGV_OK");
|
||||
process.stdout.write("ARGV_OK\\n", () => process.exit(0));
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
@@ -922,6 +922,13 @@ export async function createPaseoDaemon(
|
||||
resolvePaseoToolPolicy(provider, daemonConfigStore.get().providers),
|
||||
logger,
|
||||
});
|
||||
const syncPluginProviders = () => {
|
||||
agentManager.updateProviderRegistry(
|
||||
providerSnapshotManager.replacePluginProviders(pluginRuntime.getProviderRegistrations()),
|
||||
);
|
||||
};
|
||||
const unsubscribePluginProviders =
|
||||
pluginRuntime.subscribeProviderRegistrations(syncPluginProviders);
|
||||
|
||||
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
||||
logger,
|
||||
@@ -1745,6 +1752,7 @@ export async function createPaseoDaemon(
|
||||
speechService.start();
|
||||
scriptHealthMonitor.start();
|
||||
} catch (error) {
|
||||
unsubscribePluginProviders();
|
||||
await pluginRuntime.stopAllPlugins().catch(() => undefined);
|
||||
await serviceProxy.stopStandalone().catch(() => undefined);
|
||||
await agentProviderRuntime.shutdown().catch(() => undefined);
|
||||
@@ -1758,6 +1766,7 @@ export async function createPaseoDaemon(
|
||||
|
||||
const stop = async () => {
|
||||
await pluginRuntime.stopAllPlugins();
|
||||
unsubscribePluginProviders();
|
||||
await hubRelationships.stop();
|
||||
workspaceReconciliation.dispose();
|
||||
scriptHealthMonitor.stop();
|
||||
|
||||
@@ -155,6 +155,59 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
|
||||
}
|
||||
|
||||
describe("PluginService", () => {
|
||||
it("resolves a provider icon path to sanitized inline SVG", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const directory = await createPlugin(
|
||||
"provider-icon",
|
||||
`export default function contribute(server) {
|
||||
server.registerProvider({
|
||||
id: "plugin-agent",
|
||||
label: "Plugin agent",
|
||||
icon: "icon.svg",
|
||||
async connect() { throw new Error("not opened by this test"); },
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const iconSvg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
await writeFile(path.join(directory, "icon.svg"), iconSvg);
|
||||
const service = createService(home);
|
||||
|
||||
await service.start();
|
||||
await service.installDirectory({ path: directory });
|
||||
|
||||
expect(service.getProviderRegistrations()).toMatchObject([
|
||||
{ id: "plugin-agent", icon: iconSvg },
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes provider registrations only while their plugin is running", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
const directory = await createPlugin(
|
||||
"provider-lifecycle",
|
||||
`export default function contribute(server) {
|
||||
server.registerProvider({
|
||||
id: "plugin-agent",
|
||||
label: "Plugin agent",
|
||||
async connect() { throw new Error("not opened by this test"); },
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const service = createService(home);
|
||||
|
||||
await service.start();
|
||||
await service.installDirectory({ path: directory });
|
||||
expect(service.getProviderRegistrations()).toMatchObject([
|
||||
{ id: "plugin-agent", label: "Plugin agent" },
|
||||
]);
|
||||
|
||||
await service.disablePlugin("provider-lifecycle");
|
||||
expect(service.getProviderRegistrations()).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains logs when disabled and clears them only when removed", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { stat } from "node:fs/promises";
|
||||
import type pino from "pino";
|
||||
import type { ProviderRegistration } from "@getpaseo/plugin/provider";
|
||||
import {
|
||||
PluginIdSchema,
|
||||
type PluginLogEntry,
|
||||
@@ -10,17 +11,24 @@ import {
|
||||
type PluginSourceUpdateItem,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
|
||||
import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
|
||||
import type { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js";
|
||||
import { readPluginManifest } from "./manifest.js";
|
||||
import { runPluginBuild } from "./preparation.js";
|
||||
import { PluginRuntime } from "./runtime.js";
|
||||
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
|
||||
import { readPluginProviderIcon } from "./provider-icon.js";
|
||||
|
||||
const BUILTIN_PROVIDER_ID_SET: ReadonlySet<string> = new Set(BUILTIN_PROVIDER_IDS);
|
||||
|
||||
interface PluginRuntimePort {
|
||||
catalog(): Array<{ id: string; clientBundle: string }>;
|
||||
invoke(pluginId: string, method: string, input: unknown): Promise<unknown>;
|
||||
getLogs(pluginId: string): PluginLogEntry[];
|
||||
clearLogs(pluginId: string): void;
|
||||
getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[];
|
||||
connectProvider: PluginRuntime["connectProvider"];
|
||||
validatePlugin?(path: string): Promise<void>;
|
||||
startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise<void>;
|
||||
stopPluginById(pluginId: string): Promise<boolean>;
|
||||
@@ -49,6 +57,9 @@ export class PluginService {
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly errors = new Map<string, string>();
|
||||
private readonly listeners = new Set<(pluginId: string) => void>();
|
||||
private readonly providers = new Map<string, ProviderRegistration>();
|
||||
private readonly providerIdsByPlugin = new Map<string, readonly string[]>();
|
||||
private readonly providerListeners = new Set<() => void>();
|
||||
private lifecycle = Promise.resolve();
|
||||
private globalStartsBlocked = true;
|
||||
private started = false;
|
||||
@@ -63,6 +74,7 @@ export class PluginService {
|
||||
this.runtime = dependencies.runtime ?? new PluginRuntime(logger, daemonVersion);
|
||||
this.managedSources = dependencies.managedSources ?? null;
|
||||
this.runtime.subscribe((pluginId, error) => {
|
||||
this.removeProviderRegistrations(pluginId);
|
||||
if (error) this.errors.set(pluginId, error);
|
||||
this.notify(pluginId);
|
||||
});
|
||||
@@ -77,6 +89,15 @@ export class PluginService {
|
||||
this.runtime.bindPaseoSessionHost(sessionHost);
|
||||
}
|
||||
|
||||
getProviderRegistrations(): readonly ProviderRegistration[] {
|
||||
return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
subscribeProviderRegistrations(listener: () => void): () => void {
|
||||
this.providerListeners.add(listener);
|
||||
return () => this.providerListeners.delete(listener);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
@@ -252,7 +273,7 @@ export class PluginService {
|
||||
throw new Error("Plugins are globally disabled");
|
||||
}
|
||||
this.errors.delete(pluginId);
|
||||
await this.runtime.stopPluginById(pluginId);
|
||||
await this.stopPlugin(pluginId);
|
||||
await this.startExplicit(pluginId, source.path);
|
||||
this.notify(pluginId);
|
||||
return this.requireItem(pluginId);
|
||||
@@ -276,7 +297,7 @@ export class PluginService {
|
||||
async disablePlugin(pluginId: string): Promise<PluginListItem> {
|
||||
const source = this.requireSource(pluginId);
|
||||
this.patchSource(pluginId, { ...source, enabled: false });
|
||||
const stopping = this.runtime.stopPluginById(pluginId);
|
||||
const stopping = this.stopPlugin(pluginId);
|
||||
return this.enqueue(async () => {
|
||||
await stopping;
|
||||
this.errors.delete(pluginId);
|
||||
@@ -287,7 +308,7 @@ export class PluginService {
|
||||
|
||||
async removePlugin(pluginId: string): Promise<void> {
|
||||
this.requireSource(pluginId);
|
||||
const stopping = this.runtime.stopPluginById(pluginId);
|
||||
const stopping = this.stopPlugin(pluginId);
|
||||
const sources = { ...this.configStore.get().plugins };
|
||||
delete sources[pluginId];
|
||||
this.configStore.patch({ plugins: sources });
|
||||
@@ -306,17 +327,17 @@ export class PluginService {
|
||||
|
||||
async stopAllPlugins(): Promise<void> {
|
||||
this.globalStartsBlocked = true;
|
||||
const stopping = this.runtime.stopAll();
|
||||
const stopping = this.stopAll();
|
||||
await this.enqueue(async () => {
|
||||
await stopping;
|
||||
await this.runtime.stopAll();
|
||||
await this.stopAll();
|
||||
});
|
||||
}
|
||||
|
||||
private handleGlobalSwitch(enabled: boolean): void {
|
||||
if (!enabled) {
|
||||
this.globalStartsBlocked = true;
|
||||
const stopping = this.runtime.stopAll();
|
||||
const stopping = this.stopAll();
|
||||
for (const id of Object.keys(this.configStore.get().plugins ?? {})) this.notify(id);
|
||||
void this.enqueue(async () => {
|
||||
await stopping;
|
||||
@@ -337,7 +358,7 @@ export class PluginService {
|
||||
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
|
||||
this.errors.delete(pluginId);
|
||||
try {
|
||||
await this.runtime.startPlugin(pluginId, source.path, () => this.canPublish(pluginId));
|
||||
await this.startPlugin(pluginId, source.path);
|
||||
} catch (error) {
|
||||
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
|
||||
}
|
||||
@@ -345,7 +366,7 @@ export class PluginService {
|
||||
|
||||
private async startExplicit(pluginId: string, sourcePath: string): Promise<void> {
|
||||
try {
|
||||
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
|
||||
await this.startPlugin(pluginId, sourcePath);
|
||||
} catch (error) {
|
||||
if (this.canPublish(pluginId)) {
|
||||
this.recordFailure(pluginId, error);
|
||||
@@ -365,6 +386,88 @@ export class PluginService {
|
||||
);
|
||||
}
|
||||
|
||||
private async startPlugin(pluginId: string, sourcePath: string): Promise<void> {
|
||||
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
|
||||
try {
|
||||
await this.publishProviderRegistrations(pluginId, sourcePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
this.removeProviderRegistrations(pluginId);
|
||||
} finally {
|
||||
await this.runtime.stopPluginById(pluginId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private stopPlugin(pluginId: string): Promise<boolean> {
|
||||
this.removeProviderRegistrations(pluginId);
|
||||
return this.runtime.stopPluginById(pluginId);
|
||||
}
|
||||
|
||||
private async stopAll(): Promise<void> {
|
||||
for (const pluginId of this.providerIdsByPlugin.keys()) {
|
||||
this.removeProviderRegistrations(pluginId);
|
||||
}
|
||||
await this.runtime.stopAll();
|
||||
}
|
||||
|
||||
private async publishProviderRegistrations(
|
||||
pluginId: string,
|
||||
pluginDirectory: string,
|
||||
): Promise<void> {
|
||||
const metadata = this.runtime.getProviderRegistrations?.(pluginId) ?? [];
|
||||
const configuredIds = new Set(Object.keys(this.configStore.get().providers));
|
||||
for (const provider of metadata) {
|
||||
if (BUILTIN_PROVIDER_ID_SET.has(provider.id)) {
|
||||
throw new Error(`Plugin ${pluginId} cannot register builtin provider ID "${provider.id}"`);
|
||||
}
|
||||
if (configuredIds.has(provider.id)) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} cannot register configured provider ID "${provider.id}"`,
|
||||
);
|
||||
}
|
||||
if (this.providers.has(provider.id)) {
|
||||
throw new Error(`Plugin ${pluginId} cannot register provider ID "${provider.id}" twice`);
|
||||
}
|
||||
}
|
||||
const registrations = await Promise.all(
|
||||
metadata.map(
|
||||
async (provider): Promise<ProviderRegistration> => ({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
description: provider.description,
|
||||
icon: provider.iconPath
|
||||
? await readPluginProviderIcon(pluginDirectory, provider.iconPath)
|
||||
: undefined,
|
||||
connect: (request) => this.runtime.connectProvider(pluginId, provider.id, request),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const ids = registrations.map((provider) => provider.id);
|
||||
for (const provider of registrations) {
|
||||
this.providers.set(provider.id, {
|
||||
...provider,
|
||||
});
|
||||
}
|
||||
if (ids.length > 0) {
|
||||
this.providerIdsByPlugin.set(pluginId, ids);
|
||||
this.notifyProviderRegistrations();
|
||||
}
|
||||
}
|
||||
|
||||
private removeProviderRegistrations(pluginId: string): void {
|
||||
const ids = this.providerIdsByPlugin.get(pluginId);
|
||||
if (!ids) return;
|
||||
this.providerIdsByPlugin.delete(pluginId);
|
||||
for (const id of ids) this.providers.delete(id);
|
||||
this.notifyProviderRegistrations();
|
||||
}
|
||||
|
||||
private notifyProviderRegistrations(): void {
|
||||
for (const listener of this.providerListeners) listener();
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.lifecycle.then(operation, operation);
|
||||
this.lifecycle = result.then(
|
||||
@@ -413,7 +516,7 @@ export class PluginService {
|
||||
const isGloballyEnabled = this.configStore.get().pluginsEnabled === true;
|
||||
const shouldActivate = isEnabled && isGloballyEnabled;
|
||||
if (shouldActivate) {
|
||||
if (isRunning) await this.runtime.stopPluginById(pluginId);
|
||||
if (isRunning) await this.stopPlugin(pluginId);
|
||||
try {
|
||||
await this.startExplicit(pluginId, candidate.directory);
|
||||
} catch (error) {
|
||||
@@ -432,7 +535,7 @@ export class PluginService {
|
||||
|
||||
const activatedSource = this.configStore.get().plugins?.[pluginId];
|
||||
if (!activatedSource) {
|
||||
await this.runtime.stopPluginById(pluginId);
|
||||
await this.stopPlugin(pluginId);
|
||||
await managedSources.discard(candidate);
|
||||
throw new Error(`Plugin is no longer configured: ${pluginId}`);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,185 @@
|
||||
import type {
|
||||
ProviderConnectRequest,
|
||||
ProviderEvent,
|
||||
ProviderInput,
|
||||
} from "@getpaseo/plugin/provider";
|
||||
import { ProviderEventSchema, ProviderInputSchema } from "@getpaseo/plugin/provider";
|
||||
import { z } from "zod";
|
||||
|
||||
export interface PluginProviderMetadata {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
iconPath?: string;
|
||||
}
|
||||
|
||||
export type PluginProcessRequest =
|
||||
| { type: "initialize"; pluginId: string; bundle: string; appVersion: string }
|
||||
| { type: "invoke"; requestId: string; method: string; input: unknown }
|
||||
| {
|
||||
type: "provider.connect";
|
||||
providerId: string;
|
||||
connectionId: string;
|
||||
request: ProviderConnectRequest;
|
||||
}
|
||||
| {
|
||||
type: "provider.send";
|
||||
connectionId: string;
|
||||
acceptanceId: string;
|
||||
input: ProviderInput;
|
||||
}
|
||||
| { type: "provider.close"; connectionId: string }
|
||||
| { type: "shutdown" }
|
||||
| { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean }
|
||||
| { type: "paseo_close" };
|
||||
|
||||
export type PluginProcessMessage =
|
||||
| { type: "ready"; methods: string[] }
|
||||
| { type: "ready"; methods: string[]; providers: PluginProviderMetadata[] }
|
||||
| { type: "result"; requestId: string; output: unknown }
|
||||
| { type: "error"; requestId: string; error: string }
|
||||
| { type: "fatal"; error: string }
|
||||
| {
|
||||
type: "provider.connected";
|
||||
connectionId: string;
|
||||
version: number;
|
||||
capabilities: readonly string[];
|
||||
}
|
||||
| { type: "provider.connect_failed"; connectionId: string; error: string }
|
||||
| { type: "provider.accepted"; connectionId: string; acceptanceId: string }
|
||||
| {
|
||||
type: "provider.rejected";
|
||||
connectionId: string;
|
||||
acceptanceId: string;
|
||||
error: string;
|
||||
}
|
||||
| { type: "provider.event"; connectionId: string; event: ProviderEvent }
|
||||
| { type: "provider.closed"; connectionId: string; error?: string }
|
||||
| { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean }
|
||||
| { type: "paseo_close" };
|
||||
|
||||
const providerMetadataSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
iconPath: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
const providerConnectRequestSchema = z
|
||||
.object({
|
||||
versions: z.array(z.number().int().positive()),
|
||||
capabilities: z.array(z.string()),
|
||||
})
|
||||
.strict();
|
||||
const frameFields = {
|
||||
data: z.union([z.string(), z.instanceof(Uint8Array)]),
|
||||
isBinary: z.boolean(),
|
||||
};
|
||||
|
||||
export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.discriminatedUnion(
|
||||
"type",
|
||||
[
|
||||
z
|
||||
.object({
|
||||
type: z.literal("initialize"),
|
||||
pluginId: z.string().min(1),
|
||||
bundle: z.string(),
|
||||
appVersion: z.string(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("invoke"),
|
||||
requestId: z.string().min(1),
|
||||
method: z.string().min(1),
|
||||
input: z.unknown(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("provider.connect"),
|
||||
providerId: z.string().min(1),
|
||||
connectionId: z.string().min(1),
|
||||
request: providerConnectRequestSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("provider.send"),
|
||||
connectionId: z.string().min(1),
|
||||
acceptanceId: z.string().min(1),
|
||||
input: ProviderInputSchema,
|
||||
})
|
||||
.strict(),
|
||||
z.object({ type: z.literal("provider.close"), connectionId: z.string().min(1) }).strict(),
|
||||
z.object({ type: z.literal("shutdown") }).strict(),
|
||||
z.object({ type: z.literal("paseo_frame"), ...frameFields }).strict(),
|
||||
z.object({ type: z.literal("paseo_close") }).strict(),
|
||||
],
|
||||
);
|
||||
|
||||
export const PluginProcessMessageSchema: z.ZodType<PluginProcessMessage> = z.discriminatedUnion(
|
||||
"type",
|
||||
[
|
||||
z
|
||||
.object({
|
||||
type: z.literal("ready"),
|
||||
methods: z.array(z.string()),
|
||||
providers: z.array(providerMetadataSchema),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({ type: z.literal("result"), requestId: z.string().min(1), output: z.unknown() })
|
||||
.strict(),
|
||||
z
|
||||
.object({ type: z.literal("error"), requestId: z.string().min(1), error: z.string() })
|
||||
.strict(),
|
||||
z.object({ type: z.literal("fatal"), error: z.string() }).strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("provider.connected"),
|
||||
connectionId: z.string().min(1),
|
||||
version: z.number().int().positive(),
|
||||
capabilities: z.array(z.string()),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("provider.connect_failed"),
|
||||
connectionId: z.string().min(1),
|
||||
error: z.string(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("provider.accepted"),
|
||||
connectionId: z.string().min(1),
|
||||
acceptanceId: z.string().min(1),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("provider.rejected"),
|
||||
connectionId: z.string().min(1),
|
||||
acceptanceId: z.string().min(1),
|
||||
error: z.string(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("provider.event"),
|
||||
connectionId: z.string().min(1),
|
||||
event: ProviderEventSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("provider.closed"),
|
||||
connectionId: z.string().min(1),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
.strict(),
|
||||
z.object({ type: z.literal("paseo_frame"), ...frameFields }).strict(),
|
||||
z.object({ type: z.literal("paseo_close") }).strict(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
|
||||
import {
|
||||
PluginProcessRequestSchema,
|
||||
type PluginProcessMessage,
|
||||
type PluginProcessRequest,
|
||||
} from "./plugin-process-protocol.js";
|
||||
import { createRequire } from "node:module";
|
||||
import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@getpaseo/plugin";
|
||||
import {
|
||||
ProviderEventSchema,
|
||||
type ProviderConnection,
|
||||
type ProviderRegistration,
|
||||
} from "@getpaseo/plugin/provider";
|
||||
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
||||
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -20,6 +29,12 @@ interface RegisteredRpc {
|
||||
}
|
||||
|
||||
const handlers = new Map<string, RegisteredRpc>();
|
||||
const providers = new Map<string, ProviderRegistration>();
|
||||
const providerConnections = new Map<
|
||||
string,
|
||||
{ connection: ProviderConnection; unsubscribe: () => void }
|
||||
>();
|
||||
const pendingProviderConnections = new Map<string, { tombstoned: boolean }>();
|
||||
let cleanup: (() => void | Promise<void>) | null = null;
|
||||
let daemonClient: DaemonClient | null = null;
|
||||
let paseo: PaseoApi | null = null;
|
||||
@@ -44,6 +59,12 @@ function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function jsonTransportValue<Value>(value: Value): Value {
|
||||
const encoded = JSON.stringify(value);
|
||||
if (encoded === undefined) throw new Error("Provider value is not JSON-serializable");
|
||||
return JSON.parse(encoded) as Value;
|
||||
}
|
||||
|
||||
function validateMethod(method: string): string {
|
||||
const normalized = method.trim();
|
||||
if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) {
|
||||
@@ -63,6 +84,106 @@ function register(contract: PluginRpcContract, handler: RpcHandler): void {
|
||||
handlers.set(method, { contract: { ...contract, name: method }, handler });
|
||||
}
|
||||
|
||||
function registerProvider(provider: ProviderRegistration): void {
|
||||
const id = provider.id.trim();
|
||||
if (!/^[a-z][a-z0-9._-]*$/.test(id)) {
|
||||
throw new Error(`Invalid plugin provider ID: ${provider.id}`);
|
||||
}
|
||||
if (!provider.label.trim()) throw new Error(`Plugin provider ${id} requires a label`);
|
||||
if (typeof provider.connect !== "function") {
|
||||
throw new Error(`Plugin provider ${id} must implement connect()`);
|
||||
}
|
||||
if (providers.has(id)) throw new Error(`Duplicate plugin provider ID: ${id}`);
|
||||
providers.set(id, { ...provider, id });
|
||||
}
|
||||
|
||||
function providerMetadata(provider: ProviderRegistration) {
|
||||
return {
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
description: provider.description,
|
||||
iconPath: provider.icon,
|
||||
};
|
||||
}
|
||||
|
||||
async function connectProvider(
|
||||
message: Extract<PluginProcessRequest, { type: "provider.connect" }>,
|
||||
): Promise<void> {
|
||||
if (stopping) throw new Error("Plugin is stopping");
|
||||
const provider = providers.get(message.providerId);
|
||||
if (!provider) throw new Error(`Unknown plugin provider: ${message.providerId}`);
|
||||
if (
|
||||
providerConnections.has(message.connectionId) ||
|
||||
pendingProviderConnections.has(message.connectionId)
|
||||
) {
|
||||
throw new Error(`Duplicate provider connection: ${message.connectionId}`);
|
||||
}
|
||||
const pending = { tombstoned: false };
|
||||
pendingProviderConnections.set(message.connectionId, pending);
|
||||
let connection: ProviderConnection;
|
||||
try {
|
||||
connection = await provider.connect(message.request);
|
||||
} catch (error) {
|
||||
pendingProviderConnections.delete(message.connectionId);
|
||||
if (pending.tombstoned || stopping) return;
|
||||
throw error;
|
||||
}
|
||||
pendingProviderConnections.delete(message.connectionId);
|
||||
if (pending.tombstoned || stopping) {
|
||||
await connection.close().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
let unsubscribe = () => {};
|
||||
unsubscribe = connection.onEvent((event) => {
|
||||
try {
|
||||
send({
|
||||
type: "provider.event",
|
||||
connectionId: message.connectionId,
|
||||
event: ProviderEventSchema.parse(jsonTransportValue(event)),
|
||||
});
|
||||
} catch (error) {
|
||||
providerConnections.delete(message.connectionId);
|
||||
unsubscribe();
|
||||
void connection.close().catch(() => undefined);
|
||||
send({
|
||||
type: "provider.closed",
|
||||
connectionId: message.connectionId,
|
||||
error: describeError(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
providerConnections.set(message.connectionId, { connection, unsubscribe });
|
||||
send({
|
||||
type: "provider.connected",
|
||||
connectionId: message.connectionId,
|
||||
version: connection.version,
|
||||
capabilities: connection.capabilities,
|
||||
});
|
||||
}
|
||||
|
||||
async function sendProviderInput(
|
||||
message: Extract<PluginProcessRequest, { type: "provider.send" }>,
|
||||
): Promise<void> {
|
||||
if (stopping) throw new Error("Plugin is stopping");
|
||||
const current = providerConnections.get(message.connectionId);
|
||||
if (!current) throw new Error(`Unknown provider connection: ${message.connectionId}`);
|
||||
await current.connection.send(message.input);
|
||||
send({
|
||||
type: "provider.accepted",
|
||||
connectionId: message.connectionId,
|
||||
acceptanceId: message.acceptanceId,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeProviderConnection(connectionId: string): Promise<void> {
|
||||
const current = providerConnections.get(connectionId);
|
||||
if (!current) return;
|
||||
providerConnections.delete(connectionId);
|
||||
current.unsubscribe();
|
||||
await current.connection.close();
|
||||
send({ type: "provider.closed", connectionId });
|
||||
}
|
||||
|
||||
const pluginAuthorRuntime = {
|
||||
defineAttachmentSource,
|
||||
defineRpc,
|
||||
@@ -90,7 +211,7 @@ function evaluateBundle(bundle: string): void {
|
||||
if (typeof setup !== "function") {
|
||||
throw new Error("Plugin server bundle must default export a function");
|
||||
}
|
||||
const contributedCleanup = setup({ handle: register });
|
||||
const contributedCleanup = setup({ handle: register, registerProvider });
|
||||
if (typeof contributedCleanup !== "function") {
|
||||
throw new Error("Plugin contribution must return a cleanup function");
|
||||
}
|
||||
@@ -117,12 +238,19 @@ async function initialize(message: Extract<PluginProcessRequest, { type: "initia
|
||||
paseo = createPaseoApi(daemonClient);
|
||||
await daemonClient.connect();
|
||||
evaluateBundle(message.bundle);
|
||||
send({ type: "ready", methods: [...handlers.keys()].sort() });
|
||||
send({
|
||||
type: "ready",
|
||||
methods: [...handlers.keys()].sort(),
|
||||
providers: [...providers.values()]
|
||||
.sort((left, right) => left.id.localeCompare(right.id))
|
||||
.map(providerMetadata),
|
||||
});
|
||||
}
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
for (const pending of pendingProviderConnections.values()) pending.tombstoned = true;
|
||||
const currentCleanup = cleanup;
|
||||
cleanup = null;
|
||||
try {
|
||||
@@ -130,6 +258,7 @@ async function shutdown(): Promise<void> {
|
||||
} catch (error) {
|
||||
console.error("Plugin cleanup failed", error);
|
||||
}
|
||||
await Promise.all([...providerConnections.keys()].map(closeProviderConnection));
|
||||
await daemonClient?.close().catch(() => undefined);
|
||||
await sendAndWait({ type: "paseo_close" });
|
||||
daemonClient = null;
|
||||
@@ -137,7 +266,25 @@ async function shutdown(): Promise<void> {
|
||||
process.disconnect();
|
||||
}
|
||||
|
||||
process.on("message", (message: PluginProcessRequest) => {
|
||||
process.on("message", (rawMessage: unknown) => {
|
||||
const parsed = PluginProcessRequestSchema.safeParse(rawMessage);
|
||||
if (!parsed.success) {
|
||||
const value = rawMessage as { connectionId?: unknown; acceptanceId?: unknown } | null;
|
||||
if (value && typeof value.connectionId === "string" && typeof value.acceptanceId === "string") {
|
||||
send({
|
||||
type: "provider.rejected",
|
||||
connectionId: value.connectionId,
|
||||
acceptanceId: value.acceptanceId,
|
||||
error: `Invalid provider input: ${parsed.error.message}`,
|
||||
});
|
||||
void closeProviderConnection(value.connectionId);
|
||||
return;
|
||||
}
|
||||
send({ type: "fatal", error: `Invalid plugin process request: ${parsed.error.message}` });
|
||||
void shutdown();
|
||||
return;
|
||||
}
|
||||
const message = parsed.data;
|
||||
if (message.type === "initialize") {
|
||||
void initialize(message).catch(async (error) => {
|
||||
send({ type: "fatal", error: describeError(error) });
|
||||
@@ -149,8 +296,59 @@ process.on("message", (message: PluginProcessRequest) => {
|
||||
void shutdown();
|
||||
return;
|
||||
}
|
||||
if (stopping) {
|
||||
if (message.type === "provider.connect") {
|
||||
send({
|
||||
type: "provider.connect_failed",
|
||||
connectionId: message.connectionId,
|
||||
error: "Plugin is stopping",
|
||||
});
|
||||
} else if (message.type === "provider.send") {
|
||||
send({
|
||||
type: "provider.rejected",
|
||||
connectionId: message.connectionId,
|
||||
acceptanceId: message.acceptanceId,
|
||||
error: "Plugin is stopping",
|
||||
});
|
||||
} else if (message.type === "provider.close") {
|
||||
send({ type: "provider.closed", connectionId: message.connectionId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "provider.connect") {
|
||||
void connectProvider(message).catch((error) => {
|
||||
if (stopping) return;
|
||||
send({
|
||||
type: "provider.connect_failed",
|
||||
connectionId: message.connectionId,
|
||||
error: describeError(error),
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === "provider.send") {
|
||||
void sendProviderInput(message).catch((error) => {
|
||||
if (stopping) return;
|
||||
send({
|
||||
type: "provider.rejected",
|
||||
connectionId: message.connectionId,
|
||||
acceptanceId: message.acceptanceId,
|
||||
error: describeError(error),
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === "provider.close") {
|
||||
void closeProviderConnection(message.connectionId).catch((error) => {
|
||||
send({
|
||||
type: "provider.closed",
|
||||
connectionId: message.connectionId,
|
||||
error: describeError(error),
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === "paseo_frame" || message.type === "paseo_close") return;
|
||||
if (stopping) return;
|
||||
const registered = handlers.get(message.method);
|
||||
if (!registered) {
|
||||
send({
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { readPluginProviderIcon } from "./provider-icon.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
async function writeIcon(contents: string): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icon-"));
|
||||
directories.push(directory);
|
||||
await writeFile(path.join(directory, "icon.svg"), contents);
|
||||
return directory;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("readPluginProviderIcon", () => {
|
||||
test("accepts a self-contained SVG", async () => {
|
||||
const svg =
|
||||
'<svg viewBox="0 0 24 24"><defs><path id="mark" d="M0 0h1v1z" /></defs><use href="#mark" /></svg>';
|
||||
const directory = await writeIcon(svg);
|
||||
|
||||
await expect(readPluginProviderIcon(directory, "icon.svg")).resolves.toBe(svg);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["non-SVG document", "hello", "file is not an SVG document"],
|
||||
["script", "<svg><script /></svg>", "script elements"],
|
||||
["foreign object", "<svg><foreignObject /></svg>", "foreignObject elements"],
|
||||
["style", "<svg><style /></svg>", "style elements"],
|
||||
["event handler", '<svg><path onclick="run()" /></svg>', "event-handler attributes"],
|
||||
["javascript URL", '<svg><a href="javascript:run()" /></svg>', "javascript URLs"],
|
||||
["external href", '<svg><use href="https://example.com/icon.svg" /></svg>', "external href"],
|
||||
])("rejects %s", async (_name, svg, reason) => {
|
||||
const directory = await writeIcon(svg);
|
||||
|
||||
await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow(
|
||||
`icon.svg": ${reason}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a path outside the plugin directory", async () => {
|
||||
const directory = await writeIcon("<svg />");
|
||||
|
||||
await expect(readPluginProviderIcon(directory, "../icon.svg")).rejects.toThrow(
|
||||
"path leaves the plugin directory",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a symlink that escapes the plugin directory", async () => {
|
||||
const directory = await writeIcon("<svg />");
|
||||
const externalDirectory = await mkdtemp(path.join(tmpdir(), "paseo-external-icon-"));
|
||||
directories.push(externalDirectory);
|
||||
const externalIcon = path.join(externalDirectory, "external.svg");
|
||||
await writeFile(externalIcon, "<svg />");
|
||||
await symlink(externalIcon, path.join(directory, "linked.svg"));
|
||||
|
||||
await expect(readPluginProviderIcon(directory, "linked.svg")).rejects.toThrow(
|
||||
"path leaves the plugin directory",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a directory instead of a regular file", async () => {
|
||||
const directory = await writeIcon("<svg />");
|
||||
await mkdir(path.join(directory, "nested.svg"));
|
||||
|
||||
await expect(readPluginProviderIcon(directory, "nested.svg")).rejects.toThrow(
|
||||
"file does not exist or is not a regular file",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an icon larger than 64 KiB", async () => {
|
||||
const directory = await writeIcon(`<svg>${" ".repeat(64 * 1024)}</svg>`);
|
||||
|
||||
await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow(
|
||||
"file exceeds 64 KiB",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const MAX_ICON_BYTES = 64 * 1024;
|
||||
|
||||
export async function readPluginProviderIcon(
|
||||
pluginDirectory: string,
|
||||
iconPath: string,
|
||||
): Promise<string> {
|
||||
const resolvedDirectory = await realpath(path.resolve(pluginDirectory));
|
||||
const requestedIcon = path.resolve(resolvedDirectory, iconPath);
|
||||
assertInsidePluginDirectory(resolvedDirectory, requestedIcon, iconPath);
|
||||
const resolvedIcon = await realpath(requestedIcon).catch(() => null);
|
||||
if (!resolvedIcon) {
|
||||
throw iconError(iconPath, "file does not exist or is not a regular file");
|
||||
}
|
||||
assertInsidePluginDirectory(resolvedDirectory, resolvedIcon, iconPath);
|
||||
|
||||
const iconStat = await stat(resolvedIcon);
|
||||
if (!iconStat.isFile()) {
|
||||
throw iconError(iconPath, "file does not exist or is not a regular file");
|
||||
}
|
||||
if (iconStat.size > MAX_ICON_BYTES) throw iconError(iconPath, "file exceeds 64 KiB");
|
||||
|
||||
const svg = await readFile(resolvedIcon, "utf8");
|
||||
validateSvg(iconPath, svg);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function assertInsidePluginDirectory(
|
||||
resolvedDirectory: string,
|
||||
resolvedIcon: string,
|
||||
iconPath: string,
|
||||
): void {
|
||||
const relative = path.relative(resolvedDirectory, resolvedIcon);
|
||||
const escapesDirectory =
|
||||
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
||||
if (escapesDirectory) throw iconError(iconPath, "path leaves the plugin directory");
|
||||
}
|
||||
|
||||
function validateSvg(iconPath: string, svg: string): void {
|
||||
if (!/^\s*<svg(?:\s|>)/i.test(svg)) throw iconError(iconPath, "file is not an SVG document");
|
||||
if (/<script(?:\s|>)/i.test(svg)) throw iconError(iconPath, "script elements are not allowed");
|
||||
if (/<foreignObject(?:\s|>)/i.test(svg)) {
|
||||
throw iconError(iconPath, "foreignObject elements are not allowed");
|
||||
}
|
||||
if (/<style(?:\s|>)/i.test(svg)) throw iconError(iconPath, "style elements are not allowed");
|
||||
if (/\son[a-z0-9_-]*\s*=/i.test(svg)) {
|
||||
throw iconError(iconPath, "event-handler attributes are not allowed");
|
||||
}
|
||||
if (/javascript\s*:/i.test(svg)) throw iconError(iconPath, "javascript URLs are not allowed");
|
||||
|
||||
const hrefPattern = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi;
|
||||
for (const match of svg.matchAll(hrefPattern)) {
|
||||
const href = match[2] ?? match[3] ?? "";
|
||||
if (!href.startsWith("#")) {
|
||||
throw iconError(iconPath, "external href references are not allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function iconError(iconPath: string, reason: string): Error {
|
||||
return new Error(`Invalid plugin provider icon "${iconPath}": ${reason}`);
|
||||
}
|
||||
@@ -4,12 +4,19 @@ import { PassThrough } from "node:stream";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import pino from "pino";
|
||||
import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentStreamEvent } from "../agent/agent-sdk-types.js";
|
||||
import { PluginAgentClientRegistry } from "../agent/plugin-provider.js";
|
||||
import { PluginRuntime } from "./runtime.js";
|
||||
import type { PluginSessionSocket } from "./session-socket.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function hasCompletedAgentTurn(events: readonly AgentStreamEvent[]): boolean {
|
||||
return events.some((event) => event.type === "turn_completed");
|
||||
}
|
||||
|
||||
async function createPlugin(id: string, source: string): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
|
||||
temporaryDirectories.push(directory);
|
||||
@@ -18,7 +25,20 @@ async function createPlugin(id: string, source: string): Promise<string> {
|
||||
return directory;
|
||||
}
|
||||
|
||||
function createReloadChild(name: string, events: string[], methods: string[] = []) {
|
||||
async function readTextIfPresent(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createReloadChild(
|
||||
name: string,
|
||||
events: string[],
|
||||
methods: string[] = [],
|
||||
providers: Array<{ id: string; label: string }> = [],
|
||||
) {
|
||||
const listeners = new Map<string, Array<(message: never) => void>>();
|
||||
const emit = (event: string, message: unknown) => {
|
||||
for (const listener of listeners.get(event) ?? []) listener(message as never);
|
||||
@@ -32,7 +52,7 @@ function createReloadChild(name: string, events: string[], methods: string[] = [
|
||||
callback?.(null);
|
||||
if (message.type === "initialize") {
|
||||
events.push(`start:${name}`);
|
||||
queueMicrotask(() => emit("message", { type: "ready", methods }));
|
||||
queueMicrotask(() => emit("message", { type: "ready", methods, providers }));
|
||||
}
|
||||
if (message.type === "shutdown") {
|
||||
events.push(`shutdown:${name}`);
|
||||
@@ -59,6 +79,9 @@ function createReloadChild(name: string, events: string[], methods: string[] = [
|
||||
listeners.set(event, registered);
|
||||
return this;
|
||||
},
|
||||
emitMessage(message: unknown) {
|
||||
emit("message", message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,6 +163,42 @@ function lifecycleMessages(runtime: PluginRuntime): string[] {
|
||||
.sort();
|
||||
}
|
||||
|
||||
function hasCompletedTurn(events: readonly ProviderEvent[]): boolean {
|
||||
return events.some((event) => event.type === "session.turn" && event.state === "completed");
|
||||
}
|
||||
|
||||
function hasReadyRequest(events: readonly ProviderEvent[], requestId: string): boolean {
|
||||
return events.some((event) => event.type === "session.ready" && event.requestId === requestId);
|
||||
}
|
||||
|
||||
function runtimeFailure(events: readonly ProviderEvent[]): ProviderEvent | undefined {
|
||||
return events.find((event) => event.type === "session.runtime_failed");
|
||||
}
|
||||
|
||||
function providerCloseCount(
|
||||
messages: readonly { type: string; connectionId?: string }[],
|
||||
connectionId: string | undefined,
|
||||
): number {
|
||||
return messages.filter(
|
||||
(message) => message.type === "provider.close" && message.connectionId === connectionId,
|
||||
).length;
|
||||
}
|
||||
|
||||
function hasMessageType(messages: readonly { type: string }[], type: string): boolean {
|
||||
return messages.some((message) => message.type === type);
|
||||
}
|
||||
|
||||
function adaptRuntimeProvider(
|
||||
runtime: PluginRuntime,
|
||||
pluginId: string,
|
||||
metadata: { id: string; label: string; description?: string; icon?: string },
|
||||
): ProviderRegistration {
|
||||
return {
|
||||
...metadata,
|
||||
connect: (request) => runtime.connectProvider(pluginId, metadata.id, request),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
@@ -147,6 +206,523 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("PluginRuntime", () => {
|
||||
it("runs the direct example through the existing AgentClient path", async () => {
|
||||
const pluginId = "provider-direct-example";
|
||||
const directory = fileURLToPath(
|
||||
new URL("../../../../../plugin-examples/provider-direct/", import.meta.url),
|
||||
);
|
||||
const runtime = createTestRuntime();
|
||||
await runtime.startPlugin(pluginId, directory);
|
||||
const [metadata] = runtime.getProviderRegistrations(pluginId);
|
||||
expect(metadata).toBeDefined();
|
||||
const adapters = new PluginAgentClientRegistry(pino({ level: "silent" }));
|
||||
adapters.replace([adaptRuntimeProvider(runtime, pluginId, metadata!)]);
|
||||
const client = adapters.clients()[metadata!.id];
|
||||
|
||||
const session = await client!.createSession({ provider: metadata!.id, cwd: directory });
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
await expect(session.startTurn("hello", { clientMessageId: "direct-client" })).resolves.toEqual(
|
||||
{ turnId: "turn-1" },
|
||||
);
|
||||
await expect.poll(() => hasCompletedAgentTurn(events)).toBe(true);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
id: undefined,
|
||||
messageId: "assistant-1",
|
||||
text: "Echo: hello",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: expect.objectContaining({ type: "timeline" }),
|
||||
}),
|
||||
);
|
||||
|
||||
await session.close();
|
||||
await adapters.shutdown();
|
||||
await runtime.stopAll();
|
||||
});
|
||||
|
||||
it("runs a provider connection through the real plugin subprocess boundary", async () => {
|
||||
const directory = await createPlugin(
|
||||
"provider-round-trip",
|
||||
`import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider";
|
||||
|
||||
const provider: ProviderRegistration = {
|
||||
id: "direct-example",
|
||||
label: "Direct example",
|
||||
icon: "icon.svg",
|
||||
async connect(request) {
|
||||
const listeners = new Set<(event: ProviderEvent) => void>();
|
||||
const emit = (event: ProviderEvent) => {
|
||||
for (const listener of listeners) listener(event);
|
||||
};
|
||||
return {
|
||||
version: request.versions[0] ?? 1,
|
||||
capabilities: request.capabilities,
|
||||
async send(input) {
|
||||
if (input.type === "session.open") {
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: request.capabilities,
|
||||
restoration: "core",
|
||||
persistence: { version: 1, data: { resume: "native-1" } },
|
||||
cwd: input.config.cwd,
|
||||
futureField: "ignored by this protocol version",
|
||||
} as ProviderEvent);
|
||||
emit({
|
||||
type: "session.config",
|
||||
sessionId: input.sessionId,
|
||||
config: {
|
||||
model: "example-1",
|
||||
models: [{ id: "example-1", label: "Example 1" }],
|
||||
modes: [],
|
||||
thinkingOptions: [],
|
||||
settings: [
|
||||
{ type: "toggle", id: "concise", label: "Concise", value: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return;
|
||||
}
|
||||
if (input.type !== "session.prompt") return;
|
||||
if (input.prompt.input.type === "command") {
|
||||
emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "completed" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "user_message",
|
||||
id: "user-1",
|
||||
text: "hello",
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
},
|
||||
});
|
||||
emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "turn", turnId: "turn-1" },
|
||||
});
|
||||
emit({
|
||||
type: "session.opened",
|
||||
sessionId: "child-1",
|
||||
parentSessionId: input.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: "/repo",
|
||||
});
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: "child-1",
|
||||
item: {
|
||||
type: "plugin",
|
||||
id: "review-1",
|
||||
pluginId: "provider-round-trip",
|
||||
kind: "review",
|
||||
version: 1,
|
||||
data: { verdict: "ship" },
|
||||
},
|
||||
});
|
||||
},
|
||||
onEvent(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(provider);
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const runtime = createTestRuntime();
|
||||
await runtime.startPlugin("provider-round-trip", directory);
|
||||
|
||||
const [registration] = runtime.getProviderRegistrations("provider-round-trip");
|
||||
expect(registration).toMatchObject({
|
||||
id: "direct-example",
|
||||
label: "Direct example",
|
||||
iconPath: "icon.svg",
|
||||
});
|
||||
const connection = await runtime.connectProvider("provider-round-trip", "direct-example", {
|
||||
versions: [1],
|
||||
capabilities: [
|
||||
"prompt.message",
|
||||
"prompt.command",
|
||||
"session.configure",
|
||||
"session.persistence",
|
||||
"session.subsession",
|
||||
"timeline.plugin",
|
||||
"future.capability",
|
||||
],
|
||||
});
|
||||
expect(connection.capabilities).not.toContain("future.capability");
|
||||
const events: ProviderEvent[] = [];
|
||||
const unsubscribe = connection.onEvent((event) => events.push(event));
|
||||
|
||||
await connection.send({
|
||||
type: "session.open",
|
||||
requestId: "open-1",
|
||||
sessionId: "root-1",
|
||||
config: {
|
||||
cwd: "/repo",
|
||||
env: {},
|
||||
mcpServers: {},
|
||||
settings: {},
|
||||
persist: true,
|
||||
},
|
||||
history: "replay",
|
||||
});
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "root-1",
|
||||
prompt: {
|
||||
clientMessageId: "client-1",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "hello" }] },
|
||||
},
|
||||
});
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "root-1",
|
||||
prompt: {
|
||||
clientMessageId: "client-2",
|
||||
delivery: "auto",
|
||||
input: { type: "command", name: "compact", arguments: "" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect.poll(() => events.length).toBe(8);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.prompt_result",
|
||||
clientMessageId: "client-1",
|
||||
result: { type: "turn", turnId: "turn-1" },
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.prompt_result",
|
||||
clientMessageId: "client-2",
|
||||
result: { type: "completed" },
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.opened",
|
||||
sessionId: "child-1",
|
||||
parentSessionId: "root-1",
|
||||
}),
|
||||
);
|
||||
expect(events.find((event) => event.type === "session.opened")).not.toHaveProperty(
|
||||
"futureField",
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline.item",
|
||||
sessionId: "child-1",
|
||||
item: expect.objectContaining({ type: "plugin", pluginId: "provider-round-trip" }),
|
||||
}),
|
||||
);
|
||||
|
||||
unsubscribe();
|
||||
await connection.close();
|
||||
await runtime.stopAll();
|
||||
});
|
||||
|
||||
it("adapts an ACP command and example transformer through the AgentClient path", async () => {
|
||||
const transformerPath = fileURLToPath(
|
||||
new URL(
|
||||
"../../../../../plugin-examples/provider-acp-transformer/server/vendor-edit.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const directory = await createPlugin(
|
||||
"provider-acp-round-trip",
|
||||
`import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { runAcpProvider } from "@getpaseo/plugin/acp";
|
||||
import { vendorEditTransformer } from "./server/vendor-edit.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(runAcpProvider({
|
||||
id: "acp-example",
|
||||
label: "ACP example",
|
||||
command: [process.execPath, __ACP_COMMAND__],
|
||||
transformers: [vendorEditTransformer],
|
||||
}));
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
await mkdir(path.join(directory, "server"));
|
||||
await writeFile(
|
||||
path.join(directory, "server/vendor-edit.ts"),
|
||||
await readFile(transformerPath, "utf8"),
|
||||
"utf8",
|
||||
);
|
||||
const agentPath = path.join(directory, "fake-acp.cjs");
|
||||
await writeFile(
|
||||
agentPath,
|
||||
`const readline = require("node:readline");
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
lines.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "initialize") {
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } });
|
||||
} else if (message.method === "session/new") {
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } });
|
||||
} else if (message.method === "session/prompt") {
|
||||
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "native-1", update: { sessionUpdate: "tool_call", toolCallId: "edit-1", name: "vendor_file_edit", title: "Vendor edit", status: "completed", rawInput: { path: "/tmp/example.ts", before: "old", after: "new" } } } });
|
||||
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "native-1", update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-1", content: { type: "text", text: "ACP says hi" } } } });
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
|
||||
} else if (message.method === "session/close" || message.method === "session/cancel") {
|
||||
send({ jsonrpc: "2.0", id: message.id, result: {} });
|
||||
}
|
||||
});`,
|
||||
"utf8",
|
||||
);
|
||||
const pluginPath = path.join(directory, "index.server.ts");
|
||||
const pluginSource = (await readFile(pluginPath, "utf8")).replace(
|
||||
"__ACP_COMMAND__",
|
||||
JSON.stringify(agentPath),
|
||||
);
|
||||
await writeFile(pluginPath, pluginSource, "utf8");
|
||||
|
||||
const runtime = createTestRuntime();
|
||||
await runtime.startPlugin("provider-acp-round-trip", directory);
|
||||
const connection = await runtime.connectProvider("provider-acp-round-trip", "acp-example", {
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message", "session.persistence"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
|
||||
await connection.send({
|
||||
type: "session.open",
|
||||
requestId: "open-acp",
|
||||
sessionId: "paseo-1",
|
||||
config: { cwd: directory, env: {}, mcpServers: {}, settings: {}, persist: true },
|
||||
history: "skip",
|
||||
});
|
||||
await expect.poll(() => hasReadyRequest(events, "open-acp")).toBe(true);
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "paseo-1",
|
||||
prompt: {
|
||||
clientMessageId: "client-acp",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "hello" }] },
|
||||
},
|
||||
});
|
||||
|
||||
await expect.poll(() => hasCompletedTurn(events)).toBe(true);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.prompt_result",
|
||||
clientMessageId: "client-acp",
|
||||
result: { type: "turn", turnId: "acp:client-acp" },
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline.item",
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
id: "assistant-1",
|
||||
text: "ACP says hi",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await connection.close();
|
||||
const [metadata] = runtime.getProviderRegistrations("provider-acp-round-trip");
|
||||
const adapters = new PluginAgentClientRegistry(pino({ level: "silent" }));
|
||||
adapters.replace([adaptRuntimeProvider(runtime, "provider-acp-round-trip", metadata!)]);
|
||||
const client = adapters.clients()[metadata!.id];
|
||||
const session = await client!.createSession({ provider: metadata!.id, cwd: directory });
|
||||
const agentEvents: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => agentEvents.push(event));
|
||||
await expect(
|
||||
session.startTurn("hello", { clientMessageId: "agent-client-acp" }),
|
||||
).resolves.toEqual({ turnId: "acp:agent-client-acp" });
|
||||
await expect.poll(() => hasCompletedAgentTurn(agentEvents)).toBe(true);
|
||||
expect(agentEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({ type: "assistant_message", text: "ACP says hi" }),
|
||||
}),
|
||||
);
|
||||
expect(agentEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({
|
||||
type: "tool_call",
|
||||
callId: "edit-1",
|
||||
detail: {
|
||||
type: "edit",
|
||||
filePath: "/tmp/example.ts",
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
unifiedDiff: undefined,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await session.close();
|
||||
await adapters.shutdown();
|
||||
await runtime.stopAll();
|
||||
});
|
||||
|
||||
it("rejects a malformed provider event from a real plugin subprocess", async () => {
|
||||
const directory = await createPlugin(
|
||||
"malicious-provider",
|
||||
`import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider";
|
||||
let connectionId = "";
|
||||
process.on("message", (message: unknown) => {
|
||||
const value = message as { type?: string; connectionId?: string };
|
||||
if (value.type === "provider.connect") connectionId = value.connectionId ?? "";
|
||||
});
|
||||
const provider: ProviderRegistration = {
|
||||
id: "malicious",
|
||||
label: "Malicious",
|
||||
async connect(request) {
|
||||
const listeners = new Set<(event: ProviderEvent) => void>();
|
||||
const emit = (event: ProviderEvent) => { for (const listener of listeners) listener(event); };
|
||||
return {
|
||||
version: request.versions[0] ?? 1,
|
||||
capabilities: ["prompt.message"],
|
||||
async send(input) {
|
||||
if (input.type === "session.open") {
|
||||
emit({ type: "session.opened", requestId: input.requestId, sessionId: input.sessionId, capabilities: ["prompt.message"], restoration: "core", cwd: input.config.cwd });
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
} else if (input.type === "session.prompt") {
|
||||
process.send?.({ type: "provider.event", connectionId, event: { type: "invented.event", sessionId: input.sessionId } });
|
||||
}
|
||||
},
|
||||
onEvent(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
export default function contribute(server: any) { server.registerProvider(provider); return () => undefined; }`,
|
||||
);
|
||||
const runtime = createTestRuntime();
|
||||
await runtime.startPlugin("malicious-provider", directory);
|
||||
const connection = await runtime.connectProvider("malicious-provider", "malicious", {
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send({
|
||||
type: "session.open",
|
||||
requestId: "open-malicious",
|
||||
sessionId: "root-malicious",
|
||||
config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: true },
|
||||
history: "skip",
|
||||
});
|
||||
|
||||
await expect(
|
||||
connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "root-malicious",
|
||||
prompt: {
|
||||
clientMessageId: "malformed-now",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "break transport" }] },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("invalid message");
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: "session.runtime_failed", sessionId: "root-malicious" }),
|
||||
);
|
||||
expect(runtime.catalog()).toHaveLength(1);
|
||||
await runtime.stopAll();
|
||||
});
|
||||
|
||||
it("emits runtime failure for live sessions when a real plugin process dies", async () => {
|
||||
const directory = await createPlugin(
|
||||
"dying-provider",
|
||||
`import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/provider";
|
||||
const provider: ProviderRegistration = {
|
||||
id: "dying",
|
||||
label: "Dying",
|
||||
async connect(request) {
|
||||
const listeners = new Set<(event: ProviderEvent) => void>();
|
||||
const emit = (event: ProviderEvent) => { for (const listener of listeners) listener(event); };
|
||||
return {
|
||||
version: request.versions[0] ?? 1,
|
||||
capabilities: ["prompt.message"],
|
||||
async send(input) {
|
||||
if (input.type === "session.open") {
|
||||
emit({ type: "session.opened", requestId: input.requestId, sessionId: input.sessionId, capabilities: ["prompt.message"], restoration: "core", cwd: input.config.cwd });
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
}
|
||||
if (input.type === "session.prompt") setTimeout(() => process.exit(17), 10);
|
||||
},
|
||||
onEvent(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
export default function contribute(server: any) { server.registerProvider(provider); return () => undefined; }`,
|
||||
);
|
||||
const runtime = createTestRuntime();
|
||||
await runtime.startPlugin("dying-provider", directory);
|
||||
const connection = await runtime.connectProvider("dying-provider", "dying", {
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send({
|
||||
type: "session.open",
|
||||
requestId: "open-dying",
|
||||
sessionId: "root-dying",
|
||||
config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: true },
|
||||
history: "skip",
|
||||
});
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "root-dying",
|
||||
prompt: {
|
||||
clientMessageId: "die-now",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "bye" }] },
|
||||
},
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => runtimeFailure(events))
|
||||
.toMatchObject({
|
||||
type: "session.runtime_failed",
|
||||
sessionId: "root-dying",
|
||||
error: { message: "Plugin process exited: dying-provider" },
|
||||
});
|
||||
await runtime.stopAll();
|
||||
});
|
||||
|
||||
it("records host-owned plugin lifecycle events", async () => {
|
||||
const directory = await createPlugin(
|
||||
"lifecycle",
|
||||
@@ -382,38 +958,298 @@ export default function contribute(plugin: unknown) {
|
||||
await rm(cleanupFile, { force: true });
|
||||
});
|
||||
|
||||
it("does not kill a healthy child while its graceful cleanup is still running", async () => {
|
||||
it("closes a provider connection that resolves during plugin shutdown", async () => {
|
||||
const suffix = `${process.pid}-${Date.now()}`;
|
||||
const startedFile = path.join(tmpdir(), `paseo-provider-connect-started-${suffix}`);
|
||||
const closedFile = path.join(tmpdir(), `paseo-provider-connect-closed-${suffix}`);
|
||||
const directory = await createPlugin(
|
||||
"shutdown-connect",
|
||||
`import { writeFile } from "node:fs/promises";
|
||||
import type { ProviderRegistration } from "@getpaseo/plugin/provider";
|
||||
|
||||
const provider: ProviderRegistration = {
|
||||
id: "delayed",
|
||||
label: "Delayed",
|
||||
async connect() {
|
||||
await writeFile(${JSON.stringify(startedFile)}, "started");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return {
|
||||
version: 1,
|
||||
capabilities: [],
|
||||
async send() {},
|
||||
onEvent() { return () => undefined; },
|
||||
async close() { await writeFile(${JSON.stringify(closedFile)}, "closed"); },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default function contribute(server: { registerProvider(provider: ProviderRegistration): void }) {
|
||||
server.registerProvider(provider);
|
||||
return () => undefined;
|
||||
}`,
|
||||
);
|
||||
const runtime = createTestRuntime();
|
||||
await runtime.startPlugin("shutdown-connect", directory);
|
||||
const pending = runtime.connectProvider("shutdown-connect", "delayed", {
|
||||
versions: [1],
|
||||
capabilities: [],
|
||||
});
|
||||
await expect.poll(() => readTextIfPresent(startedFile)).toBe("started");
|
||||
|
||||
const stopping = runtime.stopPluginById("shutdown-connect");
|
||||
await expect(pending).rejects.toThrow("Plugin stopped: shutdown-connect");
|
||||
await stopping;
|
||||
|
||||
await expect(readFile(closedFile, "utf8")).resolves.toBe("closed");
|
||||
await Promise.all([rm(startedFile, { force: true }), rm(closedFile, { force: true })]);
|
||||
});
|
||||
|
||||
it("closes a provider connection that arrives after negotiation timed out", async () => {
|
||||
const directory = await createPlugin(
|
||||
"late-provider",
|
||||
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
|
||||
);
|
||||
const child = createReloadChild(
|
||||
"late-provider",
|
||||
[],
|
||||
[],
|
||||
[{ id: "provider", label: "Provider" }],
|
||||
);
|
||||
const sent: Array<{ type: string; connectionId?: string }> = [];
|
||||
const originalSend = child.send.bind(child);
|
||||
child.send = (message, callback) => {
|
||||
sent.push(message);
|
||||
if (message.type === "provider.connect") {
|
||||
callback?.(null);
|
||||
return true;
|
||||
}
|
||||
return originalSend(message, callback);
|
||||
};
|
||||
const runtime = createTestRuntime({ spawnChild: () => child });
|
||||
await runtime.startPlugin("late-provider", directory);
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const rejected = expect(
|
||||
runtime.connectProvider("late-provider", "provider", {
|
||||
versions: [1],
|
||||
capabilities: [],
|
||||
}),
|
||||
).rejects.toThrow("timed out");
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await rejected;
|
||||
const connectionId = sent.find(
|
||||
(message) => message.type === "provider.connect",
|
||||
)?.connectionId;
|
||||
expect(
|
||||
sent.filter(
|
||||
(message) => message.type === "provider.close" && message.connectionId === connectionId,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
child.emitMessage({
|
||||
type: "provider.connected",
|
||||
connectionId,
|
||||
version: 1,
|
||||
capabilities: [],
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(
|
||||
sent.filter(
|
||||
(message) => message.type === "provider.close" && message.connectionId === connectionId,
|
||||
),
|
||||
).toHaveLength(2);
|
||||
child.emitMessage({ type: "provider.closed", connectionId });
|
||||
} finally {
|
||||
await runtime.stopAll();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes child-side state after invalid provider negotiation", async () => {
|
||||
const directory = await createPlugin(
|
||||
"invalid-provider",
|
||||
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
|
||||
);
|
||||
const child = createReloadChild(
|
||||
"invalid-provider",
|
||||
[],
|
||||
[],
|
||||
[{ id: "provider", label: "Provider" }],
|
||||
);
|
||||
const sent: Array<{ type: string; connectionId?: string }> = [];
|
||||
const originalSend = child.send.bind(child);
|
||||
child.send = (message, callback) => {
|
||||
sent.push(message);
|
||||
if (message.type === "provider.connect") {
|
||||
callback?.(null);
|
||||
queueMicrotask(() =>
|
||||
child.emitMessage({
|
||||
type: "provider.connected",
|
||||
connectionId: message.connectionId,
|
||||
version: 2,
|
||||
capabilities: [],
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return originalSend(message, callback);
|
||||
};
|
||||
const runtime = createTestRuntime({ spawnChild: () => child });
|
||||
await runtime.startPlugin("invalid-provider", directory);
|
||||
|
||||
await expect(
|
||||
runtime.connectProvider("invalid-provider", "provider", {
|
||||
versions: [1],
|
||||
capabilities: [],
|
||||
}),
|
||||
).rejects.toThrow("unoffered version");
|
||||
const connectionId = sent.find((message) => message.type === "provider.connect")?.connectionId;
|
||||
await expect.poll(() => sent).toContainEqual({ type: "provider.close", connectionId });
|
||||
child.emitMessage({ type: "provider.closed", connectionId });
|
||||
await runtime.stopAll();
|
||||
});
|
||||
|
||||
it("closes a late connection after the child rejected its negotiation", async () => {
|
||||
const directory = await createPlugin(
|
||||
"rejected-provider",
|
||||
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
|
||||
);
|
||||
const child = createReloadChild(
|
||||
"rejected-provider",
|
||||
[],
|
||||
[],
|
||||
[{ id: "provider", label: "Provider" }],
|
||||
);
|
||||
const sent: Array<{ type: string; connectionId?: string }> = [];
|
||||
const originalSend = child.send.bind(child);
|
||||
child.send = (message, callback) => {
|
||||
sent.push(message);
|
||||
if (message.type === "provider.connect") {
|
||||
callback?.(null);
|
||||
queueMicrotask(() =>
|
||||
child.emitMessage({
|
||||
type: "provider.connect_failed",
|
||||
connectionId: message.connectionId,
|
||||
error: "provider rejected connection",
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return originalSend(message, callback);
|
||||
};
|
||||
const runtime = createTestRuntime({ spawnChild: () => child });
|
||||
await runtime.startPlugin("rejected-provider", directory);
|
||||
|
||||
await expect(
|
||||
runtime.connectProvider("rejected-provider", "provider", {
|
||||
versions: [1],
|
||||
capabilities: [],
|
||||
}),
|
||||
).rejects.toThrow("provider rejected connection");
|
||||
const connectionId = sent.find((message) => message.type === "provider.connect")?.connectionId;
|
||||
const closesBeforeLateSuccess = providerCloseCount(sent, connectionId);
|
||||
|
||||
child.emitMessage({
|
||||
type: "provider.connected",
|
||||
connectionId,
|
||||
version: 1,
|
||||
capabilities: [],
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => providerCloseCount(sent, connectionId))
|
||||
.toBe(closesBeforeLateSuccess + 1);
|
||||
child.emitMessage({ type: "provider.closed", connectionId });
|
||||
await runtime.stopAll();
|
||||
});
|
||||
|
||||
it("closes a late connection after plugin shutdown cancels negotiation", async () => {
|
||||
const directory = await createPlugin(
|
||||
"canceled-provider",
|
||||
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
|
||||
);
|
||||
const child = createReloadChild(
|
||||
"canceled-provider",
|
||||
[],
|
||||
[],
|
||||
[{ id: "provider", label: "Provider" }],
|
||||
);
|
||||
const sent: Array<{ type: string; connectionId?: string }> = [];
|
||||
const originalSend = child.send.bind(child);
|
||||
child.send = (message, callback) => {
|
||||
sent.push(message);
|
||||
if (message.type === "provider.connect" || message.type === "shutdown") {
|
||||
callback?.(null);
|
||||
return true;
|
||||
}
|
||||
return originalSend(message, callback);
|
||||
};
|
||||
const runtime = createTestRuntime({ spawnChild: () => child });
|
||||
await runtime.startPlugin("canceled-provider", directory);
|
||||
const pending = runtime.connectProvider("canceled-provider", "provider", {
|
||||
versions: [1],
|
||||
capabilities: [],
|
||||
});
|
||||
await expect.poll(() => hasMessageType(sent, "provider.connect")).toBe(true);
|
||||
const connectionId = sent.find((message) => message.type === "provider.connect")?.connectionId;
|
||||
const rejection = pending.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
const stopping = runtime.stopPluginById("canceled-provider");
|
||||
try {
|
||||
await expect.poll(() => providerCloseCount(sent, connectionId)).toBe(1);
|
||||
await expect(rejection).resolves.toMatchObject({
|
||||
message: "Plugin stopped: canceled-provider",
|
||||
});
|
||||
const closesBeforeLateSuccess = providerCloseCount(sent, connectionId);
|
||||
child.emitMessage({
|
||||
type: "provider.connected",
|
||||
connectionId,
|
||||
version: 1,
|
||||
capabilities: [],
|
||||
});
|
||||
await expect
|
||||
.poll(() => providerCloseCount(sent, connectionId))
|
||||
.toBe(closesBeforeLateSuccess + 1);
|
||||
} finally {
|
||||
child.kill();
|
||||
await stopping;
|
||||
}
|
||||
});
|
||||
|
||||
it("escalates a plugin that ignores graceful shutdown from TERM to KILL", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const directory = await createPlugin(
|
||||
"held-cleanup",
|
||||
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
|
||||
);
|
||||
const events: string[] = [];
|
||||
const child = createReloadChild("held-cleanup", events);
|
||||
const child = createReloadChild("held-cleanup", []);
|
||||
const originalSend = child.send.bind(child);
|
||||
let releaseCleanup = () => undefined;
|
||||
child.send = (message, callback) => {
|
||||
if (message.type !== "shutdown") return originalSend(message, callback);
|
||||
callback?.(null);
|
||||
events.push("shutdown:held-cleanup");
|
||||
releaseCleanup = () => {
|
||||
child.connected = false;
|
||||
child.kill();
|
||||
child.killed = false;
|
||||
};
|
||||
return true;
|
||||
};
|
||||
const signals: Array<NodeJS.Signals | undefined> = [];
|
||||
const originalKill = child.kill.bind(child);
|
||||
child.kill = (signal?: NodeJS.Signals) => {
|
||||
signals.push(signal);
|
||||
if (signal === "SIGKILL") return originalKill();
|
||||
child.killed = true;
|
||||
return true;
|
||||
};
|
||||
const runtime = createTestRuntime({ spawnChild: () => child });
|
||||
await runtime.startPlugin("held-cleanup", directory);
|
||||
|
||||
const stopping = runtime.stopPluginById("held-cleanup");
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(child.killed).toBe(false);
|
||||
|
||||
releaseCleanup();
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(signals).toEqual(["SIGTERM"]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await stopping;
|
||||
expect(events).toContain("shutdown:held-cleanup");
|
||||
expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -4,10 +4,23 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type pino from "pino";
|
||||
import {
|
||||
PROVIDER_CAPABILITIES,
|
||||
requireProviderCapabilities,
|
||||
type ProviderConnectRequest,
|
||||
type ProviderConnection,
|
||||
type ProviderEvent,
|
||||
type ProviderInput,
|
||||
} from "@getpaseo/plugin/provider";
|
||||
import type { PluginLogEntry } from "@getpaseo/protocol/messages";
|
||||
import { compilePlugin } from "./compiler.js";
|
||||
import { readPluginManifest } from "./manifest.js";
|
||||
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
|
||||
import type {
|
||||
PluginProcessMessage,
|
||||
PluginProcessRequest,
|
||||
PluginProviderMetadata,
|
||||
} from "./plugin-process-protocol.js";
|
||||
import { PluginProcessMessageSchema } from "./plugin-process-protocol.js";
|
||||
import { PluginSessionSocket } from "./session-socket.js";
|
||||
|
||||
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
|
||||
@@ -16,6 +29,7 @@ const REQUEST_TIMEOUT_MS = 30_000;
|
||||
const MAX_LOG_ENTRIES = 500;
|
||||
const MAX_LOG_BYTES = 256 * 1024;
|
||||
const MAX_LOG_LINE_BYTES = 16 * 1024;
|
||||
const SOFT_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||
|
||||
interface PluginOutputStream {
|
||||
on(event: "data", listener: (chunk: Buffer | string) => void): this;
|
||||
@@ -27,9 +41,9 @@ interface PluginChild {
|
||||
stdout?: PluginOutputStream | null;
|
||||
stderr?: PluginOutputStream | null;
|
||||
send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean;
|
||||
kill(): boolean;
|
||||
kill(signal?: NodeJS.Signals): boolean;
|
||||
disconnect(): void;
|
||||
on(event: "message", listener: (message: PluginProcessMessage) => void): this;
|
||||
on(event: "message", listener: (message: unknown) => void): this;
|
||||
on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
||||
}
|
||||
|
||||
@@ -43,13 +57,37 @@ interface LoadedPlugin {
|
||||
id: string;
|
||||
clientBundle: string;
|
||||
methods: ReadonlySet<string>;
|
||||
providers: readonly PluginProviderMetadata[];
|
||||
child: PluginChild | null;
|
||||
outputCapture: PluginOutputCapture | null;
|
||||
pending: Map<string, PendingInvocation>;
|
||||
providerConnections: Map<string, RemoteProviderConnection>;
|
||||
providerConnectionTombstones: Set<string>;
|
||||
sessionSocket: PluginSessionSocket | null;
|
||||
sessionClosed: Promise<void> | null;
|
||||
}
|
||||
|
||||
interface PendingProviderSend {
|
||||
input: ProviderInput;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface RemoteProviderConnection {
|
||||
request: ProviderConnectRequest;
|
||||
capabilities: readonly string[];
|
||||
listeners: Set<(event: ProviderEvent) => void>;
|
||||
pendingSends: Map<string, PendingProviderSend>;
|
||||
sessions: Map<string, readonly string[]>;
|
||||
connected: boolean;
|
||||
closed: boolean;
|
||||
closedPromise: Promise<void>;
|
||||
resolveConnected(connection: ProviderConnection): void;
|
||||
rejectConnected(error: Error): void;
|
||||
resolveClosed(): void;
|
||||
rejectClosed(error: Error): void;
|
||||
}
|
||||
|
||||
interface PluginRuntimeDependencies {
|
||||
spawnChild?: () => PluginChild;
|
||||
sessionHost?: PluginPaseoSessionHost;
|
||||
@@ -174,6 +212,12 @@ function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function jsonTransportValue<Value>(value: Value): Value {
|
||||
const encoded = JSON.stringify(value);
|
||||
if (encoded === undefined) throw new Error("Provider value is not JSON-serializable");
|
||||
return JSON.parse(encoded) as Value;
|
||||
}
|
||||
|
||||
function send(child: PluginChild, message: PluginProcessRequest): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.send(message, (error) => {
|
||||
@@ -285,6 +329,70 @@ export class PluginRuntime {
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
getProviderRegistrations(pluginId: string): readonly PluginProviderMetadata[] {
|
||||
return this.plugins.get(pluginId)?.providers ?? [];
|
||||
}
|
||||
|
||||
async connectProvider(
|
||||
pluginId: string,
|
||||
providerId: string,
|
||||
request: ProviderConnectRequest,
|
||||
): Promise<ProviderConnection> {
|
||||
const loaded = this.plugins.get(pluginId);
|
||||
if (!loaded?.child) throw new Error(`Plugin is not available: ${pluginId}`);
|
||||
if (!loaded.providers.some((provider) => provider.id === providerId)) {
|
||||
throw new Error(`Plugin ${pluginId} does not contribute provider ${providerId}`);
|
||||
}
|
||||
const connectionId = randomUUID();
|
||||
let resolveConnected!: (connection: ProviderConnection) => void;
|
||||
let rejectConnected!: (error: Error) => void;
|
||||
let resolveClosed!: () => void;
|
||||
let rejectClosed!: (error: Error) => void;
|
||||
const connected = new Promise<ProviderConnection>((resolve, reject) => {
|
||||
resolveConnected = resolve;
|
||||
rejectConnected = reject;
|
||||
});
|
||||
const closed = new Promise<void>((resolve, reject) => {
|
||||
resolveClosed = resolve;
|
||||
rejectClosed = reject;
|
||||
});
|
||||
void closed.catch(() => undefined);
|
||||
const state: RemoteProviderConnection = {
|
||||
request,
|
||||
capabilities: [],
|
||||
listeners: new Set(),
|
||||
pendingSends: new Map(),
|
||||
sessions: new Map(),
|
||||
connected: false,
|
||||
closed: false,
|
||||
closedPromise: closed,
|
||||
resolveConnected,
|
||||
rejectConnected,
|
||||
resolveClosed,
|
||||
rejectClosed,
|
||||
};
|
||||
loaded.providerConnections.set(connectionId, state);
|
||||
const timeout = setTimeout(() => {
|
||||
if (state.connected) return;
|
||||
this.abandonProviderConnect(
|
||||
loaded,
|
||||
connectionId,
|
||||
state,
|
||||
new Error(`Plugin provider connection timed out: ${pluginId}.${providerId}`),
|
||||
);
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
void send(loaded.child, {
|
||||
type: "provider.connect",
|
||||
providerId,
|
||||
connectionId,
|
||||
request,
|
||||
}).catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
this.abandonProviderConnect(loaded, connectionId, state, error);
|
||||
});
|
||||
return connected.finally(() => clearTimeout(timeout));
|
||||
}
|
||||
|
||||
getLogs(pluginId: string): PluginLogEntry[] {
|
||||
return (
|
||||
this.logTails.get(pluginId)?.entries.map((entry) => ({
|
||||
@@ -345,9 +453,12 @@ export class PluginRuntime {
|
||||
id: pluginId,
|
||||
clientBundle: bundles.clientBundle ?? "",
|
||||
methods: new Set(),
|
||||
providers: [],
|
||||
child: null,
|
||||
outputCapture: null,
|
||||
pending: new Map(),
|
||||
providerConnections: new Map(),
|
||||
providerConnectionTombstones: new Set(),
|
||||
sessionSocket: null,
|
||||
sessionClosed: null,
|
||||
};
|
||||
@@ -367,51 +478,63 @@ export class PluginRuntime {
|
||||
throw error;
|
||||
});
|
||||
let loaded: LoadedPlugin | null = null;
|
||||
let methods: string[];
|
||||
let ready: Extract<PluginProcessMessage, { type: "ready" }>;
|
||||
try {
|
||||
methods = await new Promise<string[]>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(
|
||||
() => fail(new Error(`Plugin ${pluginId} did not initialize`)),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
const fail = (error: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
};
|
||||
child.on("message", (message) => {
|
||||
if (message.type === "paseo_frame") {
|
||||
sessionSocket.receive(message.data, message.isBinary);
|
||||
} else if (message.type === "paseo_close") {
|
||||
sessionSocket.peerClosed();
|
||||
} else if (message.type === "ready") {
|
||||
ready = await new Promise<Extract<PluginProcessMessage, { type: "ready" }>>(
|
||||
(resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(
|
||||
() => fail(new Error(`Plugin ${pluginId} did not initialize`)),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
const fail = (error: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(message.methods);
|
||||
} else if (message.type === "fatal") {
|
||||
fail(new Error(message.error));
|
||||
} else if (loaded) {
|
||||
this.handleChildMessage(loaded, message);
|
||||
}
|
||||
});
|
||||
child.on("close", () => {
|
||||
sessionSocket.peerClosed();
|
||||
if (!loaded) {
|
||||
fail(new Error(`Plugin ${pluginId} exited during initialization`));
|
||||
return;
|
||||
}
|
||||
void this.handleChildClose(loaded);
|
||||
});
|
||||
void send(child, {
|
||||
type: "initialize",
|
||||
pluginId,
|
||||
appVersion: this.daemonVersion,
|
||||
bundle: serverBundle,
|
||||
}).catch(fail);
|
||||
});
|
||||
reject(error);
|
||||
};
|
||||
child.on("message", (rawMessage) => {
|
||||
const parsed = PluginProcessMessageSchema.safeParse(rawMessage);
|
||||
if (!parsed.success) {
|
||||
const error = new Error(
|
||||
`Plugin ${pluginId} sent an invalid message: ${parsed.error.message}`,
|
||||
);
|
||||
if (loaded) this.handleMalformedChildMessage(loaded, rawMessage, error);
|
||||
else fail(error);
|
||||
return;
|
||||
}
|
||||
const message = parsed.data;
|
||||
if (message.type === "paseo_frame") {
|
||||
sessionSocket.receive(message.data, message.isBinary);
|
||||
} else if (message.type === "paseo_close") {
|
||||
sessionSocket.peerClosed();
|
||||
} else if (message.type === "ready") {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(message);
|
||||
} else if (message.type === "fatal") {
|
||||
fail(new Error(message.error));
|
||||
} else if (loaded) {
|
||||
this.handleChildMessage(loaded, message);
|
||||
}
|
||||
});
|
||||
child.on("close", () => {
|
||||
sessionSocket.peerClosed();
|
||||
if (!loaded) {
|
||||
fail(new Error(`Plugin ${pluginId} exited during initialization`));
|
||||
return;
|
||||
}
|
||||
void this.handleChildClose(loaded);
|
||||
});
|
||||
void send(child, {
|
||||
type: "initialize",
|
||||
pluginId,
|
||||
appVersion: this.daemonVersion,
|
||||
bundle: serverBundle,
|
||||
}).catch(fail);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
sessionSocket.close();
|
||||
await sessionAttachment.closed;
|
||||
@@ -421,18 +544,28 @@ export class PluginRuntime {
|
||||
loaded = {
|
||||
id: pluginId,
|
||||
clientBundle: bundles.clientBundle ?? "",
|
||||
methods: new Set(methods),
|
||||
methods: new Set(ready.methods),
|
||||
providers: ready.providers ?? [],
|
||||
child,
|
||||
outputCapture,
|
||||
pending,
|
||||
providerConnections: new Map(),
|
||||
providerConnectionTombstones: new Set(),
|
||||
sessionSocket,
|
||||
sessionClosed: sessionAttachment.closed,
|
||||
};
|
||||
this.logger.info({ pluginId, methods }, "Loaded plugin");
|
||||
this.logger.info(
|
||||
{ pluginId, methods: ready.methods, providers: ready.providers },
|
||||
"Loaded plugin",
|
||||
);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void {
|
||||
if (message.type.startsWith("provider.")) {
|
||||
this.handleProviderMessage(loaded, message);
|
||||
return;
|
||||
}
|
||||
if (message.type !== "result" && message.type !== "error") return;
|
||||
const pending = loaded.pending.get(message.requestId);
|
||||
if (!pending) return;
|
||||
@@ -442,6 +575,295 @@ export class PluginRuntime {
|
||||
else pending.reject(new Error(message.error));
|
||||
}
|
||||
|
||||
private handleProviderMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void {
|
||||
if (!("connectionId" in message)) return;
|
||||
const state = loaded.providerConnections.get(message.connectionId);
|
||||
if (!state) {
|
||||
if (!loaded.providerConnectionTombstones.has(message.connectionId)) return;
|
||||
if (message.type === "provider.connected" && loaded.child?.connected) {
|
||||
void send(loaded.child, {
|
||||
type: "provider.close",
|
||||
connectionId: message.connectionId,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
if (message.type === "provider.closed" || message.type === "provider.connect_failed") {
|
||||
loaded.providerConnectionTombstones.delete(message.connectionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === "provider.connected") {
|
||||
this.finishProviderConnect(loaded, message.connectionId, state, message);
|
||||
return;
|
||||
}
|
||||
if (message.type === "provider.connect_failed") {
|
||||
this.abandonProviderConnect(loaded, message.connectionId, state, new Error(message.error));
|
||||
return;
|
||||
}
|
||||
if (message.type === "provider.accepted" || message.type === "provider.rejected") {
|
||||
const pending = state.pendingSends.get(message.acceptanceId);
|
||||
if (!pending) return;
|
||||
state.pendingSends.delete(message.acceptanceId);
|
||||
if (message.type === "provider.accepted") {
|
||||
this.trackAcceptedProviderInput(state, pending.input);
|
||||
pending.resolve();
|
||||
} else pending.reject(new Error(message.error));
|
||||
return;
|
||||
}
|
||||
if (message.type === "provider.event") {
|
||||
this.publishProviderEvent(loaded, message.connectionId, state, message.event);
|
||||
return;
|
||||
}
|
||||
if (message.type === "provider.closed") {
|
||||
loaded.providerConnections.delete(message.connectionId);
|
||||
if (message.error) {
|
||||
this.failRemoteProviderConnection(state, new Error(message.error));
|
||||
return;
|
||||
}
|
||||
state.closed = true;
|
||||
this.rejectProviderSends(state, "Provider connection closed");
|
||||
state.resolveClosed();
|
||||
}
|
||||
}
|
||||
|
||||
private finishProviderConnect(
|
||||
loaded: LoadedPlugin,
|
||||
connectionId: string,
|
||||
state: RemoteProviderConnection,
|
||||
message: Extract<PluginProcessMessage, { type: "provider.connected" }>,
|
||||
): void {
|
||||
if (!Number.isInteger(message.version) || message.version < 1) {
|
||||
this.abandonProviderConnect(
|
||||
loaded,
|
||||
connectionId,
|
||||
state,
|
||||
new Error(`Plugin provider selected invalid version: ${message.version}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!state.request.versions.includes(message.version)) {
|
||||
this.abandonProviderConnect(
|
||||
loaded,
|
||||
connectionId,
|
||||
state,
|
||||
new Error(`Plugin provider selected an unoffered version: ${message.version}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const knownCapabilities = new Set<string>(PROVIDER_CAPABILITIES);
|
||||
const invalidCapability = message.capabilities.find(
|
||||
(capability) =>
|
||||
knownCapabilities.has(capability) && !state.request.capabilities.includes(capability),
|
||||
);
|
||||
if (invalidCapability) {
|
||||
this.abandonProviderConnect(
|
||||
loaded,
|
||||
connectionId,
|
||||
state,
|
||||
new Error(`Plugin provider selected an unoffered capability: ${invalidCapability}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const capabilities = PROVIDER_CAPABILITIES.filter((capability) =>
|
||||
message.capabilities.includes(capability),
|
||||
);
|
||||
const connection: ProviderConnection = {
|
||||
version: message.version,
|
||||
capabilities,
|
||||
send: (input) => this.sendProviderInput(loaded, connectionId, state, input),
|
||||
onEvent(listener) {
|
||||
state.listeners.add(listener);
|
||||
return () => state.listeners.delete(listener);
|
||||
},
|
||||
close: () => this.closeProviderConnection(loaded, connectionId, state),
|
||||
};
|
||||
state.capabilities = capabilities;
|
||||
state.connected = true;
|
||||
state.resolveConnected(connection);
|
||||
}
|
||||
|
||||
private abandonProviderConnect(
|
||||
loaded: LoadedPlugin,
|
||||
connectionId: string,
|
||||
state: RemoteProviderConnection,
|
||||
error: Error,
|
||||
): void {
|
||||
if (state.connected || state.closed) return;
|
||||
loaded.providerConnections.delete(connectionId);
|
||||
loaded.providerConnectionTombstones.add(connectionId);
|
||||
state.closed = true;
|
||||
state.rejectConnected(error);
|
||||
state.rejectClosed(error);
|
||||
if (loaded.child?.connected) {
|
||||
void send(loaded.child, { type: "provider.close", connectionId }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private sendProviderInput(
|
||||
loaded: LoadedPlugin,
|
||||
connectionId: string,
|
||||
state: RemoteProviderConnection,
|
||||
input: ProviderInput,
|
||||
): Promise<void> {
|
||||
if (state.closed) return Promise.reject(new Error("Provider connection is closed"));
|
||||
try {
|
||||
if (input.type === "session.open" || !("sessionId" in input)) {
|
||||
requireProviderCapabilities(state.capabilities, input);
|
||||
} else {
|
||||
const capabilities = state.sessions.get(input.sessionId);
|
||||
if (!capabilities) throw new Error(`Unknown provider session: ${input.sessionId}`);
|
||||
requireProviderCapabilities(capabilities, input);
|
||||
}
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const child = loaded.child;
|
||||
if (!child) return Promise.reject(new Error(`Plugin has no server entry: ${loaded.id}`));
|
||||
const acceptanceId = randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
state.pendingSends.set(acceptanceId, { input, resolve, reject });
|
||||
void send(child, {
|
||||
type: "provider.send",
|
||||
connectionId,
|
||||
acceptanceId,
|
||||
input: jsonTransportValue(input),
|
||||
}).catch((error) => {
|
||||
state.pendingSends.delete(acceptanceId);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async closeProviderConnection(
|
||||
loaded: LoadedPlugin,
|
||||
connectionId: string,
|
||||
state: RemoteProviderConnection,
|
||||
): Promise<void> {
|
||||
if (state.closed) return;
|
||||
const child = loaded.child;
|
||||
if (!child) throw new Error(`Plugin has no server entry: ${loaded.id}`);
|
||||
await send(child, { type: "provider.close", connectionId });
|
||||
await state.closedPromise;
|
||||
}
|
||||
|
||||
private publishProviderEvent(
|
||||
loaded: LoadedPlugin,
|
||||
connectionId: string,
|
||||
state: RemoteProviderConnection,
|
||||
event: ProviderEvent,
|
||||
): void {
|
||||
if (
|
||||
event.type === "timeline.item" &&
|
||||
event.item.type === "plugin" &&
|
||||
event.item.pluginId !== loaded.id
|
||||
) {
|
||||
this.failProviderConnection(
|
||||
loaded,
|
||||
connectionId,
|
||||
state,
|
||||
new Error(
|
||||
`Provider plugin ${loaded.id} cannot emit timeline items for ${event.item.pluginId}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.type === "session.opened") {
|
||||
const knownCapabilities = new Set<string>(PROVIDER_CAPABILITIES);
|
||||
const invalidCapability = event.capabilities.find(
|
||||
(capability) =>
|
||||
knownCapabilities.has(capability) && !state.capabilities.includes(capability),
|
||||
);
|
||||
if (invalidCapability) {
|
||||
this.failProviderConnection(
|
||||
loaded,
|
||||
connectionId,
|
||||
state,
|
||||
new Error(`Provider session selected an unoffered capability: ${invalidCapability}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const selectedCapabilities = event.capabilities;
|
||||
event = {
|
||||
...event,
|
||||
capabilities: PROVIDER_CAPABILITIES.filter((capability) =>
|
||||
selectedCapabilities.includes(capability),
|
||||
),
|
||||
};
|
||||
if (
|
||||
event.parentSessionId &&
|
||||
!state.sessions.get(event.parentSessionId)?.includes("session.subsession")
|
||||
) {
|
||||
this.failProviderConnection(
|
||||
loaded,
|
||||
connectionId,
|
||||
state,
|
||||
new Error(`Provider parent session did not negotiate session.subsession`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.type === "session.opened") {
|
||||
state.sessions.set(event.sessionId, event.capabilities);
|
||||
}
|
||||
for (const listener of state.listeners) listener(event);
|
||||
if (event.type === "session.closed") state.sessions.delete(event.sessionId);
|
||||
}
|
||||
|
||||
private failProviderConnection(
|
||||
loaded: LoadedPlugin,
|
||||
connectionId: string,
|
||||
state: RemoteProviderConnection,
|
||||
error: Error,
|
||||
): void {
|
||||
loaded.providerConnections.delete(connectionId);
|
||||
this.failRemoteProviderConnection(state, error);
|
||||
if (loaded.child?.connected) {
|
||||
void send(loaded.child, { type: "provider.close", connectionId }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private trackAcceptedProviderInput(state: RemoteProviderConnection, input: ProviderInput): void {
|
||||
if (input.type === "session.open" && !state.sessions.has(input.sessionId)) {
|
||||
state.sessions.set(input.sessionId, []);
|
||||
}
|
||||
if (input.type === "session.close") state.sessions.delete(input.sessionId);
|
||||
}
|
||||
|
||||
private handleMalformedChildMessage(
|
||||
loaded: LoadedPlugin,
|
||||
rawMessage: unknown,
|
||||
error: Error,
|
||||
): void {
|
||||
const connectionId = readConnectionId(rawMessage);
|
||||
const state = connectionId ? loaded.providerConnections.get(connectionId) : undefined;
|
||||
if (!connectionId || !state) {
|
||||
this.appendLog(loaded.id, "stderr", `[paseo] ${error.message}`);
|
||||
terminatePluginChild(loaded.child!);
|
||||
return;
|
||||
}
|
||||
this.failProviderConnection(loaded, connectionId, state, error);
|
||||
}
|
||||
|
||||
private failRemoteProviderConnection(state: RemoteProviderConnection, error: Error): void {
|
||||
if (state.closed) return;
|
||||
for (const sessionId of state.sessions.keys()) {
|
||||
const event: ProviderEvent = {
|
||||
type: "session.runtime_failed",
|
||||
sessionId,
|
||||
error: { message: error.message },
|
||||
};
|
||||
for (const listener of state.listeners) listener(event);
|
||||
}
|
||||
state.closed = true;
|
||||
state.rejectConnected(error);
|
||||
this.rejectProviderSends(state, error.message);
|
||||
state.rejectClosed(error);
|
||||
}
|
||||
|
||||
private rejectProviderSends(state: RemoteProviderConnection, message: string): void {
|
||||
for (const pending of state.pendingSends.values()) pending.reject(new Error(message));
|
||||
state.pendingSends.clear();
|
||||
}
|
||||
|
||||
private async handleChildClose(loaded: LoadedPlugin): Promise<void> {
|
||||
loaded.sessionSocket?.peerClosed();
|
||||
const wasPublished = this.plugins.get(loaded.id) === loaded;
|
||||
@@ -449,12 +871,25 @@ export class PluginRuntime {
|
||||
this.plugins.delete(loaded.id);
|
||||
}
|
||||
this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`);
|
||||
for (const state of loaded.providerConnections.values()) {
|
||||
this.failRemoteProviderConnection(state, new Error(`Plugin process exited: ${loaded.id}`));
|
||||
}
|
||||
loaded.providerConnections.clear();
|
||||
await loaded.sessionClosed;
|
||||
if (wasPublished) this.notify(loaded.id, `Plugin process exited: ${loaded.id}`);
|
||||
}
|
||||
|
||||
private async stopPlugin(loaded: LoadedPlugin): Promise<void> {
|
||||
this.appendLog(loaded.id, "stdout", "[paseo] Stopping plugin");
|
||||
for (const [connectionId, state] of loaded.providerConnections) {
|
||||
if (state.connected) continue;
|
||||
this.abandonProviderConnect(
|
||||
loaded,
|
||||
connectionId,
|
||||
state,
|
||||
new Error(`Plugin stopped: ${loaded.id}`),
|
||||
);
|
||||
}
|
||||
const { child, sessionSocket, sessionClosed } = loaded;
|
||||
if (!child || !sessionSocket || !sessionClosed) {
|
||||
this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped");
|
||||
@@ -474,7 +909,15 @@ export class PluginRuntime {
|
||||
if (child.connected) {
|
||||
await send(child, { type: "shutdown" }).catch(() => undefined);
|
||||
}
|
||||
await closed;
|
||||
let forceTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
if (!child.killed) child.kill("SIGTERM");
|
||||
forceTimer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, SOFT_SHUTDOWN_TIMEOUT_MS);
|
||||
}, SOFT_SHUTDOWN_TIMEOUT_MS);
|
||||
await closed.finally(() => {
|
||||
if (forceTimer) clearTimeout(forceTimer);
|
||||
});
|
||||
sessionSocket.peerClosed();
|
||||
await sessionClosed;
|
||||
this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped");
|
||||
@@ -515,3 +958,9 @@ export class PluginRuntime {
|
||||
for (const listener of this.listeners) listener(pluginId, error);
|
||||
}
|
||||
}
|
||||
|
||||
function readConnectionId(value: unknown): string | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
const connectionId = Reflect.get(value, "connectionId");
|
||||
return typeof connectionId === "string" ? connectionId : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# ACP provider transformer example
|
||||
|
||||
This server plugin registers an ACP command through `runAcpProvider()`. The helper owns ACP process
|
||||
and SDK details behind Paseo's callback-based provider connection.
|
||||
|
||||
`server/vendor-edit.ts` shows the intended extension seam: a focused transformer converts one
|
||||
vendor tool payload into Paseo's ordinary file-edit shape. It does not translate the whole event
|
||||
stream.
|
||||
|
||||
Replace the placeholder `example-acp --stdio` command with an installed ACP agent before enabling
|
||||
the plugin.
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 7h16M4 17h16" />
|
||||
<path d="m8 3-4 4 4 4m8 2 4 4-4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1,17 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { runAcpProvider } from "@getpaseo/plugin/acp";
|
||||
import { vendorEditTransformer } from "./server/vendor-edit.js";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(
|
||||
runAcpProvider({
|
||||
id: "example-acp",
|
||||
label: "Example ACP",
|
||||
description: "An ACP command adapted to Paseo's provider boundary",
|
||||
icon: "icon.svg",
|
||||
command: ["example-acp", "--stdio"],
|
||||
transformers: [vendorEditTransformer],
|
||||
}),
|
||||
);
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "provider-acp-transformer-example",
|
||||
"name": "ACP provider transformer example",
|
||||
"version": "1.0.0",
|
||||
"description": "Wraps an ACP command and normalizes one vendor file-edit shape."
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { vendorEditTransformer } from "./vendor-edit.js";
|
||||
|
||||
describe("vendorEditTransformer", () => {
|
||||
it("normalizes only the vendor edit payload", () => {
|
||||
const transformed = vendorEditTransformer.toolCall?.(
|
||||
{
|
||||
id: "edit-1",
|
||||
name: "vendor_file_edit",
|
||||
title: "Edit file",
|
||||
status: "completed",
|
||||
input: { path: "src/app.ts", before: "old", after: "new" },
|
||||
output: null,
|
||||
locations: [],
|
||||
},
|
||||
{ sessionId: "session-1" },
|
||||
);
|
||||
|
||||
expect(transformed).toMatchObject({
|
||||
kind: "edit",
|
||||
input: { filePath: "src/app.ts", oldString: "old", newString: "new" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a vendor edit with a malformed payload", () => {
|
||||
const toolCall = {
|
||||
id: "edit-1",
|
||||
name: "vendor_file_edit",
|
||||
title: "Edit file",
|
||||
status: "completed" as const,
|
||||
input: { path: "src/app.ts", before: "old" },
|
||||
output: null,
|
||||
locations: [],
|
||||
};
|
||||
|
||||
const transformed = vendorEditTransformer.toolCall?.(toolCall, {
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(transformed).toBe(toolCall);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AcpTransformer } from "@getpaseo/plugin/acp";
|
||||
import { z } from "zod";
|
||||
|
||||
const vendorEditInputSchema = z.object({
|
||||
path: z.string(),
|
||||
before: z.string(),
|
||||
after: z.string(),
|
||||
});
|
||||
|
||||
export const vendorEditTransformer: AcpTransformer = {
|
||||
toolCall(toolCall) {
|
||||
if (toolCall.name !== "vendor_file_edit") return toolCall;
|
||||
const input = vendorEditInputSchema.safeParse(toolCall.input);
|
||||
if (!input.success) return toolCall;
|
||||
return {
|
||||
...toolCall,
|
||||
kind: "edit",
|
||||
input: {
|
||||
filePath: input.data.path,
|
||||
oldString: input.data.before,
|
||||
newString: input.data.after,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
# Direct provider example
|
||||
|
||||
This plugin implements `ProviderRegistration` directly. It demonstrates connection negotiation,
|
||||
catalog and session listing, session creation, prompt and command disposition, settings,
|
||||
persistence and replay, archive actions, and an ordinary child session.
|
||||
|
||||
The provider emits versioned `provider-result` timeline items for its root and child sessions.
|
||||
`index.client.tsx` registers the Zod schema and React Native renderer for those items. The server
|
||||
and client share only the item kind and schema under `shared/`.
|
||||
|
||||
The provider receives Paseo host tools through `config.mcpServers`; it does not receive a second
|
||||
callback tool API.
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PluginTimelineItemProps } from "@getpaseo/plugin";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import type { z } from "zod";
|
||||
import { providerResultSchema } from "../shared/provider-result";
|
||||
|
||||
type ProviderResultData = z.output<typeof providerResultSchema>;
|
||||
|
||||
export function ProviderResult({ item, theme }: PluginTimelineItemProps<ProviderResultData>) {
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
card: {
|
||||
gap: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
label: { color: theme.colors.accent, fontWeight: "600" as const },
|
||||
detail: { color: theme.colors.foreground },
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.label}>{item.data.label}</Text>
|
||||
<Text style={styles.detail}>{item.data.detail}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 5h14v14H5z" />
|
||||
<path d="m9 9 6 6m0-6-6 6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 227 B |
@@ -0,0 +1,13 @@
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { ProviderResult } from "./client/provider-result";
|
||||
import { providerResultKind, providerResultSchema } from "./shared/provider-result";
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addTimelineRenderer({
|
||||
kind: providerResultKind,
|
||||
version: 1,
|
||||
schema: providerResultSchema,
|
||||
Component: ProviderResult,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { createDirectExampleProvider } from "./server/provider";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(createDirectExampleProvider());
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"id": "provider-direct-example"
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { ProviderEvent } from "@getpaseo/plugin/provider";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDirectExampleProvider } from "./provider.js";
|
||||
|
||||
describe("direct provider example", () => {
|
||||
it("fails steering when no turn is active without creating a turn", async () => {
|
||||
const connection = await createDirectExampleProvider().connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.command", "prompt.message", "prompt.steer"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send({
|
||||
type: "session.open",
|
||||
requestId: "open-1",
|
||||
sessionId: "session-1",
|
||||
config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: false },
|
||||
history: "skip",
|
||||
});
|
||||
const turnEventsBefore = events.filter((event) => event.type === "session.turn").length;
|
||||
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "steer-1",
|
||||
delivery: "steer",
|
||||
input: { type: "command", name: "reset", arguments: "" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "session.prompt_result",
|
||||
sessionId: "session-1",
|
||||
clientMessageId: "steer-1",
|
||||
result: { type: "failed", error: { message: "There is no active turn to steer" } },
|
||||
});
|
||||
expect(events.filter((event) => event.type === "session.turn")).toHaveLength(turnEventsBefore);
|
||||
expect(events).not.toContainEqual(expect.objectContaining({ type: "session.notice" }));
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("demonstrates settings, commands, persistence, and provider-rendered timeline items", async () => {
|
||||
const connection = await createDirectExampleProvider().connect({
|
||||
versions: [1],
|
||||
capabilities: [
|
||||
"prompt.message",
|
||||
"prompt.command",
|
||||
"session.configure",
|
||||
"session.persistence",
|
||||
"session.subsession",
|
||||
"timeline.plugin",
|
||||
],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
await connection.send({
|
||||
type: "session.open",
|
||||
requestId: "open-complete",
|
||||
sessionId: "session-complete",
|
||||
config: {
|
||||
cwd: "/repo",
|
||||
env: { EXAMPLE: "1" },
|
||||
systemPrompt: "Be useful",
|
||||
mcpServers: {},
|
||||
model: "example-1",
|
||||
mode: "build",
|
||||
settings: { concise: true, voice: "calm" },
|
||||
providerOptions: { privateValue: 1 },
|
||||
persist: true,
|
||||
},
|
||||
history: "skip",
|
||||
});
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.config",
|
||||
config: expect.objectContaining({
|
||||
settings: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "concise", value: true }),
|
||||
expect.objectContaining({ id: "voice", value: "calm" }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(expect.objectContaining({ type: "session.commands" }));
|
||||
|
||||
await connection.send({
|
||||
type: "session.configure",
|
||||
requestId: "configure-complete",
|
||||
sessionId: "session-complete",
|
||||
changes: { settings: { voice: "direct" } },
|
||||
});
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-complete",
|
||||
prompt: {
|
||||
clientMessageId: "command-1",
|
||||
delivery: "auto",
|
||||
input: { type: "command", name: "reset", arguments: "" },
|
||||
},
|
||||
});
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-complete",
|
||||
prompt: {
|
||||
clientMessageId: "message-1",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "hello" }] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.prompt_result",
|
||||
clientMessageId: "command-1",
|
||||
result: { type: "completed" },
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline.item",
|
||||
item: expect.objectContaining({ type: "user_message", clientMessageId: "message-1" }),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline.item",
|
||||
sessionId: "session-complete",
|
||||
item: expect.objectContaining({ type: "plugin", kind: "provider-result" }),
|
||||
}),
|
||||
);
|
||||
const child = events.find(
|
||||
(event): event is Extract<ProviderEvent, { type: "session.opened" }> =>
|
||||
event.type === "session.opened" && event.parentSessionId === "session-complete",
|
||||
);
|
||||
expect(child?.sessionId).toMatch(/^[0-9a-f-]{36}$/u);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline.item",
|
||||
sessionId: child?.sessionId,
|
||||
item: expect.objectContaining({ type: "plugin", kind: "provider-result" }),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(expect.objectContaining({ type: "session.persistence" }));
|
||||
await connection.close();
|
||||
});
|
||||
|
||||
it("rejects calls after the example connection closes", async () => {
|
||||
const connection = await createDirectExampleProvider().connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
|
||||
await connection.close();
|
||||
|
||||
await expect(
|
||||
connection.send({ type: "catalog", requestId: "catalog-after-close" }),
|
||||
).rejects.toThrow("Provider connection is closed");
|
||||
await expect(
|
||||
connection.send({
|
||||
type: "session.open",
|
||||
requestId: "open-after-close",
|
||||
sessionId: "closed-session",
|
||||
config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: false },
|
||||
history: "skip",
|
||||
}),
|
||||
).rejects.toThrow("Provider connection is closed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
import {
|
||||
negotiateProviderCapabilities,
|
||||
requireProviderCapabilities,
|
||||
type ProviderConfigState,
|
||||
type ProviderConnection,
|
||||
type ProviderEvent,
|
||||
type ProviderInput,
|
||||
type ProviderPersistence,
|
||||
type ProviderRegistration,
|
||||
type ProviderSessionConfig,
|
||||
} from "@getpaseo/plugin/provider";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { providerResultKind } from "../shared/provider-result.js";
|
||||
|
||||
const CAPABILITIES = [
|
||||
"prompt.message",
|
||||
"prompt.command",
|
||||
"prompt.steer",
|
||||
"session.archive",
|
||||
"session.configure",
|
||||
"session.list",
|
||||
"session.persistence",
|
||||
"session.subsession",
|
||||
"session.unarchive",
|
||||
"timeline.plugin",
|
||||
] as const;
|
||||
|
||||
interface ExampleSession {
|
||||
config: ProviderSessionConfig;
|
||||
persistence: ProviderPersistence;
|
||||
turn: number;
|
||||
activeTurnId: string | null;
|
||||
}
|
||||
|
||||
export function createDirectExampleProvider(): ProviderRegistration {
|
||||
return {
|
||||
id: "direct-example",
|
||||
label: "Direct provider example",
|
||||
description: "A complete provider implemented directly against Paseo's provider boundary",
|
||||
icon: "icon.svg",
|
||||
async connect(request) {
|
||||
if (!request.versions.includes(1)) throw new Error("Provider protocol version 1 is required");
|
||||
return createConnection(negotiateProviderCapabilities(request.capabilities, CAPABILITIES));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createConnection(capabilities: readonly string[]): ProviderConnection {
|
||||
const listeners = new Set<(event: ProviderEvent) => void>();
|
||||
const sessions = new Map<string, ExampleSession>();
|
||||
const archived = new Set<string>();
|
||||
let closed = false;
|
||||
const emit = (event: ProviderEvent) => {
|
||||
if (closed) return;
|
||||
for (const listener of listeners) listener(event);
|
||||
};
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
capabilities,
|
||||
async send(input) {
|
||||
if (closed) throw new Error("Provider connection is closed");
|
||||
validateAdmission(input, { sessions, capabilities });
|
||||
queueMicrotask(() => {
|
||||
if (!closed) dispatch(input, { sessions, archived, emit, capabilities });
|
||||
});
|
||||
},
|
||||
onEvent(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
sessions.clear();
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateAdmission(
|
||||
input: ProviderInput,
|
||||
state: Pick<ExampleState, "sessions" | "capabilities">,
|
||||
): void {
|
||||
if (input.type === "session.open") {
|
||||
if (state.sessions.has(input.sessionId)) {
|
||||
throw new Error(`Session already exists: ${input.sessionId}`);
|
||||
}
|
||||
requireProviderCapabilities(state.capabilities, input);
|
||||
return;
|
||||
}
|
||||
if (!("sessionId" in input)) {
|
||||
requireProviderCapabilities(state.capabilities, input);
|
||||
return;
|
||||
}
|
||||
const session = state.sessions.get(input.sessionId);
|
||||
if (!session) throw new Error(`Unknown session: ${input.sessionId}`);
|
||||
requireProviderCapabilities(state.capabilities, input);
|
||||
}
|
||||
|
||||
interface ExampleState {
|
||||
sessions: Map<string, ExampleSession>;
|
||||
archived: Set<string>;
|
||||
emit(event: ProviderEvent): void;
|
||||
capabilities: readonly string[];
|
||||
}
|
||||
|
||||
function dispatch(input: ProviderInput, state: ExampleState): void {
|
||||
switch (input.type) {
|
||||
case "catalog":
|
||||
state.emit({
|
||||
type: "catalog",
|
||||
requestId: input.requestId,
|
||||
catalog: {
|
||||
models: [{ id: "example-1", label: "Example 1" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
defaultModel: "example-1",
|
||||
defaultMode: "build",
|
||||
},
|
||||
});
|
||||
return;
|
||||
case "sessions":
|
||||
state.emit({
|
||||
type: "sessions",
|
||||
requestId: input.requestId,
|
||||
sessions: [...state.sessions.values()].map((session) => ({
|
||||
persistence: session.persistence,
|
||||
cwd: session.config.cwd,
|
||||
title: session.config.title,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
case "session.open":
|
||||
openSession(input, state);
|
||||
return;
|
||||
case "session.prompt":
|
||||
promptSession(input, state);
|
||||
return;
|
||||
case "session.configure":
|
||||
configureSession(input, state);
|
||||
return;
|
||||
case "session.interrupt":
|
||||
state.emit({ type: "request.completed", requestId: input.requestId });
|
||||
return;
|
||||
case "session.permission":
|
||||
state.emit({
|
||||
type: "session.permission_resolved",
|
||||
sessionId: input.sessionId,
|
||||
permissionId: input.permissionId,
|
||||
});
|
||||
return;
|
||||
case "session.revert":
|
||||
state.emit({ type: "request.completed", requestId: input.requestId });
|
||||
return;
|
||||
case "session.archive":
|
||||
state.archived.add(persistenceKey(input.persistence));
|
||||
state.emit({ type: "request.completed", requestId: input.requestId });
|
||||
return;
|
||||
case "session.unarchive":
|
||||
state.archived.delete(persistenceKey(input.persistence));
|
||||
state.emit({ type: "request.completed", requestId: input.requestId });
|
||||
return;
|
||||
case "session.close":
|
||||
state.sessions.delete(input.sessionId);
|
||||
state.emit({ type: "session.closed", sessionId: input.sessionId });
|
||||
state.emit({ type: "request.completed", requestId: input.requestId });
|
||||
}
|
||||
}
|
||||
|
||||
function openSession(
|
||||
input: Extract<ProviderInput, { type: "session.open" }>,
|
||||
state: ExampleState,
|
||||
): void {
|
||||
const persistence = input.persistence ?? {
|
||||
version: 1,
|
||||
data: { nativeSessionId: `example:${input.sessionId}` },
|
||||
};
|
||||
const session: ExampleSession = {
|
||||
config: input.config,
|
||||
persistence,
|
||||
turn: 0,
|
||||
activeTurnId: null,
|
||||
};
|
||||
state.sessions.set(input.sessionId, session);
|
||||
state.emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: state.capabilities,
|
||||
restoration: "core",
|
||||
persistence,
|
||||
title: input.config.title,
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
state.emit({ type: "session.config", sessionId: input.sessionId, config: configState(session) });
|
||||
state.emit({
|
||||
type: "session.commands",
|
||||
sessionId: input.sessionId,
|
||||
commands: [{ name: "reset", description: "Reset the example provider state" }],
|
||||
});
|
||||
if (input.history === "replay" && input.persistence) {
|
||||
state.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: { type: "assistant_message", id: "replayed-1", text: "Restored from persistence" },
|
||||
});
|
||||
}
|
||||
state.emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
}
|
||||
|
||||
function promptSession(
|
||||
input: Extract<ProviderInput, { type: "session.prompt" }>,
|
||||
state: ExampleState,
|
||||
): void {
|
||||
const session = requireSession(state, input.sessionId);
|
||||
if (input.prompt.delivery === "steer") {
|
||||
if (!session.activeTurnId) {
|
||||
state.emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "failed", error: { message: "There is no active turn to steer" } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "steer", turnId: session.activeTurnId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.prompt.input.type === "command") {
|
||||
if (input.prompt.input.name !== "reset") {
|
||||
state.emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "failed", error: { message: "Unknown command" } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
session.turn = 0;
|
||||
state.emit({
|
||||
type: "session.notice",
|
||||
sessionId: input.sessionId,
|
||||
notice: { id: "reset", severity: "info", title: "Example state reset" },
|
||||
});
|
||||
state.emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "completed" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
session.turn += 1;
|
||||
const turnId = `turn-${session.turn}`;
|
||||
session.activeTurnId = turnId;
|
||||
const text = input.prompt.input.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
state.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "user_message",
|
||||
id: `user-${session.turn}`,
|
||||
text,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
},
|
||||
});
|
||||
state.emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "turn", turnId },
|
||||
});
|
||||
state.emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" });
|
||||
state.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: { type: "assistant_message", id: `assistant-${session.turn}`, text: `Echo: ${text}` },
|
||||
});
|
||||
state.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "plugin",
|
||||
id: `provider-result-${session.turn}`,
|
||||
pluginId: "provider-direct-example",
|
||||
kind: providerResultKind,
|
||||
version: 1,
|
||||
data: { label: "Provider result", detail: `Echoed turn ${session.turn}` },
|
||||
},
|
||||
});
|
||||
publishChild(input.sessionId, session.turn, state);
|
||||
session.persistence = {
|
||||
version: 1,
|
||||
data: { nativeSessionId: `example:${input.sessionId}`, turns: session.turn },
|
||||
};
|
||||
state.emit({
|
||||
type: "session.persistence",
|
||||
sessionId: input.sessionId,
|
||||
persistence: session.persistence,
|
||||
});
|
||||
state.emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" });
|
||||
session.activeTurnId = null;
|
||||
}
|
||||
|
||||
function publishChild(parentSessionId: string, turn: number, state: ExampleState): void {
|
||||
const sessionId = randomUUID();
|
||||
state.emit({
|
||||
type: "session.opened",
|
||||
sessionId,
|
||||
parentSessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
title: "Example child",
|
||||
cwd: requireSession(state, parentSessionId).config.cwd,
|
||||
});
|
||||
state.emit({
|
||||
type: "timeline.item",
|
||||
sessionId,
|
||||
item: {
|
||||
type: "plugin",
|
||||
id: `child-result-${turn}`,
|
||||
pluginId: "provider-direct-example",
|
||||
kind: providerResultKind,
|
||||
version: 1,
|
||||
data: { label: "Child result", detail: "Completed" },
|
||||
},
|
||||
});
|
||||
state.emit({ type: "session.ready", sessionId });
|
||||
}
|
||||
|
||||
function configureSession(
|
||||
input: Extract<ProviderInput, { type: "session.configure" }>,
|
||||
state: ExampleState,
|
||||
): void {
|
||||
const session = requireSession(state, input.sessionId);
|
||||
const changes = input.changes;
|
||||
session.config = {
|
||||
...session.config,
|
||||
model: changes.model === null ? undefined : (changes.model ?? session.config.model),
|
||||
mode: changes.mode === null ? undefined : (changes.mode ?? session.config.mode),
|
||||
thinkingOption:
|
||||
changes.thinkingOption === null
|
||||
? undefined
|
||||
: (changes.thinkingOption ?? session.config.thinkingOption),
|
||||
settings: changes.settings
|
||||
? { ...session.config.settings, ...changes.settings }
|
||||
: session.config.settings,
|
||||
};
|
||||
state.emit({ type: "session.config", sessionId: input.sessionId, config: configState(session) });
|
||||
state.emit({ type: "request.completed", requestId: input.requestId });
|
||||
}
|
||||
|
||||
function configState(session: ExampleSession): ProviderConfigState {
|
||||
return {
|
||||
model: session.config.model ?? "example-1",
|
||||
mode: session.config.mode ?? "build",
|
||||
thinkingOption: session.config.thinkingOption,
|
||||
models: [{ id: "example-1", label: "Example 1" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
settings: [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "concise",
|
||||
label: "Concise responses",
|
||||
value: session.config.settings.concise === true,
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
id: "voice",
|
||||
label: "Voice",
|
||||
value:
|
||||
typeof session.config.settings.voice === "string" ? session.config.settings.voice : null,
|
||||
options: [
|
||||
{ label: "Calm", value: "calm" },
|
||||
{ label: "Direct", value: "direct" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function requireSession(state: ExampleState, sessionId: string): ExampleSession {
|
||||
const session = state.sessions.get(sessionId);
|
||||
if (!session) throw new Error(`Unknown session: ${sessionId}`);
|
||||
return session;
|
||||
}
|
||||
|
||||
function persistenceKey(persistence: ProviderPersistence): string {
|
||||
return JSON.stringify(persistence);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const providerResultKind = "provider-result";
|
||||
|
||||
export const providerResultSchema = z.object({
|
||||
label: z.string(),
|
||||
detail: z.string(),
|
||||
});
|
||||
@@ -24,7 +24,8 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add
|
||||
[timeline items](/docs/plugins/v0.8/reference#timeline-items),
|
||||
[themes](/docs/plugins/v0.8/reference#contribute-a-theme),
|
||||
[attachment sources](/docs/plugins/v0.8/reference#add-a-composer-attachment-source), and
|
||||
[daemon-side RPCs](/docs/plugins/v0.8/reference#add-plugin-specific-backend-behavior). Client
|
||||
[daemon-side RPCs](/docs/plugins/v0.8/reference#add-plugin-specific-backend-behavior). It can also
|
||||
[connect a coding agent as a provider](/docs/plugins/v0.8/providers). Client
|
||||
contributions run on every Paseo client connected to that daemon, including mobile.
|
||||
|
||||
This guide scaffolds a plugin, runs it, and adds a workspace panel to it.
|
||||
@@ -265,6 +266,8 @@ log.
|
||||
|
||||
## Next
|
||||
|
||||
- [Build a provider plugin](/docs/plugins/v0.8/providers): connect an agent directly or through ACP,
|
||||
render provider-owned timeline items, test it, and publish it.
|
||||
- [Plugin reference](/docs/plugins/v0.8/reference): every contribution type, its fields, the runtime
|
||||
modules, hosts, and the CLI.
|
||||
- [Migrate a plugin to runtime entries](/docs/plugins/v0.8/migration): move a plugin written against the
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
---
|
||||
title: Build a provider plugin
|
||||
description: Add a coding agent to Paseo directly or adapt an ACP agent.
|
||||
nav: Provider plugins
|
||||
order: 47
|
||||
category: Plugins
|
||||
---
|
||||
|
||||
# Build a provider plugin
|
||||
|
||||
> **For the upcoming Paseo v0.8 release.** Start with the
|
||||
> [plugin quickstart](/docs/plugins/v0.8) if you have not built a Paseo plugin before.
|
||||
|
||||
A provider plugin connects a coding agent to Paseo without adding it to Paseo core. Publish the
|
||||
plugin in a Git repository and users can install and update it with `paseo plugin add` and
|
||||
`paseo plugin update`.
|
||||
|
||||
Choose one implementation path:
|
||||
|
||||
| Agent | Path |
|
||||
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| It already implements ACP | Register it with `runAcpProvider()` and add only narrow vendor transformers. |
|
||||
| It has a TypeScript SDK, JSON-RPC API, or custom process protocol | Implement `ProviderRegistration` directly. |
|
||||
|
||||
The complete examples are:
|
||||
|
||||
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, settings, prompts, persistence, child sessions, and a provider-owned timeline renderer;
|
||||
- [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer): an ACP command with a Zod-validated vendor edit transformer;
|
||||
- [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking): a renderer-only plugin that does not implement a provider.
|
||||
|
||||
## Register a direct provider
|
||||
|
||||
Add a server entry:
|
||||
|
||||
```ts
|
||||
// index.server.ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { createProvider } from "./server/provider";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(createProvider());
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
`createProvider()` returns one `ProviderRegistration`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
negotiateProviderCapabilities,
|
||||
type ProviderRegistration,
|
||||
} from "@getpaseo/plugin/provider";
|
||||
|
||||
const supported = ["prompt.message"] as const;
|
||||
|
||||
export function createProvider(): ProviderRegistration {
|
||||
return {
|
||||
id: "my-agent",
|
||||
label: "My agent",
|
||||
icon: "icon.svg",
|
||||
async connect(request) {
|
||||
const capabilities = negotiateProviderCapabilities(request.capabilities, supported);
|
||||
return createConnection(capabilities);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The connection has three operations:
|
||||
|
||||
- `send(input)` accepts work and returns. Results are events, not return values.
|
||||
- `onEvent(listener)` publishes session state and complete timeline snapshots.
|
||||
- `close()` stops every session and releases processes, subscriptions, and pending work.
|
||||
|
||||
Keep the native SDK, process, and stream inside the connection implementation. Convert its output
|
||||
to `ProviderEvent` objects before publishing it.
|
||||
|
||||
## Return models, modes, and thinking options
|
||||
|
||||
Paseo requests the catalog before creating a session. Return the choices needed by the agent form:
|
||||
|
||||
```ts
|
||||
if (input.type === "catalog") {
|
||||
emit({
|
||||
type: "catalog",
|
||||
requestId: input.requestId,
|
||||
catalog: {
|
||||
models: [
|
||||
{ id: "agent-large", label: "Agent Large" },
|
||||
{ id: "agent-small", label: "Agent Small" },
|
||||
],
|
||||
modes: [
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "plan", label: "Plan" },
|
||||
],
|
||||
thinkingOptions: [
|
||||
{ id: "standard", label: "Standard" },
|
||||
{ id: "extended", label: "Extended" },
|
||||
],
|
||||
defaultModel: "agent-large",
|
||||
defaultMode: "build",
|
||||
defaultThinkingOption: "standard",
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Return an empty array for a category the agent does not expose. The selected `model`, `mode`, and
|
||||
`thinkingOption` arrive in the session configuration.
|
||||
|
||||
The catalog describes choices available before a session exists. Session-specific controls come
|
||||
later through `session.config`.
|
||||
|
||||
## Open a session
|
||||
|
||||
`session.open` contains the complete launch configuration: working directory, environment, system
|
||||
prompt, MCP servers, tool policy, model, mode, settings, opaque provider options, and persistence
|
||||
preference.
|
||||
|
||||
Create the native session with the selected catalog values, then publish its effective state:
|
||||
|
||||
```ts
|
||||
const nativeSession = await sdk.createSession({
|
||||
cwd: input.config.cwd,
|
||||
model: input.config.model,
|
||||
mode: input.config.mode,
|
||||
thinking: input.config.thinkingOption,
|
||||
systemPrompt: input.config.systemPrompt,
|
||||
env: input.config.env,
|
||||
mcpServers: input.config.mcpServers,
|
||||
});
|
||||
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: ["prompt.message"],
|
||||
restoration: "core",
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
emit({
|
||||
type: "session.config",
|
||||
sessionId: input.sessionId,
|
||||
config: {
|
||||
model: nativeSession.model,
|
||||
mode: nativeSession.mode,
|
||||
thinkingOption: nativeSession.thinking,
|
||||
models,
|
||||
modes,
|
||||
thinkingOptions,
|
||||
settings: [],
|
||||
},
|
||||
});
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
```
|
||||
|
||||
`session.config` contains the committed values. Publish what the native agent selected after
|
||||
normalization, not a copy of the request.
|
||||
|
||||
## Complete the first prompt
|
||||
|
||||
Start with message prompts. Advertise `prompt.message`, pass the content to the native agent, and
|
||||
publish the resulting lifecycle:
|
||||
|
||||
```ts
|
||||
const turnId = crypto.randomUUID();
|
||||
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId,
|
||||
item: {
|
||||
type: "user_message",
|
||||
id: `user:${turnId}`,
|
||||
text,
|
||||
clientMessageId: prompt.clientMessageId,
|
||||
},
|
||||
});
|
||||
emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId,
|
||||
clientMessageId: prompt.clientMessageId,
|
||||
result: { type: "turn", turnId },
|
||||
});
|
||||
emit({ type: "session.turn", sessionId, turnId, state: "started" });
|
||||
|
||||
// Publish complete snapshots as native output changes.
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId,
|
||||
item: { type: "assistant_message", id: `assistant:${turnId}`, text: completeText },
|
||||
});
|
||||
|
||||
emit({ type: "session.turn", sessionId, turnId, state: "completed" });
|
||||
```
|
||||
|
||||
Publish exactly one `session.prompt_result` for each `clientMessageId`. Copy `clientMessageId` onto
|
||||
the user timeline item so Paseo replaces its optimistic message. Every started turn needs one
|
||||
terminal `completed`, `failed`, or `canceled` event.
|
||||
|
||||
At this point the provider is usable: users can select a model, open a session, send a message, and
|
||||
see the response.
|
||||
|
||||
## Add session-specific composer controls
|
||||
|
||||
Add `session.configure` only when the native agent has settings users should change after opening a
|
||||
session. Publish them in `session.config` as toggle or select controls:
|
||||
|
||||
```ts
|
||||
emit({
|
||||
type: "session.config",
|
||||
sessionId,
|
||||
config: {
|
||||
model: "agent-large",
|
||||
models: [{ id: "agent-large", label: "Agent Large" }],
|
||||
modes: [],
|
||||
thinkingOptions: [],
|
||||
settings: [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "fast",
|
||||
label: "Fast mode",
|
||||
value: nativeSession.fast,
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
id: "approval",
|
||||
label: "Approvals",
|
||||
value: nativeSession.approval,
|
||||
options: [
|
||||
{ label: "Ask", value: "ask" },
|
||||
{ label: "Auto accept", value: "auto" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Paseo renders these controls in the composer. A user change arrives as `session.configure`; apply it
|
||||
and publish the committed `session.config` before `request.completed`. The provider may change the
|
||||
available controls at runtime, including after a model change.
|
||||
|
||||
`settings` are user-facing controls. `providerOptions` is opaque JSON supplied at session creation
|
||||
for provider-specific configuration that Paseo does not render. A provider-specific token budget
|
||||
belongs in one of those two places; it is not a dedicated prompt field.
|
||||
|
||||
## Add persistence and replay
|
||||
|
||||
Advertise `session.persistence` when a native session can be reopened. Open a new native session
|
||||
when `session.open.persistence` is absent. Resume the identified native session when it is present.
|
||||
Return an opaque persistence value in `session.opened` or `session.persistence`; Paseo stores it
|
||||
without inspecting it.
|
||||
|
||||
When `history` is `"replay"`, publish the native session's existing `timeline.item` snapshots before
|
||||
`session.ready`. Use `history: "skip"` to open without replaying old rows.
|
||||
|
||||
Paseo refreshes an agent by closing its current provider session and opening it again with current
|
||||
configuration and persistence. Re-read credentials, environment, global configuration, and MCP
|
||||
servers during `session.open`. There is no separate reload operation.
|
||||
|
||||
Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child
|
||||
uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and
|
||||
recreate it while restoring the parent.
|
||||
|
||||
## Add commands and steering
|
||||
|
||||
All user input arrives through `session.prompt`:
|
||||
|
||||
- `input.type: "message"` carries text, images, and attachments;
|
||||
- `input.type: "command"` carries a selected command and its arguments;
|
||||
- `delivery: "steer"` asks you to add input to the active turn;
|
||||
- `delivery: "auto"` lets you start a turn or complete a command without one.
|
||||
|
||||
Publish `session.commands` to add structured commands to the composer. A command can start a normal
|
||||
turn or finish as a side effect with a `completed` prompt result.
|
||||
|
||||
Advertise only capabilities you implement. If steering is unsupported, omit `prompt.steer`; Paseo
|
||||
can replace the active turn instead.
|
||||
|
||||
## Publish timeline items
|
||||
|
||||
Publish complete snapshots through `timeline.item`. Reuse an item `id` when updating streamed text,
|
||||
a running tool, or a todo list. Paseo derives the live delta and keeps the normal timeline.
|
||||
|
||||
Built-in item types cover user and assistant messages, reasoning, tools, todos, errors,
|
||||
notifications, and compaction. Use a plugin item when your provider has a presentation that those
|
||||
types cannot express:
|
||||
|
||||
```ts
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId,
|
||||
item: {
|
||||
type: "plugin",
|
||||
id: "review-42",
|
||||
pluginId: "my-provider-plugin",
|
||||
kind: "review-verdict",
|
||||
version: 1,
|
||||
data: { verdict: "ship", summary: "All checks passed" },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Register the renderer independently in `index.client.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
import { ReviewVerdict } from "./client/review-verdict";
|
||||
|
||||
const reviewVerdictSchema = z.object({
|
||||
verdict: z.enum(["ship", "hold"]),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addTimelineRenderer({
|
||||
kind: "review-verdict",
|
||||
version: 1,
|
||||
schema: reviewVerdictSchema,
|
||||
Component: ReviewVerdict,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
The same renderer can display items emitted by a provider, a client timeline transformer, or a
|
||||
daemon timeline append. A renderer does not require a provider implementation.
|
||||
|
||||
## Adapt an ACP agent
|
||||
|
||||
Use the ACP shim when the agent already speaks ACP:
|
||||
|
||||
```ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { runAcpProvider } from "@getpaseo/plugin/acp";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(
|
||||
runAcpProvider({
|
||||
id: "vendor-agent",
|
||||
label: "Vendor agent",
|
||||
icon: "icon.svg",
|
||||
command: ["vendor-agent", "acp"],
|
||||
}),
|
||||
);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
The shim owns the ACP process, capability mapping, session lifecycle, prompts, permissions, and
|
||||
timeline conversion. Do not copy that machinery into the plugin.
|
||||
|
||||
Use `transformers` only for vendor differences ACP cannot describe. Validate vendor payloads with
|
||||
Zod and leave malformed or unrelated values unchanged:
|
||||
|
||||
```ts
|
||||
import type { AcpTransformer } from "@getpaseo/plugin/acp";
|
||||
import { z } from "zod";
|
||||
|
||||
const editSchema = z.object({
|
||||
path: z.string(),
|
||||
before: z.string(),
|
||||
after: z.string(),
|
||||
});
|
||||
|
||||
export const vendorEdits: AcpTransformer = {
|
||||
toolCall(toolCall) {
|
||||
if (toolCall.name !== "vendor_file_edit") return toolCall;
|
||||
const input = editSchema.safeParse(toolCall.input);
|
||||
if (!input.success) return toolCall;
|
||||
return {
|
||||
...toolCall,
|
||||
kind: "edit",
|
||||
input: {
|
||||
filePath: input.data.path,
|
||||
oldString: input.data.before,
|
||||
newString: input.data.after,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Test and publish
|
||||
|
||||
Test the provider against the real agent, not only mocked frames:
|
||||
|
||||
1. install the plugin as a directory source;
|
||||
2. confirm its SVG, models, modes, and settings appear;
|
||||
3. create a session and complete a real prompt;
|
||||
4. exercise permissions, steering, cancellation, persistence, and replay when supported;
|
||||
5. render every custom timeline item on desktop and mobile-width layouts;
|
||||
6. reload and remove the plugin while a session is active and confirm the session terminates.
|
||||
|
||||
Push the plugin to a Git repository. Users install it with:
|
||||
|
||||
```bash
|
||||
paseo plugin add owner/repository
|
||||
paseo plugin update my-provider-plugin
|
||||
```
|
||||
|
||||
Keep vendor compatibility and releases in that repository. Paseo core should only change when the
|
||||
provider boundary cannot express a user-facing capability shared by more than one provider.
|
||||
|
||||
See the [plugin reference](/docs/plugins/v0.8/reference#providers) for the exact runtime and SVG
|
||||
rules.
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Plugin reference
|
||||
description: Local plugin files, client and server runtimes, platform limits, contributions, RPCs, lifecycle, hosts, and CLI commands.
|
||||
nav: Reference
|
||||
order: 47
|
||||
order: 48
|
||||
category: Plugins
|
||||
---
|
||||
|
||||
@@ -142,7 +142,42 @@ Center callbacks can only open surfaces and panels registered by the same plugin
|
||||
|
||||
### Server runtime
|
||||
|
||||
Paseo provides `@getpaseo/plugin`, `@getpaseo/plugin/server`, and `zod` to server code. Backend contributions run in a daemon subprocess with Node access to the host machine. Keep filesystem, process, credential, and other machine-local work under `server/`. A plugin without `index.server.ts` starts no subprocess.
|
||||
Paseo provides `@getpaseo/plugin`, `@getpaseo/plugin/server`,
|
||||
`@getpaseo/plugin/provider`, `@getpaseo/plugin/acp`, and `zod` to server code. Backend
|
||||
contributions run in a daemon subprocess with Node access to the host machine. Keep filesystem,
|
||||
process, credential, and other machine-local work under `server/`. A plugin without
|
||||
`index.server.ts` starts no subprocess.
|
||||
|
||||
### Providers
|
||||
|
||||
Follow [Build a provider plugin](/docs/plugins/v0.8/providers) for direct and ACP implementations,
|
||||
session lifecycle, composer settings, timeline renderers, testing, and distribution.
|
||||
|
||||
Call `server.registerProvider()` with a `ProviderRegistration` from
|
||||
`@getpaseo/plugin/provider`. Its connection accepts inputs with `send()` and emits complete state
|
||||
snapshots through `onEvent()`. `send()` reports acceptance only; prompt disposition, turns,
|
||||
configuration, persistence, permissions, and failures are events.
|
||||
|
||||
Use the single `session.prompt` input for messages, structured commands, steering, and command side
|
||||
effects. Repeat `clientMessageId` on the live user timeline item and publish exactly one matching
|
||||
`session.prompt_result`. Publish provider-created children as sessions with `parentSessionId`.
|
||||
|
||||
Provider settings are toggle/select descriptors that Paseo renders in the composer. Keep
|
||||
provider-private JSON under `providerOptions`. Host tools arrive as MCP servers in the complete
|
||||
session config.
|
||||
|
||||
Paseo refreshes an agent by closing its current provider session and opening it with current
|
||||
configuration and persistence. Providers re-read external state during `session.open`.
|
||||
|
||||
Use `runAcpProvider()` from `@getpaseo/plugin/acp` to adapt a command-backed ACP. Add transformer
|
||||
hooks only for a vendor's discovery, configuration, notification, or tool-call differences.
|
||||
|
||||
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
|
||||
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
|
||||
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
|
||||
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
|
||||
## Entry point and cleanup
|
||||
|
||||
|
||||
Reference in New Issue
Block a user