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..0fd91d582 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..fc23d278f 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,8 @@ import { runtimeCatalogProviderIsFresh, setRuntimeCatalogRequest, refreshProviderForFamily, + reserveRuntimeCatalogScope, + DEFAULT_RUNTIME_CATALOG_SCOPE, } from "./runtimeCatalogCache"; export type ModelPickerProps = { @@ -42,6 +48,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 +105,7 @@ export const ModelPicker = memo(function ModelPicker({ providerAuthStatus, onOpenSignIn, onRuntimeCatalogRefreshed, + runtimePin, constrainToAvailableModelIds = false, fastMode, onFastModeChange, @@ -104,8 +120,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 +176,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 +191,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; @@ -156,15 +199,26 @@ 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 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, + scopeSerial, }); setRuntimeCatalog(visible); if (args.refreshProvider) setRefreshErrorProvider((current) => current === args.refreshProvider ? null : current); @@ -180,7 +234,7 @@ export const ModelPicker = memo(function ModelPicker({ clearRuntimeCatalogRequest(requestKey, request); }); return await request; - }, [cursorSource]); + }, [catalogScopeKey, cursorSource, setRuntimeCatalog]); useEffect(() => { if (!open) return; @@ -192,10 +246,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 +267,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 +294,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 +302,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 +317,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 +379,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..5ab3dc954 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,7 +9,9 @@ import { import { sortModelItems } from "./modelOrdering"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; import { + getSharedRuntimeCatalog, rememberRuntimeCatalog, + reserveRuntimeCatalogScope, resetModelPickerRuntimeCatalogForTests, runtimeCatalogProviderIsFresh, } from "./runtimeCatalogCache"; @@ -452,3 +455,168 @@ 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(); + }); + + // 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", + 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..e832aa0bd 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,102 @@ 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; + // 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 +// 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>(); +let nextRuntimeCatalogScopeSerial = 1; + +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(), + 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; +} + +/** 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 +157,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 +207,46 @@ export function rememberRuntimeCatalog( mode: "cached" | "refresh-stale" | "force"; refreshProvider?: AgentChatModelCatalogRefreshProvider; cursorSource?: "sdk" | "cli"; + scopeKey?: string; + /** Token from {@link reserveRuntimeCatalogScope}; omit to skip the check. */ + scopeSerial?: number; }, ): AgentChatModelCatalog { - if (args.mode === "cached" && sharedRuntimeCatalog) { + 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) { 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. |