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) => {