mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 14:43:01 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5fa8a5d83 | ||
|
|
ca178b6578 | ||
|
|
b965a3db7c | ||
|
|
c1f64d45e1 | ||
|
|
aa558ab845 | ||
|
|
a2b76f77f3 | ||
|
|
ad88797f8d | ||
|
|
d92b16afbf | ||
|
|
abd4d2c1ea | ||
|
|
77f90d3460 | ||
|
|
7a651fceb3 | ||
|
|
4095d3784d | ||
|
|
21365c571b | ||
|
|
f4dea7413e | ||
|
|
6bf7a47a96 | ||
|
|
d0a5d4d970 | ||
|
|
377b06be0d | ||
|
|
25a3efe0ac | ||
|
|
5b4636b856 | ||
|
|
00ec4bc80c | ||
|
|
970c281daf | ||
|
|
389db81784 |
@@ -92,6 +92,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `agent.metadata.update.request`; `null` in a patch removes a key |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
@@ -478,6 +479,7 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
||||
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
||||
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `workspace.metadata.update.request`. |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
|
||||
|
||||
|
||||
+40
-3
@@ -511,13 +511,21 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
|
||||
`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 `ProviderSessionConfig.providerOptions` object on `session.open`.
|
||||
It contains the provider defaults and per-agent overrides merged by the daemon.
|
||||
A provider's configuration is the opaque `ProviderSessionConfig.providerOptions` object on
|
||||
`session.open`. It contains the provider defaults and per-agent overrides merged by the daemon.
|
||||
Validate and apply it inside the provider; core does not know your option shape.
|
||||
See [provider options](custom-providers.md#provider-options) for configuration and
|
||||
merge semantics.
|
||||
|
||||
The provider draws some of those keys in the composer by reporting `controls` and the values it runs
|
||||
(`providerOptions`) in `session.config`. A running-agent change arrives as
|
||||
`session.configure` with `changes.providerOptions` and follows the same apply-or-throw contract as
|
||||
built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in
|
||||
`packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting
|
||||
`settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and
|
||||
its value into an option and is sent both shapes in `session.configure`; delete it with
|
||||
`COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`.
|
||||
|
||||
Providers add an optional veto to daemon baseline cleanup through the per-session
|
||||
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
|
||||
@@ -636,6 +644,35 @@ Mounted surfaces and command invocations have shorter API lifetimes.
|
||||
Keep the client entry synchronous: return its cleanup function immediately and start asynchronous
|
||||
work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx).
|
||||
|
||||
## Contribute composer items
|
||||
|
||||
`addComposerItem` mounts a plugin component in the agent, draft and New workspace composers. The
|
||||
public contract is the [composer items reference](../public-docs/plugins/reference.md#composer-items).
|
||||
|
||||
The composer is two contexts, provided by two owners (`packages/app/src/composer/session.tsx`).
|
||||
`Composer` provides `ComposerInput` (prompt and attachments). `AgentControls` and
|
||||
`DraftAgentControls` provide `ComposerAgent` (provider, model, mode, thinking, options, metadata and
|
||||
their commands). `toPluginComposer` (`packages/app/src/plugins/composer-items/`) joins them into the
|
||||
`PluginComposer` an item reads, so a plugin cannot tell which composer a command reaches. Add a
|
||||
capability to `ComposerAgent` and both implementations; never branch on `context` in the model.
|
||||
|
||||
Plugin `Toggle`, `Select` and `Action`, the thinking select and provider option controls all
|
||||
render through `ComposerControl` (`packages/app/src/composer/agent-controls/composer-control.tsx`),
|
||||
a toolbar icon or a row in the overflow sheet. Model and mode stay specialized but share its trigger,
|
||||
density and overflow. Plugin items count in the density presence in `layout.ts`, so a composer
|
||||
condenses to make room for them. A custom component renders wherever its item is placed, and an
|
||||
item that renders nothing still counts for density; there is no registration for either.
|
||||
|
||||
A command runs against the owner's current state, not the render that built the callback. The
|
||||
draft's form (`openAgentForm`) and options (`openDraftOptions`) are plain stores for that reason,
|
||||
and `draftComposerAgent` rejects a provider the host does not offer or a model its loaded catalog
|
||||
does not list. `ComposerControl` owns the pending state and the error toast for callbacks that
|
||||
return a promise.
|
||||
|
||||
The overflow sheet's content portals to the app root, away from the composer. Pass
|
||||
`useComposerSheetBridge()` as the sheet's `contextBridge`, or `useComposer()` returns nothing and
|
||||
the sheet draws no plugin rows.
|
||||
|
||||
## Contribute timeline items
|
||||
|
||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withComposerControlsExample } from "../support/helpers/plugin-composer-items";
|
||||
|
||||
test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerControlsExample(page, async (composer) => {
|
||||
await composer.narrowTheWindow();
|
||||
await composer.openAgentFeatures();
|
||||
await composer.expectFeatureRow("Needs review");
|
||||
await composer.expectFeatureRow("Insert checklist");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
|
||||
const PLUGIN_ID = "composer-controls-example";
|
||||
const NARROW = { width: 500, height: 800 };
|
||||
|
||||
/** Runs `run` in an agent's composer with the composer-controls example installed. */
|
||||
export async function withComposerControlsExample(
|
||||
page: Page,
|
||||
run: (composer: {
|
||||
narrowTheWindow(): Promise<void>;
|
||||
openAgentFeatures(): Promise<void>;
|
||||
expectFeatureRow(name: string): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "composer-items-",
|
||||
title: "Composer items",
|
||||
});
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Composer items agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("composer-controls");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await gotoAppShell(page);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await run({
|
||||
narrowTheWindow: () =>
|
||||
test.step("narrow the window until the composer folds its controls away", () =>
|
||||
page.setViewportSize(NARROW)),
|
||||
openAgentFeatures: () =>
|
||||
test.step("open the agent features sheet", async () => {
|
||||
await page.getByRole("button", { name: "Open agent features", exact: true }).click();
|
||||
}),
|
||||
expectFeatureRow: (name) =>
|
||||
test.step(`the sheet has a row for "${name}"`, () =>
|
||||
expect(page.getByRole("button", { name, exact: true })).toBeVisible()),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import type {
|
||||
* - `remembered`: the provider's last-used control values.
|
||||
* - `edits`: toggles made in this draft.
|
||||
*
|
||||
* An edit of `null` removes the key from every layer, the way a patch does on a running agent, so
|
||||
* the provider's default applies again.
|
||||
*
|
||||
* Choosing a provider by hand starts the options over; applying a profile replaces the provider
|
||||
* and the options in one step, so the provider change that follows does not clear them.
|
||||
*/
|
||||
@@ -47,6 +50,11 @@ export interface DraftOptionsModel {
|
||||
export function openDraftOptions(input: {
|
||||
provider: string | null;
|
||||
seed?: ProviderOptions;
|
||||
/**
|
||||
* The provider the draft's form has now. An edit belongs to it even before the screen has
|
||||
* rendered the change, so `setOption` observes it first.
|
||||
*/
|
||||
currentProvider?: () => string | null;
|
||||
}): DraftOptionsModel {
|
||||
let state: DraftOptionsState = {
|
||||
provider: input.provider,
|
||||
@@ -64,6 +72,15 @@ export function openDraftOptions(input: {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function observeProvider(provider: string | null): void {
|
||||
publish((current) => {
|
||||
if (provider === current.provider) return current;
|
||||
// The first provider a draft picks does not discard what it was opened with.
|
||||
if (current.provider === null) return { ...current, provider };
|
||||
return { ...current, provider, seed: {}, edits: {}, resolved: null };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: (listener) => {
|
||||
@@ -72,13 +89,7 @@ export function openDraftOptions(input: {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
observeProvider: (provider) =>
|
||||
publish((current) => {
|
||||
if (provider === current.provider) return current;
|
||||
// The first provider a draft picks does not discard what it was opened with.
|
||||
if (current.provider === null) return { ...current, provider };
|
||||
return { ...current, provider, seed: {}, edits: {}, resolved: null };
|
||||
}),
|
||||
observeProvider,
|
||||
applyProfile: (profile) =>
|
||||
publish((current) => ({
|
||||
...current,
|
||||
@@ -88,12 +99,14 @@ export function openDraftOptions(input: {
|
||||
resolved: current.provider === profile.provider ? current.resolved : null,
|
||||
})),
|
||||
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
|
||||
setOption: (key, value) =>
|
||||
setOption: (key, value) => {
|
||||
if (input.currentProvider) observeProvider(input.currentProvider());
|
||||
publish((current) =>
|
||||
Object.is(current.edits[key], value)
|
||||
? current
|
||||
: { ...current, edits: { ...current.edits, [key]: value } },
|
||||
),
|
||||
);
|
||||
},
|
||||
applyResolved: (resolved) => publish((current) => ({ ...current, resolved })),
|
||||
};
|
||||
}
|
||||
@@ -109,10 +122,14 @@ export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | und
|
||||
([key]) => controlKeys.has(key) && !(key in state.seed),
|
||||
),
|
||||
);
|
||||
const own = { ...state.seed, ...remembered, ...state.edits };
|
||||
const own = withoutRemovals({ ...state.seed, ...remembered, ...state.edits });
|
||||
return Object.keys(own).length > 0 ? own : undefined;
|
||||
}
|
||||
|
||||
function withoutRemovals(options: ProviderOptions): ProviderOptions {
|
||||
return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== null));
|
||||
}
|
||||
|
||||
export type DraftControlsRequest = Pick<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
|
||||
@@ -163,6 +180,20 @@ export function viewDraftOptions(
|
||||
options:
|
||||
resolved.requestKey === current.requestKey
|
||||
? resolved.options
|
||||
: { ...resolved.options, ...state.edits },
|
||||
: withoutRemovals({ ...resolved.options, ...state.edits }),
|
||||
};
|
||||
}
|
||||
|
||||
const NO_OPTIONS: ProviderOptions = {};
|
||||
|
||||
/**
|
||||
* The options the draft will create with, for a plugin or a control to read: the daemon's answer
|
||||
* for this provider when there is one, otherwise the draft's own layers, so an edit shows before
|
||||
* the daemon has answered.
|
||||
*/
|
||||
export function shownDraftOptions(
|
||||
state: DraftOptionsState,
|
||||
current: { provider: string | null; requestKey: string },
|
||||
): ProviderOptions {
|
||||
return viewDraftOptions(state, current)?.options ?? ownDraftOptions(state) ?? NO_OPTIONS;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAgentFeatureIcon } from "./icons";
|
||||
|
||||
describe("getAgentFeatureIcon", () => {
|
||||
it("draws the Lucide icon a plugin provider names", () => {
|
||||
const icon = getAgentFeatureIcon("Mic");
|
||||
expect(icon).not.toBe(Settings2);
|
||||
expect(getAgentFeatureIcon("Mic")).toBe(icon);
|
||||
});
|
||||
|
||||
it("falls back to the generic icon for a missing or unknown name", () => {
|
||||
expect(getAgentFeatureIcon()).toBe(Settings2);
|
||||
expect(getAgentFeatureIcon("not-an-icon")).toBe(Settings2);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ShieldQuestionMark,
|
||||
Zap,
|
||||
} from "lucide-react-native";
|
||||
import { isLucideIconName, pluginIconComponent } from "@/plugins/icons";
|
||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
export interface AgentControlIconProps {
|
||||
@@ -60,6 +61,9 @@ export function getAgentModeOptionIcon(
|
||||
return icon ? MODE_ICONS[icon] : undefined;
|
||||
}
|
||||
|
||||
/** A built-in control names its icon in kebab case; a plugin provider names a Lucide icon. */
|
||||
export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
|
||||
return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
|
||||
if (!icon) return Settings2;
|
||||
if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon];
|
||||
return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import React from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { Scan } from "lucide-react-native";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { ToastApiProvider } from "@/contexts/toast-api-context";
|
||||
import { ComposerControls, type ComposerControlItem } from "./composer-control";
|
||||
import { useAgentControlItems } from "./control-items";
|
||||
import { resolveComposerControlPresentation } from "./layout";
|
||||
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
afterEach(cleanup);
|
||||
|
||||
const base = { id: "x", title: "Context", icon: Scan };
|
||||
|
||||
type RecordingToast = ToastApi & { error: ReturnType<typeof vi.fn> };
|
||||
|
||||
function createToast(): RecordingToast {
|
||||
return { error: vi.fn() } as unknown as RecordingToast;
|
||||
}
|
||||
|
||||
function ignoreOpenChange() {}
|
||||
|
||||
function Controls({
|
||||
items,
|
||||
surface,
|
||||
toast,
|
||||
}: {
|
||||
items: ComposerControlItem[];
|
||||
surface: "toolbar" | "sheet";
|
||||
toast: ToastApi;
|
||||
}) {
|
||||
return (
|
||||
<ToastApiProvider api={toast}>
|
||||
<ComposerControls
|
||||
items={items}
|
||||
surface={surface}
|
||||
openId={null}
|
||||
onOpenIdChange={ignoreOpenChange}
|
||||
/>
|
||||
</ToastApiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderControls(items: ComposerControlItem[], surface: "toolbar" | "sheet" = "toolbar") {
|
||||
const toast = createToast();
|
||||
render(<Controls items={items} surface={surface} toast={toast} />);
|
||||
return toast;
|
||||
}
|
||||
|
||||
describe("ComposerControls", () => {
|
||||
it("shows a select's chosen label, or only its icon when asked", () => {
|
||||
const select: ComposerControlItem = {
|
||||
...base,
|
||||
type: "select",
|
||||
value: "large",
|
||||
choices: [
|
||||
{ value: "small", label: "Small" },
|
||||
{ value: "large", label: "Large" },
|
||||
],
|
||||
onChange: () => undefined,
|
||||
};
|
||||
renderControls([select]);
|
||||
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("Large");
|
||||
});
|
||||
|
||||
it("names the choice on an icon-only select, without drawing it", () => {
|
||||
renderControls([
|
||||
{
|
||||
...base,
|
||||
type: "select",
|
||||
value: "large",
|
||||
iconOnly: true,
|
||||
choices: [{ value: "large", label: "Large" }],
|
||||
onChange: () => undefined,
|
||||
},
|
||||
]);
|
||||
const trigger = screen.getByRole("button", { name: "Context: Large" });
|
||||
expect(trigger.textContent).not.toContain("Large");
|
||||
});
|
||||
|
||||
it("flips a toggle on press in the toolbar", () => {
|
||||
const onChange = vi.fn();
|
||||
renderControls([{ ...base, type: "toggle", value: true, onChange }]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Context" }));
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("lists a toggle as On or Off in the sheet", () => {
|
||||
renderControls([{ ...base, type: "toggle", value: true, onChange: () => undefined }], "sheet");
|
||||
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("On");
|
||||
});
|
||||
|
||||
it("reports a failed command the way a built-in control does, and frees the control", async () => {
|
||||
let reject: (error: Error) => void = () => undefined;
|
||||
const onPress = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, rejectPromise) => {
|
||||
reject = rejectPromise;
|
||||
}),
|
||||
);
|
||||
const toast = renderControls([{ ...base, type: "action", onPress }]);
|
||||
const button = screen.getByRole("button", { name: "Context" });
|
||||
|
||||
fireEvent.click(button);
|
||||
// While the command is in flight the control cannot be pressed again.
|
||||
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
|
||||
fireEvent.click(button);
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
|
||||
reject(new Error("Unknown or unavailable provider: x"));
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Unknown or unavailable provider: x"),
|
||||
);
|
||||
await waitFor(() => expect(button.getAttribute("aria-disabled")).not.toBe("true"));
|
||||
});
|
||||
|
||||
it("reports a command that throws before it returns", () => {
|
||||
const toast = renderControls([
|
||||
{
|
||||
...base,
|
||||
type: "action",
|
||||
onPress: () => {
|
||||
throw new Error("No such control");
|
||||
},
|
||||
},
|
||||
]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Context" }));
|
||||
expect(toast.error).toHaveBeenCalledWith("No such control");
|
||||
});
|
||||
});
|
||||
|
||||
const FAST_ON = { fast_mode: true };
|
||||
|
||||
describe("provider option controls", () => {
|
||||
const fastMode: AgentOptionControl = {
|
||||
type: "toggle",
|
||||
key: "fast_mode",
|
||||
label: "Fast",
|
||||
defaultValue: false,
|
||||
};
|
||||
function OptionControls({
|
||||
options,
|
||||
onSetOption,
|
||||
}: {
|
||||
options: Record<string, unknown>;
|
||||
onSetOption: (key: string, value: unknown) => void;
|
||||
}) {
|
||||
const optionControls = React.useMemo(
|
||||
() => ({ controls: [fastMode], options, onSetOption }),
|
||||
[onSetOption, options],
|
||||
);
|
||||
const items = useAgentControlItems({
|
||||
surface: "toolbar",
|
||||
presentation: resolveComposerControlPresentation("full"),
|
||||
thinking: null,
|
||||
optionControls,
|
||||
});
|
||||
return <Controls items={items.features} surface="toolbar" toast={TOAST} />;
|
||||
}
|
||||
const TOAST = createToast();
|
||||
|
||||
function recordWrites(writes: Array<[string, unknown]>) {
|
||||
return (key: string, value: unknown) => void writes.push([key, value]);
|
||||
}
|
||||
|
||||
it("write the toggled option from the agent's current options", () => {
|
||||
const writes: Array<[string, unknown]> = [];
|
||||
const onSetOption = recordWrites(writes);
|
||||
render(<OptionControls options={FAST_ON} onSetOption={onSetOption} />);
|
||||
fireEvent.click(screen.getByTestId("agent-feature-fast_mode"));
|
||||
expect(writes).toEqual([["fast_mode", false]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentControlIcon } from "@/agent-controls/icons";
|
||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { AgentControlTrigger } from "@/composer/agent-controls/control";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
interface ComposerControlBase {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: AgentControlIcon;
|
||||
/** Tints the icon; muted when absent. */
|
||||
highlight?: "blue" | "green" | "yellow";
|
||||
/** Defaults to the title, or to "title: choice" on an icon-only select. */
|
||||
tooltip?: string;
|
||||
/** Defaults to the tooltip. */
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export interface ComposerControlChoice {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One control in the composer's toolbar, drawn from data. Provider option controls, thinking and
|
||||
* plugin controls all come through here, so each is an icon in the toolbar and a row in the sheet.
|
||||
*/
|
||||
export type ComposerControlItem =
|
||||
| (ComposerControlBase & {
|
||||
type: "toggle";
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void | Promise<void>;
|
||||
})
|
||||
| (ComposerControlBase & {
|
||||
type: "select";
|
||||
value: string | null;
|
||||
choices: readonly ComposerControlChoice[];
|
||||
onChange: (value: string) => void | Promise<void>;
|
||||
/** The toolbar trigger shows only the icon. */
|
||||
iconOnly?: boolean;
|
||||
showCaret?: boolean;
|
||||
searchable?: boolean;
|
||||
desktopMinWidth?: number;
|
||||
renderOption?: (args: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => ReactElement;
|
||||
})
|
||||
| (ComposerControlBase & {
|
||||
type: "action";
|
||||
onPress: () => void | Promise<void>;
|
||||
});
|
||||
|
||||
export type ComposerControlSurface = "toolbar" | "sheet";
|
||||
|
||||
const ComposerControlSurfaceContext = createContext<ComposerControlSurface>("toolbar");
|
||||
export const ComposerControlSurfaceProvider = ComposerControlSurfaceContext.Provider;
|
||||
|
||||
/** Where the control being drawn sits: the toolbar, or a row in the overflow sheet. */
|
||||
export function useComposerControlSurface(): ComposerControlSurface {
|
||||
return useContext(ComposerControlSurfaceContext);
|
||||
}
|
||||
|
||||
interface ComposerControlProps {
|
||||
item: ComposerControlItem;
|
||||
surface: ComposerControlSurface;
|
||||
disabled?: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Runs after a toolbar toggle or action, so the composer can take focus back. */
|
||||
onActionComplete?: () => void;
|
||||
}
|
||||
|
||||
interface ControlView {
|
||||
options: ComboboxOption[];
|
||||
/** The list's selected id. */
|
||||
selectedId: string;
|
||||
/** What the trigger shows beside the icon. */
|
||||
value: string;
|
||||
iconOnly: boolean;
|
||||
tooltip: string;
|
||||
/** Whether pressing the trigger opens a list instead of acting. */
|
||||
opensList: boolean;
|
||||
}
|
||||
|
||||
function describeControl(
|
||||
item: ComposerControlItem,
|
||||
surface: ComposerControlSurface,
|
||||
t: TFunction,
|
||||
): ControlView {
|
||||
const isSheet = surface === "sheet";
|
||||
if (item.type === "action") {
|
||||
return {
|
||||
options: [],
|
||||
selectedId: "",
|
||||
value: "",
|
||||
iconOnly: true,
|
||||
tooltip: item.tooltip ?? item.title,
|
||||
opensList: false,
|
||||
};
|
||||
}
|
||||
if (item.type === "toggle") {
|
||||
const label = item.value ? t("agentControls.features.on") : t("agentControls.features.off");
|
||||
return {
|
||||
options: [
|
||||
{ id: "true", label: t("agentControls.features.on") },
|
||||
{ id: "false", label: t("agentControls.features.off") },
|
||||
],
|
||||
selectedId: String(item.value),
|
||||
value: label,
|
||||
iconOnly: true,
|
||||
tooltip: item.tooltip ?? item.title,
|
||||
opensList: isSheet,
|
||||
};
|
||||
}
|
||||
const choiceLabel =
|
||||
item.choices.find((choice) => choice.value === item.value)?.label ?? item.title;
|
||||
const iconOnly = item.iconOnly === true;
|
||||
return {
|
||||
options: item.choices.map((choice) => ({
|
||||
id: choice.value,
|
||||
label: choice.label,
|
||||
description: choice.description,
|
||||
})),
|
||||
selectedId: String(item.value ?? ""),
|
||||
value: choiceLabel,
|
||||
iconOnly,
|
||||
// An icon-only trigger names its value; a sheet row shows the value, so it names the action.
|
||||
tooltip: iconOnly && !isSheet ? `${item.title}: ${choiceLabel}` : (item.tooltip ?? item.title),
|
||||
opensList: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function ComposerControl({
|
||||
item,
|
||||
surface,
|
||||
disabled = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
onActionComplete,
|
||||
}: ComposerControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const isSheet = surface === "sheet";
|
||||
const sheetHeader = useMemo<SheetHeader>(() => ({ title: item.title }), [item.title]);
|
||||
const view = useMemo(() => describeControl(item, surface, t), [item, surface, t]);
|
||||
|
||||
// A command that returns a promise keeps the control pending until it settles, and a failure
|
||||
// reaches the user the way a built-in live control's does.
|
||||
const toast = useToast();
|
||||
const [pending, setPending] = useState(false);
|
||||
const run = useCallback(
|
||||
(invoke: () => void | Promise<void>) => {
|
||||
try {
|
||||
const result = invoke();
|
||||
if (!result) return;
|
||||
setPending(true);
|
||||
result
|
||||
.catch((error: unknown) => toast.error(toErrorMessage(error)))
|
||||
.finally(() => setPending(false));
|
||||
} catch (error) {
|
||||
toast.error(toErrorMessage(error));
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
if (item.type === "toggle") run(() => item.onChange(!item.value));
|
||||
else if (item.type === "action") run(item.onPress);
|
||||
onActionComplete?.();
|
||||
}, [item, onActionComplete, run]);
|
||||
const handleOpenPress = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (item.type === "select") run(() => item.onChange(value));
|
||||
else if (item.type === "toggle") run(() => item.onChange(value === "true"));
|
||||
},
|
||||
[item, run],
|
||||
);
|
||||
|
||||
const select = item.type === "select" ? item : null;
|
||||
const trigger = (
|
||||
<AgentControlTrigger
|
||||
ref={anchorRef}
|
||||
icon={item.icon}
|
||||
iconColor={item.highlight ? styles[item.highlight].color : undefined}
|
||||
surface={surface}
|
||||
label={item.title}
|
||||
value={view.value}
|
||||
showToolbarLabel={!view.iconOnly}
|
||||
showCaret={select?.showCaret === true && !isSheet}
|
||||
open={view.opensList && open}
|
||||
disabled={disabled || pending}
|
||||
onPress={view.opensList ? handleOpenPress : handlePress}
|
||||
accessibilityLabel={item.accessibilityLabel ?? view.tooltip}
|
||||
testID={item.testID}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSheet ? (
|
||||
trigger
|
||||
) : (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
{trigger}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{view.tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{view.opensList ? (
|
||||
<Combobox
|
||||
options={view.options}
|
||||
value={view.selectedId}
|
||||
onSelect={handleSelect}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
presentation={isSheet ? "push" : undefined}
|
||||
desktopPlacement={isSheet ? undefined : "top-start"}
|
||||
desktopMinWidth={select?.desktopMinWidth}
|
||||
searchable={select ? select.searchable : false}
|
||||
renderOption={select?.renderOption}
|
||||
header={isSheet ? sheetHeader : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ComposerControlsProps {
|
||||
items: readonly ComposerControlItem[];
|
||||
surface: ComposerControlSurface;
|
||||
disabled?: boolean;
|
||||
/** The id of the item whose list is open. */
|
||||
openId: string | null;
|
||||
onOpenIdChange: (id: string | null) => void;
|
||||
onActionComplete?: () => void;
|
||||
}
|
||||
|
||||
/** A row of Paseo-owned controls that share one open list at a time. */
|
||||
export function ComposerControls({
|
||||
items,
|
||||
onOpenIdChange,
|
||||
openId,
|
||||
...rest
|
||||
}: ComposerControlsProps) {
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<ComposerControlSlot
|
||||
key={item.id}
|
||||
item={item}
|
||||
open={openId === item.id}
|
||||
onOpenIdChange={onOpenIdChange}
|
||||
{...rest}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerControlSlot({
|
||||
item,
|
||||
open,
|
||||
onOpenIdChange,
|
||||
...rest
|
||||
}: Omit<ComposerControlsProps, "items" | "openId"> & { item: ComposerControlItem; open: boolean }) {
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => onOpenIdChange(next ? item.id : null),
|
||||
[item.id, onOpenIdChange],
|
||||
);
|
||||
return <ComposerControl item={item} open={open} onOpenChange={handleOpenChange} {...rest} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
blue: { color: theme.colors.palette.blue[400] },
|
||||
green: { color: theme.colors.palette.green[400] },
|
||||
yellow: { color: theme.colors.palette.yellow[400] },
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useMemo } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
|
||||
import {
|
||||
getControlTooltip,
|
||||
isControlActive,
|
||||
readSelectOption,
|
||||
readToggleOption,
|
||||
type ComposerOptionControls,
|
||||
} from "@/agent-controls/option-controls";
|
||||
import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import {
|
||||
type ComposerControlItem,
|
||||
type ComposerControlSurface,
|
||||
} from "@/composer/agent-controls/composer-control";
|
||||
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
|
||||
import { getAgentControlHintKey, getFeatureHighlightColor } from "@/composer/agent-controls/utils";
|
||||
|
||||
const SEARCH_THRESHOLD = 6;
|
||||
|
||||
function getFeatureHighlight(
|
||||
control: AgentOptionControl,
|
||||
options: ProviderOptions,
|
||||
): ComposerControlItem["highlight"] {
|
||||
if (!isControlActive(control, options)) return undefined;
|
||||
const color = getFeatureHighlightColor(control.key);
|
||||
return color === "default" ? undefined : color;
|
||||
}
|
||||
|
||||
function toFeatureItem(
|
||||
control: AgentOptionControl,
|
||||
optionControls: ComposerOptionControls,
|
||||
): ComposerControlItem {
|
||||
const { options, onSetOption } = optionControls;
|
||||
const base = {
|
||||
id: `feature-${control.key}`,
|
||||
title: control.label,
|
||||
icon: getAgentFeatureIcon(control.icon),
|
||||
highlight: getFeatureHighlight(control, options),
|
||||
testID: `agent-feature-${control.key}`,
|
||||
};
|
||||
if (control.type === "toggle") {
|
||||
return {
|
||||
...base,
|
||||
type: "toggle",
|
||||
tooltip: getControlTooltip(control),
|
||||
value: readToggleOption(control, options),
|
||||
onChange: (value) => onSetOption(control.key, value),
|
||||
};
|
||||
}
|
||||
const iconOnly = control.desktopTrigger === "icon";
|
||||
return {
|
||||
...base,
|
||||
type: "select",
|
||||
tooltip: getControlTooltip(control),
|
||||
value: readSelectOption(control, options),
|
||||
choices: control.choices,
|
||||
iconOnly,
|
||||
onChange: (value) => onSetOption(control.key, value),
|
||||
};
|
||||
}
|
||||
|
||||
interface ThinkingControl {
|
||||
options: readonly { id: string; label: string }[];
|
||||
selectedId: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
/** The select's displayed label, falling back like the compact model sheet. */
|
||||
displayLabel: string;
|
||||
}
|
||||
|
||||
/** The thinking select and the option controls, as the data `ComposerControl` draws. */
|
||||
export function useAgentControlItems({
|
||||
surface,
|
||||
presentation,
|
||||
thinking,
|
||||
optionControls,
|
||||
}: {
|
||||
surface: ComposerControlSurface;
|
||||
presentation: ComposerControlPresentation;
|
||||
thinking: ThinkingControl | null;
|
||||
optionControls: ComposerOptionControls | undefined;
|
||||
}): { thinking: ComposerControlItem[]; features: ComposerControlItem[] } {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const thinkingItem = useMemo(
|
||||
() => (thinking ? [toThinkingItem({ thinking, surface, presentation, t })] : []),
|
||||
[presentation, surface, t, thinking],
|
||||
);
|
||||
const features = useMemo(
|
||||
() =>
|
||||
optionControls
|
||||
? optionControls.controls.map((control) => toFeatureItem(control, optionControls))
|
||||
: [],
|
||||
[optionControls],
|
||||
);
|
||||
return { thinking: thinkingItem, features };
|
||||
}
|
||||
|
||||
function toThinkingItem({
|
||||
thinking,
|
||||
surface,
|
||||
presentation,
|
||||
t,
|
||||
}: {
|
||||
thinking: ThinkingControl;
|
||||
surface: ComposerControlSurface;
|
||||
presentation: ComposerControlPresentation;
|
||||
t: TFunction;
|
||||
}): ComposerControlItem {
|
||||
const isSheet = surface === "sheet";
|
||||
return {
|
||||
id: "thinking",
|
||||
type: "select",
|
||||
title: t("agentControls.thinking.title"),
|
||||
icon: ThinkingIcon,
|
||||
tooltip: t(getAgentControlHintKey("thinking")),
|
||||
accessibilityLabel: t("agentControls.thinking.selectWithValue", {
|
||||
value: thinking.displayLabel,
|
||||
}),
|
||||
testID: isSheet ? "agent-controls-thinking" : "agent-thinking-selector",
|
||||
value: thinking.options.some((option) => option.id === thinking.selectedId)
|
||||
? (thinking.selectedId ?? null)
|
||||
: (thinking.options[0]?.id ?? null),
|
||||
choices: thinking.options.map((option) => ({ value: option.id, label: option.label })),
|
||||
onChange: thinking.onSelect,
|
||||
iconOnly: !presentation.showThinkingLabel,
|
||||
showCaret: presentation.showCarets,
|
||||
searchable: !isSheet && thinking.options.length > SEARCH_THRESHOLD,
|
||||
desktopMinWidth: 200,
|
||||
renderOption: (args) => <ThinkingOption {...args} />,
|
||||
};
|
||||
}
|
||||
|
||||
function ThinkingOption({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const leadingSlot = useMemo(
|
||||
() => <ThinkingIcon size={16} color={styles.thinkingIcon.color} />,
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
thinkingIcon: { color: theme.colors.foreground },
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ describe("composer control layout", () => {
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" as const }],
|
||||
pluginItems: 0,
|
||||
fontScale: 1,
|
||||
};
|
||||
|
||||
@@ -95,6 +96,7 @@ describe("composer control layout", () => {
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }],
|
||||
pluginItems: 0,
|
||||
fontScale: 1,
|
||||
},
|
||||
}),
|
||||
@@ -107,6 +109,7 @@ describe("composer control layout", () => {
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
|
||||
pluginItems: 0,
|
||||
fontScale: 1,
|
||||
},
|
||||
}),
|
||||
@@ -119,6 +122,7 @@ describe("composer control layout", () => {
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }],
|
||||
pluginItems: 0,
|
||||
fontScale: 1.25,
|
||||
},
|
||||
}),
|
||||
@@ -133,6 +137,7 @@ describe("composer control layout", () => {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
pluginItems: 0,
|
||||
fontScale: 1,
|
||||
},
|
||||
};
|
||||
@@ -154,6 +159,34 @@ describe("composer control layout", () => {
|
||||
).toBe("condensed");
|
||||
});
|
||||
|
||||
it("condenses to make room for plugin items and keeps them in the overflow", () => {
|
||||
const base = {
|
||||
availableWidth: 430,
|
||||
currentDensity: "full" as const,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [],
|
||||
fontScale: 1,
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 0 } }),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 2 } }),
|
||||
).toBe("condensed");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
availableWidth: 250,
|
||||
controls: { ...base.controls, pluginItems: 2 },
|
||||
}),
|
||||
).toBe("tight");
|
||||
});
|
||||
|
||||
it("gives every toolbar control one shell and one platform glyph envelope", () => {
|
||||
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
|
||||
controlSize: 28,
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface ComposerControlPresence {
|
||||
hasThinking: boolean;
|
||||
hasMode: boolean;
|
||||
features: readonly ComposerFeatureControlPresence[];
|
||||
/** Plugin composer items. Each is budgeted like a select with a short label. */
|
||||
pluginItems: number;
|
||||
fontScale: number;
|
||||
}
|
||||
|
||||
@@ -26,6 +28,7 @@ export const COMPOSER_TOOLBAR_GEOMETRY = {
|
||||
} as const;
|
||||
|
||||
const DENSITY_HYSTERESIS = 12;
|
||||
const PLUGIN_ITEM_LABEL = "Plugin item";
|
||||
|
||||
function normalizedFontScale(fontScale: number): number {
|
||||
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
|
||||
@@ -62,7 +65,9 @@ function resolveCondensedFloor(controls: ComposerControlPresence): number {
|
||||
if (controls.hasModel) widths.push(36 + 60 * fontScale);
|
||||
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||
if (controls.hasMode) widths.push(36 + 96 * fontScale);
|
||||
if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||
if (controls.features.length > 0 || controls.pluginItems > 0) {
|
||||
widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||
}
|
||||
return sumControlWidths(widths);
|
||||
}
|
||||
|
||||
@@ -75,6 +80,11 @@ function resolveFullFloor(controls: ComposerControlPresence): number {
|
||||
for (const feature of controls.features) {
|
||||
widths.push(resolveFeatureControlWidth(feature, fontScale));
|
||||
}
|
||||
for (let index = 0; index < controls.pluginItems; index++) {
|
||||
widths.push(
|
||||
resolveFeatureControlWidth({ type: "select", label: PLUGIN_ITEM_LABEL }, fontScale),
|
||||
);
|
||||
}
|
||||
return sumControlWidths(widths);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,15 +25,19 @@ describe("resolveAgentControlsMode", () => {
|
||||
onSelectMode: () => undefined,
|
||||
models: [],
|
||||
selectedModel: "",
|
||||
onSelectModel: () => undefined,
|
||||
onSelectModel: () => true,
|
||||
isModelLoading: false,
|
||||
modelSelectorProviders: [],
|
||||
isAllModelsLoading: false,
|
||||
onSelectProviderAndModel: () => undefined,
|
||||
onSelectProviderAndModel: () => true,
|
||||
options: {},
|
||||
onSetOption: () => undefined,
|
||||
thinkingOptions: [],
|
||||
selectedThinkingOptionId: "",
|
||||
onSelectThinkingOption: () => undefined,
|
||||
onApplyAgentProfile: () => undefined,
|
||||
metadata: {},
|
||||
onSetMetadata: () => undefined,
|
||||
}),
|
||||
).toBe("draft");
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
|
||||
@@ -43,6 +44,8 @@ interface CompactModelSheetProps {
|
||||
serverId?: string | null;
|
||||
glyphSize: number;
|
||||
canSwitchProvider: boolean;
|
||||
/** Carries the composer's contexts into the sheets, which draw away from it. */
|
||||
contextBridge?: ContextBridge | null;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -95,6 +98,7 @@ export function CompactModelSheet({
|
||||
serverId = null,
|
||||
glyphSize,
|
||||
canSwitchProvider,
|
||||
contextBridge = null,
|
||||
children,
|
||||
}: CompactModelSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -299,6 +303,7 @@ export function CompactModelSheet({
|
||||
snapPoints={SNAP_POINTS}
|
||||
scrollable={false}
|
||||
contentStyle={styles.sheetBody}
|
||||
contextBridge={contextBridge}
|
||||
testID="agent-controls-model-sheet"
|
||||
>
|
||||
<View
|
||||
@@ -347,6 +352,7 @@ export function CompactModelSheet({
|
||||
scrollable={false}
|
||||
presentation="push"
|
||||
contentStyle={styles.sheetBody}
|
||||
contextBridge={contextBridge}
|
||||
testID="agent-controls-model-browser-sheet"
|
||||
>
|
||||
<View
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback } from "react";
|
||||
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { ComposerControlLayoutProvider, useComposerControlLayout } from "./layout-context";
|
||||
import {
|
||||
ComposerAgentProvider,
|
||||
ComposerInputProvider,
|
||||
useComposerAgent,
|
||||
useComposerInput,
|
||||
} from "@/composer/session";
|
||||
import { ToastApiProvider, useToast } from "@/contexts/toast-api-context";
|
||||
|
||||
/**
|
||||
* A phone's overflow draws in a sheet whose content lands at the app root, away from the
|
||||
* composer. This carries across what a control in it reads from the composer: the agent and input
|
||||
* a plugin's `useComposer()` sits on, the layout, and the toast and query client the controls use.
|
||||
* Call it from inside the composer, outside any sheet.
|
||||
*/
|
||||
export function useComposerSheetBridge(): ContextBridge {
|
||||
const layout = useComposerControlLayout();
|
||||
const agent = useComposerAgent();
|
||||
const input = useComposerInput();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback<ContextBridge>(
|
||||
(content) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastApiProvider api={toast}>
|
||||
<ComposerControlLayoutProvider value={layout}>
|
||||
<ComposerAgentProvider value={agent}>
|
||||
{input ? (
|
||||
<ComposerInputProvider value={input}>{content}</ComposerInputProvider>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</ComposerAgentProvider>
|
||||
</ComposerControlLayoutProvider>
|
||||
</ToastApiProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
[agent, input, layout, queryClient, toast],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import {
|
||||
draftControlsRequest,
|
||||
openDraftOptions,
|
||||
shownDraftOptions,
|
||||
} from "@/agent-controls/draft-options-model";
|
||||
import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store";
|
||||
import { draftComposerAgent } from "./composer-agent";
|
||||
|
||||
function definition(id: string, modeId: string): AgentProviderDefinition {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
description: id,
|
||||
defaultModeId: modeId,
|
||||
modes: [{ id: modeId, label: modeId, icon: "ShieldCheck", colorTier: "safe" }],
|
||||
};
|
||||
}
|
||||
|
||||
function model(provider: string, id: string, isDefault = false): AgentModelDefinition {
|
||||
return { provider, id, label: id, isDefault };
|
||||
}
|
||||
|
||||
/** The host: two providers with catalogs, and one that is ready but lists no models. */
|
||||
const catalog: AgentFormCatalog = {
|
||||
selectableProviders: new Map([
|
||||
["claude", definition("claude", "default")],
|
||||
["codex", definition("codex", "auto")],
|
||||
["opencode", definition("opencode", "build")],
|
||||
]),
|
||||
models: new Map([
|
||||
["claude", [model("claude", "sonnet", true)]],
|
||||
["codex", [model("codex", "gpt-5.4", true), model("codex", "gpt-5.5")]],
|
||||
["opencode", []],
|
||||
]),
|
||||
providerPreferences: () => undefined,
|
||||
};
|
||||
|
||||
/** A draft composer on the real form and options models. */
|
||||
function openDraft(context: "draft" | "new-workspace") {
|
||||
const form = openAgentForm(() => catalog);
|
||||
const options = openDraftOptions({
|
||||
provider: null,
|
||||
currentProvider: () => form.getState().form.provider,
|
||||
});
|
||||
// The screen hands the options the provider the form reaches.
|
||||
form.subscribe(() => options.observeProvider(form.getState().form.provider));
|
||||
form.setProviderAndModel("claude", "");
|
||||
|
||||
function agent() {
|
||||
const { form: current } = form.getState();
|
||||
return draftComposerAgent({
|
||||
context,
|
||||
selectedProvider: current.provider,
|
||||
selectedModel: current.model,
|
||||
selectedMode: current.modeId,
|
||||
thinking: current.thinkingOptionId || null,
|
||||
isLocked: () => false,
|
||||
options: shownDraftOptions(options.getState(), {
|
||||
provider: current.provider,
|
||||
requestKey: "",
|
||||
}),
|
||||
metadata: {},
|
||||
onSelectProviderAndModel: form.setProviderAndModel,
|
||||
onSelectModel: form.setModel,
|
||||
onSelectMode: () => undefined,
|
||||
onSelectThinkingOption: () => undefined,
|
||||
onSetOption: options.setOption,
|
||||
onSetMetadata: () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** What the daemon is asked to resolve for the agent this draft would create. */
|
||||
function request() {
|
||||
const { form: current } = form.getState();
|
||||
return draftControlsRequest(options.getState(), {
|
||||
provider: current.provider,
|
||||
cwd: "/repo",
|
||||
modeId: current.modeId,
|
||||
modelId: current.model,
|
||||
});
|
||||
}
|
||||
|
||||
return { form, agent, request };
|
||||
}
|
||||
|
||||
describe("a draft's options", () => {
|
||||
it("belong to the provider the form has now, before the screen has rendered the change", () => {
|
||||
const form = openAgentForm(() => catalog);
|
||||
const options = openDraftOptions({
|
||||
provider: null,
|
||||
currentProvider: () => form.getState().form.provider,
|
||||
});
|
||||
// No subscription: the screen observes the provider in an effect, after the command ran.
|
||||
form.setProviderAndModel("claude", "");
|
||||
options.observeProvider("claude");
|
||||
options.setOption("context", "1m");
|
||||
|
||||
form.setProviderAndModel("codex", "");
|
||||
options.setOption("effort", "high");
|
||||
|
||||
expect(options.getState()).toMatchObject({ provider: "codex", edits: { effort: "high" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["draft", "new-workspace"] as const)("the %s composer's agent", (context) => {
|
||||
it("applies each awaited command before the next one runs", async () => {
|
||||
const draft = openDraft(context);
|
||||
// A plugin keeps the object it was handed across its awaits.
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setProvider("codex");
|
||||
await retained.setModel("gpt-5.5");
|
||||
await retained.setOptions({ context: "1m" });
|
||||
|
||||
const agent = draft.agent();
|
||||
expect(agent).toMatchObject({
|
||||
context,
|
||||
provider: "codex",
|
||||
model: "gpt-5.5",
|
||||
options: { context: "1m" },
|
||||
});
|
||||
expect(draft.request()).toMatchObject({
|
||||
provider: "codex",
|
||||
model: "gpt-5.5",
|
||||
providerOptions: { context: "1m" },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts the options over when the provider is chosen by hand", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setOptions({ context: "1m" });
|
||||
expect(draft.agent().options).toEqual({ context: "1m" });
|
||||
await retained.setProvider("codex");
|
||||
|
||||
expect(draft.agent().options).toEqual({});
|
||||
});
|
||||
|
||||
it("creates with the provider's default again once a plugin resets an option", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setOptions({ context: "1m", effort: "high" });
|
||||
await retained.setOptions({ context: null });
|
||||
|
||||
expect(draft.agent().options).toEqual({ effort: "high" });
|
||||
expect(draft.request()?.providerOptions).toEqual({ effort: "high" });
|
||||
|
||||
await retained.setOptions({ effort: null });
|
||||
|
||||
expect(draft.agent().options).toEqual({});
|
||||
expect(draft.request()?.providerOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a provider the host cannot run and a model the catalog does not list", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await expect(retained.setProvider("nope")).rejects.toThrow(
|
||||
"Unknown or unavailable provider: nope",
|
||||
);
|
||||
expect(draft.agent().provider).toBe("claude");
|
||||
|
||||
await retained.setProvider("codex");
|
||||
await expect(retained.setModel("missing")).rejects.toThrow("Unknown model: missing");
|
||||
expect(draft.agent()).toMatchObject({ provider: "codex", model: "gpt-5.4" });
|
||||
});
|
||||
|
||||
it("keeps a model id as given when the provider lists no catalog", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setProvider("opencode");
|
||||
await retained.setModel("anthropic/claude-x");
|
||||
|
||||
expect(draft.agent().model).toBe("anthropic/claude-x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ComposerAgent } from "@/composer/session";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
|
||||
/**
|
||||
* The agent a draft composer creates, as a plugin sees it. Its commands go to the form and the
|
||||
* options model, which apply them as they run, so a plugin can `await` one and issue the next.
|
||||
*/
|
||||
export function draftComposerAgent(
|
||||
props: Pick<
|
||||
DraftAgentControlsProps,
|
||||
| "context"
|
||||
| "disabled"
|
||||
| "selectedProvider"
|
||||
| "selectedModel"
|
||||
| "selectedMode"
|
||||
| "options"
|
||||
| "metadata"
|
||||
| "onSelectProviderAndModel"
|
||||
| "onSelectModel"
|
||||
| "onSelectMode"
|
||||
| "onSelectThinkingOption"
|
||||
| "onSetOption"
|
||||
| "onSetMetadata"
|
||||
> & { thinking: string | null; isLocked: () => boolean },
|
||||
): ComposerAgent {
|
||||
return {
|
||||
context: props.context ?? "draft",
|
||||
locked: props.disabled ?? false,
|
||||
isLocked: props.isLocked,
|
||||
provider: props.selectedProvider,
|
||||
model: props.selectedModel || null,
|
||||
mode: props.selectedMode || null,
|
||||
thinking: props.thinking,
|
||||
options: props.options,
|
||||
metadata: props.metadata,
|
||||
// An empty model id picks the provider's default model.
|
||||
setProvider: async (provider) => {
|
||||
if (!props.onSelectProviderAndModel(provider, "")) {
|
||||
throw new Error(`Unknown or unavailable provider: ${provider}`);
|
||||
}
|
||||
},
|
||||
setModel: async (modelId) => {
|
||||
if (!props.onSelectModel(modelId)) throw new Error(`Unknown model: ${modelId}`);
|
||||
},
|
||||
setMode: async (modeId) => props.onSelectMode(modeId),
|
||||
setThinking: async (thinkingOptionId) => props.onSelectThinkingOption(thinkingOptionId),
|
||||
setOptions: async (patch) => {
|
||||
for (const [key, value] of Object.entries(patch)) props.onSetOption(key, value);
|
||||
},
|
||||
setMetadata: async (patch) => props.onSetMetadata(patch),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||
@@ -21,10 +22,25 @@ export function resolveDraftKey(input: {
|
||||
export function buildDraftAgentControls(input: {
|
||||
formState: UseAgentFormStateResult;
|
||||
optionControls?: DraftAgentControlsProps["optionControls"];
|
||||
options: DraftAgentControlsProps["options"];
|
||||
onSetOption: DraftAgentControlsProps["onSetOption"];
|
||||
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
|
||||
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
|
||||
context: NonNullable<DraftAgentControlsProps["context"]>;
|
||||
metadata: Record<string, JsonValue>;
|
||||
onSetMetadata: (patch: Record<string, JsonValue>) => void;
|
||||
}): DraftAgentControlsProps {
|
||||
const { formState, optionControls, onApplyAgentProfile, onDropdownClose } = input;
|
||||
const {
|
||||
formState,
|
||||
optionControls,
|
||||
options,
|
||||
onSetOption,
|
||||
onApplyAgentProfile,
|
||||
onDropdownClose,
|
||||
context,
|
||||
metadata,
|
||||
onSetMetadata,
|
||||
} = input;
|
||||
return {
|
||||
providerDefinitions: formState.providerDefinitions,
|
||||
selectedProvider: formState.selectedProvider,
|
||||
@@ -43,11 +59,16 @@ export function buildDraftAgentControls(input: {
|
||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||
onApplyAgentProfile,
|
||||
optionControls,
|
||||
options,
|
||||
onSetOption,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
||||
onRetryModelProvider: formState.refreshProviderModels,
|
||||
isRetryingModelProvider: formState.isProviderModelsRefreshing,
|
||||
modelSelectorServerId: formState.selectedServerId,
|
||||
context,
|
||||
metadata,
|
||||
onSetMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { TextReplacement } from "@/composer/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
@@ -28,6 +29,8 @@ import { useShallow } from "zustand/shallow";
|
||||
import type { ComposerTextSource } from "@/composer/text-source";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const NO_METADATA: Record<string, JsonValue> = {};
|
||||
|
||||
type AttachmentUpdater =
|
||||
| UserComposerAttachment[]
|
||||
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
||||
@@ -36,6 +39,8 @@ interface AgentInputDraftComposerOptions {
|
||||
initialServerId: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
initialProviderOptions?: Record<string, unknown>;
|
||||
/** Which composer a plugin's `useComposer()` reports. */
|
||||
context?: "draft" | "new-workspace";
|
||||
isVisible?: boolean;
|
||||
lockedWorkingDir?: string;
|
||||
}
|
||||
@@ -51,6 +56,8 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
providerOptions: Record<string, unknown> | undefined;
|
||||
/** What plugins set on the agent this draft creates. */
|
||||
metadata: Record<string, JsonValue>;
|
||||
agentControls: DraftAgentControlsProps;
|
||||
commandDraft: DraftCommandTarget;
|
||||
};
|
||||
@@ -191,10 +198,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[saveDraft],
|
||||
);
|
||||
|
||||
const metadata = useDraftStore((state) => state.metadataByDraftKey[draftKey] ?? NO_METADATA);
|
||||
const setMetadata = useCallback(
|
||||
(patch: Record<string, JsonValue>) =>
|
||||
useDraftStore.getState().patchDraftMetadata({ draftKey, patch }),
|
||||
[draftKey],
|
||||
);
|
||||
|
||||
const clear = useCallback(
|
||||
(lifecycle: "sent" | "abandoned") => {
|
||||
textPublication.cancel();
|
||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
|
||||
if (lifecycle === "sent") useDraftStore.getState().clearDraftMetadata({ draftKey });
|
||||
},
|
||||
[draftKey, textPublication],
|
||||
);
|
||||
@@ -276,10 +291,13 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
const {
|
||||
optionControls: draftOptionControls,
|
||||
options: draftOptions,
|
||||
shownOptions: draftShownOptions,
|
||||
setOption: setDraftOption,
|
||||
applyProfile: applyProfileOptions,
|
||||
} = useDraftProviderOptions({
|
||||
serverId: formState.selectedServerId,
|
||||
provider: formState.selectedProvider,
|
||||
getProvider: formState.getSelectedProvider,
|
||||
cwd: workingDir,
|
||||
modeId: formState.selectedMode,
|
||||
modelId: effectiveModelId,
|
||||
@@ -319,10 +337,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
providerOptions: draftOptions,
|
||||
metadata,
|
||||
agentControls: buildDraftAgentControls({
|
||||
formState,
|
||||
optionControls: draftOptionControls,
|
||||
options: draftShownOptions,
|
||||
onSetOption: setDraftOption,
|
||||
onApplyAgentProfile: applyDraftAgentProfile,
|
||||
context: composerOptions.context ?? "draft",
|
||||
metadata,
|
||||
onSetMetadata: setMetadata,
|
||||
}),
|
||||
commandDraft,
|
||||
};
|
||||
@@ -334,8 +358,12 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
effectiveThinkingOptionId,
|
||||
draftOptionControls,
|
||||
draftOptions,
|
||||
draftShownOptions,
|
||||
setDraftOption,
|
||||
applyDraftAgentProfile,
|
||||
formState,
|
||||
metadata,
|
||||
setMetadata,
|
||||
workingDir,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyMetadataPatch } from "./metadata";
|
||||
|
||||
describe("applyMetadataPatch", () => {
|
||||
it("replaces top-level keys and removes keys patched to null", () => {
|
||||
expect(
|
||||
applyMetadataPatch(
|
||||
{ "linear.issue": { id: "ENG-1" }, review: true },
|
||||
{ "linear.issue": { id: "ENG-2" }, review: null, extra: [1] },
|
||||
),
|
||||
).toEqual({ "linear.issue": { id: "ENG-2" }, extra: [1] });
|
||||
});
|
||||
|
||||
it("leaves the current record untouched", () => {
|
||||
const current = { review: true };
|
||||
applyMetadataPatch(current, { review: null });
|
||||
expect(current).toEqual({ review: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
/** Top-level keys replace; `null` removes the key. The daemon applies the same rule to an agent. */
|
||||
export function applyMetadataPatch(
|
||||
current: Readonly<Record<string, JsonValue>>,
|
||||
patch: Readonly<Record<string, JsonValue>>,
|
||||
): Record<string, JsonValue> {
|
||||
const next = { ...current };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) delete next[key];
|
||||
else next[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -133,6 +134,7 @@ async function submitDraftCreateRequest(input: {
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
providerOptions: Record<string, unknown> | undefined;
|
||||
metadata: Record<string, JsonValue>;
|
||||
};
|
||||
hostDisconnectedMessage: string;
|
||||
selectModelMessage: string;
|
||||
@@ -182,6 +184,7 @@ async function submitDraftCreateRequest(input: {
|
||||
workspaceId,
|
||||
initialPrompt: text,
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
...(Object.keys(composerState.metadata).length > 0 ? { metadata: composerState.metadata } : {}),
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
||||
};
|
||||
@@ -510,7 +513,9 @@ export function WorkspaceDraftAgentTab({
|
||||
providers: composerState.modelSelectorProviders,
|
||||
selectedProvider: draftProvider,
|
||||
selectedModelId: composerState.effectiveModelId,
|
||||
select: composerState.setProviderAndModelFromUser,
|
||||
select: (provider, modelId) => {
|
||||
composerState.setProviderAndModelFromUser(provider, modelId);
|
||||
},
|
||||
},
|
||||
thinking: {
|
||||
options: draftThinkingOptions,
|
||||
|
||||
@@ -139,6 +139,8 @@ import type {
|
||||
} from "@/attachments/types";
|
||||
import type { SelectedFile } from "@/attachments/selected-file";
|
||||
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
|
||||
import { ComposerInputProvider, type ComposerInput } from "@/composer/session";
|
||||
import { insertAtSelection } from "@/composer/insert-text";
|
||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
|
||||
import { droppedItemsToSelectedFiles } from "@/composer/attachments/drop";
|
||||
@@ -1419,6 +1421,40 @@ function ComposerContentImpl({
|
||||
[onChangeText],
|
||||
);
|
||||
|
||||
const insertUserInput = useCallback(
|
||||
(text: string) => {
|
||||
const input = messageInputRef.current;
|
||||
if (!input) {
|
||||
onChangeText(textSource.getSnapshot() + text);
|
||||
return;
|
||||
}
|
||||
const inserted = insertAtSelection(input.getInputSnapshot(), text);
|
||||
input.replaceText(inserted.text, inserted.selection);
|
||||
},
|
||||
[onChangeText, textSource],
|
||||
);
|
||||
const isComposerLockedRef = useRef(isComposerLocked);
|
||||
isComposerLockedRef.current = isComposerLocked;
|
||||
const composerInput = useMemo<ComposerInput>(
|
||||
() => ({
|
||||
locked: isComposerLocked,
|
||||
isLocked: () => isComposerLockedRef.current,
|
||||
textSource,
|
||||
setText: replaceUserInput,
|
||||
insertText: insertUserInput,
|
||||
attachments,
|
||||
changeAttachments: setSelectedAttachments,
|
||||
}),
|
||||
[
|
||||
attachments,
|
||||
insertUserInput,
|
||||
isComposerLocked,
|
||||
replaceUserInput,
|
||||
setSelectedAttachments,
|
||||
textSource,
|
||||
],
|
||||
);
|
||||
|
||||
const runClientSlashCommand = useCallback(
|
||||
(command: ClientSlashCommand): boolean => {
|
||||
if (command.execution !== "immediate" || !onClientSlashCommand) {
|
||||
@@ -2223,18 +2259,27 @@ function ComposerContentImpl({
|
||||
[attachments, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
||||
);
|
||||
|
||||
const leftContent = useMemo(
|
||||
() =>
|
||||
renderLeftContent({
|
||||
agentControls,
|
||||
agentId,
|
||||
serverId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
showAgentControls: mode.showAgentControls,
|
||||
}),
|
||||
[agentControls, agentId, focusInput, isCompactLayout, mode.showAgentControls, serverId],
|
||||
);
|
||||
const leftContent = useMemo(() => {
|
||||
const controls = renderLeftContent({
|
||||
agentControls,
|
||||
agentId,
|
||||
serverId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
showAgentControls: mode.showAgentControls,
|
||||
});
|
||||
return controls ? (
|
||||
<ComposerInputProvider value={composerInput}>{controls}</ComposerInputProvider>
|
||||
) : null;
|
||||
}, [
|
||||
agentControls,
|
||||
agentId,
|
||||
composerInput,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
mode.showAgentControls,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
attachButtonRef.current = node;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { insertAtSelection } from "./insert-text";
|
||||
|
||||
describe("insertAtSelection", () => {
|
||||
it("inserts at the caret and moves the caret after the insertion", () => {
|
||||
expect(insertAtSelection({ text: "ab", selection: { start: 1, end: 1 } }, "XY")).toEqual({
|
||||
text: "aXYb",
|
||||
selection: { start: 3, end: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces the selection, however it was dragged", () => {
|
||||
expect(insertAtSelection({ text: "abcd", selection: { start: 3, end: 1 } }, "-")).toEqual({
|
||||
text: "a-d",
|
||||
selection: { start: 2, end: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ComposerInputSnapshot } from "@/composer/input/input";
|
||||
|
||||
/** The text with `inserted` in place of the selection, and the caret after it. */
|
||||
export function insertAtSelection(
|
||||
{ text, selection }: ComposerInputSnapshot,
|
||||
inserted: string,
|
||||
): ComposerInputSnapshot {
|
||||
const start = Math.min(selection.start, selection.end);
|
||||
const end = Math.max(selection.start, selection.end);
|
||||
const caret = start + inserted.length;
|
||||
return {
|
||||
text: text.slice(0, start) + inserted + text.slice(end),
|
||||
selection: { start: caret, end: caret },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { ComposerTextSource } from "@/composer/text-source";
|
||||
|
||||
/**
|
||||
* The text and attachments of the composer a control sits in. `Composer` provides it, so a
|
||||
* control reaches the prompt without knowing which screen mounted the composer.
|
||||
*/
|
||||
export interface ComposerInput {
|
||||
/** The prompt is held while the composer submits; the built-in input is read-only meanwhile. */
|
||||
locked: boolean;
|
||||
/** `locked` as it is now. A command a plugin kept from an earlier render asks this. */
|
||||
isLocked: () => boolean;
|
||||
textSource: ComposerTextSource;
|
||||
setText: (text: string) => void;
|
||||
/** Inserts at the cursor, replacing the selection. */
|
||||
insertText: (text: string) => void;
|
||||
attachments: readonly UserComposerAttachment[];
|
||||
changeAttachments: (
|
||||
updater: (current: UserComposerAttachment[]) => UserComposerAttachment[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent a composer configures, as one set of reads and commands. A running agent sends each
|
||||
* command to the daemon; a draft keeps it locally until the agent is created. Controls call the
|
||||
* same command either way.
|
||||
*/
|
||||
export interface ComposerAgent {
|
||||
context: "agent" | "draft" | "new-workspace";
|
||||
/** The agent is being created; the built-in controls are disabled meanwhile. */
|
||||
locked: boolean;
|
||||
/** `locked` as it is now. A command a plugin kept from an earlier render asks this. */
|
||||
isLocked: () => boolean;
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
mode: string | null;
|
||||
thinking: string | null;
|
||||
/** The options the agent runs with, defaults included. */
|
||||
options: ProviderOptions;
|
||||
metadata: Record<string, JsonValue>;
|
||||
setProvider: (provider: string) => Promise<void>;
|
||||
setModel: (modelId: string) => Promise<void>;
|
||||
setMode: (modeId: string) => Promise<void>;
|
||||
setThinking: (thinkingOptionId: string) => Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the agent's own value. */
|
||||
setOptions: (patch: ProviderOptions) => Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the key. */
|
||||
setMetadata: (patch: Record<string, JsonValue>) => Promise<void>;
|
||||
}
|
||||
|
||||
const ComposerInputContext = createContext<ComposerInput | null>(null);
|
||||
const ComposerAgentContext = createContext<ComposerAgent | null>(null);
|
||||
|
||||
export function ComposerInputProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: ComposerInput;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <ComposerInputContext.Provider value={value}>{children}</ComposerInputContext.Provider>;
|
||||
}
|
||||
|
||||
export function ComposerAgentProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: ComposerAgent | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <ComposerAgentContext.Provider value={value}>{children}</ComposerAgentContext.Provider>;
|
||||
}
|
||||
|
||||
export function useComposerInput(): ComposerInput | null {
|
||||
return useContext(ComposerInputContext);
|
||||
}
|
||||
|
||||
export function useComposerAgent(): ComposerAgent | null {
|
||||
return useContext(ComposerAgentContext);
|
||||
}
|
||||
|
||||
/** True while the composer holds its prompt or its agent's settings for a submit in flight. */
|
||||
export function useComposerLocked(): boolean {
|
||||
const input = useComposerInput();
|
||||
const agent = useComposerAgent();
|
||||
return Boolean(input?.locked || agent?.locked);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import type {
|
||||
AgentMode,
|
||||
@@ -28,14 +28,13 @@ import {
|
||||
mergeSelectedComposerPreferences,
|
||||
buildProviderDefinitionMap,
|
||||
buildProviderDefinitionMapForStatuses,
|
||||
INITIAL_AGENT_FORM_RESOLUTION,
|
||||
INITIAL_USER_MODIFIED,
|
||||
RESOLVABLE_PROVIDER_STATUSES,
|
||||
SELECTABLE_PROVIDER_STATUSES,
|
||||
type FormInitialValues,
|
||||
type FormState,
|
||||
type ProviderModelsByProvider,
|
||||
} from "@/provider-selection/resolve-agent-form";
|
||||
import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { MaterializedAgentProfile } from "@/agent-profiles";
|
||||
|
||||
@@ -52,10 +51,13 @@ export interface UseAgentFormStateOptions {
|
||||
export interface UseAgentFormStateResult {
|
||||
selectedServerId: string | null;
|
||||
selectedProvider: AgentProvider | null;
|
||||
/** The provider as it is now, which a render may not have reached yet. */
|
||||
getSelectedProvider: () => AgentProvider | null;
|
||||
selectedMode: string;
|
||||
setModeFromUser: (modeId: string) => void;
|
||||
selectedModel: string;
|
||||
setModelFromUser: (modelId: string) => void;
|
||||
/** `false` when the provider's loaded catalog does not list the model. */
|
||||
setModelFromUser: (modelId: string) => boolean;
|
||||
selectedThinkingOptionId: string;
|
||||
setThinkingOptionFromUser: (thinkingOptionId: string) => void;
|
||||
workingDir: string;
|
||||
@@ -74,13 +76,20 @@ export interface UseAgentFormStateResult {
|
||||
modelError: string | null;
|
||||
refreshProviderModels: (provider?: AgentProvider) => void;
|
||||
refetchProviderModelsIfStale: () => void;
|
||||
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
|
||||
/** `false` when the provider is unknown or not ready. */
|
||||
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => boolean;
|
||||
applyProfileFromUser: (profile: MaterializedAgentProfile) => void;
|
||||
clearProviderSelectionFromUser: () => void;
|
||||
workingDirIsEmpty: boolean;
|
||||
persistFormPreferences: () => Promise<void>;
|
||||
}
|
||||
|
||||
const EMPTY_CATALOG: AgentFormCatalog = {
|
||||
selectableProviders: new Map(),
|
||||
models: new Map(),
|
||||
providerPreferences: () => undefined,
|
||||
};
|
||||
|
||||
function resolveSelectedProviderModes(input: {
|
||||
selectedEntry: ProviderSnapshotEntry | null;
|
||||
provider: AgentProvider | null;
|
||||
@@ -172,11 +181,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
[updatePreferences],
|
||||
);
|
||||
|
||||
const [{ form: formState, userModified, resolution }, dispatch] = useReducer(resolveAgentForm, {
|
||||
form: { provider: null, modeId: "", model: "", thinkingOptionId: "" },
|
||||
userModified: INITIAL_USER_MODIFIED,
|
||||
resolution: INITIAL_AGENT_FORM_RESOLUTION,
|
||||
});
|
||||
// The store reads what the host offers when a command runs; each render publishes the latest.
|
||||
const catalogRef = useRef<AgentFormCatalog>(EMPTY_CATALOG);
|
||||
const [formStore] = useState(() => openAgentForm(() => catalogRef.current));
|
||||
const { form: formState, resolution } = useSyncExternalStore(
|
||||
formStore.subscribe,
|
||||
formStore.getState,
|
||||
formStore.getState,
|
||||
);
|
||||
const dispatch = formStore.dispatch;
|
||||
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
@@ -246,6 +259,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
||||
const allProviderModels = snapshotAllProviderModels;
|
||||
catalogRef.current = {
|
||||
selectableProviders: selectableProviderDefinitionMap,
|
||||
models: allProviderModels,
|
||||
providerPreferences: (provider) =>
|
||||
preferenceOverlayRef.current.current().providerPreferences?.[provider],
|
||||
};
|
||||
const modelSelectorProviders = snapshotModelSelectorProviders;
|
||||
const availableModels = snapshotSelectedProviderModels;
|
||||
const modeOptions = snapshotSelectedProviderModes;
|
||||
@@ -277,27 +296,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
preferences,
|
||||
snapshotProviderModelsByProvider,
|
||||
snapshotResolvableProviderDefinitionMap,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
const setProviderAndModelFromUser = useCallback(
|
||||
(provider: AgentProvider, modelId: string) => {
|
||||
if (!selectableProviderDefinitionMap.has(provider)) {
|
||||
return;
|
||||
}
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
(provider: AgentProvider, modelId: string): boolean => {
|
||||
if (!formStore.setProviderAndModel(provider, modelId)) return false;
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
|
||||
|
||||
dispatch({
|
||||
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||
provider,
|
||||
modelId,
|
||||
providerDef,
|
||||
providerModels,
|
||||
providerPrefs,
|
||||
});
|
||||
const nextModelId =
|
||||
normalizeSelectedModelId(modelId) || resolveDefaultModelId(providerModels);
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
@@ -307,13 +314,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
},
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
|
||||
[allProviderModels, formStore, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const clearProviderSelectionFromUser = useCallback(() => {
|
||||
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
|
||||
}, []);
|
||||
}, [dispatch]);
|
||||
|
||||
const applyProfileFromUser = useCallback(
|
||||
(profile: MaterializedAgentProfile) => {
|
||||
@@ -322,7 +330,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
return;
|
||||
}
|
||||
|
||||
const previousProvider = formState.provider;
|
||||
const formBefore = formStore.getState();
|
||||
const previousProvider = formBefore.form.provider;
|
||||
const providerDef = selectableProviderDefinitionMap.get(provider);
|
||||
const providerModels = allProviderModels.get(provider) ?? null;
|
||||
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
|
||||
@@ -336,7 +345,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
providerModels,
|
||||
providerPrefs,
|
||||
};
|
||||
const nextState = resolveAgentForm({ form: formState, userModified, resolution }, action);
|
||||
const nextState = resolveAgentForm(formBefore, action);
|
||||
const previousProviderModeIds = previousProvider
|
||||
? (providerDefinitionMap.get(previousProvider)?.modes.map((mode) => mode.id) ?? [])
|
||||
: [];
|
||||
@@ -360,19 +369,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
},
|
||||
[
|
||||
allProviderModels,
|
||||
formState,
|
||||
dispatch,
|
||||
formStore,
|
||||
providerDefinitionMap,
|
||||
resolution,
|
||||
selectableProviderDefinitionMap,
|
||||
updateCurrentPreferences,
|
||||
userModified,
|
||||
],
|
||||
);
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
(modeId: string) => {
|
||||
dispatch({ type: "SET_MODE_FROM_USER", modeId });
|
||||
const provider = formState.provider;
|
||||
const { provider } = formStore.getState().form;
|
||||
if (provider) {
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
@@ -385,24 +393,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
);
|
||||
}
|
||||
},
|
||||
[formState.provider, updateCurrentPreferences],
|
||||
[dispatch, formStore, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string) => {
|
||||
const provider = formState.provider;
|
||||
const providerPrefs = provider
|
||||
? preferenceOverlayRef.current.current().providerPreferences?.[provider]
|
||||
: undefined;
|
||||
dispatch({
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId,
|
||||
availableModels,
|
||||
providerPrefs,
|
||||
});
|
||||
(modelId: string): boolean => {
|
||||
if (!formStore.setModel(modelId)) return false;
|
||||
const { provider } = formStore.getState().form;
|
||||
if (provider) {
|
||||
const normalizedModelId = normalizeSelectedModelId(modelId);
|
||||
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
|
||||
const listed = allProviderModels.get(provider);
|
||||
const nextModelId =
|
||||
normalizeSelectedModelId(modelId) ||
|
||||
resolveDefaultModelId(listed?.length ? listed : null);
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
preferences: current,
|
||||
@@ -413,14 +415,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[availableModels, formState.provider, updateCurrentPreferences],
|
||||
[allProviderModels, formStore, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const setThinkingOptionFromUser = useCallback(
|
||||
(thinkingOptionId: string) => {
|
||||
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
|
||||
const { provider, model: modelId } = formState;
|
||||
const { provider, model: modelId } = formStore.getState().form;
|
||||
if (provider && modelId) {
|
||||
void updateCurrentPreferences((current) =>
|
||||
mergeSelectedComposerPreferences({
|
||||
@@ -435,9 +438,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
);
|
||||
}
|
||||
},
|
||||
[formState, updateCurrentPreferences],
|
||||
[dispatch, formStore, updateCurrentPreferences],
|
||||
);
|
||||
|
||||
const getSelectedProvider = useCallback(() => formStore.getState().form.provider, [formStore]);
|
||||
|
||||
const refreshProviderModels = useCallback(
|
||||
(provider?: AgentProvider) => {
|
||||
void refreshSnapshot(provider ? [provider] : undefined);
|
||||
@@ -479,6 +484,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
() => ({
|
||||
selectedServerId: serverId,
|
||||
selectedProvider: formState.provider,
|
||||
getSelectedProvider,
|
||||
selectedMode: formState.modeId,
|
||||
setModeFromUser,
|
||||
selectedModel: formState.model,
|
||||
@@ -509,6 +515,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
||||
}),
|
||||
[
|
||||
serverId,
|
||||
getSelectedProvider,
|
||||
formState.provider,
|
||||
formState.modeId,
|
||||
formState.model,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
draftControlsRequest,
|
||||
openDraftOptions,
|
||||
ownDraftOptions,
|
||||
shownDraftOptions,
|
||||
viewDraftOptions,
|
||||
} from "@/agent-controls/draft-options-model";
|
||||
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||
@@ -19,6 +20,8 @@ import { mergeProviderPreferences, useFormPreferences } from "./use-form-prefere
|
||||
export function useDraftProviderOptions(input: {
|
||||
serverId: string | null | undefined;
|
||||
provider: AgentProvider | null;
|
||||
/** The form's provider as it is now, which a render may not have reached yet. */
|
||||
getProvider: () => AgentProvider | null;
|
||||
cwd: string | null | undefined;
|
||||
modeId: string | null | undefined;
|
||||
modelId: string | null | undefined;
|
||||
@@ -26,8 +29,19 @@ export function useDraftProviderOptions(input: {
|
||||
initialOptions?: ProviderOptions;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialOptions } = input;
|
||||
const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions }));
|
||||
const {
|
||||
serverId,
|
||||
provider,
|
||||
getProvider,
|
||||
cwd,
|
||||
modeId,
|
||||
modelId,
|
||||
thinkingOptionId,
|
||||
initialOptions,
|
||||
} = input;
|
||||
const [model] = useState(() =>
|
||||
openDraftOptions({ provider, seed: initialOptions, currentProvider: getProvider }),
|
||||
);
|
||||
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
@@ -77,21 +91,23 @@ export function useDraftProviderOptions(input: {
|
||||
if (provider && data) model.applyResolved({ provider, requestKey, ...data });
|
||||
}, [data, model, provider, requestKey]);
|
||||
|
||||
// An edit belongs to the provider the form has now, even before the render that shows it.
|
||||
const setOption = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
model.setOption(key, value);
|
||||
if (!provider) return;
|
||||
void updatePreferences((current) =>
|
||||
const current = getProvider();
|
||||
if (!current) return;
|
||||
void updatePreferences((stored) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider,
|
||||
preferences: stored,
|
||||
provider: current,
|
||||
updates: { options: { [key]: value } },
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[useDraftProviderOptions] persist option preference failed", error);
|
||||
});
|
||||
},
|
||||
[model, provider, updatePreferences],
|
||||
[getProvider, model, updatePreferences],
|
||||
);
|
||||
|
||||
const view = viewDraftOptions(state, { provider, requestKey });
|
||||
@@ -105,5 +121,14 @@ export function useDraftProviderOptions(input: {
|
||||
[setOption, viewControls, viewOptions],
|
||||
);
|
||||
|
||||
return { optionControls, options: ownOptions, applyProfile: model.applyProfile };
|
||||
// What the draft will create with, the plugin's own edits included, before the daemon answers.
|
||||
const shownOptions = shownDraftOptions(state, { provider, requestKey });
|
||||
|
||||
return {
|
||||
optionControls,
|
||||
options: ownOptions,
|
||||
shownOptions,
|
||||
setOption,
|
||||
applyProfile: model.applyProfile,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ function installation(): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -53,6 +53,7 @@ const agent: Agent = {
|
||||
attentionReason: "permission",
|
||||
parentAgentId: null,
|
||||
labels: { phase: "implementation" },
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
};
|
||||
|
||||
describe("plugin context snapshots", () => {
|
||||
@@ -107,12 +108,16 @@ describe("plugin context snapshots", () => {
|
||||
attentionReason: "permission",
|
||||
parentAgentId: null,
|
||||
labels: { phase: "implementation" },
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
});
|
||||
expect(snapshot.labels).not.toBe(agent.labels);
|
||||
expect(Object.isFrozen(snapshot)).toBe(true);
|
||||
expect(Object.isFrozen(snapshot.labels)).toBe(true);
|
||||
expect(Reflect.set(snapshot, "title", "Hostile rename")).toBe(false);
|
||||
expect(Reflect.set(snapshot.labels, "phase", "hostile")).toBe(false);
|
||||
const issue = snapshot.metadata["linear.issue"] as { id: string };
|
||||
expect(Reflect.set(issue, "id", "hostile")).toBe(false);
|
||||
expect(agent.metadata?.["linear.issue"]).toEqual({ id: "ENG-1" });
|
||||
expect(snapshot.title).toBe("Implement plugin panels");
|
||||
expect(snapshot.labels).toEqual({ phase: "implementation" });
|
||||
expect(agent.title).toBe("Implement plugin panels");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { cloneAndFreeze } from "../freeze";
|
||||
|
||||
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
|
||||
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
|
||||
@@ -49,6 +50,7 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
parentAgentId: agent.parentAgentId,
|
||||
labels: Object.freeze({ ...agent.labels }),
|
||||
metadata: cloneAndFreeze(agent.metadata ?? {}),
|
||||
});
|
||||
const byWorkspace = agentSnapshots.get(agent) ?? new Map<string, PluginAgentSnapshot>();
|
||||
byWorkspace.set(workspaceId, snapshot);
|
||||
|
||||
@@ -46,6 +46,7 @@ const agent: PluginAgentSnapshot = {
|
||||
attentionReason: null,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const inspect = defineRpc({
|
||||
@@ -82,6 +83,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "details",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
||||
import { PluginComposerProvider } from "@getpaseo/plugin/client/host";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
ComposerControlSurfaceProvider,
|
||||
type ComposerControlSurface,
|
||||
} from "@/composer/agent-controls/composer-control";
|
||||
import { useComposerAgent, useComposerInput } from "@/composer/session";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { resolvePluginPlatform } from "../platform";
|
||||
import { PluginEnvironmentProvider, type PluginEnvironment } from "../popover";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { toPluginComposer } from "./model";
|
||||
|
||||
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
|
||||
function renderNothing() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function useServerPlugins(serverId: string | null): InstalledPlugin[] {
|
||||
const installed = useInstalledPlugins();
|
||||
return useMemo(
|
||||
() =>
|
||||
serverId === null
|
||||
? []
|
||||
: installed.filter(
|
||||
(plugin) => plugin.serverId === serverId && plugin.composerItems.length > 0,
|
||||
),
|
||||
[installed, serverId],
|
||||
);
|
||||
}
|
||||
|
||||
/** How many composer items the host's plugins registered; the toolbar budgets room for them. */
|
||||
export function usePluginComposerItemCount(serverId: string | null): number {
|
||||
const plugins = useServerPlugins(serverId);
|
||||
return plugins.reduce((count, plugin) => count + plugin.composerItems.length, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's plugin composer items. They read and command the composer they are mounted in, so
|
||||
* they render only where a composer provides its input and agent.
|
||||
*/
|
||||
export function PluginComposerItems({
|
||||
serverId,
|
||||
surface,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
surface: ComposerControlSurface;
|
||||
}) {
|
||||
const plugins = useServerPlugins(serverId);
|
||||
if (serverId === null || plugins.length === 0) return null;
|
||||
return (
|
||||
<ComposerControlSurfaceProvider value={surface}>
|
||||
{plugins.map((plugin) => (
|
||||
<ThemedPluginComposerItems key={plugin.id} plugin={plugin} uniProps={pluginThemeMapping} />
|
||||
))}
|
||||
</ComposerControlSurfaceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginComposerItemsHost({
|
||||
plugin,
|
||||
theme,
|
||||
}: {
|
||||
plugin: InstalledPlugin;
|
||||
theme: PluginHostProps["theme"];
|
||||
}) {
|
||||
const agent = useComposerAgent();
|
||||
const composerInput = useComposerInput();
|
||||
const toast = useToast();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const hosts = useHosts();
|
||||
const hostLabel =
|
||||
hosts.find((host) => host.serverId === plugin.serverId)?.label ?? plugin.serverId;
|
||||
const textSource = composerInput?.textSource;
|
||||
const prompt = useSyncExternalStore(
|
||||
textSource?.subscribe ?? subscribeNothing,
|
||||
textSource?.getSnapshot ?? emptyText,
|
||||
textSource?.getSnapshot ?? emptyText,
|
||||
);
|
||||
const environment = useMemo<PluginEnvironment>(
|
||||
() => ({
|
||||
installation: plugin,
|
||||
toast,
|
||||
state: createPluginClientStateSource(plugin.serverId),
|
||||
}),
|
||||
[plugin, toast],
|
||||
);
|
||||
const composer = useMemo(
|
||||
() =>
|
||||
agent && composerInput ? toPluginComposer({ plugin, agent, composerInput, prompt }) : null,
|
||||
[agent, composerInput, plugin, prompt],
|
||||
);
|
||||
const props = useMemo<PluginHostProps>(
|
||||
() => ({
|
||||
theme,
|
||||
host: { id: plugin.serverId, label: hostLabel },
|
||||
layout: { compact, platform: resolvePluginPlatform() },
|
||||
}),
|
||||
[compact, hostLabel, plugin.serverId, theme],
|
||||
);
|
||||
if (!composer) return null;
|
||||
return (
|
||||
<PluginEnvironmentProvider environment={environment}>
|
||||
<PluginComposerProvider composer={composer}>
|
||||
{plugin.composerItems.map(({ id, Component }) => (
|
||||
<SurfaceErrorBoundary
|
||||
key={id}
|
||||
installation={plugin}
|
||||
Surface={Component}
|
||||
renderError={renderNothing}
|
||||
>
|
||||
<Component {...props} />
|
||||
</SurfaceErrorBoundary>
|
||||
))}
|
||||
</PluginComposerProvider>
|
||||
</PluginEnvironmentProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedPluginComposerItems = withUnistyles(PluginComposerItemsHost);
|
||||
|
||||
function subscribeNothing() {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
function emptyText() {
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { z } from "zod";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { ComposerAgent, ComposerInput } from "@/composer/session";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { toPluginComposer } from "./model";
|
||||
|
||||
function installed(): InstalledPlugin {
|
||||
return {
|
||||
id: "review",
|
||||
serverId: "host-1",
|
||||
clientBundle: "bundle",
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
cleanup: () => {},
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
attachmentSources: [
|
||||
{
|
||||
id: "issues",
|
||||
title: "Issues",
|
||||
pickerTitle: "Issues",
|
||||
searchPlaceholder: "Search issues",
|
||||
icon: "Bug",
|
||||
search: defineRpc({
|
||||
name: "issues.search",
|
||||
input: z.object({}),
|
||||
output: z.array(z.object({})),
|
||||
}),
|
||||
},
|
||||
],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
timelineRenderers: [],
|
||||
};
|
||||
}
|
||||
|
||||
const issue = {
|
||||
id: "ISS-1",
|
||||
identifier: "ISS-1",
|
||||
title: "Fix it",
|
||||
url: "https://example.com/ISS-1",
|
||||
text: "Fix it",
|
||||
resourceType: "issue",
|
||||
};
|
||||
|
||||
function fakeAgent(context: ComposerAgent["context"], locked = false) {
|
||||
const calls: string[] = [];
|
||||
const agent: ComposerAgent = {
|
||||
context,
|
||||
locked,
|
||||
isLocked: () => locked,
|
||||
provider: "claude",
|
||||
model: "sonnet",
|
||||
mode: "default",
|
||||
thinking: null,
|
||||
options: { effort: "high" },
|
||||
metadata: { flag: true },
|
||||
// A running agent keeps its provider, as the real adapter does.
|
||||
setProvider: async (provider) => {
|
||||
if (context === "agent") throw new Error("A running agent cannot change provider.");
|
||||
calls.push(`provider:${provider}`);
|
||||
},
|
||||
setModel: async (model) => void calls.push(`model:${model}`),
|
||||
setMode: async (mode) => void calls.push(`mode:${mode}`),
|
||||
setThinking: async (thinking) => void calls.push(`thinking:${thinking}`),
|
||||
setOptions: async (patch) => void calls.push(`options:${JSON.stringify(patch)}`),
|
||||
setMetadata: async (patch: Record<string, JsonValue>) =>
|
||||
void calls.push(`metadata:${JSON.stringify(patch)}`),
|
||||
};
|
||||
return { agent, calls };
|
||||
}
|
||||
|
||||
function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
|
||||
const state = { text: "", attachments: initial };
|
||||
const input: ComposerInput = {
|
||||
locked,
|
||||
isLocked: () => locked,
|
||||
textSource: { getSnapshot: () => state.text, subscribe: () => () => {} },
|
||||
setText: (text) => {
|
||||
state.text = text;
|
||||
},
|
||||
insertText: (text) => {
|
||||
state.text += text;
|
||||
},
|
||||
get attachments() {
|
||||
return state.attachments;
|
||||
},
|
||||
changeAttachments: (updater) => {
|
||||
state.attachments = updater(state.attachments);
|
||||
},
|
||||
};
|
||||
return { input, state };
|
||||
}
|
||||
|
||||
describe("toPluginComposer", () => {
|
||||
it("reads the agent the composer configures and the context it sits in", () => {
|
||||
const { agent } = fakeAgent("new-workspace");
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "hello",
|
||||
});
|
||||
expect(composer.context).toBe("new-workspace");
|
||||
expect(composer.prompt).toBe("hello");
|
||||
expect(composer.agent).toEqual({
|
||||
provider: "claude",
|
||||
model: "sonnet",
|
||||
mode: "default",
|
||||
thinking: null,
|
||||
options: { effort: "high" },
|
||||
metadata: { flag: true },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["agent", "draft"] as const)(
|
||||
"sends every command to the %s composer's agent",
|
||||
async (context) => {
|
||||
const { agent, calls } = fakeAgent(context);
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
await composer.setModel("gpt");
|
||||
await composer.setMode("plan");
|
||||
await composer.setThinking("high");
|
||||
await composer.setOptions({ effort: "low" });
|
||||
await composer.setMetadata({ flag: null });
|
||||
expect(calls).toEqual([
|
||||
"model:gpt",
|
||||
"mode:plan",
|
||||
"thinking:high",
|
||||
'options:{"effort":"low"}',
|
||||
'metadata:{"flag":null}',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("lets a draft change provider and tells a running agent it cannot", async () => {
|
||||
const { input } = fakeInput();
|
||||
const draft = fakeAgent("draft");
|
||||
await toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent: draft.agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
}).setProvider("codex");
|
||||
expect(draft.calls).toEqual(["provider:codex"]);
|
||||
|
||||
const running = fakeAgent("agent");
|
||||
await expect(
|
||||
toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent: running.agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
}).setProvider("codex"),
|
||||
).rejects.toThrow("A running agent cannot change provider.");
|
||||
expect(running.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("changes the prompt and inserts at the cursor", () => {
|
||||
const { agent } = fakeAgent("draft");
|
||||
const { input, state } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
composer.setPrompt("a");
|
||||
composer.insertText("b");
|
||||
expect(state.text).toBe("ab");
|
||||
});
|
||||
|
||||
it("refuses every prompt and agent command while the composer is locked", async () => {
|
||||
const { agent, calls } = fakeAgent("new-workspace", true);
|
||||
const { input, state } = fakeInput([], true);
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
|
||||
expect(() => composer.setPrompt("a")).toThrow("The composer is locked");
|
||||
expect(() => composer.insertText("b")).toThrow("The composer is locked");
|
||||
expect(() => composer.addAttachment({ sourceId: "issues", item: issue })).toThrow(
|
||||
"The composer is locked",
|
||||
);
|
||||
await expect(composer.setProvider("codex")).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setModel("gpt")).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setMode("plan")).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setThinking("high")).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setOptions({ effort: "low" })).rejects.toThrow("The composer is locked");
|
||||
await expect(composer.setMetadata({ flag: null })).rejects.toThrow("The composer is locked");
|
||||
expect(state.text).toBe("");
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a command a plugin kept from before the composer locked", async () => {
|
||||
const { agent } = fakeAgent("new-workspace");
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
|
||||
// The plugin's action was awaiting an RPC when the user submitted.
|
||||
const lock = { locked: true };
|
||||
agent.isLocked = () => lock.locked;
|
||||
input.isLocked = () => lock.locked;
|
||||
|
||||
expect(() => composer.insertText("late")).toThrow("The composer is locked");
|
||||
expect(() => composer.addAttachment({ sourceId: "issues", item: issue })).toThrow(
|
||||
"The composer is locked",
|
||||
);
|
||||
expect(() => composer.removeAttachment({ sourceId: "issues", itemId: issue.id })).toThrow(
|
||||
"The composer is locked",
|
||||
);
|
||||
await expect(composer.setOptions({ effort: "low" })).rejects.toThrow("The composer is locked");
|
||||
});
|
||||
|
||||
it("hands out options and metadata a plugin cannot change", () => {
|
||||
const { agent } = fakeAgent("draft");
|
||||
agent.metadata = { nested: { list: [1] } };
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
|
||||
expect(Object.isFrozen(composer.agent.options)).toBe(true);
|
||||
expect(Object.isFrozen(composer.agent.metadata)).toBe(true);
|
||||
expect(Object.isFrozen((composer.agent.metadata as { nested: object }).nested)).toBe(true);
|
||||
expect(agent.metadata).toEqual({ nested: { list: [1] } });
|
||||
expect(Object.isFrozen(agent.metadata)).toBe(false);
|
||||
});
|
||||
|
||||
it("adds and removes the plugin's own attachments only", () => {
|
||||
const { agent } = fakeAgent("agent");
|
||||
const other: UserComposerAttachment = {
|
||||
kind: "plugin_resource",
|
||||
pluginId: "other",
|
||||
sourceId: "issues",
|
||||
sourceTitle: "Issues",
|
||||
sourceIcon: "Bug",
|
||||
item: issue,
|
||||
};
|
||||
const { input, state } = fakeInput([other]);
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
|
||||
composer.addAttachment({ sourceId: "issues", item: issue });
|
||||
composer.addAttachment({ sourceId: "issues", item: issue });
|
||||
expect(state.attachments).toHaveLength(2);
|
||||
|
||||
const next = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
expect(next.attachments).toEqual([{ sourceId: "issues", item: issue }]);
|
||||
|
||||
composer.removeAttachment({ sourceId: "issues", itemId: "ISS-1" });
|
||||
expect(state.attachments).toEqual([other]);
|
||||
});
|
||||
|
||||
it("rejects an attachment from a source the plugin did not register", () => {
|
||||
const { agent } = fakeAgent("agent");
|
||||
const { input } = fakeInput();
|
||||
const composer = toPluginComposer({
|
||||
plugin: installed(),
|
||||
agent,
|
||||
composerInput: input,
|
||||
prompt: "",
|
||||
});
|
||||
expect(() => composer.addAttachment({ sourceId: "nope", item: issue })).toThrow(
|
||||
"Unknown attachment source: nope",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginComposer } from "@getpaseo/plugin/client";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { ComposerAgent, ComposerInput } from "@/composer/session";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { createPluginResourceAttachment } from "../attachments/model";
|
||||
import { cloneAndFreeze } from "../freeze";
|
||||
|
||||
function isPluginAttachment(
|
||||
attachment: UserComposerAttachment,
|
||||
pluginId: string,
|
||||
sourceId: string,
|
||||
itemId?: string,
|
||||
): boolean {
|
||||
return (
|
||||
attachment.kind === "plugin_resource" &&
|
||||
attachment.pluginId === pluginId &&
|
||||
attachment.sourceId === sourceId &&
|
||||
(itemId === undefined || attachment.item.id === itemId)
|
||||
);
|
||||
}
|
||||
|
||||
const LOCKED_MESSAGE = "The composer is locked while the agent is being created.";
|
||||
|
||||
function assertUnlocked(isLocked: () => boolean): void {
|
||||
if (isLocked()) throw new Error(LOCKED_MESSAGE);
|
||||
}
|
||||
|
||||
async function whenUnlocked(agent: ComposerAgent, command: () => Promise<void>): Promise<void> {
|
||||
assertUnlocked(agent.isLocked);
|
||||
await command();
|
||||
}
|
||||
|
||||
/**
|
||||
* What a plugin sees of the composer it sits in. `agent` and `input` are the composer's own
|
||||
* halves, so the plugin's commands reach the same models the built-in controls use.
|
||||
*/
|
||||
export function toPluginComposer(input: {
|
||||
plugin: InstalledPlugin;
|
||||
agent: ComposerAgent;
|
||||
composerInput: ComposerInput;
|
||||
prompt: string;
|
||||
}): PluginComposer {
|
||||
const { plugin, agent, composerInput, prompt } = input;
|
||||
return {
|
||||
context: agent.context,
|
||||
prompt,
|
||||
attachments: composerInput.attachments.flatMap((attachment) =>
|
||||
attachment.kind === "plugin_resource" && attachment.pluginId === plugin.id
|
||||
? [{ sourceId: attachment.sourceId, item: attachment.item }]
|
||||
: [],
|
||||
),
|
||||
// Provider options are JSON on the wire.
|
||||
agent: {
|
||||
provider: agent.provider,
|
||||
model: agent.model,
|
||||
mode: agent.mode,
|
||||
thinking: agent.thinking,
|
||||
options: cloneAndFreeze(agent.options as Record<string, JsonValue>),
|
||||
metadata: cloneAndFreeze(agent.metadata),
|
||||
},
|
||||
setPrompt: (text) => {
|
||||
assertUnlocked(composerInput.isLocked);
|
||||
composerInput.setText(text);
|
||||
},
|
||||
insertText: (text) => {
|
||||
assertUnlocked(composerInput.isLocked);
|
||||
composerInput.insertText(text);
|
||||
},
|
||||
addAttachment({ sourceId, item }) {
|
||||
assertUnlocked(composerInput.isLocked);
|
||||
const source = plugin.attachmentSources.find((candidate) => candidate.id === sourceId);
|
||||
if (!source) throw new Error(`Unknown attachment source: ${sourceId}`);
|
||||
const attachment = createPluginResourceAttachment(
|
||||
{
|
||||
pluginId: plugin.id,
|
||||
sourceId,
|
||||
sourceTitle: plugin.i18n.resolve(source.title, plugin.locale),
|
||||
sourceIcon: source.icon,
|
||||
},
|
||||
item,
|
||||
);
|
||||
composerInput.changeAttachments((current) =>
|
||||
current.some((candidate) => isPluginAttachment(candidate, plugin.id, sourceId, item.id))
|
||||
? current
|
||||
: [...current, attachment],
|
||||
);
|
||||
},
|
||||
removeAttachment({ sourceId, itemId }) {
|
||||
assertUnlocked(composerInput.isLocked);
|
||||
composerInput.changeAttachments((current) =>
|
||||
current.filter((candidate) => !isPluginAttachment(candidate, plugin.id, sourceId, itemId)),
|
||||
);
|
||||
},
|
||||
setProvider: (provider) => whenUnlocked(agent, () => agent.setProvider(provider)),
|
||||
setModel: (modelId) => whenUnlocked(agent, () => agent.setModel(modelId)),
|
||||
setMode: (modeId) => whenUnlocked(agent, () => agent.setMode(modeId)),
|
||||
setThinking: (thinkingOptionId) =>
|
||||
whenUnlocked(agent, () => agent.setThinking(thinkingOptionId)),
|
||||
setOptions: (patch) => whenUnlocked(agent, () => agent.setOptions(patch)),
|
||||
setMetadata: (patch) => whenUnlocked(agent, () => agent.setMetadata(patch)),
|
||||
};
|
||||
}
|
||||
@@ -531,6 +531,38 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toThrow("Duplicate Command Center item: review");
|
||||
});
|
||||
|
||||
it("collects composer items and releases an id on removal", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"review",
|
||||
bundle(`
|
||||
function Item() { return null; }
|
||||
const remove = plugin.addComposerItem({ id: "flag", Component: Item });
|
||||
remove();
|
||||
plugin.addComposerItem({ id: "flag", Component: Item });
|
||||
`),
|
||||
);
|
||||
expect(plugin.composerItems.map((item) => item.id)).toEqual(["flag"]);
|
||||
});
|
||||
|
||||
it("rejects duplicate and non-component composer items", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"review",
|
||||
bundle(`
|
||||
function Item() { return null; }
|
||||
plugin.addComposerItem({ id: "flag", Component: Item });
|
||||
plugin.addComposerItem({ id: "flag", Component: Item });
|
||||
`),
|
||||
),
|
||||
).toThrow("Duplicate composer item: flag");
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"review",
|
||||
bundle(`plugin.addComposerItem({ id: "flag", Component: "nope" })`),
|
||||
),
|
||||
).toThrow("Composer item flag is not a component");
|
||||
});
|
||||
|
||||
it("runs the client entry with the full runtime context", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"review",
|
||||
|
||||
@@ -131,6 +131,7 @@ export function runPluginClientBundle(
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
@@ -146,6 +147,7 @@ export function runPluginClientBundle(
|
||||
footer: new Set(),
|
||||
};
|
||||
const filePanelIds = new Set<string>();
|
||||
const composerItemIds = new Set<string>();
|
||||
const workspacePanelIds = new Set<string>();
|
||||
const commandCenterItemIds = new Set<string>();
|
||||
const clientSlashCommandNames = new Set<string>();
|
||||
@@ -269,6 +271,20 @@ export function runPluginClientBundle(
|
||||
() => surfaceIds.delete(normalizedId),
|
||||
);
|
||||
},
|
||||
addComposerItem(contribution) {
|
||||
const normalizedId = requireId(contribution.id, "composer item id");
|
||||
if (composerItemIds.has(normalizedId))
|
||||
throw new Error(`Duplicate composer item: ${normalizedId}`);
|
||||
if (!isComponentType(contribution.Component)) {
|
||||
throw new Error(`Composer item ${normalizedId} is not a component`);
|
||||
}
|
||||
composerItemIds.add(normalizedId);
|
||||
return register(
|
||||
collector.composerItems,
|
||||
{ id: normalizedId, Component: contribution.Component },
|
||||
() => composerItemIds.delete(normalizedId),
|
||||
);
|
||||
},
|
||||
addSidebarHeaderItem(contribution: PluginSidebarItemContribution) {
|
||||
return addSidebarItem("header", contribution);
|
||||
},
|
||||
@@ -585,6 +601,7 @@ export function runPluginClientBundle(
|
||||
sidebarItems: collector.sidebarItems,
|
||||
legacySidebarItems: collector.legacySidebarItems,
|
||||
filePanels: collector.filePanels,
|
||||
composerItems: collector.composerItems,
|
||||
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
|
||||
commandCenterItems: collector.commandCenterItems,
|
||||
clientSlashCommands: collector.clientSlashCommands,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** A deep copy no plugin can change. Plugins read data through it, never the host's own objects. */
|
||||
export function cloneAndFreeze<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.freeze(
|
||||
Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -25,6 +25,11 @@ function findLucideIcon(name: string): LucideIcon | null {
|
||||
return isComponent(candidate) ? (candidate as LucideIcon) : null;
|
||||
}
|
||||
|
||||
/** Whether `name` is a Lucide icon, so a caller can fall back before drawing nothing. */
|
||||
export function isLucideIconName(name: string): boolean {
|
||||
return findLucideIcon(name) !== null;
|
||||
}
|
||||
|
||||
function isComponent(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "function" ||
|
||||
|
||||
@@ -36,6 +36,7 @@ function installation(invoked: string[]): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ActionProps, SelectProps, ToggleProps } from "@getpaseo/plugin/client/ui";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ComposerControl,
|
||||
useComposerControlSurface,
|
||||
type ComposerControlItem,
|
||||
} from "@/composer/agent-controls/composer-control";
|
||||
import { useComposerLocked } from "@/composer/session";
|
||||
import { parsePluginIcon, pluginIconComponent } from "../icons";
|
||||
|
||||
/** Icons resolve at render, so an invalid one throws to the item's boundary. */
|
||||
function usePluginControlIcon(icon: ToggleProps["icon"]) {
|
||||
return useMemo(() => pluginIconComponent(parsePluginIcon("Composer control", icon)), [icon]);
|
||||
}
|
||||
|
||||
/** Draws one plugin control where its composer item sits: inline, or as a row in the sheet. */
|
||||
function PluginComposerControl({
|
||||
item,
|
||||
disabled,
|
||||
}: {
|
||||
item: ComposerControlItem;
|
||||
disabled: boolean | undefined;
|
||||
}) {
|
||||
const surface = useComposerControlSurface();
|
||||
const locked = useComposerLocked();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<ComposerControl
|
||||
item={item}
|
||||
surface={surface}
|
||||
disabled={disabled || locked}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toggle({ title, icon, disabled, value, onValueChange }: ToggleProps) {
|
||||
const Icon = usePluginControlIcon(icon);
|
||||
const item = useMemo<ComposerControlItem>(
|
||||
() => ({
|
||||
id: "plugin-toggle",
|
||||
type: "toggle",
|
||||
title,
|
||||
icon: Icon,
|
||||
highlight: value ? "blue" : undefined,
|
||||
value,
|
||||
onChange: (next) => onValueChange({ value: next }),
|
||||
}),
|
||||
[Icon, onValueChange, title, value],
|
||||
);
|
||||
return <PluginComposerControl item={item} disabled={disabled} />;
|
||||
}
|
||||
|
||||
export function Select<Value extends string>({
|
||||
title,
|
||||
icon,
|
||||
disabled,
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
}: SelectProps<Value>) {
|
||||
const Icon = usePluginControlIcon(icon);
|
||||
const item = useMemo<ComposerControlItem>(
|
||||
() => ({
|
||||
id: "plugin-select",
|
||||
type: "select",
|
||||
title,
|
||||
icon: Icon,
|
||||
value,
|
||||
choices: options,
|
||||
onChange: (next) => onValueChange({ value: next as Value }),
|
||||
showCaret: true,
|
||||
}),
|
||||
[Icon, onValueChange, options, title, value],
|
||||
);
|
||||
return <PluginComposerControl item={item} disabled={disabled} />;
|
||||
}
|
||||
|
||||
export function Action({ title, icon, disabled, onPress }: ActionProps) {
|
||||
const Icon = usePluginControlIcon(icon);
|
||||
const item = useMemo<ComposerControlItem>(
|
||||
() => ({ id: "plugin-action", type: "action", title, icon: Icon, onPress: () => onPress({}) }),
|
||||
[Icon, onPress, title],
|
||||
);
|
||||
return <PluginComposerControl item={item} disabled={disabled} />;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export { FilePanelLoading as Loading } from "@/file-panels/loading";
|
||||
export { SettingsGroup } from "@/components/settings";
|
||||
|
||||
export { Icon } from "../icons";
|
||||
export { Action, Select, Toggle } from "./composer-controls";
|
||||
export { Modal } from "./modal";
|
||||
export { ScrollView, FlatList } from "./scroll-view";
|
||||
export { TextInput } from "./text-input";
|
||||
|
||||
@@ -106,6 +106,7 @@ export class PluginRegistry {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -34,6 +34,7 @@ function installed(
|
||||
},
|
||||
legacySidebarItems,
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -36,6 +36,7 @@ function installation(
|
||||
icon: "Blocks",
|
||||
})),
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -61,6 +61,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -24,6 +24,7 @@ function plugin(input: {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentTimelineItem, ToolCallTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentToolCallData, PluginTimelineStreamItem, StreamItem } from "@/types/stream";
|
||||
import { cloneAndFreeze } from "../freeze";
|
||||
import type { TimelineItemTransform } from "./model";
|
||||
|
||||
type AssistantPhase = "streaming" | "complete";
|
||||
@@ -9,18 +10,6 @@ const projectionCache = new WeakMap<
|
||||
WeakMap<StreamItem, Partial<Record<AssistantPhase, StreamItem[]>>>
|
||||
>();
|
||||
|
||||
function cloneAndFreeze<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.freeze(
|
||||
Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
|
||||
const { callId, name, status, error, detail, metadata } = data;
|
||||
const base = {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
PluginFilePanelContribution as SDKPluginFilePanelContribution,
|
||||
PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution,
|
||||
PluginComposerItemContribution,
|
||||
PluginComposerPillContribution as SDKPluginComposerPillContribution,
|
||||
PluginSidebarContribution as SDKPluginSidebarContribution,
|
||||
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
|
||||
@@ -53,6 +54,7 @@ export interface EvaluatedPlugin {
|
||||
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
|
||||
legacySidebarItems: PluginSidebarContribution[];
|
||||
filePanels: PluginFilePanelContribution[];
|
||||
composerItems: PluginComposerItemContribution[];
|
||||
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||
clientSlashCommands: PluginClientSlashCommandContribution[];
|
||||
@@ -82,6 +84,7 @@ export type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientSlashCommandContribution,
|
||||
PluginComposerItemContribution,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSidebarItemContribution,
|
||||
|
||||
@@ -21,6 +21,7 @@ function installed(): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "details",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { FormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
INITIAL_AGENT_FORM_RESOLUTION,
|
||||
INITIAL_USER_MODIFIED,
|
||||
isUnlistedModel,
|
||||
resolveAgentForm,
|
||||
type AgentFormAction,
|
||||
type AgentFormReducerState,
|
||||
} from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
/** What the host offers right now. The store reads it when a command runs, never earlier. */
|
||||
export interface AgentFormCatalog {
|
||||
/** The providers the host can run. */
|
||||
selectableProviders: ReadonlyMap<AgentProvider, AgentProviderDefinition>;
|
||||
/** Each provider's selectable models; empty when the host has listed none. */
|
||||
models: ReadonlyMap<string, AgentModelDefinition[]>;
|
||||
providerPreferences: (
|
||||
provider: AgentProvider,
|
||||
) => NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft form as a plain model: `resolveAgentForm` applied synchronously. A command reads
|
||||
* `getState()` and the catalog when it runs, so the one after `await setProvider(...)` sees the
|
||||
* new provider instead of the render that created it.
|
||||
*/
|
||||
export interface AgentFormStore {
|
||||
getState: () => AgentFormReducerState;
|
||||
dispatch: (action: AgentFormAction) => void;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
/** `false` when the provider is unknown or not ready; the form is left as it was. */
|
||||
setProviderAndModel: (provider: AgentProvider, modelId: string) => boolean;
|
||||
/**
|
||||
* `false` when the provider's catalog is loaded and does not list the model. A catalog that has
|
||||
* not arrived keeps the id as given.
|
||||
*/
|
||||
setModel: (modelId: string) => boolean;
|
||||
}
|
||||
|
||||
export function openAgentForm(catalog: () => AgentFormCatalog): AgentFormStore {
|
||||
let state: AgentFormReducerState = {
|
||||
form: { provider: null, modeId: "", model: "", thinkingOptionId: "" },
|
||||
userModified: INITIAL_USER_MODIFIED,
|
||||
resolution: INITIAL_AGENT_FORM_RESOLUTION,
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
function dispatch(action: AgentFormAction): void {
|
||||
const next = resolveAgentForm(state, action);
|
||||
if (next === state) return;
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
return {
|
||||
getState: () => state,
|
||||
dispatch,
|
||||
setProviderAndModel: (provider, modelId) => {
|
||||
const host = catalog();
|
||||
const providerDef = host.selectableProviders.get(provider);
|
||||
if (!providerDef) return false;
|
||||
dispatch({
|
||||
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
|
||||
provider,
|
||||
modelId,
|
||||
providerDef,
|
||||
providerModels: host.models.get(provider) ?? null,
|
||||
providerPrefs: host.providerPreferences(provider),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
setModel: (modelId) => {
|
||||
const { provider } = state.form;
|
||||
const host = catalog();
|
||||
const listed = provider ? host.models.get(provider) : undefined;
|
||||
const models = listed?.length ? listed : null;
|
||||
if (isUnlistedModel(models, modelId)) return false;
|
||||
dispatch({
|
||||
type: "SET_MODEL_FROM_USER",
|
||||
modelId,
|
||||
availableModels: models,
|
||||
providerPrefs: provider ? host.providerPreferences(provider) : undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
resolveAgentForm,
|
||||
resolveFormState,
|
||||
resolveEffectiveModel,
|
||||
isUnlistedModel,
|
||||
resolveThinkingOptionId,
|
||||
mergeSelectedComposerPreferences,
|
||||
buildProviderDefinitionMap,
|
||||
@@ -131,6 +132,19 @@ describe("resolveDefaultModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUnlistedModel", () => {
|
||||
it("rejects an id the provider's catalog lacks, and accepts a listed id or alias", () => {
|
||||
expect(isUnlistedModel(ALIASED_CODEX_MODELS, "not-in-the-catalog")).toBe(true);
|
||||
expect(isUnlistedModel(ALIASED_CODEX_MODELS, "gpt-5.3-codex")).toBe(false);
|
||||
expect(isUnlistedModel(ALIASED_CODEX_MODELS, "gpt-5.3-codex-legacy")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts any id while the provider has listed no models", () => {
|
||||
expect(isUnlistedModel(null, "anything")).toBe(false);
|
||||
expect(isUnlistedModel([], "anything")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("model aliases", () => {
|
||||
it("canonicalizes a retired preferred model and restores thinking from its alias key", () => {
|
||||
const resolved = resolveFormState(
|
||||
|
||||
@@ -153,6 +153,19 @@ export function resolveEffectiveModel(
|
||||
return findModelByReference(availableModels, modelId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the provider has listed its models and this id is not among them. A provider that
|
||||
* lists none accepts any id.
|
||||
*/
|
||||
export function isUnlistedModel(
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
modelId: string,
|
||||
): boolean {
|
||||
if (!availableModels || availableModels.length === 0) return false;
|
||||
if (!normalizeSelectedModelId(modelId)) return false;
|
||||
return !resolveEffectiveModel(availableModels, modelId);
|
||||
}
|
||||
|
||||
function resolvePreferredThinkingOptionId(input: {
|
||||
availableModels: AgentModelDefinition[] | null;
|
||||
providerPrefs: ProviderPrefs | undefined;
|
||||
|
||||
@@ -456,6 +456,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
||||
providerOptions: composerState.providerOptions,
|
||||
},
|
||||
...(Object.keys(composerState.metadata).length > 0 ? { metadata: composerState.metadata } : {}),
|
||||
initialPrompt: text,
|
||||
clientMessageId: `${input.draftId}:initial-message`,
|
||||
images: images?.length ? images : undefined,
|
||||
@@ -535,6 +536,7 @@ function buildComposerConfig(input: {
|
||||
initialServerId: serverId || null,
|
||||
initialValues: buildComposerInitialValues({ initialSetup }),
|
||||
initialProviderOptions: initialSetup?.providerOptions,
|
||||
context: "new-workspace" as const,
|
||||
isVisible: true,
|
||||
lockedWorkingDir: workingDir,
|
||||
};
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
} from "./migration";
|
||||
import { createDraftPersistStorage } from "./persistence";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { applyMetadataPatch } from "@/composer/draft/metadata";
|
||||
|
||||
export type { DraftInput, DraftLifecycleState } from "./state";
|
||||
|
||||
@@ -49,6 +51,9 @@ interface DraftStoreActions {
|
||||
draftKey: string;
|
||||
lifecycle?: Exclude<DraftLifecycleState, "active">;
|
||||
}) => void;
|
||||
/** Plugin metadata the draft will create its agent with; see `composer/draft/metadata`. */
|
||||
patchDraftMetadata: (input: { draftKey: string; patch: Record<string, JsonValue> }) => void;
|
||||
clearDraftMetadata: (input: { draftKey: string }) => void;
|
||||
attachWorkspaceFile: (input: {
|
||||
draftKey: string;
|
||||
attachment: WorkspaceFileComposerAttachment;
|
||||
@@ -60,6 +65,11 @@ interface DraftStoreActions {
|
||||
|
||||
interface DraftStoreRuntimeState {
|
||||
attachmentFocusRequestByDraftKey: Record<string, number>;
|
||||
/**
|
||||
* Kept beside the prompt so a composer that remounts finds it. Not persisted, and not cleared
|
||||
* with an emptied prompt: a plugin's choice outlives the text until the draft is sent.
|
||||
*/
|
||||
metadataByDraftKey: Record<string, Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
|
||||
@@ -256,6 +266,7 @@ export const useDraftStore = create<DraftStore>()(
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
attachmentFocusRequestByDraftKey: {},
|
||||
metadataByDraftKey: {},
|
||||
|
||||
getDraftInput: (draftKey) => {
|
||||
const record = get().drafts[draftKey];
|
||||
@@ -374,6 +385,25 @@ export const useDraftStore = create<DraftStore>()(
|
||||
scheduleAttachmentGc();
|
||||
},
|
||||
|
||||
patchDraftMetadata: ({ draftKey, patch }) => {
|
||||
set((state) => {
|
||||
const next = applyMetadataPatch(state.metadataByDraftKey[draftKey] ?? {}, patch);
|
||||
const { [draftKey]: _previous, ...others } = state.metadataByDraftKey;
|
||||
return {
|
||||
metadataByDraftKey:
|
||||
Object.keys(next).length > 0 ? { ...others, [draftKey]: next } : others,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearDraftMetadata: ({ draftKey }) => {
|
||||
set((state) => {
|
||||
if (!(draftKey in state.metadataByDraftKey)) return state;
|
||||
const { [draftKey]: _removed, ...others } = state.metadataByDraftKey;
|
||||
return { metadataByDraftKey: others };
|
||||
});
|
||||
},
|
||||
|
||||
attachWorkspaceFile: async ({ draftKey, attachment }) => {
|
||||
await get().hydrateDraftInput({ draftKey });
|
||||
set((state) => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useDraftStore } from "./index";
|
||||
|
||||
const draftKey = "draft:metadata";
|
||||
|
||||
describe("a draft's plugin metadata", () => {
|
||||
beforeEach(() => {
|
||||
useDraftStore.setState({ drafts: {}, metadataByDraftKey: {} });
|
||||
});
|
||||
|
||||
it("is there when the draft's composer mounts again", () => {
|
||||
const store = useDraftStore.getState();
|
||||
store.editDraftText({ draftKey, text: "review this" });
|
||||
store.patchDraftMetadata({ draftKey, patch: { "plugin.review": true, "plugin.tag": "a" } });
|
||||
store.patchDraftMetadata({ draftKey, patch: { "plugin.tag": null } });
|
||||
|
||||
// A remount reads the store again; nothing from the unmounted composer is kept.
|
||||
const remounted = useDraftStore.getState();
|
||||
expect(remounted.getDraftInput(draftKey)?.text).toBe("review this");
|
||||
expect(remounted.metadataByDraftKey[draftKey]).toEqual({ "plugin.review": true });
|
||||
});
|
||||
|
||||
it("is dropped with the prompt once the draft is sent, and not before", () => {
|
||||
const store = useDraftStore.getState();
|
||||
store.patchDraftMetadata({ draftKey, patch: { "plugin.review": true } });
|
||||
|
||||
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||
expect(useDraftStore.getState().metadataByDraftKey[draftKey]).toEqual({
|
||||
"plugin.review": true,
|
||||
});
|
||||
|
||||
store.clearDraftMetadata({ draftKey });
|
||||
expect(useDraftStore.getState().metadataByDraftKey[draftKey]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentOptionControl,
|
||||
JsonValue,
|
||||
ProviderOptions,
|
||||
AgentProvider,
|
||||
AgentMode,
|
||||
@@ -101,6 +102,7 @@ export interface Agent {
|
||||
archivedAt?: Date | null;
|
||||
parentAgentId: string | null;
|
||||
labels: Record<string, string>;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
projectPlacement?: ProjectPlacementPayload | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
|
||||
...(agent.lastError ? { lastError: agent.lastError } : {}),
|
||||
title: agent.title,
|
||||
labels: agent.labels,
|
||||
...(agent.metadata ? { metadata: agent.metadata } : {}),
|
||||
requiresAttention: agent.requiresAttention ?? false,
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
|
||||
@@ -137,5 +138,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
archivedAt,
|
||||
parentAgentId,
|
||||
labels: snapshot.labels,
|
||||
metadata: snapshot.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,3 +116,8 @@ export const Maximize2 = StubIcon;
|
||||
export const Workflow = StubIcon;
|
||||
export const ZoomIn = StubIcon;
|
||||
export const ZoomOut = StubIcon;
|
||||
export const ListTodo = StubIcon;
|
||||
export const Shield = StubIcon;
|
||||
export const ShieldEllipsis = StubIcon;
|
||||
export const ShieldPlus = StubIcon;
|
||||
export const Zap = StubIcon;
|
||||
|
||||
@@ -30,8 +30,10 @@ const testTheme = {
|
||||
borderAccent: "#a1a1aa",
|
||||
palette: {
|
||||
amber: { 500: "#f59e0b" },
|
||||
blue: { 300: "#93c5fd" },
|
||||
green: { 500: "#22c55e" },
|
||||
blue: { 300: "#93c5fd", 400: "#60a5fa" },
|
||||
green: { 400: "#4ade80", 500: "#22c55e" },
|
||||
yellow: { 400: "#facc15" },
|
||||
zinc: { 600: "#52525b" },
|
||||
red: { 300: "#fca5a5" },
|
||||
white: "#ffffff",
|
||||
},
|
||||
|
||||
@@ -236,6 +236,14 @@ export default defineConfig({
|
||||
find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/react-native-fabric-shim.ts"),
|
||||
},
|
||||
// The CJS entry requires the real react-native, whose Flow sources do not parse.
|
||||
{
|
||||
find: /^@floating-ui\/react-native$/,
|
||||
replacement: path.resolve(
|
||||
rootNodeModules,
|
||||
"@floating-ui/react-native/dist/floating-ui.react-native.esm.js",
|
||||
),
|
||||
},
|
||||
// Point to the ESM build so Vite can transform its imports and apply the
|
||||
// react alias below (the CJS build uses require('react') which bypasses
|
||||
// Vite alias resolution).
|
||||
|
||||
@@ -3727,6 +3727,35 @@ test("rejects an internal create before sending when the host predates internal
|
||||
expect(mock.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("metadata needs a host that advertises entityMetadata and sends nothing to one that does not", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ features: {} });
|
||||
await connectPromise;
|
||||
|
||||
const update = "Update the host to use metadata.";
|
||||
await expect(
|
||||
client.createAgent({ provider: "codex", cwd: "/tmp/project", metadata: { key: "value" } }),
|
||||
).rejects.toThrow(update);
|
||||
await expect(
|
||||
client.createWorkspace({
|
||||
source: { kind: "directory", path: "/tmp/project" },
|
||||
metadata: { key: "value" },
|
||||
}),
|
||||
).rejects.toThrow(update);
|
||||
await expect(client.setAgentMetadata("agent-1", { key: null })).rejects.toThrow(update);
|
||||
await expect(client.setWorkspaceMetadata("workspace-1", { key: null })).rejects.toThrow(update);
|
||||
expect(mock.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sends internal workspace creation and internal listings only to hosts that support them", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./connection/index.js";
|
||||
import { CreationClient } from "./creation/index.js";
|
||||
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { z } from "zod";
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
|
||||
@@ -509,11 +510,15 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
||||
worktreeName?: string;
|
||||
requestId?: string;
|
||||
labels?: Record<string, string>;
|
||||
/** Plugin-owned data stored on the agent. Needs a host with `entityMetadata`. */
|
||||
metadata?: CreateAgentRequestMessage["metadata"];
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceRequestOptions {
|
||||
source: WorkspaceCreateRequest["source"];
|
||||
title?: string;
|
||||
/** Plugin-owned data stored on the workspace. Needs a host with `entityMetadata`. */
|
||||
metadata?: WorkspaceCreateRequest["metadata"];
|
||||
/** Workspace discovery visibility; contents retain normal durability. */
|
||||
background?: boolean;
|
||||
callerAgentId?: string;
|
||||
@@ -3251,6 +3256,13 @@ export class DaemonClient {
|
||||
legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId),
|
||||
});
|
||||
|
||||
// Entity metadata is gated once at the transport boundary.
|
||||
private requireEntityMetadata(): void {
|
||||
if (this.lastServerInfoMessage?.features?.entityMetadata !== true) {
|
||||
throw new Error("Update the host to use metadata.");
|
||||
}
|
||||
}
|
||||
|
||||
// Background workspaces are gated once at the transport boundary.
|
||||
private requireBackgroundWorkspaces(): void {
|
||||
if (this.lastServerInfoMessage?.features?.backgroundWorkspaces !== true) {
|
||||
@@ -3269,6 +3281,7 @@ export class DaemonClient {
|
||||
);
|
||||
}
|
||||
if (options.background !== undefined) this.requireBackgroundWorkspaces();
|
||||
if (options.metadata !== undefined) this.requireEntityMetadata();
|
||||
const config = resolveAgentConfig(options);
|
||||
const result = await this.creations.createAgent({
|
||||
...options,
|
||||
@@ -3505,6 +3518,42 @@ export class DaemonClient {
|
||||
return { removedWorkspaceIds: payload.removedWorkspaceIds };
|
||||
}
|
||||
|
||||
/** Top-level keys replace; `null` removes the key. Resolves to the agent's metadata afterwards. */
|
||||
async setAgentMetadata(
|
||||
agentId: string,
|
||||
patch: Record<string, JsonValue>,
|
||||
requestId?: string,
|
||||
): Promise<Record<string, JsonValue>> {
|
||||
this.requireEntityMetadata();
|
||||
const payload =
|
||||
await this.sendNamespacedCorrelatedSessionRequest<"agent.metadata.update.response">({
|
||||
requestId,
|
||||
message: { type: "agent.metadata.update.request", agentId, patch },
|
||||
});
|
||||
if (!payload.accepted || !payload.metadata) {
|
||||
throw new Error(payload.error ?? "setAgentMetadata rejected");
|
||||
}
|
||||
return payload.metadata;
|
||||
}
|
||||
|
||||
/** Top-level keys replace; `null` removes the key. Resolves to the workspace's metadata afterwards. */
|
||||
async setWorkspaceMetadata(
|
||||
workspaceId: string,
|
||||
patch: Record<string, JsonValue>,
|
||||
requestId?: string,
|
||||
): Promise<Record<string, JsonValue>> {
|
||||
this.requireEntityMetadata();
|
||||
const payload =
|
||||
await this.sendNamespacedCorrelatedSessionRequest<"workspace.metadata.update.response">({
|
||||
requestId,
|
||||
message: { type: "workspace.metadata.update.request", workspaceId, patch },
|
||||
});
|
||||
if (!payload.accepted || !payload.metadata) {
|
||||
throw new Error(payload.error ?? "setWorkspaceMetadata rejected");
|
||||
}
|
||||
return payload.metadata;
|
||||
}
|
||||
|
||||
async setWorkspaceTitle(
|
||||
workspaceId: string,
|
||||
title: string | null,
|
||||
@@ -5058,6 +5107,8 @@ export class DaemonClient {
|
||||
if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces();
|
||||
if (input.agent?.background !== undefined)
|
||||
throw new Error("Configure background on the workspace, not its initial agent.");
|
||||
if (input.metadata !== undefined || input.agent?.metadata !== undefined)
|
||||
this.requireEntityMetadata();
|
||||
let agentConfig = input.agent ? resolveAgentConfig(input.agent) : undefined;
|
||||
if (agentConfig && this.carriesLegacyOptions(agentConfig)) {
|
||||
agentConfig = await this.routeLegacyCreateConfig(agentConfig);
|
||||
@@ -7687,6 +7738,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
|
||||
worktreeName: _worktreeName,
|
||||
requestId: _requestId,
|
||||
labels: _labels,
|
||||
metadata: _metadata,
|
||||
...overrides
|
||||
} = options;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/
|
||||
import type { OwnedSubscription } from "./connection/index.js";
|
||||
export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js";
|
||||
import type { DaemonClientConfig } from "./daemon-client.js";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentPermissionResponse, JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
AgentSnapshotPayload,
|
||||
CreationSnapshot,
|
||||
@@ -184,6 +184,8 @@ export interface PaseoWorkspaceHandle {
|
||||
readonly directory: string | null;
|
||||
readonly name: string | null;
|
||||
readonly status: PaseoWorkspace["status"] | null;
|
||||
/** Data plugins stored on the workspace. Paseo never reads a key. */
|
||||
readonly metadata: NonNullable<PaseoWorkspace["metadata"]> | null;
|
||||
readonly agents: {
|
||||
create(options: PaseoWorkspaceAgentCreateOptions): Promise<PaseoAgentHandle>;
|
||||
};
|
||||
@@ -191,6 +193,8 @@ export interface PaseoWorkspaceHandle {
|
||||
current(): PaseoWorkspace | null;
|
||||
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
|
||||
setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>;
|
||||
/** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */
|
||||
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
|
||||
archive(requestId?: string): Promise<PaseoWorkspaceArchiveResult>;
|
||||
/**
|
||||
* Subscribes to already-emitted daemon workspace_update events for this id.
|
||||
@@ -274,6 +278,8 @@ export interface PaseoAgentCreateOptions {
|
||||
background?: CreateAgentRequestMessage["background"];
|
||||
requestId?: string;
|
||||
labels?: Record<string, string>;
|
||||
/** Data plugins store on the agent: JSON values under keys such as `"linear.issue"`. */
|
||||
metadata?: CreateAgentRequestMessage["metadata"];
|
||||
}
|
||||
|
||||
export type PaseoWorkspaceAgentCreateOptions = Omit<PaseoAgentCreateOptions, "cwd">;
|
||||
@@ -376,6 +382,8 @@ export interface PaseoAgentHandle {
|
||||
readonly options: NonNullable<PaseoAgent["options"]> | null;
|
||||
/** How the provider wants its options drawn. Controls hold no value; read `options`. */
|
||||
readonly controls: NonNullable<PaseoAgent["controls"]> | null;
|
||||
/** Data plugins stored on the agent. Paseo never reads a key. */
|
||||
readonly metadata: NonNullable<PaseoAgent["metadata"]> | null;
|
||||
readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null;
|
||||
readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null;
|
||||
readonly timeline: PaseoAgentTimelineHandle;
|
||||
@@ -394,6 +402,8 @@ export interface PaseoAgentHandle {
|
||||
* provider has applied the patch; a provider that cannot apply a key live rejects it.
|
||||
*/
|
||||
setOptions(patch: Record<string, unknown>): Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */
|
||||
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
|
||||
/** Sends a prompt and resolves when that turn finishes or needs attention. */
|
||||
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
|
||||
/** Waits for the current turn, including one started with `prompt`. */
|
||||
@@ -899,6 +909,9 @@ function createWorkspaceHandleFactory(
|
||||
get status() {
|
||||
return current?.status ?? null;
|
||||
},
|
||||
get metadata() {
|
||||
return current?.metadata ?? null;
|
||||
},
|
||||
agents: {
|
||||
create: async (options) => {
|
||||
const snapshot = current ?? (await refresh());
|
||||
@@ -918,6 +931,10 @@ function createWorkspaceHandleFactory(
|
||||
current: () => current,
|
||||
refresh,
|
||||
setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId),
|
||||
setMetadata: async (patch) => {
|
||||
const metadata = await daemonClient.setWorkspaceMetadata(id, patch);
|
||||
if (current) current = { ...current, metadata };
|
||||
},
|
||||
archive: async (requestId) => {
|
||||
const result = await daemonClient.archiveWorkspace(id, requestId);
|
||||
if (current) {
|
||||
@@ -1025,6 +1042,9 @@ function createAgentHandleFactory(
|
||||
get controls() {
|
||||
return current?.controls ?? null;
|
||||
},
|
||||
get metadata() {
|
||||
return current?.metadata ?? null;
|
||||
},
|
||||
get runtimeInfo() {
|
||||
return current?.runtimeInfo ?? null;
|
||||
},
|
||||
@@ -1049,6 +1069,10 @@ function createAgentHandleFactory(
|
||||
setOptions: async (patch) => {
|
||||
await daemonClient.setAgentOptions(id, patch);
|
||||
},
|
||||
setMetadata: async (patch) => {
|
||||
const metadata = await daemonClient.setAgentMetadata(id, patch);
|
||||
if (current) current = { ...current, metadata };
|
||||
},
|
||||
respondToPermission: async ({ requestId, response }) => {
|
||||
await daemonClient.respondToPermission(id, requestId, response);
|
||||
},
|
||||
|
||||
@@ -213,6 +213,43 @@ describe("plugin panel SDK boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin composer SDK boundary", () => {
|
||||
it("lets a composer item read and change the composer through typed commands and controls", () => {
|
||||
expect(
|
||||
typeErrors(`
|
||||
import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client";
|
||||
import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui";
|
||||
function Item() {
|
||||
const composer = useComposer();
|
||||
const context: "agent" | "draft" | "new-workspace" = composer.context;
|
||||
const provider: string | null = composer.agent.provider;
|
||||
const flag: boolean = composer.agent.metadata.flag === true;
|
||||
// @ts-expect-error reads are not writable
|
||||
composer.agent.model = "other";
|
||||
composer.setProvider("codex").then(() => composer.setModel("gpt"));
|
||||
composer.setOptions({ effort: null });
|
||||
composer.setMetadata({ flag: true, stale: null });
|
||||
composer.insertText("text");
|
||||
composer.addAttachment({ sourceId: "issues", item: { id: "1", identifier: "1", title: "t", url: "https://e.com", text: "t", resourceType: "issue" } });
|
||||
// @ts-expect-error commands take JSON values
|
||||
composer.setMetadata({ fn: () => {} });
|
||||
return (
|
||||
<>
|
||||
<Toggle title="Flag" icon="Flag" value={flag} onValueChange={({ value }: { value: boolean }) => composer.setMetadata({ flag: value })} />
|
||||
<Select title="Size" icon="Gauge" value="a" options={[{ value: "a", label: "A" }, { value: "b", label: "B" }]} onValueChange={({ value }: { value: "a" | "b" }) => {}} />
|
||||
<Action title="Insert" icon="Plus" onPress={() => composer.setPrompt("")} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addComposerItem({ id: "item", Component: Item });
|
||||
return () => {};
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin agent tools SDK boundary", () => {
|
||||
it("lets a session hook attach typed tools without exposing their transport", () => {
|
||||
expect(
|
||||
@@ -283,6 +320,10 @@ describe("lifecycle hook SDK types", () => {
|
||||
server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" }));
|
||||
server.addHook("workspace.create.before", async () => {});
|
||||
server.addHook("agent.open.before", async () => {});
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
const { metadata, ...withoutMetadata } = request;
|
||||
return withoutMetadata;
|
||||
});
|
||||
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
|
||||
const kind: "completed" | "failed" | "canceled" = outcome.kind;
|
||||
// @ts-expect-error Notification payloads have no creation request.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginAttachmentItem } from "../attachments.js";
|
||||
import type { PluginHostProps } from "./contracts.js";
|
||||
|
||||
/** One item the user attached from a source this plugin registered with `addAttachmentSource`. */
|
||||
export interface PluginComposerAttachment {
|
||||
readonly sourceId: string;
|
||||
readonly item: PluginAttachmentItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer the item sits in: the prompt, the attachments and the agent being configured.
|
||||
* The commands behave the same in every composer. `context` says where the composer is; it does
|
||||
* not change what a command does, except that a running agent keeps its provider.
|
||||
*/
|
||||
export interface PluginComposer {
|
||||
/** `"agent"`: a running agent. `"draft"`: a new agent in a workspace. `"new-workspace"`: the New workspace screen. */
|
||||
readonly context: "agent" | "draft" | "new-workspace";
|
||||
readonly prompt: string;
|
||||
/** This plugin's attachments only. */
|
||||
readonly attachments: readonly PluginComposerAttachment[];
|
||||
readonly agent: {
|
||||
readonly provider: string | null;
|
||||
readonly model: string | null;
|
||||
readonly mode: string | null;
|
||||
readonly thinking: string | null;
|
||||
/** The options the agent runs with, including provider defaults. */
|
||||
readonly options: Readonly<Record<string, JsonValue>>;
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
setPrompt(text: string): void;
|
||||
/** Inserts at the cursor, replacing the selection. */
|
||||
insertText(text: string): void;
|
||||
addAttachment(input: { sourceId: string; item: PluginAttachmentItem }): void;
|
||||
removeAttachment(input: { sourceId: string; itemId: string }): void;
|
||||
/** Resets the model like the built-in control. Rejects on a running agent, which keeps its provider. */
|
||||
setProvider(provider: string): Promise<void>;
|
||||
setModel(modelId: string): Promise<void>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
setThinking(thinkingOptionId: string): Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the agent's own value. */
|
||||
setOptions(patch: Record<string, JsonValue>): Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the key. */
|
||||
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginComposerItemProps extends PluginHostProps {}
|
||||
|
||||
export interface PluginComposerItemContribution {
|
||||
id: string;
|
||||
Component: ComponentType<PluginComposerItemProps>;
|
||||
}
|
||||
|
||||
const PluginComposerContext = createContext<PluginComposer | null>(null);
|
||||
|
||||
export function PluginComposerProvider({
|
||||
composer,
|
||||
children,
|
||||
}: {
|
||||
composer: PluginComposer;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PluginComposerContext.Provider value={composer}>{children}</PluginComposerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePluginComposerValue(): PluginComposer | null {
|
||||
return useContext(PluginComposerContext);
|
||||
}
|
||||
|
||||
/** The composer this item sits in. Call it from a component registered with `addComposerItem`. */
|
||||
export function useComposer(): PluginComposer {
|
||||
const composer = usePluginComposerValue();
|
||||
if (!composer) throw new Error("useComposer must run inside a composer item");
|
||||
return composer;
|
||||
}
|
||||
@@ -4,6 +4,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 { PluginFile } from "./file.js";
|
||||
import type { PluginComposerItemContribution } from "./composer.js";
|
||||
import type { PluginRpcContract } from "../rpc.js";
|
||||
import type {
|
||||
PluginButtonRegistration,
|
||||
@@ -194,6 +195,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
|
||||
contribution: PluginComposerPillContribution<PluginText>,
|
||||
): PluginButtonRegistration<PluginText>;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution<PluginText>): PluginCleanup;
|
||||
addComposerItem(contribution: PluginComposerItemContribution): PluginCleanup;
|
||||
addTheme(contribution: PluginThemeContribution<PluginText>): PluginCleanup;
|
||||
addFilePanel(contribution: PluginFilePanelContribution<PluginText>): PluginCleanup;
|
||||
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginText } from "../text.js";
|
||||
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
|
||||
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
|
||||
export { PluginComposerProvider } from "./composer.js";
|
||||
export {
|
||||
usePluginRuntimeContextBridge,
|
||||
type PluginRuntimeContextBridge,
|
||||
|
||||
@@ -71,6 +71,13 @@ export {
|
||||
} from "./file.js";
|
||||
export { usePaseo } from "./paseo-context.js";
|
||||
export { useAgent, useWorkspace } from "./client-state.js";
|
||||
export { useComposer } from "./composer.js";
|
||||
export type {
|
||||
PluginComposer,
|
||||
PluginComposerAttachment,
|
||||
PluginComposerItemContribution,
|
||||
PluginComposerItemProps,
|
||||
} from "./composer.js";
|
||||
export { useRpc } from "./rpc-context.js";
|
||||
import type { SettingsDefinition } from "../settings.js";
|
||||
import type { SettingsState } from "./contracts.js";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PluginTranslationProvider, usePluginTranslationRuntime } from "./translations.js";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { PluginComposerProvider, usePluginComposerValue } from "./composer.js";
|
||||
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
|
||||
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
|
||||
import { PluginRpcProvider, usePluginRpcContextValue } from "./rpc-context.js";
|
||||
@@ -12,6 +13,7 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
const paseo = usePaseoContextValue();
|
||||
const rpc = usePluginRpcContextValue();
|
||||
const state = usePluginClientStateSource();
|
||||
const composer = usePluginComposerValue();
|
||||
|
||||
if (!paseo || !rpc) {
|
||||
throw new Error("Plugin UI must run inside a contributed plugin surface");
|
||||
@@ -19,11 +21,16 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
|
||||
return useCallback(
|
||||
(children: ReactNode) => {
|
||||
const content = state ? (
|
||||
const withState = state ? (
|
||||
<PluginClientStateProvider source={state}>{children}</PluginClientStateProvider>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
const content = composer ? (
|
||||
<PluginComposerProvider composer={composer}>{withState}</PluginComposerProvider>
|
||||
) : (
|
||||
withState
|
||||
);
|
||||
return (
|
||||
<PaseoApiProvider paseo={paseo}>
|
||||
<PluginRpcProvider invoke={rpc.invoke}>
|
||||
@@ -32,6 +39,6 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
</PaseoApiProvider>
|
||||
);
|
||||
},
|
||||
[paseo, rpc, state, translations],
|
||||
[composer, paseo, rpc, state, translations],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,3 +185,35 @@ export interface MarkdownProps {
|
||||
|
||||
/** Renders Markdown using Paseo’s theme, selection, and link behavior. */
|
||||
export declare const Markdown: ComponentType<MarkdownProps>;
|
||||
|
||||
export interface ComposerControlProps {
|
||||
/** The control's label and tooltip. */
|
||||
title: string;
|
||||
/** A Lucide icon name, inline SVG, or a component. */
|
||||
icon: PluginIcon;
|
||||
disabled?: boolean;
|
||||
}
|
||||
/**
|
||||
* What a control's callback receives. A returned promise marks the control pending until it
|
||||
* settles, and Paseo reports a rejection to the user.
|
||||
*/
|
||||
export interface ToggleProps extends ComposerControlProps {
|
||||
value: boolean;
|
||||
onValueChange(event: { value: boolean }): void | Promise<void>;
|
||||
}
|
||||
export interface SelectProps<Value extends string = string> extends ComposerControlProps {
|
||||
value: Value;
|
||||
options: readonly { value: Value; label: string; description?: string }[];
|
||||
onValueChange(event: { value: Value }): void | Promise<void>;
|
||||
}
|
||||
export interface ActionPressEvent {}
|
||||
export interface ActionProps extends ComposerControlProps {
|
||||
onPress(event: ActionPressEvent): void | Promise<void>;
|
||||
}
|
||||
/**
|
||||
* Composer controls. Render them from a composer item's `Component`. Paseo draws them inline when
|
||||
* the composer has room and as rows in its overflow sheet when it does not.
|
||||
*/
|
||||
export declare const Toggle: ComponentType<ToggleProps>;
|
||||
export declare function Select<Value extends string>(props: SelectProps<Value>): ReactNode;
|
||||
export declare const Action: ComponentType<ActionProps>;
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface PluginAgentSnapshot {
|
||||
readonly attentionReason: "finished" | "error" | "permission" | null;
|
||||
readonly parentAgentId: string | null;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
/** Structured data plugins store on the agent. */
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
export interface PluginThemeColors {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AgentPermissionResponse,
|
||||
AgentTimelineItem,
|
||||
AgentSessionConfig,
|
||||
JsonValue,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
|
||||
@@ -19,6 +20,8 @@ export interface PluginHookWorkspace {
|
||||
cwd: string;
|
||||
name: string | null;
|
||||
archivedAt: string | null;
|
||||
/** Data plugins stored on the workspace. Paseo never reads a key. */
|
||||
metadata: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export interface PluginHookWorktree {
|
||||
@@ -68,6 +71,8 @@ export interface PluginHookAgent {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
title: string | null;
|
||||
/** Data plugins stored on the agent. Paseo never reads a key. */
|
||||
metadata: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export interface PluginSessionOpenRequest {
|
||||
@@ -79,6 +84,7 @@ export interface PluginSessionOpenRequest {
|
||||
purpose: "interactive" | "history";
|
||||
readonly title: string | null;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
tools: AgentToolDefinition[];
|
||||
env: Record<string, string>;
|
||||
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
|
||||
@@ -116,15 +122,27 @@ export interface PluginHookEvents {
|
||||
|
||||
export interface PluginHookRequests {
|
||||
"worktree.create.before": PluginWorktreeCreateRequest;
|
||||
"agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> };
|
||||
"agent.create.before": {
|
||||
config: AgentSessionConfig;
|
||||
env?: Record<string, string>;
|
||||
/** Stored on the new agent. Always present in the payload; return it changed to change it. */
|
||||
metadata?: Record<string, JsonValue>;
|
||||
};
|
||||
"agent.open.before": PluginSessionOpenRequest;
|
||||
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
|
||||
}
|
||||
|
||||
/** What a hook may return. An omitted `metadata` stays as it was. */
|
||||
type PluginHookRequestResults = Omit<PluginHookRequests, "agent.open.before"> & {
|
||||
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata"> & {
|
||||
metadata?: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginHooks = {
|
||||
[Name in keyof PluginHookRequests]: {
|
||||
request: PluginHookRequests[Name];
|
||||
result: PluginHookRequests[Name];
|
||||
result: PluginHookRequestResults[Name];
|
||||
};
|
||||
} & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & {
|
||||
"worktree.create.after": { payload: { worktree: PluginHookWorktree } };
|
||||
|
||||
@@ -116,7 +116,9 @@ export interface ProviderSessionConfig {
|
||||
model?: string;
|
||||
mode?: string;
|
||||
thinkingOption?: string;
|
||||
/** @deprecated The same values as `providerOptions`. */
|
||||
settings: Readonly<Record<string, JsonValue>>;
|
||||
/** The agent's options, the provider defaults from `config.json` included. */
|
||||
providerOptions?: Readonly<Record<string, unknown>>;
|
||||
title?: string;
|
||||
persist: boolean;
|
||||
@@ -126,6 +128,13 @@ export interface ProviderConfigChanges {
|
||||
model?: string | null;
|
||||
mode?: string | null;
|
||||
thinkingOption?: string | null;
|
||||
/**
|
||||
* The option keys to change on the running session, with or without a control. Apply every key
|
||||
* or fail the whole request, for a key you only read when a session opens. `null` returns the
|
||||
* key to the provider's own default. The committed values come back in `session.config`.
|
||||
*/
|
||||
providerOptions?: Readonly<Record<string, JsonValue>>;
|
||||
/** @deprecated Sent next to `providerOptions` to a provider that reports `settings`. */
|
||||
settings?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
@@ -311,6 +320,34 @@ export interface ProviderMode {
|
||||
isUnattended?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws one top-level option key in the composer. The control holds no value: Paseo reads it
|
||||
* from the agent's options.
|
||||
*/
|
||||
export type ProviderOptionControl =
|
||||
| {
|
||||
type: "toggle";
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** A Lucide icon name. */
|
||||
icon?: string;
|
||||
/** Shown while the key has no value. */
|
||||
defaultValue?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "select";
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** A Lucide icon name. */
|
||||
icon?: string;
|
||||
/** The choice shown while the key has no value. */
|
||||
defaultValue?: string;
|
||||
choices: ReadonlyArray<{ value: string; label: string; description?: string }>;
|
||||
};
|
||||
|
||||
/** @deprecated Report `controls` and `providerOptions` instead. */
|
||||
export type ProviderSetting =
|
||||
| {
|
||||
type: "toggle";
|
||||
@@ -335,7 +372,12 @@ export interface ProviderConfigState {
|
||||
models: readonly ProviderModel[];
|
||||
modes: readonly ProviderMode[];
|
||||
thinkingOptions: readonly ProviderThinkingOption[];
|
||||
settings: readonly ProviderSetting[];
|
||||
/** The option keys that get a composer control. May change at runtime, for example per model. */
|
||||
controls?: readonly ProviderOptionControl[];
|
||||
/** What the session runs with now, for the keys it reports. Publish the committed values. */
|
||||
providerOptions?: Readonly<Record<string, JsonValue>>;
|
||||
/** @deprecated Report `controls` and `providerOptions`. Each setting id is an option key. */
|
||||
settings?: readonly ProviderSetting[];
|
||||
}
|
||||
|
||||
export interface ProviderCatalog {
|
||||
@@ -888,6 +930,7 @@ const configChangesSchema = z
|
||||
model: z.string().nullable().optional(),
|
||||
mode: z.string().nullable().optional(),
|
||||
thinkingOption: z.string().nullable().optional(),
|
||||
providerOptions: jsonObjectSchema.optional(),
|
||||
settings: jsonObjectSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -1024,6 +1067,33 @@ const settingSchema = z.discriminatedUnion("type", [
|
||||
})
|
||||
.strip(),
|
||||
]);
|
||||
const controlSchema = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("toggle"),
|
||||
key: idSchema,
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
defaultValue: z.boolean().optional(),
|
||||
})
|
||||
.strip(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("select"),
|
||||
key: idSchema,
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
defaultValue: z.string().optional(),
|
||||
choices: z.array(
|
||||
z
|
||||
.object({ value: z.string(), label: z.string(), description: z.string().optional() })
|
||||
.strip(),
|
||||
),
|
||||
})
|
||||
.strip(),
|
||||
]);
|
||||
const configStateSchema = z
|
||||
.object({
|
||||
model: z.string().optional(),
|
||||
@@ -1032,7 +1102,9 @@ const configStateSchema = z
|
||||
models: z.array(modelSchema),
|
||||
modes: z.array(modeSchema),
|
||||
thinkingOptions: z.array(selectOptionSchema),
|
||||
settings: z.array(settingSchema),
|
||||
controls: z.array(controlSchema).optional(),
|
||||
providerOptions: jsonObjectSchema.optional(),
|
||||
settings: z.array(settingSchema).optional(),
|
||||
})
|
||||
.strip();
|
||||
const catalogSchema = z
|
||||
|
||||
@@ -303,6 +303,9 @@ import type {
|
||||
// unknown avoids zod-aot's recursive z.json() object-codegen regression.
|
||||
const JsonWireValueSchema = z.unknown() as z.ZodType<JsonValue>;
|
||||
|
||||
/** Plugin-owned data on an agent or workspace. Paseo stores it and never reads a key. */
|
||||
const EntityMetadataSchema = z.record(z.string(), JsonWireValueSchema);
|
||||
|
||||
export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
|
||||
|
||||
const AgentModeSchema: z.ZodType<AgentMode> = z.object({
|
||||
@@ -922,6 +925,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
lastError: z.string().optional(),
|
||||
title: z.string().nullable(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
metadata: EntityMetadataSchema.optional(),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
attentionTimestamp: z.string().nullable().optional(),
|
||||
@@ -1068,6 +1072,22 @@ export const WorkspaceTitleSetRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const AgentMetadataUpdateRequestSchema = z.object({
|
||||
type: z.literal("agent.metadata.update.request"),
|
||||
agentId: z.string(),
|
||||
// Top-level keys replace; null removes the key.
|
||||
patch: EntityMetadataSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspaceMetadataUpdateRequestSchema = z.object({
|
||||
type: z.literal("workspace.metadata.update.request"),
|
||||
workspaceId: z.string(),
|
||||
// Top-level keys replace; null removes the key.
|
||||
patch: EntityMetadataSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspacePinSetRequestSchema = z.object({
|
||||
type: z.literal("workspace.pin.set.request"),
|
||||
workspaceId: z.string(),
|
||||
@@ -1826,6 +1846,7 @@ export const CreateAgentRequestMessageSchema = z.object({
|
||||
// Workspace creation intent, rejected when selecting an existing workspace.
|
||||
background: z.boolean().optional(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
metadata: EntityMetadataSchema.optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
@@ -2204,6 +2225,28 @@ export const WorkspaceTitleSetResponseSchema = z.object({
|
||||
payload: WorkspaceTitleSetResponsePayloadSchema,
|
||||
});
|
||||
|
||||
export const AgentMetadataUpdateResponseSchema = z.object({
|
||||
type: z.literal("agent.metadata.update.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
metadata: EntityMetadataSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspaceMetadataUpdateResponseSchema = z.object({
|
||||
type: z.literal("workspace.metadata.update.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
metadata: EntityMetadataSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspacePinSetResponsePayloadSchema = z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
@@ -2759,6 +2802,7 @@ export const WorkspaceCreateRequestSchema = z.object({
|
||||
idempotencyKey: z.string().min(1).max(512).optional(),
|
||||
// Optional user-set title applied to the created workspace.
|
||||
title: z.string().optional(),
|
||||
metadata: EntityMetadataSchema.optional(),
|
||||
// Optional prompt context for workspace-level name/branch generation.
|
||||
firstAgentContext: FirstAgentContextSchema.optional(),
|
||||
// Retained for wire parsing; public internal creation is no longer supported.
|
||||
@@ -3355,6 +3399,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProjectIconSetRequestSchema,
|
||||
ProjectRemoveRequestSchema,
|
||||
WorkspaceTitleSetRequestSchema,
|
||||
AgentMetadataUpdateRequestSchema,
|
||||
WorkspaceMetadataUpdateRequestSchema,
|
||||
WorkspacePinSetRequestSchema,
|
||||
WorkspaceLabelListRequestSchema,
|
||||
WorkspaceLabelAssignmentSetRequestSchema,
|
||||
@@ -3888,6 +3934,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
explicitEventSubscriptions: z.boolean().optional(),
|
||||
ownedSubscriptions: z.boolean().optional(),
|
||||
providerOptionControls: z.boolean().optional(),
|
||||
entityMetadata: z.boolean().optional(),
|
||||
binaryChunkingV1: z.boolean().optional(),
|
||||
workspaceSetupObservation: z.boolean().optional(),
|
||||
workspaceSortTimes: z.boolean().optional(),
|
||||
@@ -4222,6 +4269,7 @@ export const WorkspaceDescriptorPayloadSchema = z
|
||||
pinnedAt: z.string().nullable().optional(),
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14.
|
||||
labels: z.array(z.string()).optional(),
|
||||
metadata: EntityMetadataSchema.optional(),
|
||||
// Retained for wire parsing of older daemon descriptors.
|
||||
internal: z.boolean().optional(),
|
||||
background: z.boolean().optional(),
|
||||
@@ -7200,6 +7248,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProjectIconSetResponseSchema,
|
||||
ProjectRemoveResponseSchema,
|
||||
WorkspaceTitleSetResponseSchema,
|
||||
AgentMetadataUpdateResponseSchema,
|
||||
WorkspaceMetadataUpdateResponseSchema,
|
||||
WorkspacePinSetResponseSchema,
|
||||
WorkspaceRecoveryInspectResponseSchema,
|
||||
WorkspaceRecoveryRestoreResponseSchema,
|
||||
@@ -7566,6 +7616,10 @@ export type ProjectIconSetRequest = z.infer<typeof ProjectIconSetRequestSchema>;
|
||||
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
|
||||
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
|
||||
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
|
||||
export type AgentMetadataUpdateRequest = z.infer<typeof AgentMetadataUpdateRequestSchema>;
|
||||
export type WorkspaceMetadataUpdateRequest = z.infer<typeof WorkspaceMetadataUpdateRequestSchema>;
|
||||
export type AgentMetadataUpdateResponse = z.infer<typeof AgentMetadataUpdateResponseSchema>;
|
||||
export type WorkspaceMetadataUpdateResponse = z.infer<typeof WorkspaceMetadataUpdateResponseSchema>;
|
||||
export type WorkspaceRecoveryInspectRequest = z.infer<typeof WorkspaceRecoveryInspectRequestSchema>;
|
||||
export type WorkspaceRecoveryRestoreRequest = z.infer<typeof WorkspaceRecoveryRestoreRequestSchema>;
|
||||
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
|
||||
|
||||
@@ -133,6 +133,7 @@ export async function ensureAgentLoaded(
|
||||
}
|
||||
snapshot = await deps.agentManager.createAgent(config, agentId, {
|
||||
labels: record.labels,
|
||||
metadata: record.metadata,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
env: record.launchEnv,
|
||||
|
||||
@@ -12475,6 +12475,7 @@ test.each([
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: null,
|
||||
metadata: {},
|
||||
},
|
||||
turnId: "hook-turn",
|
||||
outcome,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
|
||||
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
|
||||
import { applyRecordPatch } from "../record-patch.js";
|
||||
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename, resolve } from "node:path";
|
||||
@@ -60,7 +61,7 @@ import {
|
||||
type ListImportableSessionsOptions,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import {
|
||||
applyProviderOptionsPatch,
|
||||
@@ -324,6 +325,7 @@ type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
|
||||
|
||||
export interface CreateAgentOptions {
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
initialPrompt?: string;
|
||||
env?: Record<string, string>;
|
||||
persistSession?: boolean;
|
||||
@@ -465,6 +467,8 @@ interface ManagedAgentBase {
|
||||
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
|
||||
*/
|
||||
labels: Record<string, string>;
|
||||
/** Plugin-owned data. Paseo persists and forwards it and never reads a key. */
|
||||
metadata: AgentMetadata;
|
||||
}
|
||||
|
||||
type ManagedAgentWithSession = ManagedAgentBase & {
|
||||
@@ -524,6 +528,7 @@ type ActiveManagedAgent =
|
||||
|
||||
type LiveManagedAgent = ActiveManagedAgent;
|
||||
type AgentLabelPatch = Record<string, string | null>;
|
||||
type AgentMetadata = Record<string, JsonValue>;
|
||||
|
||||
function attachManagedTurnIdentity(
|
||||
agent: ActiveManagedAgent,
|
||||
@@ -571,6 +576,7 @@ interface WriteLabelsResult {
|
||||
interface AgentMetadataPatch {
|
||||
title?: string;
|
||||
labels?: AgentLabelPatch;
|
||||
metadata?: Record<string, JsonValue | null>;
|
||||
}
|
||||
|
||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||
@@ -629,6 +635,11 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
|
||||
return Object.assign(new Error(message), { name: "AbortError" });
|
||||
}
|
||||
|
||||
/** The user labels and plugin metadata an agent carries; both default to empty. */
|
||||
function agentEntityData(source?: { labels?: Record<string, string>; metadata?: AgentMetadata }) {
|
||||
return { labels: source?.labels ?? {}, metadata: source?.metadata ?? {} };
|
||||
}
|
||||
|
||||
function validateAgentId(agentId: string, source: string): string {
|
||||
const result = AgentIdSchema.safeParse(agentId);
|
||||
if (!result.success) {
|
||||
@@ -637,21 +648,6 @@ function validateAgentId(agentId: string, source: string): string {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function applyLabelPatch(
|
||||
labels: Record<string, string>,
|
||||
patch: AgentLabelPatch,
|
||||
): Record<string, string> {
|
||||
const nextLabels = { ...labels };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete nextLabels[key];
|
||||
} else {
|
||||
nextLabels[key] = value;
|
||||
}
|
||||
}
|
||||
return nextLabels;
|
||||
}
|
||||
|
||||
function buildExplicitTimelineSeedForRegister(
|
||||
now: Date,
|
||||
options:
|
||||
@@ -1386,6 +1382,7 @@ export class AgentManager {
|
||||
const request = await this.pluginLifecycle.before("agent.create.before", {
|
||||
config: sent,
|
||||
env: options.env,
|
||||
metadata: options.metadata ?? {},
|
||||
});
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
|
||||
// against the previous SDK edits `config.featureValues`.
|
||||
@@ -1397,7 +1394,7 @@ export class AgentManager {
|
||||
}),
|
||||
internal: config.internal,
|
||||
};
|
||||
options = { ...options, env: request.env };
|
||||
options = { ...options, env: request.env, metadata: request.metadata ?? options.metadata };
|
||||
}
|
||||
await this.deleteAgentState(resolvedAgentId);
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
@@ -1422,6 +1419,7 @@ export class AgentManager {
|
||||
workspaceId: options.workspaceId ?? null,
|
||||
title: options.initialTitle ?? storedConfig.title ?? null,
|
||||
labels: options.labels ?? {},
|
||||
metadata: options.metadata ?? {},
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
@@ -1432,6 +1430,7 @@ export class AgentManager {
|
||||
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
launchedOptions,
|
||||
labels: options.labels,
|
||||
metadata: options.metadata,
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
@@ -1465,6 +1464,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
attention?: AttentionState;
|
||||
@@ -1497,6 +1497,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
attention?: AttentionState;
|
||||
@@ -1549,7 +1550,7 @@ export class AgentManager {
|
||||
purpose,
|
||||
workspaceId: options?.workspaceId ?? null,
|
||||
title: record?.title ?? mergedConfig.title ?? null,
|
||||
labels: record?.labels ?? options?.labels ?? {},
|
||||
...agentEntityData(record ?? options),
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
@@ -1720,6 +1721,7 @@ export class AgentManager {
|
||||
workspaceId: existing.workspaceId,
|
||||
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
|
||||
labels: existing.labels,
|
||||
metadata: existing.metadata,
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
@@ -1764,6 +1766,7 @@ export class AgentManager {
|
||||
return this.registerSession(session, storedConfig, agentId, {
|
||||
launchedOptions,
|
||||
labels: existing.labels,
|
||||
metadata: existing.metadata,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
launchEnv: existing.launchEnv,
|
||||
@@ -2224,6 +2227,7 @@ export class AgentManager {
|
||||
attention,
|
||||
internal: record.internal,
|
||||
labels: record.labels,
|
||||
metadata: record.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2425,10 +2429,30 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
/** Applies a plugin-metadata patch (null removes a key) and returns the stored result. */
|
||||
async setMetadata(
|
||||
agentId: string,
|
||||
patch: Record<string, JsonValue | null>,
|
||||
): Promise<AgentMetadata> {
|
||||
return this.runLifecycleMutation(agentId, async () => {
|
||||
const liveAgent = this.agents.get(agentId);
|
||||
if (liveAgent) {
|
||||
liveAgent.metadata = applyRecordPatch<JsonValue>(liveAgent.metadata, patch);
|
||||
this.touchUpdatedAt(liveAgent);
|
||||
await this.persistSnapshot(liveAgent);
|
||||
this.emitState(liveAgent, { persist: false });
|
||||
return liveAgent.metadata;
|
||||
}
|
||||
const record = await this.writeStoredMetadata(agentId, { metadata: patch });
|
||||
if (!record.internal) this.dispatchStoredAgentState(record);
|
||||
return record.metadata;
|
||||
});
|
||||
}
|
||||
|
||||
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
||||
const liveAgent = this.agents.get(agentId);
|
||||
if (liveAgent) {
|
||||
liveAgent.labels = applyLabelPatch(liveAgent.labels, patch);
|
||||
liveAgent.labels = applyRecordPatch(liveAgent.labels, patch);
|
||||
this.touchUpdatedAt(liveAgent);
|
||||
await this.persistSnapshot(liveAgent);
|
||||
this.emitState(liveAgent, { persist: false });
|
||||
@@ -2453,7 +2477,8 @@ export class AgentManager {
|
||||
const nextRecord = {
|
||||
...record,
|
||||
...(patch.title ? { title: patch.title } : {}),
|
||||
...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}),
|
||||
...(patch.labels ? { labels: applyRecordPatch(record.labels, patch.labels) } : {}),
|
||||
...(patch.metadata ? { metadata: applyRecordPatch(record.metadata, patch.metadata) } : {}),
|
||||
updatedAt: this.nextStoredUpdatedAt(record),
|
||||
};
|
||||
await registry.upsert(nextRecord);
|
||||
@@ -2632,7 +2657,7 @@ export class AgentManager {
|
||||
await registry.upsert({
|
||||
...record,
|
||||
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
|
||||
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
|
||||
...(updates?.labels ? { labels: applyRecordPatch(record.labels, updates.labels) } : {}),
|
||||
archivedAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
@@ -3919,6 +3944,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
timeline?: AgentTimelineItem[];
|
||||
timelineRows?: AgentTimelineRow[];
|
||||
timelineNextSeq?: number;
|
||||
@@ -4128,6 +4154,7 @@ export class AgentManager {
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
historyPrimed?: boolean;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string;
|
||||
@@ -4186,7 +4213,7 @@ export class AgentManager {
|
||||
lastError: options?.lastError,
|
||||
attention: resolveInitialAttention(options?.attention),
|
||||
internal: config.internal ?? false,
|
||||
labels: options?.labels ?? {},
|
||||
...agentEntityData(options),
|
||||
} as ActiveManagedAgent;
|
||||
}
|
||||
|
||||
@@ -5770,6 +5797,7 @@ export class AgentManager {
|
||||
workspaceId?: string | null;
|
||||
title?: string | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: AgentMetadata;
|
||||
},
|
||||
): Promise<AgentLaunchContext> {
|
||||
// A plugin changing during this open must still make the resulting session stale.
|
||||
@@ -5787,7 +5815,7 @@ export class AgentManager {
|
||||
purpose: opening?.purpose ?? "interactive",
|
||||
env: { ...env },
|
||||
title: opening?.title ?? null,
|
||||
labels: opening?.labels ?? {},
|
||||
...agentEntityData(opening),
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
};
|
||||
|
||||
@@ -94,6 +94,7 @@ export function toStoredAgentRecord(
|
||||
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
metadata: agent.metadata,
|
||||
lastStatus: agent.lifecycle,
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
@@ -155,6 +156,7 @@ export function toAgentPayload(
|
||||
persistence: projectPersistenceHandleForWire(agent.persistence),
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
metadata: agent.metadata,
|
||||
archivedAt: options?.archivedAt ?? null,
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
};
|
||||
@@ -284,6 +286,7 @@ export function buildStoredAgentPayload(
|
||||
archivedAt: record.archivedAt ?? null,
|
||||
...(record.internal ? { internal: true } : {}),
|
||||
labels: normalizeLabels(record.labels),
|
||||
metadata: record.metadata,
|
||||
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
|
||||
...(providerAvailable ? {} : { providerUnavailable: true }),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { promises as fs, type Dirent } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
||||
import { AgentFeatureSchema, AgentOptionControlSchema, AgentStatusSchema } from "../messages.js";
|
||||
@@ -56,6 +57,7 @@ const RAW_STORED_AGENT_SCHEMA = z.object({
|
||||
lastUserMessageAt: z.string().nullable().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()).default({}),
|
||||
lastStatus: AgentStatusSchema.default("closed"),
|
||||
resumeOnStartup: z.boolean().optional(),
|
||||
lastModeId: z.string().nullable().optional(),
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface CreateAgentFromSessionInput {
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: AgentAttachment[];
|
||||
labels: Record<string, string>;
|
||||
metadata?: CreateAgentOptions["metadata"];
|
||||
env?: Record<string, string>;
|
||||
provisionalTitle: string | null;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
@@ -251,6 +252,7 @@ async function resolveSessionCreateAgent(
|
||||
config: sessionConfig,
|
||||
createOptions: {
|
||||
labels: input.labels,
|
||||
metadata: input.metadata,
|
||||
initialPrompt: trimmedPrompt,
|
||||
env: input.env,
|
||||
initialTitle: input.provisionalTitle,
|
||||
|
||||
@@ -562,6 +562,7 @@ function createManagedAgent(overrides: Partial<ManagedAgent> = {}): ManagedAgent
|
||||
pendingPermissions: new Map(),
|
||||
persistence: null,
|
||||
labels: {},
|
||||
metadata: {},
|
||||
attention: { requiresAttention: false },
|
||||
...overrides,
|
||||
} as ManagedAgent;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderConfigState } from "@getpaseo/plugin/server/provider";
|
||||
import type { AgentOptionControl } from "./agent-sdk-types.js";
|
||||
|
||||
/** How a provider session's composer options look to the daemon, whichever shape it reports. */
|
||||
export interface PluginOptionState {
|
||||
controls: AgentOptionControl[];
|
||||
/** The values the session runs with, for the keys it reports. */
|
||||
options: Record<string, JsonValue>;
|
||||
/** The provider reports `settings`, so it also reads `changes.settings`. */
|
||||
legacySettings: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider that reports `controls` and `providerOptions` is read as is.
|
||||
* COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A provider still
|
||||
* reporting `settings` has each setting id read as an option key and its value as the option.
|
||||
*/
|
||||
export function readPluginOptionState(config: ProviderConfigState): PluginOptionState {
|
||||
if (config.controls || config.providerOptions) {
|
||||
return {
|
||||
controls: (config.controls ?? []).map(toControl),
|
||||
options: { ...config.providerOptions },
|
||||
legacySettings: false,
|
||||
};
|
||||
}
|
||||
const settings = config.settings ?? [];
|
||||
return {
|
||||
controls: settings.map(
|
||||
(setting): AgentOptionControl =>
|
||||
setting.type === "toggle"
|
||||
? {
|
||||
type: "toggle",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
}
|
||||
: {
|
||||
type: "select",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
choices: setting.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
},
|
||||
),
|
||||
options: Object.fromEntries(settings.map((setting) => [setting.id, setting.value])),
|
||||
legacySettings: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toControl(
|
||||
control: NonNullable<ProviderConfigState["controls"]>[number],
|
||||
): AgentOptionControl {
|
||||
return control.type === "toggle"
|
||||
? { ...control }
|
||||
: { ...control, choices: control.choices.map((choice) => ({ ...choice })) };
|
||||
}
|
||||
@@ -58,6 +58,10 @@ interface ProviderHarnessOptions {
|
||||
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function controlKey(control: { key: string }): string {
|
||||
return control.key;
|
||||
}
|
||||
|
||||
function lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined {
|
||||
return inputs.findLast((input) => input.type === "session.configure");
|
||||
}
|
||||
@@ -501,6 +505,144 @@ test("combined plugin rewind replays the retained native branch", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a rewind reopens with the options as they stand, a reset included", async () => {
|
||||
const options: Record<string, boolean> = {};
|
||||
const report = (emit: (event: ProviderEvent) => void, sessionId: string) =>
|
||||
emit({
|
||||
type: "session.config",
|
||||
sessionId,
|
||||
config: {
|
||||
model: "plugin-model",
|
||||
mode: "build",
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
controls: [{ type: "toggle", key: "concise", label: "Concise" }],
|
||||
providerOptions: { ...options },
|
||||
},
|
||||
});
|
||||
const harness = createRewindProviderHarness({
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type === "session.open" && input.history !== "replay") {
|
||||
options.concise = true;
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: [...CAPABILITIES, "session.revert.conversation"],
|
||||
restoration: "core",
|
||||
persistence: { version: 1, data: { token: "root" } },
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
report(emit, input.sessionId);
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.configure") {
|
||||
// A plugin that represents the default by leaving the key out.
|
||||
delete options.concise;
|
||||
report(emit, input.sessionId);
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: tmpdir(),
|
||||
providerOptions: { concise: true },
|
||||
});
|
||||
await session.run("A");
|
||||
await session.applyOptions?.({ concise: null });
|
||||
|
||||
await session.revertConversation!({ messageId: "user-A" });
|
||||
|
||||
const reopened = harness.inputs.findLast((input) => input.type === "session.open");
|
||||
expect(reopened?.config.providerOptions).toEqual({});
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
});
|
||||
|
||||
// A plugin may report no options at all while it runs the ones it was given.
|
||||
async function withUnreportedOptionSession(
|
||||
run: (
|
||||
session: Awaited<ReturnType<AgentClient["createSession"]>>,
|
||||
harness: ReturnType<typeof createRewindProviderHarness>,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
const harness = createRewindProviderHarness({
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type === "session.open" && input.history !== "replay") {
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: [...CAPABILITIES, "session.revert.conversation"],
|
||||
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: [],
|
||||
controls: [],
|
||||
providerOptions: {},
|
||||
},
|
||||
});
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.configure") {
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: tmpdir(),
|
||||
providerOptions: { token_budget: 1000 },
|
||||
});
|
||||
try {
|
||||
await run(session, harness);
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
test("applying an option the plugin never reports leaves the stored option alone", async () => {
|
||||
await withUnreportedOptionSession(async (session) => {
|
||||
// The same value as a whole-profile patch carries it, then a new one.
|
||||
expect(await session.applyOptions?.({ token_budget: 1000 })).toEqual({});
|
||||
expect(await session.applyOptions?.({ token_budget: 2000 })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
test("a rewind reopens with the value the plugin accepted for an option it never reports", async () => {
|
||||
await withUnreportedOptionSession(async (session, harness) => {
|
||||
await session.run("A");
|
||||
await session.applyOptions?.({ token_budget: 2000 });
|
||||
|
||||
await session.revertConversation!({ messageId: "user-A" });
|
||||
|
||||
const reopened = harness.inputs.findLast((input) => input.type === "session.open");
|
||||
expect(reopened?.config.providerOptions).toEqual({ token_budget: 2000 });
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
@@ -1004,27 +1146,63 @@ describe("PluginAgentClientRegistry", () => {
|
||||
await agent.session?.close();
|
||||
});
|
||||
|
||||
describe("settings", () => {
|
||||
// The same contract holds for a plugin that reports `controls` and `providerOptions` and for one
|
||||
// still reporting `settings`.
|
||||
describe.each(["controls", "settings"] as const)("options of a plugin reporting %s", (shape) => {
|
||||
// A plugin that keeps `voice` on "direct" whatever it is asked, and takes `loud` as given.
|
||||
async function openStubbornVoiceSession(
|
||||
given: Record<string, boolean | string>,
|
||||
{ refuseAfterChanging = false }: { refuseAfterChanging?: boolean } = {},
|
||||
) {
|
||||
const state = { voice: "direct", loud: false };
|
||||
const state: { voice: string; loud: boolean; tokenBudget?: number } = {
|
||||
voice: "direct",
|
||||
loud: false,
|
||||
};
|
||||
let openedSessionId = "";
|
||||
const settings = () => [
|
||||
{
|
||||
type: "select" as const,
|
||||
id: "voice",
|
||||
label: "Voice",
|
||||
value: state.voice,
|
||||
options: [
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Other", value: "other" },
|
||||
],
|
||||
},
|
||||
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
|
||||
];
|
||||
const reported = () =>
|
||||
shape === "controls"
|
||||
? {
|
||||
controls: [
|
||||
{
|
||||
type: "select" as const,
|
||||
key: "voice",
|
||||
label: "Voice",
|
||||
icon: "Mic",
|
||||
defaultValue: "direct",
|
||||
choices: [
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Other", value: "other" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "toggle" as const,
|
||||
key: "loud",
|
||||
label: "Loud",
|
||||
icon: "Volume2",
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
providerOptions: {
|
||||
voice: state.voice,
|
||||
loud: state.loud,
|
||||
...(state.tokenBudget === undefined ? {} : { token_budget: state.tokenBudget }),
|
||||
},
|
||||
}
|
||||
: {
|
||||
settings: [
|
||||
{
|
||||
type: "select" as const,
|
||||
id: "voice",
|
||||
label: "Voice",
|
||||
value: state.voice,
|
||||
options: [
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Other", value: "other" },
|
||||
],
|
||||
},
|
||||
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
|
||||
],
|
||||
};
|
||||
const emitConfig = (emit: (event: ProviderEvent) => void, sessionId: string) =>
|
||||
emit({
|
||||
type: "session.config",
|
||||
@@ -1035,7 +1213,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
settings: settings(),
|
||||
...reported(),
|
||||
},
|
||||
});
|
||||
const harness = createProviderHarness({
|
||||
@@ -1056,8 +1234,22 @@ describe("PluginAgentClientRegistry", () => {
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.configure") {
|
||||
const loud = input.changes.settings?.loud;
|
||||
const patch =
|
||||
shape === "settings" ? input.changes.settings : input.changes.providerOptions;
|
||||
const { loud, token_budget: tokenBudget } = patch ?? {};
|
||||
// A plugin that takes `token_budget` live refuses the whole request on a bad value.
|
||||
if (tokenBudget !== undefined && typeof tokenBudget !== "number") {
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "bad budget" },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (typeof loud === "boolean") state.loud = loud;
|
||||
if (typeof tokenBudget === "number") state.tokenBudget = tokenBudget;
|
||||
// Going loud returns an unrequested token budget to its default, by leaving it out.
|
||||
if (loud === true && tokenBudget === undefined) state.tokenBudget = undefined;
|
||||
emitConfig(emit, input.sessionId);
|
||||
emit(
|
||||
refuseAfterChanging
|
||||
@@ -1096,6 +1288,47 @@ describe("PluginAgentClientRegistry", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test.runIf(shape === "controls")(
|
||||
"reports an option the plugin stops reporting as removed, alone or with another change",
|
||||
async () => {
|
||||
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
|
||||
changeOnItsOwn({ tokenBudget: 500 });
|
||||
|
||||
changeOnItsOwn({ tokenBudget: undefined });
|
||||
changeOnItsOwn({ tokenBudget: 700 });
|
||||
changeOnItsOwn({ tokenBudget: undefined, loud: true });
|
||||
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 500 } },
|
||||
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: null } },
|
||||
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 700 } },
|
||||
{
|
||||
type: "options_changed",
|
||||
provider: "plugin-direct",
|
||||
patch: { token_budget: null, loud: true },
|
||||
},
|
||||
]);
|
||||
await session.close();
|
||||
},
|
||||
);
|
||||
|
||||
test.runIf(shape === "controls")(
|
||||
"an apply returns the option the plugin reset along the way as removed",
|
||||
async () => {
|
||||
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
|
||||
changeOnItsOwn({ tokenBudget: 500 });
|
||||
|
||||
const ended = await session.applyOptions?.({ loud: true });
|
||||
|
||||
expect(ended).toEqual({ token_budget: null });
|
||||
// The apply reports it; the plugin's later identical report is not a second change.
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 500 } },
|
||||
]);
|
||||
await session.close();
|
||||
},
|
||||
);
|
||||
|
||||
test("a rejected apply reports what the plugin moved before refusing, since nothing is stored", async () => {
|
||||
const { session, events } = await openStubbornVoiceSession({}, { refuseAfterChanging: true });
|
||||
|
||||
@@ -1115,7 +1348,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("settings the plugin opened with that nobody set are in the open patch", async () => {
|
||||
test("options the plugin opened with that nobody set are in the open patch, defaults included", async () => {
|
||||
const { session, events } = await openStubbornVoiceSession({});
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
|
||||
@@ -1129,30 +1362,94 @@ describe("PluginAgentClientRegistry", () => {
|
||||
await session.applyOptions?.({ loud: true });
|
||||
|
||||
const configure = lastConfigure(harness.inputs);
|
||||
expect(configure).toMatchObject({ changes: { settings: { loud: true } } });
|
||||
expect(
|
||||
Object.keys((configure as { changes: { settings: object } }).changes.settings),
|
||||
).toEqual(["loud"]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rejects a key the plugin does not report as a setting, and configures none of a mixed patch", async () => {
|
||||
const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" });
|
||||
const configureBefore = lastConfigure(harness.inputs);
|
||||
|
||||
await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow(
|
||||
/launch_key.*cannot change on a running session/,
|
||||
// Only a plugin that reports `settings` is also sent them.
|
||||
expect((configure as { changes: object }).changes).toEqual(
|
||||
shape === "settings"
|
||||
? { providerOptions: { loud: true }, settings: { loud: true } }
|
||||
: { providerOptions: { loud: true } },
|
||||
);
|
||||
|
||||
expect(lastConfigure(harness.inputs)).toBe(configureBefore);
|
||||
|
||||
// The value it opened with is not a change, so a whole profile can be applied.
|
||||
await session.applyOptions?.({ loud: true, launch_key: "original" });
|
||||
const configure = lastConfigure(harness.inputs) as { changes: { settings: object } };
|
||||
expect(Object.keys(configure.changes.settings)).toEqual(["loud"]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a removal reaches the plugin as null", async () => {
|
||||
const { harness, session } = await openStubbornVoiceSession({});
|
||||
|
||||
const ended = await session.applyOptions?.({ loud: null });
|
||||
|
||||
expect(lastConfigure(harness.inputs)).toMatchObject({
|
||||
changes: { providerOptions: { loud: null } },
|
||||
});
|
||||
// It went back to its default, and what it ended up with is reported like any provider's.
|
||||
expect(ended).toEqual({ loud: false });
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("draws a control from what the plugin reports", async () => {
|
||||
const { session } = await openStubbornVoiceSession({});
|
||||
|
||||
expect((session.controls ?? []).map(controlKey)).toEqual(["voice", "loud"]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test.runIf(shape === "controls")(
|
||||
"keeps a control's icon, default and choices as the plugin declared them",
|
||||
async () => {
|
||||
const { session } = await openStubbornVoiceSession({});
|
||||
|
||||
expect(session.controls?.[0]).toMatchObject({
|
||||
icon: "Mic",
|
||||
defaultValue: "direct",
|
||||
choices: [
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Other", value: "other" },
|
||||
],
|
||||
});
|
||||
await session.close();
|
||||
},
|
||||
);
|
||||
|
||||
test.runIf(shape === "controls")(
|
||||
"sends a live option that has no control, and the plugin's answer decides",
|
||||
async () => {
|
||||
const { harness, session, events } = await openStubbornVoiceSession({});
|
||||
|
||||
await session.applyOptions?.({ token_budget: 2000 });
|
||||
expect(lastConfigure(harness.inputs)).toMatchObject({
|
||||
changes: { providerOptions: { token_budget: 2000 } },
|
||||
});
|
||||
|
||||
// The plugin refuses the request as a whole, so the other key it was given is not applied.
|
||||
await expect(session.applyOptions?.({ loud: true, token_budget: "lots" })).rejects.toThrow(
|
||||
"bad budget",
|
||||
);
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([]);
|
||||
expect(await session.applyOptions?.({ token_budget: 3000 })).toEqual({});
|
||||
await session.close();
|
||||
},
|
||||
);
|
||||
|
||||
test.runIf(shape === "settings")(
|
||||
"rejects a key the plugin does not report as a setting, and configures none of a mixed patch",
|
||||
async () => {
|
||||
const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" });
|
||||
const configureBefore = lastConfigure(harness.inputs);
|
||||
|
||||
await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow(
|
||||
/launch_key.*cannot change on a running session/,
|
||||
);
|
||||
|
||||
expect(lastConfigure(harness.inputs)).toBe(configureBefore);
|
||||
|
||||
// The value it opened with is not a change, so a whole profile can be applied.
|
||||
await session.applyOptions?.({ loud: true, launch_key: "original" });
|
||||
const configure = lastConfigure(harness.inputs) as {
|
||||
changes: { providerOptions: object };
|
||||
};
|
||||
expect(Object.keys(configure.changes.providerOptions)).toEqual(["loud"]);
|
||||
await session.close();
|
||||
},
|
||||
);
|
||||
|
||||
test("applying options returns the settings that ended up different, and emits nothing", async () => {
|
||||
const { session, events } = await openStubbornVoiceSession({});
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
type ProviderRuntimeSettings,
|
||||
} from "./provider-launch-config.js";
|
||||
import { assertChangesAreLive } from "./provider-options.js";
|
||||
import { readPluginOptionState } from "./plugin-provider-options.js";
|
||||
import type { RegisteredProviderDefinition } from "./provider-registry.js";
|
||||
import { runProviderTurn } from "./providers/provider-runner.js";
|
||||
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
@@ -606,7 +607,7 @@ class ProviderRuntimeSession {
|
||||
private readonly activeTurnIds = new Set<string>();
|
||||
private terminal = false;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
|
||||
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [] };
|
||||
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
|
||||
persistence: ProviderPersistence | null = null;
|
||||
|
||||
@@ -1117,7 +1118,7 @@ class PluginAgentClient implements AgentClient {
|
||||
this.provider,
|
||||
bridge,
|
||||
forget,
|
||||
async (persistence, state) => {
|
||||
async (persistence, state, options) => {
|
||||
forget();
|
||||
return await this.runtime.openSession({
|
||||
sessionId: randomUUID(),
|
||||
@@ -1126,9 +1127,8 @@ class PluginAgentClient implements AgentClient {
|
||||
model: state.model,
|
||||
mode: state.mode,
|
||||
thinkingOption: state.thinkingOption,
|
||||
settings: Object.fromEntries(
|
||||
state.settings.map((setting) => [setting.id, setting.value]),
|
||||
),
|
||||
settings: options,
|
||||
providerOptions: options,
|
||||
},
|
||||
persistence,
|
||||
history: "replay",
|
||||
@@ -1204,9 +1204,12 @@ class PluginAgentSession implements AgentSession {
|
||||
>();
|
||||
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
/** How the opened session differs from the settings it was given. */
|
||||
/** How the opened session differs from the options it was given. */
|
||||
readonly openOptionsPatch: Record<string, JsonValue>;
|
||||
private knownSettings: Record<string, JsonValue> = {};
|
||||
private knownOptions: Record<string, JsonValue> = {};
|
||||
// The options a reopened session starts with: what it launched with, then every value the
|
||||
// plugin reported or was given, minus every key that was reset.
|
||||
private readonly committedOptions: Record<string, JsonValue>;
|
||||
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
|
||||
private applyingOptions = false;
|
||||
private currentTurnId: string | null = null;
|
||||
@@ -1227,14 +1230,16 @@ class PluginAgentSession implements AgentSession {
|
||||
private readonly reopen: (
|
||||
persistence: ProviderPersistence,
|
||||
state: ProviderConfigState,
|
||||
options: Readonly<Record<string, JsonValue>>,
|
||||
) => Promise<ProviderRuntimeSession>,
|
||||
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
|
||||
private readonly givenSettings: Readonly<Record<string, JsonValue>>,
|
||||
private readonly givenOptions: Readonly<Record<string, JsonValue>>,
|
||||
) {
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
// Every setting the session opened with that was not given, including ones nobody set.
|
||||
this.openOptionsPatch = this.settingDifferences(givenSettings);
|
||||
this.knownSettings = this.currentSettings();
|
||||
this.committedOptions = { ...givenOptions };
|
||||
this.openOptionsPatch = this.optionDifferences(givenOptions);
|
||||
this.commitOptions();
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
// Opening notices arrive before the manager subscribes to the session.
|
||||
// Commit them on registration and replay the same timestamps from history.
|
||||
@@ -1258,28 +1263,8 @@ class PluginAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
/** The plugin's `settings` are its option controls; each setting id is an option key. */
|
||||
get controls(): AgentOptionControl[] {
|
||||
return this.bridge.config.settings.map(
|
||||
(setting): AgentOptionControl =>
|
||||
setting.type === "toggle"
|
||||
? {
|
||||
type: "toggle",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
}
|
||||
: {
|
||||
type: "select",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
choices: setting.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
},
|
||||
);
|
||||
return readPluginOptionState(this.bridge.config).controls;
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
@@ -1437,35 +1422,50 @@ class PluginAgentSession implements AgentSession {
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> {
|
||||
this.assertReplayComplete();
|
||||
const before = this.currentSettings();
|
||||
// A key the plugin does not report as a setting is one it only reads when the session opens;
|
||||
// a value equal to the one it opened with is not a change.
|
||||
assertChangesAreLive({
|
||||
provider: this.provider,
|
||||
patch: options,
|
||||
current: this.givenSettings,
|
||||
live: Object.keys(before),
|
||||
});
|
||||
const before = this.currentOptions();
|
||||
const { controls, legacySettings } = readPluginOptionState(this.bridge.config);
|
||||
const settings: Record<string, JsonValue> = {};
|
||||
for (const [id, value] of Object.entries(options)) {
|
||||
if (!(id in before)) continue;
|
||||
settings[id] = toJsonValue(value, `setting '${id}'`);
|
||||
// COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A plugin
|
||||
// reporting `settings` cannot refuse a key, so only its settings are sent and any other
|
||||
// changed key is rejected here; a value equal to the one it opened with is not a change.
|
||||
if (legacySettings) {
|
||||
const live = controls.map((control) => control.key);
|
||||
assertChangesAreLive({
|
||||
provider: this.provider,
|
||||
patch: options,
|
||||
current: this.givenOptions,
|
||||
live,
|
||||
});
|
||||
for (const [id, value] of Object.entries(options)) {
|
||||
if (live.includes(id)) settings[id] = toJsonValue(value, `option '${id}'`);
|
||||
}
|
||||
} else {
|
||||
// The plugin applies every key it is given or rejects the whole request.
|
||||
for (const [id, value] of Object.entries(options)) {
|
||||
settings[id] = toJsonValue(value, `option '${id}'`);
|
||||
}
|
||||
}
|
||||
if (Object.keys(settings).length === 0) return {};
|
||||
this.applyingOptions = true;
|
||||
try {
|
||||
await this.bridge.configure({ settings });
|
||||
// COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01.
|
||||
await this.bridge.configure({
|
||||
providerOptions: settings,
|
||||
...(legacySettings ? { settings } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
// Nothing is stored for a refused patch, so what the plugin moved is reported once the
|
||||
// call has ended.
|
||||
this.applyingOptions = false;
|
||||
for (const event of this.reportSettingChanges()) this.publish(event);
|
||||
for (const event of this.reportOptionChanges()) this.publish(event);
|
||||
throw error;
|
||||
} finally {
|
||||
this.applyingOptions = false;
|
||||
}
|
||||
const ended = this.settingDifferences({ ...before, ...settings });
|
||||
this.knownSettings = this.currentSettings();
|
||||
// A requested key is a request, not a report: only a key the plugin reported before can be
|
||||
// one it has now dropped.
|
||||
const ended = this.optionChanges({ ...before, ...settings }, before);
|
||||
this.commitOptions(settings);
|
||||
return ended;
|
||||
}
|
||||
|
||||
@@ -1648,28 +1648,58 @@ class PluginAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
private currentSettings(): Record<string, JsonValue> {
|
||||
return Object.fromEntries(
|
||||
this.bridge.config.settings.map((setting) => [setting.id, setting.value]),
|
||||
);
|
||||
private currentOptions(): Record<string, JsonValue> {
|
||||
return readPluginOptionState(this.bridge.config).options;
|
||||
}
|
||||
|
||||
/** Settings whose value differs from `known`: the plugin's own change or its opening state. */
|
||||
private settingDifferences(
|
||||
known: Readonly<Record<string, JsonValue>>,
|
||||
): Record<string, JsonValue> {
|
||||
/** Options whose value differs from `known`: the plugin's own change or its opening state. */
|
||||
private optionDifferences(known: Readonly<Record<string, JsonValue>>): Record<string, JsonValue> {
|
||||
const patch: Record<string, JsonValue> = {};
|
||||
for (const [id, value] of Object.entries(this.currentSettings())) {
|
||||
for (const [id, value] of Object.entries(this.currentOptions())) {
|
||||
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
private reportSettingChanges(): AgentStreamEvent[] {
|
||||
/**
|
||||
* Settle the options a reopened session starts with: what is committed, then the request the
|
||||
* plugin accepted, then what it reported back. A key it reported before and no longer reports
|
||||
* was reset.
|
||||
*/
|
||||
private commitOptions(accepted: Readonly<Record<string, JsonValue>> = {}): void {
|
||||
for (const [id, value] of Object.entries(accepted)) {
|
||||
if (value === null) delete this.committedOptions[id];
|
||||
else this.committedOptions[id] = value;
|
||||
}
|
||||
const current = this.currentOptions();
|
||||
for (const id of Object.keys(this.knownOptions)) {
|
||||
if (!(id in current)) delete this.committedOptions[id];
|
||||
}
|
||||
Object.assign(this.committedOptions, current);
|
||||
this.knownOptions = current;
|
||||
}
|
||||
|
||||
/**
|
||||
* What changed against the options `known` to be set: values that differ, and `null` for a key
|
||||
* in `reported` that the plugin no longer reports, which went back to its default.
|
||||
*/
|
||||
private optionChanges(
|
||||
known: Readonly<Record<string, JsonValue>>,
|
||||
reported: Readonly<Record<string, JsonValue>>,
|
||||
): Record<string, JsonValue> {
|
||||
const patch = this.optionDifferences(known);
|
||||
const current = this.currentOptions();
|
||||
for (const id of Object.keys(reported)) {
|
||||
if (known[id] !== null && !(id in current)) patch[id] = null;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
private reportOptionChanges(): AgentStreamEvent[] {
|
||||
if (this.applyingOptions) return [];
|
||||
const patch = this.settingDifferences(this.knownSettings);
|
||||
const patch = this.optionChanges(this.knownOptions, this.knownOptions);
|
||||
if (Object.keys(patch).length === 0) return [];
|
||||
this.knownSettings = this.currentSettings();
|
||||
this.commitOptions();
|
||||
return [{ type: "options_changed", provider: this.provider, patch }];
|
||||
}
|
||||
|
||||
@@ -1677,7 +1707,7 @@ class PluginAgentSession implements AgentSession {
|
||||
event: Extract<ProviderEvent, { type: "session.config" }>,
|
||||
): AgentStreamEvent[] {
|
||||
return [
|
||||
...this.reportSettingChanges(),
|
||||
...this.reportOptionChanges(),
|
||||
{
|
||||
type: "mode_changed",
|
||||
provider: this.provider,
|
||||
@@ -1828,7 +1858,7 @@ class PluginAgentSession implements AgentSession {
|
||||
await this.bridge.close();
|
||||
replay.closed = true;
|
||||
}
|
||||
const bridge = await this.reopen(replay.persistence, replay.config);
|
||||
const bridge = await this.reopen(replay.persistence, replay.config, this.committedOptions);
|
||||
this.bridge = bridge;
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
|
||||
@@ -214,6 +214,8 @@ const INBOUND_PERMISSION = {
|
||||
"workspace.label.delete.request": "workspace.manage",
|
||||
"workspace.label.list.request": "workspace.read",
|
||||
"workspace.label.update.request": "workspace.manage",
|
||||
"agent.metadata.update.request": "workspace.write",
|
||||
"workspace.metadata.update.request": "workspace.manage",
|
||||
"workspace.pin.set.request": "workspace.manage",
|
||||
"workspace.recovery.inspect.request": ["workspace.read", "hub.execute"],
|
||||
"workspace.recovery.restore.request": ["workspace.manage", "hub.execute"],
|
||||
@@ -453,6 +455,8 @@ const OUTBOUND_PERMISSION = {
|
||||
"workspace.label.list.response": "workspace.read",
|
||||
"workspace.label.update": "workspace.read",
|
||||
"workspace.label.update.response": "workspace.manage",
|
||||
"agent.metadata.update.response": "workspace.write",
|
||||
"workspace.metadata.update.response": "workspace.manage",
|
||||
"workspace.pin.set.response": "workspace.manage",
|
||||
"workspace.recovery.inspect.response": ["workspace.read", "hub.execute"],
|
||||
"workspace.recovery.restore.response": ["workspace.manage", "hub.execute"],
|
||||
|
||||
@@ -1109,6 +1109,7 @@ test("disabled recovery closes stale active records without resuming sessions",
|
||||
lastStatus: status as StoredAgentRecord["lastStatus"],
|
||||
title: `Keep ${status}`,
|
||||
labels: { "paseo.parent-agent-id": "parent", custom: "keep" },
|
||||
metadata: {},
|
||||
persistence: { provider: "codex", sessionId: `session-${index}` },
|
||||
config: { modeId: "full-access" },
|
||||
requiresAttention: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginLifecycle } from "./plugins/lifecycle/index.js";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
@@ -34,6 +35,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
workspaceId?: string;
|
||||
projectId?: string;
|
||||
title?: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
}
|
||||
@@ -113,6 +115,7 @@ async function createPaseoWorktreeWithPriority(
|
||||
branch: createdWorktree.worktree.branchName || null,
|
||||
baseBranch: createdWorktree.worktree.comparisonBaseRef,
|
||||
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
metadata: input.metadata,
|
||||
expectsInitialAgent: Boolean(input.firstAgentContext),
|
||||
background: input.background,
|
||||
callerWorkspaceId: input.callerWorkspaceId,
|
||||
|
||||
@@ -137,6 +137,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
updatedAt: Date;
|
||||
lastUserMessageAt: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
metadata?: StoredAgentRecord["metadata"];
|
||||
workspaceId?: string;
|
||||
owner?: StoredAgentRecord["owner"];
|
||||
} {
|
||||
@@ -145,6 +146,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
updatedAt: new Date(resolveStoredAgentUpdatedAt(record)),
|
||||
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
|
||||
labels: record.labels,
|
||||
metadata: record.metadata,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { resolveDaemonVersion } from "../daemon-version.js";
|
||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
|
||||
test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
|
||||
@@ -240,6 +241,193 @@ export default function contribute(server) {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("metadata set at creation reaches the hooks, a before hook can add to it, and snapshots carry it", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-hooks-"));
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "metadata-hooks",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
export default function contribute(server) {
|
||||
const log = (hook, metadata) => console.log(JSON.stringify({ hook, metadata }));
|
||||
server.addHook("workspace.create.before", async ({ request }) => {
|
||||
log("workspace.create.before", request.metadata);
|
||||
return request;
|
||||
});
|
||||
server.addHook("workspace.create.after", async ({ workspace }) => {
|
||||
log("workspace.create.after", workspace.metadata);
|
||||
});
|
||||
server.addHook("agent.create.before", async ({ request }) => {
|
||||
log("agent.create.before", request.metadata);
|
||||
return { ...request, metadata: { ...request.metadata, "hook.added": true } };
|
||||
});
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
log("agent.open.before", request.metadata);
|
||||
return request;
|
||||
});
|
||||
server.addHook("agent.create.after", async ({ agent }) => {
|
||||
log("agent.create.after", agent.metadata);
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
const workspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: directory },
|
||||
metadata: { "linear.issue": { id: "ENG-123" } },
|
||||
});
|
||||
expect(workspace.workspace?.metadata).toEqual({ "linear.issue": { id: "ENG-123" } });
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: directory,
|
||||
workspaceId: workspace.workspace!.id,
|
||||
metadata: { "linear.issue": { id: "ENG-123" } },
|
||||
});
|
||||
expect(agent.metadata).toEqual({ "linear.issue": { id: "ENG-123" }, "hook.added": true });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const logs = await client.getPluginLogs("metadata-hooks");
|
||||
return logs
|
||||
.filter((entry) => entry.message.startsWith('{"hook":'))
|
||||
.map((entry) => JSON.parse(entry.message));
|
||||
})
|
||||
.toMatchObject([
|
||||
{ hook: "workspace.create.before", metadata: { "linear.issue": { id: "ENG-123" } } },
|
||||
{ hook: "workspace.create.after", metadata: { "linear.issue": { id: "ENG-123" } } },
|
||||
{ hook: "agent.create.before", metadata: { "linear.issue": { id: "ENG-123" } } },
|
||||
{
|
||||
hook: "agent.open.before",
|
||||
metadata: { "linear.issue": { id: "ENG-123" }, "hook.added": true },
|
||||
},
|
||||
{
|
||||
hook: "agent.create.after",
|
||||
metadata: { "linear.issue": { id: "ENG-123" }, "hook.added": true },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("metadata set at creation and through setMetadata survives a daemon restart", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-metadata-restart-"));
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-cwd-"));
|
||||
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-plugin-"));
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "metadata-restart",
|
||||
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
|
||||
}),
|
||||
);
|
||||
// Returns an explicit object without `metadata`, as a plugin written before metadata existed.
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "index.server.ts"),
|
||||
`
|
||||
export default function contribute(server) {
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
console.log(JSON.stringify({ hook: "agent.open.before", metadata: request.metadata }));
|
||||
const { metadata, ...old } = request;
|
||||
return { ...old, env: { ...old.env, HOOK_OPEN: "opened" } };
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
const firstClient = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws` });
|
||||
const firstApi = createPaseoApi(firstClient);
|
||||
let agentId: string;
|
||||
let workspaceId: string;
|
||||
try {
|
||||
await firstClient.connect();
|
||||
await firstClient.fetchAgents({ subscribe: {} });
|
||||
await firstClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await firstClient.installDirectoryPlugin(pluginDirectory);
|
||||
const workspace = await firstClient.createWorkspace({
|
||||
source: { kind: "directory", path: directory },
|
||||
metadata: { "a.workspace": 1, "b.workspace": "to remove" },
|
||||
});
|
||||
workspaceId = workspace.workspace!.id;
|
||||
const agent = await firstClient.createAgent({
|
||||
provider: "claude",
|
||||
cwd: directory,
|
||||
workspaceId,
|
||||
metadata: { "a.agent": { nested: [1, 2] }, "b.agent": "to remove" },
|
||||
});
|
||||
agentId = agent.id;
|
||||
const agentHandle = firstApi.agents.ref(agentId);
|
||||
await agentHandle.setMetadata({ "b.agent": null, "c.agent": true });
|
||||
await firstApi.workspaces.ref(workspaceId).setMetadata({
|
||||
"b.workspace": null,
|
||||
"c.workspace": [],
|
||||
});
|
||||
expect((await firstClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
|
||||
"a.agent": { nested: [1, 2] },
|
||||
"c.agent": true,
|
||||
});
|
||||
} finally {
|
||||
await firstApi.dispose();
|
||||
await firstClient.close();
|
||||
await first.close();
|
||||
}
|
||||
|
||||
const second = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
|
||||
const secondClient = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws` });
|
||||
try {
|
||||
await secondClient.connect();
|
||||
const agent = await secondClient.fetchAgent({ agentId });
|
||||
expect(agent?.agent.metadata).toEqual({ "a.agent": { nested: [1, 2] }, "c.agent": true });
|
||||
const workspaces = await secondClient.fetchWorkspaces({});
|
||||
expect(workspaces.entries.find((entry) => entry.id === workspaceId)?.metadata).toEqual({
|
||||
"a.workspace": 1,
|
||||
"c.workspace": [],
|
||||
});
|
||||
await secondClient.fetchAgents({ subscribe: {} });
|
||||
await secondClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await secondClient.installDirectoryPlugin(pluginDirectory);
|
||||
await secondClient.refreshAgent(agentId);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const logs = await secondClient.getPluginLogs("metadata-restart");
|
||||
return logs
|
||||
.filter((entry) => entry.message.startsWith('{"hook":'))
|
||||
.map((entry) => JSON.parse(entry.message));
|
||||
})
|
||||
.toContainEqual({
|
||||
hook: "agent.open.before",
|
||||
metadata: { "a.agent": { nested: [1, 2] }, "c.agent": true },
|
||||
});
|
||||
expect((await secondClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
|
||||
"a.agent": { nested: [1, 2] },
|
||||
"c.agent": true,
|
||||
});
|
||||
} finally {
|
||||
await secondClient.close();
|
||||
await second.close();
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await rm(pluginDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("agent creation hooks change the provider and environment before the session opens", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-hooks-"));
|
||||
const pluginDirectory = await createProviderPlugin(directory, {
|
||||
|
||||
@@ -92,6 +92,7 @@ test("session-open hooks reject changes to session identity instead of silently
|
||||
env: {},
|
||||
title: null,
|
||||
labels: {},
|
||||
metadata: {},
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
},
|
||||
@@ -100,6 +101,105 @@ test("session-open hooks reject changes to session identity instead of silently
|
||||
).rejects.toThrow("agent.open.before hooks can only change env");
|
||||
});
|
||||
|
||||
test("a create hook that omits metadata passes on what an earlier hook added", async () => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
hooks.addHook("agent.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
metadata: { ...request.metadata, "new.plugin": { ticket: 42 } },
|
||||
}));
|
||||
hooks.addHook("agent.create.before", async ({ request }) => ({
|
||||
config: request.config,
|
||||
env: request.env,
|
||||
}));
|
||||
const seen: unknown[] = [];
|
||||
hooks.addHook("agent.create.before", async ({ request }) => {
|
||||
seen.push(request.metadata);
|
||||
});
|
||||
const result = await hooks.invoke(
|
||||
"operation",
|
||||
"agent.create.before",
|
||||
{ config: { provider: "codex", cwd: "/project" }, env: {}, metadata: { original: true } },
|
||||
paseo,
|
||||
);
|
||||
expect(result).toMatchObject({ metadata: { original: true, "new.plugin": { ticket: 42 } } });
|
||||
expect(seen).toEqual([{ original: true, "new.plugin": { ticket: 42 } }]);
|
||||
});
|
||||
|
||||
test("a workspace create hook that omits metadata passes on what an earlier hook added", async () => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
hooks.addHook("workspace.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
metadata: { ...request.metadata, "new.plugin": { ticket: 42 } },
|
||||
}));
|
||||
hooks.addHook("workspace.create.before", async ({ request }) => ({ source: request.source }));
|
||||
const seen: unknown[] = [];
|
||||
hooks.addHook("workspace.create.before", async ({ request }) => {
|
||||
seen.push(request.metadata);
|
||||
});
|
||||
const result = await hooks.invoke(
|
||||
"operation",
|
||||
"workspace.create.before",
|
||||
{ source: { kind: "directory", path: "/project" }, metadata: { original: true } },
|
||||
paseo,
|
||||
);
|
||||
expect(result).toMatchObject({ metadata: { original: true, "new.plugin": { ticket: 42 } } });
|
||||
expect(seen).toEqual([{ original: true, "new.plugin": { ticket: 42 } }]);
|
||||
|
||||
const replacing = new PluginHookHandlers(() => {});
|
||||
replacing.addHook("workspace.create.before", async ({ request }) => ({
|
||||
...request,
|
||||
metadata: {},
|
||||
}));
|
||||
await expect(
|
||||
replacing.invoke(
|
||||
"operation",
|
||||
"workspace.create.before",
|
||||
{ source: { kind: "directory", path: "/project" }, metadata: { original: true } },
|
||||
paseo,
|
||||
),
|
||||
).resolves.toMatchObject({ metadata: {} });
|
||||
});
|
||||
|
||||
test("a session-open hook that returns its own object without metadata leaves metadata unchanged", async () => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
hooks.addHook("agent.open.before", async ({ request }) => ({
|
||||
agentId: request.agentId,
|
||||
workspaceId: request.workspaceId,
|
||||
provider: request.provider,
|
||||
cwd: request.cwd,
|
||||
reason: request.reason,
|
||||
purpose: request.purpose,
|
||||
title: request.title,
|
||||
labels: request.labels,
|
||||
tools: request.tools,
|
||||
env: { HOOK: "1" },
|
||||
mcpServers: request.mcpServers,
|
||||
}));
|
||||
const result = await hooks.invoke(
|
||||
"operation",
|
||||
"agent.open.before",
|
||||
{
|
||||
agentId: "agent",
|
||||
workspaceId: null,
|
||||
provider: "codex",
|
||||
cwd: "/project",
|
||||
title: null,
|
||||
labels: {},
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
env: {},
|
||||
reason: "resume",
|
||||
purpose: "interactive",
|
||||
},
|
||||
paseo,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
env: { HOOK: "1" },
|
||||
metadata: { "linear.issue": { id: "ENG-1" } },
|
||||
});
|
||||
});
|
||||
|
||||
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
|
||||
"session-open targeting metadata is read-only: %j",
|
||||
async (patch) => {
|
||||
@@ -116,6 +216,7 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
|
||||
cwd: "/project",
|
||||
title: "Original",
|
||||
labels: {},
|
||||
metadata: {},
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
env: {},
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { AgentToolDefinition } from "@getpaseo/plugin/server";
|
||||
import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js";
|
||||
import { z } from "zod";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
PluginHookAgent,
|
||||
@@ -91,6 +92,7 @@ const beforeSchemas = {
|
||||
"agent.create.before": CreateAgentRequestMessageSchema.pick({
|
||||
config: true,
|
||||
env: true,
|
||||
metadata: true,
|
||||
}).strict(),
|
||||
"agent.open.before": z
|
||||
.object({
|
||||
@@ -102,6 +104,8 @@ const beforeSchemas = {
|
||||
purpose: z.enum(["interactive", "history"]),
|
||||
title: z.string().nullable(),
|
||||
labels: z.record(z.string(), z.string()),
|
||||
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()).optional(),
|
||||
tools: z.array(agentToolMetadataSchema),
|
||||
env: z.record(z.string(), z.string()),
|
||||
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
|
||||
@@ -152,6 +156,7 @@ export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): Plug
|
||||
cwd: workspace.cwd,
|
||||
name: workspace.title,
|
||||
archivedAt: workspace.archivedAt,
|
||||
metadata: workspace.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,6 +167,7 @@ export function describeHookAgent(agent: {
|
||||
cwd: string;
|
||||
title?: string | null;
|
||||
labels: Record<string, string>;
|
||||
metadata: Record<string, JsonValue>;
|
||||
}): PluginHookAgent {
|
||||
return {
|
||||
id: agent.id,
|
||||
@@ -170,6 +176,7 @@ export function describeHookAgent(agent: {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
title: agent.title ?? null,
|
||||
metadata: agent.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,10 +236,12 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
|
||||
previous.reason !== next.reason ||
|
||||
previous.purpose !== next.purpose ||
|
||||
previous.title !== next.title ||
|
||||
!isDeepStrictEqual(previous.labels, next.labels)
|
||||
!isDeepStrictEqual(previous.labels, next.labels) ||
|
||||
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
|
||||
) {
|
||||
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
|
||||
}
|
||||
return { ...result, metadata: previous.metadata };
|
||||
}
|
||||
if (name === "agent.create.before") {
|
||||
const previous = beforeSchemas["agent.create.before"].parse(input);
|
||||
@@ -240,6 +249,14 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
|
||||
if (previous.config.cwd !== next.config.cwd) {
|
||||
throw new Error("agent.create.before hooks cannot change the workspace directory");
|
||||
}
|
||||
// Omitted means unchanged: the next hook sees what this one was handed.
|
||||
return { ...result, metadata: next.metadata ?? previous.metadata };
|
||||
}
|
||||
if (name === "workspace.create.before") {
|
||||
const previous = beforeSchemas["workspace.create.before"].parse(input);
|
||||
const next = beforeSchemas["workspace.create.before"].parse(result);
|
||||
// Omitted means unchanged; an explicit {} still replaces.
|
||||
return { ...result, metadata: next.metadata ?? previous.metadata };
|
||||
}
|
||||
if (name === "worktree.create.before") {
|
||||
const previous = beforeSchemas["worktree.create.before"].parse(input);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginHookAgent } from "@getpaseo/plugin/server";
|
||||
import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage";
|
||||
import type {
|
||||
@@ -157,6 +158,7 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
|
||||
provider: z.string(),
|
||||
cwd: z.string(),
|
||||
title: z.string().nullable(),
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()),
|
||||
})
|
||||
.strict(),
|
||||
name: z.string(),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Top-level keys replace; a null value removes the key. */
|
||||
export function applyRecordPatch<Value>(
|
||||
record: Record<string, Value>,
|
||||
patch: Record<string, Value | null>,
|
||||
): Record<string, Value> {
|
||||
const next = { ...record };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -85,6 +85,7 @@ import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-regist
|
||||
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import { applyRecordPatch } from "./record-patch.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import type { ProjectUpdate } from "./workspace-reconciliation-service.js";
|
||||
import {
|
||||
@@ -3093,6 +3094,10 @@ export class Session {
|
||||
return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId);
|
||||
case "workspace.pin.set.request":
|
||||
return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId);
|
||||
case "agent.metadata.update.request":
|
||||
return this.handleAgentMetadataUpdateRequest(msg);
|
||||
case "workspace.metadata.update.request":
|
||||
return this.handleWorkspaceMetadataUpdateRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -3942,6 +3947,79 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAgentMetadataUpdateRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "agent.metadata.update.request" }>,
|
||||
): Promise<void> {
|
||||
const { agentId, patch, requestId } = request;
|
||||
this.sessionLogger.info({ agentId, requestId }, "session: agent.metadata.update.request");
|
||||
try {
|
||||
const metadata = await this.agentManager.setMetadata(agentId, patch);
|
||||
this.emit({
|
||||
type: "agent.metadata.update.response",
|
||||
payload: { requestId, agentId, accepted: true, metadata, error: null },
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId, requestId },
|
||||
"session: agent.metadata.update.request error",
|
||||
);
|
||||
this.emit({
|
||||
type: "agent.metadata.update.response",
|
||||
payload: {
|
||||
requestId,
|
||||
agentId,
|
||||
accepted: false,
|
||||
metadata: null,
|
||||
error: getErrorMessageOr(error, "Failed to update agent metadata"),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspaceMetadataUpdateRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.metadata.update.request" }>,
|
||||
): Promise<void> {
|
||||
const { workspaceId, patch, requestId } = request;
|
||||
this.sessionLogger.info(
|
||||
{ workspaceId, requestId },
|
||||
"session: workspace.metadata.update.request",
|
||||
);
|
||||
const reject = (error: string) =>
|
||||
this.emit({
|
||||
type: "workspace.metadata.update.response",
|
||||
payload: { requestId, workspaceId, accepted: false, metadata: null, error },
|
||||
});
|
||||
try {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const updated = await this.workspaceRegistry.update(workspaceId, (existing) => ({
|
||||
...existing,
|
||||
metadata: applyRecordPatch(existing.metadata, patch),
|
||||
updatedAt,
|
||||
}));
|
||||
if (!updated) {
|
||||
reject("Workspace not found");
|
||||
return;
|
||||
}
|
||||
this.emit({
|
||||
type: "workspace.metadata.update.response",
|
||||
payload: {
|
||||
requestId,
|
||||
workspaceId,
|
||||
accepted: true,
|
||||
metadata: updated.metadata,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId]);
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, workspaceId, requestId },
|
||||
"session: workspace.metadata.update.request error",
|
||||
);
|
||||
reject(getErrorMessageOr(error, "Failed to update workspace metadata"));
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspacePinSetRequest(
|
||||
workspaceId: string,
|
||||
pinned: boolean,
|
||||
@@ -4510,6 +4588,7 @@ export class Session {
|
||||
images,
|
||||
attachments,
|
||||
labels: resolvedIntent.intent.labels,
|
||||
metadata: msg.metadata,
|
||||
env,
|
||||
provisionalTitle,
|
||||
firstAgentContext,
|
||||
@@ -5710,6 +5789,7 @@ export class Session {
|
||||
title: workspace.title,
|
||||
pinnedAt: workspace.pinnedAt,
|
||||
...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}),
|
||||
metadata: workspace.metadata,
|
||||
background: workspace.background,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
@@ -5808,6 +5888,7 @@ export class Session {
|
||||
...(result.workspace.labels && result.workspace.labels.length > 0
|
||||
? { labels: result.workspace.labels }
|
||||
: {}),
|
||||
metadata: result.workspace.metadata,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: result.workspace.createdAt,
|
||||
@@ -6870,6 +6951,7 @@ export class Session {
|
||||
{
|
||||
expectsInitialAgent: Boolean(request.firstAgentContext),
|
||||
workspaceId,
|
||||
metadata: request.metadata,
|
||||
background: request.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
|
||||
},
|
||||
@@ -6934,6 +7016,7 @@ export class Session {
|
||||
githubPrNumber: source.githubPrNumber,
|
||||
firstAgentContext: request.firstAgentContext,
|
||||
title: request.title,
|
||||
metadata: request.metadata,
|
||||
background: request.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
|
||||
},
|
||||
|
||||
+6
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginLifecycle } from "../../plugins/lifecycle/index.js";
|
||||
import { describeHookWorkspace } from "../../plugins/lifecycle/index.js";
|
||||
import { basename, resolve } from "node:path";
|
||||
@@ -50,6 +51,7 @@ export interface CreateWorktreeWorkspaceInput {
|
||||
branch: string | null;
|
||||
baseBranch: string | null;
|
||||
title: string | null;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
expectsInitialAgent?: boolean;
|
||||
untrustedSource?: UntrustedWorkspaceSource;
|
||||
background?: boolean;
|
||||
@@ -70,6 +72,7 @@ export interface WorkspaceProvisioningService {
|
||||
context?: {
|
||||
expectsInitialAgent?: boolean;
|
||||
workspaceId?: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
},
|
||||
@@ -242,6 +245,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
context?: {
|
||||
expectsInitialAgent?: boolean;
|
||||
workspaceId?: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
},
|
||||
@@ -258,6 +262,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
projectId: project.projectId,
|
||||
...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }),
|
||||
title: title?.trim() || null,
|
||||
metadata: context?.metadata,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
background: await resolveBackground(context),
|
||||
@@ -294,6 +299,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
mainRepoRoot: repoRoot,
|
||||
}),
|
||||
title: input.title,
|
||||
metadata: input.metadata,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}),
|
||||
|
||||
@@ -2012,6 +2012,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
usageSources: true,
|
||||
ownedSubscriptions: true,
|
||||
providerOptionControls: true,
|
||||
entityMetadata: true,
|
||||
binaryChunkingV1: true,
|
||||
agentRequestReceipts: true,
|
||||
workspaceRequestReceipts: true,
|
||||
|
||||
@@ -1100,6 +1100,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
baseBranch: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
metadata: {},
|
||||
createdAt: timestamp,
|
||||
updatedAt: expect.any(String),
|
||||
archivedAt: expect.any(String),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
@@ -101,6 +102,8 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
labels: z.array(z.string()).optional(),
|
||||
// Plugin-owned data. Paseo stores it and never reads a key.
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()).default({}),
|
||||
untrustedSource: UntrustedWorkspaceSourceSchema.optional(),
|
||||
// Public discovery visibility. Contents retain their ordinary lifecycle.
|
||||
background: z.boolean().optional().default(false),
|
||||
@@ -678,6 +681,7 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
autoArchivedChangeRequestUrl?: string | null;
|
||||
pinnedAt?: string | null;
|
||||
labels?: string[];
|
||||
metadata?: Record<string, JsonValue>;
|
||||
untrustedSource?: UntrustedWorkspaceSource;
|
||||
background?: boolean;
|
||||
}): PersistedWorkspaceRecord {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user