From b5b32406256327786d00388a51ceb5a1b425880a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:56:14 +0000 Subject: [PATCH 1/2] feat: resolve custom agents' inherited defaults outside the agent picker Sends from retry, resume, compaction and the plan handoff, the title bar, the palette and the VS Code composer resolve AI picks without an agent list. Once they read the resolver, a custom agent would lose the model, thinking level and Pro mode it inherits from its base agent until the workspace saved its own settings. The agent context now records each loaded agent's base for its workspace or project scope, and the resolver falls back to that list. Stack-Layer: A2a2 --- src/browser/contexts/AgentContext.test.tsx | 50 +++++++++++++++++-- src/browser/contexts/AgentContext.tsx | 2 + src/browser/utils/aiSelectionIntent.ts | 23 +++++++++ .../utils/workspaceAiSettingsSync.test.ts | 19 +++++++ src/browser/utils/workspaceAiSettingsSync.ts | 3 +- 5 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index 650433bb914..df1ac87824e 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -15,7 +15,13 @@ import { APIProvider, type APIClient } from "./API"; import { ProjectProvider } from "./ProjectContext"; import { RouterProvider } from "./RouterContext"; import { WorkspaceProvider } from "./WorkspaceContext"; -import { createTestApiClient, createTestPreferencesConfig } from "@/browser/testUtils"; +import { + createTestApiClient, + createTestConfig, + createTestPreferencesConfig, +} from "@/browser/testUtils"; +import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; +import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; let mockAgentDefinitions: AgentDefinitionDescriptor[] = []; let mockWorkspaceMetadata = new Map(); @@ -100,14 +106,27 @@ function createEmptyAsyncIterable(): AsyncIterable { }; } -function createApiClient(preferences?: UserPreferences): APIClient { +function createApiClient( + preferences?: UserPreferences, + agentAiDefaults?: AgentAiDefaults +): APIClient { const workspaceMetadata = Array.from( mockWorkspaceMetadata.entries(), ([workspaceId, overrides]) => createWorkspaceMetadata(workspaceId, overrides) ); return createTestApiClient({ - config: createTestPreferencesConfig(preferences), + config: { + ...createTestPreferencesConfig(preferences), + ...(agentAiDefaults != null + ? { + getConfig: () => + Promise.resolve( + createTestConfig({ userPreferences: preferences ?? {}, agentAiDefaults }) + ), + } + : {}), + }, agents: { list: () => Promise.resolve(mockAgentDefinitions), }, @@ -151,10 +170,11 @@ function renderAgentHarness(props: { projectPath: string; workspaceId?: string; preferences?: UserPreferences; + agentAiDefaults?: AgentAiDefaults; onChange: (value: AgentContextValue) => void; }) { return render( - + @@ -195,7 +215,10 @@ describe("AgentContext", () => { afterEach(() => { cleanup(); - getAppConfigStore().updateOptimistically({ userPreferences: undefined }); + getAppConfigStore().updateOptimistically({ + userPreferences: undefined, + agentAiDefaults: undefined, + }); getWorkspaceStoreRaw().dispose(); mock.restore(); globalThis.window = originalWindow; @@ -220,6 +243,23 @@ describe("AgentContext", () => { expect(getUserPreferences().ai?.projectDefaults).toBeUndefined(); }); + test("readers outside the agent context resolve a custom agent's inherited defaults", async () => { + const workspaceId = "ws-custom-agent"; + mockAgentDefinitions = [EXEC_AGENT, { ...REVIEW_PROJECT_AGENT, base: "exec" }]; + mockWorkspaceMetadata = new Map([[workspaceId, { agentId: "review" }]]); + + renderAgentHarness({ + projectPath: "/tmp/project", + workspaceId, + agentAiDefaults: { exec: { modelString: "openai:gpt-5.2" } }, + onChange: () => undefined, + }); + + await waitFor(() => { + expect(getWorkspaceAiSelection(workspaceId, "review").model).toBe("openai:gpt-5.2"); + }); + }); + test("project-scoped preference takes precedence over global default", async () => { const projectPath = "/tmp/project"; diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index 12e42bf457f..9bdaad0279f 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -28,6 +28,7 @@ import { useScopedAiDefault, writeScopedAiDefault, } from "@/browser/utils/scopedAiDefaults"; +import { setAgentBases } from "@/browser/utils/aiSelectionIntent"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import { sortAgentsStable } from "@/browser/utils/agents"; import { normalizeAgentId, resolveRemovedBuiltinAgentId } from "@/common/utils/agentIds"; @@ -198,6 +199,7 @@ function AgentProviderWithState(props: { isMountedRef.current ) { setAgents(result); + setAgentBases(getScopeId(workspaceId, projectPath), result); setLoadFailed(false); setLoaded(true); } diff --git a/src/browser/utils/aiSelectionIntent.ts b/src/browser/utils/aiSelectionIntent.ts index 6c3672bb5c2..9d1fa0fd53c 100644 --- a/src/browser/utils/aiSelectionIntent.ts +++ b/src/browser/utils/aiSelectionIntent.ts @@ -34,6 +34,7 @@ export type WorkspaceAiMetadata = Pick< const pendingByScope = new Map>>(); const metadataByWorkspace = new Map(); +const agentBasesByScope = new Map>(); const listeners = new Set<() => void>(); let nextToken = 1; let version = 0; @@ -63,6 +64,27 @@ export function getWorkspaceAiMetadata(workspaceId: string): WorkspaceAiMetadata return metadataByWorkspace.get(workspaceId); } +/** + * Agent id -> base id of the agents loaded for a workspace or creation scope, so readers outside + * the agent context still resolve a custom agent's inherited defaults. + */ +export function setAgentBases( + scopeId: string, + agents: ReadonlyArray<{ id: string; base?: string }> +): void { + const bases = new Map(agents.map((agent) => [agent.id, agent.base])); + const previous = agentBasesByScope.get(scopeId); + if (previous != null && JSON.stringify([...previous]) === JSON.stringify([...bases])) return; + agentBasesByScope.set(scopeId, bases); + notify(); +} + +export function getAgentBases( + scopeId: string +): ReadonlyMap | undefined { + return agentBasesByScope.get(scopeId); +} + function normalizeAgent(agentId: string): string { return agentId.trim().toLowerCase() || WORKSPACE_DEFAULTS.agentId; } @@ -215,5 +237,6 @@ export function dropPendingModelPicks(shouldDrop: (model: string) => boolean): v export function resetAiSelectionIntentForTests(): void { pendingByScope.clear(); metadataByWorkspace.clear(); + agentBasesByScope.clear(); notify(); } diff --git a/src/browser/utils/workspaceAiSettingsSync.test.ts b/src/browser/utils/workspaceAiSettingsSync.test.ts index 237fb71d2f0..6d1fb765204 100644 --- a/src/browser/utils/workspaceAiSettingsSync.test.ts +++ b/src/browser/utils/workspaceAiSettingsSync.test.ts @@ -8,6 +8,7 @@ import { getAppConfigStore } from "@/browser/stores/AppConfigStore"; import { markAiSelectionIntent, resetAiSelectionIntentForTests, + setAgentBases, setWorkspaceAiMetadata, } from "@/browser/utils/aiSelectionIntent"; import { @@ -126,6 +127,24 @@ describe("getWorkspaceAiSelection", () => { } }); + test("a custom agent resolves its base agent's defaults from the loaded agent list", () => { + getAppConfigStore().updateOptimistically({ + defaultModel: "openai:global", + agentAiDefaults: { exec: { modelString: "openai:configured", thinkingLevel: "high" } }, + }); + setWorkspaceAiMetadata(WS, { + projectPath: PROJECT, + aiSettings: undefined, + aiSettingsByAgent: undefined, + }); + setAgentBases(WS, [{ id: "exec" }, { id: "reviewer", base: "exec" }]); + + expect(getWorkspaceAiSelection(WS, "reviewer")).toMatchObject({ + model: "openai:configured", + thinkingLevel: "high", + }); + }); + test("the hook follows a default model that loads after it rendered", () => { setWorkspaceAiMetadata(WS, { projectPath: PROJECT, diff --git a/src/browser/utils/workspaceAiSettingsSync.ts b/src/browser/utils/workspaceAiSettingsSync.ts index b882faa9039..6446d093518 100644 --- a/src/browser/utils/workspaceAiSettingsSync.ts +++ b/src/browser/utils/workspaceAiSettingsSync.ts @@ -1,6 +1,7 @@ import { useSyncExternalStore } from "react"; import { normalizeModelPreference } from "@/browser/utils/messages/buildSendMessageOptions"; import { + getAgentBases, getAiSelectionVersion, getPendingAiSelection, getWorkspaceAiMetadata, @@ -59,7 +60,7 @@ function resolveWorkspaceAiSelection(input: WorkspaceAiSelectionInput): Workspac const configured = resolveConfiguredAiDefaults( agentId, input.agentAiDefaults, - input.agentBaseById + input.agentBaseById ?? getAgentBases(input.workspaceId) ); const project = metadata?.projectPath != null ? input.ai?.projectDefaults?.[metadata.projectPath] : undefined; From 64c30f7d14e2327c216a212aee2019d2e45106c2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:25:49 +0000 Subject: [PATCH 2/2] fix: drop cached agent bases when the agent list fails to load A failed refresh cleared the agent list but kept the inheritance map from the last good load, so readers outside the agent context still resolved defaults through agents that were no longer loaded. Stack-Layer: A2a2 --- src/browser/contexts/AgentContext.test.tsx | 34 ++++++++++++++++++++-- src/browser/contexts/AgentContext.tsx | 1 + 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/browser/contexts/AgentContext.test.tsx b/src/browser/contexts/AgentContext.test.tsx index df1ac87824e..17787a8bacf 100644 --- a/src/browser/contexts/AgentContext.test.tsx +++ b/src/browser/contexts/AgentContext.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import { GlobalWindow } from "happy-dom"; import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; @@ -25,6 +25,7 @@ import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync let mockAgentDefinitions: AgentDefinitionDescriptor[] = []; let mockWorkspaceMetadata = new Map(); +let mockAgentListFails = false; const EXEC_AGENT: AgentDefinitionDescriptor = { id: "exec", @@ -128,7 +129,10 @@ function createApiClient( : {}), }, agents: { - list: () => Promise.resolve(mockAgentDefinitions), + list: () => + mockAgentListFails + ? Promise.reject(new Error("agents unavailable")) + : Promise.resolve(mockAgentDefinitions), }, workspace: { list: () => Promise.resolve(workspaceMetadata), @@ -196,6 +200,7 @@ describe("AgentContext", () => { beforeEach(() => { mockAgentDefinitions = []; mockWorkspaceMetadata = new Map(); + mockAgentListFails = false; originalWindow = globalThis.window; originalDocument = globalThis.document; @@ -260,6 +265,31 @@ describe("AgentContext", () => { }); }); + test("a failed agent refresh drops the inherited defaults it loaded before", async () => { + const workspaceId = "ws-custom-agent"; + mockAgentDefinitions = [EXEC_AGENT, { ...REVIEW_PROJECT_AGENT, base: "exec" }]; + mockWorkspaceMetadata = new Map([[workspaceId, { agentId: "review" }]]); + let contextValue: AgentContextValue | undefined; + + renderAgentHarness({ + projectPath: "/tmp/project", + workspaceId, + agentAiDefaults: { exec: { modelString: "openai:gpt-5.2" } }, + onChange: (value) => (contextValue = value), + }); + await waitFor(() => { + expect(getWorkspaceAiSelection(workspaceId, "review").model).toBe("openai:gpt-5.2"); + }); + + mockAgentListFails = true; + await act(async () => { + await contextValue?.refresh(); + }); + + expect(contextValue?.loadFailed).toBe(true); + expect(getWorkspaceAiSelection(workspaceId, "review").model).not.toBe("openai:gpt-5.2"); + }); + test("project-scoped preference takes precedence over global default", async () => { const projectPath = "/tmp/project"; diff --git a/src/browser/contexts/AgentContext.tsx b/src/browser/contexts/AgentContext.tsx index 9bdaad0279f..98fc3a1d707 100644 --- a/src/browser/contexts/AgentContext.tsx +++ b/src/browser/contexts/AgentContext.tsx @@ -212,6 +212,7 @@ function AgentProviderWithState(props: { isMountedRef.current ) { setAgents([]); + setAgentBases(getScopeId(workspaceId, projectPath), []); setLoadFailed(true); setLoaded(true); }