mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 15:34:39 +00:00
fix(plugins): composers by reference follow the focused agent and reach retained composers
- `{ focused: true }` is the agent Command Center hands to agent items, published by its
registration through plugins/focused-agent.ts and empty outside a workspace. The composer source
reads that module instead of the remembered workspace selection, the navigation store and the
layout store, which also removes the require cycles those imports added.
- A composer the app retains in a workspace the user left has no live input. `insertText` and
`setPrompt` now store the text with `replaceDraftText`, which asks the mounted input to show it;
before, the text reached the store but the retained input never displayed it.
- `model` and `thinking` read exactly what the picker resolves before the catalog loads (no
models), then re-resolve when it lands, instead of reading null.
- The daemon validates a thinking option against the model the agent runs: the runtime model when
the provider lists it, else the configured one.
- The composer sources subscribe to query-cache updates only. A query another component creates
during its render is announced as `added`, and a subscriber that updates then made React warn
about updating a component while rendering a different one. A catalog read no longer starts its
load synchronously either.
This commit is contained in:
+18
-6
@@ -676,10 +676,16 @@ implementations provide it:
|
||||
without a mounted tab: the prompt and attachments are the agent's draft in the draft store, the
|
||||
settings are the session store's agent, and `model` and `thinking` resolve through the provider
|
||||
catalog with `resolveAgentModelSelection`, the picker's own function, so a read equals what the
|
||||
picker shows. The model loads the catalog itself and reads `null` meanwhile. The mounted
|
||||
`AgentControls` plugs its input in with `attachInput` so `insertText` lands at the cursor;
|
||||
without it the model edits the stored prompt. The daemon is the authority for `setModel` and
|
||||
`setThinking`.
|
||||
picker shows. The model loads the catalog itself and resolves with no models until it lands, as the picker does,
|
||||
so a read never differs from what the picker shows. The mounted
|
||||
`AgentControls` plugs its input in with `attachInput` so `insertText` lands at the cursor.
|
||||
Without a live input the model writes the stored prompt with `replaceDraftText`, which also asks
|
||||
a mounted input to show it. A workspace the user left stays mounted, display none, with no
|
||||
input ref: `ComposerInput.isLive()` is how the model tells. Closing a root agent's tab archives
|
||||
it, so a running agent with no mounted composer at all only exists as a subagent whose tab was
|
||||
closed. The daemon is the authority for `setModel` and `setThinking`, and validates a thinking
|
||||
option against the model the agent runs: the runtime model when the catalog lists it, else the
|
||||
configured one.
|
||||
- A draft or New workspace screen's `composerModelFromHalves`, an adapter over the two contexts
|
||||
in `composer/session.tsx`: `Composer` provides `ComposerInput` (prompt, attachments),
|
||||
`DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock.
|
||||
@@ -689,8 +695,14 @@ implementations and `toPluginComposer`; never branch on `context` in the host.
|
||||
|
||||
`useComposers` and `composers()` reach an agent's model by reference (`plugins/composer-items/source.ts`):
|
||||
`PluginInstallationProvider` provides the plugin's source to every surface, and
|
||||
`createPluginCapabilities` puts `composers` on every command context. `{ focused: true }` resolves
|
||||
from the last viewed workspace on the plugin's host and its layout's focused tab.
|
||||
`createPluginCapabilities` puts `composers` on every command context. `{ focused: true }` is the
|
||||
agent Command Center registration hands to agent items, published through `plugins/focused-agent.ts`
|
||||
(null outside a workspace route). The source reads that module, not the navigation or layout
|
||||
stores, so the plugin runtime does not import them. The remembered workspace selection is
|
||||
navigation restore state and is not the focused agent. The query-cache subscriptions react to
|
||||
`updated` events only: a `useQuery` in another component's render announces `added`, and a
|
||||
subscriber that updates then triggers React's "update a component while rendering a different one"
|
||||
warning.
|
||||
|
||||
Plugin `Toggle`, `Select` and `Action`, the thinking select and provider option controls all
|
||||
render through `ComposerControl` (`packages/app/src/composer/agent-controls/composer-control.tsx`),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test } from "../support/fixtures";
|
||||
import {
|
||||
CHECKLIST,
|
||||
withComposerToolsExample,
|
||||
withPluginWritingToAnotherAgent,
|
||||
withPluginWritingToAnotherWorkspace,
|
||||
} from "../support/helpers/plugin-composers-by-reference";
|
||||
|
||||
test("a panel in the Explorer inserts at the cursor of the focused agent's composer", async ({
|
||||
@@ -39,12 +39,15 @@ test("the panel follows which agent is focused", async ({ page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a plugin writes to another agent's composer before its tab is opened", async ({ page }) => {
|
||||
test("a plugin writes to an agent's composer while its workspace is not on screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginWritingToAnotherAgent(page, async (author) => {
|
||||
await author.openTheOtherAgent();
|
||||
await author.insertFromCommandCenter();
|
||||
await withPluginWritingToAnotherWorkspace(page, async (author) => {
|
||||
await author.openTargetAgent();
|
||||
await author.goToAnotherWorkspace();
|
||||
await author.insertFromCommandCenter();
|
||||
await author.returnToTargetWorkspace();
|
||||
await author.expectComposerText("A note from a plugin");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,11 @@ 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 { openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
@@ -125,21 +126,22 @@ export async function withComposerToolsExample(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A plugin that names an agent by id from a Command Center item while the user is in another
|
||||
* workspace: the target's workspace is not on screen, so its composer is not mounted.
|
||||
*/
|
||||
export async function withPluginWritingToAnotherAgent(
|
||||
export async function withPluginWritingToAnotherWorkspace(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openTheOtherAgent(): Promise<void>;
|
||||
insertFromCommandCenter(): Promise<void>;
|
||||
openTargetAgent(): Promise<void>;
|
||||
goToAnotherWorkspace(): Promise<void>;
|
||||
insertFromCommandCenter(): Promise<void>;
|
||||
returnToTargetWorkspace(): Promise<void>;
|
||||
expectComposerText(text: string): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-ref-", title: "Composer ref" });
|
||||
const elsewhere = await seedWorkspace({ repoPrefix: "composer-away-", title: "Composer away" });
|
||||
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-"));
|
||||
@@ -162,22 +164,33 @@ export async function withPluginWritingToAnotherAgent(
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const targetTab = () => page.getByTestId(`workspace-tab-agent_${target.id}`);
|
||||
const workspaceRow = (id: string) =>
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${id}`);
|
||||
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 })),
|
||||
openTargetAgent: () =>
|
||||
test.step("open the agent the plugin will write to", async () => {
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: target.id });
|
||||
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
|
||||
}),
|
||||
goToAnotherWorkspace: () =>
|
||||
test.step("go to another workspace, so the agent's composer is not on screen", async () => {
|
||||
await workspaceRow(elsewhere.workspaceId).click();
|
||||
// The app keeps the workspace it left mounted, out of sight.
|
||||
await expect(targetTab().first()).toBeHidden();
|
||||
}),
|
||||
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 });
|
||||
returnToTargetWorkspace: () =>
|
||||
test.step("return to the agent's workspace without reloading the app", async () => {
|
||||
await workspaceRow(workspace.workspaceId).click();
|
||||
await expect(targetTab().first()).toBeVisible({ timeout: 30_000 });
|
||||
}),
|
||||
expectComposerText: (text) =>
|
||||
test.step("its composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
|
||||
@@ -187,6 +200,7 @@ export async function withPluginWritingToAnotherAgent(
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await elsewhere.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ function composer() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useDraftStore.setState({ drafts: {} });
|
||||
useDraftStore.setState({ drafts: {}, textReplacementRequestByDraftKey: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -137,6 +137,31 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("agent composer", () => {
|
||||
it("reads without notifying anyone, so a read during a render updates no other component", () => {
|
||||
const running = agent();
|
||||
const other = agent({ id: "agent-2", cwd: "/other-repo" });
|
||||
openSession(running);
|
||||
useSessionStore.getState().setAgents(
|
||||
serverId,
|
||||
new Map([
|
||||
[running.id, running],
|
||||
[other.id, other],
|
||||
]),
|
||||
);
|
||||
const queryClient = new QueryClient();
|
||||
const watched = createAgentComposer(serverId, running.id, { queryClient });
|
||||
const reader = createAgentComposer(serverId, other.id, { queryClient });
|
||||
let notifications = 0;
|
||||
const stop = watched.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
const before = notifications;
|
||||
// The first read of another catalog creates its query in the shared cache.
|
||||
reader.getState();
|
||||
expect(notifications).toBe(before);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("keeps the prompt in the stored draft while no composer is mounted", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
@@ -153,6 +178,7 @@ describe("agent composer", () => {
|
||||
const model = composer();
|
||||
const mounted: string[] = [];
|
||||
const detach = model.attachInput({
|
||||
isLive: () => true,
|
||||
setText: (text) => mounted.push(`set:${text}`),
|
||||
insertText: (text) => mounted.push(`insert:${text}`),
|
||||
});
|
||||
@@ -165,6 +191,24 @@ describe("agent composer", () => {
|
||||
expect(model.getState().prompt).toBe("stored");
|
||||
});
|
||||
|
||||
it("stores the prompt and asks the draft's input to show it while the composer is mounted off screen", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
const mounted: string[] = [];
|
||||
model.attachInput({
|
||||
isLive: () => false,
|
||||
setText: (text) => mounted.push(`set:${text}`),
|
||||
insertText: (text) => mounted.push(`insert:${text}`),
|
||||
});
|
||||
model.insertText("while away");
|
||||
expect(mounted).toEqual([]);
|
||||
expect(model.getState().prompt).toBe("while away");
|
||||
expect(useDraftStore.getState().textReplacementRequestByDraftKey[draftKey]).toBe(1);
|
||||
model.setPrompt("replaced");
|
||||
expect(model.getState().prompt).toBe("replaced");
|
||||
expect(useDraftStore.getState().textReplacementRequestByDraftKey[draftKey]).toBe(2);
|
||||
});
|
||||
|
||||
it("notifies subscribers of a stored prompt and returns the same state until something changes", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
@@ -237,31 +281,40 @@ describe("agent composer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
function pickerShows(running: Agent, catalog: ProviderSnapshotEntry[] | null) {
|
||||
const picker = resolveAgentModelSelection({
|
||||
models: filterSelectableModels(catalog?.[0]?.models ?? null),
|
||||
runtimeModelId: running.runtimeInfo?.model ?? null,
|
||||
configuredModelId: running.model,
|
||||
runtimeThinkingOptionId: running.runtimeInfo?.thinkingOptionId ?? null,
|
||||
explicitThinkingOptionId: running.thinkingOptionId,
|
||||
});
|
||||
return { model: picker.activeModelId, thinking: picker.selectedThinkingId };
|
||||
}
|
||||
|
||||
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"],
|
||||
[
|
||||
"an alias of a listed model",
|
||||
{ runtimeInfo: { model: "sonnet" } },
|
||||
"sonnet",
|
||||
"claude-sonnet-4",
|
||||
"low",
|
||||
],
|
||||
["no reported model", { model: null }, null, "opus", "high"],
|
||||
["the configured model", { model: "opus", thinkingOptionId: "low" }, "opus", "opus", "low"],
|
||||
] as const)(
|
||||
"reads the model and thinking the picker shows for %s, once the catalog loads",
|
||||
async (_name, overrides, expectedModel, expectedThinking) => {
|
||||
"shows what the picker shows for %s, before the catalog and after it loads",
|
||||
async (_name, overrides, modelBefore, modelAfter, thinkingAfter) => {
|
||||
const running = agent(overrides as unknown as Partial<Agent>);
|
||||
openSession(running);
|
||||
const model = composer();
|
||||
expect(model.getState()).toMatchObject({ model: null, thinking: null });
|
||||
// No catalog yet: the picker resolves with no models.
|
||||
expect(model.getState()).toMatchObject(pickerShows(running, null));
|
||||
expect(model.getState().model).toBe(modelBefore);
|
||||
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);
|
||||
await until(() => model.getState().model, modelAfter);
|
||||
expect(model.getState()).toMatchObject(pickerShows(running, entries));
|
||||
expect(model.getState().thinking).toBe(thinkingAfter);
|
||||
stop();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -18,6 +18,8 @@ import type { UserComposerAttachment } from "@/attachments/types";
|
||||
|
||||
/** The mounted composer's input. While attached, prompt commands go through it. */
|
||||
export interface MountedComposerInput {
|
||||
/** False while the composer is retained but its input is not on screen. */
|
||||
isLive(): boolean;
|
||||
setText(text: string): void;
|
||||
/** Inserts at the cursor, replacing the selection. */
|
||||
insertText(text: string): void;
|
||||
@@ -115,16 +117,19 @@ export function createAgentComposer(
|
||||
if (!session.client) return { status: "loading" };
|
||||
if (!catalogRequests.has(key)) {
|
||||
catalogRequests.add(key);
|
||||
void loadCatalog(query, key);
|
||||
// Creating the query tells every query-cache subscriber. A read can run in another
|
||||
// component's render, so the load starts after it.
|
||||
queueMicrotask(() => void loadCatalog(query, key));
|
||||
}
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
function selectModel(agent: Agent, catalog: Catalog) {
|
||||
if (catalog.status !== "ready") return null;
|
||||
// Until the catalog lands there are no models, which is what the picker resolves with too.
|
||||
const entries = catalog.status === "ready" ? catalog.entries : null;
|
||||
return resolveAgentModelSelection({
|
||||
models: filterSelectableModels(
|
||||
resolveProviderSnapshotEntry(catalog.entries ?? undefined, agent.provider)?.models ?? null,
|
||||
resolveProviderSnapshotEntry(entries ?? undefined, agent.provider)?.models ?? null,
|
||||
),
|
||||
runtimeModelId: agent.runtimeInfo?.model ?? null,
|
||||
configuredModelId: agent.model,
|
||||
@@ -145,8 +150,6 @@ export function createAgentComposer(
|
||||
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,
|
||||
@@ -184,7 +187,11 @@ export function createAgentComposer(
|
||||
const stops = [
|
||||
useSessionStore.subscribe(notify),
|
||||
useDraftStore.subscribe(notify),
|
||||
deps.queryClient.getQueryCache().subscribe(notify),
|
||||
// Only a query's update changes the catalog. A query another component creates while it
|
||||
// renders is announced too, and a subscriber that updates then makes React warn.
|
||||
deps.queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.type === "updated") notify();
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
@@ -198,14 +205,14 @@ export function createAgentComposer(
|
||||
};
|
||||
},
|
||||
setPrompt(text) {
|
||||
if (mounted) mounted.setText(text);
|
||||
else useDraftStore.getState().editDraftText({ draftKey, text });
|
||||
if (mounted?.isLive()) mounted.setText(text);
|
||||
else useDraftStore.getState().replaceDraftText({ draftKey, text });
|
||||
},
|
||||
insertText(text) {
|
||||
if (mounted) mounted.insertText(text);
|
||||
if (mounted?.isLive()) mounted.insertText(text);
|
||||
else {
|
||||
const current = useDraftStore.getState().getDraftInput(draftKey)?.text ?? "";
|
||||
useDraftStore.getState().editDraftText({ draftKey, text: current + text });
|
||||
useDraftStore.getState().replaceDraftText({ draftKey, text: current + text });
|
||||
}
|
||||
},
|
||||
changeAttachments(updater) {
|
||||
|
||||
@@ -1135,6 +1135,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
() =>
|
||||
composerInput
|
||||
? composer.attachInput({
|
||||
isLive: composerInput.isLive,
|
||||
setText: composerInput.setText,
|
||||
insertText: composerInput.insertText,
|
||||
})
|
||||
|
||||
@@ -144,6 +144,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
attachmentFocusRequestByDraftKey: {},
|
||||
textReplacementRequestByDraftKey: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -486,6 +487,37 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows text another module put in the draft while the input is retained off screen", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
||||
if (!latest) throw new Error("Expected hook result");
|
||||
return latest;
|
||||
}
|
||||
function Probe() {
|
||||
latest = useAgentInputDraft({ draftKey: "draft:replaced" });
|
||||
return null;
|
||||
}
|
||||
const container = document.getElementById("root");
|
||||
if (!container) throw new Error("Missing root container");
|
||||
const root = createTestRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<Probe />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
const before = getLatest().textReplacement.key;
|
||||
|
||||
await act(async () => {
|
||||
useDraftStore.getState().replaceDraftText({ draftKey: "draft:replaced", text: "from away" });
|
||||
});
|
||||
|
||||
expect(getLatest().textReplacement).toMatchObject({ kind: "replace", text: "from away" });
|
||||
expect(getLatest().textReplacement.key).not.toBe(before);
|
||||
expect(getLatest().textSource.getSnapshot()).toBe("from away");
|
||||
});
|
||||
|
||||
it("clear resets text and attachments", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const image: AttachmentMetadata = {
|
||||
|
||||
@@ -154,6 +154,20 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[draftKey],
|
||||
);
|
||||
|
||||
// Text another module put in the draft (a plugin writing to a composer that is not on screen)
|
||||
// reaches the input the way a rewind restore does.
|
||||
const replacementRequest = useDraftStore(
|
||||
(state) => state.textReplacementRequestByDraftKey[draftKey] ?? 0,
|
||||
);
|
||||
const shownReplacementRequestRef = useRef({ draftKey, request: replacementRequest });
|
||||
useEffect(() => {
|
||||
const shown = shownReplacementRequestRef.current;
|
||||
if (shown.draftKey === draftKey && shown.request === replacementRequest) return;
|
||||
shownReplacementRequestRef.current = { draftKey, request: replacementRequest };
|
||||
textPublication.cancel();
|
||||
publishTextReplacement("replace", textSource.getSnapshot());
|
||||
}, [draftKey, publishTextReplacement, replacementRequest, textPublication, textSource]);
|
||||
|
||||
const editText = useCallback(
|
||||
(nextText: string) => {
|
||||
if (isWeb) {
|
||||
|
||||
@@ -1439,6 +1439,7 @@ function ComposerContentImpl({
|
||||
() => ({
|
||||
locked: isComposerLocked,
|
||||
isLocked: () => isComposerLockedRef.current,
|
||||
isLive: () => messageInputRef.current !== null,
|
||||
textSource,
|
||||
setText: replaceUserInput,
|
||||
insertText: insertUserInput,
|
||||
|
||||
@@ -12,6 +12,11 @@ export interface ComposerInput {
|
||||
locked: boolean;
|
||||
/** `locked` as it is now. A command a plugin kept from an earlier render asks this. */
|
||||
isLocked: () => boolean;
|
||||
/**
|
||||
* The input is on screen. A retained composer in a workspace that is not shown has none, so it
|
||||
* cannot take the cursor-aware commands below.
|
||||
*/
|
||||
isLive: () => boolean;
|
||||
textSource: ComposerTextSource;
|
||||
setText: (text: string) => void;
|
||||
/** Inserts at the cursor, replacing the selection. */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { usePathname } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useCommandCenterActions } from "@/command-center/provider";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
@@ -9,6 +9,7 @@ import { useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { publishFocusedAgent } from "../focused-agent";
|
||||
import { hostIdFromPathname } from "../routes";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { buildPluginCommandCenterContributions } from "./contributions";
|
||||
@@ -37,6 +38,12 @@ export function PluginCommandCenterActions() {
|
||||
normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId)
|
||||
);
|
||||
});
|
||||
const handedAgentId = agentExists ? focusedAgentId : null;
|
||||
// The agent a plugin's `{ focused: true }` composer names is the one handed to agent items.
|
||||
useEffect(() => {
|
||||
publishFocusedAgent(serverId && handedAgentId ? { serverId, agentId: handedAgentId } : null);
|
||||
return () => publishFocusedAgent(null);
|
||||
}, [handedAgentId, serverId]);
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const installed = useInstalledPlugins();
|
||||
const plugins = useMemo(
|
||||
@@ -54,23 +61,13 @@ export function PluginCommandCenterActions() {
|
||||
plugins,
|
||||
state: stateSource,
|
||||
workspaceId: workspaceExists ? workspaceId : null,
|
||||
agentId: agentExists ? focusedAgentId : null,
|
||||
agentId: handedAgentId,
|
||||
navigation: createPluginNavigation({ serverId, workspaceId }),
|
||||
reportError(error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
});
|
||||
}, [
|
||||
agentExists,
|
||||
client,
|
||||
focusedAgentId,
|
||||
plugins,
|
||||
serverId,
|
||||
stateSource,
|
||||
toast,
|
||||
workspaceExists,
|
||||
workspaceId,
|
||||
]);
|
||||
}, [client, handedAgentId, plugins, serverId, stateSource, toast, workspaceExists, workspaceId]);
|
||||
|
||||
useCommandCenterActions({
|
||||
sourceId: serverId ? `plugins:${serverId}` : "plugins",
|
||||
|
||||
@@ -96,6 +96,7 @@ function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
|
||||
const input: ComposerInput = {
|
||||
locked,
|
||||
isLocked: () => locked,
|
||||
isLive: () => true,
|
||||
textSource: { getSnapshot: () => state.text, subscribe: () => () => {} },
|
||||
setText: (text) => {
|
||||
state.text = text;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { defineRpc } from "@getpaseo/plugin";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
|
||||
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { buildPluginCommandCenterContributions } from "../command-center/contributions";
|
||||
import { publishFocusedAgent } from "../focused-agent";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { pluginComposersSource } from "./source";
|
||||
|
||||
const serverId = "host-1";
|
||||
|
||||
function plugin(): InstalledPlugin {
|
||||
return {
|
||||
id: "review",
|
||||
serverId,
|
||||
clientBundle: "bundle",
|
||||
lifetime: new AbortController(),
|
||||
locale: "en",
|
||||
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
|
||||
queryClient: new QueryClient(),
|
||||
paseo: {} as InstalledPlugin["paseo"],
|
||||
invoke: async () => undefined,
|
||||
subscribe: () => {
|
||||
throw new Error("Subscriptions are not used here");
|
||||
},
|
||||
cleanup: () => {},
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
newWorkspaceItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
attachmentSources: [
|
||||
{
|
||||
id: "issues",
|
||||
title: "Issues",
|
||||
pickerTitle: "Issues",
|
||||
searchPlaceholder: "Search",
|
||||
icon: "Bug",
|
||||
search: defineRpc({
|
||||
name: "issues.search",
|
||||
input: z.object({}),
|
||||
output: z.array(z.object({})),
|
||||
}),
|
||||
},
|
||||
],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
timelineRenderers: [],
|
||||
};
|
||||
}
|
||||
|
||||
function agent(id: string, archivedAt: Date | null = null): Agent {
|
||||
return {
|
||||
serverId,
|
||||
id,
|
||||
provider: "claude",
|
||||
status: "idle",
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date(),
|
||||
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: {},
|
||||
archivedAt,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useDraftStore.setState({ drafts: {}, textReplacementRequestByDraftKey: {} });
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, {} as never);
|
||||
store.setAgents(
|
||||
serverId,
|
||||
new Map([
|
||||
["agent-1", agent("agent-1")],
|
||||
["agent-2", agent("agent-2", new Date())],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
publishFocusedAgent(null);
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
});
|
||||
|
||||
describe("a plugin's composers by reference", () => {
|
||||
it("names a running agent by id, and nothing for an unknown or archived one", () => {
|
||||
const source = pluginComposersSource(plugin());
|
||||
expect(source.resolve({ agentId: "agent-1" })).toHaveLength(1);
|
||||
expect(source.resolve({ agentId: "nope" })).toEqual([]);
|
||||
expect(source.resolve({ agentId: "agent-2" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("follows the agent the Command Center hands out, and is empty when there is none", () => {
|
||||
const source = pluginComposersSource(plugin());
|
||||
let notified = 0;
|
||||
const stop = source.subscribe(() => {
|
||||
notified += 1;
|
||||
});
|
||||
expect(source.resolve({ focused: true })).toEqual([]);
|
||||
|
||||
publishFocusedAgent({ serverId, agentId: "agent-1" });
|
||||
const [focused] = source.resolve({ focused: true });
|
||||
expect(focused?.context).toBe("agent");
|
||||
expect(notified).toBeGreaterThan(0);
|
||||
|
||||
// Leaving the workspace for a plugin screen or New workspace publishes no agent.
|
||||
publishFocusedAgent(null);
|
||||
expect(source.resolve({ focused: true })).toEqual([]);
|
||||
|
||||
// Another host's focused agent is not this plugin's.
|
||||
publishFocusedAgent({ serverId: "host-2", agentId: "agent-1" });
|
||||
expect(source.resolve({ focused: true })).toEqual([]);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("is not told when another component creates a query while it renders", () => {
|
||||
const source = pluginComposersSource(plugin());
|
||||
let notified = 0;
|
||||
const stop = source.subscribe(() => {
|
||||
notified += 1;
|
||||
});
|
||||
// `useQuery` does this during a render, and a subscriber that updates then makes React warn
|
||||
// that one component updated another while rendering.
|
||||
queryClient.getQueryCache().build(queryClient, { queryKey: ["created-during-a-render"] });
|
||||
expect(notified).toBe(0);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("returns the same array until a composer changes", () => {
|
||||
const source = pluginComposersSource(plugin());
|
||||
const first = source.resolve({ agentId: "agent-1" });
|
||||
expect(source.resolve({ agentId: "agent-1" })).toBe(first);
|
||||
first[0]?.insertText("hello");
|
||||
const next = source.resolve({ agentId: "agent-1" });
|
||||
expect(next).not.toBe(first);
|
||||
expect(next[0]?.prompt).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a Command Center item reaching an agent's composer by id", () => {
|
||||
const snapshot: PluginAgentSnapshot = {
|
||||
id: "agent-1",
|
||||
workspaceId: "workspace-1",
|
||||
provider: "claude",
|
||||
status: "idle",
|
||||
createdAt: "2026-08-16T10:00:00.000Z",
|
||||
updatedAt: "2026-08-16T10:00:00.000Z",
|
||||
lastActivityAt: "2026-08-16T10:00:00.000Z",
|
||||
title: null,
|
||||
cwd: "/repo",
|
||||
model: null,
|
||||
currentModeId: null,
|
||||
thinkingOptionId: null,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const workspace: PluginWorkspaceSnapshot = {
|
||||
id: "workspace-1",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Repo",
|
||||
projectRootPath: "/repo",
|
||||
directory: "/repo",
|
||||
projectKind: "git",
|
||||
kind: "checkout",
|
||||
name: "Repo",
|
||||
title: null,
|
||||
status: "running",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
};
|
||||
|
||||
function markForReview(rejection?: Error) {
|
||||
const patches: unknown[] = [];
|
||||
const client = {
|
||||
async setAgentMetadata(_agentId: string, patch: unknown) {
|
||||
patches.push(patch);
|
||||
if (rejection) throw rejection;
|
||||
},
|
||||
} as unknown as DaemonClient;
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
useSessionStore.getState().initializeSession(serverId, client);
|
||||
useSessionStore.getState().setAgents(serverId, new Map([["agent-1", agent("agent-1")]]));
|
||||
const installed = plugin();
|
||||
installed.commandCenterItems = [
|
||||
{
|
||||
id: "mark",
|
||||
title: "Mark for review",
|
||||
icon: "Flag",
|
||||
context: "agent",
|
||||
async onSelect({ agent: handed, composers }) {
|
||||
for (const composer of composers({ agentId: handed.id })) {
|
||||
await composer.setMetadata({ needsReview: true });
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
const reported: unknown[] = [];
|
||||
const [item] = buildPluginCommandCenterContributions({
|
||||
plugins: [installed],
|
||||
state: {
|
||||
subscribe: () => () => {},
|
||||
getWorkspace: () => workspace,
|
||||
getAgent: () => snapshot,
|
||||
},
|
||||
workspaceId: "workspace-1",
|
||||
agentId: "agent-1",
|
||||
navigation: {
|
||||
openSettings() {},
|
||||
openSurface() {},
|
||||
openWorkspacePanel() {},
|
||||
openAgentPanel() {},
|
||||
},
|
||||
reportError: (error) => reported.push(error),
|
||||
});
|
||||
return { run: item?.run, patches, reported };
|
||||
}
|
||||
|
||||
it("changes the agent's metadata through the daemon", async () => {
|
||||
const { run, patches, reported } = markForReview();
|
||||
await run?.();
|
||||
expect(patches).toEqual([{ needsReview: true }]);
|
||||
expect(reported).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports what the daemon rejected", async () => {
|
||||
const { run, reported } = markForReview(new Error("metadata refused"));
|
||||
await run?.();
|
||||
expect(reported).toEqual([new Error("metadata refused")]);
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,8 @@ 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 { getFocusedAgent, subscribeFocusedAgent } from "../focused-agent";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { toPluginComposer } from "./model";
|
||||
|
||||
@@ -44,13 +39,8 @@ function createSource(plugin: InstalledPlugin): PluginComposersSource {
|
||||
}
|
||||
|
||||
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);
|
||||
const focused = getFocusedAgent();
|
||||
return focused?.serverId === serverId ? focused.agentId : null;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -58,9 +48,12 @@ function createSource(plugin: InstalledPlugin): PluginComposersSource {
|
||||
const stops = [
|
||||
useSessionStore.subscribe(listener),
|
||||
useDraftStore.subscribe(listener),
|
||||
useWorkspaceLayoutStore.subscribe(listener),
|
||||
subscribeLastWorkspaceSelection(listener),
|
||||
queryClient.getQueryCache().subscribe(listener),
|
||||
subscribeFocusedAgent(listener),
|
||||
// The model catalog arrives as an update. A query another component creates while it
|
||||
// renders is announced too, and a subscriber that updates then makes React warn.
|
||||
queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.type === "updated") listener();
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
for (const stop of stops) stop();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The agent the user has focused: the one the Command Center hands to agent items. The Command
|
||||
* Center registration reads the router and the workspace layout, publishes the answer here, and
|
||||
* publishes `null` outside a workspace. A plugin's `{ focused: true }` composer reads it from here,
|
||||
* so both name the same agent and neither imports the navigation or layout stores.
|
||||
*/
|
||||
export interface FocusedAgent {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
let current: FocusedAgent | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function publishFocusedAgent(next: FocusedAgent | null): void {
|
||||
if (current?.serverId === next?.serverId && current?.agentId === next?.agentId) return;
|
||||
current = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function getFocusedAgent(): FocusedAgent | null {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function subscribeFocusedAgent(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -52,6 +52,11 @@ interface DraftStoreActions {
|
||||
update: (current: DraftInput) => DraftInput;
|
||||
}) => void;
|
||||
editDraftText: (input: { draftKey: string; text: string }) => void;
|
||||
/**
|
||||
* Sets the text on behalf of something other than the input, which then shows it. Use
|
||||
* `editDraftText` for the input's own edits.
|
||||
*/
|
||||
replaceDraftText: (input: { draftKey: string; text: string }) => void;
|
||||
markDraftLifecycle: (input: { draftKey: string; lifecycle: DraftLifecycleState }) => void;
|
||||
clearDraftInput: (input: {
|
||||
draftKey: string;
|
||||
@@ -71,6 +76,8 @@ interface DraftStoreActions {
|
||||
|
||||
interface DraftStoreRuntimeState {
|
||||
attachmentFocusRequestByDraftKey: Record<string, number>;
|
||||
/** Counts `replaceDraftText`, so a mounted input knows to show text it did not type. */
|
||||
textReplacementRequestByDraftKey: Record<string, number>;
|
||||
/**
|
||||
* Kept beside the prompt so a composer that remounts finds it. Not persisted, and not cleared
|
||||
* with an emptied prompt: a plugin's choice outlives the text until the draft is sent.
|
||||
@@ -272,6 +279,7 @@ export const useDraftStore = create<DraftStore>()(
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
attachmentFocusRequestByDraftKey: {},
|
||||
textReplacementRequestByDraftKey: {},
|
||||
metadataByDraftKey: {},
|
||||
|
||||
getDraftInput: (draftKey) => {
|
||||
@@ -349,6 +357,16 @@ export const useDraftStore = create<DraftStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
replaceDraftText: ({ draftKey, text }) => {
|
||||
get().editDraftText({ draftKey, text });
|
||||
set((state) => ({
|
||||
textReplacementRequestByDraftKey: {
|
||||
...state.textReplacementRequestByDraftKey,
|
||||
[draftKey]: (state.textReplacementRequestByDraftKey[draftKey] ?? 0) + 1,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
markDraftLifecycle: ({ draftKey, lifecycle }) => {
|
||||
set((state) => {
|
||||
const existing = state.drafts[draftKey];
|
||||
|
||||
@@ -96,10 +96,6 @@ 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,
|
||||
|
||||
@@ -5436,6 +5436,81 @@ test("a running agent rejects a model or thinking option its provider does not l
|
||||
expect(manager.getAgent(snapshot.id)?.config.model).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a thinking option is checked against the model the agent runs, as the picker chooses it", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-runtime-model-"));
|
||||
const provider: ProviderSnapshotEntry = {
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "configured",
|
||||
label: "Configured",
|
||||
isDefault: true,
|
||||
thinkingOptions: [{ id: "configured-only", label: "Configured only" }],
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
id: "runtime",
|
||||
label: "Runtime",
|
||||
thinkingOptions: [{ id: "runtime-only", label: "Runtime only" }],
|
||||
},
|
||||
],
|
||||
modes: [],
|
||||
};
|
||||
class RuntimeModelSession extends TestAgentSession {
|
||||
constructor(
|
||||
config: AgentSessionConfig,
|
||||
private readonly runtimeModel: string,
|
||||
) {
|
||||
super(config);
|
||||
}
|
||||
override async getRuntimeInfo() {
|
||||
return { ...(await super.getRuntimeInfo()), model: this.runtimeModel };
|
||||
}
|
||||
}
|
||||
class RuntimeModelClient extends TestAgentClient {
|
||||
constructor(private readonly runtimeModel: string) {
|
||||
super();
|
||||
}
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new RuntimeModelSession(config, this.runtimeModel);
|
||||
}
|
||||
}
|
||||
async function agentRunning(runtimeModel: string) {
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new RuntimeModelClient(runtimeModel) },
|
||||
registry: new AgentStorage(join(workdir, `agents-${runtimeModel}`), logger),
|
||||
validateAgentSelection: async (input) =>
|
||||
validateAgentConfigurationAgainstProvider({ input, provider }),
|
||||
logger,
|
||||
idFactory: () =>
|
||||
`00000000-0000-4000-8000-${runtimeModel === "runtime" ? "000000000137" : "000000000138"}`,
|
||||
});
|
||||
const snapshot = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, model: "configured" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
return { manager, id: snapshot.id };
|
||||
}
|
||||
|
||||
// The session runs a listed model other than the configured one: its options are the ones shown.
|
||||
const listed = await agentRunning("runtime");
|
||||
await expect(listed.manager.setAgentThinkingOption(listed.id, "configured-only")).rejects.toThrow(
|
||||
"Thinking option 'configured-only' is not available",
|
||||
);
|
||||
await listed.manager.setAgentThinkingOption(listed.id, "runtime-only");
|
||||
|
||||
// The session reports a model the catalog does not list: the picker falls back to the configured one.
|
||||
const unlisted = await agentRunning("unlisted");
|
||||
await unlisted.manager.setAgentThinkingOption(unlisted.id, "configured-only");
|
||||
await expect(
|
||||
unlisted.manager.setAgentThinkingOption(unlisted.id, "runtime-only"),
|
||||
).rejects.toThrow("Thinking option 'runtime-only' is not available");
|
||||
});
|
||||
|
||||
test("setAgentThinkingOption adopts the session's effective clamped level", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-"));
|
||||
class ClampingSession extends TestAgentSession {
|
||||
|
||||
@@ -2282,6 +2282,35 @@ export class AgentManager {
|
||||
if (issues.length > 0) throw new Error(issues.map((issue) => issue.message).join("; "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the option against the model the agent runs, chosen as the model picker chooses it: the
|
||||
* model the session reports when the provider lists it, otherwise the configured model, otherwise
|
||||
* the provider's default.
|
||||
*/
|
||||
private async assertThinkingOptionListed(
|
||||
agent: ActiveManagedAgent,
|
||||
thinkingOptionId: string,
|
||||
): Promise<void> {
|
||||
const reported = agent.runtimeInfo?.model;
|
||||
const candidates = [reported, agent.config.model].filter(
|
||||
(model): model is string => typeof model === "string" && model.length > 0,
|
||||
);
|
||||
for (const model of candidates) {
|
||||
const issues =
|
||||
(await this.validateAgentSelection?.({
|
||||
provider: agent.provider,
|
||||
model,
|
||||
thinkingOptionId,
|
||||
})) ?? [];
|
||||
const modelListed = !issues.some((issue) => issue.path[0] === "model");
|
||||
if (modelListed) {
|
||||
if (issues.length > 0) throw new Error(issues.map((issue) => issue.message).join("; "));
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.assertSelectionListed({ provider: agent.provider, thinkingOptionId });
|
||||
}
|
||||
|
||||
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const normalizedModelId =
|
||||
@@ -2317,11 +2346,7 @@ export class AgentManager {
|
||||
? thinkingOptionId
|
||||
: null;
|
||||
if (normalizedThinkingOptionId) {
|
||||
await this.assertSelectionListed({
|
||||
provider: agent.provider,
|
||||
model: agent.config.model,
|
||||
thinkingOptionId: normalizedThinkingOptionId,
|
||||
});
|
||||
await this.assertThinkingOptionListed(agent, normalizedThinkingOptionId);
|
||||
}
|
||||
|
||||
let notice: AgentProviderNotice | null = null;
|
||||
|
||||
@@ -2026,10 +2026,10 @@ export default function contribute(client: PluginClientContext) {
|
||||
}
|
||||
```
|
||||
|
||||
| Selector | Result |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `{ agentId }` | The composer of that agent on this host. Empty for an agent that is unknown or archived. |
|
||||
| `{ focused: true }` | The composer of the agent whose tab is focused in the workspace you last viewed on this host. Empty when the focused tab is not an agent, and it follows focus as the user changes tabs. |
|
||||
| Selector | Result |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `{ agentId }` | The composer of that agent on this host. Empty for an agent that is unknown or archived. |
|
||||
| `{ focused: true }` | The composer of the agent the Command Center hands to agent items: the focused agent tab of the workspace on screen. Empty when the screen is not a workspace, such as a plugin screen or New workspace, or when the focused tab is not an agent. It follows focus as the user changes tabs. |
|
||||
|
||||
The result is always an array, empty or with one composer, so the same code reads either selector.
|
||||
`useComposers` re-renders when a composer changes. A composer from `composers()` is the state at the
|
||||
@@ -2038,13 +2038,15 @@ call; call `composers()` again for the next state.
|
||||
The commands are those of [`useComposer()`](#composer-items), with these differences for a composer
|
||||
you reach from outside:
|
||||
|
||||
- `insertText` inserts at the cursor while the agent's tab is open. With the tab closed it appends to
|
||||
the agent's saved prompt, and the text is there when the tab opens.
|
||||
- `insertText` inserts at the cursor while the agent's composer is on screen. While it is not, for
|
||||
example when the user is in another workspace, it appends to the agent's saved prompt, and the text
|
||||
is in the input when the user returns. `setPrompt` replaces the saved prompt the same way.
|
||||
- A running agent's composer is never locked.
|
||||
- `setProvider` rejects, as it does inside the agent's own composer.
|
||||
- `setModel` and `setThinking` reject, with the daemon's message, for a value the provider does not
|
||||
list. `model` and `thinking` read `null` until the provider's catalog has loaded, then the value
|
||||
the model picker shows. Pass an id the catalog lists.
|
||||
list. Pass an id the provider's catalog lists. `model` and `thinking` read what the model picker
|
||||
shows at that moment: until the provider's catalog loads that is the id the agent reports or was
|
||||
created with, which may be an alias, and once it loads the catalog's own id.
|
||||
- Drafts and the New workspace screen are not addressable by reference. Only an item inside one
|
||||
reaches its composer, through `useComposer()`.
|
||||
|
||||
|
||||
@@ -1704,19 +1704,19 @@ export default function contribute(client: PluginClientContext) {
|
||||
}
|
||||
```
|
||||
|
||||
| 选择器 | 结果 |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `{ agentId }` | 此主机上该 Agent 的 composer。Agent 未知或已归档时为空。 |
|
||||
| `{ focused: true }` | 你在此主机上最近查看的工作区中,标签页处于聚焦状态的 Agent 的 composer。聚焦的标签页不是 Agent 时为空,并随用户切换标签页而变化。 |
|
||||
| 选择器 | 结果 |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `{ agentId }` | 此主机上该 Agent 的 composer。Agent 未知或已归档时为空。 |
|
||||
| `{ focused: true }` | Command Center 交给 Agent 项的那个 Agent 的 composer:屏幕上工作区中聚焦的 Agent 标签页。当前界面不是工作区(例如插件界面或新建工作区),或聚焦的标签页不是 Agent 时为空,并随用户切换标签页而变化。 |
|
||||
|
||||
结果始终是数组,为空或只含一个 composer,所以同一段代码可以读取任一选择器。composer 变化时 `useComposers` 会重新渲染。`composers()` 返回的 composer 是调用时的状态,要获取下一个状态请再次调用 `composers()`。
|
||||
|
||||
命令与 [`useComposer()`](#composer-items) 相同,从外部访问时有以下差异:
|
||||
|
||||
- Agent 的标签页打开时,`insertText` 在光标处插入。标签页关闭时,它会追加到该 Agent 已保存的提示词,标签页打开时文本就在那里。
|
||||
- Agent 的 composer 在屏幕上时,`insertText` 在光标处插入。不在屏幕上时(例如用户在另一个工作区),它会追加到该 Agent 已保存的提示词,用户回来时文本就在输入框里。`setPrompt` 以同样的方式替换已保存的提示词。
|
||||
- 运行中 Agent 的 composer 从不锁定。
|
||||
- `setProvider` 会被拒绝,与在 Agent 自己的 composer 内一样。
|
||||
- 对于 Provider 未列出的值,`setModel` 和 `setThinking` 会以 daemon 的消息拒绝。在 Provider 的模型目录加载完成之前,`model` 和 `thinking` 读取为 `null`,之后是模型选择器显示的值。请传入目录中列出的 id。
|
||||
- 对于 Provider 未列出的值,`setModel` 和 `setThinking` 会以 daemon 的消息拒绝。请传入 Provider 目录中列出的 id。`model` 和 `thinking` 读取的是模型选择器此刻显示的值:在 Provider 的模型目录加载完成之前,是 Agent 上报或创建时使用的 id(可能是别名),加载完成后是目录中的 id。
|
||||
- 草稿和新建工作区界面不能按引用访问,只有其中的项能通过 `useComposer()` 访问其 composer。
|
||||
|
||||
## 新建工作区项 {#new-workspace-items}
|
||||
|
||||
Reference in New Issue
Block a user