From 65a1eec782f0d496fa026038499133e964566ca3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 12:46:48 +0000 Subject: [PATCH] fix(ui): keep manual model override after delegated subtask completes Synthetic subagent-completion nudges were treated as the latest user model choice and rehydrated the agent default, while setAgent preferred the agent pin over the session override. Skip synthetic prompts for restore, preserve manual selection-store overrides, and prefer session agent models in setAgent. Closes openchamber/openchamber#2404 Co-authored-by: Serhii Dziupin --- .../ui/src/components/chat/ModelControls.tsx | 63 +++--- .../src/lib/messages/userModelChoice.test.ts | 145 +++++++++++++ .../ui/src/lib/messages/userModelChoice.ts | 103 +++++++++ packages/ui/src/stores/useConfigStore.test.ts | 67 ++++++ packages/ui/src/stores/useConfigStore.ts | 30 +-- .../ui/src/sync/__tests__/issue-2404.test.ts | 200 ++++++++++++++++++ packages/ui/src/sync/session-ui-store.ts | 39 ++-- 7 files changed, 579 insertions(+), 68 deletions(-) create mode 100644 packages/ui/src/lib/messages/userModelChoice.test.ts create mode 100644 packages/ui/src/lib/messages/userModelChoice.ts create mode 100644 packages/ui/src/sync/__tests__/issue-2404.test.ts diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 3fbf959b..262664b9 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -40,6 +40,11 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness'; import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts'; import { markStartupTrace } from '@/lib/startupTrace'; +import { + findLatestUserModelChoice, + shouldPreserveManualModelOverride, +} from '@/lib/messages/userModelChoice'; +import { getSyncParts } from '@/sync/sync-refs'; type IconComponent = IconName; @@ -645,37 +650,14 @@ export const ModelControls: React.FC = ({ currentSessionDirectory ?? undefined, ); const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined); + // Skip synthetic subagent-completion nudges — restoring from them resets a + // manual model override back to the agent default (issue #2404). const latestLoadedUserChoice = React.useMemo(() => { - for (let i = currentSessionMessagesFromSync.length - 1; i >= 0; i -= 1) { - const message = currentSessionMessagesFromSync[i] as typeof currentSessionMessagesFromSync[number] & { - model?: { providerID?: string; modelID?: string; variant?: string }; - variant?: string; - mode?: string; - }; - if (message.role !== 'user') { - continue; - } - - const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0 - ? message.model.providerID - : undefined; - const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0 - ? message.model.modelID - : undefined; - const agent = typeof message.agent === 'string' && message.agent.trim().length > 0 - ? message.agent - : (typeof message.mode === 'string' && message.mode.trim().length > 0 ? message.mode : undefined); - // OpenCode 1.4.0 moved variant from top-level to model.variant. - // Prefer the new location, fall back to the legacy one for older servers. - const variantCandidate = message.model?.variant ?? message.variant; - const variant = typeof variantCandidate === 'string' && variantCandidate.trim().length > 0 - ? variantCandidate - : undefined; - - return { id: message.id, agent, providerID, modelID, variant }; - } - return null; - }, [currentSessionMessagesFromSync]); + return findLatestUserModelChoice( + currentSessionMessagesFromSync, + (messageId) => getSyncParts(messageId, currentSessionDirectory ?? undefined), + ); + }, [currentSessionDirectory, currentSessionMessagesFromSync]); const tryApplyModelSelection = React.useCallback( (providerId: string, modelId: string, agentName?: string): ModelApplyResult => { @@ -828,6 +810,25 @@ export const ModelControls: React.FC = ({ return; } + // Manual session override wins over historical / synthetic message metadata. + const savedSessionModel = getSessionModelSelection(currentSessionId); + if (shouldPreserveManualModelOverride({ + selectionSource: useConfigStore.getState().selectionSource, + savedSessionModel, + candidate: latestLoadedUserChoice, + })) { + if (savedSessionModel) { + applyModelSelectionWithVariant( + savedSessionModel.providerId, + savedSessionModel.modelId, + resolveModelVariantSelection(savedSessionModel.providerId, savedSessionModel.modelId), + currentAgentName || undefined, + ); + } + latestLoadedUserChoiceRestoreRef.current = restoreKey; + return; + } + if (latestLoadedUserChoice.agent && currentAgentName !== latestLoadedUserChoice.agent) { setAgent(latestLoadedUserChoice.agent); } @@ -869,6 +870,8 @@ export const ModelControls: React.FC = ({ setAgent, applyModelSelectionWithVariant, getModelVariantOptions, + getSessionModelSelection, + resolveModelVariantSelection, saveSessionAgentSelection, saveAgentModelVariantForSession, saveSessionModelSelection, diff --git a/packages/ui/src/lib/messages/userModelChoice.test.ts b/packages/ui/src/lib/messages/userModelChoice.test.ts new file mode 100644 index 00000000..e0c43ef9 --- /dev/null +++ b/packages/ui/src/lib/messages/userModelChoice.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from 'bun:test' +import type { Message, Part } from '@opencode-ai/sdk/v2' + +import { + extractUserModelChoice, + findLatestUserModelChoice, + shouldPreserveManualModelOverride, +} from './userModelChoice' + +const userMessage = ( + id: string, + model: { providerID: string; modelID: string }, + agent = 'custom-agent', +): Message => ({ + id, + sessionID: 'ses_1', + role: 'user', + time: { created: 1 }, + agent, + model, +} as Message) + +const assistantMessage = (id: string): Message => ({ + id, + sessionID: 'ses_1', + role: 'assistant', + time: { created: 2 }, + parentID: 'u1', + modelID: 'model-a', + providerID: 'provider', +} as Message) + +const textPart = (id: string, text: string, synthetic = false): Part => ({ + id, + sessionID: 'ses_1', + messageID: 'u1', + type: 'text', + text, + ...(synthetic ? { synthetic: true } : {}), +} as Part) + +describe('findLatestUserModelChoice', () => { + test('returns the latest real user prompt model', () => { + const messages = [ + userMessage('u1', { providerID: 'provider', modelID: 'model-a' }), + assistantMessage('a1'), + userMessage('u2', { providerID: 'provider', modelID: 'model-b' }), + ] + const partsById: Record = { + u1: [textPart('p1', 'first')], + u2: [textPart('p2', 'second')], + } + + const choice = findLatestUserModelChoice(messages, (id) => partsById[id]) + expect(choice?.id).toBe('u2') + expect(choice?.modelID).toBe('model-b') + expect(choice?.providerID).toBe('provider') + expect(choice?.agent).toBe('custom-agent') + }) + + test('[issue-2404] skips synthetic subagent-completion nudges so manual override is not clobbered', () => { + // Real prompt sent with the manual override (model-b). + const realPrompt = userMessage('u-real', { providerID: 'provider', modelID: 'model-b' }) + // After a delegated child session goes idle, OpenCode injects a synthetic + // user nudge that often carries the agent default model (model-a). + const syntheticNudge = userMessage('u-nudge', { providerID: 'provider', modelID: 'model-a' }) + const messages = [realPrompt, assistantMessage('a1'), syntheticNudge] + const partsById: Record = { + 'u-real': [textPart('p-real', 'please investigate', false)], + 'u-nudge': [textPart('p-nudge', 'Subagent finished.', true)], + } + + const choice = findLatestUserModelChoice(messages, (id) => partsById[id]) + expect(choice?.id).toBe('u-real') + expect(choice?.modelID).toBe('model-b') + }) + + test('skips user messages whose parts have not loaded yet', () => { + const messages = [ + userMessage('u1', { providerID: 'provider', modelID: 'model-a' }), + userMessage('u2', { providerID: 'provider', modelID: 'model-b' }), + ] + const partsById: Record = { + u1: [textPart('p1', 'first')], + // u2 parts missing + } + + const choice = findLatestUserModelChoice(messages, (id) => partsById[id]) + expect(choice?.id).toBe('u1') + expect(choice?.modelID).toBe('model-a') + }) + + test('returns null when only synthetic user messages exist', () => { + const messages = [userMessage('u-nudge', { providerID: 'provider', modelID: 'model-a' })] + const partsById: Record = { + 'u-nudge': [textPart('p-nudge', 'Subagent finished.', true)], + } + + expect(findLatestUserModelChoice(messages, (id) => partsById[id])).toBeNull() + }) +}) + +describe('shouldPreserveManualModelOverride', () => { + test('preserves manual override when it differs from the candidate message model', () => { + expect(shouldPreserveManualModelOverride({ + selectionSource: 'manual', + savedSessionModel: { providerId: 'provider', modelId: 'model-b' }, + candidate: { providerID: 'provider', modelID: 'model-a' }, + })).toBe(true) + }) + + test('does not preserve when selection matches the candidate', () => { + expect(shouldPreserveManualModelOverride({ + selectionSource: 'manual', + savedSessionModel: { providerId: 'provider', modelId: 'model-b' }, + candidate: { providerID: 'provider', modelID: 'model-b' }, + })).toBe(false) + }) + + test('does not preserve auto selections', () => { + expect(shouldPreserveManualModelOverride({ + selectionSource: 'auto', + savedSessionModel: { providerId: 'provider', modelId: 'model-b' }, + candidate: { providerID: 'provider', modelID: 'model-a' }, + })).toBe(false) + }) + + test('preserves manual override when candidate has no model', () => { + expect(shouldPreserveManualModelOverride({ + selectionSource: 'manual', + savedSessionModel: { providerId: 'provider', modelId: 'model-b' }, + candidate: { providerID: undefined, modelID: undefined }, + })).toBe(true) + }) +}) + +describe('extractUserModelChoice', () => { + test('reads variant from model.variant', () => { + const message = { + ...userMessage('u1', { providerID: 'provider', modelID: 'model-b' }), + model: { providerID: 'provider', modelID: 'model-b', variant: 'high' }, + } as Message + expect(extractUserModelChoice(message as never)?.variant).toBe('high') + }) +}) diff --git a/packages/ui/src/lib/messages/userModelChoice.ts b/packages/ui/src/lib/messages/userModelChoice.ts new file mode 100644 index 00000000..a94bfbfc --- /dev/null +++ b/packages/ui/src/lib/messages/userModelChoice.ts @@ -0,0 +1,103 @@ +import type { Message, Part } from '@opencode-ai/sdk/v2' + +import { isFullySyntheticMessage } from './synthetic' + +type UserModelChoice = { + id: string + agent?: string + providerID?: string + modelID?: string + variant?: string +} + +type MessageLike = Message & { + model?: { providerID?: string; modelID?: string; variant?: string } + variant?: string + mode?: string +} + +/** + * Extract agent/model selection metadata from a user message, if present. + */ +export const extractUserModelChoice = (message: MessageLike): UserModelChoice | null => { + if (message.role !== 'user') { + return null + } + + const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0 + ? message.model.providerID + : undefined + const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0 + ? message.model.modelID + : undefined + const agent = typeof message.agent === 'string' && message.agent.trim().length > 0 + ? message.agent + : (typeof message.mode === 'string' && message.mode.trim().length > 0 ? message.mode : undefined) + // OpenCode 1.4.0 moved variant from top-level to model.variant. + const variantCandidate = message.model?.variant ?? message.variant + const variant = typeof variantCandidate === 'string' && variantCandidate.trim().length > 0 + ? variantCandidate + : undefined + + return { id: message.id, agent, providerID, modelID, variant } +} + +/** + * Find the latest *real* user prompt's model/agent choice. + * + * Synthetic user messages (e.g. subagent-completion nudges injected when a + * delegated child session goes idle) must not drive the composer model + * selector — restoring from them clobber a manual session override and reset + * to the agent default. + * + * Messages whose parts have not been loaded yet are skipped so an incomplete + * snapshot cannot be treated as authoritative. + */ +export const findLatestUserModelChoice = ( + messages: readonly MessageLike[], + getParts: (messageId: string) => Part[] | undefined, +): UserModelChoice | null => { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] + if (message.role !== 'user') { + continue + } + + const parts = getParts(message.id) + if (!Array.isArray(parts) || parts.length === 0) { + continue + } + if (isFullySyntheticMessage(parts)) { + continue + } + + return extractUserModelChoice(message) + } + + return null +} + +/** + * When the user has a manual session model override, historical (or synthetic) + * user-message metadata must not overwrite it. After a real send the selection + * store is updated to match the message, so a conflict means the picker was + * changed after the last prompt — keep the override. + */ +export const shouldPreserveManualModelOverride = ({ + selectionSource, + savedSessionModel, + candidate, +}: { + selectionSource: 'auto' | 'manual' | undefined + savedSessionModel: { providerId: string; modelId: string } | null | undefined + candidate: Pick | null | undefined +}): boolean => { + if (selectionSource !== 'manual' || !savedSessionModel?.providerId || !savedSessionModel.modelId) { + return false + } + if (!candidate?.providerID || !candidate.modelID) { + return true + } + return savedSessionModel.providerId !== candidate.providerID + || savedSessionModel.modelId !== candidate.modelID +} diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index 0b2b18c6..52dbe3e6 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -522,6 +522,73 @@ describe('useConfigStore provider persistence', () => { expect(state.currentVariant).toBe('high'); }); + test('[issue-2404] setAgent keeps session model override over agent default model', () => { + // Custom agent default is model-a; user manually overrode to model-b for this session. + // Re-applying setAgent (e.g. after delegated subtask completion rematerializes the + // parent) must keep model-b rather than resetting to the agent pin. + const sessionId = 'ses_2404_model_override'; + const multiModelProvider = { + ...provider('provider', 'model-a'), + models: [ + provider('provider', 'model-a').models[0], + provider('provider', 'model-b').models[0], + ], + }; + useSessionUIStore.setState({ currentSessionId: sessionId }); + useSelectionStore.getState().saveSessionModelSelection(sessionId, 'provider', 'model-b'); + useSelectionStore.getState().saveAgentModelForSession(sessionId, 'custom-agent', 'provider', 'model-b'); + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [multiModelProvider], + agents: [testAgent('custom-agent', { model: { providerID: 'provider', modelID: 'model-a' } })], + currentProviderId: 'provider', + currentModelId: 'model-b', + currentAgentName: 'custom-agent', + selectionSource: 'manual', + currentVariant: undefined, + directoryScoped: {}, + }); + + useConfigStore.getState().setAgent('custom-agent'); + + const state = useConfigStore.getState(); + expect(state.currentProviderId).toBe('provider'); + expect(state.currentModelId).toBe('model-b'); + expect(useSelectionStore.getState().getAgentModelForSession(sessionId, 'custom-agent')).toEqual({ + providerId: 'provider', + modelId: 'model-b', + }); + }); + + test('[issue-2404] setAgent uses agent default when no session override exists', () => { + const sessionId = 'ses_2404_agent_default'; + const multiModelProvider = { + ...provider('provider', 'model-a'), + models: [ + provider('provider', 'model-a').models[0], + provider('provider', 'model-b').models[0], + ], + }; + useSessionUIStore.setState({ currentSessionId: sessionId }); + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [multiModelProvider], + agents: [testAgent('custom-agent', { model: { providerID: 'provider', modelID: 'model-a' } })], + currentProviderId: 'provider', + currentModelId: 'model-b', + currentAgentName: undefined, + selectionSource: 'auto', + currentVariant: undefined, + directoryScoped: {}, + }); + + useConfigStore.getState().setAgent('custom-agent'); + + const state = useConfigStore.getState(); + expect(state.currentProviderId).toBe('provider'); + expect(state.currentModelId).toBe('model-a'); + }); + test('loadAgents does not fetch OpenCode config directly', async () => { useConfigStore.setState({ activeDirectoryKey: DIRECTORY, diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 4fe82ee2..4a7dfd2e 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -2503,20 +2503,13 @@ export const useConfigStore = create()( return undefined; }; - // Prefer the selected agent's configured model when switching agents. const agent = agents.find((candidate) => candidate.name === agentName); - const agentModelSelection = agent?.model; - if (agentModelSelection?.providerID && agentModelSelection?.modelID) { - const { providerID, modelID } = agentModelSelection; - const agentProvider = providers.find((provider) => provider.id === providerID); - const agentModel = agentProvider?.models.find((model) => model.id === modelID); - - if (agentModel) { - applyResolvedModelSelection(providerID, modelID, resolveVariantForModel(providerID, modelID, agent?.variant)); - return; - } - } + // Prefer a session-level manual override for this agent over the + // agent's configured default. Re-applying setAgent after subtask + // completion / rematerialization must not clobber the override + // (issue #2404). Explicit agent-picker switches still force the + // agent default via ModelControls' shouldPreferAgentModel path. if (currentSessionId) { const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName); if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) { @@ -2532,6 +2525,19 @@ export const useConfigStore = create()( } } + // No session override — use the agent's configured/pinned model. + const agentModelSelection = agent?.model; + if (agentModelSelection?.providerID && agentModelSelection?.modelID) { + const { providerID, modelID } = agentModelSelection; + const agentProvider = providers.find((provider) => provider.id === providerID); + const agentModel = agentProvider?.models.find((model) => model.id === modelID); + + if (agentModel) { + applyResolvedModelSelection(providerID, modelID, resolveVariantForModel(providerID, modelID, agent?.variant)); + return; + } + } + // If the agent has no preferred model, use settings default. if (settingsDefaultModel) { const parsed = parseModelString(settingsDefaultModel); diff --git a/packages/ui/src/sync/__tests__/issue-2404.test.ts b/packages/ui/src/sync/__tests__/issue-2404.test.ts new file mode 100644 index 00000000..16bf87a5 --- /dev/null +++ b/packages/ui/src/sync/__tests__/issue-2404.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { Message, Part } from '@opencode-ai/sdk/v2' + +import { + findLatestUserModelChoice, + shouldPreserveManualModelOverride, +} from '@/lib/messages/userModelChoice' + +/** + * Regression for openchamber/openchamber#2404: + * custom agent default model A → manual override to B → delegate subtask → + * after child completes, synthetic completion nudge must not revert to A. + */ +describe('issue #2404 model override persistence across delegated subtask', () => { + const sessionId = 'ses_2404' + const agentName = 'custom-agent' + const agentDefault = { providerID: 'provider', modelID: 'model-a' } + const manualOverride = { providerID: 'provider', modelID: 'model-b' } + + let sessionModelSelections: Map + let sessionAgentModelSelections: Map> + let selectionSource: 'auto' | 'manual' + let currentProviderId: string + let currentModelId: string + + beforeEach(() => { + sessionModelSelections = new Map() + sessionAgentModelSelections = new Map() + selectionSource = 'auto' + currentProviderId = agentDefault.providerID + currentModelId = agentDefault.modelID + }) + + const createSessionWithAgentDefault = () => { + // Session starts on the custom agent's pinned model A. + currentProviderId = agentDefault.providerID + currentModelId = agentDefault.modelID + selectionSource = 'auto' + sessionModelSelections.set(sessionId, { + providerId: agentDefault.providerID, + modelId: agentDefault.modelID, + }) + sessionAgentModelSelections.set(sessionId, new Map([ + [agentName, { providerId: agentDefault.providerID, modelId: agentDefault.modelID }], + ])) + } + + const setManualModelOverride = () => { + selectionSource = 'manual' + currentProviderId = manualOverride.providerID + currentModelId = manualOverride.modelID + sessionModelSelections.set(sessionId, { + providerId: manualOverride.providerID, + modelId: manualOverride.modelID, + }) + const agentMap = sessionAgentModelSelections.get(sessionId) ?? new Map() + agentMap.set(agentName, { + providerId: manualOverride.providerID, + modelId: manualOverride.modelID, + }) + sessionAgentModelSelections.set(sessionId, agentMap) + } + + const completeDelegatedSubtask = () => { + // Parent already has the real user prompt (sent with override B) plus a + // synthetic subagent-completion nudge that carries the agent default A. + const messages: Message[] = [ + { + id: 'u-real', + sessionID: sessionId, + role: 'user', + time: { created: 1 }, + agent: agentName, + model: manualOverride, + } as Message, + { + id: 'a1', + sessionID: sessionId, + role: 'assistant', + time: { created: 2 }, + parentID: 'u-real', + providerID: manualOverride.providerID, + modelID: manualOverride.modelID, + } as Message, + { + id: 'u-nudge', + sessionID: sessionId, + role: 'user', + time: { created: 3 }, + agent: agentName, + model: agentDefault, + } as Message, + ] + const partsById: Record = { + 'u-real': [{ + id: 'p-real', + sessionID: sessionId, + messageID: 'u-real', + type: 'text', + text: 'Delegate a subtask', + } as Part], + 'u-nudge': [{ + id: 'p-nudge', + sessionID: sessionId, + messageID: 'u-nudge', + type: 'text', + text: 'Subagent finished.', + synthetic: true, + } as Part], + } + + const latestChoice = findLatestUserModelChoice(messages, (id) => partsById[id]) + const saved = sessionModelSelections.get(sessionId) ?? null + + // Composer restore must ignore the synthetic nudge and keep the override. + expect(latestChoice?.modelID).toBe(manualOverride.modelID) + expect(shouldPreserveManualModelOverride({ + selectionSource, + savedSessionModel: saved, + candidate: { + providerID: agentDefault.providerID, + modelID: agentDefault.modelID, + }, + })).toBe(true) + + // Re-applying the session agent (as ModelControls may after rematerialization) + // must also prefer the stored override over the agent pin. + const agentOverride = sessionAgentModelSelections.get(sessionId)?.get(agentName) + if (agentOverride) { + currentProviderId = agentOverride.providerId + currentModelId = agentOverride.modelId + } else { + currentProviderId = agentDefault.providerID + currentModelId = agentDefault.modelID + } + } + + test('manual override survives delegated subtask completion', () => { + createSessionWithAgentDefault() + setManualModelOverride() + completeDelegatedSubtask() + + expect(selectionSource).toBe('manual') + expect(currentProviderId).toBe(manualOverride.providerID) + expect(currentModelId).toBe(manualOverride.modelID) + expect(sessionModelSelections.get(sessionId)).toEqual({ + providerId: manualOverride.providerID, + modelId: manualOverride.modelID, + }) + }) + + test('agent default is used when no manual override was set', () => { + createSessionWithAgentDefault() + // No setManualModelOverride — stay on agent default through subtask completion. + const messages: Message[] = [ + { + id: 'u-real', + sessionID: sessionId, + role: 'user', + time: { created: 1 }, + agent: agentName, + model: agentDefault, + } as Message, + { + id: 'u-nudge', + sessionID: sessionId, + role: 'user', + time: { created: 2 }, + agent: agentName, + model: agentDefault, + } as Message, + ] + const partsById: Record = { + 'u-real': [{ + id: 'p-real', + sessionID: sessionId, + messageID: 'u-real', + type: 'text', + text: 'Delegate a subtask', + } as Part], + 'u-nudge': [{ + id: 'p-nudge', + sessionID: sessionId, + messageID: 'u-nudge', + type: 'text', + text: 'Subagent finished.', + synthetic: true, + } as Part], + } + + const latestChoice = findLatestUserModelChoice(messages, (id) => partsById[id]) + expect(latestChoice?.modelID).toBe(agentDefault.modelID) + expect(shouldPreserveManualModelOverride({ + selectionSource: 'auto', + savedSessionModel: sessionModelSelections.get(sessionId), + candidate: latestChoice, + })).toBe(false) + expect(currentModelId).toBe(agentDefault.modelID) + }) +}) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 895f5062..4ecdb87d 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -30,6 +30,7 @@ import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { normalizePath } from "@/lib/pathNormalization" import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" +import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice" import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree" import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap" import { getWorktreeSetupWaitEnabled } from "@/lib/openchamberConfig" @@ -1713,33 +1714,19 @@ export const useSessionUIStore = create()((set, get) => ({ getLastUserChoice: (sessionId) => { const directory = get().getDirectoryForSession(sessionId) ?? undefined const messages = getSyncMessages(sessionId, directory) - for (let i = messages.length - 1; i >= 0; i -= 1) { - const message = messages[i] as Message & { - model?: { providerID?: string; modelID?: string; variant?: string } - variant?: string - mode?: string - } - if (message.role !== "user") { - continue - } - - const providerID = typeof message.model?.providerID === "string" && message.model.providerID.trim().length > 0 - ? message.model.providerID - : undefined - const modelID = typeof message.model?.modelID === "string" && message.model.modelID.trim().length > 0 - ? message.model.modelID - : undefined - const agent = typeof message.agent === "string" && message.agent.trim().length > 0 - ? message.agent - : (typeof message.mode === "string" && message.mode.trim().length > 0 ? message.mode : undefined) - const variantCandidate = message.model?.variant ?? message.variant - const variant = typeof variantCandidate === "string" && variantCandidate.trim().length > 0 - ? variantCandidate - : undefined - - return { agent, providerID, modelID, variant } + const choice = findLatestUserModelChoice( + messages, + (messageId) => getSyncParts(messageId, directory), + ) + if (!choice) { + return null + } + return { + agent: choice.agent, + providerID: choice.providerID, + modelID: choice.modelID, + variant: choice.variant, } - return null }, getCurrentAgent: (sessionId) => {