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>
146 lines
5.0 KiB
TypeScript
146 lines
5.0 KiB
TypeScript
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')
|
|
})
|
|
})
|