From 923dfb71b46d6a21f9b7f2ebfc83f802139dcba7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 7 Aug 2026 18:11:25 -0400 Subject: [PATCH 01/50] docs: define session configuration laws Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- LAWS/CONFIGURATION.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 LAWS/CONFIGURATION.md diff --git a/LAWS/CONFIGURATION.md b/LAWS/CONFIGURATION.md new file mode 100644 index 000000000..43dcf006d --- /dev/null +++ b/LAWS/CONFIGURATION.md @@ -0,0 +1,5 @@ +# Session configuration + +- A session's harness, provider, and model MUST form a compatible configuration. +- A session MUST have exactly one effective configuration. +- Berd MUST show the configuration that the session uses. From 2ab184c4d57ca8d0b689d7e9df7eb989168517b7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 09:43:55 -0400 Subject: [PATCH 02/50] docs: state provider compatibility law Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- LAWS/CONFIGURATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LAWS/CONFIGURATION.md b/LAWS/CONFIGURATION.md index 43dcf006d..70e195cd6 100644 --- a/LAWS/CONFIGURATION.md +++ b/LAWS/CONFIGURATION.md @@ -1,5 +1,5 @@ # Session configuration -- A session's harness, provider, and model MUST form a compatible configuration. +- A session’s provider MUST support its model, and its harness MUST support that provider. - A session MUST have exactly one effective configuration. - Berd MUST show the configuration that the session uses. From 2166e115ad92a7e760fe712c04433866e180b257 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 09:56:55 -0400 Subject: [PATCH 03/50] fix: validate managed provider migrations Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../lib/managedModelSelectionRepair.ts | 4 ++ src/shared/api/__tests__/acp.test.ts | 69 +++++++++++++++++++ src/shared/api/acp.ts | 22 ++++-- src/shared/api/acpSessionRegistry.ts | 9 +++ .../modelProviderPolicy.test.ts | 57 ++++++++++++++- .../runtime-config/modelProviderPolicy.ts | 42 +++++++++++ 6 files changed, 195 insertions(+), 8 deletions(-) diff --git a/src/features/providers/lib/managedModelSelectionRepair.ts b/src/features/providers/lib/managedModelSelectionRepair.ts index d4a9b752c..8e97104ec 100644 --- a/src/features/providers/lib/managedModelSelectionRepair.ts +++ b/src/features/providers/lib/managedModelSelectionRepair.ts @@ -5,6 +5,7 @@ import { resolveAgentProviderCatalogIdStrict } from "@/features/providers/provid import { subscribeToProviderModelInventoryInvalidation } from "./providerModelInventoryEvents"; import { resolveManagedGooseProviderSelection, + resolveValidatedManagedGooseProviderSelection, type GooseProviderSelection, type ManagedGooseProviderSelection, } from "@/shared/runtime-config/modelProviderPolicy"; @@ -102,6 +103,9 @@ export async function repairManagedGooseModelSelection( const config = useRuntimeConfigStore.getState().config; const initial = resolveManagedGooseProviderSelection(config, selection); if (!initial) return null; + if (initial.providerId !== selection.providerId) { + return resolveValidatedManagedGooseProviderSelection(config, selection); + } const targetModelIds = initial.providerId === DATABRICKS_V2_PROVIDER_ID && selection.modelId diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index 31b8d224e..af955dd9e 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -140,6 +140,16 @@ vi.mock("@/features/berdctl/appPreamble", () => ({ getBerdctlPreamble: () => mockGetBerdctlPreamble(), })); +const mockSupportedModelsList = vi.hoisted(() => vi.fn()); +vi.mock("../acpConnection", () => ({ + getClient: () => + Promise.resolve({ + goose: { + GooseUnstableProvidersSupportedModelsList: mockSupportedModelsList, + }, + }), +})); + vi.mock("../acpActiveMessageTracking", () => ({ setActiveMessageId: vi.fn(), clearActiveMessageId: vi.fn(), @@ -799,6 +809,8 @@ describe("acpCreateSession", () => { mockSetProvider.mockResolvedValue({ model: null, reasoningEffort: null }); mockSetModel.mockReset(); mockSetModel.mockResolvedValue({ model: null, reasoningEffort: null }); + mockSupportedModelsList.mockReset(); + mockSupportedModelsList.mockResolvedValue({ models: ["goose-gpt-5-5"] }); await setRuntimeConfig(DEFAULT_RUNTIME_CONFIG); }); @@ -1032,6 +1044,43 @@ describe("acpCreateSession", () => { }); }); + it("does not mutate ACP when managed provider migration cannot prove support", async () => { + await setRuntimeConfig(managedRuntimeConfig); + mockSupportedModelsList.mockRejectedValueOnce(new Error("offline")); + const { acpCreateSession } = await import("../acp"); + + await expect( + acpCreateSession("goose", "/tmp/project", { modelId: "other-model" }), + ).rejects.toThrow( + "Cannot verify models for migrated provider databricks_v2", + ); + + expect(mockNewSession).not.toHaveBeenCalled(); + expect(mockSetProvider).not.toHaveBeenCalled(); + expect(mockSetModel).not.toHaveBeenCalled(); + }); + + it("uses a proven default instead of an unsupported migrated model", async () => { + await setRuntimeConfig(managedRuntimeConfig); + mockSupportedModelsList.mockResolvedValueOnce({ + models: ["goose-gpt-5-5"], + }); + mockNewSession.mockResolvedValue({ sessionId: "migrated-session" }); + const { acpCreateSession } = await import("../acp"); + + await acpCreateSession("goose", "/tmp/project", { modelId: "other-model" }); + + expect(mockSetProvider).toHaveBeenCalledWith( + "migrated-session", + "databricks_v2", + ); + expect(mockSetModel).toHaveBeenCalledWith( + "migrated-session", + "goose-gpt-5-5", + noRequestModelContext("databricks_v2"), + ); + }); + it("rejects an explicit provider outside managed policy before creating", async () => { await setRuntimeConfig(managedRuntimeConfig); const { acpCreateSession } = await import("../acp"); @@ -1177,6 +1226,8 @@ describe("acpPrepareSession", () => { beforeEach(async () => { vi.clearAllMocks(); vi.resetModules(); + mockSupportedModelsList.mockReset(); + mockSupportedModelsList.mockResolvedValue({ models: ["goose-gpt-5-5"] }); await setRuntimeConfig(DEFAULT_RUNTIME_CONFIG); }); @@ -1203,6 +1254,24 @@ describe("acpPrepareSession", () => { expect(sessionRegistry.isSessionPrepared("acp-session-1")).toBe(true); }); + it("does not load or mutate a session when managed migration cannot prove support", async () => { + await setRuntimeConfig(managedRuntimeConfig); + mockSupportedModelsList.mockRejectedValueOnce(new Error("offline")); + const { acpPrepareSession } = await import("../acp"); + + await expect( + acpPrepareSession("legacy-session", "goose", "/tmp/project", { + modelId: "other-model", + }), + ).rejects.toThrow( + "Cannot verify models for migrated provider databricks_v2", + ); + + expect(mockLoadSession).not.toHaveBeenCalled(); + expect(mockSetProvider).not.toHaveBeenCalled(); + expect(mockSetModel).not.toHaveBeenCalled(); + }); + it("rejects a provider outside managed policy before loading the session", async () => { await setRuntimeConfig(managedRuntimeConfig); const { acpPrepareSession } = await import("../acp"); diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index 4817b03fb..e3ac67e62 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -28,7 +28,7 @@ import { type PersonaHandoffClaim, } from "./acpPersonaHandoff"; import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; -import { resolveManagedGooseProviderSelection } from "@/shared/runtime-config/modelProviderPolicy"; +import { resolveValidatedManagedGooseProviderSelection } from "@/shared/runtime-config/modelProviderPolicy"; import { getStyleGuidelinesPrompt } from "@/shared/preferences/styleGuidelinesPreference"; import { getBerdctlPreamble } from "@/features/berdctl/appPreamble"; import { INTERACTION_NORMS_PREAMBLE } from "@/shared/api/interactionNorms"; @@ -169,7 +169,8 @@ async function acpSendMessageNow( const sid = sessionId.slice(0, 8); const tStart = performance.now(); - const resolvedProvider = resolveGooseSessionSelection(providerId).providerId; + const resolvedProvider = (await resolveGooseSessionSelection(providerId)) + .providerId; if (resolvedProvider !== providerId) { throw new Error( `Session provider ${providerId} is outside the managed Goose provider policy. Re-prepare the session before prompting.`, @@ -324,10 +325,10 @@ export async function acpSteerMessage( ); } -function resolveGooseSessionSelection( +async function resolveGooseSessionSelection( providerId: string, modelId?: string | null, -): { providerId: string; modelId?: string } { +): Promise<{ providerId: string; modelId?: string }> { if (modelId === "goose") { throw new Error(`Invalid model id: ${modelId}`); } @@ -356,7 +357,7 @@ function resolveGooseSessionSelection( providerId, ...(concreteModelId ? { modelId: concreteModelId } : {}), }; - const managedSelection = resolveManagedGooseProviderSelection( + const managedSelection = await resolveValidatedManagedGooseProviderSelection( runtimeConfigState.config, requestedSelection, ); @@ -385,7 +386,11 @@ export async function acpPrepareSession( perfLog( `[perf:prepare] ${sid} acpPrepareSession start (provider=${providerId})`, ); - const selection = resolveGooseSessionSelection(providerId, options.modelId); + sessionRegistry.supersedeSessionMutation(sessionId); + const selection = await resolveGooseSessionSelection( + providerId, + options.modelId, + ); const applyResolvedModel = Boolean(options.modelId) || selection.providerId !== providerId; const snapshots = @@ -414,7 +419,10 @@ export async function acpCreateSession( workingDir: string, options: AcpCreateSessionOptions = {}, ): Promise { - const selection = resolveGooseSessionSelection(providerId, options.modelId); + const selection = await resolveGooseSessionSelection( + providerId, + options.modelId, + ); providerId = selection.providerId; options = { ...options, modelId: selection.modelId }; // Only the "goose" sentinel should rely on backend defaults. Concrete diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 83c6d0c33..99b43233b 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -129,6 +129,15 @@ function serializeSessionMutation( return result; } +export function supersedeSessionMutation(sessionId: string): void { + const queue = mutationQueues.get(sessionId); + if (!queue) return; + // Resolution may need authoritative I/O before it can enqueue its ACP + // mutation. Make that user-initiated configuration current immediately so a + // concurrent load cannot publish an obsolete response snapshot meanwhile. + queue.latestSequence = nextMutationSequence++; +} + export async function prepareSession( sessionId: string, providerId: string, diff --git a/src/shared/runtime-config/modelProviderPolicy.test.ts b/src/shared/runtime-config/modelProviderPolicy.test.ts index 4195073ab..4baaa0b81 100644 --- a/src/shared/runtime-config/modelProviderPolicy.test.ts +++ b/src/shared/runtime-config/modelProviderPolicy.test.ts @@ -1,10 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeConfig } from "./schema"; import { managedGooseSelectionChanged, resolveManagedGooseProviderSelection, + resolveValidatedManagedGooseProviderSelection, } from "./modelProviderPolicy"; +const mockSupportedModelsList = vi.hoisted(() => vi.fn()); +vi.mock("@/shared/api/acpConnection", () => ({ + getClient: () => + Promise.resolve({ + goose: { + GooseUnstableProvidersSupportedModelsList: mockSupportedModelsList, + }, + }), +})); + const managedConfig: RuntimeConfig = { schemaVersion: 1, goose: { @@ -29,6 +40,9 @@ const managedConfig: RuntimeConfig = { }; describe("resolveManagedGooseProviderSelection", () => { + beforeEach(() => { + mockSupportedModelsList.mockReset(); + }); it("returns unrestricted for an empty provider list", () => { expect( resolveManagedGooseProviderSelection( @@ -161,4 +175,45 @@ describe("resolveManagedGooseProviderSelection", () => { modelId: "goose-gpt-5-5", }); }); + + it("validates a model retained across a provider migration", async () => { + mockSupportedModelsList.mockResolvedValue({ models: ["shared-model"] }); + + await expect( + resolveValidatedManagedGooseProviderSelection(managedConfig, { + providerId: "disallowed", + modelId: "shared-model", + }), + ).resolves.toEqual({ + providerId: "databricks_v2", + modelId: "shared-model", + }); + }); + + it("replaces an unsupported migrated model only with a proven default", async () => { + mockSupportedModelsList.mockResolvedValue({ models: ["goose-gpt-5-5"] }); + + await expect( + resolveValidatedManagedGooseProviderSelection(managedConfig, { + providerId: "disallowed", + modelId: "other-model", + }), + ).resolves.toEqual({ + providerId: "databricks_v2", + modelId: "goose-gpt-5-5", + }); + }); + + it("rejects a provider migration when support cannot be proved", async () => { + mockSupportedModelsList.mockRejectedValue(new Error("offline")); + + await expect( + resolveValidatedManagedGooseProviderSelection(managedConfig, { + providerId: "disallowed", + modelId: "other-model", + }), + ).rejects.toThrow( + "Cannot verify models for migrated provider databricks_v2", + ); + }); }); diff --git a/src/shared/runtime-config/modelProviderPolicy.ts b/src/shared/runtime-config/modelProviderPolicy.ts index 59e16dc35..1806aa99c 100644 --- a/src/shared/runtime-config/modelProviderPolicy.ts +++ b/src/shared/runtime-config/modelProviderPolicy.ts @@ -1,5 +1,6 @@ import type { RuntimeConfig, RuntimeGooseConfig } from "./schema"; import { normalizeConcreteModelId } from "@/shared/lib/modelIdentity"; +import { getClient } from "@/shared/api/acpConnection"; export interface GooseProviderSelection { providerId?: string | null; @@ -81,6 +82,47 @@ export function resolveManagedGooseProviderSelection( return { providerId, modelId: modelId ?? undefined }; } +/** + * Resolve a managed provider migration only after the target provider proves + * support for the selected (or fallback) model. Runtime config metadata is + * advisory, so it is never evidence for this decision. + */ +export async function resolveValidatedManagedGooseProviderSelection( + config: Pick, + selection: GooseProviderSelection, +): Promise { + const resolved = resolveManagedGooseProviderSelection(config, selection); + if (!resolved || resolved.providerId === selection.providerId) { + return resolved; + } + + let supportedModelIds: ReadonlySet; + try { + const client = await getClient(); + const response = + await client.goose.GooseUnstableProvidersSupportedModelsList({ + providerId: resolved.providerId, + }); + supportedModelIds = new Set(response.models as string[]); + } catch (error) { + throw new Error( + `Cannot verify models for migrated provider ${resolved.providerId}; provider selection was not changed.`, + { cause: error }, + ); + } + + if (resolved.modelId && supportedModelIds.has(resolved.modelId)) { + return resolved; + } + const fallbackModelId = normalizeConcreteModelId(config.goose.defaultModelId); + if (fallbackModelId && supportedModelIds.has(fallbackModelId)) { + return { providerId: resolved.providerId, modelId: fallbackModelId }; + } + throw new Error( + `No supported model is available for migrated provider ${resolved.providerId}; provider selection was not changed.`, + ); +} + export function managedGooseSelectionChanged( current: GooseProviderSelection, resolved: ManagedGooseProviderSelection | null, From b0d98157eff780f92afd2da1ae6cea3b48d8c1db Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 10:14:50 -0400 Subject: [PATCH 04/50] fix: retain configuration intent through preflight Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/shared/api/__tests__/acp.test.ts | 52 +++++++++++++++++++++ src/shared/api/acp.ts | 56 +++++++++++----------- src/shared/api/acpSessionRegistry.ts | 70 +++++++++++++++++++++------- 3 files changed, 136 insertions(+), 42 deletions(-) diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index af955dd9e..7954f5f94 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -762,6 +762,58 @@ describe("acpLoadSession", () => { ); }); + it("does not dispatch a load snapshot when provider-changing prepare is awaiting inventory proof", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const supportedModels = deferred<{ models: string[] }>(); + const loadResponse = deferred>(); + mockSupportedModelsList.mockReturnValueOnce(supportedModels.promise); + mockLoadSession.mockReturnValueOnce(loadResponse.promise); + mockSetProvider.mockResolvedValueOnce({ + model: null, + reasoningEffort: null, + }); + mockSetModel.mockResolvedValueOnce({ model: null, reasoningEffort: null }); + const applyModelConfigSnapshot = vi.fn(); + const { setSessionConfigSnapshotHandlers } = await import( + "../acpSessionConfigSnapshots" + ); + setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); + const { acpLoadSession, acpPrepareSession } = await import("../acp"); + + const configure = acpPrepareSession( + "acp-session-preflight-race", + "goose", + "/tmp/replay", + { modelId: "other-model" }, + ); + await vi.waitFor(() => expect(mockSupportedModelsList).toHaveBeenCalled()); + + const load = acpLoadSession("acp-session-preflight-race", "/tmp/replay"); + await vi.waitFor(() => expect(mockLoadSession).toHaveBeenCalledTimes(1)); + loadResponse.resolve( + executionConfigResponse("other-managed", "other-model"), + ); + await load; + + expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + expect(mockSetProvider).not.toHaveBeenCalled(); + expect(mockSetModel).not.toHaveBeenCalled(); + + supportedModels.resolve({ models: ["goose-gpt-5-5"] }); + await configure; + + expect(mockSetProvider).toHaveBeenCalledWith( + "acp-session-preflight-race", + "databricks_v2", + noRequestProviderContext, + ); + expect(mockSetModel).toHaveBeenCalledWith( + "acp-session-preflight-race", + "goose-gpt-5-5", + noRequestModelContext("databricks_v2"), + ); + }); + it("does not dispatch a load snapshot superseded by a UI configuration", async () => { const loadResponse = deferred>(); mockLoadSession.mockReturnValueOnce(loadResponse.promise); diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index e3ac67e62..a9b99c4b1 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -386,32 +386,36 @@ export async function acpPrepareSession( perfLog( `[perf:prepare] ${sid} acpPrepareSession start (provider=${providerId})`, ); - sessionRegistry.supersedeSessionMutation(sessionId); - const selection = await resolveGooseSessionSelection( - providerId, - options.modelId, - ); - const applyResolvedModel = - Boolean(options.modelId) || selection.providerId !== providerId; - const snapshots = - applyResolvedModel && selection.modelId - ? await sessionRegistry.configureSession( - sessionId, - selection.providerId, - workingDir, - selection.modelId, - options, - ) - : await sessionRegistry.prepareSession( - sessionId, - selection.providerId, - workingDir, - options, - ); - perfLog( - `[perf:prepare] ${sid} acpPrepareSession done in ${(performance.now() - t0).toFixed(1)}ms`, - ); - return snapshots; + const clearSupersession = sessionRegistry.supersedeSessionMutation(sessionId); + try { + const selection = await resolveGooseSessionSelection( + providerId, + options.modelId, + ); + const applyResolvedModel = + Boolean(options.modelId) || selection.providerId !== providerId; + const snapshots = + applyResolvedModel && selection.modelId + ? await sessionRegistry.configureSession( + sessionId, + selection.providerId, + workingDir, + selection.modelId, + options, + ) + : await sessionRegistry.prepareSession( + sessionId, + selection.providerId, + workingDir, + options, + ); + perfLog( + `[perf:prepare] ${sid} acpPrepareSession done in ${(performance.now() - t0).toFixed(1)}ms`, + ); + return snapshots; + } finally { + clearSupersession(); + } } export async function acpCreateSession( diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 99b43233b..89b60eb32 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -30,13 +30,31 @@ interface SessionConfigMutationOptions { const SESSION_MUTATION_TIMEOUT_MS = 60_000; +interface SessionMutationQueue { + latestSequence: number; + tail: Promise; + /** Configuration intent awaiting async preflight before it can enqueue. */ + pendingSupersession?: number; +} + const prepared = new Map(); -const mutationQueues = new Map< - string, - { latestSequence: number; tail: Promise } ->(); +const mutationQueues = new Map(); let nextMutationSequence = 1; +function scheduleQueueCleanup( + sessionId: string, + queue: SessionMutationQueue, +): void { + void queue.tail.then(() => { + if ( + mutationQueues.get(sessionId) === queue && + queue.pendingSupersession === undefined + ) { + mutationQueues.delete(sessionId); + } + }); +} + function clonePreparedSession( entry: PreparedSession | undefined, ): PreparedSession | undefined { @@ -103,6 +121,7 @@ function serializeSessionMutation( sessionId: string, mutation: (isLatest: () => boolean) => Promise, bounded = true, + consumesPendingSupersession = true, ): Promise { let queue = mutationQueues.get(sessionId); if (!queue) { @@ -112,7 +131,12 @@ function serializeSessionMutation( const sequence = nextMutationSequence++; queue.latestSequence = sequence; - const execute = () => mutation(() => queue?.latestSequence === sequence); + const execute = () => + mutation( + () => + queue?.pendingSupersession === undefined && + queue?.latestSequence === sequence, + ); const result = queue.tail.then(() => bounded ? runBoundedSessionMutation(sessionId, execute()) : execute(), ); @@ -121,21 +145,34 @@ function serializeSessionMutation( () => undefined, ); queue.tail = tail; - void tail.then(() => { - if (mutationQueues.get(sessionId)?.tail === tail) { - mutationQueues.delete(sessionId); - } - }); + if (consumesPendingSupersession) { + // The first configuration mutation queued after async preflight consumes + // the intent; loads remain obsolete until then. + queue.pendingSupersession = undefined; + } + scheduleQueueCleanup(sessionId, queue); return result; } -export function supersedeSessionMutation(sessionId: string): void { - const queue = mutationQueues.get(sessionId); - if (!queue) return; +export function supersedeSessionMutation(sessionId: string): () => void { + let queue = mutationQueues.get(sessionId); + if (!queue) { + queue = { latestSequence: 0, tail: Promise.resolve() }; + mutationQueues.set(sessionId, queue); + } // Resolution may need authoritative I/O before it can enqueue its ACP - // mutation. Make that user-initiated configuration current immediately so a - // concurrent load cannot publish an obsolete response snapshot meanwhile. - queue.latestSequence = nextMutationSequence++; + // mutation. Retain that intent even when the session was idle, so a later + // load cannot publish a snapshot that predates it. + const sequence = nextMutationSequence++; + queue.latestSequence = sequence; + queue.pendingSupersession = sequence; + scheduleQueueCleanup(sessionId, queue); + + return () => { + if (queue?.pendingSupersession !== sequence) return; + queue.pendingSupersession = undefined; + scheduleQueueCleanup(sessionId, queue); + }; } export async function prepareSession( @@ -414,6 +451,7 @@ export async function loadSession( }; }, false, + false, ); } From ac47f55891b4e0593151cf114482ae53cfb373ae Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 10:23:14 -0400 Subject: [PATCH 05/50] fix: own preflight configuration intent Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/shared/api/__tests__/acp.test.ts | 138 +++++++++++++++++++++++++++ src/shared/api/acp.ts | 6 +- src/shared/api/acpSessionRegistry.ts | 46 ++++++--- 3 files changed, 176 insertions(+), 14 deletions(-) diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index 7954f5f94..3f03ced5a 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -814,6 +814,144 @@ describe("acpLoadSession", () => { ); }); + it("does not let a stale preflight consume a newer preflight intent or publish a load", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const firstInventory = deferred<{ models: string[] }>(); + const secondInventory = deferred<{ models: string[] }>(); + mockSupportedModelsList + .mockReturnValueOnce(firstInventory.promise) + .mockReturnValueOnce(secondInventory.promise); + mockSetProvider.mockResolvedValue({ model: null, reasoningEffort: null }); + mockSetModel.mockResolvedValue({ model: null, reasoningEffort: null }); + const applyModelConfigSnapshot = vi.fn(); + const { setSessionConfigSnapshotHandlers } = await import( + "../acpSessionConfigSnapshots" + ); + setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); + const { acpLoadSession, acpPrepareSession } = await import("../acp"); + + const first = acpPrepareSession( + "acp-session-two-preflights", + "goose", + "/tmp/replay", + { + modelId: "other-model", + }, + ); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(1), + ); + const second = acpPrepareSession( + "acp-session-two-preflights", + "goose", + "/tmp/replay", + { + modelId: "other-model", + }, + ); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(2), + ); + + firstInventory.resolve({ models: ["goose-gpt-5-5"] }); + await first; + expect(mockSetProvider).not.toHaveBeenCalled(); + expect(mockSetModel).not.toHaveBeenCalled(); + + await acpLoadSession("acp-session-two-preflights", "/tmp/replay"); + expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + + secondInventory.resolve({ models: ["goose-gpt-5-5"] }); + await second; + expect(mockSetProvider).toHaveBeenCalledTimes(1); + expect(mockSetModel).toHaveBeenCalledTimes(1); + }); + + it("releases only a rejected latest preflight intent", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const firstInventory = deferred<{ models: string[] }>(); + mockSupportedModelsList + .mockReturnValueOnce(firstInventory.promise) + .mockRejectedValueOnce(new Error("offline")); + mockLoadSession.mockResolvedValueOnce( + executionConfigResponse("other-managed", "other-model"), + ); + const applyModelConfigSnapshot = vi.fn(); + const { setSessionConfigSnapshotHandlers } = await import( + "../acpSessionConfigSnapshots" + ); + setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); + const { acpLoadSession, acpPrepareSession } = await import("../acp"); + + const first = acpPrepareSession( + "acp-session-rejected-latest", + "goose", + "/tmp/replay", + { + modelId: "other-model", + }, + ); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(1), + ); + await expect( + acpPrepareSession("acp-session-rejected-latest", "goose", "/tmp/replay", { + modelId: "other-model", + }), + ).rejects.toThrow("Cannot verify models for migrated provider"); + + firstInventory.resolve({ models: ["goose-gpt-5-5"] }); + await first; + await acpLoadSession("acp-session-rejected-latest", "/tmp/replay"); + + expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1); + expect(mockSetProvider).not.toHaveBeenCalled(); + expect(mockSetModel).not.toHaveBeenCalled(); + }); + + it("keeps the latest preflight when it resolves before an older one", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const firstInventory = deferred<{ models: string[] }>(); + const secondInventory = deferred<{ models: string[] }>(); + mockSupportedModelsList + .mockReturnValueOnce(firstInventory.promise) + .mockReturnValueOnce(secondInventory.promise); + mockSetProvider.mockResolvedValue({ model: null, reasoningEffort: null }); + mockSetModel.mockResolvedValue({ model: null, reasoningEffort: null }); + const { acpPrepareSession } = await import("../acp"); + + const first = acpPrepareSession( + "acp-session-reverse-preflights", + "goose", + "/tmp/replay", + { + modelId: "other-model", + }, + ); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(1), + ); + const second = acpPrepareSession( + "acp-session-reverse-preflights", + "goose", + "/tmp/replay", + { + modelId: "other-model", + }, + ); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(2), + ); + + secondInventory.resolve({ models: ["goose-gpt-5-5"] }); + await second; + firstInventory.resolve({ models: ["goose-gpt-5-5"] }); + await first; + + expect(mockSetProvider).toHaveBeenCalledTimes(1); + expect(mockSetModel).toHaveBeenCalledTimes(1); + }); + it("does not dispatch a load snapshot superseded by a UI configuration", async () => { const loadResponse = deferred>(); mockLoadSession.mockReturnValueOnce(loadResponse.promise); diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index a9b99c4b1..9d716f865 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -386,7 +386,7 @@ export async function acpPrepareSession( perfLog( `[perf:prepare] ${sid} acpPrepareSession start (provider=${providerId})`, ); - const clearSupersession = sessionRegistry.supersedeSessionMutation(sessionId); + const supersession = sessionRegistry.supersedeSessionMutation(sessionId); try { const selection = await resolveGooseSessionSelection( providerId, @@ -402,19 +402,21 @@ export async function acpPrepareSession( workingDir, selection.modelId, options, + supersession, ) : await sessionRegistry.prepareSession( sessionId, selection.providerId, workingDir, options, + supersession, ); perfLog( `[perf:prepare] ${sid} acpPrepareSession done in ${(performance.now() - t0).toFixed(1)}ms`, ); return snapshots; } finally { - clearSupersession(); + supersession.clear(); } } diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 89b60eb32..58fa77621 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -37,6 +37,12 @@ interface SessionMutationQueue { pendingSupersession?: number; } +/** Opaque ownership of a configuration intent that is awaiting preflight. */ +export interface SessionMutationSupersession { + readonly sequence: number; + clear(): void; +} + const prepared = new Map(); const mutationQueues = new Map(); let nextMutationSequence = 1; @@ -121,7 +127,6 @@ function serializeSessionMutation( sessionId: string, mutation: (isLatest: () => boolean) => Promise, bounded = true, - consumesPendingSupersession = true, ): Promise { let queue = mutationQueues.get(sessionId); if (!queue) { @@ -145,16 +150,27 @@ function serializeSessionMutation( () => undefined, ); queue.tail = tail; - if (consumesPendingSupersession) { - // The first configuration mutation queued after async preflight consumes - // the intent; loads remain obsolete until then. - queue.pendingSupersession = undefined; - } scheduleQueueCleanup(sessionId, queue); return result; } -export function supersedeSessionMutation(sessionId: string): () => void { +function consumeSessionSupersession( + sessionId: string, + supersession: SessionMutationSupersession | undefined, +): boolean { + if (!supersession) return true; + const queue = mutationQueues.get(sessionId); + if (!queue || queue.pendingSupersession !== supersession.sequence) { + return false; + } + queue.pendingSupersession = undefined; + scheduleQueueCleanup(sessionId, queue); + return true; +} + +export function supersedeSessionMutation( + sessionId: string, +): SessionMutationSupersession { let queue = mutationQueues.get(sessionId); if (!queue) { queue = { latestSequence: 0, tail: Promise.resolve() }; @@ -168,10 +184,13 @@ export function supersedeSessionMutation(sessionId: string): () => void { queue.pendingSupersession = sequence; scheduleQueueCleanup(sessionId, queue); - return () => { - if (queue?.pendingSupersession !== sequence) return; - queue.pendingSupersession = undefined; - scheduleQueueCleanup(sessionId, queue); + return { + sequence, + clear() { + if (queue?.pendingSupersession !== sequence) return; + queue.pendingSupersession = undefined; + scheduleQueueCleanup(sessionId, queue); + }, }; } @@ -180,7 +199,9 @@ export async function prepareSession( providerId: string, workingDir: string, options: SessionConfigMutationOptions = {}, + supersession?: SessionMutationSupersession, ): Promise { + if (!consumeSessionSupersession(sessionId, supersession)) return; return serializeSessionMutation(sessionId, () => prepareSessionNow(sessionId, providerId, workingDir, options), ); @@ -360,11 +381,13 @@ export async function configureSession( workingDir: string, modelId?: string, options: SessionConfigMutationOptions = {}, + supersession?: SessionMutationSupersession, ): Promise { const concreteModelId = normalizeConcreteModelId(modelId); if (modelId && !concreteModelId) { throw new Error(`Invalid model id: ${modelId}`); } + if (!consumeSessionSupersession(sessionId, supersession)) return; return serializeSessionMutation(sessionId, async () => { let snapshots = await prepareSessionNow( sessionId, @@ -451,7 +474,6 @@ export async function loadSession( }; }, false, - false, ); } From f00bed5e6e0e77a575edd0d2d90dec7c46f7d6df Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 10:31:25 -0400 Subject: [PATCH 06/50] fix: retain active session mutation queues Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../api/__tests__/acpSessionRegistry.test.ts | 37 +++++++++++++++++++ src/shared/api/acpSessionRegistry.ts | 4 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/shared/api/__tests__/acpSessionRegistry.test.ts b/src/shared/api/__tests__/acpSessionRegistry.test.ts index 38c34bb20..dad120bb3 100644 --- a/src/shared/api/__tests__/acpSessionRegistry.test.ts +++ b/src/shared/api/__tests__/acpSessionRegistry.test.ts @@ -382,6 +382,43 @@ describe("applySessionModel", () => { ); }); + it("keeps a mutation behind an owned preflight configuration after its old tail settles", async () => { + const registry = await importPreparedRegistry("openai", "gpt-4.1"); + const firstProviderResponse = deferred(); + mockSetProvider.mockReturnValueOnce(firstProviderResponse.promise); + + const supersession = registry.supersedeSessionMutation("session-1"); + const first = registry.prepareSession( + "session-1", + "anthropic", + "/project", + {}, + supersession, + ); + + await vi.waitFor(() => expect(mockSetProvider).toHaveBeenCalledTimes(1)); + + // The owned intent was consumed before the real mutation was appended. A + // cleanup registered against the formerly resolved tail must not reclaim + // the queue while this provider call is still in flight. + const second = registry.prepareSession("session-1", "gemini", "/project"); + await Promise.resolve(); + expect(mockSetProvider).toHaveBeenCalledTimes(1); + + firstProviderResponse.resolve( + modelConfigResponse("claude-default", "Claude Default"), + ); + await first; + await second; + + expect(mockSetProvider).toHaveBeenCalledTimes(2); + expect(mockSetProvider).toHaveBeenLastCalledWith( + "session-1", + "gemini", + noRequestProviderContext, + ); + }); + it("does not run a load between one provider and model configuration", async () => { const registry = await importPreparedRegistry("openai", "gpt-4.1"); const providerResponse = deferred(); diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 58fa77621..f8c78c446 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -51,9 +51,11 @@ function scheduleQueueCleanup( sessionId: string, queue: SessionMutationQueue, ): void { - void queue.tail.then(() => { + const tail = queue.tail; + void tail.then(() => { if ( mutationQueues.get(sessionId) === queue && + queue.tail === tail && queue.pendingSupersession === undefined ) { mutationQueues.delete(sessionId); From 143148f59b37339b17d5bdf0e4a6dbbda0b5c3ca Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 11:43:17 -0400 Subject: [PATCH 07/50] docs: place session configuration laws with chat Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- LAWS/CHAT.md | 6 ++++++ LAWS/CONFIGURATION.md | 5 ----- 2 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 LAWS/CONFIGURATION.md diff --git a/LAWS/CHAT.md b/LAWS/CHAT.md index 917e91747..6687c4345 100644 --- a/LAWS/CHAT.md +++ b/LAWS/CHAT.md @@ -37,3 +37,9 @@ - A session's subagent activity MUST appear in the chat transcript with the subagent identity when known. - A session's subagent activity MUST appear in the chat transcript with the delegated task when known. + +## Session configuration + +- A session’s provider MUST support its model, and its harness MUST support that provider. +- A session MUST have exactly one effective configuration. +- Berd MUST show the configuration that the session uses. diff --git a/LAWS/CONFIGURATION.md b/LAWS/CONFIGURATION.md deleted file mode 100644 index 70e195cd6..000000000 --- a/LAWS/CONFIGURATION.md +++ /dev/null @@ -1,5 +0,0 @@ -# Session configuration - -- A session’s provider MUST support its model, and its harness MUST support that provider. -- A session MUST have exactly one effective configuration. -- Berd MUST show the configuration that the session uses. From 029a41adc9a76fdff5dc8b29f7812bb397b27e9c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 12:07:01 -0400 Subject: [PATCH 08/50] fix(acp): publish deferred load snapshots after rejected preflight Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/shared/api/__tests__/acp.test.ts | 52 ++++++++++++++++++++++++++++ src/shared/api/acp.ts | 15 ++++++-- src/shared/api/acpSessionRegistry.ts | 44 +++++++++++++++++++---- 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index 3f03ced5a..8bedc2e94 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -814,6 +814,58 @@ describe("acpLoadSession", () => { ); }); + it("publishes a deferred load snapshot when its owning preflight rejects", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const supportedModels = deferred<{ models: string[] }>(); + mockSupportedModelsList.mockReturnValueOnce(supportedModels.promise); + mockLoadSession.mockResolvedValueOnce({ + ...executionConfigResponse("other-managed", "other-model"), + configOptions: [ + ...executionConfigResponse("other-managed", "other-model") + .configOptions, + { + id: "thinking_effort", + kind: { + type: "select", + currentValue: "high", + options: [{ value: "high", name: "High" }], + }, + }, + ], + }); + const applyModelConfigSnapshot = vi.fn(); + const applyReasoningEffortConfigSnapshot = vi.fn(); + const { setSessionConfigSnapshotHandlers } = await import( + "../acpSessionConfigSnapshots" + ); + setSessionConfigSnapshotHandlers({ + applyModelConfigSnapshot, + applyReasoningEffortConfigSnapshot, + }); + const { acpLoadSession, acpPrepareSession } = await import("../acp"); + + const configure = acpPrepareSession( + "acp-session-rejected-preflight-load", + "goose", + "/tmp/replay", + { modelId: "other-model" }, + ); + await vi.waitFor(() => expect(mockSupportedModelsList).toHaveBeenCalled()); + + await acpLoadSession("acp-session-rejected-preflight-load", "/tmp/replay"); + expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + expect(applyReasoningEffortConfigSnapshot).not.toHaveBeenCalled(); + + supportedModels.reject(new Error("offline")); + await expect(configure).rejects.toThrow( + "Cannot verify models for migrated provider", + ); + await vi.waitFor(() => { + expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1); + expect(applyReasoningEffortConfigSnapshot).toHaveBeenCalledTimes(1); + }); + }); + it("does not let a stale preflight consume a newer preflight intent or publish a load", async () => { await setRuntimeConfig(managedRuntimeConfig); const firstInventory = deferred<{ models: string[] }>(); diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index 9d716f865..89fcf8958 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -577,9 +577,20 @@ export async function acpLoadSession( sessionId: shortLogId(sessionId), }); perfLog(`[perf:load] ${sid} acpLoadSession → client.loadSession`); - const { response, isCurrent, executionSelection } = - await sessionRegistry.loadSession(sessionId, effectiveWorkingDir); + const { + response, + isCurrent, + preflightSettlement, + canPublish, + executionSelection, + } = await sessionRegistry.loadSession(sessionId, effectiveWorkingDir); if (!isCurrent) { + void preflightSettlement?.then((publishDeferredLoad) => { + if (!publishDeferredLoad || !canPublish()) return; + applySessionConfigOptionsSnapshot(sessionId, response, { + origin: "response", + }); + }); perfLog( `[perf:load] ${sid} dropped superseded load snapshot in ${(performance.now() - t0).toFixed(1)}ms`, ); diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index f8c78c446..6803a8f04 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -34,7 +34,13 @@ interface SessionMutationQueue { latestSequence: number; tail: Promise; /** Configuration intent awaiting async preflight before it can enqueue. */ - pendingSupersession?: number; + pendingSupersession?: PendingSessionSupersession; +} + +interface PendingSessionSupersession { + sequence: number; + settled: Promise; + resolve(publishDeferredLoad: boolean): void; } /** Opaque ownership of a configuration intent that is awaiting preflight. */ @@ -127,7 +133,11 @@ async function runBoundedSessionMutation( function serializeSessionMutation( sessionId: string, - mutation: (isLatest: () => boolean) => Promise, + mutation: ( + isLatest: () => boolean, + sequence: number, + queue: SessionMutationQueue, + ) => Promise, bounded = true, ): Promise { let queue = mutationQueues.get(sessionId); @@ -143,6 +153,8 @@ function serializeSessionMutation( () => queue?.pendingSupersession === undefined && queue?.latestSequence === sequence, + sequence, + queue, ); const result = queue.tail.then(() => bounded ? runBoundedSessionMutation(sessionId, execute()) : execute(), @@ -162,9 +174,10 @@ function consumeSessionSupersession( ): boolean { if (!supersession) return true; const queue = mutationQueues.get(sessionId); - if (!queue || queue.pendingSupersession !== supersession.sequence) { + if (!queue || queue.pendingSupersession?.sequence !== supersession.sequence) { return false; } + queue.pendingSupersession.resolve(false); queue.pendingSupersession = undefined; scheduleQueueCleanup(sessionId, queue); return true; @@ -182,14 +195,19 @@ export function supersedeSessionMutation( // mutation. Retain that intent even when the session was idle, so a later // load cannot publish a snapshot that predates it. const sequence = nextMutationSequence++; + let resolve!: (publishDeferredLoad: boolean) => void; + const settled = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); queue.latestSequence = sequence; - queue.pendingSupersession = sequence; + queue.pendingSupersession = { sequence, settled, resolve }; scheduleQueueCleanup(sessionId, queue); return { sequence, clear() { - if (queue?.pendingSupersession !== sequence) return; + if (queue?.pendingSupersession?.sequence !== sequence) return; + queue.pendingSupersession.resolve(true); queue.pendingSupersession = undefined; scheduleQueueCleanup(sessionId, queue); }, @@ -457,13 +475,21 @@ export async function loadSession( ): Promise<{ response: Awaited>; isCurrent: boolean; + /** Resolves when the preflight that suppressed this load settles. */ + preflightSettlement?: Promise; + /** Re-checks the load's ordering after asynchronous preflight settlement. */ + canPublish(): boolean; executionSelection?: AcpSessionExecutionSelection; }> { return serializeSessionMutation( sessionId, - async (isLatest) => { + async (isLatest, sequence, queue) => { const response = await acpApi.loadSession(sessionId, workingDir); const isCurrentResult = isLatest(); + const pendingSupersession = isCurrentResult + ? undefined + : mutationQueues.get(sessionId)?.pendingSupersession; + const preflightSettlement = pendingSupersession?.settled; const executionSnapshot = readSessionExecutionConfigSnapshot(response); prepared.set(sessionId, { workingDir, @@ -472,6 +498,12 @@ export async function loadSession( return { response, isCurrent: isCurrentResult, + preflightSettlement, + canPublish: () => + pendingSupersession !== undefined && + pendingSupersession.sequence < sequence && + queue.latestSequence === sequence && + queue.pendingSupersession === undefined, executionSelection: executionSnapshot ?? undefined, }; }, From 809601547cf4020caad9aaeeeaa501ecdf8e950a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 12:22:50 -0400 Subject: [PATCH 09/50] fix(acp): retain deferred load settlement across preflights Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/shared/api/__tests__/acp.test.ts | 195 +++++++++++++++++++++++++++ src/shared/api/acp.ts | 4 +- src/shared/api/acpSessionRegistry.ts | 126 ++++++++++++----- 3 files changed, 286 insertions(+), 39 deletions(-) diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index 8bedc2e94..6c158897c 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -866,6 +866,201 @@ describe("acpLoadSession", () => { }); }); + it("publishes a load deferred by a later rejected preflight", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const loadResponse = deferred>(); + const supportedModels = deferred<{ models: string[] }>(); + mockLoadSession.mockReturnValueOnce(loadResponse.promise); + mockSupportedModelsList.mockReturnValueOnce(supportedModels.promise); + const applyModelConfigSnapshot = vi.fn(); + const { setSessionConfigSnapshotHandlers } = await import( + "../acpSessionConfigSnapshots" + ); + setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); + const { acpLoadSession, acpPrepareSession } = await import("../acp"); + + const load = acpLoadSession( + "acp-session-load-before-preflight", + "/tmp/replay", + ); + await vi.waitFor(() => expect(mockLoadSession).toHaveBeenCalledTimes(1)); + const configure = acpPrepareSession( + "acp-session-load-before-preflight", + "goose", + "/tmp/replay", + { modelId: "other-model" }, + ); + await vi.waitFor(() => expect(mockSupportedModelsList).toHaveBeenCalled()); + loadResponse.resolve( + executionConfigResponse("other-managed", "other-model"), + ); + await load; + expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + + supportedModels.reject(new Error("offline")); + await expect(configure).rejects.toThrow( + "Cannot verify models for migrated provider", + ); + await vi.waitFor(() => + expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1), + ); + }); + + it("transfers a deferred load settlement to a replacing preflight", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const firstInventory = deferred<{ models: string[] }>(); + const secondInventory = deferred<{ models: string[] }>(); + mockSupportedModelsList + .mockReturnValueOnce(firstInventory.promise) + .mockReturnValueOnce(secondInventory.promise); + mockLoadSession.mockResolvedValueOnce( + executionConfigResponse("other-managed", "other-model"), + ); + const applyModelConfigSnapshot = vi.fn(); + const { setSessionConfigSnapshotHandlers } = await import( + "../acpSessionConfigSnapshots" + ); + setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); + const { acpLoadSession, acpPrepareSession } = await import("../acp"); + + const first = acpPrepareSession( + "acp-session-transfer-preflight", + "goose", + "/tmp/replay", + { modelId: "other-model" }, + ); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(1), + ); + await acpLoadSession("acp-session-transfer-preflight", "/tmp/replay"); + expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + + const second = acpPrepareSession( + "acp-session-transfer-preflight", + "goose", + "/tmp/replay", + { modelId: "other-model" }, + ); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(2), + ); + firstInventory.resolve({ models: ["goose-gpt-5-5"] }); + await first; + expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + + secondInventory.reject(new Error("offline")); + await expect(second).rejects.toThrow( + "Cannot verify models for migrated provider", + ); + await vi.waitFor(() => + expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1), + ); + }); + + it("publishes a transferred load when the replacement rejects before its predecessor", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const firstInventory = deferred<{ models: string[] }>(); + const secondInventory = deferred<{ models: string[] }>(); + mockSupportedModelsList + .mockReturnValueOnce(firstInventory.promise) + .mockReturnValueOnce(secondInventory.promise); + mockLoadSession.mockResolvedValueOnce( + executionConfigResponse("other-managed", "other-model"), + ); + const applyModelConfigSnapshot = vi.fn(); + const { setSessionConfigSnapshotHandlers } = await import( + "../acpSessionConfigSnapshots" + ); + setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); + const { acpLoadSession, acpPrepareSession } = await import("../acp"); + + const first = acpPrepareSession( + "acp-session-transfer-reverse", + "goose", + "/tmp/replay", + { modelId: "other-model" }, + ); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(1), + ); + await acpLoadSession("acp-session-transfer-reverse", "/tmp/replay"); + const second = acpPrepareSession( + "acp-session-transfer-reverse", + "goose", + "/tmp/replay", + { modelId: "other-model" }, + ); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(2), + ); + + secondInventory.reject(new Error("offline")); + await expect(second).rejects.toThrow( + "Cannot verify models for migrated provider", + ); + await vi.waitFor(() => + expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1), + ); + + firstInventory.resolve({ models: ["goose-gpt-5-5"] }); + await first; + expect(mockSetProvider).not.toHaveBeenCalled(); + expect(mockSetModel).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "setProvider", + load: executionConfigResponse("other-managed", "other-model"), + modelId: "other-model", + reject: () => mockSetProvider.mockRejectedValueOnce(new Error("offline")), + expected: () => expect(mockSetProvider).toHaveBeenCalledTimes(1), + }, + { + name: "setModel", + load: executionConfigResponse("databricks_v2", "old-model"), + modelId: "goose-gpt-5-5", + reject: () => mockSetModel.mockRejectedValueOnce(new Error("offline")), + expected: () => expect(mockSetModel).toHaveBeenCalledTimes(1), + }, + ])("does not publish a deferred load after attempted $name fails", async ({ + name, + load, + modelId, + reject, + expected, + }) => { + await setRuntimeConfig(managedRuntimeConfig); + const supportedModels = deferred<{ models: string[] }>(); + mockSupportedModelsList.mockReturnValueOnce(supportedModels.promise); + mockLoadSession.mockResolvedValueOnce(load); + reject(); + const applyModelConfigSnapshot = vi.fn(); + const { setSessionConfigSnapshotHandlers } = await import( + "../acpSessionConfigSnapshots" + ); + setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); + const sessionRegistry = await import("../acpSessionRegistry"); + const { acpLoadSession, acpPrepareSession } = await import("../acp"); + const sessionId = `acp-session-failed-${modelId}`; + + const configure = acpPrepareSession(sessionId, "goose", "/tmp/replay", { + modelId, + }); + await vi.waitFor(() => expect(mockSupportedModelsList).toHaveBeenCalled()); + await acpLoadSession(sessionId, "/tmp/replay"); + expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + + supportedModels.resolve({ models: ["goose-gpt-5-5"] }); + await expect(configure).rejects.toThrow("offline"); + expected(); + if (name === "setProvider") { + expect(sessionRegistry.isSessionPrepared(sessionId)).toBe(false); + } + await Promise.resolve(); + expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + }); + it("does not let a stale preflight consume a newer preflight intent or publish a load", async () => { await setRuntimeConfig(managedRuntimeConfig); const firstInventory = deferred<{ models: string[] }>(); diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index 89fcf8958..a7bdb8021 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -585,8 +585,8 @@ export async function acpLoadSession( executionSelection, } = await sessionRegistry.loadSession(sessionId, effectiveWorkingDir); if (!isCurrent) { - void preflightSettlement?.then((publishDeferredLoad) => { - if (!publishDeferredLoad || !canPublish()) return; + void preflightSettlement?.then((settlement) => { + if (!canPublish(settlement)) return; applySessionConfigOptionsSnapshot(sessionId, response, { origin: "response", }); diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 6803a8f04..8b3f666d1 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -37,10 +37,25 @@ interface SessionMutationQueue { pendingSupersession?: PendingSessionSupersession; } +interface SessionSupersessionSettlement { + /** Whether a suppressed load is again authoritative. */ + publishDeferredLoad: boolean; + /** Latest intent whose outcome decided this settlement. */ + sequence: number; +} + interface PendingSessionSupersession { sequence: number; - settled: Promise; - resolve(publishDeferredLoad: boolean): void; + settled: Promise; + resolve( + settlement: + | SessionSupersessionSettlement + | PromiseLike, + ): void; +} + +interface ConsumedSessionSupersession { + settleMutation(sequence: number): void; } /** Opaque ownership of a configuration intent that is awaiting preflight. */ @@ -171,16 +186,25 @@ function serializeSessionMutation( function consumeSessionSupersession( sessionId: string, supersession: SessionMutationSupersession | undefined, -): boolean { - if (!supersession) return true; +): ConsumedSessionSupersession | undefined { + if (!supersession) { + return { settleMutation() {} }; + } const queue = mutationQueues.get(sessionId); - if (!queue || queue.pendingSupersession?.sequence !== supersession.sequence) { - return false; + const pendingSupersession = queue?.pendingSupersession; + if (!queue || pendingSupersession?.sequence !== supersession.sequence) { + return undefined; } - queue.pendingSupersession.resolve(false); queue.pendingSupersession = undefined; scheduleQueueCleanup(sessionId, queue); - return true; + return { + settleMutation(sequence) { + pendingSupersession.resolve({ + publishDeferredLoad: false, + sequence, + }); + }, + }; } export function supersedeSessionMutation( @@ -195,10 +219,18 @@ export function supersedeSessionMutation( // mutation. Retain that intent even when the session was idle, so a later // load cannot publish a snapshot that predates it. const sequence = nextMutationSequence++; - let resolve!: (publishDeferredLoad: boolean) => void; - const settled = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); + let resolve!: (settlement: SessionSupersessionSettlement) => void; + const settled = new Promise( + (resolvePromise) => { + resolve = resolvePromise; + }, + ); + const previousSupersession = queue.pendingSupersession; + if (previousSupersession) { + previousSupersession.resolve( + settled.then((settlement) => ({ ...settlement, sequence })), + ); + } queue.latestSequence = sequence; queue.pendingSupersession = { sequence, settled, resolve }; scheduleQueueCleanup(sessionId, queue); @@ -207,7 +239,10 @@ export function supersedeSessionMutation( sequence, clear() { if (queue?.pendingSupersession?.sequence !== sequence) return; - queue.pendingSupersession.resolve(true); + queue.pendingSupersession.resolve({ + publishDeferredLoad: true, + sequence, + }); queue.pendingSupersession = undefined; scheduleQueueCleanup(sessionId, queue); }, @@ -221,10 +256,19 @@ export async function prepareSession( options: SessionConfigMutationOptions = {}, supersession?: SessionMutationSupersession, ): Promise { - if (!consumeSessionSupersession(sessionId, supersession)) return; - return serializeSessionMutation(sessionId, () => - prepareSessionNow(sessionId, providerId, workingDir, options), + const consumedSupersession = consumeSessionSupersession( + sessionId, + supersession, ); + if (!consumedSupersession) return; + const snapshots = await serializeSessionMutation( + sessionId, + (_isLatest, sequence) => { + consumedSupersession.settleMutation(sequence); + return prepareSessionNow(sessionId, providerId, workingDir, options); + }, + ); + return snapshots; } async function prepareSessionNow( @@ -407,21 +451,30 @@ export async function configureSession( if (modelId && !concreteModelId) { throw new Error(`Invalid model id: ${modelId}`); } - if (!consumeSessionSupersession(sessionId, supersession)) return; - return serializeSessionMutation(sessionId, async () => { - let snapshots = await prepareSessionNow( - sessionId, - providerId, - workingDir, - concreteModelId ? {} : options, - ); - if (concreteModelId) { - snapshots = - (await applySessionModelNow(sessionId, concreteModelId, options)) ?? - snapshots; - } - return snapshots; - }); + const consumedSupersession = consumeSessionSupersession( + sessionId, + supersession, + ); + if (!consumedSupersession) return; + const snapshots = await serializeSessionMutation( + sessionId, + async (_isLatest, sequence) => { + consumedSupersession.settleMutation(sequence); + let snapshots = await prepareSessionNow( + sessionId, + providerId, + workingDir, + concreteModelId ? {} : options, + ); + if (concreteModelId) { + snapshots = + (await applySessionModelNow(sessionId, concreteModelId, options)) ?? + snapshots; + } + return snapshots; + }, + ); + return snapshots; } export function applySessionConfigOption( @@ -476,9 +529,9 @@ export async function loadSession( response: Awaited>; isCurrent: boolean; /** Resolves when the preflight that suppressed this load settles. */ - preflightSettlement?: Promise; + preflightSettlement?: Promise; /** Re-checks the load's ordering after asynchronous preflight settlement. */ - canPublish(): boolean; + canPublish(settlement: SessionSupersessionSettlement): boolean; executionSelection?: AcpSessionExecutionSelection; }> { return serializeSessionMutation( @@ -499,10 +552,9 @@ export async function loadSession( response, isCurrent: isCurrentResult, preflightSettlement, - canPublish: () => - pendingSupersession !== undefined && - pendingSupersession.sequence < sequence && - queue.latestSequence === sequence && + canPublish: (settlement) => + settlement.publishDeferredLoad && + queue.latestSequence === Math.max(sequence, settlement.sequence) && queue.pendingSupersession === undefined, executionSelection: executionSnapshot ?? undefined, }; From 816993740809b4863a9edcba61621cbf4625b67a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 12:34:28 -0400 Subject: [PATCH 10/50] fix(acp): preserve terminal supersession settlement Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/shared/api/__tests__/acp.test.ts | 80 ++++++++++++++++++++++++++++ src/shared/api/acpSessionRegistry.ts | 4 +- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index 6c158897c..caa197176 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -1008,6 +1008,86 @@ describe("acpLoadSession", () => { expect(mockSetModel).not.toHaveBeenCalled(); }); + it.each([ + { name: "rejects", outcome: "reject" }, + { name: "mutates", outcome: "succeed" }, + { name: "fails mutation", outcome: "fail" }, + ])("settles an A→B→C deferred load when C $name", async ({ outcome }) => { + await setRuntimeConfig(managedRuntimeConfig); + const inventories = [ + deferred<{ models: string[] }>(), + deferred<{ models: string[] }>(), + deferred<{ models: string[] }>(), + ]; + mockSupportedModelsList + .mockReturnValueOnce(inventories[0].promise) + .mockReturnValueOnce(inventories[1].promise) + .mockReturnValueOnce(inventories[2].promise); + mockLoadSession.mockResolvedValueOnce( + executionConfigResponse("other-managed", "other-model"), + ); + if (outcome === "fail") { + mockSetProvider.mockRejectedValueOnce(new Error("offline")); + } else if (outcome === "succeed") { + mockSetProvider.mockResolvedValueOnce({ + model: null, + reasoningEffort: null, + }); + mockSetModel.mockResolvedValueOnce({ + model: null, + reasoningEffort: null, + }); + } + const applyModelConfigSnapshot = vi.fn(); + const { setSessionConfigSnapshotHandlers } = await import( + "../acpSessionConfigSnapshots" + ); + setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); + const { acpLoadSession, acpPrepareSession } = await import("../acp"); + const sessionId = `acp-session-three-preflights-${outcome}`; + const prepare = () => + acpPrepareSession(sessionId, "goose", "/tmp/replay", { + modelId: "other-model", + }); + + const first = prepare(); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(1), + ); + await acpLoadSession(sessionId, "/tmp/replay"); + const second = prepare(); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(2), + ); + const third = prepare(); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(3), + ); + + if (outcome === "reject") { + inventories[2].reject(new Error("offline")); + await expect(third).rejects.toThrow( + "Cannot verify models for migrated provider", + ); + await vi.waitFor(() => + expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1), + ); + } else { + inventories[2].resolve({ models: ["goose-gpt-5-5"] }); + if (outcome === "fail") { + await expect(third).rejects.toThrow("offline"); + } else { + await third; + } + expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + } + + inventories[0].resolve({ models: ["goose-gpt-5-5"] }); + inventories[1].resolve({ models: ["goose-gpt-5-5"] }); + await first; + await second; + }); + it.each([ { name: "setProvider", diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 8b3f666d1..990ec2fe7 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -227,9 +227,7 @@ export function supersedeSessionMutation( ); const previousSupersession = queue.pendingSupersession; if (previousSupersession) { - previousSupersession.resolve( - settled.then((settlement) => ({ ...settlement, sequence })), - ); + previousSupersession.resolve(settled); } queue.latestSequence = sequence; queue.pendingSupersession = { sequence, settled, resolve }; From cc6464180fa8106f3655ed6eade4361b566a1cc7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 13:52:19 -0400 Subject: [PATCH 11/50] Simplify superseded ACP load handling Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/shared/api/__tests__/acp.test.ts | 243 +-------------------------- src/shared/api/acp.ts | 15 +- src/shared/api/acpSessionRegistry.ts | 131 +++------------ 3 files changed, 35 insertions(+), 354 deletions(-) diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index caa197176..a0fd71058 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -814,59 +814,7 @@ describe("acpLoadSession", () => { ); }); - it("publishes a deferred load snapshot when its owning preflight rejects", async () => { - await setRuntimeConfig(managedRuntimeConfig); - const supportedModels = deferred<{ models: string[] }>(); - mockSupportedModelsList.mockReturnValueOnce(supportedModels.promise); - mockLoadSession.mockResolvedValueOnce({ - ...executionConfigResponse("other-managed", "other-model"), - configOptions: [ - ...executionConfigResponse("other-managed", "other-model") - .configOptions, - { - id: "thinking_effort", - kind: { - type: "select", - currentValue: "high", - options: [{ value: "high", name: "High" }], - }, - }, - ], - }); - const applyModelConfigSnapshot = vi.fn(); - const applyReasoningEffortConfigSnapshot = vi.fn(); - const { setSessionConfigSnapshotHandlers } = await import( - "../acpSessionConfigSnapshots" - ); - setSessionConfigSnapshotHandlers({ - applyModelConfigSnapshot, - applyReasoningEffortConfigSnapshot, - }); - const { acpLoadSession, acpPrepareSession } = await import("../acp"); - - const configure = acpPrepareSession( - "acp-session-rejected-preflight-load", - "goose", - "/tmp/replay", - { modelId: "other-model" }, - ); - await vi.waitFor(() => expect(mockSupportedModelsList).toHaveBeenCalled()); - - await acpLoadSession("acp-session-rejected-preflight-load", "/tmp/replay"); - expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); - expect(applyReasoningEffortConfigSnapshot).not.toHaveBeenCalled(); - - supportedModels.reject(new Error("offline")); - await expect(configure).rejects.toThrow( - "Cannot verify models for migrated provider", - ); - await vi.waitFor(() => { - expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1); - expect(applyReasoningEffortConfigSnapshot).toHaveBeenCalledTimes(1); - }); - }); - - it("publishes a load deferred by a later rejected preflight", async () => { + it("does not publish a load superseded by a rejected preflight", async () => { await setRuntimeConfig(managedRuntimeConfig); const loadResponse = deferred>(); const supportedModels = deferred<{ models: string[] }>(); @@ -880,214 +828,33 @@ describe("acpLoadSession", () => { const { acpLoadSession, acpPrepareSession } = await import("../acp"); const load = acpLoadSession( - "acp-session-load-before-preflight", + "acp-session-load-before-rejected-preflight", "/tmp/replay", ); await vi.waitFor(() => expect(mockLoadSession).toHaveBeenCalledTimes(1)); const configure = acpPrepareSession( - "acp-session-load-before-preflight", + "acp-session-load-before-rejected-preflight", "goose", "/tmp/replay", { modelId: "other-model" }, ); await vi.waitFor(() => expect(mockSupportedModelsList).toHaveBeenCalled()); + loadResponse.resolve( executionConfigResponse("other-managed", "other-model"), ); await load; - expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); - supportedModels.reject(new Error("offline")); await expect(configure).rejects.toThrow( "Cannot verify models for migrated provider", ); - await vi.waitFor(() => - expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1), - ); - }); - - it("transfers a deferred load settlement to a replacing preflight", async () => { - await setRuntimeConfig(managedRuntimeConfig); - const firstInventory = deferred<{ models: string[] }>(); - const secondInventory = deferred<{ models: string[] }>(); - mockSupportedModelsList - .mockReturnValueOnce(firstInventory.promise) - .mockReturnValueOnce(secondInventory.promise); - mockLoadSession.mockResolvedValueOnce( - executionConfigResponse("other-managed", "other-model"), - ); - const applyModelConfigSnapshot = vi.fn(); - const { setSessionConfigSnapshotHandlers } = await import( - "../acpSessionConfigSnapshots" - ); - setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); - const { acpLoadSession, acpPrepareSession } = await import("../acp"); - - const first = acpPrepareSession( - "acp-session-transfer-preflight", - "goose", - "/tmp/replay", - { modelId: "other-model" }, - ); - await vi.waitFor(() => - expect(mockSupportedModelsList).toHaveBeenCalledTimes(1), - ); - await acpLoadSession("acp-session-transfer-preflight", "/tmp/replay"); - expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + await Promise.resolve(); - const second = acpPrepareSession( - "acp-session-transfer-preflight", - "goose", - "/tmp/replay", - { modelId: "other-model" }, - ); - await vi.waitFor(() => - expect(mockSupportedModelsList).toHaveBeenCalledTimes(2), - ); - firstInventory.resolve({ models: ["goose-gpt-5-5"] }); - await first; expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); - - secondInventory.reject(new Error("offline")); - await expect(second).rejects.toThrow( - "Cannot verify models for migrated provider", - ); - await vi.waitFor(() => - expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1), - ); - }); - - it("publishes a transferred load when the replacement rejects before its predecessor", async () => { - await setRuntimeConfig(managedRuntimeConfig); - const firstInventory = deferred<{ models: string[] }>(); - const secondInventory = deferred<{ models: string[] }>(); - mockSupportedModelsList - .mockReturnValueOnce(firstInventory.promise) - .mockReturnValueOnce(secondInventory.promise); - mockLoadSession.mockResolvedValueOnce( - executionConfigResponse("other-managed", "other-model"), - ); - const applyModelConfigSnapshot = vi.fn(); - const { setSessionConfigSnapshotHandlers } = await import( - "../acpSessionConfigSnapshots" - ); - setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); - const { acpLoadSession, acpPrepareSession } = await import("../acp"); - - const first = acpPrepareSession( - "acp-session-transfer-reverse", - "goose", - "/tmp/replay", - { modelId: "other-model" }, - ); - await vi.waitFor(() => - expect(mockSupportedModelsList).toHaveBeenCalledTimes(1), - ); - await acpLoadSession("acp-session-transfer-reverse", "/tmp/replay"); - const second = acpPrepareSession( - "acp-session-transfer-reverse", - "goose", - "/tmp/replay", - { modelId: "other-model" }, - ); - await vi.waitFor(() => - expect(mockSupportedModelsList).toHaveBeenCalledTimes(2), - ); - - secondInventory.reject(new Error("offline")); - await expect(second).rejects.toThrow( - "Cannot verify models for migrated provider", - ); - await vi.waitFor(() => - expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1), - ); - - firstInventory.resolve({ models: ["goose-gpt-5-5"] }); - await first; expect(mockSetProvider).not.toHaveBeenCalled(); expect(mockSetModel).not.toHaveBeenCalled(); }); - it.each([ - { name: "rejects", outcome: "reject" }, - { name: "mutates", outcome: "succeed" }, - { name: "fails mutation", outcome: "fail" }, - ])("settles an A→B→C deferred load when C $name", async ({ outcome }) => { - await setRuntimeConfig(managedRuntimeConfig); - const inventories = [ - deferred<{ models: string[] }>(), - deferred<{ models: string[] }>(), - deferred<{ models: string[] }>(), - ]; - mockSupportedModelsList - .mockReturnValueOnce(inventories[0].promise) - .mockReturnValueOnce(inventories[1].promise) - .mockReturnValueOnce(inventories[2].promise); - mockLoadSession.mockResolvedValueOnce( - executionConfigResponse("other-managed", "other-model"), - ); - if (outcome === "fail") { - mockSetProvider.mockRejectedValueOnce(new Error("offline")); - } else if (outcome === "succeed") { - mockSetProvider.mockResolvedValueOnce({ - model: null, - reasoningEffort: null, - }); - mockSetModel.mockResolvedValueOnce({ - model: null, - reasoningEffort: null, - }); - } - const applyModelConfigSnapshot = vi.fn(); - const { setSessionConfigSnapshotHandlers } = await import( - "../acpSessionConfigSnapshots" - ); - setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); - const { acpLoadSession, acpPrepareSession } = await import("../acp"); - const sessionId = `acp-session-three-preflights-${outcome}`; - const prepare = () => - acpPrepareSession(sessionId, "goose", "/tmp/replay", { - modelId: "other-model", - }); - - const first = prepare(); - await vi.waitFor(() => - expect(mockSupportedModelsList).toHaveBeenCalledTimes(1), - ); - await acpLoadSession(sessionId, "/tmp/replay"); - const second = prepare(); - await vi.waitFor(() => - expect(mockSupportedModelsList).toHaveBeenCalledTimes(2), - ); - const third = prepare(); - await vi.waitFor(() => - expect(mockSupportedModelsList).toHaveBeenCalledTimes(3), - ); - - if (outcome === "reject") { - inventories[2].reject(new Error("offline")); - await expect(third).rejects.toThrow( - "Cannot verify models for migrated provider", - ); - await vi.waitFor(() => - expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1), - ); - } else { - inventories[2].resolve({ models: ["goose-gpt-5-5"] }); - if (outcome === "fail") { - await expect(third).rejects.toThrow("offline"); - } else { - await third; - } - expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); - } - - inventories[0].resolve({ models: ["goose-gpt-5-5"] }); - inventories[1].resolve({ models: ["goose-gpt-5-5"] }); - await first; - await second; - }); - it.each([ { name: "setProvider", diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index a7bdb8021..9d716f865 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -577,20 +577,9 @@ export async function acpLoadSession( sessionId: shortLogId(sessionId), }); perfLog(`[perf:load] ${sid} acpLoadSession → client.loadSession`); - const { - response, - isCurrent, - preflightSettlement, - canPublish, - executionSelection, - } = await sessionRegistry.loadSession(sessionId, effectiveWorkingDir); + const { response, isCurrent, executionSelection } = + await sessionRegistry.loadSession(sessionId, effectiveWorkingDir); if (!isCurrent) { - void preflightSettlement?.then((settlement) => { - if (!canPublish(settlement)) return; - applySessionConfigOptionsSnapshot(sessionId, response, { - origin: "response", - }); - }); perfLog( `[perf:load] ${sid} dropped superseded load snapshot in ${(performance.now() - t0).toFixed(1)}ms`, ); diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 990ec2fe7..3de7d549c 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -34,28 +34,7 @@ interface SessionMutationQueue { latestSequence: number; tail: Promise; /** Configuration intent awaiting async preflight before it can enqueue. */ - pendingSupersession?: PendingSessionSupersession; -} - -interface SessionSupersessionSettlement { - /** Whether a suppressed load is again authoritative. */ - publishDeferredLoad: boolean; - /** Latest intent whose outcome decided this settlement. */ - sequence: number; -} - -interface PendingSessionSupersession { - sequence: number; - settled: Promise; - resolve( - settlement: - | SessionSupersessionSettlement - | PromiseLike, - ): void; -} - -interface ConsumedSessionSupersession { - settleMutation(sequence: number): void; + pendingSupersession?: { sequence: number }; } /** Opaque ownership of a configuration intent that is awaiting preflight. */ @@ -186,25 +165,15 @@ function serializeSessionMutation( function consumeSessionSupersession( sessionId: string, supersession: SessionMutationSupersession | undefined, -): ConsumedSessionSupersession | undefined { - if (!supersession) { - return { settleMutation() {} }; - } +): boolean { + if (!supersession) return true; const queue = mutationQueues.get(sessionId); - const pendingSupersession = queue?.pendingSupersession; - if (!queue || pendingSupersession?.sequence !== supersession.sequence) { - return undefined; + if (queue?.pendingSupersession?.sequence !== supersession.sequence) { + return false; } queue.pendingSupersession = undefined; scheduleQueueCleanup(sessionId, queue); - return { - settleMutation(sequence) { - pendingSupersession.resolve({ - publishDeferredLoad: false, - sequence, - }); - }, - }; + return true; } export function supersedeSessionMutation( @@ -215,32 +184,17 @@ export function supersedeSessionMutation( queue = { latestSequence: 0, tail: Promise.resolve() }; mutationQueues.set(sessionId, queue); } - // Resolution may need authoritative I/O before it can enqueue its ACP - // mutation. Retain that intent even when the session was idle, so a later - // load cannot publish a snapshot that predates it. + // Retain preflight intent before its authoritative I/O completes so a load + // cannot publish a snapshot that predates the requested configuration. const sequence = nextMutationSequence++; - let resolve!: (settlement: SessionSupersessionSettlement) => void; - const settled = new Promise( - (resolvePromise) => { - resolve = resolvePromise; - }, - ); - const previousSupersession = queue.pendingSupersession; - if (previousSupersession) { - previousSupersession.resolve(settled); - } queue.latestSequence = sequence; - queue.pendingSupersession = { sequence, settled, resolve }; + queue.pendingSupersession = { sequence }; scheduleQueueCleanup(sessionId, queue); return { sequence, clear() { if (queue?.pendingSupersession?.sequence !== sequence) return; - queue.pendingSupersession.resolve({ - publishDeferredLoad: true, - sequence, - }); queue.pendingSupersession = undefined; scheduleQueueCleanup(sessionId, queue); }, @@ -254,17 +208,9 @@ export async function prepareSession( options: SessionConfigMutationOptions = {}, supersession?: SessionMutationSupersession, ): Promise { - const consumedSupersession = consumeSessionSupersession( - sessionId, - supersession, - ); - if (!consumedSupersession) return; - const snapshots = await serializeSessionMutation( - sessionId, - (_isLatest, sequence) => { - consumedSupersession.settleMutation(sequence); - return prepareSessionNow(sessionId, providerId, workingDir, options); - }, + if (!consumeSessionSupersession(sessionId, supersession)) return; + const snapshots = await serializeSessionMutation(sessionId, () => + prepareSessionNow(sessionId, providerId, workingDir, options), ); return snapshots; } @@ -449,29 +395,21 @@ export async function configureSession( if (modelId && !concreteModelId) { throw new Error(`Invalid model id: ${modelId}`); } - const consumedSupersession = consumeSessionSupersession( - sessionId, - supersession, - ); - if (!consumedSupersession) return; - const snapshots = await serializeSessionMutation( - sessionId, - async (_isLatest, sequence) => { - consumedSupersession.settleMutation(sequence); - let snapshots = await prepareSessionNow( - sessionId, - providerId, - workingDir, - concreteModelId ? {} : options, - ); - if (concreteModelId) { - snapshots = - (await applySessionModelNow(sessionId, concreteModelId, options)) ?? - snapshots; - } - return snapshots; - }, - ); + if (!consumeSessionSupersession(sessionId, supersession)) return; + const snapshots = await serializeSessionMutation(sessionId, async () => { + let snapshots = await prepareSessionNow( + sessionId, + providerId, + workingDir, + concreteModelId ? {} : options, + ); + if (concreteModelId) { + snapshots = + (await applySessionModelNow(sessionId, concreteModelId, options)) ?? + snapshots; + } + return snapshots; + }); return snapshots; } @@ -526,21 +464,13 @@ export async function loadSession( ): Promise<{ response: Awaited>; isCurrent: boolean; - /** Resolves when the preflight that suppressed this load settles. */ - preflightSettlement?: Promise; - /** Re-checks the load's ordering after asynchronous preflight settlement. */ - canPublish(settlement: SessionSupersessionSettlement): boolean; executionSelection?: AcpSessionExecutionSelection; }> { return serializeSessionMutation( sessionId, - async (isLatest, sequence, queue) => { + async (isLatest) => { const response = await acpApi.loadSession(sessionId, workingDir); const isCurrentResult = isLatest(); - const pendingSupersession = isCurrentResult - ? undefined - : mutationQueues.get(sessionId)?.pendingSupersession; - const preflightSettlement = pendingSupersession?.settled; const executionSnapshot = readSessionExecutionConfigSnapshot(response); prepared.set(sessionId, { workingDir, @@ -549,11 +479,6 @@ export async function loadSession( return { response, isCurrent: isCurrentResult, - preflightSettlement, - canPublish: (settlement) => - settlement.publishDeferredLoad && - queue.latestSequence === Math.max(sequence, settlement.sequence) && - queue.pendingSupersession === undefined, executionSelection: executionSnapshot ?? undefined, }; }, From be4e4d3445f12d2ec4657e594d4df2b5638031a9 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 14:20:34 -0400 Subject: [PATCH 12/50] fix(acp): reserve configuration intent before migration proof Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../chat/lib/sessionTargetCoordinator.ts | 9 ++- ...essionTargetTransition.integration.test.ts | 80 +++++++++++++++++++ src/shared/api/__tests__/acp.test.ts | 41 ++++++++++ src/shared/api/acp.ts | 19 ++++- 4 files changed, 146 insertions(+), 3 deletions(-) diff --git a/src/features/chat/lib/sessionTargetCoordinator.ts b/src/features/chat/lib/sessionTargetCoordinator.ts index 4720276b2..07526a715 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.ts @@ -1,4 +1,7 @@ -import { acpPrepareSession } from "@/shared/api/acp"; +import { + acpPrepareSession, + reserveAcpSessionConfiguration, +} from "@/shared/api/acp"; import type { AcpModelConfigSnapshot, AcpReasoningEffortConfigSnapshot, @@ -269,6 +272,7 @@ async function execute( operation: PendingOperation, ): Promise { const { request, operationId } = operation; + const intent = reserveAcpSessionConfiguration(request.sessionId); try { const effective = await resolveEffectiveTarget(request.target); const liveTarget = useChatSessionStore @@ -339,6 +343,7 @@ async function execute( ? { requestId: operationId } : {}), }, + intent, ); if (!currentOperation(actor, operation)) { resolveSuperseded(actor, operation); @@ -441,6 +446,8 @@ async function execute( error, fallback, }); + } finally { + intent.clear(); } } diff --git a/src/features/chat/lib/sessionTargetTransition.integration.test.ts b/src/features/chat/lib/sessionTargetTransition.integration.test.ts index 43b94ebdc..440f5af73 100644 --- a/src/features/chat/lib/sessionTargetTransition.integration.test.ts +++ b/src/features/chat/lib/sessionTargetTransition.integration.test.ts @@ -6,6 +6,32 @@ const mockSetProvider = vi.fn(); const mockSetModel = vi.fn(); const mockGetClient = vi.fn(); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function executionConfigResponse(providerId: string, modelId: string) { + return { + configOptions: [ + { + id: "provider", + kind: { type: "select", currentValue: providerId, options: [] }, + }, + { + id: "model", + category: "model", + kind: { type: "select", currentValue: modelId, options: [] }, + }, + ], + }; +} + vi.mock("@/shared/api/acpApi", () => ({ loadSession: (...args: unknown[]) => mockLoadSession(...args), setProvider: (...args: unknown[]) => mockSetProvider(...args), @@ -275,6 +301,60 @@ describe("transitionSessionTarget with managed Goose models", () => { }); }); + it("suppresses a concurrent load while migration proof is pending", async () => { + const supportedModels = deferred<{ models: string[] }>(); + const supportedModelsList = vi + .fn() + .mockReturnValue(supportedModels.promise); + mockGetClient.mockResolvedValue({ + goose: { GooseUnstableProvidersSupportedModelsList: supportedModelsList }, + }); + mockLoadSession.mockResolvedValueOnce( + executionConfigResponse("legacy-provider", "legacy-model"), + ); + const applyModelConfigSnapshot = vi.fn(); + const { setSessionConfigSnapshotHandlers } = await import( + "@/shared/api/acpSessionConfigSnapshots" + ); + setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); + const { resetManagedModelSelectionRepairCacheForTests } = await import( + "@/features/providers/lib/managedModelSelectionRepair" + ); + resetManagedModelSelectionRepairCacheForTests(); + const { acpLoadSession } = await import("@/shared/api/acp"); + const { transitionSessionTarget } = await import( + "./sessionTargetCoordinator" + ); + + const transition = transitionSessionTarget({ + sessionId: "migration-proof-session", + target: { + harnessId: "goose", + modelProviderId: "legacy-provider", + modelId: "legacy-model", + modelName: "Legacy model", + }, + workingDir: "/tmp/project", + }); + await vi.waitFor(() => + expect(supportedModelsList).toHaveBeenCalledTimes(1), + ); + + await acpLoadSession("migration-proof-session", "/tmp/project"); + expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + + supportedModels.resolve({ models: ["goose-gpt-5-5"] }); + await expect(transition).resolves.toMatchObject({ + applied: true, + resolvedTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + modelId: "goose-gpt-5-5", + }, + }); + expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + }); + it("finishes on the explicitly selected model instead of the managed default", async () => { const { transitionSessionTarget } = await import( "./sessionTargetCoordinator" diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index a0fd71058..ef628d0ce 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -1538,6 +1538,47 @@ describe("acpPrepareSession", () => { expect(sessionRegistry.isSessionPrepared("acp-session-1")).toBe(true); }); + it("keeps a caller-owned configuration intent after prepare rejects", async () => { + await setRuntimeConfig(managedRuntimeConfig); + mockSupportedModelsList.mockRejectedValueOnce(new Error("offline")); + const applyModelConfigSnapshot = vi.fn(); + const { setSessionConfigSnapshotHandlers } = await import( + "../acpSessionConfigSnapshots" + ); + setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); + const { + acpLoadSession, + acpPrepareSession, + reserveAcpSessionConfiguration, + } = await import("../acp"); + const sessionId = "acp-session-caller-owned-intent"; + const intent = reserveAcpSessionConfiguration(sessionId); + + await expect( + acpPrepareSession( + sessionId, + "goose", + "/tmp/project", + { + modelId: "other-model", + }, + intent, + ), + ).rejects.toThrow("Cannot verify models for migrated provider"); + mockLoadSession.mockResolvedValueOnce( + executionConfigResponse("openai", "gpt-5.5"), + ); + await acpLoadSession(sessionId, "/tmp/project"); + expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + + intent.clear(); + mockLoadSession.mockResolvedValueOnce( + executionConfigResponse("openai", "gpt-5.5"), + ); + await acpLoadSession(sessionId, "/tmp/project"); + expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1); + }); + it("does not load or mutate a session when managed migration cannot prove support", async () => { await setRuntimeConfig(managedRuntimeConfig); mockSupportedModelsList.mockRejectedValueOnce(new Error("offline")); diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index 9d716f865..c8bd26e92 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -80,6 +80,19 @@ export interface AcpSessionConfigApplyOptions { requestId?: string; } +export type AcpSessionConfigurationIntent = + sessionRegistry.SessionMutationSupersession; + +/** + * Reserve a session's configuration ordering before asynchronous resolution. + * The owner must pass it to acpPrepareSession and clear it when finished. + */ +export function reserveAcpSessionConfiguration( + sessionId: string, +): AcpSessionConfigurationIntent { + return sessionRegistry.supersedeSessionMutation(sessionId); +} + export interface AcpCreateSessionResult { sessionId: string; configOptionsSnapshot: AcpSessionConfigSnapshots; @@ -380,13 +393,15 @@ export async function acpPrepareSession( providerId: string, workingDir: string, options: AcpSessionConfigApplyOptions = {}, + intent?: AcpSessionConfigurationIntent, ): Promise { const sid = sessionId.slice(0, 8); const t0 = performance.now(); perfLog( `[perf:prepare] ${sid} acpPrepareSession start (provider=${providerId})`, ); - const supersession = sessionRegistry.supersedeSessionMutation(sessionId); + const supersession = intent ?? reserveAcpSessionConfiguration(sessionId); + const ownsSupersession = intent === undefined; try { const selection = await resolveGooseSessionSelection( providerId, @@ -416,7 +431,7 @@ export async function acpPrepareSession( ); return snapshots; } finally { - supersession.clear(); + if (ownsSupersession) supersession.clear(); } } From 0f5d4204ec539f9deb04f8f36c543c238f8f6968 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 14:33:19 -0400 Subject: [PATCH 13/50] fix(goose): choose proven migration inventory fallback Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../modelProviderPolicy.test.ts | 35 +++++++++++++++++++ .../runtime-config/modelProviderPolicy.ts | 7 ++++ 2 files changed, 42 insertions(+) diff --git a/src/shared/runtime-config/modelProviderPolicy.test.ts b/src/shared/runtime-config/modelProviderPolicy.test.ts index 4baaa0b81..565b53ca9 100644 --- a/src/shared/runtime-config/modelProviderPolicy.test.ts +++ b/src/shared/runtime-config/modelProviderPolicy.test.ts @@ -204,6 +204,41 @@ describe("resolveManagedGooseProviderSelection", () => { }); }); + it("uses a deterministic proven inventory model when a migration has no default model", async () => { + mockSupportedModelsList.mockResolvedValue({ + models: ["z-model", "a-model"], + }); + const configWithoutDefault: RuntimeConfig = { + ...managedConfig, + goose: { ...managedConfig.goose, defaultModelId: undefined }, + }; + + await expect( + resolveValidatedManagedGooseProviderSelection(configWithoutDefault, { + providerId: "disallowed", + }), + ).resolves.toEqual({ + providerId: "databricks_v2", + modelId: "a-model", + }); + }); + + it("rejects a migration with no selected default when target inventory is empty", async () => { + mockSupportedModelsList.mockResolvedValue({ models: [] }); + const configWithoutDefault: RuntimeConfig = { + ...managedConfig, + goose: { ...managedConfig.goose, defaultModelId: undefined }, + }; + + await expect( + resolveValidatedManagedGooseProviderSelection(configWithoutDefault, { + providerId: "disallowed", + }), + ).rejects.toThrow( + "No supported model is available for migrated provider databricks_v2", + ); + }); + it("rejects a provider migration when support cannot be proved", async () => { mockSupportedModelsList.mockRejectedValue(new Error("offline")); diff --git a/src/shared/runtime-config/modelProviderPolicy.ts b/src/shared/runtime-config/modelProviderPolicy.ts index 1806aa99c..4d80e23eb 100644 --- a/src/shared/runtime-config/modelProviderPolicy.ts +++ b/src/shared/runtime-config/modelProviderPolicy.ts @@ -118,6 +118,13 @@ export async function resolveValidatedManagedGooseProviderSelection( if (fallbackModelId && supportedModelIds.has(fallbackModelId)) { return { providerId: resolved.providerId, modelId: fallbackModelId }; } + const provenInventoryFallback = [...supportedModelIds].sort()[0]; + if (provenInventoryFallback) { + return { + providerId: resolved.providerId, + modelId: provenInventoryFallback, + }; + } throw new Error( `No supported model is available for migrated provider ${resolved.providerId}; provider selection was not changed.`, ); From ae3ce818ea94a84f798fd5097d3b49d7d80960e3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sat, 8 Aug 2026 14:53:13 -0400 Subject: [PATCH 14/50] fix(goose): bound inventory migration proof Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../lib/managedModelSelectionRepair.ts | 17 ++-- .../lib/providerModelInventoryEvents.ts | 24 +---- src/shared/api/__tests__/acp.test.ts | 97 +++++++++++++++++++ .../modelProviderPolicy.test.ts | 49 ++++++++++ .../runtime-config/modelProviderPolicy.ts | 38 ++++++-- .../providerModelInventoryInvalidation.ts | 28 ++++++ 6 files changed, 217 insertions(+), 36 deletions(-) create mode 100644 src/shared/runtime-config/providerModelInventoryInvalidation.ts diff --git a/src/features/providers/lib/managedModelSelectionRepair.ts b/src/features/providers/lib/managedModelSelectionRepair.ts index 8e97104ec..0db7a47cb 100644 --- a/src/features/providers/lib/managedModelSelectionRepair.ts +++ b/src/features/providers/lib/managedModelSelectionRepair.ts @@ -2,7 +2,10 @@ import packageJson from "../../../../package.json"; import { getClient } from "@/shared/api/acpConnection"; import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; import { resolveAgentProviderCatalogIdStrict } from "@/features/providers/providerCatalog"; -import { subscribeToProviderModelInventoryInvalidation } from "./providerModelInventoryEvents"; +import { + providerModelInventoryGeneration, + subscribeToProviderModelInventoryInvalidation, +} from "@/shared/runtime-config/providerModelInventoryInvalidation"; import { resolveManagedGooseProviderSelection, resolveValidatedManagedGooseProviderSelection, @@ -20,16 +23,9 @@ const inventoryRequests = new Map< string, Promise | null> >(); -const inventoryGenerations = new Map(); - -function inventoryGeneration(providerId: string): number { - return inventoryGenerations.get(providerId) ?? 0; -} - subscribeToProviderModelInventoryInvalidation((providerId) => { validatedInventories.delete(providerId); inventoryRequests.delete(providerId); - inventoryGenerations.set(providerId, inventoryGeneration(providerId) + 1); }); export type ManagedModelRepairSource = @@ -51,7 +47,7 @@ async function validatedModelIds( const existing = inventoryRequests.get(providerId); if (existing) return existing; - const generationAtStart = inventoryGeneration(providerId); + const generationAtStart = providerModelInventoryGeneration(providerId); let request!: Promise | null>; request = (async () => { try { @@ -61,7 +57,7 @@ async function validatedModelIds( providerId, }); const modelIds = new Set(response.models as string[]); - if (generationAtStart !== inventoryGeneration(providerId)) { + if (generationAtStart !== providerModelInventoryGeneration(providerId)) { return validatedModelIds(providerId); } validatedInventories.set(providerId, { @@ -136,5 +132,4 @@ export async function repairManagedGooseModelSelection( export function resetManagedModelSelectionRepairCacheForTests(): void { validatedInventories.clear(); inventoryRequests.clear(); - inventoryGenerations.clear(); } diff --git a/src/features/providers/lib/providerModelInventoryEvents.ts b/src/features/providers/lib/providerModelInventoryEvents.ts index 3c17f91e8..244fb9aff 100644 --- a/src/features/providers/lib/providerModelInventoryEvents.ts +++ b/src/features/providers/lib/providerModelInventoryEvents.ts @@ -1,19 +1,5 @@ -type ProviderModelInventoryInvalidationListener = (providerId: string) => void; - -const invalidationListeners = - new Set(); - -export function notifyProviderModelInventoryInvalidated( - providerId: string, -): void { - for (const listener of invalidationListeners) { - listener(providerId); - } -} - -export function subscribeToProviderModelInventoryInvalidation( - listener: ProviderModelInventoryInvalidationListener, -): () => void { - invalidationListeners.add(listener); - return () => invalidationListeners.delete(listener); -} +export { + notifyProviderModelInventoryInvalidated, + providerModelInventoryGeneration, + subscribeToProviderModelInventoryInvalidation, +} from "@/shared/runtime-config/providerModelInventoryInvalidation"; diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index ef628d0ce..a44dac0da 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -1579,6 +1579,103 @@ describe("acpPrepareSession", () => { expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1); }); + it("releases timed-out inventory intent so fresh load and retry can reconcile", async () => { + await setRuntimeConfig(managedRuntimeConfig); + vi.useFakeTimers(); + const timedOutInventory = deferred<{ models: string[] }>(); + mockSupportedModelsList + .mockReturnValueOnce(timedOutInventory.promise) + .mockResolvedValueOnce({ models: ["goose-gpt-5-5"] }); + mockLoadSession.mockResolvedValue( + executionConfigResponse("openai", "gpt-5.5"), + ); + mockSetProvider.mockResolvedValue({ model: null, reasoningEffort: null }); + mockSetModel.mockResolvedValue({ model: null, reasoningEffort: null }); + const applyModelConfigSnapshot = vi.fn(); + const { setSessionConfigSnapshotHandlers } = await import( + "../acpSessionConfigSnapshots" + ); + setSessionConfigSnapshotHandlers({ applyModelConfigSnapshot }); + const { acpLoadSession, acpPrepareSession } = await import("../acp"); + const sessionId = "acp-session-timeout-retry"; + + const timedOutPrepare = acpPrepareSession( + sessionId, + "goose", + "/tmp/project", + { + modelId: "other-model", + }, + ); + const rejectedPrepare = expect(timedOutPrepare).rejects.toThrow( + "Cannot verify models for migrated provider", + ); + await vi.advanceTimersByTimeAsync(60_000); + await rejectedPrepare; + + expect(mockLoadSession).not.toHaveBeenCalled(); + expect(mockSetProvider).not.toHaveBeenCalled(); + expect(mockSetModel).not.toHaveBeenCalled(); + timedOutInventory.resolve({ models: ["goose-gpt-5-5"] }); + await Promise.resolve(); + expect(mockSetProvider).not.toHaveBeenCalled(); + expect(mockSetModel).not.toHaveBeenCalled(); + + await acpLoadSession(sessionId, "/tmp/project"); + expect(applyModelConfigSnapshot).toHaveBeenCalledTimes(1); + await acpPrepareSession(sessionId, "goose", "/tmp/project", { + modelId: "other-model", + }); + expect(mockSetProvider).toHaveBeenCalledWith( + sessionId, + "databricks_v2", + noRequestProviderContext, + ); + expect(mockSetModel).toHaveBeenCalledWith( + sessionId, + "goose-gpt-5-5", + noRequestModelContext("databricks_v2"), + ); + vi.useRealTimers(); + }); + + it("rejects invalidated inventory proof and requires a fresh proof before mutation", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const staleInventory = deferred<{ models: string[] }>(); + mockSupportedModelsList + .mockReturnValueOnce(staleInventory.promise) + .mockResolvedValueOnce({ models: ["goose-gpt-5-5"] }); + mockSetProvider.mockResolvedValue({ model: null, reasoningEffort: null }); + mockSetModel.mockResolvedValue({ model: null, reasoningEffort: null }); + const { acpPrepareSession } = await import("../acp"); + const { notifyProviderModelInventoryInvalidated } = await import( + "@/shared/runtime-config/providerModelInventoryInvalidation" + ); + const sessionId = "acp-session-invalidated-proof"; + + const stalePrepare = acpPrepareSession(sessionId, "goose", "/tmp/project", { + modelId: "other-model", + }); + await vi.waitFor(() => + expect(mockSupportedModelsList).toHaveBeenCalledTimes(1), + ); + notifyProviderModelInventoryInvalidated("databricks_v2"); + staleInventory.resolve({ models: ["goose-gpt-5-5"] }); + await expect(stalePrepare).rejects.toThrow( + "Cannot verify models for migrated provider", + ); + + expect(mockLoadSession).not.toHaveBeenCalled(); + expect(mockSetProvider).not.toHaveBeenCalled(); + expect(mockSetModel).not.toHaveBeenCalled(); + await acpPrepareSession(sessionId, "goose", "/tmp/project", { + modelId: "other-model", + }); + expect(mockSupportedModelsList).toHaveBeenCalledTimes(2); + expect(mockSetProvider).toHaveBeenCalledTimes(1); + expect(mockSetModel).toHaveBeenCalledTimes(1); + }); + it("does not load or mutate a session when managed migration cannot prove support", async () => { await setRuntimeConfig(managedRuntimeConfig); mockSupportedModelsList.mockRejectedValueOnce(new Error("offline")); diff --git a/src/shared/runtime-config/modelProviderPolicy.test.ts b/src/shared/runtime-config/modelProviderPolicy.test.ts index 565b53ca9..790c9f7e6 100644 --- a/src/shared/runtime-config/modelProviderPolicy.test.ts +++ b/src/shared/runtime-config/modelProviderPolicy.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeConfig } from "./schema"; +import { notifyProviderModelInventoryInvalidated } from "./providerModelInventoryInvalidation"; import { managedGooseSelectionChanged, resolveManagedGooseProviderSelection, @@ -41,6 +42,7 @@ const managedConfig: RuntimeConfig = { describe("resolveManagedGooseProviderSelection", () => { beforeEach(() => { + vi.useRealTimers(); mockSupportedModelsList.mockReset(); }); it("returns unrestricted for an empty provider list", () => { @@ -239,6 +241,53 @@ describe("resolveManagedGooseProviderSelection", () => { ); }); + it("times out a never-settling inventory proof without accepting its late result", async () => { + vi.useFakeTimers(); + let resolveInventory!: (value: { models: string[] }) => void; + mockSupportedModelsList.mockReturnValue( + new Promise<{ models: string[] }>((resolve) => { + resolveInventory = resolve; + }), + ); + + const migration = resolveValidatedManagedGooseProviderSelection( + managedConfig, + { + providerId: "disallowed", + }, + ); + const rejectedMigration = expect(migration).rejects.toThrow( + "Cannot verify models for migrated provider databricks_v2", + ); + await vi.advanceTimersByTimeAsync(60_000); + await rejectedMigration; + resolveInventory({ models: ["goose-gpt-5-5"] }); + await Promise.resolve(); + }); + + it("rejects an inventory proof invalidated while it is in flight", async () => { + let resolveInventory!: (value: { models: string[] }) => void; + mockSupportedModelsList.mockReturnValue( + new Promise<{ models: string[] }>((resolve) => { + resolveInventory = resolve; + }), + ); + + const migration = resolveValidatedManagedGooseProviderSelection( + managedConfig, + { + providerId: "disallowed", + }, + ); + await vi.waitFor(() => expect(mockSupportedModelsList).toHaveBeenCalled()); + notifyProviderModelInventoryInvalidated("databricks_v2"); + resolveInventory({ models: ["goose-gpt-5-5"] }); + + await expect(migration).rejects.toThrow( + "Cannot verify models for migrated provider databricks_v2", + ); + }); + it("rejects a provider migration when support cannot be proved", async () => { mockSupportedModelsList.mockRejectedValue(new Error("offline")); diff --git a/src/shared/runtime-config/modelProviderPolicy.ts b/src/shared/runtime-config/modelProviderPolicy.ts index 4d80e23eb..7765ab7ac 100644 --- a/src/shared/runtime-config/modelProviderPolicy.ts +++ b/src/shared/runtime-config/modelProviderPolicy.ts @@ -1,6 +1,7 @@ import type { RuntimeConfig, RuntimeGooseConfig } from "./schema"; import { normalizeConcreteModelId } from "@/shared/lib/modelIdentity"; import { getClient } from "@/shared/api/acpConnection"; +import { providerModelInventoryGeneration } from "./providerModelInventoryInvalidation"; export interface GooseProviderSelection { providerId?: string | null; @@ -20,6 +21,7 @@ export interface ManagedGooseProviderResolutionContext { } const DATABRICKS_V2_PROVIDER_ID = "databricks_v2"; +const INVENTORY_PROOF_TIMEOUT_MS = 60_000; /** * Runtime model providers define provider policy and curated model metadata. @@ -87,6 +89,35 @@ export function resolveManagedGooseProviderSelection( * support for the selected (or fallback) model. Runtime config metadata is * advisory, so it is never evidence for this decision. */ + +async function readProvenTargetInventory( + providerId: string, +): Promise> { + const generationAtStart = providerModelInventoryGeneration(providerId); + let timeoutId: ReturnType | undefined; + try { + const client = await getClient(); + const response = await Promise.race([ + client.goose.GooseUnstableProvidersSupportedModelsList({ providerId }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new Error(`Timed out proving models for provider ${providerId}.`), + ); + }, INVENTORY_PROOF_TIMEOUT_MS); + }), + ]); + if (generationAtStart !== providerModelInventoryGeneration(providerId)) { + throw new Error( + `Model inventory changed while proving provider ${providerId}.`, + ); + } + return new Set(response.models as string[]); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } +} + export async function resolveValidatedManagedGooseProviderSelection( config: Pick, selection: GooseProviderSelection, @@ -98,12 +129,7 @@ export async function resolveValidatedManagedGooseProviderSelection( let supportedModelIds: ReadonlySet; try { - const client = await getClient(); - const response = - await client.goose.GooseUnstableProvidersSupportedModelsList({ - providerId: resolved.providerId, - }); - supportedModelIds = new Set(response.models as string[]); + supportedModelIds = await readProvenTargetInventory(resolved.providerId); } catch (error) { throw new Error( `Cannot verify models for migrated provider ${resolved.providerId}; provider selection was not changed.`, diff --git a/src/shared/runtime-config/providerModelInventoryInvalidation.ts b/src/shared/runtime-config/providerModelInventoryInvalidation.ts new file mode 100644 index 000000000..0ced94916 --- /dev/null +++ b/src/shared/runtime-config/providerModelInventoryInvalidation.ts @@ -0,0 +1,28 @@ +type ProviderModelInventoryInvalidationListener = (providerId: string) => void; + +const invalidationListeners = + new Set(); +const inventoryGenerations = new Map(); + +export function providerModelInventoryGeneration(providerId: string): number { + return inventoryGenerations.get(providerId) ?? 0; +} + +export function notifyProviderModelInventoryInvalidated( + providerId: string, +): void { + inventoryGenerations.set( + providerId, + providerModelInventoryGeneration(providerId) + 1, + ); + for (const listener of invalidationListeners) { + listener(providerId); + } +} + +export function subscribeToProviderModelInventoryInvalidation( + listener: ProviderModelInventoryInvalidationListener, +): () => void { + invalidationListeners.add(listener); + return () => invalidationListeners.delete(listener); +} From 6dfcfa3907c132564e88463f2cc52ec44611a49c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 17:57:15 -0400 Subject: [PATCH 15/50] fix(goose): bound client acquisition during migration proof Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../modelProviderPolicy.test.ts | 30 +++++++++++++++---- .../runtime-config/modelProviderPolicy.ts | 5 ++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/shared/runtime-config/modelProviderPolicy.test.ts b/src/shared/runtime-config/modelProviderPolicy.test.ts index 790c9f7e6..d99f2dd00 100644 --- a/src/shared/runtime-config/modelProviderPolicy.test.ts +++ b/src/shared/runtime-config/modelProviderPolicy.test.ts @@ -7,14 +7,10 @@ import { resolveValidatedManagedGooseProviderSelection, } from "./modelProviderPolicy"; +const mockGetClient = vi.hoisted(() => vi.fn()); const mockSupportedModelsList = vi.hoisted(() => vi.fn()); vi.mock("@/shared/api/acpConnection", () => ({ - getClient: () => - Promise.resolve({ - goose: { - GooseUnstableProvidersSupportedModelsList: mockSupportedModelsList, - }, - }), + getClient: () => mockGetClient(), })); const managedConfig: RuntimeConfig = { @@ -43,6 +39,12 @@ const managedConfig: RuntimeConfig = { describe("resolveManagedGooseProviderSelection", () => { beforeEach(() => { vi.useRealTimers(); + mockGetClient.mockReset(); + mockGetClient.mockResolvedValue({ + goose: { + GooseUnstableProvidersSupportedModelsList: mockSupportedModelsList, + }, + }); mockSupportedModelsList.mockReset(); }); it("returns unrestricted for an empty provider list", () => { @@ -265,6 +267,22 @@ describe("resolveManagedGooseProviderSelection", () => { await Promise.resolve(); }); + it("times out stalled ACP client acquisition", async () => { + vi.useFakeTimers(); + mockGetClient.mockReturnValue(new Promise(() => {})); + + const migration = resolveValidatedManagedGooseProviderSelection( + managedConfig, + { providerId: "disallowed" }, + ); + const rejectedMigration = expect(migration).rejects.toThrow( + "Cannot verify models for migrated provider databricks_v2", + ); + await vi.advanceTimersByTimeAsync(60_000); + await rejectedMigration; + expect(mockSupportedModelsList).not.toHaveBeenCalled(); + }); + it("rejects an inventory proof invalidated while it is in flight", async () => { let resolveInventory!: (value: { models: string[] }) => void; mockSupportedModelsList.mockReturnValue( diff --git a/src/shared/runtime-config/modelProviderPolicy.ts b/src/shared/runtime-config/modelProviderPolicy.ts index 7765ab7ac..9bc6a065f 100644 --- a/src/shared/runtime-config/modelProviderPolicy.ts +++ b/src/shared/runtime-config/modelProviderPolicy.ts @@ -96,9 +96,10 @@ async function readProvenTargetInventory( const generationAtStart = providerModelInventoryGeneration(providerId); let timeoutId: ReturnType | undefined; try { - const client = await getClient(); const response = await Promise.race([ - client.goose.GooseUnstableProvidersSupportedModelsList({ providerId }), + getClient().then((client) => + client.goose.GooseUnstableProvidersSupportedModelsList({ providerId }), + ), new Promise((_, reject) => { timeoutId = setTimeout(() => { reject( From 57cb1bf890466ebb4c5b8254d2c382fcb398c656 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 18:06:33 -0400 Subject: [PATCH 16/50] fix(goose): preserve provider harness boundaries Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../__tests__/personaExecutionTarget.test.ts | 15 ++++++++ .../agents/lib/personaExecutionTarget.ts | 37 +++++++++++++++++-- .../modelProviderPolicy.test.ts | 5 +++ .../runtime-config/modelProviderPolicy.ts | 17 ++++++++- 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts index d5e05acc9..20be9525f 100644 --- a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts +++ b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts @@ -52,6 +52,21 @@ describe("personaExecutionTarget", () => { }); }); + it("rejects an agent harness persisted as a Goose model provider", () => { + const persona = { + provider: "goose", + modelProviderId: "claude-acp", + model: "sonnet", + }; + + expect(personaExecutionTarget(persona, context())).toBeUndefined(); + expect(personaTargetMigration(persona, context())).toEqual({ + provider: null, + modelProviderId: null, + model: null, + }); + }); + it("uses an external harness as the runtime provider boundary", () => { expect( personaExecutionTarget( diff --git a/src/features/agents/lib/personaExecutionTarget.ts b/src/features/agents/lib/personaExecutionTarget.ts index 44c0fa251..e933ab3f7 100644 --- a/src/features/agents/lib/personaExecutionTarget.ts +++ b/src/features/agents/lib/personaExecutionTarget.ts @@ -75,13 +75,32 @@ function harnessIdForPersona( ); } +function isAgentProviderId( + providerId: string, + catalogEntries: ProviderCatalogEntry[], +): boolean { + return ( + resolveAgentProviderCatalogIdStrictFromEntries( + catalogEntries, + providerId, + ) !== null + ); +} + function persistedModelProviderId( persona: Pick, harnessId: string, catalogEntries: ProviderCatalogEntry[], ): string | undefined { - if (persona.modelProviderId?.trim()) { - return canonicalModelProviderId(persona.modelProviderId, catalogEntries); + const persistedProviderId = persona.modelProviderId?.trim(); + const persistedProviderIsAgent = + Boolean(persistedProviderId) && + isAgentProviderId(persistedProviderId, catalogEntries); + if ( + persistedProviderId && + !(harnessId === "goose" && persistedProviderIsAgent) + ) { + return canonicalModelProviderId(persistedProviderId, catalogEntries); } if ( persona.provider?.trim() && @@ -190,10 +209,22 @@ export function personaTargetMigration( context.providers, context.catalogEntries, ); + const persistedAgentProviderForGoose = + harnessIdForPersona( + persona.provider, + context.providers, + context.catalogEntries, + ) === "goose" && + Boolean( + persona.modelProviderId && + isAgentProviderId(persona.modelProviderId, context.catalogEntries), + ); // Clear only when the saved data itself proves it cannot form one target. // No inventory match may be a transient availability problem, so preserve // that legacy metadata until a later authoritative refresh can repair it. - return unknownHarness || matchingProviderIds.size > 1 + return unknownHarness || + persistedAgentProviderForGoose || + matchingProviderIds.size > 1 ? { provider: null, modelProviderId: null, model: null } : null; } diff --git a/src/shared/runtime-config/modelProviderPolicy.test.ts b/src/shared/runtime-config/modelProviderPolicy.test.ts index d99f2dd00..aac386d34 100644 --- a/src/shared/runtime-config/modelProviderPolicy.test.ts +++ b/src/shared/runtime-config/modelProviderPolicy.test.ts @@ -8,9 +8,11 @@ import { } from "./modelProviderPolicy"; const mockGetClient = vi.hoisted(() => vi.fn()); +const mockInvalidateClientConnection = vi.hoisted(() => vi.fn()); const mockSupportedModelsList = vi.hoisted(() => vi.fn()); vi.mock("@/shared/api/acpConnection", () => ({ getClient: () => mockGetClient(), + invalidateClientConnection: () => mockInvalidateClientConnection(), })); const managedConfig: RuntimeConfig = { @@ -40,6 +42,8 @@ describe("resolveManagedGooseProviderSelection", () => { beforeEach(() => { vi.useRealTimers(); mockGetClient.mockReset(); + mockInvalidateClientConnection.mockReset(); + mockInvalidateClientConnection.mockResolvedValue(undefined); mockGetClient.mockResolvedValue({ goose: { GooseUnstableProvidersSupportedModelsList: mockSupportedModelsList, @@ -280,6 +284,7 @@ describe("resolveManagedGooseProviderSelection", () => { ); await vi.advanceTimersByTimeAsync(60_000); await rejectedMigration; + expect(mockInvalidateClientConnection).toHaveBeenCalledOnce(); expect(mockSupportedModelsList).not.toHaveBeenCalled(); }); diff --git a/src/shared/runtime-config/modelProviderPolicy.ts b/src/shared/runtime-config/modelProviderPolicy.ts index 9bc6a065f..720856490 100644 --- a/src/shared/runtime-config/modelProviderPolicy.ts +++ b/src/shared/runtime-config/modelProviderPolicy.ts @@ -1,6 +1,9 @@ import type { RuntimeConfig, RuntimeGooseConfig } from "./schema"; import { normalizeConcreteModelId } from "@/shared/lib/modelIdentity"; -import { getClient } from "@/shared/api/acpConnection"; +import { + getClient, + invalidateClientConnection, +} from "@/shared/api/acpConnection"; import { providerModelInventoryGeneration } from "./providerModelInventoryInvalidation"; export interface GooseProviderSelection { @@ -95,6 +98,7 @@ async function readProvenTargetInventory( ): Promise> { const generationAtStart = providerModelInventoryGeneration(providerId); let timeoutId: ReturnType | undefined; + let didTimeOut = false; try { const response = await Promise.race([ getClient().then((client) => @@ -102,6 +106,7 @@ async function readProvenTargetInventory( ), new Promise((_, reject) => { timeoutId = setTimeout(() => { + didTimeOut = true; reject( new Error(`Timed out proving models for provider ${providerId}.`), ); @@ -114,6 +119,16 @@ async function readProvenTargetInventory( ); } return new Set(response.models as string[]); + } catch (error) { + if (didTimeOut) { + await invalidateClientConnection().catch((invalidationError) => { + console.error( + "Failed to invalidate timed-out ACP connection:", + invalidationError, + ); + }); + } + throw error; } finally { if (timeoutId !== undefined) clearTimeout(timeoutId); } From 60243e49c7e1475d78a688025aa5d720414e0e21 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 18:27:48 -0400 Subject: [PATCH 17/50] fix(goose): discard stale ACP initialization Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../agents/lib/personaExecutionTarget.ts | 16 ++++---- src/shared/api/acpConnection.ts | 39 ++++++++++++++----- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/features/agents/lib/personaExecutionTarget.ts b/src/features/agents/lib/personaExecutionTarget.ts index e933ab3f7..51e9499cf 100644 --- a/src/features/agents/lib/personaExecutionTarget.ts +++ b/src/features/agents/lib/personaExecutionTarget.ts @@ -93,14 +93,14 @@ function persistedModelProviderId( catalogEntries: ProviderCatalogEntry[], ): string | undefined { const persistedProviderId = persona.modelProviderId?.trim(); - const persistedProviderIsAgent = - Boolean(persistedProviderId) && - isAgentProviderId(persistedProviderId, catalogEntries); - if ( - persistedProviderId && - !(harnessId === "goose" && persistedProviderIsAgent) - ) { - return canonicalModelProviderId(persistedProviderId, catalogEntries); + if (persistedProviderId) { + const persistedProviderIsAgent = isAgentProviderId( + persistedProviderId, + catalogEntries, + ); + if (!(harnessId === "goose" && persistedProviderIsAgent)) { + return canonicalModelProviderId(persistedProviderId, catalogEntries); + } } if ( persona.provider?.trim() && diff --git a/src/shared/api/acpConnection.ts b/src/shared/api/acpConnection.ts index 0f69884f5..f682b8ec7 100644 --- a/src/shared/api/acpConnection.ts +++ b/src/shared/api/acpConnection.ts @@ -61,6 +61,7 @@ export function setPermissionHandler(handler: PermissionRequestHandler): void { let clientPromise: Promise | null = null; let resolvedClient: GooseClient | null = null; let activeStream: ReturnType | null = null; +let connectionGeneration = 0; function createClientCallbacks(): () => Client { return () => ({ @@ -125,6 +126,7 @@ function monitorConnection( * safer than allowing later mutations to race work still running remotely. */ export async function invalidateClientConnection(): Promise { + connectionGeneration += 1; const stream = activeStream; activeStream = null; resolvedClient = null; @@ -134,7 +136,12 @@ export async function invalidateClientConnection(): Promise { } } -async function initializeConnection(): Promise { +interface InitializedConnection { + client: GooseClient; + stream: ReturnType; +} + +async function initializeConnection(): Promise { // Dev-only: inject a real failure into startup so the WARP probe runs // for real against kgoose. `VITE_DEV_STARTUP_ERROR=warp just dev` lets // us experience the diagnostic UI with whatever real WARP state the @@ -167,7 +174,6 @@ async function initializeConnection(): Promise { const tStream = performance.now(); const stream = createWebSocketStream(wsUrl); - activeStream = stream; const client = new GooseClient(createClientCallbacks(), stream); perfLog( @@ -194,9 +200,7 @@ async function initializeConnection(): Promise { `[perf:conn] client.initialize in ${(performance.now() - tInit).toFixed(1)}ms (total ${(performance.now() - tStart).toFixed(1)}ms)`, ); - monitorConnection(client, stream); - - return client; + return { client, stream }; } export async function getClient(): Promise { @@ -206,15 +210,32 @@ export async function getClient(): Promise { if (!clientPromise) { perfLog("[perf:conn] getClient() → initializing new ACP connection"); - clientPromise = initializeConnection() - .then((client) => { - resolvedClient = client; + const generation = connectionGeneration; + const pendingClient = initializeConnection(); + const clientInitialization = pendingClient + .then(async ({ client, stream }) => { + if ( + generation === connectionGeneration && + clientPromise === clientInitialization + ) { + activeStream = stream; + resolvedClient = client; + monitorConnection(client, stream); + return client; + } + await stream.writable.abort(); return client; }) .catch((error) => { - clientPromise = null; + if ( + generation === connectionGeneration && + clientPromise === clientInitialization + ) { + clientPromise = null; + } throw error; }); + clientPromise = clientInitialization; } else { perfLog("[perf:conn] getClient() awaiting in-flight initializeConnection"); } From 83fa8fb7286ca579ee488c963bb622c26aef2f3e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 18:38:28 -0400 Subject: [PATCH 18/50] fix(goose): own ACP initialization attempts Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/shared/api/acpConnection.test.ts | 51 ++++++++++++++++++++++++++++ src/shared/api/acpConnection.ts | 49 +++++++++++++++----------- 2 files changed, 81 insertions(+), 19 deletions(-) create mode 100644 src/shared/api/acpConnection.test.ts diff --git a/src/shared/api/acpConnection.test.ts b/src/shared/api/acpConnection.test.ts new file mode 100644 index 000000000..27f59dc2d --- /dev/null +++ b/src/shared/api/acpConnection.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const initializations: Array<() => void> = []; + const streams: Array<{ writable: { abort: ReturnType } }> = []; + class MockGooseClient { + closed = new Promise(() => {}); + async initialize(): Promise { + await new Promise((resolve) => initializations.push(resolve)); + } + } + return { initializations, streams, MockGooseClient }; +}); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue("ws://goose"), +})); +vi.mock("@aaif/goose-sdk", () => ({ + DEFAULT_GOOSE_MCP_HOST_CAPABILITIES: {}, + GooseClient: mocks.MockGooseClient, +})); +vi.mock("./createWebSocketStream", () => ({ + createWebSocketStream: () => { + const stream = { + writable: { abort: vi.fn().mockResolvedValue(undefined) }, + }; + mocks.streams.push(stream); + return stream; + }, +})); +describe("ACP connection lifecycle", () => { + beforeEach(() => { + mocks.initializations.length = 0; + mocks.streams.length = 0; + vi.resetModules(); + }); + it("retires and aborts an initializing transport before retry", async () => { + const connection = await import("./acpConnection"); + const stale = connection.getClient(); + await vi.waitFor(() => expect(mocks.initializations).toHaveLength(1)); + await connection.invalidateClientConnection(); + expect(mocks.streams[0]?.writable.abort).toHaveBeenCalledOnce(); + const retry = connection.getClient(); + await vi.waitFor(() => expect(mocks.initializations).toHaveLength(2)); + mocks.initializations[1]?.(); + const client = await retry; + mocks.initializations[0]?.(); + await stale; + expect(connection.getClientSync()).toBe(client); + expect(mocks.streams[1]?.writable.abort).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/api/acpConnection.ts b/src/shared/api/acpConnection.ts index f682b8ec7..9205a24a6 100644 --- a/src/shared/api/acpConnection.ts +++ b/src/shared/api/acpConnection.ts @@ -63,6 +63,13 @@ let resolvedClient: GooseClient | null = null; let activeStream: ReturnType | null = null; let connectionGeneration = 0; +interface ConnectionAttempt { + generation: number; + stream: ReturnType | null; +} + +let currentAttempt: ConnectionAttempt | null = null; + function createClientCallbacks(): () => Client { return () => ({ requestPermission: async ( @@ -96,14 +103,16 @@ function createClientCallbacks(): () => Client { function monitorConnection( client: GooseClient, stream: ReturnType, + attempt: ConnectionAttempt, ): void { const clearCurrentConnection = () => { - if (activeStream !== stream) { + if (currentAttempt !== attempt || activeStream !== stream) { return; } resolvedClient = null; clientPromise = null; activeStream = null; + currentAttempt = null; }; client.closed .then(() => { @@ -127,7 +136,9 @@ function monitorConnection( */ export async function invalidateClientConnection(): Promise { connectionGeneration += 1; - const stream = activeStream; + const attempt = currentAttempt; + currentAttempt = null; + const stream = attempt?.stream ?? activeStream; activeStream = null; resolvedClient = null; clientPromise = null; @@ -141,7 +152,9 @@ interface InitializedConnection { stream: ReturnType; } -async function initializeConnection(): Promise { +async function initializeConnection( + attempt: ConnectionAttempt, +): Promise { // Dev-only: inject a real failure into startup so the WARP probe runs // for real against kgoose. `VITE_DEV_STARTUP_ERROR=warp just dev` lets // us experience the diagnostic UI with whatever real WARP state the @@ -174,6 +187,7 @@ async function initializeConnection(): Promise { const tStream = performance.now(); const stream = createWebSocketStream(wsUrl); + attempt.stream = stream; const client = new GooseClient(createClientCallbacks(), stream); perfLog( @@ -204,42 +218,39 @@ async function initializeConnection(): Promise { } export async function getClient(): Promise { - if (resolvedClient) { - return resolvedClient; - } - + if (resolvedClient) return resolvedClient; if (!clientPromise) { perfLog("[perf:conn] getClient() → initializing new ACP connection"); - const generation = connectionGeneration; - const pendingClient = initializeConnection(); - const clientInitialization = pendingClient + const attempt: ConnectionAttempt = { + generation: connectionGeneration, + stream: null, + }; + currentAttempt = attempt; + const initialization = initializeConnection(attempt) .then(async ({ client, stream }) => { if ( - generation === connectionGeneration && - clientPromise === clientInitialization + currentAttempt === attempt && + attempt.generation === connectionGeneration ) { activeStream = stream; resolvedClient = client; - monitorConnection(client, stream); + monitorConnection(client, stream, attempt); return client; } await stream.writable.abort(); return client; }) .catch((error) => { - if ( - generation === connectionGeneration && - clientPromise === clientInitialization - ) { + if (currentAttempt === attempt) { + currentAttempt = null; clientPromise = null; } throw error; }); - clientPromise = clientInitialization; + clientPromise = initialization; } else { perfLog("[perf:conn] getClient() awaiting in-flight initializeConnection"); } - return clientPromise; } From c18dfcf5ec2e0f7d4062c9932c5605f973cd4111 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 19:10:42 -0400 Subject: [PATCH 19/50] fix(goose): share bounded inventory proof Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../lib/managedModelSelectionRepair.test.ts | 64 +++++++++++++++++++ .../lib/managedModelSelectionRepair.ts | 12 ++-- .../runtime-config/modelProviderPolicy.ts | 15 +++-- 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/features/providers/lib/managedModelSelectionRepair.test.ts b/src/features/providers/lib/managedModelSelectionRepair.test.ts index ecc4a6fdc..9682841d0 100644 --- a/src/features/providers/lib/managedModelSelectionRepair.test.ts +++ b/src/features/providers/lib/managedModelSelectionRepair.test.ts @@ -11,6 +11,7 @@ import { notifyProviderModelInventoryInvalidated } from "./providerModelInventor vi.mock("@/shared/api/acpConnection", () => ({ getClient: vi.fn(), + invalidateClientConnection: vi.fn().mockResolvedValue(undefined), })); const managedConfig: RuntimeConfig = { @@ -172,6 +173,69 @@ describe("repairManagedGooseModelSelection", () => { expect(supportedModelsList).toHaveBeenCalledTimes(2); }); + it("releases same-provider proof after stalled client acquisition", async () => { + vi.useFakeTimers(); + vi.mocked(getClient).mockReturnValue(new Promise(() => {})); + + const repair = repairManagedGooseModelSelection( + { providerId: "databricks_v2", modelId: "future-model" }, + "session", + ); + await vi.advanceTimersByTimeAsync(60_000); + await expect(repair).resolves.toEqual({ + providerId: "databricks_v2", + modelId: "future-model", + }); + + vi.mocked(getClient).mockResolvedValue({ + goose: { + GooseUnstableProvidersSupportedModelsList: vi.fn().mockResolvedValue({ + models: ["future-model"], + }), + }, + } as never); + await expect( + repairManagedGooseModelSelection( + { providerId: "databricks_v2", modelId: "future-model" }, + "session", + ), + ).resolves.toEqual({ + providerId: "databricks_v2", + modelId: "future-model", + }); + }); + + it("releases same-provider proof after stalled inventory RPC", async () => { + vi.useFakeTimers(); + const stalledInventory = new Promise<{ models: string[] }>(() => {}); + const supportedModelsList = vi + .fn() + .mockReturnValueOnce(stalledInventory) + .mockResolvedValueOnce({ models: ["future-model"] }); + vi.mocked(getClient).mockResolvedValue({ + goose: { GooseUnstableProvidersSupportedModelsList: supportedModelsList }, + } as never); + + const repair = repairManagedGooseModelSelection( + { providerId: "databricks_v2", modelId: "future-model" }, + "session", + ); + await vi.advanceTimersByTimeAsync(60_000); + await expect(repair).resolves.toEqual({ + providerId: "databricks_v2", + modelId: "future-model", + }); + await expect( + repairManagedGooseModelSelection( + { providerId: "databricks_v2", modelId: "future-model" }, + "session", + ), + ).resolves.toEqual({ + providerId: "databricks_v2", + modelId: "future-model", + }); + expect(supportedModelsList).toHaveBeenCalledTimes(2); + }); it("preserves the selected model when live inventory cannot be read", async () => { vi.mocked(getClient).mockRejectedValue(new Error("offline")); diff --git a/src/features/providers/lib/managedModelSelectionRepair.ts b/src/features/providers/lib/managedModelSelectionRepair.ts index 0db7a47cb..da6e1f713 100644 --- a/src/features/providers/lib/managedModelSelectionRepair.ts +++ b/src/features/providers/lib/managedModelSelectionRepair.ts @@ -1,5 +1,4 @@ import packageJson from "../../../../package.json"; -import { getClient } from "@/shared/api/acpConnection"; import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; import { resolveAgentProviderCatalogIdStrict } from "@/features/providers/providerCatalog"; import { @@ -7,6 +6,7 @@ import { subscribeToProviderModelInventoryInvalidation, } from "@/shared/runtime-config/providerModelInventoryInvalidation"; import { + readBoundedProvenModelInventory, resolveManagedGooseProviderSelection, resolveValidatedManagedGooseProviderSelection, type GooseProviderSelection, @@ -51,12 +51,7 @@ async function validatedModelIds( let request!: Promise | null>; request = (async () => { try { - const client = await getClient(); - const response = - await client.goose.GooseUnstableProvidersSupportedModelsList({ - providerId, - }); - const modelIds = new Set(response.models as string[]); + const modelIds = await readBoundedProvenModelInventory(providerId); if (generationAtStart !== providerModelInventoryGeneration(providerId)) { return validatedModelIds(providerId); } @@ -66,6 +61,9 @@ async function validatedModelIds( }); return modelIds; } catch (error) { + if (generationAtStart !== providerModelInventoryGeneration(providerId)) { + return validatedModelIds(providerId); + } console.warn("Could not validate managed provider model inventory", { providerId, error: error instanceof Error ? error.message : String(error), diff --git a/src/shared/runtime-config/modelProviderPolicy.ts b/src/shared/runtime-config/modelProviderPolicy.ts index 720856490..44f74a973 100644 --- a/src/shared/runtime-config/modelProviderPolicy.ts +++ b/src/shared/runtime-config/modelProviderPolicy.ts @@ -88,12 +88,13 @@ export function resolveManagedGooseProviderSelection( } /** - * Resolve a managed provider migration only after the target provider proves - * support for the selected (or fallback) model. Runtime config metadata is - * advisory, so it is never evidence for this decision. + * Read a provider's live model inventory as authoritative evidence for managed + * configuration decisions. Both ACP acquisition and the inventory RPC share + * one deadline; a timeout invalidates the connection so a later proof starts + * from fresh state. Results from an invalidated inventory generation are never + * accepted. */ - -async function readProvenTargetInventory( +export async function readBoundedProvenModelInventory( providerId: string, ): Promise> { const generationAtStart = providerModelInventoryGeneration(providerId); @@ -145,7 +146,9 @@ export async function resolveValidatedManagedGooseProviderSelection( let supportedModelIds: ReadonlySet; try { - supportedModelIds = await readProvenTargetInventory(resolved.providerId); + supportedModelIds = await readBoundedProvenModelInventory( + resolved.providerId, + ); } catch (error) { throw new Error( `Cannot verify models for migrated provider ${resolved.providerId}; provider selection was not changed.`, From 235aaaa87c67cbffae8a11d7f26e62097474eb36 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 19:31:37 -0400 Subject: [PATCH 20/50] fix(agents): canonicalize persisted harness targets Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../__tests__/personaExecutionTarget.test.ts | 63 +++++++++++++++++++ .../agents/lib/personaExecutionTarget.ts | 21 ++++--- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts index 20be9525f..b51e5332a 100644 --- a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts +++ b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ProviderCatalogEntry } from "@/shared/types/providers"; +import { gooseServeSelectionFromExecutionTarget } from "@/features/chat/lib/gooseServeExecutionTarget"; import { personaExecutionTarget, personaTargetMigration, @@ -67,6 +68,68 @@ describe("personaExecutionTarget", () => { }); }); + it("never materializes an agent provider as a Goose target without a model", () => { + const persona = { provider: "goose", modelProviderId: "claude-acp" }; + + expect(personaExecutionTarget(persona, context())).toEqual({ + harnessId: "goose", + }); + expect(personaTargetMigration(persona, context())).toEqual({ + provider: "goose", + modelProviderId: null, + model: null, + }); + }); + + it("owns an external harness model provider and repairs legacy display metadata", () => { + const persona = { + provider: "claude-acp", + modelProviderId: "openai", + model: "sonnet", + }; + const target = personaExecutionTarget( + persona, + context([{ id: "sonnet", displayName: "Sonnet" }]), + ); + + expect(target).toEqual({ + harnessId: "claude-acp", + modelProviderId: "claude-acp", + modelId: "sonnet", + modelName: "Sonnet", + }); + expect(gooseServeSelectionFromExecutionTarget(target)).toEqual({ + providerId: "claude-acp", + modelId: "sonnet", + modelName: "Sonnet", + }); + expect( + personaTargetMigration( + persona, + context([{ id: "sonnet", displayName: "Sonnet" }]), + ), + ).toEqual({ + provider: "claude-acp", + modelProviderId: "claude-acp", + model: "sonnet", + }); + }); + + it("returns no target for an unknown persisted harness", () => { + const persona = { + provider: "deleted-harness", + modelProviderId: "openai", + model: "gpt-5", + }; + + expect(personaExecutionTarget(persona, context())).toBeUndefined(); + expect(personaTargetMigration(persona, context())).toEqual({ + provider: null, + modelProviderId: null, + model: null, + }); + }); + it("uses an external harness as the runtime provider boundary", () => { expect( personaExecutionTarget( diff --git a/src/features/agents/lib/personaExecutionTarget.ts b/src/features/agents/lib/personaExecutionTarget.ts index 51e9499cf..9f283d463 100644 --- a/src/features/agents/lib/personaExecutionTarget.ts +++ b/src/features/agents/lib/personaExecutionTarget.ts @@ -92,19 +92,20 @@ function persistedModelProviderId( harnessId: string, catalogEntries: ProviderCatalogEntry[], ): string | undefined { + // A non-Goose harness is its own provider boundary. Its persisted model + // provider is display metadata from an older representation, never an + // independent provider that may be sent to Goose. + if (harnessId !== "goose") return harnessId; + const persistedProviderId = persona.modelProviderId?.trim(); - if (persistedProviderId) { - const persistedProviderIsAgent = isAgentProviderId( - persistedProviderId, - catalogEntries, - ); - if (!(harnessId === "goose" && persistedProviderIsAgent)) { - return canonicalModelProviderId(persistedProviderId, catalogEntries); - } + if ( + persistedProviderId && + !isAgentProviderId(persistedProviderId, catalogEntries) + ) { + return canonicalModelProviderId(persistedProviderId, catalogEntries); } if ( persona.provider?.trim() && - harnessId === "goose" && (INTERNAL_DATABRICKS_KEYS.has(normalizeProviderKey(persona.provider)) || resolveModelProviderCatalogIdStrictFromEntries( catalogEntries, @@ -113,7 +114,7 @@ function persistedModelProviderId( ) { return canonicalModelProviderId(persona.provider, catalogEntries); } - return harnessId === "goose" ? undefined : harnessId; + return undefined; } /** From 5399767f662d51855822d87e5292c52c4f1fb0a7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 19:55:47 -0400 Subject: [PATCH 21/50] fix(agents): clear unsupported persisted models Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/app/AppShell.tsx | 4 + src/app/lib/chatRuntimeStartup.ts | 1 + .../__tests__/personaExecutionTarget.test.ts | 107 ++++++++++++++++++ .../agents/lib/personaExecutionTarget.ts | 9 ++ .../chat/hooks/useChatSessionController.ts | 9 +- .../chat/hooks/useResolvedAgentModelPicker.ts | 1 + src/shared/ui/GlobalComposerPill.test.tsx | 19 ++-- src/shared/ui/GlobalComposerPill.tsx | 9 +- 8 files changed, 147 insertions(+), 12 deletions(-) diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 4606476c0..1fcad220f 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -2711,6 +2711,10 @@ export function AppShell({ const executionTarget = personaExecutionTarget(persona, { providers: agentState.providers, models: cachedModels, + isModelInventoryAuthoritative: (providerId) => + useProviderModelCacheStore + .getState() + .isModelInventoryAuthoritative(providerId), catalogEntries: getProviderCatalog(), }); diff --git a/src/app/lib/chatRuntimeStartup.ts b/src/app/lib/chatRuntimeStartup.ts index 72c74573b..525f0c55d 100644 --- a/src/app/lib/chatRuntimeStartup.ts +++ b/src/app/lib/chatRuntimeStartup.ts @@ -268,6 +268,7 @@ async function startChatRuntime( const targetContext = { providers: useAgentStore.getState().providers, models: cachedModels, + isModelInventoryAuthoritative: authoritativeProviderIds.has, catalogEntries: getProviderCatalog(), }; const personas = useAgentStore.getState().personas; diff --git a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts index b51e5332a..354b6ceb8 100644 --- a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts +++ b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts @@ -19,12 +19,15 @@ const catalog = (id: string, category: "agent" | "model", aliases?: string[]) => const context = ( models: Array<{ id: string; providerId?: string; displayName?: string }> = [], + authoritativeProviderIds: readonly string[] = [], ) => ({ providers: [ { id: "goose", label: "Goose" }, { id: "claude-acp", label: "Claude Code" }, ], models, + isModelInventoryAuthoritative: (providerId: string) => + authoritativeProviderIds.includes(providerId), catalogEntries: [ catalog("goose", "agent"), catalog("claude-acp", "agent", ["claude"]), @@ -81,6 +84,110 @@ describe("personaExecutionTarget", () => { }); }); + it.each([ + { + name: "Goose canonical provider with a supported model", + persona: { provider: "goose", modelProviderId: "openai", model: "gpt-5" }, + models: [{ id: "gpt-5", providerId: "openai" }], + authoritativeProviderIds: ["openai"], + target: { + harnessId: "goose", + modelProviderId: "openai", + modelId: "gpt-5", + modelName: "gpt-5", + }, + migration: null, + }, + { + name: "Goose canonical provider with an unsupported model", + persona: { provider: "goose", modelProviderId: "openai", model: "gpt-5" }, + models: [], + authoritativeProviderIds: ["openai"], + target: { harnessId: "goose", modelProviderId: "openai" }, + migration: { provider: "goose", modelProviderId: "openai", model: null }, + }, + { + name: "external harness with foreign provider and supported model", + persona: { + provider: "claude-acp", + modelProviderId: "openai", + model: "sonnet", + }, + models: [{ id: "sonnet", displayName: "Sonnet" }], + authoritativeProviderIds: ["claude-acp"], + target: { + harnessId: "claude-acp", + modelProviderId: "claude-acp", + modelId: "sonnet", + modelName: "Sonnet", + }, + migration: { + provider: "claude-acp", + modelProviderId: "claude-acp", + model: "sonnet", + }, + }, + { + name: "external harness with foreign provider and unsupported model", + persona: { + provider: "claude-acp", + modelProviderId: "openai", + model: "gpt-5", + }, + models: [], + authoritativeProviderIds: ["claude-acp"], + target: { harnessId: "claude-acp", modelProviderId: "claude-acp" }, + migration: { + provider: "claude-acp", + modelProviderId: "claude-acp", + model: null, + }, + }, + { + name: "external harness with unavailable inventory", + persona: { + provider: "claude-acp", + modelProviderId: "openai", + model: "gpt-5", + }, + models: [], + authoritativeProviderIds: [], + target: { + harnessId: "claude-acp", + modelProviderId: "claude-acp", + modelId: "gpt-5", + modelName: "gpt-5", + }, + migration: { + provider: "claude-acp", + modelProviderId: "claude-acp", + model: "gpt-5", + }, + }, + ])("canonicalizes $name across persisted target, migration, and wire selection", ({ + persona, + models, + authoritativeProviderIds, + target, + migration, + }) => { + const targetContext = context(models, authoritativeProviderIds); + + expect(personaExecutionTarget(persona, targetContext)).toEqual(target); + expect(personaTargetMigration(persona, targetContext)).toEqual(migration); + const wireProviderId = + target.harnessId === "goose" ? target.modelProviderId : target.harnessId; + expect( + gooseServeSelectionFromExecutionTarget( + personaExecutionTarget(persona, targetContext), + ), + ).toEqual({ + providerId: wireProviderId, + modelId: target.modelId, + modelName: target.modelName, + }); + }); + it("owns an external harness model provider and repairs legacy display metadata", () => { const persona = { provider: "claude-acp", diff --git a/src/features/agents/lib/personaExecutionTarget.ts b/src/features/agents/lib/personaExecutionTarget.ts index 9f283d463..cd1d17401 100644 --- a/src/features/agents/lib/personaExecutionTarget.ts +++ b/src/features/agents/lib/personaExecutionTarget.ts @@ -28,6 +28,8 @@ export interface PersonaTargetContext { providers: readonly AvailableHarness[]; models: readonly AvailableModel[]; getModelsForHarness?: (harnessId: string) => readonly AvailableModel[]; + /** Whether the model inventory for a provider/harness is authoritative. */ + isModelInventoryAuthoritative?: (providerId: string) => boolean; catalogEntries: ProviderCatalogEntry[]; } @@ -130,6 +132,7 @@ export function personaExecutionTarget( providers, models, getModelsForHarness, + isModelInventoryAuthoritative, catalogEntries, }: PersonaTargetContext, ): SessionExecutionTarget | undefined { @@ -171,6 +174,12 @@ export function personaExecutionTarget( canonicalModelProviderId(model.providerId, catalogEntries) === modelProviderId), ); + const inventoryIsAuthoritative = + isModelInventoryAuthoritative?.(modelProviderId ?? harnessId) ?? false; + + if (modelId && !matchingModel && inventoryIsAuthoritative) { + return normalizeSessionExecutionTarget({ harnessId, modelProviderId }); + } return normalizeSessionExecutionTarget({ harnessId, diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 3cbe97a42..7b6ab472a 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -1093,6 +1093,7 @@ export function useChatSessionController({ pickerAgents, availableModels, getModelsForAgent, + isModelInventoryAuthoritative, modelsLoading, modelStatusMessage, handleProviderChange, @@ -1208,9 +1209,15 @@ export function useChatSessionController({ providers, models: getModelsForAgent("goose"), getModelsForHarness: getModelsForAgent, + isModelInventoryAuthoritative, catalogEntries, }), - [catalogEntries, getModelsForAgent, providers], + [ + catalogEntries, + getModelsForAgent, + isModelInventoryAuthoritative, + providers, + ], ); const prepareSessionForCurrentSelection = useCallback( async ( diff --git a/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/src/features/chat/hooks/useResolvedAgentModelPicker.ts index d21d8a4b9..ddfb84d03 100644 --- a/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -817,6 +817,7 @@ export function useResolvedAgentModelPicker({ pickerAgents, availableModels, getModelsForAgent, + isModelInventoryAuthoritative, modelsLoading, modelStatusMessage, handleProviderChange, diff --git a/src/shared/ui/GlobalComposerPill.test.tsx b/src/shared/ui/GlobalComposerPill.test.tsx index 02e8d841b..c70faa7e1 100644 --- a/src/shared/ui/GlobalComposerPill.test.tsx +++ b/src/shared/ui/GlobalComposerPill.test.tsx @@ -883,12 +883,13 @@ describe("GlobalComposerPill", () => { await user.type(screen.getByRole("textbox"), "Hello"); await user.click(screen.getByRole("button", { name: /send message/i })); + // The mock inventory is authoritative and does not contain this legacy + // model, so the new provider remains selected but its unsupported model + // must not reach the execution target. expectSent(onSend, "Hello", { executionTarget: { harnessId: "goose", modelProviderId: "databricks_v2", - modelId: "goose-claude-opus-4-8", - modelName: "goose-claude-opus-4-8", }, personaId: "persona-1", }); @@ -1158,12 +1159,12 @@ describe("GlobalComposerPill", () => { await user.type(screen.getByRole("textbox"), "Hello"); await user.click(screen.getByRole("button", { name: /send message/i })); + // `goose-default` is absent from the authoritative mock inventory, so the + // selected harness/provider is retained but the model cannot be dispatched. expectSent(onSend, "Hello", { executionTarget: { harnessId: "goose", modelProviderId: "databricks_v2", - modelId: "goose-default", - modelName: "goose-default", }, personaId: "persona-2", }); @@ -1194,13 +1195,11 @@ describe("GlobalComposerPill", () => { await user.type(screen.getByRole("textbox"), "Hello"); await user.click(screen.getByRole("button", { name: /send message/i })); + // Clearing the persona retains the selected external harness. Its model is + // absent from the authoritative inventory, so no stale provider/model + // metadata may be dispatched. expectSent(onSend, "Hello", { - executionTarget: { - harnessId: "claude-acp", - modelProviderId: "claude-acp", - modelId: "claude-sonnet-4", - modelName: "claude-sonnet-4", - }, + executionTarget: { harnessId: "claude-acp" }, }); }); diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index 1c9795e82..1f4221630 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -503,9 +503,16 @@ export function GlobalComposerPill({ providers, models: getModelsForAgent("goose"), getModelsForHarness: getModelsForAgent, + isModelInventoryAuthoritative, catalogEntries, }), - [catalogEntries, getModelsForAgent, providers, selectedPersona], + [ + catalogEntries, + getModelsForAgent, + isModelInventoryAuthoritative, + providers, + selectedPersona, + ], ); useEffect(() => { From b0542fca66a77a776ae42c4287a9c4caf9d428cc Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 20:00:46 -0400 Subject: [PATCH 22/50] test(agents): cover startup target migration Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/app/lib/chatRuntimeStartup.test.ts | 58 ++++++++++++++++++++++++-- src/app/lib/chatRuntimeStartup.ts | 3 +- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/app/lib/chatRuntimeStartup.test.ts b/src/app/lib/chatRuntimeStartup.test.ts index 3bf339aff..3e8c24778 100644 --- a/src/app/lib/chatRuntimeStartup.test.ts +++ b/src/app/lib/chatRuntimeStartup.test.ts @@ -5,6 +5,15 @@ const mockLoadPersistedMessageQueues = vi.hoisted(() => ); const mockGetClient = vi.hoisted(() => vi.fn<() => Promise>()); const mockRefreshAllModelProviders = vi.hoisted(() => vi.fn()); +const mockMigratePersonaTargetIfUnchanged = vi.hoisted(() => vi.fn()); +const mockAgentState = vi.hoisted(() => ({ + personas: [] as Array>, + providers: [] as Array<{ id: string; label?: string }>, +})); +const mockModelCacheState = vi.hoisted(() => ({ + providers: new Map> }>(), + runtimeManagedProviderIds: new Set(), +})); // The latch under test wraps startChatRuntime, whose body touches most of the // startup module graph. Everything it reaches is stubbed inert (resolved, @@ -18,6 +27,7 @@ vi.mock("@/features/agents/stores/agentStore", () => ({ setProviders: () => {}, setPersonas: () => {}, setPersonasLoading: () => {}, + ...mockAgentState, }), }, })); @@ -52,8 +62,11 @@ vi.mock("@/features/providers/modelCacheRefresh", () => ({ })); vi.mock("@/features/providers/providerCatalog", () => ({ + canonicalProviderCatalogIdFromEntries: (_entries: unknown, id: string) => id, getModelProviders: () => [], getProviderCatalog: () => [], + resolveAgentProviderCatalogIdStrictFromEntries: () => null, + resolveModelProviderCatalogIdStrictFromEntries: () => null, })); vi.mock("@/features/providers/runtimeProviderConfig", () => ({ @@ -110,8 +123,7 @@ vi.mock("@/features/providers/stores/defaultProviderReadinessStore", () => ({ vi.mock("@/features/providers/stores/providerModelCacheStore", () => ({ useProviderModelCacheStore: { getState: () => ({ - providers: new Map(), - runtimeManagedProviderIds: new Set(), + ...mockModelCacheState, loadPersisted: () => {}, refreshAllModelProviders: (...args: unknown[]) => mockRefreshAllModelProviders(...args), @@ -166,8 +178,9 @@ vi.mock("@/shared/api/distro", () => ({ })); vi.mock("@/shared/api/agents", () => ({ - listPersonas: async () => [], - migratePersonaTargetIfUnchanged: async () => null, + listPersonas: async () => mockAgentState.personas, + migratePersonaTargetIfUnchanged: (...args: unknown[]) => + mockMigratePersonaTargetIfUnchanged(...args), })); function deferred() { @@ -190,6 +203,12 @@ describe("runChatRuntimeStartup", () => { mockGetClient.mockResolvedValue({}); mockRefreshAllModelProviders.mockReset(); mockRefreshAllModelProviders.mockResolvedValue(undefined); + mockMigratePersonaTargetIfUnchanged.mockReset(); + mockMigratePersonaTargetIfUnchanged.mockResolvedValue(null); + mockAgentState.personas = []; + mockAgentState.providers = []; + mockModelCacheState.providers = new Map(); + mockModelCacheState.runtimeManagedProviderIds = new Set(); }); it("collapses concurrent callers onto one startup run", async () => { @@ -221,6 +240,37 @@ describe("runChatRuntimeStartup", () => { inventoryRefresh.resolve(); }); + it("migrates an authoritative unsupported persona model without losing its harness", async () => { + mockAgentState.providers = [{ id: "claude-acp", label: "Claude Code" }]; + mockAgentState.personas = [ + { + id: "persona-1", + displayName: "Legacy Claude", + systemPrompt: "Help.", + provider: "claude-acp", + modelProviderId: "openai", + model: "gpt-5", + isBuiltin: false, + writable: true, + }, + ]; + mockModelCacheState.runtimeManagedProviderIds = new Set(["claude-acp"]); + mockModelCacheState.providers = new Map([["claude-acp", { models: [] }]]); + + const { runChatRuntimeStartup } = await import("./chatRuntimeStartup"); + await runChatRuntimeStartup(); + await vi.waitFor(() => { + expect(mockMigratePersonaTargetIfUnchanged).toHaveBeenCalledWith( + expect.objectContaining({ id: "persona-1" }), + { + provider: "claude-acp", + modelProviderId: "claude-acp", + model: null, + }, + ); + }); + }); + it("stays latched after a successful run", async () => { const { runChatRuntimeStartup } = await import("./chatRuntimeStartup"); const first = runChatRuntimeStartup(); diff --git a/src/app/lib/chatRuntimeStartup.ts b/src/app/lib/chatRuntimeStartup.ts index 525f0c55d..b93768980 100644 --- a/src/app/lib/chatRuntimeStartup.ts +++ b/src/app/lib/chatRuntimeStartup.ts @@ -268,7 +268,8 @@ async function startChatRuntime( const targetContext = { providers: useAgentStore.getState().providers, models: cachedModels, - isModelInventoryAuthoritative: authoritativeProviderIds.has, + isModelInventoryAuthoritative: (providerId: string) => + authoritativeProviderIds.has(providerId), catalogEntries: getProviderCatalog(), }; const personas = useAgentStore.getState().personas; From f69eb7639bbeca07b34995644473b705e22b7d3c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 20:22:53 -0400 Subject: [PATCH 23/50] fix(agents): separate model proof from display candidates Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/app/AppShell.tsx | 15 ++++++--- src/app/lib/chatRuntimeStartup.test.ts | 9 ++++-- src/app/lib/chatRuntimeStartup.ts | 10 +++--- .../__tests__/personaExecutionTarget.test.ts | 25 +++++++++++++++ .../agents/lib/personaExecutionTarget.ts | 16 ++++++++-- .../chat/hooks/useAgentModelPickerState.ts | 2 ++ .../chat/hooks/useChatSessionController.ts | 3 ++ .../chat/hooks/useResolvedAgentModelPicker.ts | 2 ++ src/features/chat/ui/AgentModelPicker.tsx | 22 +++++++------ .../providers/hooks/useProviderModels.ts | 19 ++++++++++++ .../stores/providerModelCacheStore.test.ts | 6 ++++ .../stores/providerModelCacheStore.ts | 19 ++++++++++-- src/shared/ui/GlobalComposerPill.test.tsx | 4 +++ src/shared/ui/GlobalComposerPill.tsx | 31 +++++++++++++++---- 14 files changed, 154 insertions(+), 29 deletions(-) diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 1fcad220f..662acc4c7 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -2700,10 +2700,16 @@ export function AppShell({ const persona = agentState.personas.find( (candidate) => candidate.id === agentId, ); - const cachedModels = [ - ...useProviderModelCacheStore.getState().providers, - ].flatMap(([providerId, entry]) => - entry.models.map((model) => ({ + const modelCache = useProviderModelCacheStore.getState(); + const cachedModels = [...modelCache.providers].flatMap( + ([providerId, entry]) => + entry.models.map((model) => ({ + ...model, + providerId: model.providerId ?? providerId, + })), + ); + const provenModels = [...modelCache.providers].flatMap(([providerId]) => + modelCache.getProvenModelsForProvider(providerId).map((model) => ({ ...model, providerId: model.providerId ?? providerId, })), @@ -2711,6 +2717,7 @@ export function AppShell({ const executionTarget = personaExecutionTarget(persona, { providers: agentState.providers, models: cachedModels, + getProvenModelsForHarness: () => provenModels, isModelInventoryAuthoritative: (providerId) => useProviderModelCacheStore .getState() diff --git a/src/app/lib/chatRuntimeStartup.test.ts b/src/app/lib/chatRuntimeStartup.test.ts index 3e8c24778..f3b325041 100644 --- a/src/app/lib/chatRuntimeStartup.test.ts +++ b/src/app/lib/chatRuntimeStartup.test.ts @@ -11,7 +11,10 @@ const mockAgentState = vi.hoisted(() => ({ providers: [] as Array<{ id: string; label?: string }>, })); const mockModelCacheState = vi.hoisted(() => ({ - providers: new Map> }>(), + providers: new Map< + string, + { models: Array>; provenModelIds?: string[] } + >(), runtimeManagedProviderIds: new Set(), })); @@ -255,7 +258,9 @@ describe("runChatRuntimeStartup", () => { }, ]; mockModelCacheState.runtimeManagedProviderIds = new Set(["claude-acp"]); - mockModelCacheState.providers = new Map([["claude-acp", { models: [] }]]); + mockModelCacheState.providers = new Map([ + ["claude-acp", { models: [], provenModelIds: [] }], + ]); const { runChatRuntimeStartup } = await import("./chatRuntimeStartup"); await runChatRuntimeStartup(); diff --git a/src/app/lib/chatRuntimeStartup.ts b/src/app/lib/chatRuntimeStartup.ts index b93768980..5b6575ca6 100644 --- a/src/app/lib/chatRuntimeStartup.ts +++ b/src/app/lib/chatRuntimeStartup.ts @@ -259,10 +259,12 @@ async function startChatRuntime( const cachedModels = [...modelState.providers].flatMap( ([providerId, entry]) => authoritativeProviderIds.has(providerId) - ? entry.models.map((model) => ({ - ...model, - providerId: model.providerId ?? providerId, - })) + ? entry.models + .filter((model) => entry.provenModelIds?.includes(model.id)) + .map((model) => ({ + ...model, + providerId: model.providerId ?? providerId, + })) : [], ); const targetContext = { diff --git a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts index 354b6ceb8..e873a9624 100644 --- a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts +++ b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts @@ -20,12 +20,14 @@ const catalog = (id: string, category: "agent" | "model", aliases?: string[]) => const context = ( models: Array<{ id: string; providerId?: string; displayName?: string }> = [], authoritativeProviderIds: readonly string[] = [], + provenModels = models, ) => ({ providers: [ { id: "goose", label: "Goose" }, { id: "claude-acp", label: "Claude Code" }, ], models, + getProvenModelsForHarness: () => provenModels, isModelInventoryAuthoritative: (providerId: string) => authoritativeProviderIds.includes(providerId), catalogEntries: [ @@ -56,6 +58,29 @@ describe("personaExecutionTarget", () => { }); }); + it("does not treat an advisory-only display candidate as live proof", () => { + const persona = { + provider: "goose", + modelProviderId: "openai", + model: "advisory-only", + }; + const targetContext = context( + [{ id: "advisory-only", providerId: "openai" }], + ["openai"], + [], + ); + + expect(personaExecutionTarget(persona, targetContext)).toEqual({ + harnessId: "goose", + modelProviderId: "openai", + }); + expect(personaTargetMigration(persona, targetContext)).toEqual({ + provider: "goose", + modelProviderId: "openai", + model: null, + }); + }); + it("rejects an agent harness persisted as a Goose model provider", () => { const persona = { provider: "goose", diff --git a/src/features/agents/lib/personaExecutionTarget.ts b/src/features/agents/lib/personaExecutionTarget.ts index cd1d17401..7e834d6f1 100644 --- a/src/features/agents/lib/personaExecutionTarget.ts +++ b/src/features/agents/lib/personaExecutionTarget.ts @@ -28,6 +28,8 @@ export interface PersonaTargetContext { providers: readonly AvailableHarness[]; models: readonly AvailableModel[]; getModelsForHarness?: (harnessId: string) => readonly AvailableModel[]; + /** Live inventory models, separate from display/advisory candidates. */ + getProvenModelsForHarness?: (harnessId: string) => readonly AvailableModel[]; /** Whether the model inventory for a provider/harness is authoritative. */ isModelInventoryAuthoritative?: (providerId: string) => boolean; catalogEntries: ProviderCatalogEntry[]; @@ -132,6 +134,7 @@ export function personaExecutionTarget( providers, models, getModelsForHarness, + getProvenModelsForHarness, isModelInventoryAuthoritative, catalogEntries, }: PersonaTargetContext, @@ -144,6 +147,8 @@ export function personaExecutionTarget( if (!harnessId) return undefined; const availableModels = getModelsForHarness?.(harnessId) ?? models; + const provenModels = + getProvenModelsForHarness?.(harnessId) ?? availableModels; const modelId = normalizeConcreteModelId(persona?.model); let modelProviderId = persistedModelProviderId( persona ?? {}, @@ -154,7 +159,7 @@ export function personaExecutionTarget( // Compatibility read until the migration write completes. if (modelId && !modelProviderId && harnessId === "goose") { const matches = new Set( - availableModels.flatMap((model) => + provenModels.flatMap((model) => model.id === modelId && model.providerId ? [canonicalModelProviderId(model.providerId, catalogEntries)] : [], @@ -174,10 +179,17 @@ export function personaExecutionTarget( canonicalModelProviderId(model.providerId, catalogEntries) === modelProviderId), ); + const provenModel = provenModels.find( + (model) => + model.id === modelId && + (!model.providerId || + canonicalModelProviderId(model.providerId, catalogEntries) === + modelProviderId), + ); const inventoryIsAuthoritative = isModelInventoryAuthoritative?.(modelProviderId ?? harnessId) ?? false; - if (modelId && !matchingModel && inventoryIsAuthoritative) { + if (modelId && !provenModel && inventoryIsAuthoritative) { return normalizeSessionExecutionTarget({ harnessId, modelProviderId }); } diff --git a/src/features/chat/hooks/useAgentModelPickerState.ts b/src/features/chat/hooks/useAgentModelPickerState.ts index 6293edcf1..f42c15e67 100644 --- a/src/features/chat/hooks/useAgentModelPickerState.ts +++ b/src/features/chat/hooks/useAgentModelPickerState.ts @@ -29,6 +29,7 @@ export function useAgentModelPickerState({ configuredModelProviderIds, modelCacheRefreshProviderIds, getModelsForAgent, + getProvenModelsForAgent, isModelInventoryAuthoritative: isProviderModelInventoryAuthoritative, refreshAllModelProviders, isRefreshingProvider, @@ -212,6 +213,7 @@ export function useAgentModelPickerState({ pickerAgents, availableModels, getModelsForAgent, + getProvenModelsForAgent, isModelInventoryAuthoritative, modelsLoading, modelStatusMessage, diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 7b6ab472a..85d54e357 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -1093,6 +1093,7 @@ export function useChatSessionController({ pickerAgents, availableModels, getModelsForAgent, + getProvenModelsForAgent, isModelInventoryAuthoritative, modelsLoading, modelStatusMessage, @@ -1209,12 +1210,14 @@ export function useChatSessionController({ providers, models: getModelsForAgent("goose"), getModelsForHarness: getModelsForAgent, + getProvenModelsForHarness: getProvenModelsForAgent, isModelInventoryAuthoritative, catalogEntries, }), [ catalogEntries, getModelsForAgent, + getProvenModelsForAgent, isModelInventoryAuthoritative, providers, ], diff --git a/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/src/features/chat/hooks/useResolvedAgentModelPicker.ts index ddfb84d03..5dc1791b1 100644 --- a/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -337,6 +337,7 @@ export function useResolvedAgentModelPicker({ pickerAgents, availableModels, getModelsForAgent, + getProvenModelsForAgent, isModelInventoryAuthoritative, modelsLoading, modelStatusMessage, @@ -817,6 +818,7 @@ export function useResolvedAgentModelPicker({ pickerAgents, availableModels, getModelsForAgent, + getProvenModelsForAgent, isModelInventoryAuthoritative, modelsLoading, modelStatusMessage, diff --git a/src/features/chat/ui/AgentModelPicker.tsx b/src/features/chat/ui/AgentModelPicker.tsx index f88687bd2..8ce856b01 100644 --- a/src/features/chat/ui/AgentModelPicker.tsx +++ b/src/features/chat/ui/AgentModelPicker.tsx @@ -55,6 +55,8 @@ interface AgentModelPickerProps { loading?: boolean; isCompact?: boolean; showSelectedModelInTrigger?: boolean; + /** A provider-only target must not synthesize a default model for display. */ + showDefaultModelInTrigger?: boolean; triggerTabIndex?: number; triggerIconOnly?: boolean; open?: boolean; @@ -175,6 +177,7 @@ export function AgentModelPicker({ loading = false, isCompact = false, showSelectedModelInTrigger = true, + showDefaultModelInTrigger = true, triggerTabIndex, triggerIconOnly = false, open: controlledOpen, @@ -275,15 +278,16 @@ export function AgentModelPicker({ displayModelLabel, selectedAgentId, ]); - const triggerLabel = showSelectedModelInTrigger - ? resolvePickerTriggerLabel({ - currentModelId, - currentModelName, - currentModelProviderId, - availableModels: displayedModels, - selectedAgentLabel, - }) - : selectedAgentLabel; + const triggerLabel = + showSelectedModelInTrigger && (currentModelId || showDefaultModelInTrigger) + ? resolvePickerTriggerLabel({ + currentModelId, + currentModelName, + currentModelProviderId, + availableModels: displayedModels, + selectedAgentLabel, + }) + : selectedAgentLabel; const triggerTitle = triggerLabel ?? (loading ? t("toolbar.loading") : undefined); const triggerButtonSize = triggerIconOnly ? "icon-pill-sm" : "sm"; diff --git a/src/features/providers/hooks/useProviderModels.ts b/src/features/providers/hooks/useProviderModels.ts index 869ccfe07..2de572b4f 100644 --- a/src/features/providers/hooks/useProviderModels.ts +++ b/src/features/providers/hooks/useProviderModels.ts @@ -82,6 +82,16 @@ export function useProviderModels() { [providers], ); + const getProvenModelsForProvider = useCallback( + (providerId: string) => { + const entry = providers.get(providerId); + if (!entry?.provenModelIds) return EMPTY_MODELS; + const provenIds = new Set(entry.provenModelIds); + return entry.models.filter((model) => provenIds.has(model.id)); + }, + [providers], + ); + const isModelInventoryAuthoritative = useCallback( (providerId: string) => isCachedModelInventoryAuthoritative(providers.get(providerId)), @@ -123,6 +133,14 @@ export function useProviderModels() { ], ); + const getProvenModelsForAgent = useCallback( + (agentId: string) => + agentId === "goose" + ? configuredModelProviderIds.flatMap(getProvenModelsForProvider) + : getProvenModelsForProvider(agentId), + [configuredModelProviderIds, getProvenModelsForProvider], + ); + const isRefreshingProvider = useCallback( (providerId: string) => refreshingProviderIds.has(providerId), [refreshingProviderIds], @@ -141,6 +159,7 @@ export function useProviderModels() { modelCacheRefreshProviderIds, getModelsForAgent, getModelsForProvider, + getProvenModelsForAgent, isModelInventoryAuthoritative, refreshProviderModels, refreshAllModelProviders, diff --git a/src/features/providers/stores/providerModelCacheStore.test.ts b/src/features/providers/stores/providerModelCacheStore.test.ts index f61cf3848..4521c5af3 100644 --- a/src/features/providers/stores/providerModelCacheStore.test.ts +++ b/src/features/providers/stores/providerModelCacheStore.test.ts @@ -206,6 +206,12 @@ describe("providerModelCacheStore", () => { expect(models.find((model) => model.id === "goose-gpt-5-6-sol")).toEqual( expect.objectContaining(configuredModel), ); + expect( + useProviderModelCacheStore + .getState() + .getProvenModelsForProvider("databricks_v2") + .map((model) => model.id), + ).toEqual(["goose-gpt-5-5"]); }); it("keeps configured models after a failed refresh and retry", async () => { diff --git a/src/features/providers/stores/providerModelCacheStore.ts b/src/features/providers/stores/providerModelCacheStore.ts index b69ab3361..b64838584 100644 --- a/src/features/providers/stores/providerModelCacheStore.ts +++ b/src/features/providers/stores/providerModelCacheStore.ts @@ -13,7 +13,10 @@ const providerRefreshVersions = new Map(); export interface CachedProviderModels { providerId: string; + /** Display candidates: live models plus configured recommendations. */ models: ModelOption[]; + /** IDs returned by a successful live inventory response; the only proof. */ + provenModelIds?: string[]; fetchedAt: number; runtimeManaged?: boolean; configuredModels?: ModelOption[]; @@ -33,6 +36,7 @@ interface ProviderModelCacheActions { options?: { fresh?: boolean; runtimeManagedProviderIds?: Set }, ) => void; getModelsForProvider: (providerId: string) => ModelOption[]; + getProvenModelsForProvider: (providerId: string) => ModelOption[]; isModelInventoryAuthoritative: (providerId: string) => boolean; getError: (providerId: string) => string | null; refreshProviderModels: ( @@ -124,7 +128,7 @@ async function fetchProviderSupportedModels( export function isCachedModelInventoryAuthoritative( entry: CachedProviderModels | undefined, ): boolean { - return entry != null && (entry.runtimeManaged || entry.fetchedAt > 0); + return entry != null && Array.isArray(entry.provenModelIds); } function isStale(entry: CachedProviderModels | undefined): boolean { @@ -173,7 +177,10 @@ export const useProviderModelCacheStore = create( models, fetchedAt: runtimeManaged || options.fresh ? Date.now() : 0, ...(runtimeManaged - ? { runtimeManaged } + ? { + runtimeManaged, + provenModelIds: models.map((model) => model.id), + } : { configuredModels: models }), }); if (runtimeManaged) { @@ -202,6 +209,13 @@ export const useProviderModelCacheStore = create( getModelsForProvider: (providerId) => get().providers.get(providerId)?.models ?? [], + getProvenModelsForProvider: (providerId) => { + const entry = get().providers.get(providerId); + if (!entry?.provenModelIds) return []; + const provenIds = new Set(entry.provenModelIds); + return entry.models.filter((model) => provenIds.has(model.id)); + }, + isModelInventoryAuthoritative: (providerId) => isCachedModelInventoryAuthoritative(get().providers.get(providerId)), @@ -283,6 +297,7 @@ export const useProviderModelCacheStore = create( providerId, models, fetchedAt: Date.now(), + provenModelIds: ids, ...(configuredModels.length > 0 ? { configuredModels } : {}), }; if (versionAtStart !== refreshVersion(providerId)) { diff --git a/src/shared/ui/GlobalComposerPill.test.tsx b/src/shared/ui/GlobalComposerPill.test.tsx index c70faa7e1..ddb32323d 100644 --- a/src/shared/ui/GlobalComposerPill.test.tsx +++ b/src/shared/ui/GlobalComposerPill.test.tsx @@ -89,6 +89,8 @@ vi.mock("@/features/providers/hooks/useProviderModels", () => ({ configuredModelProviderIds: ["openai", "anthropic"], modelCacheRefreshProviderIds: ["openai", "anthropic"], getModelsForAgent: (agentId: string) => mockGetModelsForAgent(agentId), + getProvenModelsForAgent: (agentId: string) => + mockGetModelsForAgent(agentId), isModelInventoryAuthoritative: () => mockProviderModelsState.inventoryAuthoritative, refreshAllModelProviders: (...args: unknown[]) => @@ -1161,6 +1163,8 @@ describe("GlobalComposerPill", () => { // `goose-default` is absent from the authoritative mock inventory, so the // selected harness/provider is retained but the model cannot be dispatched. + expect(screen.queryByText("GPT-5.5")).not.toBeInTheDocument(); + expect(screen.getByText("Goose")).toBeInTheDocument(); expectSent(onSend, "Hello", { executionTarget: { harnessId: "goose", diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index 1f4221630..dfe38469e 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -454,6 +454,7 @@ export function GlobalComposerPill({ pickerAgents, availableModels, getModelsForAgent, + getProvenModelsForAgent, isModelInventoryAuthoritative, modelsLoading, modelStatusMessage, @@ -503,12 +504,14 @@ export function GlobalComposerPill({ providers, models: getModelsForAgent("goose"), getModelsForHarness: getModelsForAgent, + getProvenModelsForHarness: getProvenModelsForAgent, isModelInventoryAuthoritative, catalogEntries, }), [ catalogEntries, getModelsForAgent, + getProvenModelsForAgent, isModelInventoryAuthoritative, providers, selectedPersona, @@ -667,11 +670,26 @@ export function GlobalComposerPill({ }, [currentExecutionTarget]); const hasLocalExecutionOverride = providerOverride !== null || modelOverride !== null; + const personaSelectionOverridden = + personaOverrideUserOverrideForRef.current === selectedPersonaId; + // A persona target is the configuration sent to the runtime. Materialize the + // picker from that exact target: a provider-only target deliberately has no + // model selection and must not borrow a default model for display. + const personaModelSelection = + !personaSelectionOverridden && personaTarget?.modelId + ? { + modelProviderId: personaTarget.modelProviderId, + modelId: personaTarget.modelId, + modelName: personaTarget.modelName, + } + : null; const effectiveModelSelection = - modelOverride ?? - (!hasLocalExecutionOverride && currentExecutionTarget !== undefined - ? controlledModelSelection - : defaultModelSelection); + !personaSelectionOverridden && personaTarget + ? personaModelSelection + : (modelOverride ?? + (!hasLocalExecutionOverride && currentExecutionTarget !== undefined + ? controlledModelSelection + : defaultModelSelection)); const localExecutionTarget = useMemo( () => hasLocalExecutionOverride || currentExecutionTarget === undefined @@ -689,8 +707,6 @@ export function GlobalComposerPill({ selectedProviderForPicker, ], ); - const personaSelectionOverridden = - personaOverrideUserOverrideForRef.current === selectedPersonaId; const effectiveExecutionTarget = !personaSelectionOverridden && personaTarget ? personaTarget @@ -1512,6 +1528,9 @@ export function GlobalComposerPill({ availableModels={availableModels} modelsLoading={modelsLoading} modelStatusMessage={modelStatusMessage} + showDefaultModelInTrigger={ + personaSelectionOverridden || !personaTarget + } onModelChange={handleModelChange} onOpen={handlePickerOpen} onOpenChange={setModelPickerOpen} From e95c0615230a40f657944b955d29cd3a7a6382f7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 20:36:56 -0400 Subject: [PATCH 24/50] fix(agents): keep runtime model seeds provisional Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/app/lib/chatRuntimeStartup.test.ts | 38 +++++- src/app/lib/chatRuntimeStartup.ts | 11 +- .../hooks/useProviderModels.test.tsx | 1 + src/features/providers/modelCacheRefresh.ts | 16 ++- .../providers/runtimeProviderConfig.test.ts | 19 ++- .../providers/runtimeProviderConfig.ts | 6 +- .../stores/providerModelCacheStore.test.ts | 114 +++++++++++++++--- .../stores/providerModelCacheStore.ts | 99 ++++++++------- 8 files changed, 224 insertions(+), 80 deletions(-) diff --git a/src/app/lib/chatRuntimeStartup.test.ts b/src/app/lib/chatRuntimeStartup.test.ts index f3b325041..c625ac5e4 100644 --- a/src/app/lib/chatRuntimeStartup.test.ts +++ b/src/app/lib/chatRuntimeStartup.test.ts @@ -61,7 +61,7 @@ vi.mock("@/features/providers/runtimeProviderConstraints", () => ({ })); vi.mock("@/features/providers/modelCacheRefresh", () => ({ - getModelCacheRefreshProviderIds: () => [], + getModelCacheRefreshProviderIds: () => ["claude-acp"], })); vi.mock("@/features/providers/providerCatalog", () => ({ @@ -243,6 +243,36 @@ describe("runChatRuntimeStartup", () => { inventoryRefresh.resolve(); }); + it("does not migrate a runtime-managed configuration seed before live discovery", async () => { + mockAgentState.providers = [{ id: "claude-acp", label: "Claude Code" }]; + mockAgentState.personas = [ + { + id: "persona-1", + displayName: "Configured Claude", + systemPrompt: "Help.", + provider: "claude-acp", + modelProviderId: "claude-acp", + model: "configured-model", + isBuiltin: false, + writable: true, + }, + ]; + mockModelCacheState.runtimeManagedProviderIds = new Set(["claude-acp"]); + mockModelCacheState.providers = new Map([ + [ + "claude-acp", + { + models: [{ id: "configured-model", providerId: "claude-acp" }], + }, + ], + ]); + + const { runChatRuntimeStartup } = await import("./chatRuntimeStartup"); + await runChatRuntimeStartup(); + + expect(mockMigratePersonaTargetIfUnchanged).not.toHaveBeenCalled(); + }); + it("migrates an authoritative unsupported persona model without losing its harness", async () => { mockAgentState.providers = [{ id: "claude-acp", label: "Claude Code" }]; mockAgentState.personas = [ @@ -257,10 +287,14 @@ describe("runChatRuntimeStartup", () => { writable: true, }, ]; - mockModelCacheState.runtimeManagedProviderIds = new Set(["claude-acp"]); mockModelCacheState.providers = new Map([ ["claude-acp", { models: [], provenModelIds: [] }], ]); + mockRefreshAllModelProviders.mockImplementation(async () => { + mockModelCacheState.providers = new Map([ + ["claude-acp", { models: [], provenModelIds: [] }], + ]); + }); const { runChatRuntimeStartup } = await import("./chatRuntimeStartup"); await runChatRuntimeStartup(); diff --git a/src/app/lib/chatRuntimeStartup.ts b/src/app/lib/chatRuntimeStartup.ts index 5b6575ca6..f42469b18 100644 --- a/src/app/lib/chatRuntimeStartup.ts +++ b/src/app/lib/chatRuntimeStartup.ts @@ -315,13 +315,14 @@ async function startChatRuntime( ); await modelCacheStore.refreshAllModelProviders(refreshProviderIds); const modelState = useProviderModelCacheStore.getState(); - return new Set([ - ...modelState.runtimeManagedProviderIds, - ...refreshProviderIds.filter((providerId) => { + return new Set( + refreshProviderIds.filter((providerId) => { const entry = modelState.providers.get(providerId); - return entry != null && !entry.error; + return ( + entry != null && !entry.error && entry.provenModelIds !== undefined + ); }), - ]); + ); }; const loadSessionState = async () => { diff --git a/src/features/providers/hooks/useProviderModels.test.tsx b/src/features/providers/hooks/useProviderModels.test.tsx index f0a1da316..498a94cd1 100644 --- a/src/features/providers/hooks/useProviderModels.test.tsx +++ b/src/features/providers/hooks/useProviderModels.test.tsx @@ -104,6 +104,7 @@ describe("useProviderModels", () => { { providerId: "databricks_v2", models, + provenModelIds: models.map((model) => model.id), fetchedAt: Date.now(), }, ], diff --git a/src/features/providers/modelCacheRefresh.ts b/src/features/providers/modelCacheRefresh.ts index fa99a093c..8777240fc 100644 --- a/src/features/providers/modelCacheRefresh.ts +++ b/src/features/providers/modelCacheRefresh.ts @@ -11,7 +11,10 @@ import { getModelProviders, getModelProvidersFromEntries, } from "./providerCatalog"; -import { runtimeRefreshableModelProviderIds } from "./runtimeProviderConfig"; +import { + runtimeManagedModelProviderIds, + runtimeRefreshableModelProviderIds, +} from "./runtimeProviderConfig"; export function getModelCacheRefreshProviderIds( runtimeConfig: RuntimeConfig | null | undefined, @@ -33,10 +36,13 @@ export function getModelCacheRefreshProviderIds( ? new Set(configuredProviderIds) : null; - for (const providerId of runtimeRefreshableModelProviderIds( - runtimeConfig, - defaultModelInventoryMode, - )) { + for (const providerId of [ + ...runtimeManagedModelProviderIds(runtimeConfig, defaultModelInventoryMode), + ...runtimeRefreshableModelProviderIds( + runtimeConfig, + defaultModelInventoryMode, + ), + ]) { ids.add(providerId); } diff --git a/src/features/providers/runtimeProviderConfig.test.ts b/src/features/providers/runtimeProviderConfig.test.ts index 1a8e6dce9..c6f2b4bc7 100644 --- a/src/features/providers/runtimeProviderConfig.test.ts +++ b/src/features/providers/runtimeProviderConfig.test.ts @@ -436,10 +436,21 @@ describe("getModelCacheRefreshProviderIds", () => { ]); }); - it("excludes runtime-managed model providers from startup refresh", () => { - expect(getModelCacheRefreshProviderIds(DEFAULT_RUNTIME_CONFIG)).toEqual([ - "codex-acp", - ]); + it("includes runtime-managed model providers so live discovery can establish proof", () => { + expect( + getModelCacheRefreshProviderIds({ + ...MANAGED_RUNTIME_CONFIG, + goose: { + ...MANAGED_RUNTIME_CONFIG.goose, + modelProviders: [ + { + ...MANAGED_RUNTIME_CONFIG.goose.modelProviders[0], + modelInventoryMode: "authoritative", + }, + ], + }, + }), + ).toEqual(["databricks_v2", "codex-acp"]); }); it("includes model providers for bundled appDefault refresh", () => { diff --git a/src/features/providers/runtimeProviderConfig.ts b/src/features/providers/runtimeProviderConfig.ts index 55f2beced..06adb5d4c 100644 --- a/src/features/providers/runtimeProviderConfig.ts +++ b/src/features/providers/runtimeProviderConfig.ts @@ -164,16 +164,16 @@ function modelInventoryMode( } export function runtimeManagedModelProviderIds( - runtimeConfig: RuntimeConfig, + runtimeConfig: RuntimeConfig | null | undefined, defaultMode: RuntimeModelInventoryMode = DEFAULT_MODEL_INVENTORY_MODE, ): Set { return new Set( - runtimeConfig.goose.modelProviders + runtimeConfig?.goose.modelProviders .filter( (provider) => modelInventoryMode(provider, defaultMode) === "authoritative", ) - .map((provider) => provider.id), + .map((provider) => provider.id) ?? [], ); } diff --git a/src/features/providers/stores/providerModelCacheStore.test.ts b/src/features/providers/stores/providerModelCacheStore.test.ts index 4521c5af3..86f5e5cf1 100644 --- a/src/features/providers/stores/providerModelCacheStore.test.ts +++ b/src/features/providers/stores/providerModelCacheStore.test.ts @@ -41,49 +41,73 @@ describe("providerModelCacheStore", () => { }); }); - it("seeds runtime models as authoritative runtime-managed entries", async () => { + it("keeps runtime-managed configuration seeds advisory until live discovery succeeds", async () => { const model = seededModel({ contextLimit: 128000, recommended: true, featured: true, sortOrder: 0, }); - useProviderModelCacheStore .getState() .seedRuntimeModels(new Map([["databricks_v2", [model]]])); + + expect( + useProviderModelCacheStore + .getState() + .isModelInventoryAuthoritative("databricks_v2"), + ).toBe(false); + expect( + useProviderModelCacheStore + .getState() + .getProvenModelsForProvider("databricks_v2"), + ).toEqual([]); + + mocks.supportedModelsList.mockResolvedValueOnce({ models: [] }); await useProviderModelCacheStore .getState() - .refreshAllModelProviders(["databricks_v2"]); - await useProviderModelCacheStore - .getState() - .refreshProviderModels("databricks_v2", { force: true }); + .refreshProviderModels("databricks_v2"); const entry = useProviderModelCacheStore .getState() .providers.get("databricks_v2"); expect(entry?.runtimeManaged).toBe(true); + expect(entry?.provenModelIds).toEqual([]); expect( useProviderModelCacheStore .getState() .getModelsForProvider("databricks_v2"), ).toEqual([model]); - expect(mocks.supportedModelsList).not.toHaveBeenCalled(); - }); - - it("preserves runtime-managed models after invalidation and forced refresh", async () => { - const model = seededModel({ - contextLimit: 128000, - recommended: true, - featured: true, - sortOrder: 0, + expect(mocks.supportedModelsList).toHaveBeenCalledWith({ + providerId: "databricks_v2", }); + }); + it("invalidates runtime-managed proof without discarding its display seed", async () => { + const model = seededModel({ recommended: true, featured: true }); useProviderModelCacheStore .getState() .seedRuntimeModels(new Map([["databricks_v2", [model]]])); + mocks.supportedModelsList + .mockResolvedValueOnce({ models: ["goose-gpt-5-5"] }) + .mockResolvedValueOnce({ models: ["goose-gpt-5-5"] }); + + await useProviderModelCacheStore + .getState() + .refreshProviderModels("databricks_v2"); useProviderModelCacheStore.getState().invalidateProvider("databricks_v2"); + const invalidatedEntry = useProviderModelCacheStore + .getState() + .providers.get("databricks_v2"); + expect(invalidatedEntry?.provenModelIds).toBeUndefined(); + expect(invalidatedEntry?.models).toEqual([model]); + expect( + useProviderModelCacheStore + .getState() + .isModelInventoryAuthoritative("databricks_v2"), + ).toBe(false); + await useProviderModelCacheStore .getState() .refreshProviderModels("databricks_v2", { force: true }); @@ -92,12 +116,16 @@ describe("providerModelCacheStore", () => { .getState() .providers.get("databricks_v2"); expect(entry?.runtimeManaged).toBe(true); + expect(entry?.provenModelIds).toEqual(["goose-gpt-5-5"]); expect( useProviderModelCacheStore .getState() - .getModelsForProvider("databricks_v2"), - ).toEqual([model]); - expect(mocks.supportedModelsList).not.toHaveBeenCalled(); + .getModelsForProvider("databricks_v2") + .map((candidate) => candidate.id), + ).toEqual(["goose-gpt-5-5", "seeded-model"]); + expect(mocks.supportedModelsList).toHaveBeenLastCalledWith({ + providerId: "databricks_v2", + }); }); it("keeps refreshable runtime models provisional until discovery succeeds", async () => { @@ -277,6 +305,56 @@ describe("providerModelCacheStore", () => { ).toBe(true); }); + it("keeps configuration-only runtime seeds provisional across restart", () => { + const model = seededModel({ recommended: true, featured: true }); + useProviderModelCacheStore + .getState() + .seedRuntimeModels(new Map([["databricks_v2", [model]]])); + + useProviderModelCacheStore.setState({ + providers: new Map(), + runtimeManagedProviderIds: new Set(), + }); + useProviderModelCacheStore.getState().loadPersisted(); + + const entry = useProviderModelCacheStore + .getState() + .providers.get("databricks_v2"); + expect(entry?.provenModelIds).toBeUndefined(); + expect( + useProviderModelCacheStore + .getState() + .isModelInventoryAuthoritative("databricks_v2"), + ).toBe(false); + }); + + it("persists authority only after a successful live response", async () => { + const model = seededModel({ recommended: true, featured: true }); + useProviderModelCacheStore + .getState() + .seedRuntimeModels(new Map([["databricks_v2", [model]]])); + mocks.supportedModelsList.mockResolvedValueOnce({ models: [] }); + + await useProviderModelCacheStore + .getState() + .refreshProviderModels("databricks_v2"); + useProviderModelCacheStore.setState({ + providers: new Map(), + runtimeManagedProviderIds: new Set(), + }); + useProviderModelCacheStore.getState().loadPersisted(); + + const entry = useProviderModelCacheStore + .getState() + .providers.get("databricks_v2"); + expect(entry?.provenModelIds).toEqual([]); + expect( + useProviderModelCacheStore + .getState() + .isModelInventoryAuthoritative("databricks_v2"), + ).toBe(true); + }); + it("runs a forced refresh after an in-flight refresh finishes", async () => { let rejectInitialRefresh!: (error: Error) => void; const initialRefresh = new Promise<{ models: string[] }>( diff --git a/src/features/providers/stores/providerModelCacheStore.ts b/src/features/providers/stores/providerModelCacheStore.ts index b64838584..f1179e857 100644 --- a/src/features/providers/stores/providerModelCacheStore.ts +++ b/src/features/providers/stores/providerModelCacheStore.ts @@ -18,6 +18,7 @@ export interface CachedProviderModels { /** IDs returned by a successful live inventory response; the only proof. */ provenModelIds?: string[]; fetchedAt: number; + /** Runtime configuration policy; it does not establish model proof. */ runtimeManaged?: boolean; configuredModels?: ModelOption[]; error?: string; @@ -125,6 +126,35 @@ async function fetchProviderSupportedModels( return response.models; } +function mergeDisplayModels( + discoveredModels: ModelOption[], + configuredModels: ModelOption[], +): ModelOption[] { + const configuredModelsById = new Map( + configuredModels.map((model) => [model.id, model]), + ); + const hasConfiguredFeaturedModel = configuredModels.some( + (model) => model.featured, + ); + const discoveredModelIds = new Set(discoveredModels.map((model) => model.id)); + return [ + ...discoveredModels.map((model) => ({ + ...model, + ...(hasConfiguredFeaturedModel ? { featured: false } : {}), + ...configuredModelsById.get(model.id), + })), + ...configuredModels.filter((model) => !discoveredModelIds.has(model.id)), + ]; +} + +function getProvenModels( + entry: CachedProviderModels | undefined, +): ModelOption[] { + if (!entry?.provenModelIds) return []; + const provenIds = new Set(entry.provenModelIds); + return entry.models.filter((model) => provenIds.has(model.id)); +} + export function isCachedModelInventoryAuthoritative( entry: CachedProviderModels | undefined, ): boolean { @@ -135,9 +165,7 @@ function isStale(entry: CachedProviderModels | undefined): boolean { if (!entry || !isCachedModelInventoryAuthoritative(entry)) { return true; } - return ( - !entry.runtimeManaged && Date.now() - entry.fetchedAt > MODEL_CACHE_TTL_MS - ); + return Date.now() - entry.fetchedAt > MODEL_CACHE_TTL_MS; } function refreshVersion(providerId: string): number { @@ -170,18 +198,22 @@ export const useProviderModelCacheStore = create( for (const providerId of runtimeProviderIds) { bumpRefreshVersion(providerId); - const models = modelsByProviderId.get(providerId) ?? []; + const configuredModels = modelsByProviderId.get(providerId) ?? []; const runtimeManaged = runtimeManagedProviderIds.has(providerId); + const existing = providers.get(providerId); + const provenModels = getProvenModels(existing); + const provenModelIds = existing?.provenModelIds; + const hasLiveProof = Array.isArray(provenModelIds); providers.set(providerId, { providerId, - models, - fetchedAt: runtimeManaged || options.fresh ? Date.now() : 0, - ...(runtimeManaged - ? { - runtimeManaged, - provenModelIds: models.map((model) => model.id), - } - : { configuredModels: models }), + // Every runtime seed is advisory, including providers whose + // connection policy is runtime-managed. A prior successful live + // response stays proof, but the seed can neither create nor renew it. + models: mergeDisplayModels(provenModels, configuredModels), + fetchedAt: existing?.fetchedAt ?? 0, + configuredModels, + ...(hasLiveProof ? { provenModelIds } : {}), + ...(runtimeManaged ? { runtimeManaged } : {}), }); if (runtimeManaged) { nextRuntimeManagedProviderIds.add(providerId); @@ -224,12 +256,6 @@ export const useProviderModelCacheStore = create( refreshProviderModels: async (providerId, options = {}) => { const current = get(); const existing = current.providers.get(providerId); - if ( - existing?.runtimeManaged || - current.runtimeManagedProviderIds.has(providerId) - ) { - return; - } if (!options.force && !isStale(existing)) { return; } @@ -274,30 +300,13 @@ export const useProviderModelCacheStore = create( const ids = await fetchProviderSupportedModels(providerId); const discoveredModels = providerModelOptionsFromIds(providerId, ids); const configuredModels = existing?.configuredModels ?? []; - const configuredModelsById = new Map( - configuredModels.map((model) => [model.id, model]), - ); - const hasConfiguredFeaturedModel = configuredModels.some( - (model) => model.featured, - ); - const discoveredModelIds = new Set( - discoveredModels.map((model) => model.id), - ); - const models = [ - ...discoveredModels.map((model) => ({ - ...model, - ...(hasConfiguredFeaturedModel ? { featured: false } : {}), - ...configuredModelsById.get(model.id), - })), - ...configuredModels.filter( - (model) => !discoveredModelIds.has(model.id), - ), - ]; + const models = mergeDisplayModels(discoveredModels, configuredModels); const entry: CachedProviderModels = { providerId, models, fetchedAt: Date.now(), provenModelIds: ids, + ...(existing?.runtimeManaged ? { runtimeManaged: true } : {}), ...(configuredModels.length > 0 ? { configuredModels } : {}), }; if (versionAtStart !== refreshVersion(providerId)) { @@ -356,13 +365,17 @@ export const useProviderModelCacheStore = create( invalidateProvider: (providerId) => { bumpRefreshVersion(providerId); set((state) => { - if (state.runtimeManagedProviderIds.has(providerId)) { - const existing = state.providers.get(providerId); - if (!existing || existing.runtimeManaged) { - return {}; - } + const existing = state.providers.get(providerId); + if (state.runtimeManagedProviderIds.has(providerId) && existing) { const providers = new Map(state.providers); - providers.set(providerId, { ...existing, runtimeManaged: true }); + // Keep the configured display seed but remove proof: an invalidation + // means the old live response can no longer justify compatibility. + providers.set(providerId, { + ...existing, + models: existing.configuredModels ?? existing.models, + fetchedAt: 0, + provenModelIds: undefined, + }); persistModels(providers); return { providers }; } From ce93ec4f1ccec6173b5ac22b282a5967a9bf83e6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 20:50:32 -0400 Subject: [PATCH 25/50] test(providers): model inventory proof explicitly Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/features/providers/hooks/useNewSessionTarget.test.tsx | 1 + .../providers/lib/resolveSessionModelPreference.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/providers/hooks/useNewSessionTarget.test.tsx b/src/features/providers/hooks/useNewSessionTarget.test.tsx index 72db5f68a..68fb7d525 100644 --- a/src/features/providers/hooks/useNewSessionTarget.test.tsx +++ b/src/features/providers/hooks/useNewSessionTarget.test.tsx @@ -60,6 +60,7 @@ describe("useNewSessionTarget", () => { providerId: "anthropic", }, ], + provenModelIds: ["claude-sonnet-4"], fetchedAt: Date.now(), }, ], diff --git a/src/features/providers/lib/resolveSessionModelPreference.test.ts b/src/features/providers/lib/resolveSessionModelPreference.test.ts index cff241f2c..60fa67dbe 100644 --- a/src/features/providers/lib/resolveSessionModelPreference.test.ts +++ b/src/features/providers/lib/resolveSessionModelPreference.test.ts @@ -13,8 +13,9 @@ const mockCheckAllProviderStatus = vi.mocked(checkAllProviderStatus); function setCachedModels( providerId: string, models: string[], - fetchedAt = Date.now(), + options: { fetchedAt?: number; proven?: boolean } = {}, ) { + const { fetchedAt = Date.now(), proven = true } = options; useProviderModelCacheStore.setState({ providers: new Map([ [ @@ -22,6 +23,7 @@ function setCachedModels( { providerId, models: models.map((id) => ({ id, name: id, providerId })), + ...(proven ? { provenModelIds: models } : {}), fetchedAt, }, ], @@ -227,7 +229,7 @@ describe("resolveSupportedSessionModelPreference", () => { }); it("preserves a selected model while a populated cache is provisional", async () => { - setCachedModels("openai", ["gpt-5.3"], 0); + setCachedModels("openai", ["gpt-5.3"], { proven: false }); await expect( resolveSupportedSessionModelPreference("openai", "gpt-5.4"), From 4f54a7b5adedae84e258edd94d689a40b1f46c5a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 21:01:06 -0400 Subject: [PATCH 26/50] fix(providers): distinguish empty model proof Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../chat/lib/sessionModelPreference.test.ts | 13 +++ .../chat/lib/sessionModelPreference.ts | 4 - .../hooks/useNewSessionTarget.test.tsx | 64 +++++++++++++ .../lib/resolveSessionModelPreference.test.ts | 92 ++++++++++++------- .../lib/resolveSessionModelPreference.ts | 25 +++-- 5 files changed, 146 insertions(+), 52 deletions(-) diff --git a/src/features/chat/lib/sessionModelPreference.test.ts b/src/features/chat/lib/sessionModelPreference.test.ts index db4b3d4e6..d561dc3e0 100644 --- a/src/features/chat/lib/sessionModelPreference.test.ts +++ b/src/features/chat/lib/sessionModelPreference.test.ts @@ -95,6 +95,19 @@ describe("resolveSessionModelPreference", () => { }); }); + it("drops a stored model when an authoritative inventory is empty", () => { + expect( + sanitizeSessionModelPreference( + { + providerId: "openai", + modelId: "gpt-5.4", + modelName: "GPT-5.4", + }, + { models: [] }, + ), + ).toEqual({ providerId: "openai" }); + }); + it("drops a stored model when the provider model list no longer contains it", () => { expect( sanitizeSessionModelPreference( diff --git a/src/features/chat/lib/sessionModelPreference.ts b/src/features/chat/lib/sessionModelPreference.ts index 80ab31deb..d0c65a980 100644 --- a/src/features/chat/lib/sessionModelPreference.ts +++ b/src/features/chat/lib/sessionModelPreference.ts @@ -67,10 +67,6 @@ export function sanitizeSessionModelPreference( return preference; } - if (providerModels.models.length === 0) { - return preference; - } - if (providerModels.models.some((model) => model.id === preference.modelId)) { return preference; } diff --git a/src/features/providers/hooks/useNewSessionTarget.test.tsx b/src/features/providers/hooks/useNewSessionTarget.test.tsx index 68fb7d525..eaa149365 100644 --- a/src/features/providers/hooks/useNewSessionTarget.test.tsx +++ b/src/features/providers/hooks/useNewSessionTarget.test.tsx @@ -96,6 +96,70 @@ describe("useNewSessionTarget", () => { }); }); + it.each([ + { + name: "proof is absent", + provenModelIds: undefined, + expectedModelId: "removed-model", + }, + { + name: "live proof is empty", + provenModelIds: [], + expectedModelId: "goose-gpt-5-5", + }, + { + name: "live proof omits the stored model", + provenModelIds: ["claude-sonnet-4"], + expectedModelId: "goose-gpt-5-5", + }, + ])("uses $name when resolving a stored model for a new chat", async ({ + provenModelIds, + expectedModelId, + }) => { + useProviderModelCacheStore.setState({ + providers: new Map([ + [ + "anthropic", + { + providerId: "anthropic", + models: [ + { + id: "claude-sonnet-4", + name: "Claude Sonnet 4", + providerId: "anthropic", + }, + ], + ...(provenModelIds !== undefined ? { provenModelIds } : {}), + fetchedAt: Date.now(), + }, + ], + ]), + refreshingProviderIds: new Set(), + }); + window.localStorage.setItem( + "goose:preferredModelsByAgent", + JSON.stringify({ + goose: { + modelId: "removed-model", + modelName: "Removed model", + providerId: "anthropic", + }, + }), + ); + + const { result } = renderHook(() => useNewSessionTarget()); + let target: Awaited> | undefined; + await act(async () => { + target = await result.current(); + }); + + expect(target).toMatchObject({ + status: "ready", + providerId: "goose", + modelId: expectedModelId, + }); + }); + it("drops an unavailable stored model before resolving the new chat", async () => { window.localStorage.setItem( "goose:preferredModelsByAgent", diff --git a/src/features/providers/lib/resolveSessionModelPreference.test.ts b/src/features/providers/lib/resolveSessionModelPreference.test.ts index 60fa67dbe..0ff3f1ee5 100644 --- a/src/features/providers/lib/resolveSessionModelPreference.test.ts +++ b/src/features/providers/lib/resolveSessionModelPreference.test.ts @@ -206,50 +206,72 @@ describe("resolveSupportedSessionModelPreference", () => { }); }); - it("preserves the selected model when the model cache has no model list", async () => { - setCachedModels("openai", []); - - await expect( - resolveSupportedSessionModelPreference("openai", "gpt-5.4"), - ).resolves.toEqual({ - providerId: "openai", - modelId: "gpt-5.4", - modelName: "gpt-5.4", - }); - }); - - it("drops an unsupported model when populated model cache is available", async () => { - setCachedModels("openai", ["gpt-5.3"]); + it.each([ + { + name: "proof is absent", + models: ["gpt-5.3"], + options: { proven: false }, + expected: { + providerId: "openai", + modelId: "gpt-5.4", + modelName: "gpt-5.4", + }, + }, + { + name: "live proof is empty", + models: [], + options: {}, + expected: { providerId: "openai" }, + }, + { + name: "live proof contains the preferred model", + models: ["gpt-5.4"], + options: {}, + expected: { + providerId: "openai", + modelId: "gpt-5.4", + modelName: "gpt-5.4", + }, + }, + { + name: "live proof omits the preferred model", + models: ["gpt-5.3"], + options: {}, + expected: { providerId: "openai" }, + }, + ])("uses only live proof when $name", async ({ + models, + options, + expected, + }) => { + setCachedModels("openai", models, options); await expect( resolveSupportedSessionModelPreference("openai", "gpt-5.4"), - ).resolves.toEqual({ - providerId: "openai", - }); + ).resolves.toEqual(expected); }); - it("preserves a selected model while a populated cache is provisional", async () => { - setCachedModels("openai", ["gpt-5.3"], { proven: false }); - - await expect( - resolveSupportedSessionModelPreference("openai", "gpt-5.4"), - ).resolves.toEqual({ - providerId: "openai", - modelId: "gpt-5.4", - modelName: "gpt-5.4", + it("rejects an advisory display candidate absent from live proof", async () => { + useProviderModelCacheStore.setState({ + providers: new Map([ + [ + "openai", + { + providerId: "openai", + models: [ + { id: "gpt-5.3", name: "gpt-5.3", providerId: "openai" }, + { id: "gpt-5.4", name: "gpt-5.4", providerId: "openai" }, + ], + provenModelIds: ["gpt-5.3"], + fetchedAt: Date.now(), + }, + ], + ]), }); - }); - - it("keeps a supported model when populated model cache is available", async () => { - setCachedModels("openai", ["gpt-5.4"]); await expect( resolveSupportedSessionModelPreference("openai", "gpt-5.4"), - ).resolves.toEqual({ - providerId: "openai", - modelId: "gpt-5.4", - modelName: "gpt-5.4", - }); + ).resolves.toEqual({ providerId: "openai" }); }); it("drops a stored model whose provider is disconnected", async () => { diff --git a/src/features/providers/lib/resolveSessionModelPreference.ts b/src/features/providers/lib/resolveSessionModelPreference.ts index cd6193511..b07cb7bbf 100644 --- a/src/features/providers/lib/resolveSessionModelPreference.ts +++ b/src/features/providers/lib/resolveSessionModelPreference.ts @@ -121,14 +121,12 @@ export async function resolveSupportedSessionModelPreference( } const modelCache = useProviderModelCacheStore.getState(); - const models = modelCache.getModelsForProvider( - sessionModelPreference.providerId, - ); + const modelProviderId = sessionModelPreference.providerId; - if ( - modelCache.isModelInventoryAuthoritative(sessionModelPreference.providerId) - ) { - return sanitizeSessionModelPreference(sessionModelPreference, { models }); + if (modelCache.isModelInventoryAuthoritative(modelProviderId)) { + return sanitizeSessionModelPreference(sessionModelPreference, { + models: modelCache.getProvenModelsForProvider(modelProviderId), + }); } if (!(await isProviderDisconnected(sessionModelPreference.providerId))) { @@ -138,12 +136,13 @@ export async function resolveSupportedSessionModelPreference( if (providerId === "goose") { const fallback = gooseDefaultPreference(); if (fallback && fallback.providerId !== sessionModelPreference.providerId) { - const fallbackModels = useProviderModelCacheStore - .getState() - .getModelsForProvider(fallback.providerId); - return sanitizeSessionModelPreference(fallback, { - models: fallbackModels, - }); + const fallbackCache = useProviderModelCacheStore.getState(); + if (fallbackCache.isModelInventoryAuthoritative(fallback.providerId)) { + return sanitizeSessionModelPreference(fallback, { + models: fallbackCache.getProvenModelsForProvider(fallback.providerId), + }); + } + return fallback; } } From d36af8d3e95fc641d71b2a19726bb7af3ac4d2a1 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 11 Aug 2026 08:37:08 -0400 Subject: [PATCH 27/50] fix(providers): enforce proven model inventory Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../useResolvedAgentModelPicker.test.ts | 27 +++++++++++ .../chat/hooks/useResolvedAgentModelPicker.ts | 39 ++++++++++++++-- .../providers/defaultProviderConfig.test.ts | 25 +++++++++++ .../providers/defaultProviderConfig.ts | 7 +-- .../lib/managedModelSelectionRepair.test.ts | 17 +++++++ .../lib/managedModelSelectionRepair.ts | 6 +-- .../runtime-config/modelProviderPolicy.ts | 45 +++++++++---------- src/shared/ui/GlobalComposerPill.test.tsx | 32 ++++++++++++- src/shared/ui/GlobalComposerPill.tsx | 26 +++++++++-- 9 files changed, 184 insertions(+), 40 deletions(-) diff --git a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts index dd04d0bbc..64524e8a0 100644 --- a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts +++ b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts @@ -58,6 +58,7 @@ function renderModelPicker(overrides: Partial = {}) { vi.mock("../useAgentModelPickerState", () => ({ useAgentModelPickerState: (args: unknown) => ({ getModelsForAgent: () => [], + getProvenModelsForAgent: () => [], isModelInventoryAuthoritative: () => false, ...mockUseAgentModelPickerState(args), }), @@ -1220,6 +1221,29 @@ describe("useResolvedAgentModelPicker", () => { }); }); + it("does not auto-select an unqualified advisory model from authoritative empty inventory", () => { + mockUseAgentModelPickerState.mockImplementation(() => ({ + pickerAgents: [{ id: "goose", label: "Goose" }], + availableModels: [ + { id: "advisory", name: "Advisory", recommended: true }, + ], + getProvenModelsForAgent: () => [], + isModelInventoryAuthoritative: () => true, + modelsLoading: false, + modelStatusMessage: null, + handleProviderChange: vi.fn(), + handleModelChange: vi.fn(), + })); + + const { result } = renderModelPicker({ + selectedProvider: "openai", + sessionId: null, + session: undefined, + }); + + expect(result.current.effectiveModelSelection).toBeNull(); + }); + it("ignores a stored model missing from an authoritative populated inventory", () => { window.localStorage.setItem( "goose:preferredModelsByAgent", @@ -1241,6 +1265,9 @@ describe("useResolvedAgentModelPicker", () => { recommended: true, }, ], + getProvenModelsForAgent: () => [ + { id: "gpt-5.6", name: "GPT-5.6", providerId: "openai" }, + ], isModelInventoryAuthoritative: () => true, modelsLoading: true, modelStatusMessage: null, diff --git a/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/src/features/chat/hooks/useResolvedAgentModelPicker.ts index 5dc1791b1..26f053c8a 100644 --- a/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -133,6 +133,7 @@ function getPreferredSelectionForAgent( function resolveAvailableSelection( selection: PreferredModelSelection, models: readonly ModelOption[], + provenModels: readonly ModelOption[], selectedModelProviderId: string | null, isInventoryAuthoritative: (providerId: string) => boolean, ): PreferredModelSelection | null { @@ -143,7 +144,10 @@ function resolveAvailableSelection( return null; } - const matchingModel = models.find( + const candidates = isInventoryAuthoritative(selection.modelProviderId) + ? provenModels + : models; + const matchingModel = candidates.find( (model) => model.id === selection.id && (!model.providerId || model.providerId === selection.modelProviderId), @@ -696,6 +700,7 @@ export function useResolvedAgentModelPicker({ const availableStoredSelection = resolveAvailableSelection( storedSelection, availableModels, + getProvenModelsForAgent(selectedAgentId), concreteSelectedProviderId, isModelInventoryAuthoritative, ); @@ -724,6 +729,7 @@ export function useResolvedAgentModelPicker({ modelProviderId: defaultModelProviderId, }, availableModels, + getProvenModelsForAgent(selectedAgentId), concreteSelectedProviderId, isModelInventoryAuthoritative, ); @@ -731,6 +737,7 @@ export function useResolvedAgentModelPicker({ availableModels, catalogEntries, concreteSelectedProviderId, + getProvenModelsForAgent, gooseDefaultSelection, isModelInventoryAuthoritative, selectedAgentId, @@ -782,13 +789,30 @@ export function useResolvedAgentModelPicker({ const availableDefaultModelSelection = useMemo(() => { + const provenModels = getProvenModelsForAgent(selectedAgentId); + const selectableModels = availableModels.filter((model) => { + const providerId = model.providerId ?? concreteSelectedProviderId; + const authoritative = providerId + ? isModelInventoryAuthoritative(providerId) + : isModelInventoryAuthoritative(); + return ( + !authoritative || + provenModels.some( + (proven) => + proven.id === model.id && + (!providerId || + !proven.providerId || + proven.providerId === providerId), + ) + ); + }); const compatibleModels = concreteSelectedProviderId - ? availableModels.filter( + ? selectableModels.filter( (model) => !model.providerId || model.providerId === concreteSelectedProviderId, ) - : availableModels; + : selectableModels; const defaultModel = compatibleModels.find((model) => model.recommended) ?? compatibleModels[0]; @@ -803,7 +827,14 @@ export function useResolvedAgentModelPicker({ modelProviderId: defaultModel.providerId ?? selectedProvider, source: defaultModel.recommended ? "default" : "explicit", }; - }, [availableModels, concreteSelectedProviderId, selectedProvider]); + }, [ + availableModels, + concreteSelectedProviderId, + getProvenModelsForAgent, + isModelInventoryAuthoritative, + selectedAgentId, + selectedProvider, + ]); const fallbackModelSelection = session ? null diff --git a/src/features/providers/defaultProviderConfig.test.ts b/src/features/providers/defaultProviderConfig.test.ts index ab8e429cd..c59c7f591 100644 --- a/src/features/providers/defaultProviderConfig.test.ts +++ b/src/features/providers/defaultProviderConfig.test.ts @@ -119,6 +119,30 @@ describe("saveDefaultProviderSelection", () => { }); }); + it("does not persist an advisory recommendation excluded from live proof", async () => { + const refreshProviderModels = vi.fn().mockImplementation((providerId) => { + useProviderModelCacheStore.setState({ + providers: new Map([ + [ + providerId, + { + providerId, + fetchedAt: Date.now(), + provenModelIds: [], + models: [{ id: "advisory", name: "Advisory", recommended: true }], + }, + ], + ]), + }); + }); + useProviderModelCacheStore.setState({ refreshProviderModels }); + + await expect(saveDefaultProviderSelection("openai")).rejects.toThrow( + "Could not load models for provider", + ); + expect(defaultsSave).not.toHaveBeenCalled(); + }); + it("saves backend defaults, local goose preference, and readiness", async () => { const refreshProviderModels = vi.fn().mockImplementation((providerId) => { useProviderModelCacheStore.setState({ @@ -128,6 +152,7 @@ describe("saveDefaultProviderSelection", () => { { providerId, fetchedAt: Date.now(), + provenModelIds: ["gpt-4o"], models: [{ id: "gpt-4o", name: "gpt-4o", recommended: true }], }, ], diff --git a/src/features/providers/defaultProviderConfig.ts b/src/features/providers/defaultProviderConfig.ts index 58ad083af..1ad4f8d7e 100644 --- a/src/features/providers/defaultProviderConfig.ts +++ b/src/features/providers/defaultProviderConfig.ts @@ -137,9 +137,10 @@ export async function saveDefaultProviderSelection( const modelCacheStore = useProviderModelCacheStore.getState(); await modelCacheStore.refreshProviderModels(providerId, { force: true }); - const models = useProviderModelCacheStore - .getState() - .getModelsForProvider(providerId); + const cache = useProviderModelCacheStore.getState(); + const models = cache.isModelInventoryAuthoritative(providerId) + ? cache.getProvenModelsForProvider(providerId) + : cache.getModelsForProvider(providerId); const runtimeDefaultModelId = providerId === getDefaultGooseModelProviderId() ? getDefaultGooseModelId() diff --git a/src/features/providers/lib/managedModelSelectionRepair.test.ts b/src/features/providers/lib/managedModelSelectionRepair.test.ts index 9682841d0..8fd73ebec 100644 --- a/src/features/providers/lib/managedModelSelectionRepair.test.ts +++ b/src/features/providers/lib/managedModelSelectionRepair.test.ts @@ -63,6 +63,23 @@ describe("repairManagedGooseModelSelection", () => { }); }); + it("keeps an authoritative-empty same-provider target provider-only", async () => { + vi.mocked(getClient).mockResolvedValue({ + goose: { + GooseUnstableProvidersSupportedModelsList: vi.fn().mockResolvedValue({ + models: [], + }), + }, + } as never); + + await expect( + repairManagedGooseModelSelection( + { providerId: "databricks_v2" }, + "session", + ), + ).resolves.toEqual({ providerId: "databricks_v2", modelId: undefined }); + }); + it("repairs any model absent from the live target-provider inventory", async () => { vi.mocked(getClient).mockResolvedValue({ goose: { diff --git a/src/features/providers/lib/managedModelSelectionRepair.ts b/src/features/providers/lib/managedModelSelectionRepair.ts index da6e1f713..44c116a99 100644 --- a/src/features/providers/lib/managedModelSelectionRepair.ts +++ b/src/features/providers/lib/managedModelSelectionRepair.ts @@ -13,7 +13,6 @@ import { type ManagedGooseProviderSelection, } from "@/shared/runtime-config/modelProviderPolicy"; -const DATABRICKS_V2_PROVIDER_ID = "databricks_v2"; const VALIDATED_INVENTORY_TTL_MS = 5 * 60 * 1000; const validatedInventories = new Map< string, @@ -101,10 +100,7 @@ export async function repairManagedGooseModelSelection( return resolveValidatedManagedGooseProviderSelection(config, selection); } - const targetModelIds = - initial.providerId === DATABRICKS_V2_PROVIDER_ID && selection.modelId - ? await validatedModelIds(initial.providerId) - : null; + const targetModelIds = await validatedModelIds(initial.providerId); const repaired = resolveManagedGooseProviderSelection(config, selection, { ...(targetModelIds ? { targetModelIds } : {}), targetInventoryValidated: targetModelIds !== null, diff --git a/src/shared/runtime-config/modelProviderPolicy.ts b/src/shared/runtime-config/modelProviderPolicy.ts index 44f74a973..fc6dcafa4 100644 --- a/src/shared/runtime-config/modelProviderPolicy.ts +++ b/src/shared/runtime-config/modelProviderPolicy.ts @@ -23,7 +23,6 @@ export interface ManagedGooseProviderResolutionContext { targetInventoryValidated?: boolean; } -const DATABRICKS_V2_PROVIDER_ID = "databricks_v2"; const INVENTORY_PROOF_TIMEOUT_MS = 60_000; /** @@ -71,20 +70,21 @@ export function resolveManagedGooseProviderSelection( (provider) => provider.id === selection.providerId, )?.id; const providerId = configuredProviderId ?? defaultManagedProviderId(goose); - let modelId = - normalizeConcreteModelId(selection.modelId) ?? - normalizeConcreteModelId(goose.defaultModelId); - - if ( - providerId === DATABRICKS_V2_PROVIDER_ID && - modelId && - context.targetInventoryValidated === true && - !context.targetModelIds?.has(modelId) - ) { - modelId = goose.defaultModelId; + const selectedModelId = normalizeConcreteModelId(selection.modelId); + const defaultModelId = normalizeConcreteModelId(goose.defaultModelId); + if (context.targetInventoryValidated === true) { + const provenModelIds = context.targetModelIds ?? new Set(); + const modelId = + (selectedModelId && provenModelIds.has(selectedModelId) + ? selectedModelId + : undefined) ?? + (defaultModelId && provenModelIds.has(defaultModelId) + ? defaultModelId + : undefined); + return { providerId, modelId }; } - return { providerId, modelId: modelId ?? undefined }; + return { providerId, modelId: selectedModelId ?? defaultModelId }; } /** @@ -140,9 +140,7 @@ export async function resolveValidatedManagedGooseProviderSelection( selection: GooseProviderSelection, ): Promise { const resolved = resolveManagedGooseProviderSelection(config, selection); - if (!resolved || resolved.providerId === selection.providerId) { - return resolved; - } + if (!resolved) return null; let supportedModelIds: ReadonlySet; try { @@ -150,19 +148,20 @@ export async function resolveValidatedManagedGooseProviderSelection( resolved.providerId, ); } catch (error) { + if (resolved.providerId === selection.providerId) return resolved; throw new Error( `Cannot verify models for migrated provider ${resolved.providerId}; provider selection was not changed.`, { cause: error }, ); } - if (resolved.modelId && supportedModelIds.has(resolved.modelId)) { - return resolved; - } - const fallbackModelId = normalizeConcreteModelId(config.goose.defaultModelId); - if (fallbackModelId && supportedModelIds.has(fallbackModelId)) { - return { providerId: resolved.providerId, modelId: fallbackModelId }; - } + const proven = resolveManagedGooseProviderSelection(config, selection, { + targetModelIds: supportedModelIds, + targetInventoryValidated: true, + }); + if (resolved.providerId === selection.providerId) return proven; + if (proven?.modelId) return proven; + const provenInventoryFallback = [...supportedModelIds].sort()[0]; if (provenInventoryFallback) { return { diff --git a/src/shared/ui/GlobalComposerPill.test.tsx b/src/shared/ui/GlobalComposerPill.test.tsx index ddb32323d..24ea3adb9 100644 --- a/src/shared/ui/GlobalComposerPill.test.tsx +++ b/src/shared/ui/GlobalComposerPill.test.tsx @@ -24,6 +24,7 @@ const mockNormalizeImageBase64 = vi.fn(); const mockSearchFilesForMentions = vi.fn(); const mockResizeImage = vi.fn(); const mockGetModelsForAgent = vi.fn(); +const mockGetProvenModelsForAgent = vi.fn(); const pendingMentionLoad = new Promise(() => {}); const mockRefreshAllModelProviders = vi.fn(); const mockRefreshAgentProviderStatus = vi.fn(); @@ -90,7 +91,7 @@ vi.mock("@/features/providers/hooks/useProviderModels", () => ({ modelCacheRefreshProviderIds: ["openai", "anthropic"], getModelsForAgent: (agentId: string) => mockGetModelsForAgent(agentId), getProvenModelsForAgent: (agentId: string) => - mockGetModelsForAgent(agentId), + mockGetProvenModelsForAgent(agentId), isModelInventoryAuthoritative: () => mockProviderModelsState.inventoryAuthoritative, refreshAllModelProviders: (...args: unknown[]) => @@ -257,6 +258,10 @@ describe("GlobalComposerPill", () => { vi.mocked(listSkills).mockImplementation(() => pendingMentionLoad); mockGetModelsForAgent.mockReset(); mockGetModelsForAgent.mockReturnValue([]); + mockGetProvenModelsForAgent.mockReset(); + mockGetProvenModelsForAgent.mockImplementation((agentId: string) => + mockGetModelsForAgent(agentId), + ); mockRefreshAllModelProviders.mockReset(); mockRefreshAllModelProviders.mockResolvedValue(undefined); mockRefreshAgentProviderStatus.mockReset(); @@ -897,6 +902,31 @@ describe("GlobalComposerPill", () => { }); }); + it("does not synthesize an unqualified advisory model from authoritative empty inventory", async () => { + const user = userEvent.setup(); + useAgentStore.setState({ selectedProvider: "databricks_v2" }); + mockGetModelsForAgent.mockReturnValue([ + { id: "advisory", name: "Advisory", recommended: true }, + ]); + mockGetProvenModelsForAgent.mockReturnValue([]); + const onSend = renderGlobalComposer(vi.fn(), { + currentExecutionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + }, + }); + + await user.type(screen.getByRole("textbox"), "Hello"); + await user.click(screen.getByRole("button", { name: /send message/i })); + + expectSent(onSend, "Hello", { + executionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + }, + }); + }); + it("does not send a stored model that is absent from the loaded inventory", async () => { const user = userEvent.setup(); mockProviderModelsState.refreshing = true; diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index dfe38469e..f51c7e766 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -580,10 +580,27 @@ export function GlobalComposerPill({ ? selectedProviderForPicker : null; const defaultModelSelection = useMemo(() => { + const provenModels = getProvenModelsForAgent(selectedAgentId); + const selectableModels = availableModels.filter((model) => { + const providerId = model.providerId ?? concreteSelectedProviderId; + const authoritative = providerId + ? isModelInventoryAuthoritative(providerId) + : isModelInventoryAuthoritative(); + return ( + !authoritative || + provenModels.some( + (proven) => + proven.id === model.id && + (!providerId || + !proven.providerId || + proven.providerId === providerId), + ) + ); + }); const storedPreference = getStoredModelPreference(selectedAgentId); if (storedPreference) { const matchingModel = findMatchingModel( - availableModels, + selectableModels, storedPreference.modelId, storedPreference.providerId, ); @@ -619,7 +636,7 @@ export function GlobalComposerPill({ gooseDefaultSelection.modelProviderId === concreteSelectedProviderId) ) { const matchingDefault = findMatchingModel( - availableModels, + selectableModels, gooseDefaultSelection.modelId, gooseDefaultSelection.modelProviderId, ); @@ -637,17 +654,18 @@ export function GlobalComposerPill({ } const compatibleModels = concreteSelectedProviderId - ? availableModels.filter( + ? selectableModels.filter( (model) => !model.providerId || model.providerId === concreteSelectedProviderId, ) - : availableModels; + : selectableModels; return getPreferredModel(compatibleModels, selectedProviderForPicker); }, [ availableModels, concreteSelectedProviderId, + getProvenModelsForAgent, gooseDefaultSelection, isModelInventoryAuthoritative, selectedAgentId, From 7bfd13d5e68d3f9c246f45e2dfed8a27470842cd Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 11 Aug 2026 08:49:50 -0400 Subject: [PATCH 28/50] test: provide proven model inventory in picker mocks Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../chat/hooks/__tests__/useChatSessionController.test.ts | 3 +++ .../chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index d12c33d75..5e9195b2e 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -255,6 +255,9 @@ vi.mock("../useAgentModelPickerState", () => ({ getModelsForAgent: (agentId: string) => mockPickerState.modelsByAgent.get(agentId) ?? mockPickerState.availableModels, + getProvenModelsForAgent: (agentId: string) => + mockPickerState.modelsByAgent.get(agentId) ?? + mockPickerState.availableModels, isModelInventoryAuthoritative: () => false, modelsLoading: mockPickerState.modelsLoading, modelStatusMessage: mockPickerState.modelStatusMessage, diff --git a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts index 64524e8a0..fb7846c1f 100644 --- a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts +++ b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts @@ -1150,6 +1150,7 @@ describe("useResolvedAgentModelPicker", () => { recommended: true, }, ], + getProvenModelsForAgent: () => [], isModelInventoryAuthoritative: () => false, modelsLoading: true, modelStatusMessage: null, @@ -1202,6 +1203,7 @@ describe("useResolvedAgentModelPicker", () => { recommended: true, }, ], + getProvenModelsForAgent: () => [], isModelInventoryAuthoritative: (providerId: string) => providerId === "openai", modelsLoading: false, From 1543ef09d730e14562456d9309e7130818d9d03d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 13 Aug 2026 09:53:32 -0400 Subject: [PATCH 29/50] fix(providers): preserve intent without model proof Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../useResolvedAgentModelPicker.test.ts | 23 ++++++++++++++ .../chat/hooks/useResolvedAgentModelPicker.ts | 19 ++++-------- .../providers/defaultProviderConfig.test.ts | 4 +++ .../providers/defaultProviderConfig.ts | 9 ++++-- .../lib/managedModelSelectionRepair.test.ts | 11 +++++++ .../lib/resolveSessionModelPreference.test.ts | 2 ++ .../lib/resolveSessionModelPreference.ts | 30 ++++++++++++------ src/shared/api/__tests__/acp.test.ts | 31 +++++++++++++++++++ src/shared/api/acp.ts | 6 ++-- .../modelProviderPolicy.test.ts | 8 ++--- .../runtime-config/modelProviderPolicy.ts | 14 ++++++--- src/shared/ui/GlobalComposerPill.test.tsx | 26 ++++++++++++++++ src/shared/ui/GlobalComposerPill.tsx | 23 ++++---------- 13 files changed, 152 insertions(+), 54 deletions(-) diff --git a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts index fb7846c1f..88be53e95 100644 --- a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts +++ b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts @@ -1223,6 +1223,29 @@ describe("useResolvedAgentModelPicker", () => { }); }); + it("does not auto-select an advisory model while inventory proof is unavailable", () => { + mockUseAgentModelPickerState.mockImplementation(() => ({ + pickerAgents: [{ id: "goose", label: "Goose" }], + availableModels: [ + { id: "advisory", name: "Advisory", recommended: true }, + ], + getProvenModelsForAgent: () => [], + isModelInventoryAuthoritative: () => false, + modelsLoading: true, + modelStatusMessage: null, + handleProviderChange: vi.fn(), + handleModelChange: vi.fn(), + })); + + const { result } = renderModelPicker({ + selectedProvider: "openai", + sessionId: null, + session: undefined, + }); + + expect(result.current.effectiveModelSelection).toBeNull(); + }); + it("does not auto-select an unqualified advisory model from authoritative empty inventory", () => { mockUseAgentModelPickerState.mockImplementation(() => ({ pickerAgents: [{ id: "goose", label: "Goose" }], diff --git a/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/src/features/chat/hooks/useResolvedAgentModelPicker.ts index 26f053c8a..3a0e2b9ac 100644 --- a/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -792,18 +792,12 @@ export function useResolvedAgentModelPicker({ const provenModels = getProvenModelsForAgent(selectedAgentId); const selectableModels = availableModels.filter((model) => { const providerId = model.providerId ?? concreteSelectedProviderId; - const authoritative = providerId - ? isModelInventoryAuthoritative(providerId) - : isModelInventoryAuthoritative(); - return ( - !authoritative || - provenModels.some( - (proven) => - proven.id === model.id && - (!providerId || - !proven.providerId || - proven.providerId === providerId), - ) + return provenModels.some( + (proven) => + proven.id === model.id && + (!providerId || + !proven.providerId || + proven.providerId === providerId), ); }); const compatibleModels = concreteSelectedProviderId @@ -831,7 +825,6 @@ export function useResolvedAgentModelPicker({ availableModels, concreteSelectedProviderId, getProvenModelsForAgent, - isModelInventoryAuthoritative, selectedAgentId, selectedProvider, ]); diff --git a/src/features/providers/defaultProviderConfig.test.ts b/src/features/providers/defaultProviderConfig.test.ts index c59c7f591..20bd26fb1 100644 --- a/src/features/providers/defaultProviderConfig.test.ts +++ b/src/features/providers/defaultProviderConfig.test.ts @@ -80,6 +80,9 @@ describe("reconcileManagedDefaultProviderSelection", () => { modelId: "goose", }), GooseUnstableDefaultsSave: defaultsSave, + GooseUnstableProvidersSupportedModelsList: vi.fn().mockResolvedValue({ + models: ["goose-gpt-5-5"], + }), }, } as never); @@ -296,6 +299,7 @@ describe("saveDefaultProviderSelectionFromConfiguredProvider", () => { { providerId, fetchedAt: Date.now(), + provenModelIds: models.map((model) => model.id), models, }, ], diff --git a/src/features/providers/defaultProviderConfig.ts b/src/features/providers/defaultProviderConfig.ts index 1ad4f8d7e..753c05671 100644 --- a/src/features/providers/defaultProviderConfig.ts +++ b/src/features/providers/defaultProviderConfig.ts @@ -138,9 +138,12 @@ export async function saveDefaultProviderSelection( await modelCacheStore.refreshProviderModels(providerId, { force: true }); const cache = useProviderModelCacheStore.getState(); - const models = cache.isModelInventoryAuthoritative(providerId) - ? cache.getProvenModelsForProvider(providerId) - : cache.getModelsForProvider(providerId); + if (!cache.isModelInventoryAuthoritative(providerId)) { + throw new Error( + "Could not prove models for provider. Check provider setup and try again.", + ); + } + const models = cache.getProvenModelsForProvider(providerId); const runtimeDefaultModelId = providerId === getDefaultGooseModelProviderId() ? getDefaultGooseModelId() diff --git a/src/features/providers/lib/managedModelSelectionRepair.test.ts b/src/features/providers/lib/managedModelSelectionRepair.test.ts index 8fd73ebec..f2fd17937 100644 --- a/src/features/providers/lib/managedModelSelectionRepair.test.ts +++ b/src/features/providers/lib/managedModelSelectionRepair.test.ts @@ -80,6 +80,17 @@ describe("repairManagedGooseModelSelection", () => { ).resolves.toEqual({ providerId: "databricks_v2", modelId: undefined }); }); + it("preserves same-provider model-free intent when live proof cannot be read", async () => { + vi.mocked(getClient).mockRejectedValue(new Error("offline")); + + await expect( + repairManagedGooseModelSelection( + { providerId: "databricks_v2" }, + "session", + ), + ).resolves.toEqual({ providerId: "databricks_v2", modelId: undefined }); + }); + it("repairs any model absent from the live target-provider inventory", async () => { vi.mocked(getClient).mockResolvedValue({ goose: { diff --git a/src/features/providers/lib/resolveSessionModelPreference.test.ts b/src/features/providers/lib/resolveSessionModelPreference.test.ts index 0ff3f1ee5..5c6d3f660 100644 --- a/src/features/providers/lib/resolveSessionModelPreference.test.ts +++ b/src/features/providers/lib/resolveSessionModelPreference.test.ts @@ -65,6 +65,7 @@ describe("resolveSupportedSessionModelPreference", () => { modelId: "gpt-5.4", }, }); + setCachedModels("openai", ["gpt-5.4"]); await expect( resolveSupportedSessionModelPreference("goose"), @@ -296,6 +297,7 @@ describe("resolveSupportedSessionModelPreference", () => { modelId: "goose-gpt-5-5", }, }); + setCachedModels("databricks_v2", ["goose-gpt-5-5"]); mockCheckAllProviderStatus.mockResolvedValue([ { providerId: "openai", isConfigured: false }, ]); diff --git a/src/features/providers/lib/resolveSessionModelPreference.ts b/src/features/providers/lib/resolveSessionModelPreference.ts index b07cb7bbf..dd4a5c2af 100644 --- a/src/features/providers/lib/resolveSessionModelPreference.ts +++ b/src/features/providers/lib/resolveSessionModelPreference.ts @@ -112,15 +112,25 @@ export async function resolveSupportedSessionModelPreference( }; } + const modelCache = useProviderModelCacheStore.getState(); + + // A configured default is synthesized intent. Unlike an explicit preference, + // it cannot survive without a successful inventory proof. if (providerId === "goose" && !sessionModelPreference.modelId) { - sessionModelPreference = gooseDefaultPreference() ?? sessionModelPreference; + const fallback = gooseDefaultPreference(); + if (!fallback) return sessionModelPreference; + if (!modelCache.isModelInventoryAuthoritative(fallback.providerId)) { + return { providerId }; + } + return sanitizeSessionModelPreference(fallback, { + models: modelCache.getProvenModelsForProvider(fallback.providerId), + }); } if (!sessionModelPreference.modelId) { return sessionModelPreference; } - const modelCache = useProviderModelCacheStore.getState(); const modelProviderId = sessionModelPreference.providerId; if (modelCache.isModelInventoryAuthoritative(modelProviderId)) { @@ -135,14 +145,14 @@ export async function resolveSupportedSessionModelPreference( if (providerId === "goose") { const fallback = gooseDefaultPreference(); - if (fallback && fallback.providerId !== sessionModelPreference.providerId) { - const fallbackCache = useProviderModelCacheStore.getState(); - if (fallbackCache.isModelInventoryAuthoritative(fallback.providerId)) { - return sanitizeSessionModelPreference(fallback, { - models: fallbackCache.getProvenModelsForProvider(fallback.providerId), - }); - } - return fallback; + if ( + fallback && + fallback.providerId !== sessionModelPreference.providerId && + modelCache.isModelInventoryAuthoritative(fallback.providerId) + ) { + return sanitizeSessionModelPreference(fallback, { + models: modelCache.getProvenModelsForProvider(fallback.providerId), + }); } } diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index a44dac0da..a4986d4f1 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -1392,6 +1392,34 @@ describe("acpCreateSession", () => { expect(mockSetModel).not.toHaveBeenCalled(); }); + it("does not send a same-provider model disproved by live inventory", async () => { + await setRuntimeConfig(managedRuntimeConfig); + mockSupportedModelsList.mockResolvedValueOnce({ + models: ["goose-gpt-5-5"], + }); + mockNewSession.mockResolvedValue({ sessionId: "managed-session" }); + const { acpCreateSession } = await import("../acp"); + + await acpCreateSession("databricks_v2", "/tmp/project", { + modelId: "retired-model", + }); + + expect(mockSetProvider).toHaveBeenCalledWith( + "managed-session", + "databricks_v2", + ); + expect(mockSetModel).toHaveBeenCalledWith( + "managed-session", + "goose-gpt-5-5", + noRequestModelContext("databricks_v2"), + ); + expect(mockSetModel).not.toHaveBeenCalledWith( + "managed-session", + "retired-model", + expect.anything(), + ); + }); + it.each([ "claude-acp", "codex-acp", @@ -1722,6 +1750,9 @@ describe("acpPrepareSession", () => { it("allows upstream models omitted from recommendation metadata", async () => { await setRuntimeConfig(managedRuntimeConfig); + mockSupportedModelsList.mockResolvedValueOnce({ + models: ["new-upstream-model"], + }); const { acpPrepareSession } = await import("../acp"); await acpPrepareSession("other-session", "other-managed", "/tmp/project", { diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index c8bd26e92..6d6565c74 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -382,9 +382,9 @@ async function resolveGooseSessionSelection( ); } - // A concrete provider is renderer-owned. Policy may validate it, but must - // not replace its provider or inject a different provider's default model. - return requestedSelection; + // A concrete provider stays renderer-owned; validation may remove or replace + // only its model with a proven result. + return managedSelection; } /** Prepare or warm an ACP session ahead of the first prompt. */ diff --git a/src/shared/runtime-config/modelProviderPolicy.test.ts b/src/shared/runtime-config/modelProviderPolicy.test.ts index aac386d34..e467d8936 100644 --- a/src/shared/runtime-config/modelProviderPolicy.test.ts +++ b/src/shared/runtime-config/modelProviderPolicy.test.ts @@ -80,7 +80,7 @@ describe("resolveManagedGooseProviderSelection", () => { ).toEqual({ providerId: "databricks_v2", modelId: "shared-model" }); }); - it("repairs the legacy Goose model sentinel without live inventory", () => { + it("keeps a legacy model sentinel model-free without live proof", () => { expect( resolveManagedGooseProviderSelection(managedConfig, { providerId: "databricks", @@ -88,7 +88,7 @@ describe("resolveManagedGooseProviderSelection", () => { }), ).toEqual({ providerId: "databricks_v2", - modelId: "goose-gpt-5-5", + modelId: undefined, }); }); @@ -173,14 +173,14 @@ describe("resolveManagedGooseProviderSelection", () => { }); }); - it("uses the configured default only when no model is selected", () => { + it("keeps a migrated provider model-free without live proof", () => { expect( resolveManagedGooseProviderSelection(managedConfig, { providerId: "databricks", }), ).toEqual({ providerId: "databricks_v2", - modelId: "goose-gpt-5-5", + modelId: undefined, }); }); diff --git a/src/shared/runtime-config/modelProviderPolicy.ts b/src/shared/runtime-config/modelProviderPolicy.ts index fc6dcafa4..093fb08ad 100644 --- a/src/shared/runtime-config/modelProviderPolicy.ts +++ b/src/shared/runtime-config/modelProviderPolicy.ts @@ -53,8 +53,8 @@ function defaultManagedProviderId(goose: RuntimeGooseConfig): string { * - `null` means policy is unrestricted; the caller must preserve its values. * - Allowed providers and all of their upstream-discovered models stay selected. * - Disallowed/missing providers move to the runtime default provider. - * - Existing model selections survive provider migration. A missing model uses - * the configured default, whose inventory entry is recommendation metadata. + * - Existing concrete model selections survive while proof is unavailable. + * - A configured default is synthesized only from successful inventory proof. */ export function resolveManagedGooseProviderSelection( config: Pick, @@ -70,21 +70,27 @@ export function resolveManagedGooseProviderSelection( (provider) => provider.id === selection.providerId, )?.id; const providerId = configuredProviderId ?? defaultManagedProviderId(goose); + const providerWasMigrated = configuredProviderId === undefined; const selectedModelId = normalizeConcreteModelId(selection.modelId); const defaultModelId = normalizeConcreteModelId(goose.defaultModelId); if (context.targetInventoryValidated === true) { const provenModelIds = context.targetModelIds ?? new Set(); + const needsModelRepair = providerWasMigrated || selection.modelId != null; const modelId = (selectedModelId && provenModelIds.has(selectedModelId) ? selectedModelId : undefined) ?? - (defaultModelId && provenModelIds.has(defaultModelId) + // A default is a synthesized fallback. Do not turn same-provider, + // provider-only intent into a concrete selection merely because live + // inventory happened to be available. It may repair an existing concrete + // selection (including a legacy sentinel) or a provider migration. + (needsModelRepair && defaultModelId && provenModelIds.has(defaultModelId) ? defaultModelId : undefined); return { providerId, modelId }; } - return { providerId, modelId: selectedModelId ?? defaultModelId }; + return { providerId, modelId: selectedModelId }; } /** diff --git a/src/shared/ui/GlobalComposerPill.test.tsx b/src/shared/ui/GlobalComposerPill.test.tsx index 24ea3adb9..cfedb3342 100644 --- a/src/shared/ui/GlobalComposerPill.test.tsx +++ b/src/shared/ui/GlobalComposerPill.test.tsx @@ -902,6 +902,32 @@ describe("GlobalComposerPill", () => { }); }); + it("does not synthesize an advisory model while inventory proof is unavailable", async () => { + const user = userEvent.setup(); + mockProviderModelsState.inventoryAuthoritative = false; + useAgentStore.setState({ selectedProvider: "databricks_v2" }); + mockGetModelsForAgent.mockReturnValue([ + { id: "advisory", name: "Advisory", recommended: true }, + ]); + mockGetProvenModelsForAgent.mockReturnValue([]); + const onSend = renderGlobalComposer(vi.fn(), { + currentExecutionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + }, + }); + + await user.type(screen.getByRole("textbox"), "Hello"); + await user.click(screen.getByRole("button", { name: /send message/i })); + + expectSent(onSend, "Hello", { + executionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + }, + }); + }); + it("does not synthesize an unqualified advisory model from authoritative empty inventory", async () => { const user = userEvent.setup(); useAgentStore.setState({ selectedProvider: "databricks_v2" }); diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index f51c7e766..8063dd9d1 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -583,18 +583,12 @@ export function GlobalComposerPill({ const provenModels = getProvenModelsForAgent(selectedAgentId); const selectableModels = availableModels.filter((model) => { const providerId = model.providerId ?? concreteSelectedProviderId; - const authoritative = providerId - ? isModelInventoryAuthoritative(providerId) - : isModelInventoryAuthoritative(); - return ( - !authoritative || - provenModels.some( - (proven) => - proven.id === model.id && - (!providerId || - !proven.providerId || - proven.providerId === providerId), - ) + return provenModels.some( + (proven) => + proven.id === model.id && + (!providerId || + !proven.providerId || + proven.providerId === providerId), ); }); const storedPreference = getStoredModelPreference(selectedAgentId); @@ -646,11 +640,6 @@ export function GlobalComposerPill({ selectedProviderForPicker, ); } - if ( - !isModelInventoryAuthoritative(gooseDefaultSelection.modelProviderId) - ) { - return gooseDefaultSelection; - } } const compatibleModels = concreteSelectedProviderId From e003a1ccbe208835410a99c38d98b2aa2f5a535a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 13 Aug 2026 10:08:31 -0400 Subject: [PATCH 30/50] fix(providers): avoid inventory lookup on prompt send Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/shared/api/__tests__/acp.test.ts | 36 ++++++++++++++++++++++++++++ src/shared/api/acp.ts | 30 +++++++++++++++++------ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index a4986d4f1..89e4606a8 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -209,6 +209,42 @@ describe("acpSendMessage", () => { expect(mockPrompt).not.toHaveBeenCalled(); }); + it("admits a managed-provider prompt without reading live model inventory", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const sessionRegistry = await import("../acpSessionRegistry"); + const { acpSendMessage } = await import("../acp"); + sessionRegistry.registerPreparedSession( + "acp-session-managed-send", + "databricks_v2", + "/tmp/project", + "goose-gpt-5-5", + ); + + await acpSendMessage("acp-session-managed-send", "hello"); + + expect(mockSupportedModelsList).not.toHaveBeenCalled(); + expect(mockPrompt).toHaveBeenCalledOnce(); + }); + + it("rejects an out-of-policy provider without reading live model inventory", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const sessionRegistry = await import("../acpSessionRegistry"); + const { acpSendMessage } = await import("../acp"); + sessionRegistry.registerPreparedSession( + "acp-session-outside-policy", + "outside-policy", + "/tmp/project", + "outside-model", + ); + + await expect( + acpSendMessage("acp-session-outside-policy", "hello"), + ).rejects.toThrow("outside the managed Goose provider policy"); + + expect(mockSupportedModelsList).not.toHaveBeenCalled(); + expect(mockPrompt).not.toHaveBeenCalled(); + }); + it("reports dispatch only after ACP setup reaches the transport boundary", async () => { const sessionRegistry = await import("../acpSessionRegistry"); const { acpSendMessage } = await import("../acp"); diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index 6d6565c74..9c5e4f19e 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -28,7 +28,10 @@ import { type PersonaHandoffClaim, } from "./acpPersonaHandoff"; import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; -import { resolveValidatedManagedGooseProviderSelection } from "@/shared/runtime-config/modelProviderPolicy"; +import { + resolveManagedGooseProviderSelection, + resolveValidatedManagedGooseProviderSelection, +} from "@/shared/runtime-config/modelProviderPolicy"; import { getStyleGuidelinesPrompt } from "@/shared/preferences/styleGuidelinesPreference"; import { getBerdctlPreamble } from "@/features/berdctl/appPreamble"; import { INTERACTION_NORMS_PREAMBLE } from "@/shared/api/interactionNorms"; @@ -182,12 +185,25 @@ async function acpSendMessageNow( const sid = sessionId.slice(0, 8); const tStart = performance.now(); - const resolvedProvider = (await resolveGooseSessionSelection(providerId)) - .providerId; - if (resolvedProvider !== providerId) { - throw new Error( - `Session provider ${providerId} is outside the managed Goose provider policy. Re-prepare the session before prompting.`, - ); + if ( + providerId === "goose" || + CURATED_PROVIDER_CATALOG_BY_ID.get(providerId)?.category !== "agent" + ) { + const runtimeConfigState = useRuntimeConfigStore.getState(); + if (runtimeConfigState.result.status === "unavailable") { + throw new Error( + `Goose provider policy is unavailable: ${runtimeConfigState.result.message}`, + ); + } + const resolvedProvider = resolveManagedGooseProviderSelection( + runtimeConfigState.config, + { providerId }, + )?.providerId; + if (resolvedProvider && resolvedProvider !== providerId) { + throw new Error( + `Session provider ${providerId} is outside the managed Goose provider policy. Re-prepare the session before prompting.`, + ); + } } // Goose owns prompt assembly and accepts a real system prompt via its ACP From 02346ccbcff355b193addb12b89b63a4edb4d49e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 17 Aug 2026 18:06:42 +0000 Subject: [PATCH 31/50] test: reconcile suite with session-target lease and model-proof gating Rebasing onto main introduced reserveAcpSessionConfiguration (a new 5th argument threaded through acpPrepareSession) and provenModelIds (the requirement that a default/configured model selection prove itself via a live inventory response before being surfaced). Update mocks and assertions across the affected suites to match both contracts instead of weakening them. Signed-off-by: loganj --- src/app/AppShell.berdctl.test.tsx | 1 + src/app/AppShell.navigation.test.tsx | 17 ++++++++++ .../__tests__/NavigationPanesView.test.tsx | 1 + .../__tests__/commands/commands.test.ts | 7 ++++ .../commands/runtime/sessionSend.test.ts | 8 +++++ .../__tests__/useChat.attachments.test.ts | 1 + .../__tests__/useChat.compaction.test.ts | 1 + .../useChat.personaPreparation.test.ts | 1 + .../__tests__/useChat.skillChips.test.ts | 1 + .../chat/hooks/__tests__/useChat.test.ts | 1 + ...onController.targetLeaseCompaction.test.ts | 1 + .../useChatSessionController.test.ts | 3 ++ .../useResolvedAgentModelPicker.test.ts | 32 ++++++++++++++++++- .../lib/__tests__/sessionActivation.test.ts | 2 ++ .../__tests__/sessionWorkspaceCleanup.test.ts | 1 + .../chat/lib/__tests__/steerCore.test.ts | 1 + src/features/chat/lib/sendCore.test.ts | 1 + .../chat/lib/sessionTargetCoordinator.test.ts | 22 +++++++++---- .../chat/lib/sessionTargetTransition.test.ts | 3 ++ .../stores/__tests__/chatSessionStore.test.ts | 1 + src/features/home/ui/HomeScreen.test.tsx | 1 + .../__tests__/useAutoArchiveSessions.test.ts | 1 + .../hooks/__tests__/useSessionSearch.test.ts | 1 + .../ui/__tests__/SessionHistoryView.test.tsx | 1 + 24 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/app/AppShell.berdctl.test.tsx b/src/app/AppShell.berdctl.test.tsx index f85f08521..8cc92f52b 100644 --- a/src/app/AppShell.berdctl.test.tsx +++ b/src/app/AppShell.berdctl.test.tsx @@ -80,6 +80,7 @@ vi.mock("@/app/views/NavigationPanesView", () => ({ })); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpCreateSession: (...args: unknown[]) => mockAcpCreateSession(...args), acpListSessionsPage: (...args: unknown[]) => mockAcpListSessionsPage(...args), acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args), diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 095c4cb48..abb34d596 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -317,6 +317,19 @@ function seedProviderModels( ], ]), ); + // Simulate a successful live inventory response: seeding a provider's + // display candidates alone is advisory and never establishes proof. + useProviderModelCacheStore.setState((state) => { + const providers = new Map(state.providers); + const existing = providers.get(providerId); + if (existing) { + providers.set(providerId, { + ...existing, + provenModelIds: models.map((model) => model.id), + }); + } + return { providers }; + }); } vi.mock("@/shared/profile/buildProfile", () => ({ @@ -440,6 +453,7 @@ vi.mock("@/shared/api/acp", () => ({ acpListSessionsPage: (...args: unknown[]) => mockAcpListSessionsPage(...args), acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args), discoverAcpProviders: vi.fn().mockResolvedValue([]), + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), })); vi.mock("@/shared/api/acpApi", () => ({ @@ -1412,6 +1426,7 @@ describe("AppShell global navigation", () => { "openai", "~/goose artifacts", expect.any(Object), + expect.objectContaining({ clear: expect.any(Function) }), ); }); expect( @@ -3435,6 +3450,7 @@ describe("AppShell global navigation", () => { "codex-acp", "~/goose artifacts", expect.objectContaining({ modelId: "gpt-5.4-mini" }), + expect.objectContaining({ clear: expect.any(Function) }), ); }); act(() => pendingPrepare.resolve({})); @@ -3497,6 +3513,7 @@ describe("AppShell global navigation", () => { "databricks_v2", "~/goose artifacts", expect.objectContaining({ modelId: "goose-gpt-5-5" }), + expect.objectContaining({ clear: expect.any(Function) }), ); }); await waitFor(() => { diff --git a/src/app/views/__tests__/NavigationPanesView.test.tsx b/src/app/views/__tests__/NavigationPanesView.test.tsx index d40ffed73..36a84cb16 100644 --- a/src/app/views/__tests__/NavigationPanesView.test.tsx +++ b/src/app/views/__tests__/NavigationPanesView.test.tsx @@ -315,6 +315,7 @@ vi.mock("@/features/chat/stores/chatStore", () => ({ })); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpSearchSessions: (...args: unknown[]) => mockAcpSearchSessions(...args), })); diff --git a/src/features/berdctl/__tests__/commands/commands.test.ts b/src/features/berdctl/__tests__/commands/commands.test.ts index 0ae4fa33d..fbbd203d1 100644 --- a/src/features/berdctl/__tests__/commands/commands.test.ts +++ b/src/features/berdctl/__tests__/commands/commands.test.ts @@ -74,6 +74,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpCreateSession: (...args: unknown[]) => mocks.acpCreateSession(...args), acpDuplicateSession: (...args: unknown[]) => mocks.acpDuplicateSession(...args), @@ -289,6 +290,9 @@ function seedModelCache(cacheKey: string, modelIds: string[]): void { providerId: cacheKey, models: modelIds.map((id) => ({ id, name: id })), fetchedAt: Date.now(), + // Simulate a successful live inventory response: proof is what keeps + // this cache entry from being treated as stale and re-fetched. + provenModelIds: modelIds, }); return { providers }; }); @@ -1006,6 +1010,7 @@ describe("sessions.send", () => { "codex-acp", "/resolved/cwd", { modelId: "gpt-6" }, + expect.objectContaining({ clear: expect.any(Function) }), ); expect(controller.openSession).not.toHaveBeenCalled(); @@ -1083,6 +1088,7 @@ describe("sessions.send", () => { "codex-acp", "/resolved/cwd", {}, + expect.objectContaining({ clear: expect.any(Function) }), ); expect(getPendingSessionWorkspaceActivation("session-1")).toBeNull(); }); @@ -1132,6 +1138,7 @@ describe("sessions.send", () => { "old-provider", "/resolved/cwd", { modelId: "old-model" }, + expect.objectContaining({ clear: expect.any(Function) }), ); await vi.waitFor(() => { expect(mocks.acpSendMessage).toHaveBeenCalledWith( diff --git a/src/features/berdctl/commands/runtime/sessionSend.test.ts b/src/features/berdctl/commands/runtime/sessionSend.test.ts index 864ca273e..f8066ef77 100644 --- a/src/features/berdctl/commands/runtime/sessionSend.test.ts +++ b/src/features/berdctl/commands/runtime/sessionSend.test.ts @@ -37,6 +37,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpGetSessionInfo: (...args: unknown[]) => mocks.acpGetSessionInfo(...args), acpLoadSession: (...args: unknown[]) => mocks.acpLoadSession(...args), acpPrepareSession: (...args: unknown[]) => { @@ -581,6 +582,7 @@ describe("sendPromptToExistingSessionInBackground", () => { "claude-acp", expect.any(String), { modelId: "claude-sonnet-4" }, + expect.objectContaining({ clear: expect.any(Function) }), ); expect(mocks.acpSendMessage).toHaveBeenCalledWith( SESSION_ID, @@ -656,6 +658,7 @@ describe("sendPromptToExistingSessionInBackground", () => { UPDATED_TARGET.modelProviderId, "/tmp/project", { modelId: UPDATED_TARGET.modelId }, + expect.objectContaining({ clear: expect.any(Function) }), ]); expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); expect( @@ -721,6 +724,7 @@ describe("sendPromptToExistingSessionInBackground", () => { INITIAL_TARGET.modelProviderId, "/tmp/project", { modelId: INITIAL_TARGET.modelId }, + expect.objectContaining({ clear: expect.any(Function) }), ]); expect(mocks.transportProviders).toEqual([INITIAL_TARGET.modelProviderId]); expect( @@ -866,6 +870,7 @@ describe("sendPromptToExistingSessionInBackground", () => { INITIAL_TARGET.modelProviderId, "/tmp/project", { modelId: INITIAL_TARGET.modelId }, + expect.objectContaining({ clear: expect.any(Function) }), ); expect(mocks.acpPrepareSession.mock.calls.at(-1)).toEqual([ SESSION_ID, @@ -875,6 +880,7 @@ describe("sendPromptToExistingSessionInBackground", () => { modelId: UPDATED_TARGET.modelId, requestId: "select-updated-during-prepare", }), + expect.objectContaining({ clear: expect.any(Function) }), ]); expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); expect(mocks.transportProviders).toEqual([INITIAL_TARGET.modelProviderId]); @@ -916,6 +922,7 @@ describe("sendPromptToExistingSessionInBackground", () => { INITIAL_TARGET.modelProviderId, "/tmp/project", { modelId: INITIAL_TARGET.modelId }, + expect.objectContaining({ clear: expect.any(Function) }), ]); expect(mocks.acpPrepareSession.mock.calls.at(-1)).toEqual([ SESSION_ID, @@ -925,6 +932,7 @@ describe("sendPromptToExistingSessionInBackground", () => { modelId: UPDATED_TARGET.modelId, requestId: "select-updated-during-cwd", }), + expect.objectContaining({ clear: expect.any(Function) }), ]); expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); expect(mocks.transportProviders).toEqual([INITIAL_TARGET.modelProviderId]); diff --git a/src/features/chat/hooks/__tests__/useChat.attachments.test.ts b/src/features/chat/hooks/__tests__/useChat.attachments.test.ts index 6dde38e17..6019c0904 100644 --- a/src/features/chat/hooks/__tests__/useChat.attachments.test.ts +++ b/src/features/chat/hooks/__tests__/useChat.attachments.test.ts @@ -9,6 +9,7 @@ const mockAcpCancelSession = vi.fn(); const mockAcpPrepareSession = vi.fn(); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpSendMessage: (...args: unknown[]) => { const result = mockAcpSendMessage(...args); const options = args[2] as diff --git a/src/features/chat/hooks/__tests__/useChat.compaction.test.ts b/src/features/chat/hooks/__tests__/useChat.compaction.test.ts index 03f1d53fc..451125154 100644 --- a/src/features/chat/hooks/__tests__/useChat.compaction.test.ts +++ b/src/features/chat/hooks/__tests__/useChat.compaction.test.ts @@ -12,6 +12,7 @@ const mockAcpSendMessage = vi.fn(); const mockAcpLoadSession = vi.fn(); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpSendMessage: (...args: unknown[]) => mockAcpSendMessage(...args), acpCancelSession: vi.fn(), acpLoadSession: (...args: unknown[]) => mockAcpLoadSession(...args), diff --git a/src/features/chat/hooks/__tests__/useChat.personaPreparation.test.ts b/src/features/chat/hooks/__tests__/useChat.personaPreparation.test.ts index 37e22ab1f..0c64c6f20 100644 --- a/src/features/chat/hooks/__tests__/useChat.personaPreparation.test.ts +++ b/src/features/chat/hooks/__tests__/useChat.personaPreparation.test.ts @@ -10,6 +10,7 @@ const mockAcpCancelSession = vi.fn(); const mockAcpLoadSession = vi.fn(); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpSendMessage: (...args: unknown[]) => { const result = mockAcpSendMessage(...args); const options = args[2] as diff --git a/src/features/chat/hooks/__tests__/useChat.skillChips.test.ts b/src/features/chat/hooks/__tests__/useChat.skillChips.test.ts index 6d506431f..3c8762c9a 100644 --- a/src/features/chat/hooks/__tests__/useChat.skillChips.test.ts +++ b/src/features/chat/hooks/__tests__/useChat.skillChips.test.ts @@ -10,6 +10,7 @@ const mockAcpLoadSession = vi.fn(); const mockAcpPrepareSession = vi.fn(); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpSendMessage: (...args: unknown[]) => { const result = mockAcpSendMessage(...args); const options = args[2] as diff --git a/src/features/chat/hooks/__tests__/useChat.test.ts b/src/features/chat/hooks/__tests__/useChat.test.ts index 3a7ebb280..d70bd0591 100644 --- a/src/features/chat/hooks/__tests__/useChat.test.ts +++ b/src/features/chat/hooks/__tests__/useChat.test.ts @@ -24,6 +24,7 @@ const mockAcpPrepareSession = vi.fn(); let mockAcpDispatches = true; vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpSendMessage: (...args: unknown[]) => { const result = mockAcpSendMessage(...args); const options = args[2] as diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.targetLeaseCompaction.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.targetLeaseCompaction.test.ts index 71afa9c07..e2984c356 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.targetLeaseCompaction.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.targetLeaseCompaction.test.ts @@ -31,6 +31,7 @@ function deferred() { } vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpPrepareSession: async (...args: unknown[]) => { const result = await mockAcpPrepareSession(...args); preparedProviderBySession.set(args[0] as string, args[1] as string); diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index 5e9195b2e..08bc549c8 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -115,6 +115,7 @@ function deferred() { } vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args), acpSetSessionConfigOption: (...args: unknown[]) => mockAcpSetSessionConfigOption(...args), @@ -326,6 +327,7 @@ function expectSessionPreparation({ ...(modelId ? { modelId } : {}), ...(forceConfigRefresh ? { forceConfigRefresh: true } : {}), }), + expect.objectContaining({ clear: expect.any(Function) }), ); } @@ -4811,6 +4813,7 @@ describe("useChatSessionController", () => { "openai", "/tmp/project", expect.objectContaining({ requestId: expect.any(String) }), + expect.objectContaining({ clear: expect.any(Function) }), ); }); diff --git a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts index 88be53e95..35321ad43 100644 --- a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts +++ b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts @@ -69,6 +69,7 @@ vi.mock("@/shared/api/acpConnection", () => ({ })); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpPrepareSession: (...args: unknown[]) => mockPrepareSession(...args), })); @@ -212,6 +213,7 @@ describe("useResolvedAgentModelPicker", () => { "openai", "/w", expect.objectContaining({ modelId: "next" }), + expect.objectContaining({ clear: expect.any(Function) }), ); }); @@ -470,6 +472,14 @@ describe("useResolvedAgentModelPicker", () => { providerId: "anthropic", }, ], + getProvenModelsForAgent: () => [ + { id: "gpt-5.4", name: "GPT-5.4", providerId: "openai" }, + { + id: "claude-sonnet-4", + name: "Claude Sonnet 4", + providerId: "anthropic", + }, + ], modelsLoading: false, modelStatusMessage: null, handleProviderChange: (providerId: string) => @@ -557,6 +567,19 @@ describe("useResolvedAgentModelPicker", () => { recommended: true, }, ], + getProvenModelsForAgent: () => [ + { + id: "gpt-5.4-mini", + name: "GPT Mini 5.4", + providerId: "codex-acp", + }, + { + id: "gpt-5.5", + name: "GPT 5.5", + providerId: "codex-acp", + recommended: true, + }, + ], modelsLoading: false, modelStatusMessage: null, handleProviderChange: vi.fn(), @@ -1203,7 +1226,14 @@ describe("useResolvedAgentModelPicker", () => { recommended: true, }, ], - getProvenModelsForAgent: () => [], + getProvenModelsForAgent: () => [ + { + id: "gpt-5.6", + name: "GPT-5.6", + providerId: "openai", + recommended: true, + }, + ], isModelInventoryAuthoritative: (providerId: string) => providerId === "openai", modelsLoading: false, diff --git a/src/features/chat/lib/__tests__/sessionActivation.test.ts b/src/features/chat/lib/__tests__/sessionActivation.test.ts index 08641cc9f..021ee4c01 100644 --- a/src/features/chat/lib/__tests__/sessionActivation.test.ts +++ b/src/features/chat/lib/__tests__/sessionActivation.test.ts @@ -37,6 +37,7 @@ const resolvePath = vi.hoisted(() => vi.fn()); const checkDirectoriesExist = vi.hoisted(() => vi.fn()); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpGetSessionInfo: (...args: unknown[]) => acpGetSessionInfo(...args), acpLoadSession: (...args: unknown[]) => acpLoadSession(...args), acpPrepareSession: (...args: unknown[]) => acpPrepareSession(...args), @@ -613,6 +614,7 @@ describe("loadSessionMessages", () => { "databricks_v2", "/resolved/existing/session", { modelId: "goose-gpt-5-6-sol" }, + expect.objectContaining({ clear: expect.any(Function) }), ); expect(acpLoadSession.mock.invocationCallOrder[0]).toBeLessThan( acpPrepareSession.mock.invocationCallOrder[0], diff --git a/src/features/chat/lib/__tests__/sessionWorkspaceCleanup.test.ts b/src/features/chat/lib/__tests__/sessionWorkspaceCleanup.test.ts index 8216387f1..0c0df1745 100644 --- a/src/features/chat/lib/__tests__/sessionWorkspaceCleanup.test.ts +++ b/src/features/chat/lib/__tests__/sessionWorkspaceCleanup.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpListSessionsPage: mocks.acpListSessionsPage, })); diff --git a/src/features/chat/lib/__tests__/steerCore.test.ts b/src/features/chat/lib/__tests__/steerCore.test.ts index 8f5602679..204458370 100644 --- a/src/features/chat/lib/__tests__/steerCore.test.ts +++ b/src/features/chat/lib/__tests__/steerCore.test.ts @@ -5,6 +5,7 @@ import { MAX_PROMPT_ATTACHMENT_BYTES } from "../attachmentPayloadBudget"; const mockAcpSteerMessage = vi.fn(); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpSteerMessage: (...args: unknown[]) => mockAcpSteerMessage(...args), })); diff --git a/src/features/chat/lib/sendCore.test.ts b/src/features/chat/lib/sendCore.test.ts index 167bf5476..2f7b47e57 100644 --- a/src/features/chat/lib/sendCore.test.ts +++ b/src/features/chat/lib/sendCore.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpSendMessage: (...args: unknown[]) => mocks.acpSendMessage(...args), })); diff --git a/src/features/chat/lib/sessionTargetCoordinator.test.ts b/src/features/chat/lib/sessionTargetCoordinator.test.ts index 93854799b..4d394e130 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.test.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.test.ts @@ -17,6 +17,7 @@ import { const mockPrepare = vi.fn(); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpPrepareSession: (...args: unknown[]) => mockPrepare(...args), })); @@ -126,9 +127,13 @@ describe("session target coordinator", () => { target: target("c"), }); expect(mockPrepare).toHaveBeenCalledTimes(1); - expect(mockPrepare).toHaveBeenCalledWith("s", "openai", "/w", { - modelId: "c", - }); + expect(mockPrepare).toHaveBeenCalledWith( + "s", + "openai", + "/w", + { modelId: "c" }, + expect.objectContaining({ clear: expect.any(Function) }), + ); }); it("prevents an on-wire stale operation from committing over the winner", async () => { @@ -405,10 +410,13 @@ describe("session target coordinator", () => { requireReasoningEffort: true, }); - expect(mockPrepare).toHaveBeenCalledWith("s", "openai", "/w", { - modelId: "a", - forceConfigRefresh: true, - }); + expect(mockPrepare).toHaveBeenCalledWith( + "s", + "openai", + "/w", + { modelId: "a", forceConfigRefresh: true }, + expect.objectContaining({ clear: expect.any(Function) }), + ); }); it("defers external hydration until the dispatch lease releases", () => { diff --git a/src/features/chat/lib/sessionTargetTransition.test.ts b/src/features/chat/lib/sessionTargetTransition.test.ts index 40c6c7d81..5bb1ae16d 100644 --- a/src/features/chat/lib/sessionTargetTransition.test.ts +++ b/src/features/chat/lib/sessionTargetTransition.test.ts @@ -5,6 +5,7 @@ import { resetSessionTargetCoordinatorsForTests } from "./sessionTargetCoordinat const mockAcpPrepareSession = vi.fn(); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpPrepareSession: (...args: unknown[]) => mockAcpPrepareSession(...args), })); @@ -39,6 +40,7 @@ describe("transitionSessionTarget", () => { "new-provider", "/new", { modelId: "new-model" }, + expect.objectContaining({ clear: expect.any(Function) }), ); }); @@ -65,6 +67,7 @@ describe("transitionSessionTarget", () => { forceConfigRefresh: true, requestId: "request-5-6", }, + expect.objectContaining({ clear: expect.any(Function) }), ); }); }); diff --git a/src/features/chat/stores/__tests__/chatSessionStore.test.ts b/src/features/chat/stores/__tests__/chatSessionStore.test.ts index 61c3842fb..6193eccaa 100644 --- a/src/features/chat/stores/__tests__/chatSessionStore.test.ts +++ b/src/features/chat/stores/__tests__/chatSessionStore.test.ts @@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpCreateSession: (...args: unknown[]) => mocks.acpCreateSession(...args), acpListSessionsPage: (...args: unknown[]) => mocks.acpListSessionsPage(...args), diff --git a/src/features/home/ui/HomeScreen.test.tsx b/src/features/home/ui/HomeScreen.test.tsx index bd56861a7..64ad3b1b5 100644 --- a/src/features/home/ui/HomeScreen.test.tsx +++ b/src/features/home/ui/HomeScreen.test.tsx @@ -127,6 +127,7 @@ vi.mock("@/features/chat/hooks/useMentionHandlers", () => ({ })); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), discoverAcpProviders: vi.fn().mockResolvedValue([ { id: "goose", label: "Goose" }, { id: "claude-acp", label: "Claude Code" }, diff --git a/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts b/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts index 543c0cd3e..930349a22 100644 --- a/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts +++ b/src/features/sessions/hooks/__tests__/useAutoArchiveSessions.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpGetSessionInfo: (...args: unknown[]) => mocks.getSessionInfo(...args), })); diff --git a/src/features/sessions/hooks/__tests__/useSessionSearch.test.ts b/src/features/sessions/hooks/__tests__/useSessionSearch.test.ts index 10d574d72..06d1943fb 100644 --- a/src/features/sessions/hooks/__tests__/useSessionSearch.test.ts +++ b/src/features/sessions/hooks/__tests__/useSessionSearch.test.ts @@ -47,6 +47,7 @@ function sweep( } vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpSearchSessions: (...args: unknown[]) => mockAcpSearchSessions(...args), })); diff --git a/src/features/sessions/ui/__tests__/SessionHistoryView.test.tsx b/src/features/sessions/ui/__tests__/SessionHistoryView.test.tsx index b07a6cd5d..be8035709 100644 --- a/src/features/sessions/ui/__tests__/SessionHistoryView.test.tsx +++ b/src/features/sessions/ui/__tests__/SessionHistoryView.test.tsx @@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpExportSession: (...args: unknown[]) => mocks.acpExportSession(...args), acpImportSession: (...args: unknown[]) => mocks.acpImportSession(...args), acpSearchSessions: (...args: unknown[]) => mocks.acpSearchSessions(...args), From 76ff7bed33c5fca1e170a628b83bf1a628bbf6a3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 18 Aug 2026 23:52:58 -0400 Subject: [PATCH 32/50] test: reconcile queued send ACP mock Signed-off-by: loganj --- src/features/chat/lib/queuedSessionSend.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/chat/lib/queuedSessionSend.test.ts b/src/features/chat/lib/queuedSessionSend.test.ts index bfdb2777b..f37d12394 100644 --- a/src/features/chat/lib/queuedSessionSend.test.ts +++ b/src/features/chat/lib/queuedSessionSend.test.ts @@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/shared/api/acp", () => ({ + reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), acpGetSessionInfo: (...args: unknown[]) => mocks.acpGetSessionInfo(...args), acpLoadSession: (...args: unknown[]) => mocks.acpLoadSession(...args), acpPrepareSession: (...args: unknown[]) => mocks.acpPrepareSession(...args), From 929c548d3d6e884c4c9681983bf64a60f3323a4e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 19 Aug 2026 20:50:06 +0000 Subject: [PATCH 33/50] fix: enforce immutable session configuration Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/app/lib/chatRuntimeStartup.test.ts | 13 +--- .../__tests__/personaExecutionTarget.test.ts | 34 ++++------ .../agents/lib/personaExecutionTarget.ts | 4 +- .../__tests__/commands/commands.test.ts | 10 +-- .../commands/runtime/sessionSend.test.ts | 12 ++-- .../lib/__tests__/sessionActivation.test.ts | 2 +- .../chat/lib/sessionTargetCoordinator.test.ts | 8 ++- .../chat/lib/sessionTargetCoordinator.ts | 21 +++--- ...essionTargetTransition.integration.test.ts | 20 ++++++ .../chat/lib/sessionTargetTransition.test.ts | 3 +- src/shared/api/acp.ts | 64 ++++++++++++------- src/shared/api/acpConnection.test.ts | 49 ++++++++++++-- src/shared/api/acpConnection.ts | 28 ++++++-- src/shared/ui/GlobalComposerPill.test.tsx | 48 ++++++-------- src/shared/ui/GlobalComposerPill.tsx | 7 +- 15 files changed, 207 insertions(+), 116 deletions(-) diff --git a/src/app/lib/chatRuntimeStartup.test.ts b/src/app/lib/chatRuntimeStartup.test.ts index c625ac5e4..e5b8dc1e2 100644 --- a/src/app/lib/chatRuntimeStartup.test.ts +++ b/src/app/lib/chatRuntimeStartup.test.ts @@ -273,7 +273,7 @@ describe("runChatRuntimeStartup", () => { expect(mockMigratePersonaTargetIfUnchanged).not.toHaveBeenCalled(); }); - it("migrates an authoritative unsupported persona model without losing its harness", async () => { + it("leaves an authoritative unsupported persona persisted for explicit repair", async () => { mockAgentState.providers = [{ id: "claude-acp", label: "Claude Code" }]; mockAgentState.personas = [ { @@ -298,16 +298,7 @@ describe("runChatRuntimeStartup", () => { const { runChatRuntimeStartup } = await import("./chatRuntimeStartup"); await runChatRuntimeStartup(); - await vi.waitFor(() => { - expect(mockMigratePersonaTargetIfUnchanged).toHaveBeenCalledWith( - expect.objectContaining({ id: "persona-1" }), - { - provider: "claude-acp", - modelProviderId: "claude-acp", - model: null, - }, - ); - }); + expect(mockMigratePersonaTargetIfUnchanged).not.toHaveBeenCalled(); }); it("stays latched after a successful run", async () => { diff --git a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts index e873a9624..fa3db52ba 100644 --- a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts +++ b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts @@ -70,15 +70,8 @@ describe("personaExecutionTarget", () => { [], ); - expect(personaExecutionTarget(persona, targetContext)).toEqual({ - harnessId: "goose", - modelProviderId: "openai", - }); - expect(personaTargetMigration(persona, targetContext)).toEqual({ - provider: "goose", - modelProviderId: "openai", - model: null, - }); + expect(personaExecutionTarget(persona, targetContext)).toBeUndefined(); + expect(personaTargetMigration(persona, targetContext)).toBeNull(); }); it("rejects an agent harness persisted as a Goose model provider", () => { @@ -128,8 +121,8 @@ describe("personaExecutionTarget", () => { persona: { provider: "goose", modelProviderId: "openai", model: "gpt-5" }, models: [], authoritativeProviderIds: ["openai"], - target: { harnessId: "goose", modelProviderId: "openai" }, - migration: { provider: "goose", modelProviderId: "openai", model: null }, + target: undefined, + migration: null, }, { name: "external harness with foreign provider and supported model", @@ -161,12 +154,8 @@ describe("personaExecutionTarget", () => { }, models: [], authoritativeProviderIds: ["claude-acp"], - target: { harnessId: "claude-acp", modelProviderId: "claude-acp" }, - migration: { - provider: "claude-acp", - modelProviderId: "claude-acp", - model: null, - }, + target: undefined, + migration: null, }, { name: "external harness with unavailable inventory", @@ -200,13 +189,14 @@ describe("personaExecutionTarget", () => { expect(personaExecutionTarget(persona, targetContext)).toEqual(target); expect(personaTargetMigration(persona, targetContext)).toEqual(migration); + const actualTarget = personaExecutionTarget(persona, targetContext); + if (!target) { + expect(gooseServeSelectionFromExecutionTarget(actualTarget)).toEqual({}); + return; + } const wireProviderId = target.harnessId === "goose" ? target.modelProviderId : target.harnessId; - expect( - gooseServeSelectionFromExecutionTarget( - personaExecutionTarget(persona, targetContext), - ), - ).toEqual({ + expect(gooseServeSelectionFromExecutionTarget(actualTarget)).toEqual({ providerId: wireProviderId, modelId: target.modelId, modelName: target.modelName, diff --git a/src/features/agents/lib/personaExecutionTarget.ts b/src/features/agents/lib/personaExecutionTarget.ts index 7e834d6f1..73b93c64b 100644 --- a/src/features/agents/lib/personaExecutionTarget.ts +++ b/src/features/agents/lib/personaExecutionTarget.ts @@ -190,7 +190,9 @@ export function personaExecutionTarget( isModelInventoryAuthoritative?.(modelProviderId ?? harnessId) ?? false; if (modelId && !provenModel && inventoryIsAuthoritative) { - return normalizeSessionExecutionTarget({ harnessId, modelProviderId }); + // Preserve the saved configuration for repair, but do not expose an + // incomplete execution target: agents require both provider and model. + return undefined; } return normalizeSessionExecutionTarget({ diff --git a/src/features/berdctl/__tests__/commands/commands.test.ts b/src/features/berdctl/__tests__/commands/commands.test.ts index fbbd203d1..b99b14dd8 100644 --- a/src/features/berdctl/__tests__/commands/commands.test.ts +++ b/src/features/berdctl/__tests__/commands/commands.test.ts @@ -844,7 +844,9 @@ describe("sessions.create", () => { expect(mocks.acpCreateSession).toHaveBeenCalledWith( "codex-acp", "/resolved/cwd", - expect.objectContaining({ modelId: "gpt-6" }), + expect.objectContaining({ + modelId: "gpt-6", + }), ); }); @@ -1009,7 +1011,7 @@ describe("sessions.send", () => { "session-1", "codex-acp", "/resolved/cwd", - { modelId: "gpt-6" }, + { modelId: "gpt-6", selectionAlreadyResolved: true }, expect.objectContaining({ clear: expect.any(Function) }), ); expect(controller.openSession).not.toHaveBeenCalled(); @@ -1087,7 +1089,7 @@ describe("sessions.send", () => { "session-1", "codex-acp", "/resolved/cwd", - {}, + { selectionAlreadyResolved: true }, expect.objectContaining({ clear: expect.any(Function) }), ); expect(getPendingSessionWorkspaceActivation("session-1")).toBeNull(); @@ -1137,7 +1139,7 @@ describe("sessions.send", () => { "session-1", "old-provider", "/resolved/cwd", - { modelId: "old-model" }, + { modelId: "old-model", selectionAlreadyResolved: true }, expect.objectContaining({ clear: expect.any(Function) }), ); await vi.waitFor(() => { diff --git a/src/features/berdctl/commands/runtime/sessionSend.test.ts b/src/features/berdctl/commands/runtime/sessionSend.test.ts index f8066ef77..99dcc5cc0 100644 --- a/src/features/berdctl/commands/runtime/sessionSend.test.ts +++ b/src/features/berdctl/commands/runtime/sessionSend.test.ts @@ -581,7 +581,7 @@ describe("sendPromptToExistingSessionInBackground", () => { SESSION_ID, "claude-acp", expect.any(String), - { modelId: "claude-sonnet-4" }, + { modelId: "claude-sonnet-4", selectionAlreadyResolved: true }, expect.objectContaining({ clear: expect.any(Function) }), ); expect(mocks.acpSendMessage).toHaveBeenCalledWith( @@ -657,7 +657,7 @@ describe("sendPromptToExistingSessionInBackground", () => { SESSION_ID, UPDATED_TARGET.modelProviderId, "/tmp/project", - { modelId: UPDATED_TARGET.modelId }, + { modelId: UPDATED_TARGET.modelId, selectionAlreadyResolved: true }, expect.objectContaining({ clear: expect.any(Function) }), ]); expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); @@ -723,7 +723,7 @@ describe("sendPromptToExistingSessionInBackground", () => { SESSION_ID, INITIAL_TARGET.modelProviderId, "/tmp/project", - { modelId: INITIAL_TARGET.modelId }, + { modelId: INITIAL_TARGET.modelId, selectionAlreadyResolved: true }, expect.objectContaining({ clear: expect.any(Function) }), ]); expect(mocks.transportProviders).toEqual([INITIAL_TARGET.modelProviderId]); @@ -869,7 +869,7 @@ describe("sendPromptToExistingSessionInBackground", () => { SESSION_ID, INITIAL_TARGET.modelProviderId, "/tmp/project", - { modelId: INITIAL_TARGET.modelId }, + { modelId: INITIAL_TARGET.modelId, selectionAlreadyResolved: true }, expect.objectContaining({ clear: expect.any(Function) }), ); expect(mocks.acpPrepareSession.mock.calls.at(-1)).toEqual([ @@ -878,6 +878,7 @@ describe("sendPromptToExistingSessionInBackground", () => { "/tmp/project", expect.objectContaining({ modelId: UPDATED_TARGET.modelId, + selectionAlreadyResolved: true, requestId: "select-updated-during-prepare", }), expect.objectContaining({ clear: expect.any(Function) }), @@ -921,7 +922,7 @@ describe("sendPromptToExistingSessionInBackground", () => { SESSION_ID, INITIAL_TARGET.modelProviderId, "/tmp/project", - { modelId: INITIAL_TARGET.modelId }, + { modelId: INITIAL_TARGET.modelId, selectionAlreadyResolved: true }, expect.objectContaining({ clear: expect.any(Function) }), ]); expect(mocks.acpPrepareSession.mock.calls.at(-1)).toEqual([ @@ -930,6 +931,7 @@ describe("sendPromptToExistingSessionInBackground", () => { "/tmp/project", expect.objectContaining({ modelId: UPDATED_TARGET.modelId, + selectionAlreadyResolved: true, requestId: "select-updated-during-cwd", }), expect.objectContaining({ clear: expect.any(Function) }), diff --git a/src/features/chat/lib/__tests__/sessionActivation.test.ts b/src/features/chat/lib/__tests__/sessionActivation.test.ts index 021ee4c01..7a112fe4d 100644 --- a/src/features/chat/lib/__tests__/sessionActivation.test.ts +++ b/src/features/chat/lib/__tests__/sessionActivation.test.ts @@ -613,7 +613,7 @@ describe("loadSessionMessages", () => { "s-selection-race", "databricks_v2", "/resolved/existing/session", - { modelId: "goose-gpt-5-6-sol" }, + { modelId: "goose-gpt-5-6-sol", selectionAlreadyResolved: true }, expect.objectContaining({ clear: expect.any(Function) }), ); expect(acpLoadSession.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/src/features/chat/lib/sessionTargetCoordinator.test.ts b/src/features/chat/lib/sessionTargetCoordinator.test.ts index 4d394e130..829ddd480 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.test.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.test.ts @@ -131,7 +131,7 @@ describe("session target coordinator", () => { "s", "openai", "/w", - { modelId: "c" }, + { modelId: "c", selectionAlreadyResolved: true }, expect.objectContaining({ clear: expect.any(Function) }), ); }); @@ -414,7 +414,11 @@ describe("session target coordinator", () => { "s", "openai", "/w", - { modelId: "a", forceConfigRefresh: true }, + { + modelId: "a", + forceConfigRefresh: true, + selectionAlreadyResolved: true, + }, expect.objectContaining({ clear: expect.any(Function) }), ); }); diff --git a/src/features/chat/lib/sessionTargetCoordinator.ts b/src/features/chat/lib/sessionTargetCoordinator.ts index 07526a715..7a4b8f891 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.ts @@ -329,9 +329,10 @@ async function execute( throw new Error("Session execution target requires a provider boundary."); } const forceConfigRefresh = - request.requireReasoningEffort && - !useChatSessionStore.getState().getSession(request.sessionId) - ?.reasoningEffort; + (request.requireReasoningEffort && + !useChatSessionStore.getState().getSession(request.sessionId) + ?.reasoningEffort) || + (request.target.modelId !== undefined && effective.modelId === undefined); const snapshot = await acpPrepareSession( request.sessionId, selection.providerId, @@ -339,6 +340,7 @@ async function execute( { ...(selection.modelId ? { modelId: selection.modelId } : {}), ...(forceConfigRefresh ? { forceConfigRefresh: true } : {}), + selectionAlreadyResolved: true, ...(request.operationId || request.requestId ? { requestId: operationId } : {}), @@ -361,11 +363,14 @@ async function execute( settleOperation(operation, { status: "session-missing", applied: false }); return; } - const acknowledged = - !effective.modelId && snapshot?.model - ? (materializeSessionExecutionModel(effective, snapshot.model) ?? - effective) - : effective; + // The ACP response is the acknowledgement of the configuration actually + // applied. Always reconcile from it: inventory can change between + // preflight resolution and preparation, and a provider reset can select a + // replacement model. + const acknowledged = snapshot?.model + ? (materializeSessionExecutionModel(effective, snapshot.model) ?? + effective) + : effective; const legacyIntent = actor.selection ? { requestId: actor.selection.operationId, diff --git a/src/features/chat/lib/sessionTargetTransition.integration.test.ts b/src/features/chat/lib/sessionTargetTransition.integration.test.ts index 440f5af73..a26166402 100644 --- a/src/features/chat/lib/sessionTargetTransition.integration.test.ts +++ b/src/features/chat/lib/sessionTargetTransition.integration.test.ts @@ -271,6 +271,11 @@ describe("transitionSessionTarget with managed Goose models", () => { "./sessionTargetCoordinator" ); + mockSetProvider.mockResolvedValueOnce({ + model: { modelId: "backend-fallback", modelName: "Backend fallback" }, + reasoningEffort: null, + }); + await expect( transitionSessionTarget({ sessionId: "no-default-session", @@ -287,18 +292,33 @@ describe("transitionSessionTarget with managed Goose models", () => { resolvedTarget: { harnessId: "goose", modelProviderId: "databricks_v2", + modelId: "backend-fallback", }, }); + expect(mockSetProvider).toHaveBeenCalledWith( + "no-default-session", + "databricks_v2", + { requestId: undefined }, + ); expect(mockSetModel).not.toHaveBeenCalled(); + expect(mockGetClient).toHaveBeenCalledTimes(1); expect( useChatSessionStore.getState().getSession("no-default-session"), ).toMatchObject({ executionTarget: { harnessId: "goose", modelProviderId: "databricks_v2", + modelId: "backend-fallback", }, }); + const { requireSessionInvocationSelection } = await import( + "@/shared/api/acpSessionRegistry" + ); + expect(requireSessionInvocationSelection("no-default-session")).toEqual({ + providerId: "databricks_v2", + modelId: "backend-fallback", + }); }); it("suppresses a concurrent load while migration proof is pending", async () => { diff --git a/src/features/chat/lib/sessionTargetTransition.test.ts b/src/features/chat/lib/sessionTargetTransition.test.ts index 5bb1ae16d..958c4c4ef 100644 --- a/src/features/chat/lib/sessionTargetTransition.test.ts +++ b/src/features/chat/lib/sessionTargetTransition.test.ts @@ -39,7 +39,7 @@ describe("transitionSessionTarget", () => { "session-latest", "new-provider", "/new", - { modelId: "new-model" }, + { modelId: "new-model", selectionAlreadyResolved: true }, expect.objectContaining({ clear: expect.any(Function) }), ); }); @@ -65,6 +65,7 @@ describe("transitionSessionTarget", () => { { modelId: "goose-gpt-5-6-sol", forceConfigRefresh: true, + selectionAlreadyResolved: true, requestId: "request-5-6", }, expect.objectContaining({ clear: expect.any(Function) }), diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index 9c5e4f19e..498f7b7ce 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -81,6 +81,8 @@ export interface AcpSessionConfigApplyOptions { modelId?: string | null; /** UI selection intent that owns any response snapshots. */ requestId?: string; + /** The coordinator already resolved this complete selection from inventory. */ + selectionAlreadyResolved?: boolean; } export type AcpSessionConfigurationIntent = @@ -403,6 +405,33 @@ async function resolveGooseSessionSelection( return managedSelection; } +/** Apply a caller-resolved session selection without performing another inventory read. */ +async function applyResolvedSessionSelection( + sessionId: string, + selection: { providerId: string; modelId?: string }, + workingDir: string, + options: AcpSessionConfigApplyOptions, + supersession: AcpSessionConfigurationIntent, +): Promise { + const applyResolvedModel = Boolean(options.modelId); + return applyResolvedModel && selection.modelId + ? sessionRegistry.configureSession( + sessionId, + selection.providerId, + workingDir, + selection.modelId, + options, + supersession, + ) + : sessionRegistry.prepareSession( + sessionId, + selection.providerId, + workingDir, + options, + supersession, + ); +} + /** Prepare or warm an ACP session ahead of the first prompt. */ export async function acpPrepareSession( sessionId: string, @@ -419,29 +448,20 @@ export async function acpPrepareSession( const supersession = intent ?? reserveAcpSessionConfiguration(sessionId); const ownsSupersession = intent === undefined; try { - const selection = await resolveGooseSessionSelection( - providerId, - options.modelId, + const resolvedModelId = normalizeConcreteModelId(options.modelId); + const selection = options.selectionAlreadyResolved + ? { + providerId, + ...(resolvedModelId ? { modelId: resolvedModelId } : {}), + } + : await resolveGooseSessionSelection(providerId, options.modelId); + const snapshots = await applyResolvedSessionSelection( + sessionId, + selection, + workingDir, + options, + supersession, ); - const applyResolvedModel = - Boolean(options.modelId) || selection.providerId !== providerId; - const snapshots = - applyResolvedModel && selection.modelId - ? await sessionRegistry.configureSession( - sessionId, - selection.providerId, - workingDir, - selection.modelId, - options, - supersession, - ) - : await sessionRegistry.prepareSession( - sessionId, - selection.providerId, - workingDir, - options, - supersession, - ); perfLog( `[perf:prepare] ${sid} acpPrepareSession done in ${(performance.now() - t0).toFixed(1)}ms`, ); diff --git a/src/shared/api/acpConnection.test.ts b/src/shared/api/acpConnection.test.ts index 27f59dc2d..830b8ece8 100644 --- a/src/shared/api/acpConnection.test.ts +++ b/src/shared/api/acpConnection.test.ts @@ -1,12 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { - const initializations: Array<() => void> = []; + const initializations: Array<{ + resolve: () => void; + reject: (error: Error) => void; + }> = []; const streams: Array<{ writable: { abort: ReturnType } }> = []; class MockGooseClient { closed = new Promise(() => {}); async initialize(): Promise { - await new Promise((resolve) => initializations.push(resolve)); + await new Promise((resolve, reject) => + initializations.push({ resolve, reject }), + ); } } return { initializations, streams, MockGooseClient }; @@ -41,11 +46,45 @@ describe("ACP connection lifecycle", () => { expect(mocks.streams[0]?.writable.abort).toHaveBeenCalledOnce(); const retry = connection.getClient(); await vi.waitFor(() => expect(mocks.initializations).toHaveLength(2)); - mocks.initializations[1]?.(); + mocks.initializations[1]?.resolve(); const client = await retry; - mocks.initializations[0]?.(); - await stale; + mocks.initializations[0]?.resolve(); + await expect(stale).rejects.toThrow( + "ACP connection initialization was superseded", + ); expect(connection.getClientSync()).toBe(client); expect(mocks.streams[1]?.writable.abort).not.toHaveBeenCalled(); }); + + it("rejects every waiter for a retired initialization", async () => { + const connection = await import("./acpConnection"); + const first = connection.getClient(); + const second = connection.getClient(); + await vi.waitFor(() => expect(mocks.initializations).toHaveLength(1)); + + await connection.invalidateClientConnection(); + mocks.initializations[0]?.resolve(); + + await expect(first).rejects.toThrow("initialization was superseded"); + await expect(second).rejects.toThrow("initialization was superseded"); + expect(mocks.streams[0]?.writable.abort).toHaveBeenCalledOnce(); + }); + + it("aborts a transport when initialization rejects and preserves the error", async () => { + const connection = await import("./acpConnection"); + const failed = connection.getClient(); + await vi.waitFor(() => expect(mocks.initializations).toHaveLength(1)); + + mocks.initializations[0]?.reject(new Error("handshake failed")); + + await expect(failed).rejects.toThrow("handshake failed"); + expect(mocks.streams[0]?.writable.abort).toHaveBeenCalledOnce(); + expect(connection.getClientSync()).toBeNull(); + + const retry = connection.getClient(); + await vi.waitFor(() => expect(mocks.initializations).toHaveLength(2)); + mocks.initializations[1]?.resolve(); + await expect(retry).resolves.toBeTruthy(); + expect(mocks.streams[1]?.writable.abort).not.toHaveBeenCalled(); + }); }); diff --git a/src/shared/api/acpConnection.ts b/src/shared/api/acpConnection.ts index 9205a24a6..23fbc406e 100644 --- a/src/shared/api/acpConnection.ts +++ b/src/shared/api/acpConnection.ts @@ -66,10 +66,17 @@ let connectionGeneration = 0; interface ConnectionAttempt { generation: number; stream: ReturnType | null; + streamAborted: boolean; } let currentAttempt: ConnectionAttempt | null = null; +async function abortAttemptStream(attempt: ConnectionAttempt): Promise { + if (!attempt.stream || attempt.streamAborted) return; + attempt.streamAborted = true; + await attempt.stream.writable.abort(); +} + function createClientCallbacks(): () => Client { return () => ({ requestPermission: async ( @@ -142,7 +149,9 @@ export async function invalidateClientConnection(): Promise { activeStream = null; resolvedClient = null; clientPromise = null; - if (stream) { + if (attempt) { + await abortAttemptStream(attempt); + } else if (stream) { await stream.writable.abort(); } } @@ -224,6 +233,7 @@ export async function getClient(): Promise { const attempt: ConnectionAttempt = { generation: connectionGeneration, stream: null, + streamAborted: false, }; currentAttempt = attempt; const initialization = initializeConnection(attempt) @@ -237,10 +247,20 @@ export async function getClient(): Promise { monitorConnection(client, stream, attempt); return client; } - await stream.writable.abort(); - return client; + await abortAttemptStream(attempt); + throw new Error( + "ACP connection initialization was superseded; retry the operation.", + ); }) - .catch((error) => { + .catch(async (error) => { + // initializeConnection may fail after opening the transport. Retire it + // before dropping the attempt while preserving the original failure. + await abortAttemptStream(attempt).catch((abortError) => { + console.warn( + "[acp] Failed to abort rejected connection attempt.", + abortError, + ); + }); if (currentAttempt === attempt) { currentAttempt = null; clientPromise = null; diff --git a/src/shared/ui/GlobalComposerPill.test.tsx b/src/shared/ui/GlobalComposerPill.test.tsx index cfedb3342..04d0c3881 100644 --- a/src/shared/ui/GlobalComposerPill.test.tsx +++ b/src/shared/ui/GlobalComposerPill.test.tsx @@ -783,7 +783,7 @@ describe("GlobalComposerPill", () => { }); }); - it("keeps the Composer target when a persona has no plausible target", async () => { + it("blocks a persona that has saved model metadata but no plausible target", async () => { const user = userEvent.setup(); useAgentStore.setState({ personas: [ @@ -802,11 +802,11 @@ describe("GlobalComposerPill", () => { }); await user.type(screen.getByRole("textbox"), "Hello"); - await user.click(screen.getByRole("button", { name: /send message/i })); - expectSent(onSend, "Hello", { - personaId: "persona-1", - }); + expect( + screen.getByRole("button", { name: /send message/i }), + ).toBeDisabled(); + expect(onSend).not.toHaveBeenCalled(); }); it("applies a legacy persona target when inventory arrives", async () => { @@ -888,18 +888,13 @@ describe("GlobalComposerPill", () => { }); await user.type(screen.getByRole("textbox"), "Hello"); - await user.click(screen.getByRole("button", { name: /send message/i })); - // The mock inventory is authoritative and does not contain this legacy - // model, so the new provider remains selected but its unsupported model - // must not reach the execution target. - expectSent(onSend, "Hello", { - executionTarget: { - harnessId: "goose", - modelProviderId: "databricks_v2", - }, - personaId: "persona-1", - }); + // The authoritative inventory disproves this persona's saved model, so it + // has no runnable target until the user explicitly selects a supported one. + expect( + screen.getByRole("button", { name: /send message/i }), + ).toBeDisabled(); + expect(onSend).not.toHaveBeenCalled(); }); it("does not synthesize an advisory model while inventory proof is unavailable", async () => { @@ -1215,19 +1210,15 @@ describe("GlobalComposerPill", () => { expect(screen.getByText("UX Critic")).toBeInTheDocument(); await user.type(screen.getByRole("textbox"), "Hello"); - await user.click(screen.getByRole("button", { name: /send message/i })); // `goose-default` is absent from the authoritative mock inventory, so the - // selected harness/provider is retained but the model cannot be dispatched. + // newly selected persona cannot dispatch through an implicit fallback. expect(screen.queryByText("GPT-5.5")).not.toBeInTheDocument(); expect(screen.getByText("Goose")).toBeInTheDocument(); - expectSent(onSend, "Hello", { - executionTarget: { - harnessId: "goose", - modelProviderId: "databricks_v2", - }, - personaId: "persona-2", - }); + expect( + screen.getByRole("button", { name: /send message/i }), + ).toBeDisabled(); + expect(onSend).not.toHaveBeenCalled(); }); it("keeps the selected provider/model after clearing the suggested persona", async () => { @@ -1255,11 +1246,10 @@ describe("GlobalComposerPill", () => { await user.type(screen.getByRole("textbox"), "Hello"); await user.click(screen.getByRole("button", { name: /send message/i })); - // Clearing the persona retains the selected external harness. Its model is - // absent from the authoritative inventory, so no stale provider/model - // metadata may be dispatched. + // The unsupported persona target never becomes a live selection, so + // clearing it retains the composer fallback without stale metadata. expectSent(onSend, "Hello", { - executionTarget: { harnessId: "claude-acp" }, + executionTarget: { harnessId: "goose" }, }); }); diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index 8063dd9d1..2f33eac7e 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -714,8 +714,13 @@ export function GlobalComposerPill({ selectedProviderForPicker, ], ); + const personaHasSavedExecutionTarget = Boolean( + selectedPersona?.provider || + selectedPersona?.modelProviderId || + selectedPersona?.model, + ); const effectiveExecutionTarget = - !personaSelectionOverridden && personaTarget + !personaSelectionOverridden && personaHasSavedExecutionTarget ? personaTarget : (localExecutionTarget ?? currentExecutionTarget ?? undefined); const canSend = From 857a3326c5678b6b59f4df103f18a2af177f5ea1 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 19 Aug 2026 21:30:20 +0000 Subject: [PATCH 34/50] fix: invalidate unsupported active session models Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../chat/hooks/useResolvedAgentModelPicker.ts | 8 +- .../stores/providerModelCacheStore.ts | 16 ++++ src/shared/api/__tests__/acp.test.ts | 41 ++++++++++ src/shared/api/acpSessionRegistry.ts | 11 +++ src/shared/ui/GlobalComposerPill.test.tsx | 81 +++++++++++++++++++ src/shared/ui/GlobalComposerPill.tsx | 24 +++++- 6 files changed, 177 insertions(+), 4 deletions(-) diff --git a/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/src/features/chat/hooks/useResolvedAgentModelPicker.ts index 3a0e2b9ac..85a4bb808 100644 --- a/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -775,7 +775,11 @@ export function useResolvedAgentModelPicker({ }; } - if (isModelAlias(executionTarget.modelId)) { + if ( + isModelAlias(executionTarget.modelId) || + (executionTarget.modelProviderId && + isModelInventoryAuthoritative(executionTarget.modelProviderId)) + ) { return null; } @@ -785,7 +789,7 @@ export function useResolvedAgentModelPicker({ modelProviderId: executionTarget.modelProviderId, source: "explicit", }; - }, [availableModels, session]); + }, [availableModels, isModelInventoryAuthoritative, session]); const availableDefaultModelSelection = useMemo(() => { diff --git a/src/features/providers/stores/providerModelCacheStore.ts b/src/features/providers/stores/providerModelCacheStore.ts index f1179e857..5e4145b7b 100644 --- a/src/features/providers/stores/providerModelCacheStore.ts +++ b/src/features/providers/stores/providerModelCacheStore.ts @@ -161,6 +161,22 @@ export function isCachedModelInventoryAuthoritative( return entry != null && Array.isArray(entry.provenModelIds); } +/** + * Return whether cached inventory still permits a concrete provider/model pair. + * Missing or provisional inventory cannot disprove a prepared selection; a + * successful authoritative response can. + */ +export function isModelSelectionAllowedByCachedInventory( + providerId: string, + modelId: string, +): boolean { + const entry = useProviderModelCacheStore.getState().providers.get(providerId); + if (!isCachedModelInventoryAuthoritative(entry)) { + return true; + } + return entry?.provenModelIds?.includes(modelId) === true; +} + function isStale(entry: CachedProviderModels | undefined): boolean { if (!entry || !isCachedModelInventoryAuthoritative(entry)) { return true; diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index 89e4606a8..b007d7262 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -209,6 +209,47 @@ describe("acpSendMessage", () => { expect(mockPrompt).not.toHaveBeenCalled(); }); + it("blocks a prepared model disproved by cached authoritative inventory without network I/O", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const { useProviderModelCacheStore } = await import( + "@/features/providers/stores/providerModelCacheStore" + ); + const sessionRegistry = await import("../acpSessionRegistry"); + const { acpSendMessage } = await import("../acp"); + sessionRegistry.registerPreparedSession( + "acp-session-invalidated-model", + "databricks_v2", + "/tmp/project", + "removed-model", + ); + useProviderModelCacheStore.setState({ + providers: new Map([ + [ + "databricks_v2", + { + providerId: "databricks_v2", + models: [ + { + id: "supported-model", + name: "Supported model", + providerId: "databricks_v2", + }, + ], + provenModelIds: ["supported-model"], + fetchedAt: Date.now(), + }, + ], + ]), + }); + + await expect( + acpSendMessage("acp-session-invalidated-model", "hello"), + ).rejects.toThrow("removed-model is no longer supported"); + + expect(mockSupportedModelsList).not.toHaveBeenCalled(); + expect(mockPrompt).not.toHaveBeenCalled(); + }); + it("admits a managed-provider prompt without reading live model inventory", async () => { await setRuntimeConfig(managedRuntimeConfig); const sessionRegistry = await import("../acpSessionRegistry"); diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 3de7d549c..50caa88a7 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -11,6 +11,7 @@ import { shortLogId, } from "@/shared/lib/reasoningEffortDiagnostics"; import { normalizeConcreteModelId } from "@/shared/lib/modelIdentity"; +import { isModelSelectionAllowedByCachedInventory } from "@/features/providers/stores/providerModelCacheStore"; export interface AcpSessionExecutionSelection { providerId: string; @@ -443,6 +444,16 @@ export function requireSessionInvocationSelection( "Session requires a configured provider and model before prompting. Re-prepare the session after completing provider setup.", ); } + if ( + !isModelSelectionAllowedByCachedInventory( + selection.providerId, + selection.modelId, + ) + ) { + throw new Error( + `Session model ${selection.modelId} is no longer supported by provider ${selection.providerId}. Re-prepare the session before prompting.`, + ); + } return { ...selection, modelId: selection.modelId }; } diff --git a/src/shared/ui/GlobalComposerPill.test.tsx b/src/shared/ui/GlobalComposerPill.test.tsx index 04d0c3881..7fe647e12 100644 --- a/src/shared/ui/GlobalComposerPill.test.tsx +++ b/src/shared/ui/GlobalComposerPill.test.tsx @@ -897,6 +897,69 @@ describe("GlobalComposerPill", () => { expect(onSend).not.toHaveBeenCalled(); }); + it("fails closed when authoritative inventory invalidates the active session model", async () => { + const user = userEvent.setup(); + const activeTarget = { + harnessId: "goose", + modelProviderId: "databricks_v2", + modelId: "model-a", + modelName: "Model A", + }; + mockGetModelsForAgent.mockReturnValue([ + { + id: "model-a", + name: "Model A", + providerId: "databricks_v2", + }, + ]); + mockGetProvenModelsForAgent.mockReturnValue([ + { + id: "model-a", + name: "Model A", + providerId: "databricks_v2", + }, + ]); + const onSend = vi.fn(); + const { rerender } = render( + , + ); + + await user.type(screen.getByRole("textbox"), "Hello"); + expect(screen.getByRole("button", { name: /send message/i })).toBeEnabled(); + expect(screen.getByText("Model A")).toBeInTheDocument(); + + // A successful refresh is now authoritative and excludes model A. + mockGetModelsForAgent.mockReturnValue([ + { + id: "model-b", + name: "Model B", + providerId: "databricks_v2", + }, + ]); + mockGetProvenModelsForAgent.mockReturnValue([ + { + id: "model-b", + name: "Model B", + providerId: "databricks_v2", + }, + ]); + rerender( + , + ); + + expect(screen.queryByText("Model A")).not.toBeInTheDocument(); + const send = screen.getByRole("button", { name: /send message/i }); + expect(send).toBeDisabled(); + await user.click(send); + expect(onSend).not.toHaveBeenCalled(); + }); + it("does not synthesize an advisory model while inventory proof is unavailable", async () => { const user = userEvent.setup(); mockProviderModelsState.inventoryAuthoritative = false; @@ -1626,6 +1689,13 @@ describe("GlobalComposerPill", () => { it("expands with the controlled Home model", async () => { const user = userEvent.setup(); const onExpand = vi.fn().mockResolvedValue(true); + mockGetModelsForAgent.mockReturnValue([ + { + id: "goose-claude-fable", + name: "Claude Fable", + providerId: "anthropic", + }, + ]); renderGlobalComposer(vi.fn(), { onExpand, currentExecutionTarget: { @@ -1754,6 +1824,17 @@ describe("GlobalComposerPill", () => { it("uses a controlled external harness when the global provider differs", async () => { const user = userEvent.setup(); + mockGetModelsForAgent.mockImplementation((agentId: string) => + agentId === "claude-acp" + ? [ + { + id: "claude-opus-4-1", + name: "Claude Opus 4.1", + providerId: "claude-acp", + }, + ] + : [], + ); const onSend = renderGlobalComposer(vi.fn(), { currentExecutionTarget: { harnessId: "claude-acp", diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index 2f33eac7e..b87e03e28 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -668,13 +668,29 @@ export function GlobalComposerPill({ if (!currentExecutionTarget?.modelId) { return null; } + const modelProviderId = currentExecutionTarget.modelProviderId; + if ( + modelProviderId && + isModelInventoryAuthoritative(modelProviderId) && + !getProvenModelsForAgent(currentExecutionTarget.harnessId).some( + (model) => + model.id === currentExecutionTarget.modelId && + (!model.providerId || model.providerId === modelProviderId), + ) + ) { + return null; + } return { modelProviderId: currentExecutionTarget.modelProviderId, modelId: currentExecutionTarget.modelId, modelName: currentExecutionTarget.modelName, }; - }, [currentExecutionTarget]); + }, [ + currentExecutionTarget, + getProvenModelsForAgent, + isModelInventoryAuthoritative, + ]); const hasLocalExecutionOverride = providerOverride !== null || modelOverride !== null; const personaSelectionOverridden = @@ -719,10 +735,14 @@ export function GlobalComposerPill({ selectedPersona?.modelProviderId || selectedPersona?.model, ); + const controlledTargetInvalidated = + currentExecutionTarget?.modelId != null && controlledModelSelection == null; const effectiveExecutionTarget = !personaSelectionOverridden && personaHasSavedExecutionTarget ? personaTarget - : (localExecutionTarget ?? currentExecutionTarget ?? undefined); + : (localExecutionTarget ?? + (controlledTargetInvalidated ? undefined : currentExecutionTarget) ?? + undefined); const canSend = hasSendableContent && Boolean(effectiveExecutionTarget) && From 17f804a72271519992141f1f504d252d6100e1b8 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 19 Aug 2026 21:55:06 +0000 Subject: [PATCH 35/50] fix: preserve authoritative session configuration Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj --- ...essionTargetTransition.integration.test.ts | 62 +++++++++++++++++++ src/shared/api/__tests__/acp.test.ts | 16 ++++- src/shared/api/acp.ts | 42 ++++++++----- src/shared/api/acpSessionRegistry.ts | 53 +++++++++++++++- .../modelProviderPolicy.test.ts | 39 +++++++++++- .../runtime-config/modelProviderPolicy.ts | 23 ++----- 6 files changed, 193 insertions(+), 42 deletions(-) diff --git a/src/features/chat/lib/sessionTargetTransition.integration.test.ts b/src/features/chat/lib/sessionTargetTransition.integration.test.ts index a26166402..3518d0933 100644 --- a/src/features/chat/lib/sessionTargetTransition.integration.test.ts +++ b/src/features/chat/lib/sessionTargetTransition.integration.test.ts @@ -375,6 +375,68 @@ describe("transitionSessionTarget with managed Goose models", () => { expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); }); + it("materializes the prepared model for a matching provider-only transition", async () => { + const sessionId = "provider-only-prepared-session"; + const { useChatSessionStore } = await import( + "@/features/chat/stores/chatSessionStore" + ); + useChatSessionStore.setState({ + sessions: [ + { + id: sessionId, + title: "Prepared session", + executionTarget: { + harnessId: "goose", + modelProviderId: "databricks_v2", + }, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + messageCount: 1, + }, + ], + }); + const registry = await import("@/shared/api/acpSessionRegistry"); + registry.registerPreparedSession( + sessionId, + "databricks_v2", + "/tmp/project", + "goose-gpt-5-5", + ); + const { transitionSessionTarget } = await import( + "./sessionTargetCoordinator" + ); + + await expect( + transitionSessionTarget({ + sessionId, + target: { + harnessId: "goose", + modelProviderId: "databricks_v2", + }, + workingDir: "/tmp/project", + }), + ).resolves.toMatchObject({ + status: "committed", + target: { + modelProviderId: "databricks_v2", + modelId: "goose-gpt-5-5", + }, + }); + + expect(mockSetProvider).not.toHaveBeenCalled(); + expect(mockSetModel).not.toHaveBeenCalled(); + expect( + useChatSessionStore.getState().getSession(sessionId)?.executionTarget, + ).toMatchObject({ + modelProviderId: "databricks_v2", + modelId: "goose-gpt-5-5", + }); + expect(registry.requireSessionInvocationSelection(sessionId)).toEqual({ + providerId: "databricks_v2", + modelId: "goose-gpt-5-5", + }); + }); + it("finishes on the explicitly selected model instead of the managed default", async () => { const { transitionSessionTarget } = await import( "./sessionTargetCoordinator" diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index b007d7262..cb7e35791 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -891,7 +891,7 @@ describe("acpLoadSession", () => { ); }); - it("does not publish a load superseded by a rejected preflight", async () => { + it("publishes a deferred authoritative load when preflight rejects before mutation", async () => { await setRuntimeConfig(managedRuntimeConfig); const loadResponse = deferred>(); const supportedModels = deferred<{ models: string[] }>(); @@ -927,9 +927,21 @@ describe("acpLoadSession", () => { ); await Promise.resolve(); - expect(applyModelConfigSnapshot).not.toHaveBeenCalled(); + expect(applyModelConfigSnapshot).toHaveBeenCalledWith( + "acp-session-load-before-rejected-preflight", + { modelId: "other-model", modelName: "other-model" }, + { origin: "response" }, + ); expect(mockSetProvider).not.toHaveBeenCalled(); expect(mockSetModel).not.toHaveBeenCalled(); + const { requireSessionInvocationSelection } = await import( + "../acpSessionRegistry" + ); + expect( + requireSessionInvocationSelection( + "acp-session-load-before-rejected-preflight", + ), + ).toEqual({ providerId: "other-managed", modelId: "other-model" }); }); it.each([ diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index 498f7b7ce..ee9c27fc5 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -628,29 +628,37 @@ export async function acpLoadSession( sessionId: shortLogId(sessionId), }); perfLog(`[perf:load] ${sid} acpLoadSession → client.loadSession`); - const { response, isCurrent, executionSelection } = + const { response, isCurrent, deferredCurrent, executionSelection } = await sessionRegistry.loadSession(sessionId, effectiveWorkingDir); + const publish = () => { + const snapshots = readSessionConfigOptionsSnapshots(response); + logReasoningEffortInfo("acpLoadSession response", { + sessionId: shortLogId(sessionId), + hasReasoningEffortSnapshot: Boolean(snapshots.reasoningEffort), + ...reasoningEffortConfigLogFields( + "reasoningEffort", + snapshots.reasoningEffort, + ), + }); + applySessionConfigOptionsSnapshot(sessionId, response, { + origin: "response", + }); + perfLog( + `[perf:load] ${sid} client.loadSession resolved in ${(performance.now() - t0).toFixed(1)}ms`, + ); + }; if (!isCurrent) { + if (deferredCurrent) { + void deferredCurrent.then((becameCurrent) => { + if (becameCurrent) publish(); + }); + } perfLog( - `[perf:load] ${sid} dropped superseded load snapshot in ${(performance.now() - t0).toFixed(1)}ms`, + `[perf:load] ${sid} deferred or dropped superseded load snapshot in ${(performance.now() - t0).toFixed(1)}ms`, ); return undefined; } - const snapshots = readSessionConfigOptionsSnapshots(response); - logReasoningEffortInfo("acpLoadSession response", { - sessionId: shortLogId(sessionId), - hasReasoningEffortSnapshot: Boolean(snapshots.reasoningEffort), - ...reasoningEffortConfigLogFields( - "reasoningEffort", - snapshots.reasoningEffort, - ), - }); - applySessionConfigOptionsSnapshot(sessionId, response, { - origin: "response", - }); - perfLog( - `[perf:load] ${sid} client.loadSession resolved in ${(performance.now() - t0).toFixed(1)}ms`, - ); + publish(); return executionSelection; } diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 50caa88a7..f17c83984 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -35,7 +35,12 @@ interface SessionMutationQueue { latestSequence: number; tail: Promise; /** Configuration intent awaiting async preflight before it can enqueue. */ - pendingSupersession?: { sequence: number }; + pendingSupersession?: { + sequence: number; + previousSequence: number; + settled: Promise; + resolve: () => void; + }; } /** Opaque ownership of a configuration intent that is awaiting preflight. */ @@ -172,7 +177,9 @@ function consumeSessionSupersession( if (queue?.pendingSupersession?.sequence !== supersession.sequence) { return false; } + const pending = queue.pendingSupersession; queue.pendingSupersession = undefined; + pending.resolve(); scheduleQueueCleanup(sessionId, queue); return true; } @@ -188,15 +195,32 @@ export function supersedeSessionMutation( // Retain preflight intent before its authoritative I/O completes so a load // cannot publish a snapshot that predates the requested configuration. const sequence = nextMutationSequence++; + const previousSequence = + queue.pendingSupersession?.previousSequence ?? queue.latestSequence; + queue.pendingSupersession?.resolve(); + let resolveSettled!: () => void; + const settled = new Promise((resolve) => { + resolveSettled = resolve; + }); queue.latestSequence = sequence; - queue.pendingSupersession = { sequence }; + queue.pendingSupersession = { + sequence, + previousSequence, + settled, + resolve: resolveSettled, + }; scheduleQueueCleanup(sessionId, queue); return { sequence, clear() { if (queue?.pendingSupersession?.sequence !== sequence) return; + const pending = queue.pendingSupersession; queue.pendingSupersession = undefined; + if (queue.latestSequence === sequence) { + queue.latestSequence = pending.previousSequence; + } + pending.resolve(); scheduleQueueCleanup(sessionId, queue); }, }; @@ -268,6 +292,15 @@ async function prepareSessionNow( perfLog( `[perf:prepare] ${sid} reuse existing session (updates=${changed}) in ${(performance.now() - tReuse).toFixed(1)}ms`, ); + if (!snapshots && existing.executionSelection?.modelId) { + return { + model: { + modelId: existing.executionSelection.modelId, + modelName: existing.executionSelection.modelId, + }, + reasoningEffort: null, + }; + } return snapshots; } @@ -475,13 +508,26 @@ export async function loadSession( ): Promise<{ response: Awaited>; isCurrent: boolean; + deferredCurrent?: Promise; executionSelection?: AcpSessionExecutionSelection; }> { return serializeSessionMutation( sessionId, - async (isLatest) => { + async (isLatest, _sequence, queue) => { const response = await acpApi.loadSession(sessionId, workingDir); + const pendingAtResponse = queue.pendingSupersession; const isCurrentResult = isLatest(); + const deferredCurrent = pendingAtResponse + ? (async () => { + let pending: SessionMutationQueue["pendingSupersession"] = + pendingAtResponse; + while (pending) { + await pending.settled; + pending = queue.pendingSupersession; + } + return isLatest(); + })() + : undefined; const executionSnapshot = readSessionExecutionConfigSnapshot(response); prepared.set(sessionId, { workingDir, @@ -490,6 +536,7 @@ export async function loadSession( return { response, isCurrent: isCurrentResult, + ...(deferredCurrent ? { deferredCurrent } : {}), executionSelection: executionSnapshot ?? undefined, }; }, diff --git a/src/shared/runtime-config/modelProviderPolicy.test.ts b/src/shared/runtime-config/modelProviderPolicy.test.ts index e467d8936..6e937e21c 100644 --- a/src/shared/runtime-config/modelProviderPolicy.test.ts +++ b/src/shared/runtime-config/modelProviderPolicy.test.ts @@ -15,6 +15,14 @@ vi.mock("@/shared/api/acpConnection", () => ({ invalidateClientConnection: () => mockInvalidateClientConnection(), })); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + const managedConfig: RuntimeConfig = { schemaVersion: 1, goose: { @@ -267,10 +275,39 @@ describe("resolveManagedGooseProviderSelection", () => { ); await vi.advanceTimersByTimeAsync(60_000); await rejectedMigration; + expect(mockInvalidateClientConnection).not.toHaveBeenCalled(); resolveInventory({ models: ["goose-gpt-5-5"] }); await Promise.resolve(); }); + it("does not abort a concurrent prompt when inventory proof times out", async () => { + vi.useFakeTimers(); + const prompt = deferred(); + mockSupportedModelsList.mockReturnValue(new Promise(() => {})); + mockGetClient.mockResolvedValue({ + goose: { + GooseUnstableProvidersSupportedModelsList: mockSupportedModelsList, + prompt: () => prompt.promise, + }, + }); + + const client = await mockGetClient(); + const activePrompt = client.goose.prompt(); + const migration = resolveValidatedManagedGooseProviderSelection( + managedConfig, + { providerId: "disallowed" }, + ); + const rejectedMigration = expect(migration).rejects.toThrow( + "Cannot verify models for migrated provider databricks_v2", + ); + await vi.advanceTimersByTimeAsync(60_000); + await rejectedMigration; + + expect(mockInvalidateClientConnection).not.toHaveBeenCalled(); + prompt.resolve("complete"); + await expect(activePrompt).resolves.toBe("complete"); + }); + it("times out stalled ACP client acquisition", async () => { vi.useFakeTimers(); mockGetClient.mockReturnValue(new Promise(() => {})); @@ -284,7 +321,7 @@ describe("resolveManagedGooseProviderSelection", () => { ); await vi.advanceTimersByTimeAsync(60_000); await rejectedMigration; - expect(mockInvalidateClientConnection).toHaveBeenCalledOnce(); + expect(mockInvalidateClientConnection).not.toHaveBeenCalled(); expect(mockSupportedModelsList).not.toHaveBeenCalled(); }); diff --git a/src/shared/runtime-config/modelProviderPolicy.ts b/src/shared/runtime-config/modelProviderPolicy.ts index 093fb08ad..c29ccb5e0 100644 --- a/src/shared/runtime-config/modelProviderPolicy.ts +++ b/src/shared/runtime-config/modelProviderPolicy.ts @@ -1,9 +1,6 @@ import type { RuntimeConfig, RuntimeGooseConfig } from "./schema"; import { normalizeConcreteModelId } from "@/shared/lib/modelIdentity"; -import { - getClient, - invalidateClientConnection, -} from "@/shared/api/acpConnection"; +import { getClient } from "@/shared/api/acpConnection"; import { providerModelInventoryGeneration } from "./providerModelInventoryInvalidation"; export interface GooseProviderSelection { @@ -96,16 +93,15 @@ export function resolveManagedGooseProviderSelection( /** * Read a provider's live model inventory as authoritative evidence for managed * configuration decisions. Both ACP acquisition and the inventory RPC share - * one deadline; a timeout invalidates the connection so a later proof starts - * from fresh state. Results from an invalidated inventory generation are never - * accepted. + * one deadline. A timed-out proof is abandoned without invalidating the shared + * ACP transport, so unrelated active prompts remain intact. Results from an + * invalidated inventory generation are never accepted. */ export async function readBoundedProvenModelInventory( providerId: string, ): Promise> { const generationAtStart = providerModelInventoryGeneration(providerId); let timeoutId: ReturnType | undefined; - let didTimeOut = false; try { const response = await Promise.race([ getClient().then((client) => @@ -113,7 +109,6 @@ export async function readBoundedProvenModelInventory( ), new Promise((_, reject) => { timeoutId = setTimeout(() => { - didTimeOut = true; reject( new Error(`Timed out proving models for provider ${providerId}.`), ); @@ -126,16 +121,6 @@ export async function readBoundedProvenModelInventory( ); } return new Set(response.models as string[]); - } catch (error) { - if (didTimeOut) { - await invalidateClientConnection().catch((invalidationError) => { - console.error( - "Failed to invalidate timed-out ACP connection:", - invalidationError, - ); - }); - } - throw error; } finally { if (timeoutId !== undefined) clearTimeout(timeoutId); } From 6d51a21633b08531d7c4081c7bf7e1694b469b21 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 19 Aug 2026 22:51:32 +0000 Subject: [PATCH 36/50] fix: preserve model inventory proof on refresh failure Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../stores/providerModelCacheStore.test.ts | 44 ++++++++++++++++ .../stores/providerModelCacheStore.ts | 6 +++ src/shared/api/__tests__/acp.test.ts | 51 +++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/src/features/providers/stores/providerModelCacheStore.test.ts b/src/features/providers/stores/providerModelCacheStore.test.ts index 86f5e5cf1..a07199c46 100644 --- a/src/features/providers/stores/providerModelCacheStore.test.ts +++ b/src/features/providers/stores/providerModelCacheStore.test.ts @@ -273,6 +273,50 @@ describe("providerModelCacheStore", () => { ).toEqual(["goose-gpt-5-5", "goose-gpt-5-6-sol"]); }); + it.each([ + { provenModelIds: ["supported-model"], expected: ["supported-model"] }, + { provenModelIds: [], expected: [] }, + ])("preserves authoritative proof and runtime policy after refresh failure: $provenModelIds", async ({ + provenModelIds, + expected, + }) => { + const model = seededModel({ id: "configured-model" }); + useProviderModelCacheStore.setState({ + providers: new Map([ + [ + "databricks_v2", + { + providerId: "databricks_v2", + models: [model], + configuredModels: [model], + provenModelIds, + fetchedAt: 123, + runtimeManaged: true, + }, + ], + ]), + runtimeManagedProviderIds: new Set(["databricks_v2"]), + }); + mocks.supportedModelsList.mockRejectedValueOnce(new Error("offline")); + + await useProviderModelCacheStore + .getState() + .refreshProviderModels("databricks_v2", { force: true }); + + const entry = useProviderModelCacheStore + .getState() + .providers.get("databricks_v2"); + expect(entry?.provenModelIds).toEqual(expected); + expect(entry?.runtimeManaged).toBe(true); + expect(entry?.configuredModels).toEqual([model]); + expect(entry?.fetchedAt).toBe(123); + expect( + useProviderModelCacheStore + .getState() + .isModelInventoryAuthoritative("databricks_v2"), + ).toBe(true); + }); + it("removes stale runtime-managed providers when runtime config changes", () => { const model = seededModel(); diff --git a/src/features/providers/stores/providerModelCacheStore.ts b/src/features/providers/stores/providerModelCacheStore.ts index 5e4145b7b..5590b3b45 100644 --- a/src/features/providers/stores/providerModelCacheStore.ts +++ b/src/features/providers/stores/providerModelCacheStore.ts @@ -345,6 +345,12 @@ export const useProviderModelCacheStore = create( providerId, models: existing?.models ?? [], fetchedAt: existing?.fetchedAt ?? 0, + ...(existing?.provenModelIds + ? { provenModelIds: existing.provenModelIds } + : isCachedModelInventoryAuthoritative(existing) + ? { provenModelIds: [] } + : {}), + ...(existing?.runtimeManaged ? { runtimeManaged: true } : {}), ...(existing?.configuredModels ? { configuredModels: existing.configuredModels } : {}), diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index cb7e35791..3a1041425 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -250,6 +250,57 @@ describe("acpSendMessage", () => { expect(mockPrompt).not.toHaveBeenCalled(); }); + it("keeps a disproved prepared model blocked after a failed forced refresh", async () => { + await setRuntimeConfig(managedRuntimeConfig); + const { useProviderModelCacheStore } = await import( + "@/features/providers/stores/providerModelCacheStore" + ); + const sessionRegistry = await import("../acpSessionRegistry"); + const { acpSendMessage } = await import("../acp"); + sessionRegistry.registerPreparedSession( + "acp-session-model-disproved-before-refresh-failure", + "databricks_v2", + "/tmp/project", + "removed-model", + ); + mockSupportedModelsList.mockResolvedValueOnce({ + models: ["supported-model"], + }); + await useProviderModelCacheStore + .getState() + .refreshProviderModels("databricks_v2", { force: true }); + + await expect( + acpSendMessage( + "acp-session-model-disproved-before-refresh-failure", + "before failure", + ), + ).rejects.toThrow("removed-model is no longer supported"); + + mockSupportedModelsList.mockRejectedValueOnce(new Error("offline")); + await useProviderModelCacheStore + .getState() + .refreshProviderModels("databricks_v2", { force: true }); + const inventoryCallsBeforeSend = mockSupportedModelsList.mock.calls.length; + + await expect( + acpSendMessage( + "acp-session-model-disproved-before-refresh-failure", + "after failure", + ), + ).rejects.toThrow("removed-model is no longer supported"); + + expect(mockSupportedModelsList).toHaveBeenCalledTimes( + inventoryCallsBeforeSend, + ); + expect(mockPrompt).not.toHaveBeenCalled(); + expect( + useProviderModelCacheStore.getState().providers.get("databricks_v2") + ?.provenModelIds, + ).toEqual(["supported-model"]); + useProviderModelCacheStore.getState().invalidateProvider("databricks_v2"); + }); + it("admits a managed-provider prompt without reading live model inventory", async () => { await setRuntimeConfig(managedRuntimeConfig); const sessionRegistry = await import("../acpSessionRegistry"); From 841a8795ed520466713d0138ad7df84985f5e8f6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 19 Aug 2026 23:34:21 +0000 Subject: [PATCH 37/50] fix: enforce immutable session configuration intent Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj --- .../providers/defaultProviderConfig.test.ts | 30 ++++++++++ .../lib/managedModelSelectionRepair.ts | 3 + src/shared/api/__tests__/acp.test.ts | 21 +++++++ .../api/__tests__/acpSessionRegistry.test.ts | 56 +++++++++++++++++++ src/shared/api/acp.ts | 2 +- src/shared/api/acpConnection.test.ts | 41 +++++++++++++- src/shared/api/acpConnection.ts | 8 +++ src/shared/api/acpSessionRegistry.ts | 19 +++++-- 8 files changed, 172 insertions(+), 8 deletions(-) diff --git a/src/features/providers/defaultProviderConfig.test.ts b/src/features/providers/defaultProviderConfig.test.ts index 20bd26fb1..e5369adf9 100644 --- a/src/features/providers/defaultProviderConfig.test.ts +++ b/src/features/providers/defaultProviderConfig.test.ts @@ -63,6 +63,36 @@ describe("reconcileManagedDefaultProviderSelection", () => { mockGetStoredModelPreference.mockReturnValue(null); }); + it("does not wait for model inventory for provider-only defaults", async () => { + useRuntimeConfigStore.setState({ + loaded: true, + config: managedRuntimeConfig, + result: { + status: "ready", + source: "bundledFile", + config: managedRuntimeConfig, + }, + }); + const supportedModelsList = vi.fn().mockReturnValue(new Promise(() => {})); + mockGetClient.mockResolvedValue({ + goose: { + GooseUnstableDefaultsRead: vi.fn().mockResolvedValue({ + providerId: "databricks_v2", + modelId: undefined, + }), + GooseUnstableDefaultsSave: defaultsSave, + GooseUnstableProvidersSupportedModelsList: supportedModelsList, + }, + } as never); + + await expect(reconcileManagedDefaultProviderSelection()).resolves.toEqual({ + providerId: "databricks_v2", + modelId: undefined, + }); + expect(supportedModelsList).not.toHaveBeenCalled(); + expect(defaultsSave).not.toHaveBeenCalled(); + }); + it("repairs a persisted Goose harness sentinel to the managed default", async () => { useRuntimeConfigStore.setState({ loaded: true, diff --git a/src/features/providers/lib/managedModelSelectionRepair.ts b/src/features/providers/lib/managedModelSelectionRepair.ts index 44c116a99..ea5fb1388 100644 --- a/src/features/providers/lib/managedModelSelectionRepair.ts +++ b/src/features/providers/lib/managedModelSelectionRepair.ts @@ -99,6 +99,9 @@ export async function repairManagedGooseModelSelection( if (initial.providerId !== selection.providerId) { return resolveValidatedManagedGooseProviderSelection(config, selection); } + if (!selection.modelId) { + return initial; + } const targetModelIds = await validatedModelIds(initial.providerId); const repaired = resolveManagedGooseProviderSelection(config, selection, { diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index 3a1041425..45a9f62c6 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -1468,6 +1468,27 @@ describe("acpCreateSession", () => { }); }); + it("applies the complete resolved migration pair for provider-only input", async () => { + await setRuntimeConfig(managedRuntimeConfig); + mockSupportedModelsList.mockResolvedValueOnce({ + models: ["goose-gpt-5-5"], + }); + mockNewSession.mockResolvedValue({ sessionId: "migrated-session" }); + const { acpCreateSession } = await import("../acp"); + + await acpCreateSession("goose", "/tmp/project"); + + expect(mockSetProvider).toHaveBeenCalledWith( + "migrated-session", + "databricks_v2", + ); + expect(mockSetModel).toHaveBeenCalledWith( + "migrated-session", + "goose-gpt-5-5", + noRequestModelContext("databricks_v2"), + ); + }); + it("does not mutate ACP when managed provider migration cannot prove support", async () => { await setRuntimeConfig(managedRuntimeConfig); mockSupportedModelsList.mockRejectedValueOnce(new Error("offline")); diff --git a/src/shared/api/__tests__/acpSessionRegistry.test.ts b/src/shared/api/__tests__/acpSessionRegistry.test.ts index dad120bb3..46d170d12 100644 --- a/src/shared/api/__tests__/acpSessionRegistry.test.ts +++ b/src/shared/api/__tests__/acpSessionRegistry.test.ts @@ -305,6 +305,42 @@ describe("applySessionModel", () => { expect(prompt).toHaveBeenCalledWith("anthropic"); }); + it("holds prompt transport behind pending configuration intent until it clears", async () => { + const registry = await importPreparedRegistry("openai", "gpt-4.1"); + const supersession = registry.supersedeSessionMutation("session-1"); + const prompt = vi.fn().mockResolvedValue("complete"); + + const result = registry.runPreparedSessionPrompt("session-1", prompt); + await Promise.resolve(); + expect(prompt).not.toHaveBeenCalled(); + + supersession.clear(); + await expect(result).resolves.toBe("complete"); + expect(prompt).toHaveBeenCalledWith("openai"); + }); + + it("holds prompt transport until pending configuration is consumed", async () => { + const registry = await importPreparedRegistry("openai", "gpt-4.1"); + const supersession = registry.supersedeSessionMutation("session-1"); + const prompt = vi.fn().mockResolvedValue("complete"); + + const result = registry.runPreparedSessionPrompt("session-1", prompt); + const configure = registry.configureSession( + "session-1", + "anthropic", + "/project", + "claude-fable", + {}, + supersession, + ); + await expect(configure).resolves.toEqual({ + model: { modelId: "claude-fable", modelName: "claude-fable" }, + reasoningEffort: null, + }); + await expect(result).resolves.toBe("complete"); + expect(prompt).toHaveBeenCalledWith("anthropic"); + }); + it("does not time out a long-running prompt or admit config work mid-turn", async () => { vi.useFakeTimers(); try { @@ -457,6 +493,26 @@ describe("applySessionModel", () => { expect(mockSetModel).toHaveBeenCalledTimes(1); }); + it("returns the acknowledged requested model when setModel omits a snapshot", async () => { + const registry = await importPreparedRegistry("openai", "gpt-4.1"); + mockSetProvider.mockResolvedValueOnce( + modelConfigResponse("claude-sonnet", "Claude Sonnet"), + ); + mockSetModel.mockResolvedValueOnce(undefined); + + await expect( + registry.configureSession( + "session-1", + "anthropic", + "/project", + "claude-fable", + ), + ).resolves.toEqual({ + model: { modelId: "claude-fable", modelName: "claude-fable" }, + reasoningEffort: null, + }); + }); + it("returns the final model snapshot without provider-default fields", async () => { const registry = await importPreparedRegistry("openai", "gpt-5.5"); mockSetProvider.mockResolvedValueOnce( diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index ee9c27fc5..259ba8335 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -413,7 +413,7 @@ async function applyResolvedSessionSelection( options: AcpSessionConfigApplyOptions, supersession: AcpSessionConfigurationIntent, ): Promise { - const applyResolvedModel = Boolean(options.modelId); + const applyResolvedModel = Boolean(selection.modelId); return applyResolvedModel && selection.modelId ? sessionRegistry.configureSession( sessionId, diff --git a/src/shared/api/acpConnection.test.ts b/src/shared/api/acpConnection.test.ts index 830b8ece8..631dd2974 100644 --- a/src/shared/api/acpConnection.test.ts +++ b/src/shared/api/acpConnection.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { + const urlRequests: Array<{ resolve: (url: string) => void }> = []; const initializations: Array<{ resolve: () => void; reject: (error: Error) => void; @@ -14,18 +15,21 @@ const mocks = vi.hoisted(() => { ); } } - return { initializations, streams, MockGooseClient }; + return { urlRequests, initializations, streams, MockGooseClient }; }); vi.mock("@tauri-apps/api/core", () => ({ - invoke: vi.fn().mockResolvedValue("ws://goose"), + invoke: vi.fn( + () => new Promise((resolve) => mocks.urlRequests.push({ resolve })), + ), })); vi.mock("@aaif/goose-sdk", () => ({ DEFAULT_GOOSE_MCP_HOST_CAPABILITIES: {}, GooseClient: mocks.MockGooseClient, })); vi.mock("./createWebSocketStream", () => ({ - createWebSocketStream: () => { + createWebSocketStream: (url: string) => { const stream = { + url, writable: { abort: vi.fn().mockResolvedValue(undefined) }, }; mocks.streams.push(stream); @@ -34,17 +38,42 @@ vi.mock("./createWebSocketStream", () => ({ })); describe("ACP connection lifecycle", () => { beforeEach(() => { + mocks.urlRequests.length = 0; mocks.initializations.length = 0; mocks.streams.length = 0; vi.resetModules(); }); + it("rejects a retired URL lookup before opening its transport", async () => { + const connection = await import("./acpConnection"); + const stale = connection.getClient(); + await vi.waitFor(() => expect(mocks.urlRequests).toHaveLength(1)); + + await connection.invalidateClientConnection(); + const retry = connection.getClient(); + await vi.waitFor(() => expect(mocks.urlRequests).toHaveLength(2)); + mocks.urlRequests[0]?.resolve("ws://stale"); + await expect(stale).rejects.toThrow("initialization was superseded"); + expect(mocks.streams).toHaveLength(0); + + mocks.urlRequests[1]?.resolve("ws://current"); + await vi.waitFor(() => expect(mocks.initializations).toHaveLength(1)); + mocks.initializations[0]?.resolve(); + await expect(retry).resolves.toBeTruthy(); + expect(mocks.streams).toHaveLength(1); + expect(mocks.streams[0]).toMatchObject({ url: "ws://current" }); + }); + it("retires and aborts an initializing transport before retry", async () => { const connection = await import("./acpConnection"); const stale = connection.getClient(); + await vi.waitFor(() => expect(mocks.urlRequests).toHaveLength(1)); + mocks.urlRequests[0]?.resolve("ws://goose"); await vi.waitFor(() => expect(mocks.initializations).toHaveLength(1)); await connection.invalidateClientConnection(); expect(mocks.streams[0]?.writable.abort).toHaveBeenCalledOnce(); const retry = connection.getClient(); + await vi.waitFor(() => expect(mocks.urlRequests).toHaveLength(2)); + mocks.urlRequests[1]?.resolve("ws://goose-retry"); await vi.waitFor(() => expect(mocks.initializations).toHaveLength(2)); mocks.initializations[1]?.resolve(); const client = await retry; @@ -60,6 +89,8 @@ describe("ACP connection lifecycle", () => { const connection = await import("./acpConnection"); const first = connection.getClient(); const second = connection.getClient(); + await vi.waitFor(() => expect(mocks.urlRequests).toHaveLength(1)); + mocks.urlRequests[0]?.resolve("ws://goose"); await vi.waitFor(() => expect(mocks.initializations).toHaveLength(1)); await connection.invalidateClientConnection(); @@ -73,6 +104,8 @@ describe("ACP connection lifecycle", () => { it("aborts a transport when initialization rejects and preserves the error", async () => { const connection = await import("./acpConnection"); const failed = connection.getClient(); + await vi.waitFor(() => expect(mocks.urlRequests).toHaveLength(1)); + mocks.urlRequests[0]?.resolve("ws://goose"); await vi.waitFor(() => expect(mocks.initializations).toHaveLength(1)); mocks.initializations[0]?.reject(new Error("handshake failed")); @@ -82,6 +115,8 @@ describe("ACP connection lifecycle", () => { expect(connection.getClientSync()).toBeNull(); const retry = connection.getClient(); + await vi.waitFor(() => expect(mocks.urlRequests).toHaveLength(2)); + mocks.urlRequests[1]?.resolve("ws://goose-retry"); await vi.waitFor(() => expect(mocks.initializations).toHaveLength(2)); mocks.initializations[1]?.resolve(); await expect(retry).resolves.toBeTruthy(); diff --git a/src/shared/api/acpConnection.ts b/src/shared/api/acpConnection.ts index 23fbc406e..b37071809 100644 --- a/src/shared/api/acpConnection.ts +++ b/src/shared/api/acpConnection.ts @@ -193,6 +193,14 @@ async function initializeConnection( perfLog( `[perf:conn] get_goose_serve_url in ${(performance.now() - tStart).toFixed(1)}ms`, ); + if ( + currentAttempt !== attempt || + attempt.generation !== connectionGeneration + ) { + throw new Error( + "ACP connection initialization was superseded; retry the operation.", + ); + } const tStream = performance.now(); const stream = createWebSocketStream(wsUrl); diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index f17c83984..b9b2db819 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -438,9 +438,15 @@ export async function configureSession( concreteModelId ? {} : options, ); if (concreteModelId) { - snapshots = - (await applySessionModelNow(sessionId, concreteModelId, options)) ?? - snapshots; + const modelSnapshots = await applySessionModelNow( + sessionId, + concreteModelId, + options, + ); + snapshots = modelSnapshots ?? { + model: { modelId: concreteModelId, modelName: concreteModelId }, + reasoningEffort: null, + }; } return snapshots; }); @@ -491,10 +497,15 @@ export function requireSessionInvocationSelection( } /** Run prompt setup and transport without allowing session config to interleave. */ -export function runPreparedSessionPrompt( +export async function runPreparedSessionPrompt( sessionId: string, prompt: (providerId: string) => Promise, ): Promise { + let pending = mutationQueues.get(sessionId)?.pendingSupersession; + while (pending) { + await pending.settled; + pending = mutationQueues.get(sessionId)?.pendingSupersession; + } return serializeSessionMutation( sessionId, () => prompt(requireSessionInvocationSelection(sessionId).providerId), From b8aaa3cfdf59ec2a0266be4423fda271653085d3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 20 Aug 2026 15:16:50 +0000 Subject: [PATCH 38/50] fix: close immutable configuration review gaps Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj --- src/app/AppShell.navigation.test.tsx | 14 ++-- src/app/AppShell.tsx | 13 ++- .../__tests__/personaExecutionTarget.test.ts | 19 +++++ .../agents/lib/personaExecutionTarget.ts | 34 +++++++- .../__tests__/commands/commands.test.ts | 56 +++++++++++++ .../berdctl/commands/impl/createSession.ts | 53 +++++++++++- src/features/berdctl/commands/types.ts | 1 + .../useChatSessionController.test.ts | 20 +++-- .../useResolvedAgentModelPicker.test.ts | 29 +++++++ .../chat/hooks/useChatSessionController.ts | 22 +++-- .../chat/hooks/useResolvedAgentModelPicker.ts | 21 +++-- .../chat/lib/sessionTargetCoordinator.test.ts | 44 +++++++++- .../chat/lib/sessionTargetCoordinator.ts | 15 +++- .../providers/defaultProviderConfig.test.ts | 27 ++++++ .../providers/defaultProviderConfig.ts | 5 +- .../lib/managedModelSelectionRepair.ts | 2 + .../stores/providerModelCacheStore.test.ts | 84 ++++++++++++++++++- .../stores/providerModelCacheStore.ts | 81 ++++++++++++++++-- src/shared/api/acpSessionRegistry.ts | 14 +++- src/shared/i18n/locales/en/chat.json | 4 + src/shared/i18n/locales/es/chat.json | 4 + src/shared/ui/GlobalComposerPill.test.tsx | 16 ++++ src/shared/ui/GlobalComposerPill.tsx | 29 ++++++- 23 files changed, 549 insertions(+), 58 deletions(-) diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index abb34d596..5d7f338bb 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -326,6 +326,7 @@ function seedProviderModels( providers.set(providerId, { ...existing, provenModelIds: models.map((model) => model.id), + fetchedAt: Date.now(), }); } return { providers }; @@ -5093,7 +5094,7 @@ describe("AppShell global navigation", () => { }); }); - it("uses the normal new-chat target when a persona has no plausible target", async () => { + it("rejects starting an agent whose saved target is invalid", async () => { useDefaultProviderReadinessStore.setState({ readiness: { status: "ready", @@ -5124,15 +5125,10 @@ describe("AppShell global navigation", () => { ); await waitFor(() => { - expect(mockAcpCreateSession).toHaveBeenCalledWith( - "databricks_v2", - "~/goose artifacts", - expect.objectContaining({ modelId: "goose-default" }), - ); + expect(screen.getByTestId("active-view")).toHaveTextContent("agents"); }); - expect( - useChatSessionStore.getState().getSession("created-session"), - ).toMatchObject({ personaId: "persona-unresolved" }); + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + expect(useChatSessionStore.getState().sessions).toHaveLength(0); }); it("tags a Home agent starter in the composer instead of opening a blank chat", async () => { diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 662acc4c7..55f831b76 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -88,7 +88,7 @@ import { } from "@/features/chat/stores/chatSessionSelectors"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; -import { personaExecutionTarget } from "@/features/agents/lib/personaExecutionTarget"; +import { resolvePersonaExecutionTarget } from "@/features/agents/lib/personaExecutionTarget"; import { useProjectStore } from "@/features/projects/stores/projectStore"; import { selectProjects } from "@/features/projects/stores/projectSelectors"; import { findExistingDraft } from "@/features/chat/lib/newChat"; @@ -2714,7 +2714,7 @@ export function AppShell({ providerId: model.providerId ?? providerId, })), ); - const executionTarget = personaExecutionTarget(persona, { + const personaResolution = resolvePersonaExecutionTarget(persona, { providers: agentState.providers, models: cachedModels, getProvenModelsForHarness: () => provenModels, @@ -2724,6 +2724,15 @@ export function AppShell({ .isModelInventoryAuthoritative(providerId), catalogEntries: getProviderCatalog(), }); + if (personaResolution.status === "invalid") { + setActiveView("agents"); + setAgentsPersonaId(agentId); + return; + } + const executionTarget = + personaResolution.status === "valid" + ? personaResolution.target + : undefined; void createNewTab(DEFAULT_CHAT_TITLE, undefined, { executionTarget, diff --git a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts index fa3db52ba..a30df6e09 100644 --- a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts +++ b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts @@ -3,6 +3,7 @@ import type { ProviderCatalogEntry } from "@/shared/types/providers"; import { gooseServeSelectionFromExecutionTarget } from "@/features/chat/lib/gooseServeExecutionTarget"; import { personaExecutionTarget, + resolvePersonaExecutionTarget, personaTargetMigration, } from "../personaExecutionTarget"; @@ -44,6 +45,24 @@ describe("personaExecutionTarget", () => { expect(personaExecutionTarget({}, context())).toBeUndefined(); }); + it("distinguishes absent, valid, and invalid saved configurations", () => { + expect(resolvePersonaExecutionTarget({}, context())).toEqual({ + status: "absent", + }); + expect( + resolvePersonaExecutionTarget( + { provider: "goose", modelProviderId: "openai", model: "gpt-5" }, + context(), + ), + ).toMatchObject({ status: "valid", target: { modelId: "gpt-5" } }); + expect( + resolvePersonaExecutionTarget( + { provider: "missing-provider", model: "retired" }, + context(), + ), + ).toEqual({ status: "invalid" }); + }); + it("returns the complete saved Goose target without requiring inventory", () => { expect( personaExecutionTarget( diff --git a/src/features/agents/lib/personaExecutionTarget.ts b/src/features/agents/lib/personaExecutionTarget.ts index 73b93c64b..b3bf9dac8 100644 --- a/src/features/agents/lib/personaExecutionTarget.ts +++ b/src/features/agents/lib/personaExecutionTarget.ts @@ -125,7 +125,7 @@ function persistedModelProviderId( * Convert canonical saved agent metadata into PR #1085's runtime target. * An incomplete legacy target is no override; callers must leave chat state alone. */ -export function personaExecutionTarget( +function resolvePersonaExecutionTargetValue( persona: | Pick | null @@ -203,6 +203,38 @@ export function personaExecutionTarget( }); } +export type PersonaExecutionResolution = + | { status: "absent"; target?: undefined } + | { status: "invalid"; target?: undefined } + | { status: "valid"; target: SessionExecutionTarget }; + +/** Distinguish legacy absence from saved metadata that cannot be invoked safely. */ +export function resolvePersonaExecutionTarget( + persona: + | Pick + | null + | undefined, + context: PersonaTargetContext, +): PersonaExecutionResolution { + const hasSavedTarget = Boolean( + persona?.provider || persona?.modelProviderId || persona?.model, + ); + if (!hasSavedTarget) return { status: "absent" }; + const target = resolvePersonaExecutionTargetValue(persona, context); + return target ? { status: "valid", target } : { status: "invalid" }; +} + +export function personaExecutionTarget( + persona: + | Pick + | null + | undefined, + context: PersonaTargetContext, +): SessionExecutionTarget | undefined { + const resolution = resolvePersonaExecutionTarget(persona, context); + return resolution.status === "valid" ? resolution.target : undefined; +} + /** * Produce the durable repair for legacy agent metadata after provider inventory * has refreshed. `null` means the saved target is already canonical. diff --git a/src/features/berdctl/__tests__/commands/commands.test.ts b/src/features/berdctl/__tests__/commands/commands.test.ts index b99b14dd8..3b378ed2e 100644 --- a/src/features/berdctl/__tests__/commands/commands.test.ts +++ b/src/features/berdctl/__tests__/commands/commands.test.ts @@ -704,6 +704,31 @@ describe("sessions.create", () => { expect(controller.openSession).not.toHaveBeenCalled(); }); + it("rejects an agent with an invalid saved target before creating", async () => { + mocks.listPersonas.mockResolvedValue([ + { + id: "invalid-agent", + displayName: "Invalid Agent", + systemPrompt: "Do not invoke.", + provider: "missing-provider", + model: "retired-model", + isBuiltin: false, + writable: true, + }, + ]); + + await expectCommandError( + dispatchCommand( + "sessions", + { action: "create", prompt: "hi", agent_id: "invalid-agent" }, + ctx, + ), + "agent_configuration_invalid", + ); + + expect(mocks.acpCreateSession).not.toHaveBeenCalled(); + }); + it("rejects an unknown agent before creating the session", async () => { await expectCommandError( dispatchCommand( @@ -739,6 +764,37 @@ describe("sessions.create", () => { ); }); + it("uses a valid agent's saved execution target when no explicit target is passed", async () => { + seedModelCache("databricks_v2", ["agent-model"]); + mocks.listPersonas.mockResolvedValue([ + { + id: "configured-agent", + displayName: "Configured Agent", + systemPrompt: "Use the saved target.", + provider: "goose", + modelProviderId: "databricks_v2", + model: "agent-model", + isBuiltin: false, + writable: true, + }, + ]); + + await dispatchCommand( + "sessions", + { action: "create", prompt: "hi", agent_id: "configured-agent" }, + ctx, + ); + + expect(mocks.acpCreateSession).toHaveBeenCalledWith( + "databricks_v2", + "/resolved/cwd", + expect.objectContaining({ + modelId: "agent-model", + personaId: "configured-agent", + }), + ); + }); + it("rejects an unknown harness with harness_not_found before creating", async () => { const error = await expectCommandError( dispatchCommand( diff --git a/src/features/berdctl/commands/impl/createSession.ts b/src/features/berdctl/commands/impl/createSession.ts index ffdee39e9..1c4365515 100644 --- a/src/features/berdctl/commands/impl/createSession.ts +++ b/src/features/berdctl/commands/impl/createSession.ts @@ -95,6 +95,10 @@ Result: { berdctlCrossSessionSendOptions }, { GOOSE_PROVIDER_ID }, { normalizeSessionExecutionTarget, targetFromAgentModelSelection }, + { resolvePersonaExecutionTarget }, + { useAgentStore }, + { useProviderModelCacheStore }, + { getProviderCatalog }, { findPersonaOrThrow }, { findProjectOrThrow }, { findReadyHarnessOrThrow, gooseModelOptions, harnessModelOptions }, @@ -106,23 +110,62 @@ Result: import("../runtime/sessionSend"), import("@/shared/api/acpPersonaHandoff"), import("@/features/chat/lib/sessionExecutionTarget"), + import("@/features/agents/lib/personaExecutionTarget"), + import("@/features/agents/stores/agentStore"), + import("@/features/providers/stores/providerModelCacheStore"), + import("@/features/providers/providerCatalog"), import("../runtime/agents"), import("../runtime/projects"), import("../runtime/providers"), ]); - const harnessId = args.harness_id ?? GOOSE_PROVIDER_ID; + const requestedHarnessId = args.harness_id ?? GOOSE_PROVIDER_ID; // The validation legs are independent I/O; overlap them. const [project, , models, persona] = await Promise.all([ args.project_id ? findProjectOrThrow(args.project_id) : null, args.harness_id ? findReadyHarnessOrThrow(args.harness_id) : null, args.model_id - ? (harnessId === GOOSE_PROVIDER_ID + ? (requestedHarnessId === GOOSE_PROVIDER_ID ? gooseModelOptions() - : harnessModelOptions(harnessId) + : harnessModelOptions(requestedHarnessId) ).catch(() => []) : null, args.agent_id ? findPersonaOrThrow(args.agent_id) : null, ]); + const modelCache = useProviderModelCacheStore.getState(); + const cachedModels = [...modelCache.providers].flatMap(([providerId]) => + modelCache.isModelInventoryAuthoritative(providerId) + ? modelCache.getProvenModelsForProvider(providerId).map((model) => ({ + ...model, + providerId: model.providerId ?? providerId, + })) + : [], + ); + const personaResolution = persona + ? resolvePersonaExecutionTarget(persona, { + providers: useAgentStore.getState().providers, + models: cachedModels, + getModelsForHarness: (harnessId) => + harnessId === GOOSE_PROVIDER_ID ? cachedModels : [], + getProvenModelsForHarness: (harnessId) => + harnessId === GOOSE_PROVIDER_ID ? cachedModels : [], + isModelInventoryAuthoritative: + modelCache.isModelInventoryAuthoritative, + catalogEntries: getProviderCatalog(), + }) + : { status: "absent" as const }; + if (personaResolution.status === "invalid") { + throw new CommandError( + "agent_configuration_invalid", + `Agent "${persona?.id}" has a saved provider or model that is no longer available. Update the agent configuration before invoking it.`, + ); + } + const personaTarget = + personaResolution.status === "valid" + ? personaResolution.target + : undefined; + const harnessId = + args.harness_id ?? personaTarget?.harnessId ?? GOOSE_PROVIDER_ID; + // Soft model validation: only reject when the harness's model list is // known and the id is not in it. On goose a model belongs to a model // provider (anthropic, openai, ...), so a match also resolves the @@ -152,7 +195,9 @@ Result: modelId: args.model_id, modelName: args.model_id, }) - : normalizeSessionExecutionTarget({ harnessId }); + : args.harness_id + ? normalizeSessionExecutionTarget({ harnessId }) + : (personaTarget ?? normalizeSessionExecutionTarget({ harnessId })); const requiresStartupName = Boolean( project && projectRequiresStartupWorkspaceName(project), ); diff --git a/src/features/berdctl/commands/types.ts b/src/features/berdctl/commands/types.ts index 978e442a2..c4b8ac1fd 100644 --- a/src/features/berdctl/commands/types.ts +++ b/src/features/berdctl/commands/types.ts @@ -73,6 +73,7 @@ export const COMMAND_ERROR_CODES = [ "session_not_found", "project_not_found", "agent_not_found", + "agent_configuration_invalid", "skill_not_found", "harness_not_found", "harness_not_ready", diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index 08bc549c8..4adbdd18b 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -4801,12 +4801,15 @@ describe("useChatSessionController", () => { expect( useChatSessionStore.getState().getSession("session-1"), ).toMatchObject({ - personaId: "persona-1", executionTarget: { harnessId: "goose", modelProviderId: "openai", }, }); + expect( + useChatSessionStore.getState().getSession("home-unresolved-persona") + ?.personaId, + ).toBeUndefined(); }); expect(mockAcpPrepareSession).toHaveBeenCalledWith( "session-1", @@ -5189,7 +5192,7 @@ describe("useChatSessionController", () => { }); }); - it("leaves the current target alone when a legacy persona model cannot resolve", async () => { + it("rejects a legacy persona whose saved model cannot resolve", async () => { useAgentStore.setState({ personas: [ personaFixture({ @@ -5220,13 +5223,13 @@ describe("useChatSessionController", () => { expect( useChatSessionStore.getState().getSession("session-1"), ).toMatchObject({ - personaId: "persona-1", executionTarget: { harnessId: "goose", modelProviderId: "openai", modelId: "gpt-4o", }, }); + expect(useAgentStore.getState().activeAgentId).toBeNull(); expect(mockAcpPrepareSession).not.toHaveBeenCalled(); }); @@ -5328,7 +5331,7 @@ describe("useChatSessionController", () => { expect(mockAcpPrepareSession).not.toHaveBeenCalled(); }); - it("leaves the current target alone when a persona target cannot resolve", () => { + it("rejects a persona whose saved target cannot resolve", () => { useAgentStore.setState({ personas: [ personaFixture({ @@ -5350,17 +5353,17 @@ describe("useChatSessionController", () => { expect( useChatSessionStore.getState().getSession("session-1"), ).toMatchObject({ - personaId: "persona-1", executionTarget: { harnessId: "goose", modelProviderId: "openai", modelId: "gpt-4o", }, }); + expect(useAgentStore.getState().activeAgentId).toBeNull(); expect(mockAcpPrepareSession).not.toHaveBeenCalled(); }); - it("keeps the Home target when an unresolved persona is selected", async () => { + it("does not carry an unresolved persona into a later Home session", async () => { useAgentStore.setState({ personas: [ personaFixture({ @@ -5397,12 +5400,15 @@ describe("useChatSessionController", () => { expect( useChatSessionStore.getState().getSession("home-unresolved-persona"), ).toMatchObject({ - personaId: "persona-1", executionTarget: { harnessId: "goose", modelProviderId: "openai", }, }); + expect( + useChatSessionStore.getState().getSession("home-unresolved-persona") + ?.personaId, + ).toBeUndefined(); }); expect(mockAcpPrepareSession).not.toHaveBeenCalled(); }); diff --git a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts index 35321ad43..1016c89df 100644 --- a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts +++ b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts @@ -506,6 +506,35 @@ describe("useResolvedAgentModelPicker", () => { }); }); + it("does not accept an advisory retired model for an existing session", () => { + mockUseAgentModelPickerState.mockImplementation(() => ({ + pickerAgents: [{ id: "goose", label: "Goose" }], + availableModels: [ + { id: "retired", name: "Retired", providerId: "openai" }, + { id: "current", name: "Current", providerId: "openai" }, + ], + getProvenModelsForAgent: () => [ + { id: "current", name: "Current", providerId: "openai" }, + ], + isModelInventoryAuthoritative: (providerId: string) => + providerId === "openai", + modelsLoading: false, + modelStatusMessage: null, + handleProviderChange: vi.fn(), + handleModelChange: vi.fn(), + })); + + const session = makeSession({ + harnessId: "goose", + modelProviderId: "openai", + modelId: "retired", + modelName: "Retired", + }); + const { result } = renderModelPicker({ session }); + + expect(result.current.effectiveModelSelection?.id).not.toBe("retired"); + }); + it("does not synthesize a model for an existing provider-only session", () => { window.localStorage.setItem( "goose:preferredModelsByAgent", diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 85d54e357..c2ae64224 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -83,7 +83,7 @@ import { markAgentBuilderSessionPreparationFailed, preSeedDraftAgent, } from "@/features/agents/lib/agentBuilderSession"; -import { personaExecutionTarget } from "@/features/agents/lib/personaExecutionTarget"; +import { resolvePersonaExecutionTarget } from "@/features/agents/lib/personaExecutionTarget"; import { deletePersonaSource } from "@/shared/api/agents"; import type { Persona } from "@/shared/types/agents"; import { @@ -1206,7 +1206,7 @@ export function useChatSessionController({ const resolvePersonaTarget = useCallback( (persona: Persona) => - personaExecutionTarget(persona, { + resolvePersonaExecutionTarget(persona, { providers, models: getModelsForAgent("goose"), getModelsForHarness: getModelsForAgent, @@ -1446,7 +1446,16 @@ export function useChatSessionController({ } const persona = personas.find((candidate) => candidate.id === personaId); - const personaTarget = persona ? resolvePersonaTarget(persona) : undefined; + const personaResolution = persona + ? resolvePersonaTarget(persona) + : { status: "absent" as const }; + if (personaResolution.status === "invalid") { + return; + } + const personaTarget = + personaResolution.status === "valid" + ? personaResolution.target + : undefined; if (personaTarget) { const harnessId = personaTarget.harnessId; @@ -1848,9 +1857,12 @@ export function useChatSessionController({ const targetPersona = personas.find( (persona) => persona.id === overridePersona.id, ); + const resolution = targetPersona + ? resolvePersonaTarget(targetPersona) + : undefined; return ( - (targetPersona - ? resolvePersonaTarget(targetPersona)?.harnessId + (resolution?.status === "valid" + ? resolution.target.harnessId : undefined) ?? selectedAgentId ); }, diff --git a/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/src/features/chat/hooks/useResolvedAgentModelPicker.ts index 85a4bb808..ce61ade7b 100644 --- a/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -750,7 +750,13 @@ export function useResolvedAgentModelPicker({ return null; } - const modelsMatchingSessionId = availableModels.filter( + const inventoryAuthoritative = + executionTarget.modelProviderId != null && + isModelInventoryAuthoritative(executionTarget.modelProviderId); + const sessionCandidates = inventoryAuthoritative + ? getProvenModelsForAgent(executionTarget.harnessId) + : availableModels; + const modelsMatchingSessionId = sessionCandidates.filter( (model) => model.id === executionTarget.modelId, ); const exactProviderMatch = @@ -775,11 +781,7 @@ export function useResolvedAgentModelPicker({ }; } - if ( - isModelAlias(executionTarget.modelId) || - (executionTarget.modelProviderId && - isModelInventoryAuthoritative(executionTarget.modelProviderId)) - ) { + if (isModelAlias(executionTarget.modelId) || inventoryAuthoritative) { return null; } @@ -789,7 +791,12 @@ export function useResolvedAgentModelPicker({ modelProviderId: executionTarget.modelProviderId, source: "explicit", }; - }, [availableModels, isModelInventoryAuthoritative, session]); + }, [ + availableModels, + getProvenModelsForAgent, + isModelInventoryAuthoritative, + session, + ]); const availableDefaultModelSelection = useMemo(() => { diff --git a/src/features/chat/lib/sessionTargetCoordinator.test.ts b/src/features/chat/lib/sessionTargetCoordinator.test.ts index 829ddd480..640c63d48 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.test.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.test.ts @@ -16,8 +16,17 @@ import { } from "./sessionTargetCoordinator"; const mockPrepare = vi.fn(); +const configurationIntentEvents: string[] = []; +let nextConfigurationIntent = 0; vi.mock("@/shared/api/acp", () => ({ - reserveAcpSessionConfiguration: () => ({ sequence: 0, clear: () => {} }), + reserveAcpSessionConfiguration: () => { + const id = ++nextConfigurationIntent; + configurationIntentEvents.push(`reserve:${id}`); + return { + sequence: id, + clear: () => configurationIntentEvents.push(`clear:${id}`), + }; + }, acpPrepareSession: (...args: unknown[]) => mockPrepare(...args), })); @@ -48,6 +57,8 @@ const reasoningEffort = { describe("session target coordinator", () => { beforeEach(() => { vi.clearAllMocks(); + configurationIntentEvents.length = 0; + nextConfigurationIntent = 0; resetSessionTargetCoordinatorsForTests(); useChatSessionStore.setState({ sessions: [ @@ -136,6 +147,37 @@ describe("session target coordinator", () => { ); }); + it("reserves the replacement before releasing superseded transition ownership", async () => { + const firstWire = deferred(); + mockPrepare + .mockReturnValueOnce(firstWire.promise) + .mockResolvedValueOnce(undefined); + + const first = transitionSessionTarget({ + sessionId: "s", + target: target("b"), + workingDir: "/w", + }); + await vi.waitFor(() => expect(mockPrepare).toHaveBeenCalledTimes(1)); + + const replacement = transitionSessionTarget({ + sessionId: "s", + target: target("c"), + workingDir: "/w", + }); + + expect(configurationIntentEvents.slice(0, 2)).toEqual([ + "reserve:1", + "reserve:2", + ]); + firstWire.resolve(); + await expect(first).resolves.toMatchObject({ status: "superseded" }); + await expect(replacement).resolves.toMatchObject({ status: "committed" }); + expect(configurationIntentEvents.indexOf("reserve:2")).toBeLessThan( + configurationIntentEvents.indexOf("clear:1"), + ); + }); + it("prevents an on-wire stale operation from committing over the winner", async () => { const wire = deferred(); mockPrepare diff --git a/src/features/chat/lib/sessionTargetCoordinator.ts b/src/features/chat/lib/sessionTargetCoordinator.ts index 7a4b8f891..69f8b855c 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.ts @@ -64,6 +64,7 @@ interface PendingOperation { selectionAtRequest?: SessionTargetSelection; targetAtRequest?: SessionExecutionTarget; settled: boolean; + intent: ReturnType; resolve: (outcome: SessionTargetOutcome) => void; } @@ -271,8 +272,7 @@ async function execute( actor: SessionActor, operation: PendingOperation, ): Promise { - const { request, operationId } = operation; - const intent = reserveAcpSessionConfiguration(request.sessionId); + const { request, operationId, intent } = operation; try { const effective = await resolveEffectiveTarget(request.target); const liveTarget = useChatSessionStore @@ -505,8 +505,14 @@ function requestSessionTargetTransition( }); const outcome = new Promise((resolve) => { const previous = actor.latest; - if (previous && previous !== actor.current) + // Reserve the replacement before releasing the superseded operation. The + // registry atomically transfers pending ownership to this intent, so a + // prompt waiter can never observe an unowned A-to-B handoff. + const intent = reserveAcpSessionConfiguration(request.sessionId); + if (previous && previous !== actor.current) { + previous.intent.clear(); settleOperation(previous, { status: "superseded", applied: false }); + } actor.latest = { sequence, request, @@ -514,6 +520,7 @@ function requestSessionTargetTransition( selectionAtRequest, targetAtRequest, settled: false, + intent, resolve, }; }); @@ -1231,6 +1238,7 @@ export function transferSessionTargetOwnership( ), ); for (const operation of pending) { + operation.intent.clear(); settleOperation(operation, { status: "superseded", applied: false }); } source.current = undefined; @@ -1259,6 +1267,7 @@ export function cancelSessionTarget(sessionId: string): void { ), ); for (const operation of pending) { + operation.intent.clear(); settleOperation(operation, { status: "session-missing", applied: false }); } actor.latest = undefined; diff --git a/src/features/providers/defaultProviderConfig.test.ts b/src/features/providers/defaultProviderConfig.test.ts index e5369adf9..b72026d89 100644 --- a/src/features/providers/defaultProviderConfig.test.ts +++ b/src/features/providers/defaultProviderConfig.test.ts @@ -176,6 +176,33 @@ describe("saveDefaultProviderSelection", () => { expect(defaultsSave).not.toHaveBeenCalled(); }); + it("does not save a stale default after forced refresh fails", async () => { + const staleModel = { id: "gpt-4o", name: "gpt-4o", recommended: true }; + const refreshProviderModels = vi.fn().mockImplementation((providerId) => { + useProviderModelCacheStore.setState({ + providers: new Map([ + [ + providerId, + { + providerId, + fetchedAt: Date.now(), + provenModelIds: ["gpt-4o"], + models: [staleModel], + error: "offline", + }, + ], + ]), + }); + }); + useProviderModelCacheStore.setState({ refreshProviderModels }); + + await expect(saveDefaultProviderSelection("openai")).rejects.toThrow( + "Could not prove models for provider", + ); + expect(defaultsSave).not.toHaveBeenCalled(); + expect(mockSetStoredModelPreference).not.toHaveBeenCalled(); + }); + it("saves backend defaults, local goose preference, and readiness", async () => { const refreshProviderModels = vi.fn().mockImplementation((providerId) => { useProviderModelCacheStore.setState({ diff --git a/src/features/providers/defaultProviderConfig.ts b/src/features/providers/defaultProviderConfig.ts index 753c05671..552472c7b 100644 --- a/src/features/providers/defaultProviderConfig.ts +++ b/src/features/providers/defaultProviderConfig.ts @@ -138,7 +138,10 @@ export async function saveDefaultProviderSelection( await modelCacheStore.refreshProviderModels(providerId, { force: true }); const cache = useProviderModelCacheStore.getState(); - if (!cache.isModelInventoryAuthoritative(providerId)) { + if ( + cache.getError(providerId) || + !cache.isModelInventoryAuthoritative(providerId) + ) { throw new Error( "Could not prove models for provider. Check provider setup and try again.", ); diff --git a/src/features/providers/lib/managedModelSelectionRepair.ts b/src/features/providers/lib/managedModelSelectionRepair.ts index ea5fb1388..31032fd84 100644 --- a/src/features/providers/lib/managedModelSelectionRepair.ts +++ b/src/features/providers/lib/managedModelSelectionRepair.ts @@ -5,6 +5,7 @@ import { providerModelInventoryGeneration, subscribeToProviderModelInventoryInvalidation, } from "@/shared/runtime-config/providerModelInventoryInvalidation"; +import { publishProvenModelInventory } from "@/features/providers/stores/providerModelCacheStore"; import { readBoundedProvenModelInventory, resolveManagedGooseProviderSelection, @@ -58,6 +59,7 @@ async function validatedModelIds( modelIds, fetchedAt: Date.now(), }); + publishProvenModelInventory(providerId, [...modelIds]); return modelIds; } catch (error) { if (generationAtStart !== providerModelInventoryGeneration(providerId)) { diff --git a/src/features/providers/stores/providerModelCacheStore.test.ts b/src/features/providers/stores/providerModelCacheStore.test.ts index a07199c46..945ba89a6 100644 --- a/src/features/providers/stores/providerModelCacheStore.test.ts +++ b/src/features/providers/stores/providerModelCacheStore.test.ts @@ -1,7 +1,12 @@ import { waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ModelOption } from "@/features/chat/types"; -import { useProviderModelCacheStore } from "./providerModelCacheStore"; +import { + isModelSelectionAllowedByCachedInventory, + MODEL_CACHE_TTL_MS, + publishProvenModelInventory, + useProviderModelCacheStore, +} from "./providerModelCacheStore"; const mocks = vi.hoisted(() => ({ getClient: vi.fn(), @@ -83,6 +88,79 @@ describe("providerModelCacheStore", () => { }); }); + it("lets a newer ACP acknowledgement supersede retained negative proof", () => { + const model = seededModel({ id: "old-model" }); + const proofRevision = 10; + useProviderModelCacheStore.setState({ + providers: new Map([ + [ + "databricks_v2", + { + providerId: "databricks_v2", + models: [model], + provenModelIds: ["old-model"], + proofRevision, + fetchedAt: Date.now() - MODEL_CACHE_TTL_MS - 1, + }, + ], + ]), + }); + + expect( + isModelSelectionAllowedByCachedInventory( + "databricks_v2", + "newly-prepared-model", + ), + ).toBe(false); + expect( + isModelSelectionAllowedByCachedInventory( + "databricks_v2", + "newly-prepared-model", + proofRevision + 1, + ), + ).toBe(true); + + useProviderModelCacheStore.setState((state) => { + const providers = new Map(state.providers); + const entry = providers.get("databricks_v2"); + if (!entry) throw new Error("expected seeded provider"); + providers.set("databricks_v2", { + ...entry, + error: "offline", + }); + return { providers }; + }); + expect( + isModelSelectionAllowedByCachedInventory( + "databricks_v2", + "newly-prepared-model", + ), + ).toBe(false); + expect( + isModelSelectionAllowedByCachedInventory( + "databricks_v2", + "newly-prepared-model", + proofRevision + 1, + ), + ).toBe(true); + }); + + it("publishes successful live preflight as the shared fresh authority", () => { + publishProvenModelInventory("databricks_v2", ["new-model"]); + + expect( + useProviderModelCacheStore + .getState() + .isModelInventoryAuthoritative("databricks_v2"), + ).toBe(true); + expect( + isModelSelectionAllowedByCachedInventory("databricks_v2", "new-model"), + ).toBe(true); + expect( + isModelSelectionAllowedByCachedInventory("databricks_v2", "old-model"), + ).toBe(false); + }); + it("invalidates runtime-managed proof without discarding its display seed", async () => { const model = seededModel({ recommended: true, featured: true }); useProviderModelCacheStore @@ -276,7 +354,7 @@ describe("providerModelCacheStore", () => { it.each([ { provenModelIds: ["supported-model"], expected: ["supported-model"] }, { provenModelIds: [], expected: [] }, - ])("preserves authoritative proof and runtime policy after refresh failure: $provenModelIds", async ({ + ])("preserves prior proof data and runtime policy but revokes authority after refresh failure: $provenModelIds", async ({ provenModelIds, expected, }) => { @@ -314,7 +392,7 @@ describe("providerModelCacheStore", () => { useProviderModelCacheStore .getState() .isModelInventoryAuthoritative("databricks_v2"), - ).toBe(true); + ).toBe(false); }); it("removes stale runtime-managed providers when runtime config changes", () => { diff --git a/src/features/providers/stores/providerModelCacheStore.ts b/src/features/providers/stores/providerModelCacheStore.ts index 5590b3b45..cfaeeb17e 100644 --- a/src/features/providers/stores/providerModelCacheStore.ts +++ b/src/features/providers/stores/providerModelCacheStore.ts @@ -6,7 +6,7 @@ import { getClient } from "@/shared/api/acpConnection"; import { notifyProviderModelInventoryInvalidated } from "../lib/providerModelInventoryEvents"; const MODEL_CACHE_STORAGE_KEY = "goose:providerModelCache:v1"; -const MODEL_CACHE_TTL_MS = 5 * 60 * 1000; +export const MODEL_CACHE_TTL_MS = 5 * 60 * 1000; const inFlightRefreshes = new Map>(); const queuedForceRefreshes = new Map>(); const providerRefreshVersions = new Map(); @@ -17,6 +17,8 @@ export interface CachedProviderModels { models: ModelOption[]; /** IDs returned by a successful live inventory response; the only proof. */ provenModelIds?: string[]; + /** Monotonic provenance for comparing proof with a later ACP acknowledgement. */ + proofRevision?: number; fetchedAt: number; /** Runtime configuration policy; it does not establish model proof. */ runtimeManaged?: boolean; @@ -150,7 +152,9 @@ function mergeDisplayModels( function getProvenModels( entry: CachedProviderModels | undefined, ): ModelOption[] { - if (!entry?.provenModelIds) return []; + if (!isCachedModelInventoryAuthoritative(entry) || !entry?.provenModelIds) { + return []; + } const provenIds = new Set(entry.provenModelIds); return entry.models.filter((model) => provenIds.has(model.id)); } @@ -158,23 +162,31 @@ function getProvenModels( export function isCachedModelInventoryAuthoritative( entry: CachedProviderModels | undefined, ): boolean { - return entry != null && Array.isArray(entry.provenModelIds); + return ( + entry != null && + Array.isArray(entry.provenModelIds) && + entry.error === undefined && + Date.now() - entry.fetchedAt <= MODEL_CACHE_TTL_MS + ); } /** * Return whether cached inventory still permits a concrete provider/model pair. - * Missing or provisional inventory cannot disprove a prepared selection; a - * successful authoritative response can. + * Retained successful proof can disprove a selection even after it becomes + * stale or a refresh fails. A later ACP acknowledgement supersedes that older + * negative proof for the concrete prepared session only. */ export function isModelSelectionAllowedByCachedInventory( providerId: string, modelId: string, + acknowledgedProofRevision = 0, ): boolean { const entry = useProviderModelCacheStore.getState().providers.get(providerId); - if (!isCachedModelInventoryAuthoritative(entry)) { - return true; - } - return entry?.provenModelIds?.includes(modelId) === true; + if (!entry?.provenModelIds) return true; + if (entry.provenModelIds.includes(modelId)) return true; + // A proof can disprove only selections acknowledged before that proof. A + // newer ACP acknowledgement is stronger evidence for that exact session. + return proofRevision(entry) <= acknowledgedProofRevision; } function isStale(entry: CachedProviderModels | undefined): boolean { @@ -184,6 +196,21 @@ function isStale(entry: CachedProviderModels | undefined): boolean { return Date.now() - entry.fetchedAt > MODEL_CACHE_TTL_MS; } +function proofRevision(entry: CachedProviderModels | undefined): number { + return entry?.proofRevision ?? entry?.fetchedAt ?? 0; +} + +function nextProofRevision(entry: CachedProviderModels | undefined): number { + return Math.max(Date.now(), proofRevision(entry) + 1); +} + +/** Provenance of the latest successful inventory observed for a provider. */ +export function getModelInventoryProofRevision(providerId: string): number { + return proofRevision( + useProviderModelCacheStore.getState().providers.get(providerId), + ); +} + function refreshVersion(providerId: string): number { return providerRefreshVersions.get(providerId) ?? 0; } @@ -193,6 +220,35 @@ function bumpRefreshVersion(providerId: string): void { notifyProviderModelInventoryInvalidated(providerId); } +/** Publish successful live proof into the one shared inventory authority. */ +export function publishProvenModelInventory( + providerId: string, + modelIds: readonly string[], +): void { + const state = useProviderModelCacheStore.getState(); + const existing = state.providers.get(providerId); + const discoveredModels = providerModelOptionsFromIds(providerId, [ + ...modelIds, + ]); + const configuredModels = existing?.configuredModels ?? []; + // A successful preflight is newer proof than any refresh already in flight. + // Supersede only the store's refresh writer; publishing proof is not an + // invalidation and must not evict the preflight cache that produced it. + providerRefreshVersions.set(providerId, refreshVersion(providerId) + 1); + const providers = new Map(state.providers); + providers.set(providerId, { + providerId, + models: mergeDisplayModels(discoveredModels, configuredModels), + provenModelIds: [...modelIds], + proofRevision: nextProofRevision(existing), + fetchedAt: Date.now(), + ...(existing?.runtimeManaged ? { runtimeManaged: true } : {}), + ...(configuredModels.length > 0 ? { configuredModels } : {}), + }); + useProviderModelCacheStore.setState({ providers }); + persistModels(providers); +} + export const useProviderModelCacheStore = create( (set, get) => ({ ...readPersistedProviderState(), @@ -229,6 +285,9 @@ export const useProviderModelCacheStore = create( fetchedAt: existing?.fetchedAt ?? 0, configuredModels, ...(hasLiveProof ? { provenModelIds } : {}), + ...(existing?.proofRevision !== undefined + ? { proofRevision: existing.proofRevision } + : {}), ...(runtimeManaged ? { runtimeManaged } : {}), }); if (runtimeManaged) { @@ -322,6 +381,7 @@ export const useProviderModelCacheStore = create( models, fetchedAt: Date.now(), provenModelIds: ids, + proofRevision: nextProofRevision(existing), ...(existing?.runtimeManaged ? { runtimeManaged: true } : {}), ...(configuredModels.length > 0 ? { configuredModels } : {}), }; @@ -350,6 +410,9 @@ export const useProviderModelCacheStore = create( : isCachedModelInventoryAuthoritative(existing) ? { provenModelIds: [] } : {}), + ...(existing?.proofRevision !== undefined + ? { proofRevision: existing.proofRevision } + : {}), ...(existing?.runtimeManaged ? { runtimeManaged: true } : {}), ...(existing?.configuredModels ? { configuredModels: existing.configuredModels } diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index b9b2db819..51d3ed155 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -11,12 +11,17 @@ import { shortLogId, } from "@/shared/lib/reasoningEffortDiagnostics"; import { normalizeConcreteModelId } from "@/shared/lib/modelIdentity"; -import { isModelSelectionAllowedByCachedInventory } from "@/features/providers/stores/providerModelCacheStore"; +import { + getModelInventoryProofRevision, + isModelSelectionAllowedByCachedInventory, +} from "@/features/providers/stores/providerModelCacheStore"; export interface AcpSessionExecutionSelection { providerId: string; /** Last model this window observed ACP acknowledge successfully. */ modelId?: string; + /** Shared inventory proof already known when ACP acknowledged this selection. */ + acknowledgedProofRevision?: number; } interface PreparedSession { @@ -90,6 +95,7 @@ function replaceExecutionSelection( entry.executionSelection = { providerId, ...(modelId ? { modelId } : {}), + acknowledgedProofRevision: getModelInventoryProofRevision(providerId), }; } @@ -330,6 +336,7 @@ async function prepareSessionNow( executionSelection: { providerId, ...(acknowledgedModelId ? { modelId: acknowledgedModelId } : {}), + acknowledgedProofRevision: getModelInventoryProofRevision(providerId), }, }; prepared.set(sessionId, entry); @@ -487,13 +494,15 @@ export function requireSessionInvocationSelection( !isModelSelectionAllowedByCachedInventory( selection.providerId, selection.modelId, + selection.acknowledgedProofRevision, ) ) { throw new Error( `Session model ${selection.modelId} is no longer supported by provider ${selection.providerId}. Re-prepare the session before prompting.`, ); } - return { ...selection, modelId: selection.modelId }; + const { acknowledgedProofRevision: _, ...invocationSelection } = selection; + return { ...invocationSelection, modelId: selection.modelId }; } /** Run prompt setup and transport without allowing session config to interleave. */ @@ -568,6 +577,7 @@ export function registerPreparedSession( executionSelection: { providerId, ...(acknowledgedModelId ? { modelId: acknowledgedModelId } : {}), + acknowledgedProofRevision: getModelInventoryProofRevision(providerId), }, }; diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index aa4d49ffa..1886b52ee 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -45,6 +45,8 @@ "confirmStopDescription": "This terminates the shell and closes the terminal panel.", "confirmStopTabTitle": "Stop terminal {{path}}?", "confirmStopTitle": "Stop terminal?", + "invalidAgentConfiguration": "This agent’s saved provider or model is no longer available.", + "repairAgentConfiguration": "Use default configuration", "expand": "Expand terminal", "exitedWithSignal": "Exited by {{signal}}", "newTab": "New terminal tab", @@ -436,6 +438,8 @@ }, "globalPill": { "ariaLabel": "Quick compose", + "invalidAgentConfiguration": "This agent’s saved provider or model is no longer available.", + "repairAgentConfiguration": "Use default configuration", "expand": "Expand to full chat", "placeholder": "Start a conversation", "setupVoiceConversation": "Set up Voice Conversation", diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 65f824346..ef0f3aec3 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -44,6 +44,8 @@ "confirmStopDescription": "Esto finaliza el shell y cierra el panel del terminal.", "confirmStopTabTitle": "¿Detener terminal {{path}}?", "confirmStopTitle": "¿Detener terminal?", + "invalidAgentConfiguration": "El proveedor o modelo guardado de este agente ya no está disponible.", + "repairAgentConfiguration": "Usar la configuración predeterminada", "expand": "Expandir terminal", "exitedWithSignal": "Finalizado por {{signal}}", "newTab": "Nueva pestaña de terminal", @@ -435,6 +437,8 @@ }, "globalPill": { "ariaLabel": "Redacción rápida", + "invalidAgentConfiguration": "El proveedor o modelo guardado de este agente ya no está disponible.", + "repairAgentConfiguration": "Usar la configuración predeterminada", "expand": "Expandir a chat completo", "placeholder": "Inicia una conversación", "setupVoiceConversation": "Configurar conversación por voz", diff --git a/src/shared/ui/GlobalComposerPill.test.tsx b/src/shared/ui/GlobalComposerPill.test.tsx index 7fe647e12..08eb1eed1 100644 --- a/src/shared/ui/GlobalComposerPill.test.tsx +++ b/src/shared/ui/GlobalComposerPill.test.tsx @@ -807,6 +807,22 @@ describe("GlobalComposerPill", () => { screen.getByRole("button", { name: /send message/i }), ).toBeDisabled(); expect(onSend).not.toHaveBeenCalled(); + expect( + screen.getByText( + "This agent’s saved provider or model is no longer available.", + ), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: "Use default configuration" }), + ); + + expect( + screen.queryByText( + "This agent’s saved provider or model is no longer available.", + ), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /send message/i })).toBeEnabled(); }); it("applies a legacy persona target when inventory arrives", async () => { diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index b87e03e28..e7183891e 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -18,7 +18,7 @@ import { useTranslation } from "react-i18next"; import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { selectPersonas } from "@/features/agents/stores/agentSelectors"; -import { personaExecutionTarget } from "@/features/agents/lib/personaExecutionTarget"; +import { resolvePersonaExecutionTarget } from "@/features/agents/lib/personaExecutionTarget"; import { useAttachmentDropTarget } from "@/features/chat/hooks/useAttachmentDropTarget"; import { useChatInputAttachments } from "@/features/chat/hooks/useChatInputAttachments"; import { useChatInputFilePicker } from "@/features/chat/hooks/useChatInputFilePicker"; @@ -498,9 +498,9 @@ export function GlobalComposerPill({ }, }); - const personaTarget = useMemo( + const personaResolution = useMemo( () => - personaExecutionTarget(selectedPersona, { + resolvePersonaExecutionTarget(selectedPersona, { providers, models: getModelsForAgent("goose"), getModelsForHarness: getModelsForAgent, @@ -517,6 +517,9 @@ export function GlobalComposerPill({ selectedPersona, ], ); + const personaTarget = + personaResolution.status === "valid" ? personaResolution.target : undefined; + const personaConfigurationInvalid = personaResolution.status === "invalid"; useEffect(() => { if (!selectedPersonaId) { @@ -1518,6 +1521,23 @@ export function GlobalComposerPill({ + {personaConfigurationInvalid && !personaSelectionOverridden ? ( +
+ {t("globalPill.invalidAgentConfiguration")} + +
+ ) : null} +
Date: Thu, 20 Aug 2026 15:52:44 +0000 Subject: [PATCH 39/50] fix: clarify invalid agent recovery Co-authored-by: Larry Signed-off-by: loganj --- src/app/AppShell.tsx | 1 + src/shared/i18n/locales/en/chat.json | 5 ++- src/shared/i18n/locales/es/chat.json | 5 ++- src/shared/ui/GlobalComposerPill.test.tsx | 39 ++++++++++++++++++++++- src/shared/ui/GlobalComposerPill.tsx | 30 ++++++++++++----- 5 files changed, 65 insertions(+), 15 deletions(-) diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 55f831b76..a6024de25 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -5297,6 +5297,7 @@ export function AppShell({ onSend={handleGlobalCompose} onExpand={handleGlobalComposerExpand} onDismiss={dismissCenteredGlobalComposer} + onEditAgent={navigateAgents} onHandoffStart={handleGlobalComposerHandoffStart} placement={globalComposerPlacement} mainLeftOffsetPx={sidebarDockedOuterWidth} diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index 1886b52ee..220869501 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -45,8 +45,6 @@ "confirmStopDescription": "This terminates the shell and closes the terminal panel.", "confirmStopTabTitle": "Stop terminal {{path}}?", "confirmStopTitle": "Stop terminal?", - "invalidAgentConfiguration": "This agent’s saved provider or model is no longer available.", - "repairAgentConfiguration": "Use default configuration", "expand": "Expand terminal", "exitedWithSignal": "Exited by {{signal}}", "newTab": "New terminal tab", @@ -439,7 +437,8 @@ "globalPill": { "ariaLabel": "Quick compose", "invalidAgentConfiguration": "This agent’s saved provider or model is no longer available.", - "repairAgentConfiguration": "Use default configuration", + "editInvalidAgent": "Edit agent", + "continueWithoutAgent": "Continue without this agent", "expand": "Expand to full chat", "placeholder": "Start a conversation", "setupVoiceConversation": "Set up Voice Conversation", diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index ef0f3aec3..aa2f38220 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -44,8 +44,6 @@ "confirmStopDescription": "Esto finaliza el shell y cierra el panel del terminal.", "confirmStopTabTitle": "¿Detener terminal {{path}}?", "confirmStopTitle": "¿Detener terminal?", - "invalidAgentConfiguration": "El proveedor o modelo guardado de este agente ya no está disponible.", - "repairAgentConfiguration": "Usar la configuración predeterminada", "expand": "Expandir terminal", "exitedWithSignal": "Finalizado por {{signal}}", "newTab": "Nueva pestaña de terminal", @@ -438,7 +436,8 @@ "globalPill": { "ariaLabel": "Redacción rápida", "invalidAgentConfiguration": "El proveedor o modelo guardado de este agente ya no está disponible.", - "repairAgentConfiguration": "Usar la configuración predeterminada", + "editInvalidAgent": "Editar agente", + "continueWithoutAgent": "Continuar sin este agente", "expand": "Expandir a chat completo", "placeholder": "Inicia una conversación", "setupVoiceConversation": "Configurar conversación por voz", diff --git a/src/shared/ui/GlobalComposerPill.test.tsx b/src/shared/ui/GlobalComposerPill.test.tsx index 08eb1eed1..631722caa 100644 --- a/src/shared/ui/GlobalComposerPill.test.tsx +++ b/src/shared/ui/GlobalComposerPill.test.tsx @@ -813,8 +813,11 @@ describe("GlobalComposerPill", () => { ), ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Edit agent" }), + ).not.toBeInTheDocument(); await user.click( - screen.getByRole("button", { name: "Use default configuration" }), + screen.getByRole("button", { name: "Continue without this agent" }), ); expect( @@ -825,6 +828,40 @@ describe("GlobalComposerPill", () => { expect(screen.getByRole("button", { name: /send message/i })).toBeEnabled(); }); + it("offers to edit an invalid agent without clearing its saved selection", async () => { + const user = userEvent.setup(); + const onEditAgent = vi.fn(); + useAgentStore.setState({ + personas: [ + { + id: "persona-1", + displayName: "Legacy agent", + systemPrompt: "Help.", + model: "unresolved-model", + isBuiltin: false, + writable: true, + }, + ], + }); + renderGlobalComposer(vi.fn(), { + suggestedPersonaId: "persona-1", + onEditAgent, + }); + + await user.click(screen.getByRole("button", { name: "Edit agent" })); + + expect(onEditAgent).toHaveBeenCalledWith("persona-1"); + expect(screen.getByText("Legacy agent")).toBeInTheDocument(); + expect( + screen.getByText( + "This agent’s saved provider or model is no longer available.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /send message/i }), + ).toBeDisabled(); + }); + it("applies a legacy persona target when inventory arrives", async () => { const user = userEvent.setup(); const legacyPersona = { diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index e7183891e..da8b9eb41 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -101,6 +101,7 @@ interface GlobalComposerPillProps { payload: GlobalComposerExpandPayload, ) => boolean | undefined | Promise; onDismiss?: () => void; + onEditAgent?: (personaId: string) => void; onHandoffStart?: (rect: GlobalComposerHandoffRect) => void; suggestedPersonaId?: string | null; reasoningEffort?: ChatInputReasoningEffort; @@ -212,6 +213,7 @@ export function GlobalComposerPill({ onSend, onExpand, onDismiss, + onEditAgent, onHandoffStart, suggestedPersonaId = null, reasoningEffort, @@ -1527,14 +1529,26 @@ export function GlobalComposerPill({ className="mx-6 mb-14 flex items-center justify-between gap-3 text-xs text-destructive" > {t("globalPill.invalidAgentConfiguration")} - +
+ {selectedPersonaId && onEditAgent ? ( + + ) : null} + +
) : null} From 0ca8b15a8585c8acb88b3a412ac789cac860dd83 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 20 Aug 2026 18:10:58 +0000 Subject: [PATCH 40/50] fix: enforce immutable session target configuration Co-authored-by: Larry Signed-off-by: loganj --- src/app/AppShell.navigation.test.tsx | 9 +- .../__tests__/personaExecutionTarget.test.ts | 7 +- .../agents/lib/personaExecutionTarget.ts | 9 +- .../berdctl/commands/impl/createSession.ts | 134 +++++++++++------- .../useChatSessionController.test.ts | 83 +---------- .../chat/hooks/useChatSessionController.ts | 15 +- src/shared/api/__tests__/acp.test.ts | 40 ++++++ src/shared/api/acp.ts | 13 +- src/shared/api/acpSessionRegistry.ts | 20 ++- src/shared/ui/GlobalComposerPill.tsx | 4 +- 10 files changed, 181 insertions(+), 153 deletions(-) diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 5d7f338bb..f298eaad7 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -1476,9 +1476,12 @@ describe("AppShell global navigation", () => { expect(mockAcpCreateSession).toHaveBeenCalled(); }); - it("allows a configured concrete provider when the BYO default is missing", async () => { + it("allows a configured concrete target when the BYO default is missing", async () => { requireByoDefaultProviderSetup(); - setResolvingPersona(); + setResolvingPersona("goose-gpt-5-5", "databricks_v2", "databricks_v2"); + seedProviderModels("databricks_v2", [ + { id: "goose-gpt-5-5", name: "GPT-5.5" }, + ]); mockCheckAllProviderStatus.mockResolvedValue([ { providerId: "databricks_v2", isConfigured: true }, ]); @@ -1497,7 +1500,7 @@ describe("AppShell global navigation", () => { "~/goose artifacts", { deferProviderSetup: false, - modelId: undefined, + modelId: "goose-gpt-5-5", projectId: undefined, }, ); diff --git a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts index a30df6e09..3ae457fba 100644 --- a/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts +++ b/src/features/agents/lib/__tests__/personaExecutionTarget.test.ts @@ -111,11 +111,12 @@ describe("personaExecutionTarget", () => { it("never materializes an agent provider as a Goose target without a model", () => { const persona = { provider: "goose", modelProviderId: "claude-acp" }; - expect(personaExecutionTarget(persona, context())).toEqual({ - harnessId: "goose", + expect(personaExecutionTarget(persona, context())).toBeUndefined(); + expect(resolvePersonaExecutionTarget(persona, context())).toEqual({ + status: "invalid", }); expect(personaTargetMigration(persona, context())).toEqual({ - provider: "goose", + provider: null, modelProviderId: null, model: null, }); diff --git a/src/features/agents/lib/personaExecutionTarget.ts b/src/features/agents/lib/personaExecutionTarget.ts index b3bf9dac8..d3b7832d3 100644 --- a/src/features/agents/lib/personaExecutionTarget.ts +++ b/src/features/agents/lib/personaExecutionTarget.ts @@ -51,7 +51,7 @@ function canonicalModelProviderId( return canonicalProviderCatalogIdFromEntries(catalogEntries, providerId); } -function harnessIdForPersona( +export function personaHarnessId( providerId: string | undefined, providers: readonly AvailableHarness[], catalogEntries: ProviderCatalogEntry[], @@ -139,7 +139,7 @@ function resolvePersonaExecutionTargetValue( catalogEntries, }: PersonaTargetContext, ): SessionExecutionTarget | undefined { - const harnessId = harnessIdForPersona( + const harnessId = personaHarnessId( persona?.provider, providers, catalogEntries, @@ -150,6 +150,7 @@ function resolvePersonaExecutionTargetValue( const provenModels = getProvenModelsForHarness?.(harnessId) ?? availableModels; const modelId = normalizeConcreteModelId(persona?.model); + if (!modelId) return undefined; let modelProviderId = persistedModelProviderId( persona ?? {}, harnessId, @@ -260,13 +261,13 @@ export function personaTargetMigration( ); const unknownHarness = Boolean(persona.provider) && - !harnessIdForPersona( + !personaHarnessId( persona.provider, context.providers, context.catalogEntries, ); const persistedAgentProviderForGoose = - harnessIdForPersona( + personaHarnessId( persona.provider, context.providers, context.catalogEntries, diff --git a/src/features/berdctl/commands/impl/createSession.ts b/src/features/berdctl/commands/impl/createSession.ts index 1c4365515..b181d4705 100644 --- a/src/features/berdctl/commands/impl/createSession.ts +++ b/src/features/berdctl/commands/impl/createSession.ts @@ -95,7 +95,7 @@ Result: { berdctlCrossSessionSendOptions }, { GOOSE_PROVIDER_ID }, { normalizeSessionExecutionTarget, targetFromAgentModelSelection }, - { resolvePersonaExecutionTarget }, + { personaHarnessId, resolvePersonaExecutionTarget }, { useAgentStore }, { useProviderModelCacheStore }, { getProviderCatalog }, @@ -118,19 +118,38 @@ Result: import("../runtime/projects"), import("../runtime/providers"), ]); - const requestedHarnessId = args.harness_id ?? GOOSE_PROVIDER_ID; - // The validation legs are independent I/O; overlap them. - const [project, , models, persona] = await Promise.all([ + // Resolve precedence before validating any target field: a complete + // explicit target replaces saved execution metadata, while partial + // overrides still consume and therefore validate the remaining saved fields. + const [project, persona] = await Promise.all([ args.project_id ? findProjectOrThrow(args.project_id) : null, - args.harness_id ? findReadyHarnessOrThrow(args.harness_id) : null, - args.model_id - ? (requestedHarnessId === GOOSE_PROVIDER_ID - ? gooseModelOptions() - : harnessModelOptions(requestedHarnessId) - ).catch(() => []) - : null, args.agent_id ? findPersonaOrThrow(args.agent_id) : null, ]); + const providers = useAgentStore.getState().providers; + const catalogEntries = getProviderCatalog(); + const completeExplicitTarget = Boolean(args.harness_id && args.model_id); + const hasSavedTarget = Boolean( + persona?.provider || persona?.modelProviderId || persona?.model, + ); + const savedHarnessId = persona + ? personaHarnessId(persona.provider, providers, catalogEntries) + : undefined; + if (persona?.provider && !savedHarnessId && !completeExplicitTarget) { + throw new CommandError( + "agent_configuration_invalid", + `Agent "${persona.id}" has a saved provider or model that is no longer available. Update the agent configuration before invoking it.`, + ); + } + const harnessId = args.harness_id ?? savedHarnessId ?? GOOSE_PROVIDER_ID; + await findReadyHarnessOrThrow(harnessId); + + // Refresh and read inventory only after the final harness is known. The + // shared cache retains the freshness/proof distinction used by the UI. + if (harnessId === GOOSE_PROVIDER_ID) { + await gooseModelOptions(); + } else { + await harnessModelOptions(harnessId); + } const modelCache = useProviderModelCacheStore.getState(); const cachedModels = [...modelCache.providers].flatMap(([providerId]) => modelCache.isModelInventoryAuthoritative(providerId) @@ -140,19 +159,38 @@ Result: })) : [], ); - const personaResolution = persona - ? resolvePersonaExecutionTarget(persona, { - providers: useAgentStore.getState().providers, - models: cachedModels, - getModelsForHarness: (harnessId) => - harnessId === GOOSE_PROVIDER_ID ? cachedModels : [], - getProvenModelsForHarness: (harnessId) => - harnessId === GOOSE_PROVIDER_ID ? cachedModels : [], - isModelInventoryAuthoritative: - modelCache.isModelInventoryAuthoritative, - catalogEntries: getProviderCatalog(), - }) - : { status: "absent" as const }; + const modelsForHarness = (candidateHarnessId: string) => + candidateHarnessId === GOOSE_PROVIDER_ID + ? cachedModels + : modelCache.getModelsForProvider(candidateHarnessId); + const provenModelsForHarness = (candidateHarnessId: string) => + candidateHarnessId === GOOSE_PROVIDER_ID + ? cachedModels + : modelCache.getProvenModelsForProvider(candidateHarnessId); + + const effectivePersona = persona + ? { + provider: args.harness_id ?? persona.provider, + modelProviderId: args.harness_id + ? args.harness_id === GOOSE_PROVIDER_ID + ? persona.modelProviderId + : args.harness_id + : persona.modelProviderId, + model: args.model_id ?? persona.model, + } + : null; + const personaResolution = + effectivePersona && hasSavedTarget && !completeExplicitTarget + ? resolvePersonaExecutionTarget(effectivePersona, { + providers, + models: cachedModels, + getModelsForHarness: modelsForHarness, + getProvenModelsForHarness: provenModelsForHarness, + isModelInventoryAuthoritative: + modelCache.isModelInventoryAuthoritative, + catalogEntries, + }) + : { status: "absent" as const }; if (personaResolution.status === "invalid") { throw new CommandError( "agent_configuration_invalid", @@ -163,40 +201,36 @@ Result: personaResolution.status === "valid" ? personaResolution.target : undefined; - const harnessId = - args.harness_id ?? personaTarget?.harnessId ?? GOOSE_PROVIDER_ID; - - // Soft model validation: only reject when the harness's model list is - // known and the id is not in it. On goose a model belongs to a model - // provider (anthropic, openai, ...), so a match also resolves the - // provider the session should run against — mirroring the in-app picker. - let modelProviderId = - harnessId === GOOSE_PROVIDER_ID ? undefined : harnessId; - if (args.model_id && models) { - const match = models.find((model) => model.model_id === args.model_id); - if (match) { - modelProviderId = match.provider ?? modelProviderId; - } else if (models.length > 0) { - throw new CommandError( - "model_not_found", - `Model "${args.model_id}" is not available on "${harnessId}"; list models with \`berdctl info models\`.`, - ); - } + const explicitModelId = args.model_id; + const inventoryModels = modelsForHarness(harnessId); + const explicitModel = explicitModelId + ? inventoryModels.find((model) => model.id === explicitModelId) + : undefined; + if (explicitModelId && !explicitModel) { + throw new CommandError( + "model_not_found", + `Model "${explicitModelId}" is not available on "${harnessId}"; list models with \`berdctl info models\`.`, + ); } - if (args.model_id && !modelProviderId && harnessId === GOOSE_PROVIDER_ID) { + const explicitModelProviderId = + harnessId === GOOSE_PROVIDER_ID ? explicitModel?.providerId : harnessId; + if (explicitModelId && !explicitModelProviderId) { throw new CommandError( "model_not_found", - `Could not resolve a provider for model "${args.model_id}"; list models with \`berdctl info models\` and retry.`, + `Could not resolve a provider for model "${explicitModelId}"; list models with \`berdctl info models\` and retry.`, ); } - const executionTarget = args.model_id + const executionTarget = explicitModelId ? targetFromAgentModelSelection(harnessId, { - modelProviderId: modelProviderId ?? harnessId, - modelId: args.model_id, - modelName: args.model_id, + modelProviderId: explicitModelProviderId ?? harnessId, + modelId: explicitModelId, + modelName: + explicitModel?.displayName ?? + explicitModel?.name ?? + explicitModelId, }) : args.harness_id - ? normalizeSessionExecutionTarget({ harnessId }) + ? (personaTarget ?? normalizeSessionExecutionTarget({ harnessId })) : (personaTarget ?? normalizeSessionExecutionTarget({ harnessId })); const requiresStartupName = Boolean( project && projectRequiresStartupWorkspaceName(project), diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index 4adbdd18b..d72df3fcc 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -4769,17 +4769,7 @@ describe("useChatSessionController", () => { expect(result.current.currentModelName).toBe("Claude Sonnet 4"); }); - it("applies a persona's provider-only target exactly", async () => { - useProviderCatalogStore.getState().mergeEntries([ - { - id: "openai", - displayName: "OpenAI", - category: "model", - description: "OpenAI", - setupMethod: "single_api_key", - group: "default", - }, - ]); + it("rejects a persona's provider-only target", () => { useAgentStore.setState({ personas: [ personaFixture({ @@ -4789,81 +4779,16 @@ describe("useChatSessionController", () => { }), ], }); + const before = useChatSessionStore.getState().getSession("session-1"); const { result } = renderHook(() => useChatSessionController({ sessionId: "session-1" }), ); - act(() => { result.current.handlePersonaChange("persona-1"); }); - - await waitFor(() => { - expect( - useChatSessionStore.getState().getSession("session-1"), - ).toMatchObject({ - executionTarget: { - harnessId: "goose", - modelProviderId: "openai", - }, - }); - expect( - useChatSessionStore.getState().getSession("home-unresolved-persona") - ?.personaId, - ).toBeUndefined(); - }); - expect(mockAcpPrepareSession).toHaveBeenCalledWith( - "session-1", - "openai", - "/tmp/project", - expect.objectContaining({ requestId: expect.any(String) }), - expect.objectContaining({ clear: expect.any(Function) }), + expect(useChatSessionStore.getState().getSession("session-1")).toEqual( + before, ); - }); - - it("keeps a provider-only persona target local while session creation is pending", () => { - useProviderCatalogStore.getState().mergeEntries([ - { - id: "openai", - displayName: "OpenAI", - category: "model", - description: "OpenAI", - setupMethod: "single_api_key", - group: "default", - }, - ]); - useAgentStore.setState({ - personas: [ - personaFixture({ - provider: "goose", - modelProviderId: "openai", - model: undefined, - }), - ], - }); - useChatSessionStore.setState((state) => ({ - sessions: state.sessions.map((candidate) => - candidate.id === "session-1" - ? { ...candidate, creationState: "pending" } - : candidate, - ), - })); - const { result } = renderHook(() => - useChatSessionController({ sessionId: "session-1" }), - ); - - act(() => { - result.current.handlePersonaChange("persona-1"); - }); - - expect( - useChatSessionStore.getState().getSession("session-1"), - ).toMatchObject({ - personaId: "persona-1", - executionTarget: { - harnessId: "goose", - modelProviderId: "openai", - }, - }); expect(mockAcpPrepareSession).not.toHaveBeenCalled(); }); diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index c2ae64224..1239c383f 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -83,7 +83,10 @@ import { markAgentBuilderSessionPreparationFailed, preSeedDraftAgent, } from "@/features/agents/lib/agentBuilderSession"; -import { resolvePersonaExecutionTarget } from "@/features/agents/lib/personaExecutionTarget"; +import { + personaHarnessId, + resolvePersonaExecutionTarget, +} from "@/features/agents/lib/personaExecutionTarget"; import { deletePersonaSource } from "@/shared/api/agents"; import type { Persona } from "@/shared/types/agents"; import { @@ -1863,11 +1866,19 @@ export function useChatSessionController({ return ( (resolution?.status === "valid" ? resolution.target.harnessId - : undefined) ?? selectedAgentId + : targetPersona + ? personaHarnessId( + targetPersona.provider, + providers, + catalogEntries, + ) + : undefined) ?? selectedAgentId ); }, [ + catalogEntries, personas, + providers, resolvePersonaTarget, selectedAgentId, session?.executionTarget?.harnessId, diff --git a/src/shared/api/__tests__/acp.test.ts b/src/shared/api/__tests__/acp.test.ts index 45a9f62c6..88d86eb89 100644 --- a/src/shared/api/__tests__/acp.test.ts +++ b/src/shared/api/__tests__/acp.test.ts @@ -180,6 +180,46 @@ describe("acpSteerMessage", () => { expect(mockSteerSession).not.toHaveBeenCalled(); }); + + it("waits for pending configuration before steering the active run", async () => { + const registry = await import("../acpSessionRegistry"); + const { acpSteerMessage, reserveAcpSessionConfiguration } = await import( + "../acp" + ); + const sessionId = "acp-session-steer-transition"; + registry.registerPreparedSession( + sessionId, + "openai", + "/tmp/project", + "gpt-4.1", + ); + mockSteerSession.mockResolvedValueOnce({ + runId: "run-1", + messageId: "steer-message", + }); + const intent = reserveAcpSessionConfiguration(sessionId); + const steering = acpSteerMessage(sessionId, "run-1", "more"); + await Promise.resolve(); + expect(mockSteerSession).not.toHaveBeenCalled(); + await registry.configureSession( + sessionId, + "anthropic", + "/tmp/project", + "claude-fable", + {}, + intent, + ); + await expect(steering).resolves.toEqual({ + runId: "run-1", + messageId: "steer-message", + }); + expect(mockSteerSession).toHaveBeenCalledWith( + sessionId, + [{ type: "text", text: "more" }], + "run-1", + undefined, + ); + }); }); describe("acpSendMessage", () => { diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index 259ba8335..2a5b844fe 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -330,7 +330,6 @@ export async function acpSteerMessage( "assistantPrompt" | "goose" | "images" > = {}, ): Promise { - sessionRegistry.requireSessionInvocationSelection(sessionId); const { assistantPrompt, goose, images } = options; const content: ContentBlock[] = []; const assistantText = assistantPrompt?.trim(); @@ -348,11 +347,13 @@ export async function acpSteerMessage( } } - return directAcp.steerSession( - sessionId, - content, - expectedRunId, - goose && Object.keys(goose).length > 0 ? { goose } : undefined, + return sessionRegistry.runPreparedSessionInvocation(sessionId, () => + directAcp.steerSession( + sessionId, + content, + expectedRunId, + goose && Object.keys(goose).length > 0 ? { goose } : undefined, + ), ); } diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 51d3ed155..248affed4 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -505,10 +505,12 @@ export function requireSessionInvocationSelection( return { ...invocationSelection, modelId: selection.modelId }; } -/** Run prompt setup and transport without allowing session config to interleave. */ -export async function runPreparedSessionPrompt( +/** Run invocation transport without allowing session config to interleave. */ +export async function runPreparedSessionInvocation( sessionId: string, - prompt: (providerId: string) => Promise, + invoke: ( + selection: AcpSessionExecutionSelection & { modelId: string }, + ) => Promise, ): Promise { let pending = mutationQueues.get(sessionId)?.pendingSupersession; while (pending) { @@ -517,11 +519,21 @@ export async function runPreparedSessionPrompt( } return serializeSessionMutation( sessionId, - () => prompt(requireSessionInvocationSelection(sessionId).providerId), + () => invoke(requireSessionInvocationSelection(sessionId)), false, ); } +/** Run prompt setup and transport without allowing session config to interleave. */ +export function runPreparedSessionPrompt( + sessionId: string, + prompt: (providerId: string) => Promise, +): Promise { + return runPreparedSessionInvocation(sessionId, ({ providerId }) => + prompt(providerId), + ); +} + export async function loadSession( sessionId: string, workingDir: string, diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index da8b9eb41..e2fc8b3b8 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -1526,10 +1526,10 @@ export function GlobalComposerPill({ {personaConfigurationInvalid && !personaSelectionOverridden ? (
{t("globalPill.invalidAgentConfiguration")} -
+
{selectedPersonaId && onEditAgent ? (