diff --git a/docs/plugins.md b/docs/plugins.md index 7f1e962d86..13ee4e711c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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. diff --git a/packages/app/src/composer/agent-composer/index.test.ts b/packages/app/src/composer/agent-composer/index.test.ts index c02000dd2d..dd67b7af62 100644 --- a/packages/app/src/composer/agent-composer/index.test.ts +++ b/packages/app/src/composer/agent-composer/index.test.ts @@ -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); + }); }); diff --git a/packages/app/src/composer/agent-composer/index.ts b/packages/app/src/composer/agent-composer/index.ts index 7d6582da97..0c7d7d08b3 100644 --- a/packages/app/src/composer/agent-composer/index.ts +++ b/packages/app/src/composer/agent-composer/index.ts @@ -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(); - const catalogRequests = new Set(); + // 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, 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(); -/** 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; diff --git a/packages/server/src/server/agent/agent-configuration-validator.ts b/packages/server/src/server/agent/agent-configuration-validator.ts index 0893f3a692..bee8168b5a 100644 --- a/packages/server/src/server/agent/agent-configuration-validator.ts +++ b/packages/server/src/server/agent/agent-configuration-validator.ts @@ -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; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 31af86a634..081abf3f99 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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 = []; + 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 = { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 636479bd98..3fc63eadf3 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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 { @@ -2318,6 +2323,7 @@ export class AgentManager { if (normalizedModelId) { await this.assertSelectionListed({ provider: agent.provider, + cwd: agent.cwd, model: normalizedModelId, }); } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 46d1633da2..42684a0ea7 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -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 { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 7e728713be..3ddedb1612 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -498,6 +498,7 @@ export class ProviderSnapshotManager { const provider = await this.getProvider({ provider: input.provider, + cwd: input.cwd, wait: true, }); if (!provider.enabled) {