mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
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:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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() {} }),
|
||||
|
||||
Reference in New Issue
Block a user