mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 15:34:39 +00:00
- `{ 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.
265 lines
8.2 KiB
TypeScript
265 lines
8.2 KiB
TypeScript
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")]);
|
|
});
|
|
});
|