Compare commits

...
Author SHA1 Message Date
Mohamed Boudra a5fa8a5d83 fix(plugins): a built-in select in the compact sheet keeps its action name
The shared control named every icon-only select by its value ("Speed: Fast"),
including in the compact sheet, where the row already shows the value. The sheet
row is named by its action again ("Select speed"), as before the shared renderer.
2026-11-02 07:53:25 +01:00
Mohamed Boudra ca178b6578 fix(plugins): attachment commands honor the composer lock
The creation request captures the attachments when it starts, so an attachment a plugin added or
removed afterwards was lost or left in the request. They refuse like the prompt commands.
2026-11-02 07:20:56 +01:00
Mohamed Boudra b965a3db7c fix(plugins): a kept command honors the composer lock, and useAgent metadata is frozen
A plugin action that awaits an RPC holds the composer it was created with. The lock was captured
at that render, so a submit that started meanwhile did not stop its insertText. Commands now ask
the composer whether it is locked when they run.

The agent snapshot a plugin gets from useAgent froze only the outer metadata record. It now uses
the same deep frozen copy as the composer.
2026-11-02 07:14:20 +01:00
Mohamed Boudra c1f64d45e1 fix(plugins): draft metadata survives a remount, plugin commands follow the composer lock
Draft metadata was component state while the prompt lived in the draft store, so a composer that
remounted across the compact breakpoint kept its text and lost the plugin's choices. It now lives
in the draft store beside the prompt, is cleared when the draft is sent, and is not cleared when
the prompt is emptied. It is not persisted across app restarts.

While the New workspace composer is creating the agent, the built-in controls are disabled but
plugin controls and commands were not. They read the composer's own lock: Paseo controls render
disabled, and setPrompt, insertText and the agent commands refuse.

The options and metadata a plugin reads are frozen copies, through the same helper the timeline
projection uses. The composer control test mounted components in JSDOM, which the testing rules
ban; it now runs in the browser project.
2026-11-02 06:56:13 +01:00
Mohamed Boudra aa558ab845 fix(plugins): composer option resets and model checks match the documented commands
A draft kept `null` as an option value, so `setOptions({ key: null })` sent the null to the
provider and creation failed. A null edit now removes the key from every draft layer, as the patch
does on a running agent. A running agent's `setModel` accepted ids its catalog does not list; it
now rejects them the way a draft does.

The composer controls imported the toast host through the toast context, which pulled the host's
animation module into every module that loads plugins. They use the API-only context instead.
The migration guide's registration table gets the `addComposerItem` row its test requires.
2026-11-02 06:45:57 +01:00
Mohamed Boudra a2b76f77f3 test(plugins): lock draft option ownership without a subscription, name the overflow-sheet steps
The draft option test subscribed the options model to the form, which in the
app happens in a render effect, so it passed without the model observing the
form's provider itself. A case with no subscription now fails when that
observation is removed.

The overflow-sheet spec reads as steps through a helper.
2026-11-02 06:09:27 +01:00
Mohamed Boudra ad88797f8d test(plugins): composer commands tested on the models, overflow sheet in Playwright
The draft composer's command rules (unavailable provider, unlisted model,
edits belonging to the provider the form has now) moved from the React hooks
into the form store and the draft options model. They take the host's catalog
as a plain input, so the command tests run on the real models with no module
mocks and no renderer.

The overflow-sheet test faked the portal it was meant to verify. It is
replaced by a Playwright spec that installs the composer-controls example and
checks its rows in the narrow composer's Features sheet; it fails when the
sheet's context bridge is removed.
2026-11-02 06:06:55 +01:00
Mohamed Boudra d92b16afbf fix(plugins): composer commands run on current state, report failures, and reach the overflow sheet
Draft commands read the state captured at render, so a plugin's
`await setProvider(); await setModel()` acted on a stale provider. The draft
form and options are now plain stores that commands read live, and
`setProvider`/`setModel` reject an unavailable provider or a model the loaded
catalog does not list. Draft `options` include the plugin's pending edits.

Shared composer controls own the pending state and show a rejected command as
an error toast, like the built-in controls. Callback payloads are objects
(`{ value }`, `onPress(event)`) so they can grow without breaking plugins.

The overflow sheet's content portals to the app root and lost the composer's
agent, input, toast and query contexts, so plugin items drew nothing in it.
`useComposerSheetBridge` carries them across.
2026-11-02 05:59:32 +01:00
Mohamed Boudra abd4d2c1ea feat(plugins): composer items and controls in all three composers
A plugin registers a component with addComposerItem and reads and changes the
composer through useComposer(): provider, model, mode, thinking, options,
prompt, attachments and agent metadata. The agent, draft and New workspace
composers provide the same ComposerInput and ComposerAgent halves, so a
command reaches the right model without the plugin knowing which composer
it sits in.

Toggle, Select and Action are drawn by one ComposerControl renderer that
the thinking select and provider option controls now use too. Controls sit in
the toolbar when there is room and become rows in the existing overflow sheet
otherwise, so on a phone they appear in the overflow. Plugin items count in
the density presence so the composer condenses to make room. Model and mode
stay specialized but share the trigger, density and overflow.

Draft metadata is kept locally, sent at creation and reset when the prompt is
sent. PluginAgentSnapshot.metadata, deferred from the metadata phase, lands
here. The unused provider badge is removed.

vitest resolves @floating-ui/react-native to its ESM build: the CJS entry
requires the real react-native, whose Flow sources do not parse.
2026-11-02 05:36:04 +01:00
Mohamed Boudra 77f90d3460 docs(plugins): the hook result comment names metadata as the field that stays 2026-11-02 05:09:28 +01:00
Mohamed Boudra 7a651fceb3 test(server): fixtures for agents and workspaces carry metadata
Agent snapshots and workspace records now always carry metadata, so the
hand-built fixtures and expectations in three existing suites name it.
2026-11-02 04:09:28 +01:00
Mohamed Boudra 4095d3784d fix(plugins): omitted metadata in a workspace.create.before result means unchanged
Same handoff as agent.create.before: a legacy hook that returned only
{ source, ... } dropped the caller's metadata and anything an earlier
hook added, and workspace provisioning then defaulted it to {}. The
omitted field is carried forward at each handoff; an explicit {} still
replaces.
2026-11-02 03:50:36 +01:00
Mohamed Boudra 21365c571b fix(plugins): omitted metadata in a hook result means unchanged
An agent.create.before hook that returned its own { config, env } dropped
metadata that an earlier hook in the chain had added, because only the
caller's original metadata was restored at the end. The omitted field is
now carried forward at each hook handoff, as agent.open.before already
does.

The agent.open.before result type also required metadata, so an older
TypeScript plugin that returned its own object no longer compiled even
though the daemon accepted it. The result type now makes it optional.
2026-11-02 03:42:12 +01:00
Mohamed Boudra f4dea7413e fix(plugins): an agent.open.before result may omit metadata
The result schema required metadata, so a plugin written before metadata
existed that returns its own object instead of spreading the request
failed validation and the session open failed, even for agents with no
metadata. An omitted metadata now means unchanged and is filled back in
from the request so later plugins in the chain still see it; a present
value that differs is still rejected.

The restart test now sets metadata through the PaseoApi handles and
checks that agent.open.before sees the patched value after the restart.
2026-11-02 03:27:23 +01:00
Mohamed Boudra 6bf7a47a96 feat(plugins): agents and workspaces carry plugin metadata
Plugins had nowhere to keep their own data on an agent or workspace, so
Phase 5 and 6 surfaces would each have invented a side store. Both now
hold an opaque Record<string, JsonValue> that Paseo stores, snapshots and
hands to hooks without reading a key.

Set at creation or changed with agent.metadata.update.request /
workspace.metadata.update.request (top-level keys replace, null removes).
Persisted on the agent and workspace records, so it survives a restart.
Reaches agent.create.before (which may add to it), agent.open.before
(read-only), agent.create.after and workspace.create.before/after.

Gated once on server_info.features.entityMetadata; the client throws
"Update the host" instead of sending to a host without it.
2026-11-02 03:21:11 +01:00
Mohamed Boudra d0a5d4d970 fix(server): an apply never reads a requested option as one the plugin dropped
A plugin may run an option it never reports. Only a key it reported before can be
reported as removed, and a rewind reopens with the accepted request plus what the
plugin reported back.
2026-11-02 01:43:01 +01:00
Mohamed Boudra 377b06be0d fix(server): an apply reports the options the plugin reset along the way
Applying and unsolicited reports now share one conversion from the plugin's
report to a patch, so a key the plugin stops reporting is a removal on both.
2026-11-02 01:10:56 +01:00
Mohamed Boudra 25a3efe0ac fix(server): a plugin that stops reporting an option has reset it
The adapter only visited keys present in a new report, so a key the plugin
dropped to return to its default kept its stored value and its rewind value.
A previously reported key that disappears is now reported as removed.
2026-11-02 01:01:24 +01:00
Mohamed Boudra 5b4636b856 fix(server): a rewound plugin session reopens with its options as they stand
The reopen merged the plugin's reported options over the launch record, so a
key the plugin reset by leaving it out came back at its launch value. The
session now keeps the committed options, removals included. The provider-direct
example also refuses a combined request before changing anything.
2026-11-02 00:52:17 +01:00
Mohamed Boudra 00ec4bc80c fix(server): a plugin decides which options it applies live
A plugin reporting `controls` and `providerOptions` receives every key a live
change touches, with or without a control, and applies them or rejects the
whole request. The daemon's live-key guard now runs only for a plugin still
reporting `settings`, which cannot refuse a key.
2026-11-02 00:34:19 +01:00
Mohamed Boudra 970c281daf fix(server): a plugin provider's reported options are stored like any provider's
A control's `defaultValue` only says what the control shows for a key with no
value. Opening and applying store every option the session reports, defaults
and values nobody set included, as built-in providers do; the special case that
left a value at the default unset is removed.
2026-11-02 00:31:08 +01:00
Mohamed Boudra 389db81784 feat(plugin): provider plugins declare option controls and receive option changes
A provider plugin reports `controls` and the `providerOptions` it runs in
`session.config`, and receives running-session changes as
`session.configure` `changes.providerOptions` (null returns a key to its
default), under the same apply-or-throw contract as built-in providers.
A control may declare a `defaultValue`; a value a plugin reports at that
default for a key nobody set stays unset instead of being pinned to the agent.

A plugin that still reports `settings` keeps working: the daemon reads each
setting as a control plus an option and sends changes in both shapes
(COMPAT(plugin-provider-settings)).

The app resolves a control's icon as a Lucide name when it is not one of the
built-in kebab-case names, so a plugin's icon shows live and in drafts.
Public docs and the provider-direct example use only `providerOptions` and
controls; the migration doc gains the rows.
2026-11-02 00:29:50 +01:00
117 changed files with 4668 additions and 1138 deletions
+2
View File
@@ -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
View File
@@ -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);
});
});
+5 -1
View File
@@ -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,
+57 -12
View File
@@ -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 },
});
});
});
+15
View File
@@ -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 },
};
}
+89
View File
@@ -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);
}
+59 -52
View File
@@ -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)),
};
}
+32
View File
@@ -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",
+17
View File
@@ -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,
+12
View File
@@ -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;
}
+5
View File
@@ -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";
+1
View File
@@ -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: [],
+1
View File
@@ -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 = {
+3
View File
@@ -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();
});
});
+2
View File
@@ -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",
},
+8
View File
@@ -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).
+29
View File
@@ -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();
+52
View File
@@ -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;
+25 -1
View File
@@ -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);
},
+41
View File
@@ -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.
+78
View File
@@ -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;
}
+2
View File
@@ -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
View File
@@ -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,
+7
View File
@@ -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],
);
}
+32
View File
@@ -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>;
+2
View File
@@ -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 {
+20 -2
View File
@@ -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 } };
+74 -2
View File
@@ -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
+54
View File
@@ -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;
}
+83
View File
@@ -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),
},
@@ -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