fix(plugins): a running agent's composer validates and refreshes against the agent's own catalog

setAgentModel and setAgentThinkingOption checked the provider's global catalog,
while the picker reads the catalog of the agent's directory, so a model only
the project lists was rejected. The validation input now carries the directory.

The composer read the catalog once with no observer, so reconnect repair, which
refetches only active queries, never refreshed it and a failed load never
retried. While anything watches, the composer now observes the catalog query.

The shared composer map kept every agent's composer for the session. It is now
dropped when nothing watches it and no composer is mounted.
This commit is contained in:
Mohamed Boudra
2026-11-04 08:10:29 +01:00
parent dad466d696
commit e59183f2de
8 changed files with 230 additions and 37 deletions
+8 -4
View File
@@ -677,15 +677,19 @@ implementations provide it:
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 resolves with no models until it lands, as the picker does,
so a read never differs from what the picker shows. The mounted
so a read never differs from what the picker shows. While anything watches the model it holds a
`QueryObserver` on the agent's catalog: that is what lets reconnect repair, which refetches only
active queries, refresh a catalog nobody has a picker open for, and retry one that failed. The
model is shared while a watcher or a mounted composer holds it and dropped when the last one
lets go. 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.
closed. The daemon is the authority for `setModel` and `setThinking`. It validates against the
catalog of the agent's directory, the one the picker reads, and checks 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.
@@ -7,7 +7,8 @@ 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";
import { providersSnapshotQueryRoot } from "@/data/providers-snapshot";
import { agentComposer, createAgentComposer } from "./index";
const serverId = "host-1";
const agentId = "agent-1";
@@ -78,7 +79,12 @@ function fakeClient() {
const calls: string[] = [];
const client = {
async getProvidersSnapshot() {
return { entries, generatedAt: "2026-11-04T10:00:00.000Z", requestId: "snapshot" };
if (hostCatalog.failing) throw new Error("The host is busy.");
return {
entries: hostCatalog.entries,
generatedAt: "2026-11-04T10:00:00.000Z",
requestId: "snapshot",
};
},
async setAgentModel(id: string, model: string | null) {
calls.push(`model:${id}:${model}`);
@@ -102,6 +108,12 @@ function fakeClient() {
return { client: client as unknown as DaemonClient, calls };
}
/** What the host answers the next catalog request with. */
const hostCatalog: { entries: ProviderSnapshotEntry[]; failing: boolean } = {
entries,
failing: false,
};
function openSession(running: Agent) {
const { client, calls } = fakeClient();
const store = useSessionStore.getState();
@@ -124,11 +136,20 @@ async function until(read: () => unknown, expected: unknown) {
expect(read()).toBe(expected);
}
let queryClient: QueryClient;
function runtimeModel(model: string): Agent["runtimeInfo"] {
return { provider: "claude", sessionId: null, model };
}
function composer() {
return createAgentComposer(serverId, agentId, { queryClient: new QueryClient() });
return createAgentComposer(serverId, agentId, { queryClient });
}
beforeEach(() => {
queryClient = new QueryClient();
hostCatalog.entries = entries;
hostCatalog.failing = false;
useDraftStore.setState({ drafts: {}, textReplacementRequestByDraftKey: {} });
});
@@ -148,7 +169,6 @@ describe("agent composer", () => {
[other.id, other],
]),
);
const queryClient = new QueryClient();
const watched = createAgentComposer(serverId, running.id, { queryClient });
const reader = createAgentComposer(serverId, other.id, { queryClient });
let notifications = 0;
@@ -281,6 +301,41 @@ describe("agent composer", () => {
);
});
it("takes the catalog the host changed while the picker was not open, once the host reconnects", async () => {
openSession(agent({ runtimeInfo: runtimeModel("opus") }));
const model = composer();
const stop = model.subscribe(() => {});
await until(() => model.getState().thinking, "high");
// The host changes the catalog while the client is away. Reconnecting invalidates the provider
// catalogs, which refetches the ones something is watching.
hostCatalog.entries = [
{
...entries[0],
models: [{ ...entries[0].models![0], defaultThinkingOptionId: "low" }],
},
] as ProviderSnapshotEntry[];
await queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(serverId) });
await until(() => model.getState().thinking, "low");
stop();
});
it("asks for the catalog again after a failed load once the host reconnects", async () => {
hostCatalog.failing = true;
openSession(agent({ runtimeInfo: runtimeModel("opus") }));
const model = composer();
const stop = model.subscribe(() => {});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(model.getState().thinking).toBeNull();
hostCatalog.failing = false;
await queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(serverId) });
await until(() => model.getState().thinking, "high");
stop();
});
function pickerShows(running: Agent, catalog: ProviderSnapshotEntry[] | null) {
const picker = resolveAgentModelSelection({
models: filterSelectableModels(catalog?.[0]?.models ?? null),
@@ -318,4 +373,24 @@ describe("agent composer", () => {
stop();
},
);
it("is released when nothing watches it and no composer is mounted, and shared while something does", () => {
openSession(agent());
const held = agentComposer(serverId, agentId);
const stop = held.subscribe(() => {});
expect(agentComposer(serverId, agentId)).toBe(held);
stop();
// A tab that remounts holds the composer it rendered with, which is shared again.
const stopAgain = held.subscribe(() => {});
expect(agentComposer(serverId, agentId)).toBe(held);
stopAgain();
expect(agentComposer(serverId, agentId)).not.toBe(held);
const mounted = agentComposer(serverId, agentId);
const detach = mounted.attachInput({ isLive: () => true, setText() {}, insertText() {} });
expect(agentComposer(serverId, agentId)).toBe(mounted);
detach();
expect(agentComposer(serverId, agentId)).not.toBe(mounted);
});
});
@@ -1,4 +1,4 @@
import type { QueryClient } from "@tanstack/react-query";
import { QueryObserver, 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";
@@ -39,6 +39,10 @@ export interface AgentComposer extends ComposerModel {
interface AgentComposerDeps {
queryClient: QueryClient;
cache?: ProviderSnapshotCache;
/** Something watches the composer or a composer is mounted. */
onHeld?: () => void;
/** Nothing watches the composer and no composer is mounted, so it can be dropped. */
onIdle?: () => void;
}
type Catalog = { status: "loading" } | { status: "ready"; entries: ProviderSnapshotEntry[] | null };
@@ -53,8 +57,9 @@ export function createAgentComposer(
): AgentComposer {
const draftKey = buildDraftStoreKey({ serverId, agentId });
const listeners = new Set<() => void>();
const catalogFailures = new Set<string>();
const catalogRequests = new Set<string>();
// While something watches, the catalog has an observer: it fetches the catalog, and it is what
// makes the catalog an active query that reconnect repair refetches, a failed one included.
let observing: { cwd: string; stop: () => void } | null = null;
let mounted: MountedComposerInput | null = null;
let last: {
agent: Agent | null;
@@ -90,15 +95,21 @@ export function createAgentComposer(
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 observeCatalog() {
const agent = listeners.size > 0 ? readAgent() : null;
const session = useSessionStore.getState().sessions[serverId];
const cwd =
agent && session?.client && session.serverInfo?.features?.providersSnapshot === true
? agent.cwd
: null;
if ((observing?.cwd ?? null) === cwd) return;
observing?.stop();
observing = null;
if (cwd === null) return;
observing = {
cwd,
stop: new QueryObserver(deps.queryClient, catalogQuery(cwd)).subscribe(notify),
};
}
function readCatalog(agent: Agent): Catalog {
@@ -112,15 +123,14 @@ export function createAgentComposer(
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);
// 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));
if (deps.queryClient.getQueryState(query.queryKey)?.status === "error") {
return { status: "ready", entries: null };
}
if (!session.client) return { status: "loading" };
// Nothing watches, so nothing observes: a command reads once and the load starts after it.
// Creating the query tells every query-cache subscriber, and a read can run in another
// component's render.
if (!observing) queueMicrotask(() => void deps.queryClient.prefetchQuery(query));
return { status: "loading" };
}
@@ -179,29 +189,37 @@ export function createAgentComposer(
return last.state;
}
function releaseWhenIdle() {
if (listeners.size === 0 && mounted === null) deps.onIdle?.();
}
return {
getState,
subscribe(listener) {
listeners.add(listener);
deps.onHeld?.();
observeCatalog();
getState();
const stops = [
useSessionStore.subscribe(notify),
useDraftStore.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();
useSessionStore.subscribe(() => {
observeCatalog();
notify();
}),
useDraftStore.subscribe(notify),
];
return () => {
listeners.delete(listener);
for (const stop of stops) stop();
observeCatalog();
releaseWhenIdle();
};
},
attachInput(input) {
mounted = input;
deps.onHeld?.();
return () => {
if (mounted === input) mounted = null;
releaseWhenIdle();
};
},
setPrompt(text) {
@@ -251,12 +269,21 @@ function sameCatalog(left: Catalog, right: Catalog): boolean {
const composers = new Map<string, AgentComposer>();
/** The composer of a running agent; the same one for every caller. */
/** The composer of a running agent; the same one for every caller while something holds it. */
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 });
composer = createAgentComposer(serverId, agentId, {
queryClient: singletonQueryClient,
// A holder that outlives a release, such as a remounting tab, puts its composer back.
onHeld: () => {
if (composer && !composers.has(key)) composers.set(key, composer);
},
onIdle: () => {
if (composers.get(key) === composer) composers.delete(key);
},
});
composers.set(key, composer);
}
return composer;
@@ -6,6 +6,8 @@ import { filterSelectableAgentModels } from "./agent-sdk-types.js";
export interface AgentConfigurationValidationInput {
provider: string;
/** The directory whose catalog lists the choices; omitted, the provider's global catalog does. */
cwd?: string;
model?: string;
modeId?: string;
thinkingOptionId?: string;
@@ -5436,6 +5436,32 @@ 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 selection is validated against the catalog of the agent's directory", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-selection-cwd-"));
const asked: Array<string | undefined> = [];
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
validateAgentSelection: async (input) => {
asked.push(input.cwd);
return [];
},
logger,
idFactory: () => "00000000-0000-4000-8000-000000000137",
});
const snapshot = await manager.createAgent(
{ provider: "codex", cwd: workdir, model: "gpt-5.4" },
undefined,
{ workspaceId: undefined },
);
await manager.setAgentModel(snapshot.id, "gpt-5.4");
await manager.setAgentThinkingOption(snapshot.id, "low");
expect(asked.length).toBeGreaterThan(0);
expect(asked.every((cwd) => cwd === workdir)).toBe(true);
});
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 = {
@@ -2299,6 +2299,7 @@ export class AgentManager {
const issues =
(await this.validateAgentSelection?.({
provider: agent.provider,
cwd: agent.cwd,
model,
thinkingOptionId,
})) ?? [];
@@ -2308,7 +2309,11 @@ export class AgentManager {
return;
}
}
await this.assertSelectionListed({ provider: agent.provider, thinkingOptionId });
await this.assertSelectionListed({
provider: agent.provider,
cwd: agent.cwd,
thinkingOptionId,
});
}
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
@@ -2318,6 +2323,7 @@ export class AgentManager {
if (normalizedModelId) {
await this.assertSelectionListed({
provider: agent.provider,
cwd: agent.cwd,
model: normalizedModelId,
});
}
@@ -311,6 +311,58 @@ describe("ProviderSnapshotManager public surface", () => {
}
});
test("validates a selection against the catalog of the directory it is for", async () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: {
claude: { enabled: false },
codex: { enabled: false },
copilot: { enabled: false },
pi: { enabled: false },
},
extraClients: {
opencode: createExtraClient("opencode", {
isAvailable: async () => true,
// A project can configure models its global catalog does not list.
fetchCatalog: async (options) => ({
models: [
{
provider: "opencode",
id: options.scope === "workspace" ? "project/custom" : "global/default",
label: "Model",
thinkingOptions: [
{ id: options.scope === "workspace" ? "deep" : "plain", label: "Level" },
],
},
],
modes: [],
}),
}),
},
});
try {
await expect(
manager.validateAgentConfiguration({
provider: "opencode",
cwd: "/tmp/project",
model: "project/custom",
thinkingOptionId: "deep",
}),
).resolves.toEqual([]);
await expect(
manager.validateAgentConfiguration({ provider: "opencode", model: "project/custom" }),
).resolves.toEqual([
{
path: ["model"],
message: "Model 'project/custom' is not available for provider 'opencode'",
},
]);
} finally {
manager.destroy();
}
});
test("reports an unavailable Hub agent provider at the authored provider field", async () => {
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
try {
@@ -498,6 +498,7 @@ export class ProviderSnapshotManager {
const provider = await this.getProvider({
provider: input.provider,
cwd: input.cwd,
wait: true,
});
if (!provider.enabled) {