From 67940fbe2e57bf3b3becbb6a3137380ab4495f74 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:05:23 -0400 Subject: [PATCH 1/2] Scope prompt-box models to the machine the chat runs on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Work tab unions chats from every machine on the account, so the machine a chat runs on is frequently not the one the project tab is bound to. Model availability was already pinned per machine (aiDiscoveryCache keys by projectRoot + binding), but the runtime model catalog was not: it was fetched with no pin and cached in one process-global singleton. Because ModelPicker replaces the pin-scoped availableModelIds with the catalog's availability set whenever a catalog is loaded, a composer for a chat on machine B offered machine A's models — its ollama/LM Studio endpoints, its installed cursor-agent, its opencode inventory — and machine A's thinking-level ladders. Nothing cleared that singleton when the bound machine changed either, so it also went stale after a plain machine switch. Route chat.modelCatalog through the same optional runtime pin agentChat.models already takes, and bucket the catalog, its per-provider freshness and its parsed descriptors by binding key. The composer derives that pin from the machine shown in the prompt box and passes it to every chat-surface picker: the composer, both handoff pickers, and the orchestrator model-selection card. A same-machine chat passes no pin, so it keeps the identical call shape, cache bucket and local-IPC fallback it had — no extra probes for the common case. A foreign-machine chat costs one cached read over its already-open runtime channel, and provider refreshes now probe the machine that will actually run the model instead of the wrong one. There is deliberately no fallback from one machine's descriptor bucket to another's: a miss falls through to the static registry, because correct-but-generic beats confident-and-wrong. iOS already keys its catalog cache by host identity, and ADE Web is single-machine, so both are unaffected. --- apps/desktop/src/preload/global.d.ts | 5 +- apps/desktop/src/preload/preload.test.ts | 78 +++++++ apps/desktop/src/preload/preload.ts | 14 ++ .../components/chat/AgentChatComposer.tsx | 23 +- .../components/chat/AgentChatPane.test.tsx | 112 +++++++++ .../components/chat/AgentChatPane.tsx | 76 +++++- .../chat/ChatModelSelectionPendingCard.tsx | 14 +- .../shared/ModelPicker/ModelPicker.test.tsx | 221 +++++++++++++++++- .../shared/ModelPicker/ModelPicker.tsx | 87 +++++-- .../ModelPicker/ReasoningEffortPicker.tsx | 12 +- .../shared/ModelPicker/modelCatalog.test.ts | 142 +++++++++++ .../shared/ModelPicker/modelCatalog.ts | 43 +++- .../shared/ModelPicker/runtimeCatalogCache.ts | 120 ++++++++-- .../renderer/webclient/adapter/agentChat.ts | 11 +- docs/features/chat/README.md | 2 +- docs/features/chat/composer-and-ui.md | 27 ++- 16 files changed, 911 insertions(+), 76 deletions(-) diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 30dbabc6e..6516dc5a7 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1757,7 +1757,10 @@ declare global { args: AgentChatModelsArgs, pin?: OpenProjectBinding | null, ) => Promise; - modelCatalog: (args?: AgentChatModelCatalogArgs) => Promise; + modelCatalog: ( + args?: AgentChatModelCatalogArgs, + pin?: OpenProjectBinding | null, + ) => Promise; archive: ( args: AgentChatArchiveArgs, pin?: OpenProjectBinding | null, diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 019da3d0d..a7e40421e 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -1023,6 +1023,84 @@ describe("preload OAuth bridge", () => { expect(invoke).not.toHaveBeenCalledWith(IPC.appGetImageDataUrl, expect.anything()); }); + // The model catalog enumerates the SERVING machine's ollama/LM Studio + // endpoints, its installed cursor-agent and its opencode inventory. A Work + // tab unions chats from every machine, so a composer for a chat on another + // machine has to read that machine's catalog — reading the bound machine's + // is how the prompt box came to offer models the target could not run. + it("routes the model catalog through the bound runtime, or an explicit chat pin", async () => { + const binding = { + kind: "remote", + key: "remote:target-1:project-1", + targetId: "target-1", + runtimeName: "Remote", + projectId: "project-1", + rootPath: "/remote/project", + displayName: "Project", + }; + const chatRuntimePin = { + kind: "remote", + key: "remote:target-2:project-2", + targetId: "target-2", + runtimeName: "Studio", + projectId: "project-2", + rootPath: "/remote/chat-project", + displayName: "Chat project", + }; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const id = (payload as { id?: string } | undefined)?.id; + return { + ok: true, + result: { + groups: [{ key: id === "target-2" ? "ollama" : "lmstudio", label: id, providers: [] }], + fetchedAt: "2026-05-18T00:00:00.000Z", + }, + statusHints: {}, + }; + } + throw new Error(`unexpected IPC: ${channel}`); + }); + const exposeInMainWorld = vi.fn((_name: string, value: unknown) => { + (globalThis as any).__adeBridge = value; + }); + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on: vi.fn(), removeListener: vi.fn() }, + webFrame: { getZoomLevel: vi.fn(() => 0), setZoomLevel: vi.fn(), getZoomFactor: vi.fn(() => 1) }, + })); + + await import("./preload"); + const bridge = (globalThis as any).__adeBridge; + + // No pin: unchanged behaviour — the window's bound runtime answers. + await expect(bridge.agentChat.modelCatalog({ mode: "cached" })) + .resolves.toMatchObject({ groups: [{ key: "lmstudio" }] }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { domain: "chat", action: "modelCatalog", args: { mode: "cached" } }, + }); + invoke.mockClear(); + + // Pinned: the chat's own machine answers, and the bound one is not asked. + await expect(bridge.agentChat.modelCatalog({ mode: "cached" }, chatRuntimePin)) + .resolves.toMatchObject({ groups: [{ key: "ollama" }] }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-2", + projectId: "project-2", + request: { domain: "chat", action: "modelCatalog", args: { mode: "cached" } }, + }); + expect(invoke).not.toHaveBeenCalledWith( + IPC.remoteRuntimeCallAction, + expect.objectContaining({ id: "target-1" }), + ); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatModelCatalog, expect.anything()); + }); + it("reads env files locally while importing and exporting secrets on the bound remote machine", async () => { const binding = { kind: "remote", diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 768e118bb..206bd4736 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -6359,9 +6359,23 @@ contextBridge.exposeInMainWorld("ade", { ? runtime.result : ipcRenderer.invoke(IPC.agentChatModels, args); }, + // Pinned exactly like `models`: the catalog enumerates ollama/LM Studio + // endpoints, the installed cursor-agent and the opencode inventory, all of + // which are facts about the machine that serves the action. A composer + // targeting another machine must read THAT machine's catalog rather than + // the one this window's project tab happens to be bound to. modelCatalog: async ( args?: AgentChatModelCatalogArgs, + pin?: OpenProjectBinding | null, ): Promise => { + if (pin) { + return callPinnedRuntimeAction( + pin, + "chat", + "modelCatalog", + { args: args ?? {} }, + ); + } const runtime = await callProjectRuntimeActionIfBound< AgentChatModelCatalog >("chat", "modelCatalog", { args: args ?? {} }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index e7442b9b5..47029e08f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -65,6 +65,7 @@ import { import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import type { AuthStatus } from "../shared/ModelPicker/ModelPickerRail"; import { resolveModelDescriptorWithRuntimeCatalog } from "../shared/ModelPicker/modelCatalog"; +import { DEFAULT_RUNTIME_CATALOG_SCOPE } from "../shared/ModelPicker/runtimeCatalogCache"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { getPermissionOptions, type PermissionOption } from "../shared/permissionOptions"; import { ContextUsageDial } from "./usage/ContextUsageDial"; @@ -1482,6 +1483,7 @@ export function AgentChatComposer({ onPromptHistoryNavigate, attachments, composerMachineBinding = null, + modelRuntimePin = null, attachmentPersistenceUnavailableReason = null, contextAttachments = [], allowAttachmentOnlySubmit = false, @@ -1624,6 +1626,14 @@ export function AgentChatComposer({ attachments: AgentChatFileRef[]; /** Effective runtime owning this composer and its prompt stashes. */ composerMachineBinding?: OpenProjectBinding | null; + /** + * {@link composerMachineBinding} when it is NOT the machine this window's + * project tab is bound to. What a model picker offers — which models exist, + * which are configured, and their thinking levels — is a fact about the + * machine that will run the turn, so it is read from this binding. `null` + * (the common case) means the bound machine, and keeps the shared catalog. + */ + modelRuntimePin?: OpenProjectBinding | null; /** Fail-closed reason shown when the selected runtime cannot own new attachments. */ attachmentPersistenceUnavailableReason?: string | null; contextAttachments?: AgentChatContextAttachment[]; @@ -1882,6 +1892,9 @@ export function AgentChatComposer({ const fileAddInProgressRef = useRef(false); const latestComposerMachineBindingRef = useRef(composerMachineBinding); latestComposerMachineBindingRef.current = composerMachineBinding; + // Catalog bucket for every model-derived control in this composer (picker + // rows, availability, thinking levels). Empty means the bound machine. + const modelCatalogScopeKey = modelRuntimePin?.key ?? DEFAULT_RUNTIME_CATALOG_SCOPE; const objectPreviewUrlsRef = useRef>(new Set()); const cancelledPendingImageAttachmentsRef = useRef>(new Set()); const pendingImageAttachmentSequenceRef = useRef(0); @@ -3463,7 +3476,7 @@ export function AgentChatComposer({ ? (parallelModelSlots[parallelConfiguringIndex]?.modelId ?? "") : (modelId ?? ""); const fastModeSupported = modelSupportsFastMode( - resolveModelDescriptorWithRuntimeCatalog(fastModeModelId) ?? getModelById(fastModeModelId), + resolveModelDescriptorWithRuntimeCatalog(fastModeModelId, modelCatalogScopeKey) ?? getModelById(fastModeModelId), ); const fastModeActive = parallelChatMode && parallelConfiguringIndex != null @@ -4664,6 +4677,8 @@ export function AgentChatComposer({ metadata={meta} {...(availableModelIdsForPicker ? { availableModelIds: availableModelIdsForPicker } : {})} {...(providerAuthStatus ? { providerAuthStatus } : {})} + runtimePin={modelRuntimePin} + catalogScopeKey={modelCatalogScopeKey} responding={approvalResponding ?? false} onConfirm={(selection) => { onApproval("accept", null, { selection: JSON.stringify(selection) }); @@ -5226,6 +5241,7 @@ export function AgentChatComposer({ {...(providerAuthStatus ? { providerAuthStatus } : {})} {...(onOpenAiSettings ? { onOpenSignIn: onOpenAiSettings } : {})} {...(onRuntimeCatalogRefreshed ? { onRuntimeCatalogRefreshed } : {})} + runtimePin={modelRuntimePin} allowCliOnlyModels={allowCliOnlyModels} disabled={parallelLaunchBusy} compact @@ -5246,6 +5262,7 @@ export function AgentChatComposer({ disabled={parallelLaunchBusy} compact triggerClassName={COMPOSER_TOOLBAR_PICKER_TRIGGER} + catalogScopeKey={modelCatalogScopeKey} /> ) : null} @@ -5262,6 +5279,7 @@ export function AgentChatComposer({ {...(providerAuthStatus ? { providerAuthStatus } : {})} {...(onOpenAiSettings ? { onOpenSignIn: onOpenAiSettings } : {})} {...(onRuntimeCatalogRefreshed ? { onRuntimeCatalogRefreshed } : {})} + runtimePin={modelRuntimePin} allowCliOnlyModels={allowCliOnlyModels} disabled={modelSelectionLocked} compact @@ -5277,6 +5295,7 @@ export function AgentChatComposer({ disabled={modelSelectionLocked} compact triggerClassName={COMPOSER_TOOLBAR_PICKER_TRIGGER} + catalogScopeKey={modelCatalogScopeKey} /> ) : null} @@ -5310,7 +5329,7 @@ export function AgentChatComposer({ usage={usageViewModel} active={turnActive} compactionPulse={compactionPulse} - modelLabel={resolveModelDescriptorWithRuntimeCatalog(modelId)?.displayName ?? undefined} + modelLabel={resolveModelDescriptorWithRuntimeCatalog(modelId, modelCatalogScopeKey)?.displayName ?? undefined} /> ) : null} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 8dcaa5263..98f13b96a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -1353,6 +1353,118 @@ describe("AgentChatPane remote startup", () => { expect(window.ade.ai.getStatus).not.toHaveBeenCalled(); }); + /** + * A Work tab unions chats from every machine on the account, so the machine a + * chat runs on is frequently NOT the one the project tab is bound to. What the + * prompt box offers — which models exist, and their thinking levels — is a + * fact about the machine that will run the turn. + * + * The bridge here answers differently per machine, exactly as two real Macs + * would: unpinned calls land on the bound machine (that is what preload's + * bound path does), pinned calls on the chat's own. Before the composer + * carried the pin, the picker for a chat on the Studio was filled from this + * Mac's catalog and offered `ollama/bound-only`, a model the Studio cannot run. + */ + it("fills the prompt box from the chat's own machine, not the bound one", async () => { + const boundRoot = "/tmp/project-under-test"; + const studioBinding = { + kind: "remote" as const, + key: "remote:target-studio:project-studio", + targetId: "target-studio", + projectId: "project-studio", + runtimeName: "Mac Studio", + displayName: "project-under-test", + rootPath: "/Volumes/work/project-under-test", + }; + const catalogFor = (localModelId: string, displayName: string) => ({ + fetchedAt: "2026-05-22T00:00:00.000Z", + groups: [{ + key: "ollama", + displayName: "Ollama", + providers: [{ + key: "ollama", + displayName: "Ollama", + badgeColor: "#64748B", + modelCount: 1, + subsections: [{ + key: "ollama", + label: "Ollama", + models: [{ + id: localModelId, + runtimeModelId: localModelId, + provider: "ollama", + providerKey: "ollama", + groupKey: "ollama", + displayName, + isDefault: false, + isAvailable: true, + }], + }], + }], + }], + }); + + const session = buildSession("session-studio", { status: "idle", laneId: "lane-studio" }); + installAdeMocks({ sessions: [session] }); + + const modelCatalog = vi.fn(async (_args?: unknown, pin?: { targetId?: string } | null) => ( + pin?.targetId === "target-studio" + ? catalogFor("ollama/studio-only", "Studio Only") + : catalogFor("ollama/bound-only", "Bound Only") + )); + (window.ade.agentChat as any).modelCatalog = modelCatalog; + + useAppStore.setState({ + project: { rootPath: boundRoot, displayName: "project-under-test" } as any, + projectBinding: LOCAL_PROJECT_BINDING, + openRemoteProjectTabs: [studioBinding] as any, + crossMachineLanesByMachineId: { + studio: { + machineId: "studio", + machineName: "Mac Studio", + targetId: studioBinding.targetId, + projectId: studioBinding.projectId, + binding: studioBinding, + online: true, + lanes: [{ + id: "lane-studio", + name: "studio lane", + laneType: "worktree", + branchRef: "refs/heads/studio-lane", + worktreePath: `${studioBinding.rootPath}/.ade/worktrees/studio-lane`, + }], + sessions: [], + prs: [], + lastSyncedAtMs: Date.now(), + error: null, + }, + } as any, + selectedLaneId: "lane-studio", + }); + + renderPane(session); + + const trigger = await screen.findByRole("button", { name: /^Select model/ }); + fireEvent.pointerDown(trigger, { button: 0 }); + fireEvent.click(trigger); + + // The catalog request is addressed to the machine the chat runs on. + await waitFor(() => { + expect(modelCatalog).toHaveBeenCalledWith( + expect.objectContaining({ mode: "cached" }), + expect.objectContaining({ targetId: "target-studio" }), + ); + }); + + // ...and the rows the user can pick under the local-models rail come from + // that machine: the Studio's ollama endpoint, never this Mac's. + fireEvent.click(await screen.findByRole("tab", { name: /^Ollama$/i })); + await waitFor(() => { + expect(document.querySelector('[data-model-id="ollama/studio-only"]')).toBeTruthy(); + }); + expect(document.querySelector('[data-model-id="ollama/bound-only"]')).toBeNull(); + }); + it("applies shared AI status cache updates so Cursor unlocks without remount or force refresh", async () => { const projectRoot = "/tmp/project-under-test"; const unauthorizedStatus: AiSettingsStatus = { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 86b15a3e3..b5aa68796 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -116,7 +116,10 @@ import { ChatLifecycleBanner } from "./ChatLifecycleBanner"; import { ChatSubagentTakeoverBanner } from "./ChatSubagentTakeoverBanner"; import { resolveModelDescriptorWithRuntimeCatalog, descriptorsFromAgentChatModelCatalog } from "../shared/ModelPicker/modelCatalog"; import { latestContextUsageInput, toUsageViewModel, type ContextUsageViewModel } from "./usage/contextUsageModel"; -import { getSharedRuntimeCatalog } from "../shared/ModelPicker/runtimeCatalogCache"; +import { + DEFAULT_RUNTIME_CATALOG_SCOPE, + getSharedRuntimeCatalog, +} from "../shared/ModelPicker/runtimeCatalogCache"; import { familiesFromStatus } from "../shared/ModelPicker/useProviderAuthStatus"; import { AgentChatMessageList, @@ -3479,6 +3482,18 @@ export function AgentChatPane({ setModelPickerOpenRequest(undefined); }, []); const [runtimeCatalogVersion, setRuntimeCatalogVersion] = useState(0); + /** + * Runtime-catalog bucket for this pane's composer — the binding key of the + * machine that will run the turn, or `""` for the machine this window's + * project tab is bound to. + * + * It is state rather than a derived value because the composer's machine + * depends on `useDraftMachineRouting`, which is mounted far below the model + * memos that need the key. The effect that publishes it runs right after the + * binding resolves, so a machine switch costs one extra render — the same + * shape as `runtimeCatalogVersion`. + */ + const [modelCatalogScopeKey, setModelCatalogScopeKey] = useState(DEFAULT_RUNTIME_CATALOG_SCOPE); const [reasoningEffort, setReasoningEffort] = useState(null); const [fastMode, setFastMode] = useState(false); /** @@ -5459,14 +5474,21 @@ export function AgentChatPane({ includeActiveSessionModel: !modelSelectionConstrained, }); if (modelSelectionConstrained) return filterCursorModelIdsForDraftKind(base, workDraftKind); - const catalog = getSharedRuntimeCatalog(); + // Union in the runtime catalog's dynamic ids (ollama, LM Studio, opencode, + // cursor) for the composer's OWN machine — reading the bound machine's + // catalog here would offer models the target machine cannot run. + const catalog = getSharedRuntimeCatalog(modelCatalogScopeKey); if (!catalog) return filterCursorModelIdsForDraftKind(base, workDraftKind); - const runtimeIds = descriptorsFromAgentChatModelCatalog(catalog).availableModelIds; + const runtimeIds = descriptorsFromAgentChatModelCatalog( + catalog, + undefined, + modelCatalogScopeKey, + ).availableModelIds; if (!runtimeIds.length) return filterCursorModelIdsForDraftKind(base, workDraftKind); const merged = new Set(base); for (const id of runtimeIds) merged.add(id); return filterCursorModelIdsForDraftKind([...merged], workDraftKind); - }, [availableModelIds, availableModelIdsOverride, modelSelectionConstrained, selectedSessionModelId, selectedEvents.length, runtimeCatalogVersion, workDraftKind]); + }, [availableModelIds, availableModelIdsOverride, modelCatalogScopeKey, modelSelectionConstrained, selectedSessionModelId, selectedEvents.length, runtimeCatalogVersion, workDraftKind]); const modelPickerProviderAuthStatus = useMemo( () => (aiStatus ? familiesFromStatus(aiStatus, { allowCliOnlyModels: workDraftKind === "cli" }) @@ -5566,9 +5588,13 @@ export function AgentChatPane({ ?? (selectedSession?.cursorCloudAgentId ? "cloud" : "local"); const handoffAvailableModelIds = useMemo(() => { const merged = new Set(availableModelIds); - const catalog = getSharedRuntimeCatalog(); + const catalog = getSharedRuntimeCatalog(modelCatalogScopeKey); if (catalog) { - for (const id of descriptorsFromAgentChatModelCatalog(catalog).availableModelIds) { + for (const id of descriptorsFromAgentChatModelCatalog( + catalog, + undefined, + modelCatalogScopeKey, + ).availableModelIds) { merged.add(id); } } @@ -5581,12 +5607,12 @@ export function AgentChatPane({ .map((model) => model.id); const extras = filtered.filter((modelId) => !ordered.includes(modelId)); extras.sort((left, right) => { - const leftLabel = resolveModelDescriptorWithRuntimeCatalog(left)?.displayName ?? left; - const rightLabel = resolveModelDescriptorWithRuntimeCatalog(right)?.displayName ?? right; + const leftLabel = resolveModelDescriptorWithRuntimeCatalog(left, modelCatalogScopeKey)?.displayName ?? left; + const rightLabel = resolveModelDescriptorWithRuntimeCatalog(right, modelCatalogScopeKey)?.displayName ?? right; return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: "base" }); }); return [...ordered, ...extras]; - }, [availableModelIds, runtimeCatalogVersion, selectedSessionModelId]); + }, [availableModelIds, modelCatalogScopeKey, runtimeCatalogVersion, selectedSessionModelId]); const canShowHandoff = Boolean( lockSessionId && selectedSessionId @@ -11010,6 +11036,33 @@ export function AgentChatPane({ const activeComposerRuntimeBinding = selectedSessionId ? (chatRuntimePin ?? projectBinding) : draftExecutionBinding; + /** + * The composer's machine, but only when it is NOT the one this window's + * project tab is bound to. + * + * Which models a prompt box offers, which of them are configured, and their + * thinking levels are facts about the machine that will run the turn — a + * Work tab unions chats from every machine on the account, so the bound + * machine is frequently not that machine. `null` keeps the bound path (and + * its shared catalog + local IPC fallback) exactly as before, which is the + * common case and costs nothing. + */ + const composerModelRuntimePin = useMemo( + () => ( + activeComposerRuntimeBinding && activeComposerRuntimeBinding.key !== projectBinding?.key + ? activeComposerRuntimeBinding + : null + ), + [activeComposerRuntimeBinding, projectBinding?.key], + ); + const composerModelCatalogScopeKey = composerModelRuntimePin?.key ?? DEFAULT_RUNTIME_CATALOG_SCOPE; + // Layout effect, not effect: this publishes the machine the model memos above + // read from, so running it after paint would show one frame of the previous + // machine's model list when the composer switches machines. Setting the same + // key is a no-op re-render, so the common case still costs nothing. + useLayoutEffect(() => { + setModelCatalogScopeKey(composerModelCatalogScopeKey); + }, [composerModelCatalogScopeKey]); const draftAttachmentMachine = useMemo(() => ({ id: selectedDraftMachineId, name: selectedDraftMachine?.name ?? ( @@ -11464,11 +11517,13 @@ export function AgentChatPane({ availableModelIds={handoffAvailableModelIds} filter={handoffForkModelFilter} onOpenSignIn={openProviderSignIn} + runtimePin={composerModelRuntimePin} />
@@ -11501,9 +11556,11 @@ export function AgentChatPane({ surfaceKey="chat-handoff" availableModelIds={handoffAvailableModelIds} onOpenSignIn={openProviderSignIn} + runtimePin={composerModelRuntimePin} /> @@ -12133,6 +12190,7 @@ export function AgentChatPane({ onPromptHistoryNavigate={handlePromptHistoryNavigate} attachments={attachments} composerMachineBinding={composerMachineBinding} + modelRuntimePin={composerModelRuntimePin} attachmentPersistenceUnavailableReason={draftAttachmentUnavailableReason} contextAttachments={contextAttachments} allowAttachmentOnlySubmit={workDraftKind === "cli"} diff --git a/apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx b/apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx index b5702c9a2..0945e61b2 100644 --- a/apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx @@ -25,7 +25,7 @@ import { resolveProviderGroupForModel, type ProviderFamily, } from "../../../shared/modelRegistry"; -import type { AgentChatProvider } from "../../../shared/types"; +import type { AgentChatProvider, OpenProjectBinding } from "../../../shared/types"; import type { AuthStatus } from "../shared/ModelPicker/ModelPickerRail"; import { cn } from "../ui/cn"; @@ -38,6 +38,14 @@ export type ChatModelSelectionPendingCardProps = { availableModelIds?: string[]; /** Auth status fan-out for the picker rail. */ providerAuthStatus?: Partial>; + /** + * The machine this chat runs on, when it is not the one the project tab is + * bound to. The model chosen here runs on that machine, so its picker rows + * and thinking levels must come from that machine's runtime catalog. + */ + runtimePin?: OpenProjectBinding | null; + /** Catalog bucket matching {@link runtimePin}; empty means the bound machine. */ + catalogScopeKey?: string; /** Disable while a response is in flight. */ responding: boolean; onConfirm: (selection: ModelSelection) => void; @@ -83,6 +91,8 @@ export const ChatModelSelectionPendingCard = memo(function ChatModelSelectionPen metadata, availableModelIds, providerAuthStatus, + runtimePin = null, + catalogScopeKey, responding, onConfirm, onCancel, @@ -230,6 +240,7 @@ export const ChatModelSelectionPendingCard = memo(function ChatModelSelectionPen surfaceKey="orchestration-model-selection-pending" {...(availableModelIds ? { availableModelIds } : {})} {...(providerAuthStatus ? { providerAuthStatus } : {})} + runtimePin={runtimePin} disabled={responding} hidePermissionRail compact @@ -243,6 +254,7 @@ export const ChatModelSelectionPendingCard = memo(function ChatModelSelectionPen onChange={setReasoningEffort} disabled={responding} compact + {...(catalogScopeKey ? { catalogScopeKey } : {})} />
diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index 3fd375039..21c61b899 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -167,7 +167,10 @@ import { resetModelPickerRuntimeCatalogForTests, runtimeCatalogProviderIsFresh, } from "./runtimeCatalogCache"; -import { resetRuntimeCatalogDescriptorCacheForTests } from "./modelCatalog"; +import { + getRuntimeCatalogModelDescriptor, + resetRuntimeCatalogDescriptorCacheForTests, +} from "./modelCatalog"; const SONNET: ModelDescriptor = { id: "anthropic/claude-sonnet-5", @@ -1062,6 +1065,222 @@ describe("ModelPicker", () => { ); }); + // A Work tab unions chats from every machine on the account, so the composer + // for a chat on another machine must describe THAT machine: the catalog names + // local ollama/LM Studio endpoints and the installed cursor-agent, none of + // which the bound machine can answer for. + it("fetches the runtime catalog from the pinned machine and caches it apart from the bound one", async () => { + const user = userEvent.setup(); + const foreignPin = { + kind: "remote" as const, + key: "remote:target-2:project-2", + targetId: "target-2", + projectId: "project-2", + rootPath: "/Users/other/Projects/ADE", + displayName: "Studio", + runtimeName: "Studio", + }; + const modelCatalog = vi.fn(async () => ({ + groups: [], + fetchedAt: "2026-05-18T00:00:00.000Z", + stale: false, + })); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { agentChat: { modelCatalog } }, + }); + + // The bound machine already has a catalog cached; the pinned picker must + // not read it, and must route its own fetch to the pin. + // The bound bucket carries a model only THIS Mac can run. Opening the + // popover used to re-seed from the unscoped cache, which pulled that row + // into the pinned picker even though the fetch itself was routed correctly + // — so assert the descriptor bucket, not just the call. + rememberRuntimeCatalog( + { + fetchedAt: "2026-05-18T00:00:00.000Z", + stale: false, + groups: [{ + key: "ollama", + displayName: "Ollama", + providers: [{ + key: "ollama", + displayName: "Ollama", + badgeColor: "#64748B", + modelCount: 1, + subsections: [{ + key: "ollama", + label: "Ollama", + models: [{ + id: "ollama/bound-only", + runtimeModelId: "ollama/bound-only", + provider: "ollama", + providerKey: "ollama", + groupKey: "ollama", + displayName: "Bound Only", + isDefault: false, + isAvailable: true, + }], + }], + }], + }], + } as never, + { mode: "cached" }, + ); + + renderPicker({ runtimePin: foreignPin as never }); + await user.click(screen.getByRole("button", { name: /Select model/i })); + + await waitFor(() => { + expect(modelCatalog).toHaveBeenCalledWith({ mode: "cached" }, foreignPin); + }); + expect(modelCatalog).not.toHaveBeenCalledWith({ mode: "cached" }); + expect(getRuntimeCatalogModelDescriptor("ollama/bound-only", foreignPin.key)).toBeUndefined(); + expect(document.querySelector('[data-model-id="ollama/bound-only"]')).toBeNull(); + }); + + /** + * Regression for two ways the bound machine's catalog leaked into a pinned + * picker after scoping was introduced: + * + * 1. state held a bare catalog, so the render where the pin changed paired the + * PREVIOUS machine's catalog with the NEW scope key — and parsing writes, + * so machine A's descriptors were filed under machine B; and + * 2. the popover's own open handler re-seeded from the unscoped cache. + * + * Both are invisible in a fetch-level assertion: the request goes to the right + * machine and the wrong rows still show up. So this asserts on the descriptor + * buckets and the rendered rows. + */ + it("never files the bound machine's catalog under a pinned machine", async () => { + const user = userEvent.setup(); + const foreignPin = { + kind: "remote" as const, + key: "remote:target-2:project-2", + targetId: "target-2", + projectId: "project-2", + rootPath: "/remote/chat-project", + displayName: "Studio", + runtimeName: "Studio", + }; + const boundOnlyId = "ollama/bound-only"; + const ollamaCatalog = (modelId: string) => ({ + fetchedAt: "2026-05-18T00:00:00.000Z", + groups: [{ + key: "ollama", + displayName: "Ollama", + providers: [{ + key: "ollama", + displayName: "Ollama", + badgeColor: "#64748B", + modelCount: 1, + subsections: [{ + key: "ollama", + label: "Ollama", + models: [{ + id: modelId, + runtimeModelId: modelId, + provider: "ollama", + providerKey: "ollama", + groupKey: "ollama", + displayName: modelId, + isDefault: false, + isAvailable: true, + }], + }], + }], + }], + }); + + // The bound machine has a loaded catalog; the pinned machine answers with + // its own, and is asked only once the pin is applied. + rememberRuntimeCatalog(ollamaCatalog(boundOnlyId) as never, { mode: "cached" }); + const modelCatalog = vi.fn(async () => ollamaCatalog("ollama/studio-only")); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { agentChat: { modelCatalog } }, + }); + + const { rerender } = renderPicker(); + await user.click(screen.getByRole("button", { name: /Select model/i })); + await waitFor(() => { + expect(getRuntimeCatalogModelDescriptor(boundOnlyId)).toBeDefined(); + }); + + rerender( + , + ); + await waitFor(() => { + expect(getRuntimeCatalogModelDescriptor("ollama/studio-only", foreignPin.key)).toBeDefined(); + }); + + // The bound machine's model must never have been filed under the pin, and + // must not be offered as one of the pinned machine's rows. + expect(getRuntimeCatalogModelDescriptor(boundOnlyId, foreignPin.key)).toBeUndefined(); + expect(document.querySelector(`[data-model-id="${boundOnlyId}"]`)).toBeNull(); + }); + + // Machine scoping must not cost the common case anything. Same-machine + // pickers are the overwhelming majority of surfaces, so they keep the exact + // single-argument call shape they had (which is what lets preload fall back + // to local IPC), and a pin object re-created on every render must not be + // mistaken for a machine change and re-fetch. + it("adds no catalog traffic for the bound machine and ignores pin identity churn", async () => { + const user = userEvent.setup(); + const modelCatalog = vi.fn(async () => ({ + groups: [], + fetchedAt: "2026-05-18T00:00:00.000Z", + stale: false, + })); + Object.defineProperty(window, "ade", { + configurable: true, + writable: true, + value: { agentChat: { modelCatalog } }, + }); + + const { rerender } = renderPicker(); + const trigger = screen.getByRole("button", { name: /Select model/i }); + await user.click(trigger); + await waitFor(() => expect(modelCatalog).toHaveBeenCalledTimes(1)); + + // Re-opening reuses the bucket rather than re-asking the host. + await user.click(trigger); + await user.click(trigger); + expect(modelCatalog).toHaveBeenCalledTimes(1); + // One argument only: no pin on the bound path. + expect(modelCatalog.mock.calls[0]).toHaveLength(1); + + // A fresh pin object with an unchanged key is the same machine. + const pinOf = () => ({ + kind: "remote" as const, + key: "remote:target-2:project-2", + targetId: "target-2", + projectId: "project-2", + rootPath: "/remote/chat-project", + displayName: "Studio", + runtimeName: "Studio", + }); + rerender( + , + ); + await waitFor(() => expect(modelCatalog).toHaveBeenCalledTimes(2)); + for (let i = 0; i < 3; i += 1) { + rerender( + , + ); + } + await Promise.resolve(); + expect(modelCatalog).toHaveBeenCalledTimes(2); + }); + it("renders the Set up banner when the active rail is unauthed and onOpenSignIn is wired", async () => { const user = userEvent.setup(); providerAuthStatusInternal = { anthropic: "unauthed", openai: "unauthed" }; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index e3f0272d2..48067b076 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useEffect, useMemo, useState } from "react"; +import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as Popover from "@radix-ui/react-popover"; import { CaretDown, Lightning } from "@phosphor-icons/react"; import { @@ -18,7 +18,11 @@ import { resolveModelDescriptorWithRuntimeCatalog, } from "./modelCatalog"; import { useModelRecents } from "./useModelRecents"; -import type { AgentChatModelCatalog, AgentChatModelCatalogRefreshProvider } from "../../../../shared/types"; +import type { + AgentChatModelCatalog, + AgentChatModelCatalogRefreshProvider, + OpenProjectBinding, +} from "../../../../shared/types"; import { clearRuntimeCatalogRequest, getRuntimeCatalogRequest, @@ -27,6 +31,7 @@ import { runtimeCatalogProviderIsFresh, setRuntimeCatalogRequest, refreshProviderForFamily, + DEFAULT_RUNTIME_CATALOG_SCOPE, } from "./runtimeCatalogCache"; export type ModelPickerProps = { @@ -42,6 +47,15 @@ export type ModelPickerProps = { providerAuthStatus?: Partial>; onOpenSignIn?: (family?: ProviderFamily, authTypes?: readonly AuthType[]) => void; onRuntimeCatalogRefreshed?: (provider: AgentChatModelCatalogRefreshProvider) => void; + /** + * The machine whose catalog this picker describes, when it is not the one + * this window's project tab is bound to. A runtime catalog is a machine fact + * (local ollama/LM Studio endpoints, installed cursor-agent, opencode + * inventory), and a Work tab shows chats from every machine at once — so a + * picker for a chat on another machine must fetch and cache under THAT + * machine, never the bound one. `null`/omitted means the bound machine. + */ + runtimePin?: OpenProjectBinding | null; constrainToAvailableModelIds?: boolean; /** * Fast mode lives inside the picker (a per-row affordance plus a " Fast" @@ -90,6 +104,7 @@ export const ModelPicker = memo(function ModelPicker({ providerAuthStatus, onOpenSignIn, onRuntimeCatalogRefreshed, + runtimePin, constrainToAvailableModelIds = false, fastMode, onFastModeChange, @@ -104,8 +119,35 @@ export const ModelPicker = memo(function ModelPicker({ openRequestKey, onOpenRequestHandled, }: ModelPickerProps) { + const catalogScopeKey = runtimePin?.key ?? DEFAULT_RUNTIME_CATALOG_SCOPE; + // The scope KEY is the reactive input; the binding object itself is only a + // routing payload. Reading it through a ref keeps `loadRuntimeCatalog` stable + // across renders even if a caller hands us a fresh object each time, so an + // open picker cannot be pushed into repeated cached-catalog fetches. + const runtimePinRef = useRef(runtimePin ?? null); + runtimePinRef.current = runtimePin ?? null; const [open, setOpen] = useState(false); - const [runtimeCatalog, setRuntimeCatalog] = useState(() => getSharedRuntimeCatalog()); + /** + * The rendered catalog is tagged with the machine it came from, and a tag + * mismatch is resolved synchronously against that machine's bucket. + * + * Holding a bare catalog in state let one render pair the PREVIOUS machine's + * catalog with the NEW scope key, and `descriptorsFromAgentChatModelCatalog` + * writes as it parses — so that render filed machine A's descriptors under + * machine B, permanently for any model B does not also report. Deriving the + * value instead of syncing it in an effect makes the pairing impossible, and + * a late in-flight response tagged with the old machine simply never shows. + */ + const [runtimeCatalogState, setRuntimeCatalogState] = useState<{ + scopeKey: string; + catalog: AgentChatModelCatalog | null; + }>(() => ({ scopeKey: catalogScopeKey, catalog: getSharedRuntimeCatalog(catalogScopeKey) })); + const runtimeCatalog = runtimeCatalogState.scopeKey === catalogScopeKey + ? runtimeCatalogState.catalog + : getSharedRuntimeCatalog(catalogScopeKey); + const setRuntimeCatalog = useCallback((catalog: AgentChatModelCatalog | null) => { + setRuntimeCatalogState({ scopeKey: catalogScopeKey, catalog }); + }, [catalogScopeKey]); const [refreshingProvider, setRefreshingProvider] = useState(null); const [refreshErrorProvider, setRefreshErrorProvider] = useState(null); const { recents } = useModelRecents({ hydrate: open }); @@ -133,14 +175,14 @@ export const ModelPicker = memo(function ModelPicker({ refreshProvider?: AgentChatModelCatalogRefreshProvider; }): Promise => { const cursorFlavor = args.refreshProvider === "cursor" ? cursorSource : undefined; - const shared = getSharedRuntimeCatalog(); + const shared = getSharedRuntimeCatalog(catalogScopeKey); if (args.mode === "cached" && shared) { setRuntimeCatalog(shared); return shared; } if (args.mode === "refresh-stale" && args.refreshProvider && shared) { setRuntimeCatalog(shared); - if (runtimeCatalogProviderIsFresh(args.refreshProvider, cursorFlavor)) { + if (runtimeCatalogProviderIsFresh(args.refreshProvider, cursorFlavor, catalogScopeKey)) { setRefreshErrorProvider((current) => current === args.refreshProvider ? null : current); return { ...shared, stale: false }; } @@ -148,7 +190,7 @@ export const ModelPicker = memo(function ModelPicker({ const bridge = window.ade?.agentChat?.modelCatalog; if (typeof bridge !== "function") return null; - const requestKey = `${args.mode}:${args.refreshProvider ?? "all"}:${cursorFlavor ?? "all"}`; + const requestKey = `${catalogScopeKey}|${args.mode}:${args.refreshProvider ?? "all"}:${cursorFlavor ?? "all"}`; const existingRequest = getRuntimeCatalogRequest(requestKey); if (existingRequest) { const next = await existingRequest; @@ -158,13 +200,20 @@ export const ModelPicker = memo(function ModelPicker({ const request = (async () => { try { - const next = await bridge({ + const fetchArgs = { ...args, ...(cursorFlavor ? { cursorSource: cursorFlavor } : {}), - }); + }; + // Only pinned surfaces pass a second argument, so the bound path keeps + // the exact call shape (and the preload's local IPC fallback) it had. + const pin = runtimePinRef.current; + const next = pin + ? await bridge(fetchArgs, pin) + : await bridge(fetchArgs); const visible = rememberRuntimeCatalog(next, { ...args, ...(cursorFlavor ? { cursorSource: cursorFlavor } : {}), + scopeKey: catalogScopeKey, }); setRuntimeCatalog(visible); if (args.refreshProvider) setRefreshErrorProvider((current) => current === args.refreshProvider ? null : current); @@ -180,7 +229,7 @@ export const ModelPicker = memo(function ModelPicker({ clearRuntimeCatalogRequest(requestKey, request); }); return await request; - }, [cursorSource]); + }, [catalogScopeKey, cursorSource, setRuntimeCatalog]); useEffect(() => { if (!open) return; @@ -192,10 +241,10 @@ export const ModelPicker = memo(function ModelPicker({ if (refreshProvider) { void (async () => { const cursorFlavor = refreshProvider === "cursor" ? cursorSource : undefined; - const shared = getSharedRuntimeCatalog(); + const shared = getSharedRuntimeCatalog(catalogScopeKey); if (shared) { setRuntimeCatalog(shared); - if (runtimeCatalogProviderIsFresh(refreshProvider, cursorFlavor)) { + if (runtimeCatalogProviderIsFresh(refreshProvider, cursorFlavor, catalogScopeKey)) { setRefreshErrorProvider((current) => current === refreshProvider ? null : current); return; } @@ -213,11 +262,11 @@ export const ModelPicker = memo(function ModelPicker({ } })(); } - }, [cursorSource, loadRuntimeCatalog, onRuntimeCatalogRefreshed]); + }, [catalogScopeKey, cursorSource, loadRuntimeCatalog, onRuntimeCatalogRefreshed, setRuntimeCatalog]); const catalogModels = useMemo( - () => descriptorsFromAgentChatModelCatalog(runtimeCatalog, filter), - [filter, runtimeCatalog], + () => descriptorsFromAgentChatModelCatalog(runtimeCatalog, filter, catalogScopeKey), + [catalogScopeKey, filter, runtimeCatalog], ); const modelList = useMemo(() => { @@ -240,6 +289,7 @@ export const ModelPicker = memo(function ModelPicker({ selectedValue, filter, constrainToAvailableModelIds ? "available-only" : catalogMode, + catalogScopeKey, ); if (catalogModels.models.length === 0) return fallbackModels; if (constrainToAvailableModelIds) return fallbackModels; @@ -247,7 +297,7 @@ export const ModelPicker = memo(function ModelPicker({ for (const model of fallbackModels) merged.set(model.id, model); for (const model of catalogModels.models) merged.set(model.id, model); return [...merged.values()]; - }, [models, availableModelIds, value, filter, catalogMode, catalogModels.models, constrainToAvailableModelIds]); + }, [models, availableModelIds, value, filter, catalogMode, catalogModels.models, catalogScopeKey, constrainToAvailableModelIds]); const effectiveValue = useMemo(() => { if (value && value.length > 0) return value; @@ -262,8 +312,9 @@ export const ModelPicker = memo(function ModelPicker({ const selectedModel = useMemo(() => { if (!value) return undefined; - return resolveModelDescriptorWithRuntimeCatalog(value) ?? createUnknownModelPlaceholder(value); - }, [value]); + return resolveModelDescriptorWithRuntimeCatalog(value, catalogScopeKey) + ?? createUnknownModelPlaceholder(value); + }, [catalogScopeKey, value]); const availableSet = useMemo(() => { const ids = constrainToAvailableModelIds || !runtimeCatalog @@ -323,7 +374,7 @@ export const ModelPicker = memo(function ModelPicker({ setOpen(false); return; } - const shared = getSharedRuntimeCatalog(); + const shared = getSharedRuntimeCatalog(catalogScopeKey); if (next && shared) { setRuntimeCatalog(shared); } diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx index 84a00d735..ab6766540 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx @@ -16,6 +16,13 @@ export type ReasoningEffortPickerProps = { disabled?: boolean; className?: string; triggerClassName?: string; + /** + * Binding key of the machine whose runtime catalog describes `modelId`. + * Thinking levels come from that catalog's `reasoningEfforts`, so a composer + * targeting another machine must read the tiers that machine reported, not + * the bound machine's. Omitted means the bound machine. + */ + catalogScopeKey?: string; }; export function reasoningChipLabel( @@ -175,6 +182,7 @@ export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({ disabled = false, className, triggerClassName, + catalogScopeKey, }: ReasoningEffortPickerProps) { const [open, setOpen] = useState(false); const activePointerGestureRef = useRef(null); @@ -197,8 +205,8 @@ export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({ }, []); const descriptor = useMemo( - () => resolveModelDescriptorWithRuntimeCatalog(modelId), - [modelId], + () => resolveModelDescriptorWithRuntimeCatalog(modelId, catalogScopeKey), + [catalogScopeKey, modelId], ); const tiers = useMemo( diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts index 6513ce6b4..247ed87a4 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { descriptorsFromAgentChatModelCatalog, + getRuntimeCatalogModelDescriptor, mergeSelectorModels, resetRuntimeCatalogDescriptorCacheForTests, resolveModelDescriptorWithRuntimeCatalog, @@ -8,6 +9,7 @@ import { import { sortModelItems } from "./modelOrdering"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; import { + getSharedRuntimeCatalog, rememberRuntimeCatalog, resetModelPickerRuntimeCatalogForTests, runtimeCatalogProviderIsFresh, @@ -452,3 +454,143 @@ describe("runtime catalog cache flavor-aware cursor freshness", () => { expect(runtimeCatalogProviderIsFresh("cursor")).toBe(false); }); }); + +/** + * A runtime catalog is a fact about ONE machine — its ollama/LM Studio + * endpoints, its installed cursor-agent, its opencode inventory. The Work tab + * shows chats from every machine on the account at once, so the cache and the + * descriptors it feeds are bucketed per machine; a composer targeting machine B + * must never be answered from machine A's catalog. + */ +describe("runtime catalog machine scoping", () => { + const FOREIGN_SCOPE = "remote:target-2:project-2"; + + beforeEach(() => { + resetModelPickerRuntimeCatalogForTests(); + resetRuntimeCatalogDescriptorCacheForTests(); + }); + + function localModelCatalog(modelId: string, reasoningEfforts: string[]): AgentChatModelCatalog { + return { + groups: [{ + key: "ollama", + label: "Ollama", + providers: [{ + key: "ollama", + displayName: "Ollama", + modelCount: 1, + subsections: [{ + key: "ollama", + label: "Ollama", + models: [{ + id: modelId, + displayName: modelId, + family: "ollama", + groupKey: "ollama", + isAvailable: true, + supportsReasoning: true, + supportsTools: true, + reasoningEfforts: reasoningEfforts.map((effort) => ({ effort })), + }], + }], + }], + }], + fetchedAt: "2026-05-18T00:00:00.000Z", + stale: false, + } as unknown as AgentChatModelCatalog; + } + + it("keeps each machine's catalog in its own bucket", () => { + const bound = localModelCatalog("ollama/llama-bound", ["low"]); + const foreign = localModelCatalog("ollama/llama-foreign", ["low"]); + + rememberRuntimeCatalog(bound, { mode: "cached" }); + rememberRuntimeCatalog(foreign, { mode: "cached", scopeKey: FOREIGN_SCOPE }); + + expect(getSharedRuntimeCatalog()).toBe(bound); + expect(getSharedRuntimeCatalog(FOREIGN_SCOPE)).toBe(foreign); + }); + + it("does not offer one machine's local models to another machine's picker", () => { + rememberRuntimeCatalog(localModelCatalog("ollama/llama-bound", ["low"]), { mode: "cached" }); + const boundIds = descriptorsFromAgentChatModelCatalog( + getSharedRuntimeCatalog(), + ).availableModelIds; + + expect(boundIds).toContain("ollama/llama-bound"); + // The foreign machine has reported nothing yet, so its picker has no + // catalog at all rather than inheriting the bound machine's rows. + expect(getSharedRuntimeCatalog(FOREIGN_SCOPE)).toBeNull(); + }); + + it("resolves thinking levels from the composer's own machine", () => { + const sharedId = "ollama/llama-3"; + rememberRuntimeCatalog(localModelCatalog(sharedId, ["low"]), { mode: "cached" }); + rememberRuntimeCatalog(localModelCatalog(sharedId, ["low", "high"]), { + mode: "cached", + scopeKey: FOREIGN_SCOPE, + }); + descriptorsFromAgentChatModelCatalog(getSharedRuntimeCatalog()); + descriptorsFromAgentChatModelCatalog(getSharedRuntimeCatalog(FOREIGN_SCOPE), undefined, FOREIGN_SCOPE); + + expect(resolveModelDescriptorWithRuntimeCatalog(sharedId)?.reasoningTiers).toEqual(["low"]); + expect(resolveModelDescriptorWithRuntimeCatalog(sharedId, FOREIGN_SCOPE)?.reasoningTiers) + .toEqual(["low", "high"]); + }); + + // Regression: the scoped lookup used to answer a miss from the bound + // machine's bucket. That is the same cross-machine leak in a narrower place — + // a composer on the Studio would show the ladder THIS Mac reported for the + // same model id, whenever the Studio's catalog had not loaded yet (the normal + // state before its picker is first opened). + it("never answers one machine's descriptor miss from another machine's bucket", () => { + const sharedId = "ollama/llama-3"; + rememberRuntimeCatalog(localModelCatalog(sharedId, ["low", "high", "max"]), { mode: "cached" }); + descriptorsFromAgentChatModelCatalog(getSharedRuntimeCatalog()); + + // The bound machine knows this model and its ladder... + expect(resolveModelDescriptorWithRuntimeCatalog(sharedId)?.reasoningTiers) + .toEqual(["low", "high", "max"]); + // ...and the machine that has reported nothing must not inherit either. + expect(getRuntimeCatalogModelDescriptor(sharedId, FOREIGN_SCOPE)).toBeUndefined(); + expect(resolveModelDescriptorWithRuntimeCatalog(sharedId, FOREIGN_SCOPE)?.reasoningTiers) + .toBeUndefined(); + }); + + // The bucket cap is a backstop, but evicting the BOUND machine would make the + // common case refetch — it is the bucket every unpinned surface reads. Its + // descriptors ride in the same bucket, so eviction must drop both together + // rather than leaving a descriptor registry to grow on its own. + it("caps machine buckets without ever evicting the bound machine", () => { + rememberRuntimeCatalog(localModelCatalog("ollama/bound", ["low"]), { mode: "cached" }); + descriptorsFromAgentChatModelCatalog(getSharedRuntimeCatalog()); + expect(getRuntimeCatalogModelDescriptor("ollama/bound")).toBeDefined(); + + // Far more machines than the cap, none of them the bound one. + for (let i = 0; i < 20; i += 1) { + const scopeKey = `remote:target-${i}:project-${i}`; + rememberRuntimeCatalog(localModelCatalog(`ollama/m-${i}`, ["low"]), { mode: "cached", scopeKey }); + descriptorsFromAgentChatModelCatalog(getSharedRuntimeCatalog(scopeKey), undefined, scopeKey); + } + + // The bound machine survived, catalog and descriptors together... + expect(getSharedRuntimeCatalog()).not.toBeNull(); + expect(getRuntimeCatalogModelDescriptor("ollama/bound")).toBeDefined(); + // ...the most recent foreign machine is still cached... + expect(getSharedRuntimeCatalog("remote:target-19:project-19")).not.toBeNull(); + // ...and the oldest foreign machine was evicted whole, leaving no orphaned + // descriptors behind it. + expect(getSharedRuntimeCatalog("remote:target-0:project-0")).toBeNull(); + expect(getRuntimeCatalogModelDescriptor("ollama/m-0", "remote:target-0:project-0")).toBeUndefined(); + }); + + it("marks provider freshness per machine so one machine's refresh cannot silence another's", () => { + rememberRuntimeCatalog(cursorCatalog({ sdk: true, cli: true }), { + mode: "force", + refreshProvider: "cursor", + }); + + expect(runtimeCatalogProviderIsFresh("cursor", "sdk")).toBe(true); + expect(runtimeCatalogProviderIsFresh("cursor", "sdk", FOREIGN_SCOPE)).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts index eee52687a..e9436a3f9 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts @@ -13,23 +13,43 @@ import { } from "../../../../shared/modelRegistry"; import type { AgentChatModelCatalog } from "../../../../shared/types"; import { PROVIDER_BADGE_COLORS } from "../providerModelSelectorGrouping"; - -const runtimeCatalogDescriptorsById = new Map(); +import { + DEFAULT_RUNTIME_CATALOG_SCOPE, + clearRuntimeCatalogScopeDescriptors, + peekRuntimeCatalogScopeDescriptors, + runtimeCatalogScopeDescriptors, +} from "./runtimeCatalogCache"; export function resetRuntimeCatalogDescriptorCacheForTests(): void { - runtimeCatalogDescriptorsById.clear(); + clearRuntimeCatalogScopeDescriptors(); } -export function getRuntimeCatalogModelDescriptor(modelId: string | null | undefined): ModelDescriptor | undefined { +/** + * A catalog descriptor states machine-specific facts — reasoning tiers, context + * window, availability — so it is only ever read from the machine that reported + * it. There is deliberately NO fallback to another machine's bucket: answering + * a miss with the bound machine's descriptor is exactly the cross-machine leak + * this bucketing exists to prevent (it would hand a composer on machine B the + * thinking-level ladder machine A reported for the same model id). A miss falls + * through to the static registry, and past that to an unknown-model placeholder + * — correct-but-generic beats confident-and-wrong. + */ +export function getRuntimeCatalogModelDescriptor( + modelId: string | null | undefined, + scopeKey: string = DEFAULT_RUNTIME_CATALOG_SCOPE, +): ModelDescriptor | undefined { const id = modelId?.trim(); if (!id) return undefined; - return runtimeCatalogDescriptorsById.get(id); + return peekRuntimeCatalogScopeDescriptors(scopeKey)?.get(id); } -export function resolveModelDescriptorWithRuntimeCatalog(modelId: string | null | undefined): ModelDescriptor | undefined { +export function resolveModelDescriptorWithRuntimeCatalog( + modelId: string | null | undefined, + scopeKey: string = DEFAULT_RUNTIME_CATALOG_SCOPE, +): ModelDescriptor | undefined { const id = modelId?.trim(); if (!id) return undefined; - return getRuntimeCatalogModelDescriptor(id) ?? resolveModelDescriptor(id); + return getRuntimeCatalogModelDescriptor(id, scopeKey) ?? resolveModelDescriptor(id); } export function createUnknownModelPlaceholder(modelId: string): ModelDescriptor { @@ -121,6 +141,7 @@ export function mergeSelectorModels( selectedModelId?: string, filter?: (model: ModelDescriptor) => boolean, catalogMode: "all" | "available-only" = "all", + scopeKey: string = DEFAULT_RUNTIME_CATALOG_SCOPE, ): ModelDescriptor[] { const merged = new Map(); const selectedId = String(selectedModelId ?? "").trim(); @@ -138,7 +159,7 @@ export function mergeSelectorModels( } for (const rawId of availableIdSet) { - const descriptor = resolveModelDescriptorWithRuntimeCatalog(rawId); + const descriptor = resolveModelDescriptorWithRuntimeCatalog(rawId, scopeKey); if (descriptor) { if (descriptor.deprecated) continue; if (filter && !filter(descriptor)) continue; @@ -151,7 +172,7 @@ export function mergeSelectorModels( } if (selectedId && !merged.has(selectedId)) { - const selectedDescriptor = resolveModelDescriptorWithRuntimeCatalog(selectedId); + const selectedDescriptor = resolveModelDescriptorWithRuntimeCatalog(selectedId, scopeKey); if (selectedDescriptor && !selectedDescriptor.deprecated && (!filter || filter(selectedDescriptor))) { merged.set(selectedDescriptor.id, rebucketOpenCodeFamily(selectedDescriptor)); } else if (!selectedDescriptor) { @@ -193,10 +214,12 @@ function pickerFamilyForCatalogGroup(groupKey: string, fallbackFamily?: string): export function descriptorsFromAgentChatModelCatalog( catalog: AgentChatModelCatalog | null | undefined, filter?: (model: ModelDescriptor) => boolean, + scopeKey: string = DEFAULT_RUNTIME_CATALOG_SCOPE, ): { models: RuntimeCatalogModelDescriptor[]; availableModelIds: string[] } { if (!catalog) return { models: [], availableModelIds: [] }; const merged = new Map(); const available = new Set(); + const scopedDescriptors = runtimeCatalogScopeDescriptors(scopeKey); for (const group of catalog.groups ?? []) { for (const provider of group.providers ?? []) { for (const subsection of provider.subsections ?? []) { @@ -257,7 +280,7 @@ export function descriptorsFromAgentChatModelCatalog( }; if (filter && !filter(descriptor)) continue; merged.set(descriptor.id, descriptor); - runtimeCatalogDescriptorsById.set(descriptor.id, descriptor); + scopedDescriptors.set(descriptor.id, descriptor); if (model.isAvailable) available.add(descriptor.id); } } diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts index bfb90dbfa..005c14914 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts @@ -1,5 +1,5 @@ import type { AgentChatModelCatalog, AgentChatModelCatalogRefreshProvider } from "../../../../shared/types"; -import type { ProviderFamily } from "../../../../shared/modelRegistry"; +import type { ModelDescriptor, ProviderFamily } from "../../../../shared/modelRegistry"; export function refreshProviderForFamily(family: ProviderFamily): AgentChatModelCatalogRefreshProvider | null { if (family === "opencode") return "opencode"; @@ -22,23 +22,85 @@ const REFRESH_PROVIDERS: AgentChatModelCatalogRefreshProvider[] = [ "ollama", ]; -let sharedRuntimeCatalog: AgentChatModelCatalog | null = null; -const sharedRuntimeCatalogProviderRefreshedAt = new Map(); -// Cursor freshness is per discovery source: an SDK-scoped refresh must not -// mark the CLI surface fresh (a later Work-tab CLI picker would otherwise -// short-circuit its force refresh for the TTL and miss CLI-only changes). -const cursorSourceRefreshedAt = new Map<"sdk" | "cli", number>(); +/** + * A runtime model catalog is a MACHINE fact, not a process fact: the ollama and + * LM Studio endpoints it enumerates, the installed `cursor-agent`, and the + * opencode inventory all live on whichever machine served `chat.modelCatalog`. + * A Work tab shows chats from every machine on the account at once, so a single + * process-global catalog describes the project tab's machine while a composer + * may target another — every entry is keyed by the binding key of the machine + * it describes. `""` is the machine this window's project tab is bound to. + */ +export const DEFAULT_RUNTIME_CATALOG_SCOPE = ""; + +type RuntimeCatalogScopeState = { + catalog: AgentChatModelCatalog | null; + providerRefreshedAt: Map; + // Cursor freshness is per discovery source: an SDK-scoped refresh must not + // mark the CLI surface fresh (a later Work-tab CLI picker would otherwise + // short-circuit its force refresh for the TTL and miss CLI-only changes). + cursorSourceRefreshedAt: Map<"sdk" | "cli", number>; + // Descriptors parsed out of this machine's catalog. They live beside the + // catalog rather than in a parallel registry so one cap and one eviction + // govern both, and a dropped scope can never leave descriptors behind. + descriptorsById: Map; +}; + +// Scopes are bounded by the project bindings a window has open, so this cap is +// only a backstop against unbounded growth over a long-lived session. +const MAX_RUNTIME_CATALOG_SCOPES = 8; +const runtimeCatalogScopes = new Map(); const sharedRuntimeCatalogRequests = new Map>(); +function peekRuntimeCatalogScope(scopeKey: string): RuntimeCatalogScopeState | undefined { + return runtimeCatalogScopes.get(scopeKey); +} + +function runtimeCatalogScope(scopeKey: string): RuntimeCatalogScopeState { + const existing = runtimeCatalogScopes.get(scopeKey); + if (existing) return existing; + for (const key of runtimeCatalogScopes.keys()) { + if (runtimeCatalogScopes.size < MAX_RUNTIME_CATALOG_SCOPES) break; + // Never evict the bound machine: it is the hottest bucket and the one every + // unpinned surface reads, so dropping it would refetch the common case. + if (key === DEFAULT_RUNTIME_CATALOG_SCOPE) continue; + runtimeCatalogScopes.delete(key); + } + const created: RuntimeCatalogScopeState = { + catalog: null, + providerRefreshedAt: new Map(), + cursorSourceRefreshedAt: new Map(), + descriptorsById: new Map(), + }; + runtimeCatalogScopes.set(scopeKey, created); + return created; +} + +/** This machine's parsed descriptors, created on first write. */ +export function runtimeCatalogScopeDescriptors(scopeKey: string): Map { + return runtimeCatalogScope(scopeKey).descriptorsById; +} + +/** This machine's parsed descriptors, or undefined when it has reported none. */ +export function peekRuntimeCatalogScopeDescriptors( + scopeKey: string, +): Map | undefined { + return peekRuntimeCatalogScope(scopeKey)?.descriptorsById; +} + +export function clearRuntimeCatalogScopeDescriptors(): void { + for (const scope of runtimeCatalogScopes.values()) scope.descriptorsById.clear(); +} + export function resetModelPickerRuntimeCatalogForTests(): void { - sharedRuntimeCatalog = null; - sharedRuntimeCatalogProviderRefreshedAt.clear(); - cursorSourceRefreshedAt.clear(); + runtimeCatalogScopes.clear(); sharedRuntimeCatalogRequests.clear(); } -export function getSharedRuntimeCatalog(): AgentChatModelCatalog | null { - return sharedRuntimeCatalog; +export function getSharedRuntimeCatalog( + scopeKey: string = DEFAULT_RUNTIME_CATALOG_SCOPE, +): AgentChatModelCatalog | null { + return peekRuntimeCatalogScope(scopeKey)?.catalog ?? null; } function runtimeCatalogRefreshTtlMs(provider?: AgentChatModelCatalogRefreshProvider): number { @@ -78,42 +140,47 @@ function shouldMarkRefreshProviderFresh( } function markRuntimeCatalogProviderFresh( + scopeKey: string, provider: AgentChatModelCatalogRefreshProvider, refreshedAt = Date.now(), cursorFlavor?: "sdk" | "cli", ): void { + const scope = runtimeCatalogScope(scopeKey); if (provider === "cursor") { const sources: ("sdk" | "cli")[] = cursorFlavor ? [cursorFlavor] : ["sdk", "cli"]; for (const source of sources) { // Without an explicit flavor (generic cached-reuse marking), only mark a // source fresh if the catalog actually carries rows it can run, so an // sdk-only catalog never marks the cli surface fresh. - if (!cursorFlavor && sharedRuntimeCatalog && !catalogContainsRefreshProvider(sharedRuntimeCatalog, "cursor", source)) { + if (!cursorFlavor && scope.catalog && !catalogContainsRefreshProvider(scope.catalog, "cursor", source)) { continue; } - cursorSourceRefreshedAt.set(source, refreshedAt); + scope.cursorSourceRefreshedAt.set(source, refreshedAt); } return; } - sharedRuntimeCatalogProviderRefreshedAt.set(provider, refreshedAt); + scope.providerRefreshedAt.set(provider, refreshedAt); } export function runtimeCatalogProviderIsFresh( provider: AgentChatModelCatalogRefreshProvider, cursorFlavor?: "sdk" | "cli", + scopeKey: string = DEFAULT_RUNTIME_CATALOG_SCOPE, ): boolean { + const scope = peekRuntimeCatalogScope(scopeKey); + if (!scope) return false; if (provider === "cursor") { - if (!sharedRuntimeCatalog || !catalogContainsRefreshProvider(sharedRuntimeCatalog, provider, cursorFlavor)) { + if (!scope.catalog || !catalogContainsRefreshProvider(scope.catalog, provider, cursorFlavor)) { return false; } const sources: ("sdk" | "cli")[] = cursorFlavor ? [cursorFlavor] : ["sdk", "cli"]; const ttl = runtimeCatalogRefreshTtlMs(provider); return sources.every((source) => { - const at = cursorSourceRefreshedAt.get(source); + const at = scope.cursorSourceRefreshedAt.get(source); return Boolean(at && Date.now() - at <= ttl); }); } - const refreshedAt = sharedRuntimeCatalogProviderRefreshedAt.get(provider); + const refreshedAt = scope.providerRefreshedAt.get(provider); return Boolean(refreshedAt && Date.now() - refreshedAt <= runtimeCatalogRefreshTtlMs(provider)); } @@ -123,34 +190,37 @@ export function rememberRuntimeCatalog( mode: "cached" | "refresh-stale" | "force"; refreshProvider?: AgentChatModelCatalogRefreshProvider; cursorSource?: "sdk" | "cli"; + scopeKey?: string; }, ): AgentChatModelCatalog { - if (args.mode === "cached" && sharedRuntimeCatalog) { + const scopeKey = args.scopeKey ?? DEFAULT_RUNTIME_CATALOG_SCOPE; + const scope = runtimeCatalogScope(scopeKey); + if (args.mode === "cached" && scope.catalog) { for (const provider of REFRESH_PROVIDERS) { if ( - runtimeCatalogProviderIsFresh(provider) - && catalogContainsRefreshProvider(sharedRuntimeCatalog, provider) + runtimeCatalogProviderIsFresh(provider, undefined, scopeKey) + && catalogContainsRefreshProvider(scope.catalog, provider) && !catalogContainsRefreshProvider(catalog, provider) ) { - return sharedRuntimeCatalog; + return scope.catalog; } } } - sharedRuntimeCatalog = catalog; + scope.catalog = catalog; const cursorFlavor = args.refreshProvider === "cursor" ? args.cursorSource : undefined; if ( args.refreshProvider && (args.mode === "force" || catalog.stale !== true) && shouldMarkRefreshProviderFresh(catalog, args.refreshProvider, cursorFlavor) ) { - markRuntimeCatalogProviderFresh(args.refreshProvider, Date.now(), cursorFlavor); + markRuntimeCatalogProviderFresh(scopeKey, args.refreshProvider, Date.now(), cursorFlavor); return catalog; } if (args.mode === "cached" && catalog.stale !== true) { for (const provider of REFRESH_PROVIDERS) { if (catalogContainsRefreshProvider(catalog, provider)) { - markRuntimeCatalogProviderFresh(provider); + markRuntimeCatalogProviderFresh(scopeKey, provider); } } } diff --git a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts index 46e980c66..babfc254f 100644 --- a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts +++ b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts @@ -268,12 +268,17 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age await call("chat.respondToInput", args, undefined, false); }, models: (args: unknown) => call("chat.models", args, []), - modelCatalog: (args?) => - call("chat.modelCatalog", args, { + modelCatalog: (args?, pin?) => { + // A catalog describes the machine that served it, so a foreign pin cannot + // be answered from this adapter's single connection. The picker treats a + // rejection as "no catalog" and falls back to the pin-scoped model list. + assertWebRuntimePinRoutable("agentChat.modelCatalog", pin, infra); + return call("chat.modelCatalog", args, { groups: [], fetchedAt: new Date(0).toISOString(), stale: true, - }), + }); + }, archive: async (args: unknown) => { await call("chat.archive", args, undefined, false); }, diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 4ae09a6ca..b85e82036 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -1331,7 +1331,7 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. | `ade.agentChat.getSubagentTranscript` | invoke | Fetch the transcript of one subagent run within a chat session. Dispatch by runtime kind: Claude/ade-code reads the SDK's per-subagent JSONL at `~/.claude/projects///subagents/agent-.jsonl`; OpenCode pulls the child session's messages over the OpenCode HTTP client (the child session id is the agentId); Codex returns captured child-thread stream rows for the registered collab thread and merges them with `thread/turns/list` app-server backfill, with the legacy parent `subagent_*` envelope filter only as fallback; Cursor filters the parent stream by either the returned child `agentId` or the Task tool call `taskId`, since failed starts may not return an agent id; LM Studio / Droid return `null`. Returns `AgentChatSubagentTranscriptMessage[]` (same shape as `AgentChatClaudeSessionMessage`). | | `ade.agentChat.getMainTranscript` | invoke | Claude-only provider-fidelity view for an ADE chat session. Resolves the mirrored SDK session id, includes SDK system messages, and returns the byte-bounded subagent-transcript message shape. This is an alternate on-demand view and does not include ADE-only envelope events. | | `ade.agentChat.models` | invoke | `{ provider, activateRuntime? }`. For OpenCode `activateRuntime: true` is required to *launch* a probe server; otherwise the main process only returns the cached inventory (via `peekOpenCodeInventoryCache`) and an empty list until a real probe has been run. Cursor and Droid always use `activateRuntime: true` in the TUI model listing path so the SDK can enumerate available models. The renderer cache (`aiDiscoveryCache.ts`) keys on `(projectRoot, OpenProjectBinding, provider, activateRuntime)` so local/remote and passive/active reads cannot collide. | -| `ade.agentChat.modelCatalog` | invoke | `{ mode?, refreshProvider? }` → `AgentChatModelCatalog`. Returns the full provider-grouped catalog (claude / codex / cursor / droid / opencode plus the local `ollama` / `lmstudio` groups when OpenCode-routed) for the desktop and TUI ModelPickers. `mode: "cached"` returns the in-memory snapshot, `"refresh-stale"` reuses the cache but optionally re-probes the named runtime when its per-provider freshness TTL is expired, and `"force"` re-probes unconditionally. `refreshProvider` is one of `"opencode" | "cursor" | "droid" | "lmstudio" | "ollama"`. The catalog carries an optional `stale: true` flag and per-model `connected` / `requiresConfiguration` / `sourceRuntime` / `providerId` / `providerName` / `serviceTiers` annotations; Cursor rows also carry `cursorAvailability` so renderer and TUI pickers can separate SDK chat models from CLI launch models. | +| `ade.agentChat.modelCatalog` | invoke | `({ mode?, refreshProvider? }, pin?)` → `AgentChatModelCatalog`. Takes the same optional `OpenProjectBinding` pin as `ade.agentChat.models`: a catalog describes the machine that served it (its ollama/LM Studio endpoints, its installed `cursor-agent`, its opencode inventory), so a composer for a chat on another machine passes that machine's binding and the renderer caches the result under it. Omitting the pin keeps the bound-runtime path and its local-IPC fallback. Returns the full provider-grouped catalog (claude / codex / cursor / droid / opencode plus the local `ollama` / `lmstudio` groups when OpenCode-routed) for the desktop and TUI ModelPickers. `mode: "cached"` returns the in-memory snapshot, `"refresh-stale"` reuses the cache but optionally re-probes the named runtime when its per-provider freshness TTL is expired, and `"force"` re-probes unconditionally. `refreshProvider` is one of `"opencode" | "cursor" | "droid" | "lmstudio" | "ollama"`. The catalog carries an optional `stale: true` flag and per-model `connected` / `requiresConfiguration` / `sourceRuntime` / `providerId` / `providerName` / `serviceTiers` annotations; Cursor rows also carry `cursorAvailability` so renderer and TUI pickers can separate SDK chat models from CLI launch models. | | `ade.agentChat.getSessionCapabilities` | invoke | Discover supported subagent/review features. | | `ade.agentChat.getTurnFileDiff` | invoke | Lazy diff expansion for a turn-file-summary row. | | `ade.agentChat.event` | push | Stream of `AgentChatEventEnvelope` into the renderer. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 2c019e0d1..76418bf2f 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -372,7 +372,28 @@ that could not work without it. Cursor / Droid / OpenCode, 30 s for `lmstudio` / `ollama`). Cursor runtime rows carry `cursorAvailability`, so chat surfaces hide CLI-only models while Work CLI setup includes them and hides - SDK-only/chat-only rows. When a + SDK-only/chat-only rows. + + **Everything a prompt box offers is scoped to the machine set in that + prompt box.** A Work tab unions chats from every machine on the account, + so the machine a chat runs on is frequently not the one the project tab + is bound to — and a runtime catalog is a machine fact (local ollama / + LM Studio endpoints, the installed `cursor-agent`, the opencode + inventory). `AgentChatPane` derives `composerModelRuntimePin` from + `activeComposerRuntimeBinding` (the session's `chatRuntimePin`, or the + draft shelf's machine) whenever it differs from the bound binding, and + passes it to `AgentChatComposer` as `modelRuntimePin`. The picker routes + `agentChat.modelCatalog(args, pin)` to that machine — the same pin + `agentChat.models` already takes — and caches the result under that + binding key, so machine A's local models can never appear in, or supply + thinking levels to, a composer targeting machine B. A `null` pin (the + common same-machine case) keeps the bound path, its shared catalog + bucket and the preload local-IPC fallback exactly as before, so the + scoping costs no extra probes for ordinary use. Provider *availability* + (`aiStatus`) was already pin-keyed in `aiDiscoveryCache.ts`; this brings + the catalog onto the same rule. On ADE Web a foreign pin is rejected by + `assertWebRuntimePinRoutable` (single-machine adapter) and the picker + falls back to the pin-scoped model list. When a caller passes `availableModelIdsOverride`, `AgentChatPane` constrains selection to exactly those ids: `filterChatModelIdsForSession({ includeActiveSessionModel: false })` skips the usual "preserve the @@ -690,11 +711,11 @@ power the TUI picker (`apps/ade-cli/src/tuiClient/components/ModelPicker/`). | `ModelPickerRail.tsx` | Left-rail tabs (Favorites / Recents / per-provider groups). Reads `AuthStatus` per family to render auth gates and the OpenCode "Install OpenCode" CTA from `providerEmptyState`. | | `ModelListRow.tsx` | A single model row (favorite star, brand logo, display name, sub-provider chip, availability tone). Also renders the muted Fast chip when the surface supplied `onFastModeChange` and `modelSupportsFastMode()` holds for that row's descriptor; toggling it changes neither the selection nor the popover's open state. | | `ReasoningEffortPicker.tsx` | Standalone reasoning-effort dropdown, mounted next to the model trigger and inside per-slot parallel-launch controls. | -| `modelCatalog.ts` | `descriptorsFromAgentChatModelCatalog`, `mergeSelectorModels`, `resolveModelDescriptorWithRuntimeCatalog`, `createUnknownModelPlaceholder` — pure helpers that flatten the IPC catalog into a `ModelDescriptor[]` and reconcile it with the static registry while preserving runtime metadata such as `serviceTiers` and Cursor `cursorAvailability`. | +| `modelCatalog.ts` | `descriptorsFromAgentChatModelCatalog`, `mergeSelectorModels`, `resolveModelDescriptorWithRuntimeCatalog`, `createUnknownModelPlaceholder` — pure helpers that flatten the IPC catalog into a `ModelDescriptor[]` and reconcile it with the static registry while preserving runtime metadata such as `serviceTiers` and Cursor `cursorAvailability`. All four take the same optional catalog scope key as `runtimeCatalogCache.ts`: descriptors are remembered per machine because a catalog's `reasoningEfforts` (the thinking-level ladder) and context window are machine-reported. There is deliberately **no fallback to another machine's bucket** — answering a miss from the bound machine is the same cross-machine leak the bucketing exists to prevent. A miss falls through to the static registry and then to `createUnknownModelPlaceholder`: correct-but-generic beats confident-and-wrong. | | `modelOrdering.ts` | `sortModelItems` — provider/group ordering and intra-group ranking (favorites first, then recents, then default registry order). | | `modelPickerSearch.ts` | `scoreModelPickerSearch` — fuzzy search across display name, family, provider, and ids; ranks favorites/recents above strict matches. | | `providerEmptyState.tsx` | Per-provider empty/auth/install CTA copy. Surfaces "Install OpenCode" when the binary is missing, "Sign in to Cursor" when auth is missing, etc. | -| `runtimeCatalogCache.ts` | Renderer-side shared catalog cache. Tracks per-provider freshness (30 min for `opencode`/`cursor`/`droid`, 30 s for `lmstudio`/`ollama`) and dedupes concurrent `modelCatalog` requests by `${mode}:${refreshProvider}` keys. | +| `runtimeCatalogCache.ts` | Renderer-side catalog cache, **bucketed per machine** by binding key (`DEFAULT_RUNTIME_CATALOG_SCOPE` = `""` is the bound machine; capped at 8 scopes). Each bucket tracks its own per-provider freshness (30 min for `opencode`/`cursor`/`droid`, 30 s for `lmstudio`/`ollama`), so one machine's refresh cannot mark another's providers fresh. Each bucket also owns the descriptors parsed from its catalog, so one cap and one eviction govern both and a dropped scope cannot leave descriptors behind; the bound machine's bucket is never evicted. Concurrent `modelCatalog` requests dedupe by `${scopeKey}|${mode}:${refreshProvider}:${cursorSource}`. Bucketing also fixes a stale read that predates cross-machine Work: nothing clears the cache when the project binding changes, so a single global catalog kept describing the previous machine after a switch. | | `useProviderAuthStatus.ts` | Resolves `AuthStatus` (`ok` / `limited` / `unauthed` / `unknown`) per `ProviderFamily` from the runtime-binding-scoped `aiDiscoveryCache`. A picker with no explicit `providerAuthStatus` seeds from the cached value, joins the shared single-flight refresh, and reacts to cache update/invalidation events; callers that already supply status opt out of the full fetch. The separate cheap OpenCode-binary probe is deduplicated by runtime/project scope. | | `useAuthOnlyFilter.ts` | Hides models whose provider is not authenticated, with a toggle for the catalog browse mode. | | `useModelFavorites.ts` / `useModelRecents.ts` | Cross-surface favorites and recents persisted to the per-project `ade.db` tables `model_picker_favorites` and `model_picker_recents` via the `modelPicker.*` JSON-RPC methods on `adeRpcServer`. Desktop, TUI, and iOS share the CRR-backed store; the legacy `~/.ade/modelPicker.json` file is only a one-time migration source. | From 87ef0ab18bc088ee6e18f8f5c82a7b5e52d7d17b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:28:04 -0400 Subject: [PATCH 2/2] Address review: align handoff reasoning pickers with their sibling pin, and drop late catalog writes for evicted buckets CodeRabbit, both verified against the code: - The two handoff ReasoningEffortPickers read the pane's published modelCatalogScopeKey state while their sibling ModelPicker reads the freshly derived composerModelRuntimePin, so for one render after a machine change the pair could resolve tiers from different machines. composerModelCatalogScopeKey is in scope at both call sites; use it. - A catalog fetch is async, so its bucket can be evicted (8-scope cap) or reset before the response lands, and rememberRuntimeCatalog would recreate it. The data was never wrong (a response always carries the machine it was fetched for), but it resurrected a machine the window had stopped tracking. A fetch now reserves its bucket and presents that token on write; a stale token returns the catalog for display without touching the cache. --- .../components/chat/AgentChatPane.tsx | 4 +-- .../shared/ModelPicker/ModelPicker.tsx | 5 ++++ .../shared/ModelPicker/modelCatalog.test.ts | 26 +++++++++++++++++++ .../shared/ModelPicker/runtimeCatalogCache.ts | 26 +++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index b5aa68796..0fd91d582 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -11523,7 +11523,7 @@ export function AgentChatPane({ modelId={handoffModelId} reasoningEffort={handoffReasoningEffort} onChange={setHandoffReasoningEffort} - catalogScopeKey={modelCatalogScopeKey} + catalogScopeKey={composerModelCatalogScopeKey} />
@@ -11560,7 +11560,7 @@ export function AgentChatPane({ /> diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index 48067b076..fc23d278f 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -31,6 +31,7 @@ import { runtimeCatalogProviderIsFresh, setRuntimeCatalogRequest, refreshProviderForFamily, + reserveRuntimeCatalogScope, DEFAULT_RUNTIME_CATALOG_SCOPE, } from "./runtimeCatalogCache"; @@ -198,6 +199,9 @@ export const ModelPicker = memo(function ModelPicker({ return next; } + // Claim the bucket now so a response that lands after this machine's bucket + // was evicted or reset is dropped rather than resurrecting it. + const scopeSerial = reserveRuntimeCatalogScope(catalogScopeKey); const request = (async () => { try { const fetchArgs = { @@ -214,6 +218,7 @@ export const ModelPicker = memo(function ModelPicker({ ...args, ...(cursorFlavor ? { cursorSource: cursorFlavor } : {}), scopeKey: catalogScopeKey, + scopeSerial, }); setRuntimeCatalog(visible); if (args.refreshProvider) setRefreshErrorProvider((current) => current === args.refreshProvider ? null : current); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts index 247ed87a4..5ab3dc954 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts @@ -11,6 +11,7 @@ import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPicke import { getSharedRuntimeCatalog, rememberRuntimeCatalog, + reserveRuntimeCatalogScope, resetModelPickerRuntimeCatalogForTests, runtimeCatalogProviderIsFresh, } from "./runtimeCatalogCache"; @@ -584,6 +585,31 @@ describe("runtime catalog machine scoping", () => { expect(getRuntimeCatalogModelDescriptor("ollama/m-0", "remote:target-0:project-0")).toBeUndefined(); }); + // A catalog fetch is async, so its bucket can be evicted or reset before the + // response lands. Writing anyway would resurrect a machine the window stopped + // tracking — and, after a reset, repopulate state something else now owns. + it("drops a catalog response whose bucket was evicted or reset mid-flight", () => { + const scopeKey = "remote:target-late:project-late"; + const serial = reserveRuntimeCatalogScope(scopeKey); + + // The bucket disappears while the request is in flight. + resetModelPickerRuntimeCatalogForTests(); + + const late = localModelCatalog("ollama/late", ["low"]); + // The caller still gets the catalog back for immediate display... + expect(rememberRuntimeCatalog(late, { mode: "cached", scopeKey, scopeSerial: serial })).toBe(late); + // ...but the bucket is not resurrected. + expect(getSharedRuntimeCatalog(scopeKey)).toBeNull(); + + // A fresh reservation for the same key is a different bucket, and its own + // response is written normally. + const nextSerial = reserveRuntimeCatalogScope(scopeKey); + expect(nextSerial).not.toBe(serial); + const current = localModelCatalog("ollama/current", ["low"]); + rememberRuntimeCatalog(current, { mode: "cached", scopeKey, scopeSerial: nextSerial }); + expect(getSharedRuntimeCatalog(scopeKey)).toBe(current); + }); + it("marks provider freshness per machine so one machine's refresh cannot silence another's", () => { rememberRuntimeCatalog(cursorCatalog({ sdk: true, cli: true }), { mode: "force", diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts index 005c14914..e832aa0bd 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/runtimeCatalogCache.ts @@ -44,6 +44,11 @@ type RuntimeCatalogScopeState = { // catalog rather than in a parallel registry so one cap and one eviction // govern both, and a dropped scope can never leave descriptors behind. descriptorsById: Map; + // Identity of this bucket instance. A catalog fetch reserves the bucket and + // remembers this value; a response that comes back after the bucket was + // evicted or reset finds a different serial (or none) and is dropped instead + // of resurrecting a machine the window has stopped tracking. + serial: number; }; // Scopes are bounded by the project bindings a window has open, so this cap is @@ -51,6 +56,7 @@ type RuntimeCatalogScopeState = { const MAX_RUNTIME_CATALOG_SCOPES = 8; const runtimeCatalogScopes = new Map(); const sharedRuntimeCatalogRequests = new Map>(); +let nextRuntimeCatalogScopeSerial = 1; function peekRuntimeCatalogScope(scopeKey: string): RuntimeCatalogScopeState | undefined { return runtimeCatalogScopes.get(scopeKey); @@ -71,11 +77,22 @@ function runtimeCatalogScope(scopeKey: string): RuntimeCatalogScopeState { providerRefreshedAt: new Map(), cursorSourceRefreshedAt: new Map(), descriptorsById: new Map(), + serial: nextRuntimeCatalogScopeSerial++, }; runtimeCatalogScopes.set(scopeKey, created); return created; } +/** + * Claim this machine's bucket before fetching its catalog, and return the token + * the write must present. Reserving up front is what lets a late response tell + * "my bucket is still here" apart from "my bucket was evicted and something + * else now owns this key". + */ +export function reserveRuntimeCatalogScope(scopeKey: string): number { + return runtimeCatalogScope(scopeKey).serial; +} + /** This machine's parsed descriptors, created on first write. */ export function runtimeCatalogScopeDescriptors(scopeKey: string): Map { return runtimeCatalogScope(scopeKey).descriptorsById; @@ -191,9 +208,18 @@ export function rememberRuntimeCatalog( refreshProvider?: AgentChatModelCatalogRefreshProvider; cursorSource?: "sdk" | "cli"; scopeKey?: string; + /** Token from {@link reserveRuntimeCatalogScope}; omit to skip the check. */ + scopeSerial?: number; }, ): AgentChatModelCatalog { const scopeKey = args.scopeKey ?? DEFAULT_RUNTIME_CATALOG_SCOPE; + if (args.scopeSerial !== undefined + && peekRuntimeCatalogScope(scopeKey)?.serial !== args.scopeSerial) { + // The bucket this response was fetched for is gone (evicted or reset). + // Hand the catalog back for immediate display, but do not recreate the + // bucket or overwrite whatever now owns this key. + return catalog; + } const scope = runtimeCatalogScope(scopeKey); if (args.mode === "cached" && scope.catalog) { for (const provider of REFRESH_PROVIDERS) {