feat(plugins): reach a running agent's composer by reference

useComposers(selector) in any plugin component and composers(selector) on every command context return the composer of a running agent, named by id or by focus, whether or not its tab is open.

A running agent now has one store-backed composer model apart from any mounted tab. The mounted composer plugs its cursor-aware insert in while mounted; otherwise insertText and setPrompt write the agent's saved prompt. model and thinking resolve through the provider catalog with the picker's own function, so a read equals what the picker shows.

The host reads one ComposerModel from context and no longer knows whether it is in an agent, a draft or New workspace. The daemon rejects an unlisted model or thinking option for a running agent, replacing the app-side guard.
This commit is contained in:
Mohamed Boudra
2026-11-05 23:25:42 +01:00
parent 03580ce50b
commit f665cc697c
42 changed files with 1621 additions and 272 deletions
@@ -0,0 +1,50 @@
import { test } from "../support/fixtures";
import {
CHECKLIST,
withComposerToolsExample,
withPluginWritingToAnotherAgent,
} from "../support/helpers/plugin-composers-by-reference";
test("a panel in the Explorer inserts at the cursor of the focused agent's composer", async ({
page,
}) => {
test.setTimeout(120_000);
await withComposerToolsExample(page, async (author) => {
await author.openComposerToolsInExplorer();
await author.typeInComposer("start end", 5);
await author.insertChecklistFromPanel();
await author.expectComposerText(`start${CHECKLIST} end`);
});
});
test("a Command Center item changes an agent's metadata through its composer", async ({ page }) => {
test.setTimeout(120_000);
await withComposerToolsExample(page, async (author) => {
await author.openComposerToolsInExplorer();
await author.expectPanelShowsReview(false);
await author.markForReviewFromCommandCenter();
await author.expectPanelShowsReview(true);
});
});
test("the panel follows which agent is focused", async ({ page }) => {
test.setTimeout(120_000);
await withComposerToolsExample(page, async (author) => {
await author.openComposerToolsInExplorer();
await author.expectPanelFocusedAgent(true);
await author.focusANewAgentTab();
await author.expectPanelFocusedAgent(false);
await author.focusTheAgentTab();
await author.expectPanelFocusedAgent(true);
});
});
test("a plugin writes to another agent's composer before its tab is opened", async ({ page }) => {
test.setTimeout(120_000);
await withPluginWritingToAnotherAgent(page, async (author) => {
await author.openTheOtherAgent();
await author.insertFromCommandCenter();
await author.openTargetAgent();
await author.expectComposerText("A note from a plugin");
});
});
@@ -0,0 +1,194 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { openCommandCenter } from "./command-center";
import { buildAgentRoute, openAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs";
const WIDE = { width: 1440, height: 900 };
const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n";
type Workspace = Awaited<ReturnType<typeof seedWorkspace>>;
function prompt(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." });
}
async function createMockAgent(workspace: Workspace, title: string) {
return workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
model: "ten-second-stream",
modeId: "load-test",
});
}
async function runCommand(page: Page, title: string) {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
export async function withComposerToolsExample(
page: Page,
run: (author: {
openComposerToolsInExplorer(): Promise<void>;
typeInComposer(text: string, cursorAfter: number): Promise<void>;
insertChecklistFromPanel(): Promise<void>;
expectComposerText(text: string): Promise<void>;
markForReviewFromCommandCenter(): Promise<void>;
focusANewAgentTab(): Promise<void>;
focusTheAgentTab(): Promise<void>;
expectPanelFocusedAgent(focused: boolean): Promise<void>;
expectPanelShowsReview(needsReview: boolean): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-tools-", title: "Composer tools" });
const agent = await createMockAgent(workspace, "Composer tools agent");
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 page.setViewportSize(WIDE);
await gotoAppShell(page);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await run({
openComposerToolsInExplorer: () =>
test.step("open the Composer tools panel in the Explorer", async () => {
const explorer = await ensureExplorerSidebar(page);
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
await page
.getByTestId("workspace-new-tab-menu")
.filter({ visible: true })
.getByRole("menuitem", { name: "Composer tools", exact: true })
.click();
await expect(page.getByText("Focused agent: mock", { exact: true })).toBeVisible();
}),
typeInComposer: (text, cursorAfter) =>
test.step(`type "${text}" and put the cursor after ${cursorAfter} characters`, async () => {
const input = prompt(page);
await input.fill(text);
await input.press("Home");
for (let index = 0; index < cursorAfter; index += 1) await input.press("ArrowRight");
}),
insertChecklistFromPanel: () =>
test.step("press the panel's button", () =>
page
.getByRole("button", { name: "Insert checklist into the focused agent", exact: true })
.click()),
expectComposerText: (text) =>
test.step("the composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
markForReviewFromCommandCenter: () =>
test.step("run the Command Center item", () =>
runCommand(page, "Composer controls: mark for review")),
focusANewAgentTab: () =>
test.step("focus a new agent tab, which is a draft", () => createAgentTabFromMenu(page)),
focusTheAgentTab: () =>
test.step("focus the running agent's tab again", () =>
page
.getByTestId(`workspace-tab-agent_${agent.id}`)
.filter({ visible: true })
.first()
.click({ position: { x: 12, y: 13 } })),
expectPanelFocusedAgent: (focused) =>
test.step(`the panel ${focused ? "names" : "has no"} focused agent`, async () => {
await expect(page.getByText("Focused agent: mock", { exact: true })).toHaveCount(
focused ? 1 : 0,
);
await expect(page.getByText("No agent is focused", { exact: true })).toHaveCount(
focused ? 0 : 1,
);
}),
expectPanelShowsReview: (needsReview) =>
test.step(`the panel's snapshot says needs review: ${needsReview}`, () =>
expect(
page.getByText(`Needs review: ${needsReview ? "yes" : "no"}`, { exact: true }),
).toBeVisible()),
});
} finally {
await client.removePlugin("composer-controls-example");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
await workspace.cleanup();
}
}
/**
* A plugin that names an agent by id from a Command Center item, while another agent is open: the
* target's tab is not what the plugin acts on.
*/
export async function withPluginWritingToAnotherAgent(
page: Page,
run: (author: {
openTheOtherAgent(): Promise<void>;
insertFromCommandCenter(): Promise<void>;
openTargetAgent(): Promise<void>;
expectComposerText(text: string): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-ref-", title: "Composer ref" });
const target = await createMockAgent(workspace, "Target agent");
const other = await createMockAgent(workspace, "Other agent");
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-ref-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "composer-ref", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`export default function contribute(client) {
client.addCommandCenterItem({
id: "note",
title: "Composer ref: add a note",
icon: "Flag",
context: "global",
onSelect({ composers }) {
for (const composer of composers({ agentId: ${JSON.stringify(target.id)} })) composer.insertText("A note from a plugin");
},
});
return () => {};
}`,
);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await run({
openTheOtherAgent: () =>
test.step("open a different agent", () =>
openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: other.id })),
insertFromCommandCenter: () =>
test.step("run the plugin's Command Center item", () =>
runCommand(page, "Composer ref: add a note")),
openTargetAgent: () =>
test.step("open the agent the plugin wrote to", async () => {
await page.goto(buildAgentRoute(workspace.workspaceId, target.id));
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
}),
expectComposerText: (text) =>
test.step("its composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
});
} finally {
await client.removePlugin("composer-ref");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
}
export { CHECKLIST };
@@ -0,0 +1,268 @@
import { QueryClient } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { createAgentComposer } from "./index";
const serverId = "host-1";
const agentId = "agent-1";
const draftKey = buildDraftStoreKey({ serverId, agentId });
const entries: ProviderSnapshotEntry[] = [
{
provider: "claude",
status: "ready",
enabled: true,
models: [
{
provider: "claude",
id: "opus",
label: "Opus",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "high", label: "High" },
],
},
{
provider: "claude",
id: "claude-sonnet-4",
label: "Sonnet 4",
aliases: ["sonnet"],
thinkingOptions: [{ id: "low", label: "Low" }],
},
],
},
];
function agent(overrides: Partial<Agent> = {}): Agent {
return {
serverId,
id: agentId,
provider: "claude",
status: "idle",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-11-04T10:00:00.000Z"),
updatedAt: new Date("2026-11-04T10:00:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-11-04T10:00:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "default",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
cwd: "/repo",
workspaceId: "workspace-1",
model: null,
parentAgentId: null,
labels: {},
...overrides,
};
}
function fakeClient() {
const calls: string[] = [];
const client = {
async getProvidersSnapshot() {
return { entries, generatedAt: "2026-11-04T10:00:00.000Z", requestId: "snapshot" };
},
async setAgentModel(id: string, model: string | null) {
calls.push(`model:${id}:${model}`);
if (model === "nope") throw new Error("Model 'nope' is not available for provider 'claude'");
},
async setAgentMetadata(id: string, patch: unknown) {
calls.push(`metadata:${id}:${JSON.stringify(patch)}`);
},
async setAgentOptions(id: string, patch: unknown) {
calls.push(`options:${id}:${JSON.stringify(patch)}`);
},
async setAgentMode(id: string, mode: string) {
calls.push(`mode:${id}:${mode}`);
return null;
},
async setAgentThinkingOption(id: string, thinking: string) {
calls.push(`thinking:${id}:${thinking}`);
return null;
},
};
return { client: client as unknown as DaemonClient, calls };
}
function openSession(running: Agent) {
const { client, calls } = fakeClient();
const store = useSessionStore.getState();
store.initializeSession(serverId, client);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { providersSnapshot: true },
} as never);
store.setAgents(serverId, new Map([[running.id, running]]));
return calls;
}
async function until(read: () => unknown, expected: unknown) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (Object.is(read(), expected)) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(read()).toBe(expected);
}
function composer() {
return createAgentComposer(serverId, agentId, { queryClient: new QueryClient() });
}
beforeEach(() => {
useDraftStore.setState({ drafts: {} });
});
afterEach(() => {
useSessionStore.getState().clearSession(serverId);
});
describe("agent composer", () => {
it("keeps the prompt in the stored draft while no composer is mounted", () => {
openSession(agent());
const model = composer();
model.insertText("Review ");
model.insertText("this diff.\n");
expect(model.getState().prompt).toBe("Review this diff.\n");
expect(useDraftStore.getState().getDraftInput(draftKey)?.text).toBe("Review this diff.\n");
model.setPrompt("Start over");
expect(model.getState().prompt).toBe("Start over");
});
it("sends prompt commands through the mounted composer while it is mounted", () => {
openSession(agent());
const model = composer();
const mounted: string[] = [];
const detach = model.attachInput({
setText: (text) => mounted.push(`set:${text}`),
insertText: (text) => mounted.push(`insert:${text}`),
});
model.insertText("at the cursor");
model.setPrompt("replaced");
expect(mounted).toEqual(["insert:at the cursor", "set:replaced"]);
expect(useDraftStore.getState().getDraftInput(draftKey)).toBeUndefined();
detach();
model.insertText("stored");
expect(model.getState().prompt).toBe("stored");
});
it("notifies subscribers of a stored prompt and returns the same state until something changes", () => {
openSession(agent());
const model = composer();
let notified = 0;
const stop = model.subscribe(() => {
notified += 1;
});
const before = model.getState();
expect(model.getState()).toBe(before);
model.insertText("x");
expect(notified).toBeGreaterThan(0);
expect(model.getState()).not.toBe(before);
stop();
});
it("adds and removes attachments in the stored draft", () => {
openSession(agent());
const model = composer();
const attachment = {
kind: "plugin_resource" as const,
pluginId: "review",
sourceId: "issues",
sourceTitle: "Issues",
sourceIcon: "CircleDot",
item: {
id: "1",
identifier: "ENG-1",
title: "Title",
subtitle: "Open",
url: "https://example.com/1",
text: "Issue ENG-1: Title",
resourceType: "issue",
},
};
model.changeAttachments((current) => [...current, attachment]);
expect(model.getState().attachments).toHaveLength(1);
model.changeAttachments(() => []);
expect(model.getState().attachments).toHaveLength(0);
});
it("reads mode, options and metadata from the agent, and sends commands to the daemon", async () => {
const calls = openSession(
agent({ currentModeId: "plan", options: { effort: "high" }, metadata: { flag: true } }),
);
const model = composer();
expect(model.getState()).toMatchObject({
context: "agent",
provider: "claude",
mode: "plan",
options: { effort: "high" },
metadata: { flag: true },
});
await model.setMetadata({ flag: null });
await model.setOptions({ effort: "low" });
await model.setMode("default");
expect(calls).toEqual([
'metadata:agent-1:{"flag":null}',
'options:agent-1:{"effort":"low"}',
"mode:agent-1:default",
]);
await expect(model.setProvider("codex")).rejects.toThrow(
"A running agent cannot change provider.",
);
});
it("rejects an unlisted model with the daemon's message", async () => {
openSession(agent());
await expect(composer().setModel("nope")).rejects.toThrow(
"Model 'nope' is not available for provider 'claude'",
);
});
it.each([
["an alias of a listed model", { runtimeInfo: { model: "sonnet" } }, "claude-sonnet-4", "low"],
["no reported model", { model: null }, "opus", "high"],
["the configured model", { model: "opus", thinkingOptionId: "low" }, "opus", "low"],
] as const)(
"reads the model and thinking the picker shows for %s, once the catalog loads",
async (_name, overrides, expectedModel, expectedThinking) => {
const running = agent(overrides as unknown as Partial<Agent>);
openSession(running);
const model = composer();
expect(model.getState()).toMatchObject({ model: null, thinking: null });
const stop = model.subscribe(() => {});
await until(() => model.getState().model, expectedModel);
const picker = resolveAgentModelSelection({
models: filterSelectableModels(entries[0]?.models ?? null),
runtimeModelId: running.runtimeInfo?.model ?? null,
configuredModelId: running.model,
runtimeThinkingOptionId: running.runtimeInfo?.thinkingOptionId ?? null,
explicitThinkingOptionId: running.thinkingOptionId,
});
expect(model.getState()).toMatchObject({
model: picker.activeModelId,
thinking: picker.selectedThinkingId,
});
expect(model.getState().thinking).toBe(expectedThinking);
stop();
},
);
});
@@ -0,0 +1,256 @@
import type { QueryClient } from "@tanstack/react-query";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { providersSnapshotQuery } from "@/data/providers-snapshot";
import { replicaQueryOptions } from "@/data/query";
import { queryClient as singletonQueryClient } from "@/data/query-client";
import type { ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
import {
resolveAgentModelSelection,
resolveProviderSnapshotEntry,
} from "@/composer/agent-controls/utils";
import type { ComposerModel, ComposerState } from "@/composer/model";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { toDraftInputIfReady, type DraftRecord } from "@/stores/draft-store/state";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { UserComposerAttachment } from "@/attachments/types";
/** The mounted composer's input. While attached, prompt commands go through it. */
export interface MountedComposerInput {
setText(text: string): void;
/** Inserts at the cursor, replacing the selection. */
insertText(text: string): void;
}
/**
* The composer of a running agent. It exists whether or not the agent's tab is open: the prompt
* and attachments are the agent's draft in the draft store, its settings are the session store's
* agent, and the model and thinking level resolve through the provider catalog exactly as the
* model picker does.
*/
export interface AgentComposer extends ComposerModel {
/** The mounted composer plugs in cursor-aware prompt commands for as long as it is mounted. */
attachInput(input: MountedComposerInput): () => void;
}
interface AgentComposerDeps {
queryClient: QueryClient;
cache?: ProviderSnapshotCache;
}
type Catalog = { status: "loading" } | { status: "ready"; entries: ProviderSnapshotEntry[] | null };
const NO_VALUES = Object.freeze({});
const NO_ATTACHMENTS: readonly UserComposerAttachment[] = Object.freeze([]);
export function createAgentComposer(
serverId: string,
agentId: string,
deps: AgentComposerDeps,
): AgentComposer {
const draftKey = buildDraftStoreKey({ serverId, agentId });
const listeners = new Set<() => void>();
const catalogFailures = new Set<string>();
const catalogRequests = new Set<string>();
let mounted: MountedComposerInput | null = null;
let last: {
agent: Agent | null;
draft: DraftRecord | undefined;
catalog: Catalog;
state: ComposerState;
} | null = null;
function readAgent(): Agent | null {
const session = useSessionStore.getState().sessions[serverId];
return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
}
function client() {
const connected = useSessionStore.getState().sessions[serverId]?.client;
if (!connected) throw new Error("The host is not connected.");
return connected;
}
function catalogQuery(cwd: string) {
return replicaQueryOptions(
providersSnapshotQuery({
serverId,
cwd,
client,
queryClient: deps.queryClient,
cache: deps.cache,
}),
);
}
function notify() {
for (const listener of listeners) listener();
}
async function loadCatalog(query: ReturnType<typeof catalogQuery>, key: string) {
try {
await deps.queryClient.ensureQueryData(query);
} catch {
catalogFailures.add(key);
} finally {
catalogRequests.delete(key);
notify();
}
}
function readCatalog(agent: Agent): Catalog {
const session = useSessionStore.getState().sessions[serverId];
// The picker never loads a catalog from a host without it and shows the configured model.
if (session?.serverInfo?.features?.providersSnapshot !== true) {
return { status: "ready", entries: null };
}
const query = catalogQuery(agent.cwd);
const data = deps.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(
query.queryKey,
);
if (data) return { status: "ready", entries: data.entries };
const key = JSON.stringify(query.queryKey);
if (catalogFailures.has(key)) return { status: "ready", entries: null };
if (!session.client) return { status: "loading" };
if (!catalogRequests.has(key)) {
catalogRequests.add(key);
void loadCatalog(query, key);
}
return { status: "loading" };
}
function selectModel(agent: Agent, catalog: Catalog) {
if (catalog.status !== "ready") return null;
return resolveAgentModelSelection({
models: filterSelectableModels(
resolveProviderSnapshotEntry(catalog.entries ?? undefined, agent.provider)?.models ?? null,
),
runtimeModelId: agent.runtimeInfo?.model ?? null,
configuredModelId: agent.model,
runtimeThinkingOptionId: agent.runtimeInfo?.thinkingOptionId ?? null,
explicitThinkingOptionId: agent.thinkingOptionId,
});
}
function buildState(
agent: Agent | null,
catalog: Catalog,
record: DraftRecord | undefined,
): ComposerState {
const draft = toDraftInputIfReady(record);
const selection = agent ? selectModel(agent, catalog) : null;
return {
context: "agent",
prompt: draft?.text ?? "",
attachments: draft?.attachments ?? NO_ATTACHMENTS,
provider: agent?.provider ?? null,
// Null until the catalog lands, so a read never changes from the configured id to the
// catalog's canonical one.
model: selection?.activeModelId ?? null,
mode: agent?.currentModeId ?? null,
thinking: selection?.selectedThinkingId ?? null,
options: agent?.options ?? NO_VALUES,
metadata: agent?.metadata ?? NO_VALUES,
};
}
function readDraft(): DraftRecord | undefined {
const record = useDraftStore.getState().drafts[draftKey];
return record?.lifecycle === "active" ? record : undefined;
}
function getState(): ComposerState {
const agent = readAgent();
const draft = readDraft();
const catalog = agent ? readCatalog(agent) : ({ status: "loading" } as const);
if (
last &&
last.agent === agent &&
last.draft === draft &&
sameCatalog(last.catalog, catalog)
) {
return last.state;
}
last = { agent, draft, catalog, state: buildState(agent, catalog, draft) };
return last.state;
}
return {
getState,
subscribe(listener) {
listeners.add(listener);
getState();
const stops = [
useSessionStore.subscribe(notify),
useDraftStore.subscribe(notify),
deps.queryClient.getQueryCache().subscribe(notify),
];
return () => {
listeners.delete(listener);
for (const stop of stops) stop();
};
},
attachInput(input) {
mounted = input;
return () => {
if (mounted === input) mounted = null;
};
},
setPrompt(text) {
if (mounted) mounted.setText(text);
else useDraftStore.getState().editDraftText({ draftKey, text });
},
insertText(text) {
if (mounted) mounted.insertText(text);
else {
const current = useDraftStore.getState().getDraftInput(draftKey)?.text ?? "";
useDraftStore.getState().editDraftText({ draftKey, text: current + text });
}
},
changeAttachments(updater) {
useDraftStore.getState().updateDraftInput({
draftKey,
update: (current) => ({ ...current, attachments: updater(current.attachments) }),
});
},
async setProvider() {
throw new Error("A running agent cannot change provider.");
},
async setModel(modelId) {
await client().setAgentModel(agentId, modelId);
},
async setMode(modeId) {
await client().setAgentMode(agentId, modeId);
},
async setThinking(thinkingOptionId) {
await client().setAgentThinkingOption(agentId, thinkingOptionId);
},
async setOptions(patch) {
await client().setAgentOptions(agentId, patch);
},
async setMetadata(patch) {
await client().setAgentMetadata(agentId, patch);
},
};
}
function sameCatalog(left: Catalog, right: Catalog): boolean {
return (
left.status === right.status &&
(left.status === "loading" || left.entries === (right as typeof left).entries)
);
}
const composers = new Map<string, AgentComposer>();
/** The composer of a running agent; the same one for every caller. */
export function agentComposer(serverId: string, agentId: string): AgentComposer {
const key = `${serverId}:${agentId}`;
let composer = composers.get(key);
if (!composer) {
composer = createAgentComposer(serverId, agentId, { queryClient: singletonQueryClient });
composers.set(key, composer);
}
return composer;
}
@@ -21,7 +21,6 @@ import {
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { isUnlistedModel } from "@/provider-selection/resolve-agent-form";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
@@ -45,6 +44,7 @@ import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manife
import {
getAgentControlHintKey,
resolveAgentModelSelection,
resolveProviderSnapshotEntry,
} from "@/composer/agent-controls/utils";
import { useIsCompactFormFactor } from "@/constants/layout";
import { readMeasuredWidth } from "@/hooks/use-container-width";
@@ -74,7 +74,10 @@ import { useAgentControlItems } from "@/composer/agent-controls/control-items";
import { CompactModelSheet } from "@/composer/agent-controls/model-sheet";
import { useComposerSheetBridge } from "@/composer/agent-controls/sheet-bridge";
import { draftComposerAgent } from "@/composer/draft/composer-agent";
import { ComposerAgentProvider, type ComposerAgent } from "@/composer/session";
import { agentComposer } from "@/composer/agent-composer";
import { composerModelFromHalves } from "@/composer/halves-model";
import { ComposerModelProvider } from "@/composer/model";
import { ComposerAgentProvider, useComposerInput } from "@/composer/session";
import { PluginComposerItems, usePluginComposerItemCount } from "@/plugins/composer-items";
import {
useAgentProfileEditor,
@@ -94,8 +97,6 @@ interface AgentControlOption {
const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [];
const EMPTY_OPTIONS: ProviderOptions = {};
const EMPTY_METADATA: Record<string, JsonValue> = {};
const neverLocked = () => false;
interface ControlledAgentControlsProps {
provider: string;
@@ -375,18 +376,8 @@ function selectAgentControlsSlice(
};
}
function resolveSnapshotSelectedEntry(
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"],
agentProvider: string | undefined,
) {
if (!snapshotEntries || !agentProvider) {
return null;
}
return snapshotEntries.find((e) => e.provider === agentProvider) ?? null;
}
function resolveSnapshotModeIds(
entry: ReturnType<typeof resolveSnapshotSelectedEntry>,
entry: ReturnType<typeof resolveProviderSnapshotEntry>,
): string[] | null {
if (entry?.status !== "ready" || !entry.modes) {
return null;
@@ -982,7 +973,7 @@ export const AgentControls = memo(function AgentControls({
} = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
const snapshotSelectedEntry = useMemo(
() => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider),
() => resolveProviderSnapshotEntry(snapshotEntries, agent?.provider),
[snapshotEntries, agent?.provider],
);
@@ -1136,45 +1127,20 @@ export const AgentControls = memo(function AgentControls({
const agentOptionControls = useAgentOptionControls(agent, handleSetOption);
const composerAgent = useMemo<ComposerAgent | null>(() => {
if (!agent || !client) return null;
return {
context: "agent",
locked: false,
isLocked: neverLocked,
provider: agent.provider,
model: activeModelId,
mode: modeControl?.selectedModeId ?? null,
thinking: modelSelection.selectedThinkingId,
options: agent.options ?? EMPTY_OPTIONS,
metadata: agent.metadata ?? EMPTY_METADATA,
setProvider: () => Promise.reject(new Error("A running agent cannot change provider.")),
setModel: async (modelId) => {
if (isUnlistedModel(models, modelId)) throw new Error(`Unknown model: ${modelId}`);
await client.setAgentModel(agentId, modelId);
},
setMode: async (modeId) => {
await client.setAgentMode(agentId, modeId);
},
setThinking: async (thinkingOptionId) => {
await client.setAgentThinkingOption(agentId, thinkingOptionId);
},
setOptions: async (patch) => {
await client.setAgentOptions(agentId, patch);
},
setMetadata: async (patch) => {
await client.setAgentMetadata(agentId, patch);
},
};
}, [
activeModelId,
agent,
agentId,
client,
modeControl?.selectedModeId,
modelSelection.selectedThinkingId,
models,
]);
// The agent's composer exists apart from this component; while this is mounted, prompt
// commands go through the input the user is typing in.
const composer = useMemo(() => agentComposer(serverId, agentId), [agentId, serverId]);
const composerInput = useComposerInput();
useEffect(
() =>
composerInput
? composer.attachInput({
setText: composerInput.setText,
insertText: composerInput.insertText,
})
: undefined,
[composer, composerInput],
);
const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
() => ({
@@ -1237,7 +1203,7 @@ export const AgentControls = memo(function AgentControls({
}
return (
<ComposerAgentProvider value={composerAgent}>
<ComposerModelProvider value={composer}>
{commandCenterRegistration}
{profileEditor.element}
<ControlledAgentControls
@@ -1265,7 +1231,7 @@ export const AgentControls = memo(function AgentControls({
modelSelectorServerId={serverId}
isCompactLayout={isCompactLayout}
/>
</ComposerAgentProvider>
</ComposerModelProvider>
);
});
@@ -1399,35 +1365,46 @@ export function DraftAgentControls({
],
);
const composerInput = useComposerInput();
const composerModel = useMemo(
() =>
composerInput
? composerModelFromHalves({ agent: composerAgent, input: composerInput })
: null,
[composerAgent, composerInput],
);
return (
<ComposerAgentProvider value={composerAgent}>
{profileEditor.element}
<ControlledAgentControls
provider={selectedProvider ?? ""}
modelSelectorProviders={modelSelectorProviders}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={onSelectModel}
onSelectProviderAndModel={onSelectProviderAndModel}
isModelLoading={isAllModelsLoading}
agentProfiles={agentProfiles}
onApplyAgentProfile={agentProfiles?.applyProfile}
onEditAgentProfiles={handleEditAgentProfiles}
onCreateAgentProfile={profileActions.create}
onEditAgentProfile={profileActions.edit}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
optionControls={optionControls}
onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen}
onRetryModelProvider={onRetryModelProvider}
isRetryingModelProvider={isRetryingModelProvider}
disabled={disabled}
modeControl={modeControl}
modelSelectorServerId={modelSelectorServerId}
isCompactLayout={isCompactLayout}
/>
<ComposerModelProvider value={composerModel}>
{profileEditor.element}
<ControlledAgentControls
provider={selectedProvider ?? ""}
modelSelectorProviders={modelSelectorProviders}
modelOptions={modelOptions}
selectedModelId={selectedModel}
onSelectModel={onSelectModel}
onSelectProviderAndModel={onSelectProviderAndModel}
isModelLoading={isAllModelsLoading}
agentProfiles={agentProfiles}
onApplyAgentProfile={agentProfiles?.applyProfile}
onEditAgentProfiles={handleEditAgentProfiles}
onCreateAgentProfile={profileActions.create}
onEditAgentProfile={profileActions.edit}
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
optionControls={optionControls}
onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen}
onRetryModelProvider={onRetryModelProvider}
isRetryingModelProvider={isRetryingModelProvider}
disabled={disabled}
modeControl={modeControl}
modelSelectorServerId={modelSelectorServerId}
isCompactLayout={isCompactLayout}
/>
</ComposerModelProvider>
</ComposerAgentProvider>
);
}
@@ -8,11 +8,12 @@ import {
useComposerAgent,
useComposerInput,
} from "@/composer/session";
import { ComposerModelProvider, useComposerModel } from "@/composer/model";
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
* composer. This carries across what a control in it reads from the composer: the agent, model 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.
*/
@@ -20,6 +21,7 @@ export function useComposerSheetBridge(): ContextBridge {
const layout = useComposerControlLayout();
const agent = useComposerAgent();
const input = useComposerInput();
const model = useComposerModel();
const toast = useToast();
const queryClient = useQueryClient();
return useCallback<ContextBridge>(
@@ -28,16 +30,18 @@ export function useComposerSheetBridge(): ContextBridge {
<ToastApiProvider api={toast}>
<ComposerControlLayoutProvider value={layout}>
<ComposerAgentProvider value={agent}>
{input ? (
<ComposerInputProvider value={input}>{content}</ComposerInputProvider>
) : (
content
)}
<ComposerModelProvider value={model}>
{input ? (
<ComposerInputProvider value={input}>{content}</ComposerInputProvider>
) : (
content
)}
</ComposerModelProvider>
</ComposerAgentProvider>
</ComposerControlLayoutProvider>
</ToastApiProvider>
</QueryClientProvider>
),
[agent, input, layout, queryClient, toast],
[agent, input, layout, model, queryClient, toast],
);
}
@@ -1,4 +1,4 @@
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import {
@@ -49,6 +49,14 @@ export function getFeatureHighlightColor(featureId: string): FeatureHighlightCol
}
}
export function resolveProviderSnapshotEntry(
entries: readonly ProviderSnapshotEntry[] | null | undefined,
provider: string | null | undefined,
): ProviderSnapshotEntry | null {
if (!entries || !provider) return null;
return entries.find((entry) => entry.provider === provider) ?? null;
}
function findModelById(
models: AgentModelDefinition[] | null,
modelId: string | null,
+3 -17
View File
@@ -12,7 +12,6 @@ import {
import { useDraftProviderOptions } from "@/hooks/use-draft-provider-options";
import {
buildDraftAgentControls,
hasDraftContent,
resolveDraftKey,
type DraftKeyInput,
} from "@/composer/draft/input-draft-core";
@@ -23,7 +22,7 @@ import {
resolveEffectiveComposerThinkingOptionId,
type ProviderSelectionState,
} from "@/provider-selection/provider-selection";
import { useDraftStore } from "@/stores/draft-store";
import { useDraftStore, type DraftInput } from "@/stores/draft-store";
import { AfterPaintPublication } from "@/composer/after-paint-publication";
import { useShallow } from "zustand/shallow";
import type { ComposerTextSource } from "@/composer/text-source";
@@ -142,21 +141,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
);
const saveDraft = useCallback(
(
update: (draft: { text: string; attachments: UserComposerAttachment[] }) => {
text: string;
attachments: UserComposerAttachment[];
},
) => {
const store = useDraftStore.getState();
const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] };
const next = update(current);
if (!hasDraftContent(next)) {
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
return;
}
store.saveDraftInput({ draftKey, draft: next });
},
(update: (draft: DraftInput) => DraftInput) =>
useDraftStore.getState().updateDraftInput({ draftKey, update }),
[draftKey],
);
+64
View File
@@ -0,0 +1,64 @@
import type { ComposerAgent, ComposerInput } from "@/composer/session";
import type { ComposerModel, ComposerState } from "@/composer/model";
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();
}
/**
* The model of a draft or New workspace composer, built from the two halves its mounted
* composer provides. Its state belongs to the render that built it, so a change is a new model;
* only the prompt changes underneath it.
*/
export function composerModelFromHalves(input: {
agent: ComposerAgent;
input: ComposerInput;
}): ComposerModel {
const { agent, input: composerInput } = input;
let last: ComposerState | null = null;
return {
getState() {
const prompt = composerInput.textSource.getSnapshot();
if (last && last.prompt === prompt) return last;
last = {
context: agent.context,
prompt,
attachments: composerInput.attachments,
provider: agent.provider,
model: agent.model,
mode: agent.mode,
thinking: agent.thinking,
options: agent.options,
metadata: agent.metadata,
};
return last;
},
subscribe: composerInput.textSource.subscribe,
setPrompt(text) {
assertUnlocked(composerInput.isLocked);
composerInput.setText(text);
},
insertText(text) {
assertUnlocked(composerInput.isLocked);
composerInput.insertText(text);
},
changeAttachments(updater) {
assertUnlocked(composerInput.isLocked);
composerInput.changeAttachments(updater);
},
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)),
};
}
+50
View File
@@ -0,0 +1,50 @@
import { createContext, useContext } from "react";
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
import type { UserComposerAttachment } from "@/attachments/types";
/** Everything a plugin can read of a composer. A new object whenever any of it changes. */
export interface ComposerState {
/** `"agent"`: a running agent. `"draft"`: a new agent in a workspace. `"new-workspace"`: the New workspace screen. */
context: "agent" | "draft" | "new-workspace";
prompt: string;
attachments: readonly UserComposerAttachment[];
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>;
}
/**
* A composer as plugins use it: its state, a way to hear about changes, and the commands that
* change it. The commands enforce the rules the built-in controls follow (a draft being created
* is locked, a running agent keeps its provider). A running agent's model lives in the stores and
* exists whether or not its tab is open; a draft's is built from its mounted composer.
*/
export interface ComposerModel {
getState(): ComposerState;
subscribe(listener: () => void): () => void;
setPrompt(text: string): void;
/** Inserts at the cursor, replacing the selection. */
insertText(text: string): void;
changeAttachments(updater: (current: UserComposerAttachment[]) => UserComposerAttachment[]): void;
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 ComposerModelContext = createContext<ComposerModel | null>(null);
export const ComposerModelProvider = ComposerModelContext.Provider;
/** The model of the composer this control sits in. */
export function useComposerModel(): ComposerModel | null {
return useContext(ComposerModelContext);
}
@@ -119,6 +119,36 @@ export async function fetchProvidersSnapshot(input: {
return snapshot;
}
/**
* The providers snapshot as a replica query: its key and its fetch together, so the model picker
* and anything else that needs the catalog read the same cache entry. `client` is asked when the
* fetch runs.
*/
export function providersSnapshotQuery(input: {
serverId: string;
cwd?: string | null;
client: () => SnapshotClient;
queryClient?: QueryClient;
cache?: ProviderSnapshotCache;
}) {
const cwd = normalizeProvidersSnapshotCwd(input.cwd);
return {
queryKey: providersSnapshotQueryKey(input.serverId, cwd),
// The content-hash cache already owns catalog and model identity.
structuralSharing: false as const,
pushEvent: "providers_snapshot_update",
queryFn: ({ signal }: { signal: AbortSignal }) =>
fetchProvidersSnapshot({
client: input.client(),
serverId: input.serverId,
cwd,
queryClient: input.queryClient,
cache: input.cache,
signal,
}),
};
}
export async function refreshAndApplyProvidersSnapshot(input: {
client: SnapshotClient & Pick<DaemonClient, "refreshProvidersSnapshot">;
queryClient: QueryClient;
+1 -1
View File
@@ -63,7 +63,7 @@ export function useFetchQueries<TData>(
return useQueries({ queries: inputs.map((input) => fetchQueryOptions(input)) });
}
function replicaQueryOptions<
export function replicaQueryOptions<
TQueryFnData,
TError = Error,
TData = TQueryFnData,
@@ -10,6 +10,7 @@ import { useReplicaQuery } from "@/data/query";
import { queryClient as singletonQueryClient } from "@/data/query-client";
import {
normalizeProvidersSnapshotCwd,
providersSnapshotQuery,
providersSnapshotQueryKey,
providersSnapshotQueryRoot,
fetchProvidersSnapshot,
@@ -78,17 +79,16 @@ export function useProvidersSnapshot(
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]);
const snapshotQuery = useReplicaQuery({
queryKey,
// The content-hash cache already owns catalog and model identity.
structuralSharing: false,
...providersSnapshotQuery({
serverId: serverId ?? "",
cwd,
client: () => {
if (!client || !serverId) throw new Error(t("workspace.terminal.hostDisconnected"));
return client;
},
queryClient,
}),
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
pushEvent: "providers_snapshot_update",
queryFn: async ({ signal }) => {
if (!client || !serverId) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return fetchProvidersSnapshot({ client, serverId, cwd, queryClient, signal });
},
});
const refreshMutation = useMutation({
+2
View File
@@ -1,3 +1,4 @@
import { pluginComposersSource } from "./composer-items/source";
import { callPluginRpc } from "@getpaseo/plugin/client/host";
import type {
PluginAgentCommandContext,
@@ -40,6 +41,7 @@ export function createPluginCapabilities(
navigation.openSettings(plugin.id, screenId);
},
openScreen,
composers: (selector) => pluginComposersSource(plugin).resolve(selector),
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
openSurface: (screenId) => openScreen({ screenId }),
};
@@ -149,6 +149,7 @@ function harness() {
},
openScreen() {},
openSurface() {},
composers: () => [],
async playAudio() {},
openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }),
@@ -6,7 +6,7 @@ import {
ComposerControlSurfaceProvider,
type ComposerControlSurface,
} from "@/composer/agent-controls/composer-control";
import { useComposerAgent, useComposerInput } from "@/composer/session";
import { useComposerModel } from "@/composer/model";
import { PluginItemList, pluginThemeMapping, usePluginItemHost } from "../item-host";
import { PluginEnvironmentProvider } from "../popover";
import { useInstalledPlugins } from "../registry";
@@ -61,19 +61,16 @@ function PluginComposerItemsHost({
plugin: InstalledPlugin;
theme: PluginHostProps["theme"];
}) {
const agent = useComposerAgent();
const composerInput = useComposerInput();
const model = useComposerModel();
const { environment, props } = usePluginItemHost(plugin, theme);
const textSource = composerInput?.textSource;
const prompt = useSyncExternalStore(
textSource?.subscribe ?? subscribeNothing,
textSource?.getSnapshot ?? emptyText,
textSource?.getSnapshot ?? emptyText,
const state = useSyncExternalStore(
model?.subscribe ?? subscribeNothing,
model ? model.getState : noState,
model ? model.getState : noState,
);
const composer = useMemo(
() =>
agent && composerInput ? toPluginComposer({ plugin, agent, composerInput, prompt }) : null,
[agent, composerInput, plugin, prompt],
() => (model && state ? toPluginComposer({ plugin, model, state }) : null),
[model, plugin, state],
);
if (!composer) return null;
return (
@@ -91,6 +88,6 @@ function subscribeNothing() {
return () => undefined;
}
function emptyText() {
return "";
function noState(): null {
return null;
}
@@ -5,6 +5,7 @@ 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 { composerModelFromHalves } from "@/composer/halves-model";
import type { ComposerAgent, ComposerInput } from "@/composer/session";
import type { InstalledPlugin } from "../types";
import { toPluginComposer } from "./model";
@@ -112,16 +113,17 @@ function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
return { input, state };
}
function compose(agent: ComposerAgent, input: ComposerInput) {
const model = composerModelFromHalves({ agent, input });
return toPluginComposer({ plugin: installed(), model, state: model.getState() });
}
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",
});
const { input, state } = fakeInput();
state.text = "hello";
const composer = compose(agent, input);
expect(composer.context).toBe("new-workspace");
expect(composer.prompt).toBe("hello");
expect(composer.agent).toEqual({
@@ -139,12 +141,7 @@ describe("toPluginComposer", () => {
async (context) => {
const { agent, calls } = fakeAgent(context);
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
const composer = compose(agent, input);
await composer.setModel("gpt");
await composer.setMode("plan");
await composer.setThinking("high");
@@ -163,35 +160,20 @@ describe("toPluginComposer", () => {
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");
await compose(draft.agent, input).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.");
await expect(compose(running.agent, input).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: "",
});
const composer = compose(agent, input);
composer.setPrompt("a");
composer.insertText("b");
expect(state.text).toBe("ab");
@@ -200,12 +182,7 @@ describe("toPluginComposer", () => {
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: "",
});
const composer = compose(agent, input);
expect(() => composer.setPrompt("a")).toThrow("The composer is locked");
expect(() => composer.insertText("b")).toThrow("The composer is locked");
@@ -225,12 +202,7 @@ describe("toPluginComposer", () => {
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: "",
});
const composer = compose(agent, input);
// The plugin's action was awaiting an RPC when the user submitted.
const lock = { locked: true };
@@ -251,12 +223,7 @@ describe("toPluginComposer", () => {
const { agent } = fakeAgent("draft");
agent.metadata = { nested: { list: [1] } };
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
const composer = compose(agent, input);
expect(Object.isFrozen(composer.agent.options)).toBe(true);
expect(Object.isFrozen(composer.agent.metadata)).toBe(true);
@@ -276,23 +243,13 @@ describe("toPluginComposer", () => {
item: issue,
};
const { input, state } = fakeInput([other]);
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
const composer = compose(agent, input);
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: "",
});
const next = compose(agent, input);
expect(next.attachments).toEqual([{ sourceId: "issues", item: issue }]);
composer.removeAttachment({ sourceId: "issues", itemId: "ISS-1" });
@@ -302,12 +259,7 @@ describe("toPluginComposer", () => {
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: "",
});
const composer = compose(agent, input);
expect(() => composer.addAttachment({ sourceId: "nope", item: issue })).toThrow(
"Unknown attachment source: nope",
);
@@ -1,7 +1,7 @@
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 { ComposerModel, ComposerState } from "@/composer/model";
import type { InstalledPlugin } from "../types";
import { createPluginResourceAttachment } from "../attachments/model";
import { cloneAndFreeze } from "../freeze";
@@ -20,55 +20,36 @@ function isPluginAttachment(
);
}
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.
* What a plugin sees of a composer. Reads are `state`; every command goes to the model, so the
* mounted composer, a panel's handle and a Command Center item all reach the same one.
*/
export function toPluginComposer(input: {
plugin: InstalledPlugin;
agent: ComposerAgent;
composerInput: ComposerInput;
prompt: string;
model: ComposerModel;
state: ComposerState;
}): PluginComposer {
const { plugin, agent, composerInput, prompt } = input;
const { plugin, model, state } = input;
return {
context: agent.context,
prompt,
attachments: composerInput.attachments.flatMap((attachment) =>
context: state.context,
prompt: state.prompt,
attachments: state.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);
provider: state.provider,
model: state.model,
mode: state.mode,
thinking: state.thinking,
options: cloneAndFreeze(state.options as Record<string, JsonValue>),
metadata: cloneAndFreeze(state.metadata),
},
setPrompt: (text) => model.setPrompt(text),
insertText: (text) => model.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(
@@ -80,24 +61,22 @@ export function toPluginComposer(input: {
},
item,
);
composerInput.changeAttachments((current) =>
model.changeAttachments((current) =>
current.some((candidate) => isPluginAttachment(candidate, plugin.id, sourceId, item.id))
? current
: [...current, attachment],
);
},
removeAttachment({ sourceId, itemId }) {
assertUnlocked(composerInput.isLocked);
composerInput.changeAttachments((current) =>
model.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)),
setProvider: (provider) => model.setProvider(provider),
setModel: (modelId) => model.setModel(modelId),
setMode: (modeId) => model.setMode(modeId),
setThinking: (thinkingOptionId) => model.setThinking(thinkingOptionId),
setOptions: (patch) => model.setOptions(patch),
setMetadata: (patch) => model.setMetadata(patch),
};
}
@@ -0,0 +1,88 @@
import type { PluginComposer, PluginComposerSelector } from "@getpaseo/plugin/client";
import type { PluginComposersSource } from "@getpaseo/plugin/client/host";
import { agentComposer } from "@/composer/agent-composer";
import type { ComposerState } from "@/composer/model";
import { queryClient } from "@/data/query-client";
import { useDraftStore } from "@/stores/draft-store";
import {
getLastWorkspaceSelection,
subscribeLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { getFocusedAgentId } from "../command-center/context";
import type { InstalledPlugin } from "../types";
import { toPluginComposer } from "./model";
const NO_COMPOSERS: readonly PluginComposer[] = Object.freeze([]);
const sources = new WeakMap<InstalledPlugin, PluginComposersSource>();
/**
* The composers a plugin reaches by reference: one per running agent, open in a tab or not. The
* same source serves `useComposers` in every surface and `composers()` in every command.
*/
export function pluginComposersSource(plugin: InstalledPlugin): PluginComposersSource {
let source = sources.get(plugin);
if (!source) {
source = createSource(plugin);
sources.set(plugin, source);
}
return source;
}
function createSource(plugin: InstalledPlugin): PluginComposersSource {
const { serverId } = plugin;
// Handles and result arrays are reused while nothing they read has changed, so a subscriber
// that re-reads on every notification renders only when its composers did change.
const handles = new WeakMap<ComposerState, PluginComposer>();
const results = new WeakMap<PluginComposer, readonly PluginComposer[]>();
function runningAgentId(agentId: string): string | null {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
return agent && !agent.archivedAt ? agent.id : null;
}
function focusedAgentId(): string | null {
const selection = getLastWorkspaceSelection();
if (selection?.serverId !== serverId) return null;
const layout =
useWorkspaceLayoutStore.getState().layoutByWorkspace[
`${selection.serverId}:${selection.workspaceId}`
] ?? null;
return getFocusedAgentId(layout);
}
return {
subscribe(listener) {
const stops = [
useSessionStore.subscribe(listener),
useDraftStore.subscribe(listener),
useWorkspaceLayoutStore.subscribe(listener),
subscribeLastWorkspaceSelection(listener),
queryClient.getQueryCache().subscribe(listener),
];
return () => {
for (const stop of stops) stop();
};
},
resolve(selector: PluginComposerSelector) {
const requested = "agentId" in selector ? selector.agentId : focusedAgentId();
const agentId = requested === null ? null : runningAgentId(requested);
if (agentId === null) return NO_COMPOSERS;
const model = agentComposer(serverId, agentId);
const state = model.getState();
let composer = handles.get(state);
if (!composer) {
composer = toPluginComposer({ plugin, model, state });
handles.set(state, composer);
}
let result = results.get(composer);
if (!result) {
result = Object.freeze([composer]);
results.set(composer, result);
}
return result;
},
};
}
+1
View File
@@ -110,6 +110,7 @@ export type PluginClientRuntime = Pick<
| "openSettings"
| "openScreen"
| "openSurface"
| "composers"
| "openPanel"
| "subscribe"
| "addComposerPill"
@@ -1,10 +1,12 @@
import { QueryClientProvider } from "@tanstack/react-query";
import {
PaseoApiProvider,
PluginComposersProvider,
PluginRpcProvider,
PluginTranslationProvider,
} from "@getpaseo/plugin/client/host";
import React, { type ReactNode } from "react";
import { pluginComposersSource } from "./composer-items/source";
import type { InstalledPlugin } from "./types";
/** Every plugin surface renders under its installation: one query cache, one Paseo client, RPCs. */
@@ -19,7 +21,11 @@ export function PluginInstallationProvider({
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={plugin.paseo}>
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
<PluginTranslationProvider runtime={plugin.i18n}>
<PluginComposersProvider source={pluginComposersSource(plugin)}>
{children}
</PluginComposersProvider>
</PluginTranslationProvider>
</PluginRpcProvider>
</PaseoApiProvider>
</QueryClientProvider>
@@ -57,6 +57,7 @@ function registry(
},
openScreen: () => {},
openSurface: () => {},
composers: () => [],
openSettings: () => {},
playAudio: async () => {},
openPanel: () => {},
@@ -35,6 +35,7 @@ function registry(version: string) {
},
openScreen() {},
openSurface() {},
composers: () => [],
async playAudio() {},
openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }),
@@ -38,6 +38,7 @@ 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";
import { hasDraftContent } from "@/composer/draft/input-draft-core";
export type { DraftInput, DraftLifecycleState } from "./state";
@@ -45,6 +46,11 @@ interface DraftStoreActions {
getDraftInput: (draftKey: string) => DraftInput | undefined;
hydrateDraftInput: (input: { draftKey: string }) => Promise<DraftInput | undefined>;
saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void;
/** Saves what `update` makes of the current input; an emptied input abandons the draft. */
updateDraftInput: (input: {
draftKey: string;
update: (current: DraftInput) => DraftInput;
}) => void;
editDraftText: (input: { draftKey: string; text: string }) => void;
markDraftLifecycle: (input: { draftKey: string; lifecycle: DraftLifecycleState }) => void;
clearDraftInput: (input: {
@@ -329,6 +335,12 @@ export const useDraftStore = create<DraftStore>()(
scheduleAttachmentGc();
},
updateDraftInput: ({ draftKey, update }) => {
const next = update(get().getDraftInput(draftKey) ?? { text: "", attachments: [] });
if (hasDraftContent(next)) get().saveDraftInput({ draftKey, draft: next });
else get().clearDraftInput({ draftKey, lifecycle: "abandoned" });
},
editDraftText: ({ draftKey, text }) => {
set((state) => {
const previous = state.drafts[draftKey];
@@ -96,6 +96,10 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
return selection;
}
export function subscribeLastWorkspaceSelection(listener: () => void): () => void {
return lastWorkspaceSelectionStore.subscribe(listener);
}
export function useLastWorkspaceSelection(): ActiveWorkspaceSelection | null {
return useSyncExternalStore(
lastWorkspaceSelectionStore.subscribe,
+45
View File
@@ -250,6 +250,51 @@ describe("plugin composer SDK boundary", () => {
});
});
describe("plugin composers by reference SDK boundary", () => {
it("reaches a running agent's composer from any component and from every command context", () => {
expect(
typeErrors(`
import { useComposers, type PluginClientContext } from "@getpaseo/plugin/client";
function Button() {
const [focused] = useComposers({ focused: true });
const byId = useComposers({ agentId: "agent-1" });
if (!focused) return null;
const first: string = byId[0]?.prompt ?? "";
focused.insertText("text");
focused.setMetadata({ flag: true });
// @ts-expect-error a selector names an agent or the focused agent
useComposers({ active: true });
// @ts-expect-error focused is true or absent
useComposers({ focused: false });
return null;
}
export default function contribute(client: PluginClientContext) {
client.addWorkspacePanel({ id: "panel", title: "P", icon: "File", context: "workspace", Component: Button });
client.addCommandCenterItem({
id: "mark",
title: "Mark",
icon: "Flag",
context: "agent",
async onSelect(context) {
for (const composer of context.composers({ agentId: context.agent.id })) {
await composer.setMetadata({ review: true });
}
},
});
client.addCommandCenterItem({
id: "global",
title: "Global",
icon: "Flag",
context: "global",
onSelect: ({ composers }) => composers({ focused: true })[0]?.insertText("x"),
});
return () => {};
}
`),
).toEqual([]);
});
});
describe("plugin New workspace SDK boundary", () => {
it("lets a New workspace item read the setup and change it through typed commands and a Combobox", () => {
expect(
+52 -1
View File
@@ -1,4 +1,10 @@
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
import {
createContext,
useContext,
useSyncExternalStore,
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";
@@ -76,3 +82,48 @@ export function useComposer(): PluginComposer {
if (!composer) throw new Error("useComposer must run inside a composer item");
return composer;
}
/**
* Names the running agent whose composer a plugin wants. Both forms resolve to the same
* `PluginComposer` an item of that agent's composer gets from `useComposer()`.
*/
export type PluginComposerSelector = { agentId: string } | { focused: true };
/** What `useComposers` and `composers(selector)` read. Paseo implements it per installed plugin. */
export interface PluginComposersSource {
subscribe(listener: () => void): () => void;
/** The composers `selector` names now: one for a known running agent, none otherwise. */
resolve(selector: PluginComposerSelector): readonly PluginComposer[];
}
const PluginComposersContext = createContext<PluginComposersSource | null>(null);
export function PluginComposersProvider({
source,
children,
}: {
source: PluginComposersSource;
children: ReactNode;
}) {
return (
<PluginComposersContext.Provider value={source}>{children}</PluginComposersContext.Provider>
);
}
export function usePluginComposersSource(): PluginComposersSource | null {
return useContext(PluginComposersContext);
}
/**
* The composers `selector` names, whether or not their tabs are open. Runs in any plugin
* component. Empty for an unknown agent, or for `{ focused: true }` when no agent is focused.
*/
export function useComposers(selector: PluginComposerSelector): readonly PluginComposer[] {
const source = useContext(PluginComposersContext);
if (!source) throw new Error("useComposers must run inside a plugin component");
return useSyncExternalStore(
source.subscribe,
() => source.resolve(selector),
() => source.resolve(selector),
);
}
+7 -1
View File
@@ -4,7 +4,11 @@ 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 {
PluginComposer,
PluginComposerItemContribution,
PluginComposerSelector,
} from "./composer.js";
import type { PluginNewWorkspaceItemContribution } from "./new-workspace.js";
import type {
PluginRpcContract,
@@ -318,6 +322,8 @@ export interface PluginCommandCapabilities {
input: PluginWireInput<ZodInput<InputSchema>>,
): Promise<ZodOutput<OutputSchema>>;
openScreen(input: PluginOpenScreenInput): void;
/** The composers `selector` names, whether or not their tabs are open; see `useComposers`. */
composers(selector: PluginComposerSelector): readonly PluginComposer[];
/** @deprecated Use `openScreen`. */
openSurface(id: string): void;
openSettings(id: string): void;
+5 -1
View File
@@ -1,7 +1,11 @@
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 {
PluginComposerProvider,
PluginComposersProvider,
type PluginComposersSource,
} from "./composer.js";
export { PluginNewWorkspaceProvider } from "./new-workspace.js";
export {
usePluginRuntimeContextBridge,
+2 -1
View File
@@ -71,12 +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 { useComposer, useComposers } from "./composer.js";
export type {
PluginComposer,
PluginComposerAttachment,
PluginComposerItemContribution,
PluginComposerItemProps,
PluginComposerSelector,
} from "./composer.js";
export { useNewWorkspace } from "./new-workspace.js";
export type {
@@ -1,6 +1,11 @@
import { PluginTranslationProvider, usePluginTranslationRuntime } from "./translations.js";
import { useCallback, type ReactNode } from "react";
import { PluginComposerProvider, usePluginComposerValue } from "./composer.js";
import {
PluginComposerProvider,
PluginComposersProvider,
usePluginComposerValue,
usePluginComposersSource,
} from "./composer.js";
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
import { PluginRpcProvider, usePluginRpcContextValue } from "./rpc-context.js";
@@ -14,6 +19,7 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
const rpc = usePluginRpcContextValue();
const state = usePluginClientStateSource();
const composer = usePluginComposerValue();
const composers = usePluginComposersSource();
if (!paseo || !rpc) {
throw new Error("Plugin UI must run inside a contributed plugin surface");
@@ -26,11 +32,16 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
) : (
children
);
const content = composer ? (
<PluginComposerProvider composer={composer}>{withState}</PluginComposerProvider>
const withComposers = composers ? (
<PluginComposersProvider source={composers}>{withState}</PluginComposersProvider>
) : (
withState
);
const content = composer ? (
<PluginComposerProvider composer={composer}>{withComposers}</PluginComposerProvider>
) : (
withComposers
);
return (
<PaseoApiProvider paseo={paseo}>
<PluginRpcProvider invoke={rpc.invoke} subscribe={rpc.subscribe}>
@@ -39,6 +50,6 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
</PaseoApiProvider>
);
},
[composer, paseo, rpc, state, translations],
[composer, composers, paseo, rpc, state, translations],
);
}
@@ -20,6 +20,7 @@ import {
type ManagedAgent,
} from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { validateAgentConfigurationAgainstProvider } from "./agent-configuration-validator.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import { toAgentPayload } from "./agent-projections.js";
@@ -54,6 +55,7 @@ import type {
AgentTimelineItem,
ImportProviderSessionInput,
ImportProviderSessionContext,
ProviderSnapshotEntry,
ResolveAgentDefaultModeInput,
} from "./agent-sdk-types.js";
import type { PaseoToolCatalog } from "./tools/types.js";
@@ -5389,6 +5391,51 @@ test("later explicit config mutations win over events emitted by earlier mutatio
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high");
});
test("a running agent rejects a model or thinking option its provider does not list", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-selection-validation-"));
const provider: ProviderSnapshotEntry = {
provider: "codex",
status: "ready",
enabled: true,
models: [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
isDefault: true,
thinkingOptions: [{ id: "low", label: "Low" }],
},
],
modes: [],
};
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
validateAgentSelection: async (input) =>
validateAgentConfigurationAgainstProvider({ input, provider }),
logger,
idFactory: () => "00000000-0000-4000-8000-000000000136",
});
const snapshot = await manager.createAgent(
{ provider: "codex", cwd: workdir, model: "gpt-5.4" },
undefined,
{ workspaceId: undefined },
);
await expect(manager.setAgentModel(snapshot.id, "nope")).rejects.toThrow(
"Model 'nope' is not available for provider 'codex'",
);
await expect(manager.setAgentThinkingOption(snapshot.id, "extreme")).rejects.toThrow(
"Thinking option 'extreme' is not available for provider 'codex'",
);
expect(manager.getAgent(snapshot.id)?.config.model).toBe("gpt-5.4");
await manager.setAgentThinkingOption(snapshot.id, "low");
await manager.setAgentModel(snapshot.id, null);
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("low");
expect(manager.getAgent(snapshot.id)?.config.model).toBeUndefined();
});
test("setAgentThinkingOption adopts the session's effective clamped level", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-"));
class ClampingSession extends TestAgentSession {
@@ -3,6 +3,8 @@ import { ProviderOptionsValidationError } from "./provider-options.js";
import type { AgentToolResult } from "@getpaseo/plugin/server";
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import type { AgentConfigurationValidationInput } from "./agent-configuration-validator.js";
import type { HubExecutionAgentValidationIssue } from "@getpaseo/protocol/messages";
import { projectTimelineRows } from "./timeline-projection.js";
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
@@ -358,6 +360,13 @@ export interface AgentManagerOptions {
pluginLifecycle?: PluginLifecycle;
/** An internal workspace makes every agent created inside it internal. */
clients?: ProviderClientMap;
/**
* Asks whether the provider lists a model or thinking option. A running agent rejects a
* selection it does not; omitted, any value is accepted.
*/
validateAgentSelection?: (
input: AgentConfigurationValidationInput,
) => Promise<HubExecutionAgentValidationIssue[]>;
providerDefinitions?: ProviderEnabledMap;
idFactory?: () => string;
registry?: AgentStorage;
@@ -830,6 +839,7 @@ export class AgentManager {
private logger: Logger;
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
private readonly beforeSteerUnavailableFallback?: AgentManagerOptions["beforeSteerUnavailableFallback"];
private readonly validateAgentSelection?: AgentManagerOptions["validateAgentSelection"];
private acceptingAgentRegistrations = true;
constructor(options: AgentManagerOptions) {
@@ -854,6 +864,7 @@ export class AgentManager {
options.rescueTimeouts?.interruptSessionMs ?? INTERRUPT_SESSION_TIMEOUT_MS,
};
this.beforeSteerUnavailableFallback = options.beforeSteerUnavailableFallback;
this.validateAgentSelection = options.validateAgentSelection;
this.agentStreamCoalescer = new AgentStreamCoalescer({
windowMs: options.agentStreamCoalesceWindowMs ?? AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
timers: { setTimeout, clearTimeout },
@@ -2286,10 +2297,21 @@ export class AgentManager {
return notice;
}
private async assertSelectionListed(input: AgentConfigurationValidationInput): Promise<void> {
const issues = (await this.validateAgentSelection?.(input)) ?? [];
if (issues.length > 0) throw new Error(issues.map((issue) => issue.message).join("; "));
}
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
const agent = await this.ensureInteractiveSession(agentId);
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
if (normalizedModelId) {
await this.assertSelectionListed({
provider: agent.provider,
model: normalizedModelId,
});
}
if (agent.session.setModel) {
await agent.session.setModel(normalizedModelId);
@@ -2314,6 +2336,13 @@ export class AgentManager {
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
if (normalizedThinkingOptionId) {
await this.assertSelectionListed({
provider: agent.provider,
model: agent.config.model,
thinkingOptionId: normalizedThinkingOptionId,
});
}
let notice: AgentProviderNotice | null = null;
if (agent.session.setThinkingOption) {
+1
View File
@@ -1017,6 +1017,7 @@ export async function createPaseoDaemon(
clients: initialAgentManagerState.clients,
providerDefinitions: initialAgentManagerState.providerDefinitions,
registry: agentStorage,
validateAgentSelection: (input) => providerSnapshotManager.validateAgentConfiguration(input),
appendSystemPrompt: config.appendSystemPrompt,
onWorkspaceStateMayHaveChanged: ({ cwd }) => {
workspaceGitService.onWorkspaceStateMayHaveChanged(cwd);
@@ -49,13 +49,13 @@ describe("snapshot mutation ownership boundary", () => {
const applySnapshotSpy = vi.spyOn(daemonHandle.daemon.agentStorage, "applySnapshot");
await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4");
await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4-mini");
await daemonHandle.daemon.agentManager.flush();
expect(applySnapshotSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
const persisted = await daemonHandle.daemon.agentStorage.get(snapshot.id);
expect(persisted?.config?.model).toBe("gpt-5.4");
expect(persisted?.config?.model).toBe("gpt-5.4-mini");
} finally {
rmSync(cwd, { recursive: true, force: true });
await daemonHandle.close();