fix(ui): keep manual model override after delegated subtask completes
Synthetic subagent-completion nudges were treated as the latest user model choice and rehydrated the agent default, while setAgent preferred the agent pin over the session override. Skip synthetic prompts for restore, preserve manual selection-store overrides, and prefer session agent models in setAgent. Closes openchamber/openchamber#2404 Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
f47110c66f
commit
65a1eec782
@@ -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