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:
@@ -40,6 +40,11 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
|||||||
import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
|
import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
|
||||||
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
|
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
|
||||||
import { markStartupTrace } from '@/lib/startupTrace';
|
import { markStartupTrace } from '@/lib/startupTrace';
|
||||||
|
import {
|
||||||
|
findLatestUserModelChoice,
|
||||||
|
shouldPreserveManualModelOverride,
|
||||||
|
} from '@/lib/messages/userModelChoice';
|
||||||
|
import { getSyncParts } from '@/sync/sync-refs';
|
||||||
|
|
||||||
type IconComponent = IconName;
|
type IconComponent = IconName;
|
||||||
|
|
||||||
@@ -645,37 +650,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
currentSessionDirectory ?? undefined,
|
currentSessionDirectory ?? undefined,
|
||||||
);
|
);
|
||||||
const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', 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(() => {
|
const latestLoadedUserChoice = React.useMemo(() => {
|
||||||
for (let i = currentSessionMessagesFromSync.length - 1; i >= 0; i -= 1) {
|
return findLatestUserModelChoice(
|
||||||
const message = currentSessionMessagesFromSync[i] as typeof currentSessionMessagesFromSync[number] & {
|
currentSessionMessagesFromSync,
|
||||||
model?: { providerID?: string; modelID?: string; variant?: string };
|
(messageId) => getSyncParts(messageId, currentSessionDirectory ?? undefined),
|
||||||
variant?: string;
|
);
|
||||||
mode?: string;
|
}, [currentSessionDirectory, currentSessionMessagesFromSync]);
|
||||||
};
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const tryApplyModelSelection = React.useCallback(
|
const tryApplyModelSelection = React.useCallback(
|
||||||
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
|
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
|
||||||
@@ -828,6 +810,25 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
return;
|
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) {
|
if (latestLoadedUserChoice.agent && currentAgentName !== latestLoadedUserChoice.agent) {
|
||||||
setAgent(latestLoadedUserChoice.agent);
|
setAgent(latestLoadedUserChoice.agent);
|
||||||
}
|
}
|
||||||
@@ -869,6 +870,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
setAgent,
|
setAgent,
|
||||||
applyModelSelectionWithVariant,
|
applyModelSelectionWithVariant,
|
||||||
getModelVariantOptions,
|
getModelVariantOptions,
|
||||||
|
getSessionModelSelection,
|
||||||
|
resolveModelVariantSelection,
|
||||||
saveSessionAgentSelection,
|
saveSessionAgentSelection,
|
||||||
saveAgentModelVariantForSession,
|
saveAgentModelVariantForSession,
|
||||||
saveSessionModelSelection,
|
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');
|
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 () => {
|
test('loadAgents does not fetch OpenCode config directly', async () => {
|
||||||
useConfigStore.setState({
|
useConfigStore.setState({
|
||||||
activeDirectoryKey: DIRECTORY,
|
activeDirectoryKey: DIRECTORY,
|
||||||
|
|||||||
@@ -2503,20 +2503,13 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prefer the selected agent's configured model when switching agents.
|
|
||||||
const agent = agents.find((candidate) => candidate.name === agentName);
|
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) {
|
if (currentSessionId) {
|
||||||
const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName);
|
const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName);
|
||||||
if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) {
|
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 the agent has no preferred model, use settings default.
|
||||||
if (settingsDefaultModel) {
|
if (settingsDefaultModel) {
|
||||||
const parsed = parseModelString(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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,6 +30,7 @@ import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
|
|||||||
import { normalizePath } from "@/lib/pathNormalization"
|
import { normalizePath } from "@/lib/pathNormalization"
|
||||||
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
|
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
|
||||||
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
|
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
|
||||||
|
import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice"
|
||||||
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
|
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
|
||||||
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"
|
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"
|
||||||
import { getWorktreeSetupWaitEnabled } from "@/lib/openchamberConfig"
|
import { getWorktreeSetupWaitEnabled } from "@/lib/openchamberConfig"
|
||||||
@@ -1713,33 +1714,19 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
getLastUserChoice: (sessionId) => {
|
getLastUserChoice: (sessionId) => {
|
||||||
const directory = get().getDirectoryForSession(sessionId) ?? undefined
|
const directory = get().getDirectoryForSession(sessionId) ?? undefined
|
||||||
const messages = getSyncMessages(sessionId, directory)
|
const messages = getSyncMessages(sessionId, directory)
|
||||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
const choice = findLatestUserModelChoice(
|
||||||
const message = messages[i] as Message & {
|
messages,
|
||||||
model?: { providerID?: string; modelID?: string; variant?: string }
|
(messageId) => getSyncParts(messageId, directory),
|
||||||
variant?: string
|
)
|
||||||
mode?: string
|
if (!choice) {
|
||||||
}
|
return null
|
||||||
if (message.role !== "user") {
|
}
|
||||||
continue
|
return {
|
||||||
}
|
agent: choice.agent,
|
||||||
|
providerID: choice.providerID,
|
||||||
const providerID = typeof message.model?.providerID === "string" && message.model.providerID.trim().length > 0
|
modelID: choice.modelID,
|
||||||
? message.model.providerID
|
variant: choice.variant,
|
||||||
: 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 }
|
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getCurrentAgent: (sessionId) => {
|
getCurrentAgent: (sessionId) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user