Merge pull request #2622 from openchamber/feat/model-override-persistence-5865

fix(ui): persist manual model override across delegated subtask completion (#2404)
This commit is contained in:
Serhii Dziupin
2026-08-04 17:27:03 +03:00
committed by GitHub
7 changed files with 579 additions and 68 deletions
@@ -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<ModelControlsProps> = ({
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<ModelControlsProps> = ({
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<ModelControlsProps> = ({
setAgent,
applyModelSelectionWithVariant,
getModelVariantOptions,
getSessionModelSelection,
resolveModelVariantSelection,
saveSessionAgentSelection,
saveAgentModelVariantForSession,
saveSessionModelSelection,
@@ -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<string, Part[]> = {
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<string, Part[]> = {
'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<string, Part[]> = {
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<string, Part[]> = {
'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')
})
})
@@ -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<UserModelChoice, 'providerID' | 'modelID'> | 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
}
@@ -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,
+18 -12
View File
@@ -2503,20 +2503,13 @@ export const useConfigStore = create<ConfigStore>()(
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<ConfigStore>()(
}
}
// 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);
@@ -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<string, { providerId: string; modelId: string }>
let sessionAgentModelSelections: Map<string, Map<string, { providerId: string; modelId: string }>>
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<string, Part[]> = {
'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<string, Part[]> = {
'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)
})
})
+13 -26
View File
@@ -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<SessionUIState>()((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) => {