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