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 { 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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user