From 5bcebc3267ea2832af40c44040243ec09a53a100 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 2 Aug 2026 22:18:03 +0100 Subject: [PATCH] fix: retire model selection ownership --- .../pages/chat/chat-command-executor.test.ts | 2 + ui/src/pages/chat/chat-command-executor.ts | 1 + ui/src/pages/chat/chat-pane-context.ts | 10 +++++ ui/src/pages/chat/chat-pane-lifecycle.test.ts | 45 +++++++++++++++++++ ui/src/pages/chat/chat-session.ts | 19 ++++++++ ui/src/pages/chat/chat-state-page.ts | 12 ++++- 6 files changed, 88 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/chat/chat-command-executor.test.ts b/ui/src/pages/chat/chat-command-executor.test.ts index 53cad84f3b3f..427d5d055960 100644 --- a/ui/src/pages/chat/chat-command-executor.test.ts +++ b/ui/src/pages/chat/chat-command-executor.test.ts @@ -28,6 +28,7 @@ function createSessionCapability(client: GatewayBrowserClient): SessionCapabilit create: async () => null, patch: (key: string, patch: SessionPatch, options: SessionPatchOptions = {}) => request("sessions.patch", { key, agentId: options.agentId, ...patch }), + setModelOverride: () => undefined, delete: async () => false, deleteMany: async () => ({ deleted: [], errors: [], preservedWorktrees: [] }), reset: async () => true, @@ -176,6 +177,7 @@ describe("executeSlashCommand directives", () => { expect.objectContaining({ agentId: "work", deferModelOverride: true, + ownsModelOverride: expect.any(Function), }), ); expect(setModelOverride).toHaveBeenCalledWith("global", "openai/gpt-5-mini"); diff --git a/ui/src/pages/chat/chat-command-executor.ts b/ui/src/pages/chat/chat-command-executor.ts index 76ca1ae5cd91..e946505e19eb 100644 --- a/ui/src/pages/chat/chat-command-executor.ts +++ b/ui/src/pages/chat/chat-command-executor.ts @@ -316,6 +316,7 @@ async function executeModel( }, { deferModelOverride: true, + ownsModelOverride: context.ownsModelOverride, reconcile: async (result) => { const resolvedModel = result.resolved?.model ?? requestedModel; let resolvedValue = resolvePreferredServerChatModelValue( diff --git a/ui/src/pages/chat/chat-pane-context.ts b/ui/src/pages/chat/chat-pane-context.ts index 479c8d3c1c6c..dc62d3b77649 100644 --- a/ui/src/pages/chat/chat-pane-context.ts +++ b/ui/src/pages/chat/chat-pane-context.ts @@ -14,6 +14,7 @@ import { resolveSessionKey } from "../../lib/sessions/index.ts"; import { buildAgentMainSessionKey, canonicalUiSessionKeyForPersistence, + isUiGlobalSessionKey, parseAgentSessionKey, resolveUiConfiguredMainKey, uiSessionEventMatches, @@ -26,6 +27,7 @@ import { resolveAssistantAttachmentAuthToken } from "./chat-pane-state.ts"; import { markQueuedChatSendsWaitingForReconnect } from "./chat-queue.ts"; import { stopChatRealtimeTalk } from "./chat-realtime.ts"; import { retryReconnectableQueuedChatSends } from "./chat-send-actions.ts"; +import { retireChatModelSelectionOwnership } from "./chat-session.ts"; import { invalidateChatMetadataCache, refreshChatModelAuthStatus, @@ -169,6 +171,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { } const previousMediaAuthToken = resolveAssistantAttachmentAuthToken(state); const wasConnected = state.connected; + const previousAssistantAgentId = state.assistantAgentId; const previousSidebarSessionKey = canonicalUiSessionKeyForPersistence(state, state.sessionKey); const connectionLifecycle = (this.gatewayConnectionLifecycle ??= createGatewayConnectionLifecycle({ @@ -216,6 +219,13 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { this.resetOlderMessagesViewport(); state.chatLoading = false; } + if ( + sourceChanged || + (previousAssistantAgentId !== snapshot.assistantAgentId && + isUiGlobalSessionKey(state.sessionKey)) + ) { + retireChatModelSelectionOwnership(state); + } state.client = snapshot.client; state.connected = snapshot.phase === "connected"; state.connectionEpoch = this.connectionGeneration; diff --git a/ui/src/pages/chat/chat-pane-lifecycle.test.ts b/ui/src/pages/chat/chat-pane-lifecycle.test.ts index f4fafbad5098..f30a5c62ebe1 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.test.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.test.ts @@ -920,6 +920,51 @@ describe("chat pane connection lifecycle", () => { expect(state.chatIsProgrammaticScroll).toBe(false); }); + it("retires pending model selection state when the Gateway owner changes", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const setModelOverride = vi.fn(); + const sessions = { setModelOverride } as unknown as SessionCapability; + const { pane, state } = createTestChatPane({ client, sessions }); + state.sessionKey = "global"; + state.chatModelSwitchPromises = { + global: new Promise(() => {}), + }; + + pane.applyGatewaySnapshot({ + ...pane.context.gateway.snapshot, + client, + phase: "reconnecting", + hello: null, + }); + + expect(state.chatModelSwitchPromises).toEqual({}); + expect(setModelOverride).toHaveBeenCalledWith("global", undefined); + }); + + it("retires pending global model selection state when the selected agent changes", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const setModelOverride = vi.fn(); + const sessions = { setModelOverride } as unknown as SessionCapability; + const { pane, state } = createTestChatPane({ client, sessions }); + const snapshot = { + ...pane.context.gateway.snapshot, + client, + phase: "connected" as const, + assistantAgentId: "work", + }; + pane.applyGatewaySnapshot(snapshot); + state.sessionKey = "global"; + state.chatModelSwitchPromises = { + global: new Promise(() => {}), + }; + setModelOverride.mockClear(); + + pane.applyGatewaySnapshot({ ...snapshot, assistantAgentId: "main" }); + + expect(state.chatModelSwitchPromises).toEqual({}); + expect(setModelOverride).toHaveBeenCalledWith("global", undefined); + }); + it("refreshes the transcript before secondary hydration after a same-client reconnect", () => { const request = vi.fn(() => new Promise(() => {})); const client = { diff --git a/ui/src/pages/chat/chat-session.ts b/ui/src/pages/chat/chat-session.ts index 79e33fa74875..08851e0f56d0 100644 --- a/ui/src/pages/chat/chat-session.ts +++ b/ui/src/pages/chat/chat-session.ts @@ -54,6 +54,25 @@ type ChatIdleSessionReconciliationHost = SessionScopeHost & { sessionsResult?: SessionsListResult | null; }; +export function retireChatModelSelectionOwnership( + host: Pick< + ChatModelSettingsHost, + "chatModelSwitchPromises" | "requestUpdate" | "sessionKey" | "sessions" + >, +): void { + const hasPendingSwitch = Object.keys(host.chatModelSwitchPromises ?? {}).length > 0; + const hasModelOverride = Object.hasOwn( + host.sessions.state?.modelOverrides ?? {}, + host.sessionKey, + ); + if (!hasPendingSwitch && !hasModelOverride) { + return; + } + host.chatModelSwitchPromises = {}; + host.sessions.setModelOverride(host.sessionKey, undefined); + host.requestUpdate?.(); +} + function buildChatSessionListOptions( state: ChatSessionListHost, options: { offset?: number; append?: boolean; search?: string | null } = {}, diff --git a/ui/src/pages/chat/chat-state-page.ts b/ui/src/pages/chat/chat-state-page.ts index 2d428834c313..8b3ccbfe0314 100644 --- a/ui/src/pages/chat/chat-state-page.ts +++ b/ui/src/pages/chat/chat-state-page.ts @@ -4,7 +4,10 @@ import { fetchAssistantIdentity } from "../../app/assistant-identity.ts"; import type { ApplicationContext } from "../../app/context.ts"; import { loadLocalUserIdentity, loadSettings, patchSettings } from "../../app/settings.ts"; import { resolveSafeExternalUrl } from "../../lib/open-external-url.ts"; -import { canonicalUiSessionKeyForPersistence } from "../../lib/sessions/session-key.ts"; +import { + canonicalUiSessionKeyForPersistence, + isUiGlobalSessionKey, +} from "../../lib/sessions/session-key.ts"; import { resolveAgentIdForSession } from "./chat-avatar.ts"; import { removeQueuedMessage } from "./chat-queue.ts"; import { attachChatRealtimeActions, createInitialChatRealtimeState } from "./chat-realtime.ts"; @@ -14,6 +17,7 @@ import { steerQueuedChatMessage, } from "./chat-send-actions.ts"; import { handleSendChat } from "./chat-send-submit.ts"; +import { retireChatModelSelectionOwnership } from "./chat-session.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; import { handleChatDraftChange, @@ -86,6 +90,12 @@ async function loadPageAssistantIdentity( ) { return; } + if ( + state.assistantAgentId !== (identity.agentId ?? null) && + isUiGlobalSessionKey(state.sessionKey) + ) { + retireChatModelSelectionOwnership(state); + } state.assistantName = identity.name; state.assistantAvatar = identity.avatar; state.assistantAvatarSource = identity.avatarSource ?? null;