mateclaw/mateclaw-ui/src/views/__tests__/chatConsoleModelSeed.test.ts
倪程伟 04a9bb9e13
fix(chat): honour per-agent model override in web chat model selector
The web ChatConsole seeded a fresh conversation's model from the global
default, never the selected agent's model override, and handleSendMessage
then pinned that default onto the conversation row. Since a conversation
pin outranks the agent override in the backend runtime resolver
(AgentGraphBuilder.resolveRuntimeBaseModel), the model chosen on the
agent edit page was silently clobbered. IM channels and external webchat
were unaffected — they leave the conversation unpinned.

- applyConversationModel now follows the backend precedence:
  conversation pin > agent model override > global default.
- The agent tier resolves via /agents/{id}/capabilities (authoritative)
  with a synchronous fallback (currentAgent.modelName against the
  enabled-model list) so an agent switch, a capability-fetch failure, or
  a not-yet-hydrated deep-link still honour the override instead of
  dropping to the global default.
- userPickedModel guards the async re-seed from clobbering an explicit
  pick; reset on new/switch/delete conversation.
- Unit tests cover the precedence tiers, the empty-string capabilities
  wire shape, and the synchronous fallback.
2026-07-15 14:51:55 +08:00

171 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
/**
* 复刻 ChatConsole 中模型 seed 的核心判定逻辑做纯函数测试。
*
* 修复的 bug员工编辑页选定的「模型」per-Agent override在网页对话里被
* 全局默认模型覆盖。根因是 applyConversationModel 在会话无 pin 时直接回落全局
* 默认,随后 handleSendMessage 把该默认 PIN 到会话行——而会话 pin 优先级高于
* 员工 override导致员工选的模型被静默覆盖。
*
* 修复后 seed 优先级与后端 AgentGraphBuilder.resolveRuntimeBaseModel 对齐:
* 会话 pin > 员工 modelName override > 全局默认
*
* 员工 override 这一层有两个来源(复刻 agentSeedModel
* 1. agentCapabilities —— /agents/{id}/capabilities 的后端解析结果(异步、权威)
* 2. currentAgent.modelName 在 enabledModels 里的同步解析(异步失败/切换/深链兜底)
*/
type Model = { providerId: string; model: string }
type EnabledModel = { provider: string; modelName: string }
// 复刻 agentSeedModel先用 capabilities再用 modelName 同步兜底。
// 关键点capabilities 未配默认模型时后端返回的是空串 ""(见 AgentController
// 的 catch 分支),不是 null —— 空串是 JS falsy`cap?.providerId && cap?.modelName`
// 会正确落空并走同步兜底。测试特意用 "" 锁住这个 wire 契约。
function agentSeedModel(opts: {
capProvider?: string | null
capModel?: string | null
agentModelName?: string | null
enabledModels?: EnabledModel[]
}): Model | null {
if (opts.capProvider && opts.capModel) {
return { providerId: opts.capProvider, model: opts.capModel }
}
const name = opts.agentModelName
if (name) {
const hit = (opts.enabledModels || []).find(m => m.modelName === name)
if (hit?.provider) {
return { providerId: hit.provider, model: hit.modelName }
}
}
return null
}
// 复刻 applyConversationModel 的取值优先级
function resolveSeedModel(opts: {
convProvider?: string | null
convModel?: string | null
capProvider?: string | null
capModel?: string | null
agentModelName?: string | null
enabledModels?: EnabledModel[]
globalDefault?: Model | null
}): Model | null {
if (opts.convProvider && opts.convModel) {
return { providerId: opts.convProvider, model: opts.convModel }
}
const agentModel = agentSeedModel(opts)
if (agentModel) return agentModel
if (opts.globalDefault) return { ...opts.globalDefault }
return null
}
// 复刻 selectedAgentId watcher 中的 re-seed 守卫:
// 仅当会话无服务端 pin 且用户未手动选过模型时,才允许用刚加载的员工能力覆盖。
function shouldReseedFromAgentCaps(opts: {
convProvider?: string | null
convModel?: string | null
userPickedModel: boolean
}): boolean {
const hasServerPin = !!(opts.convProvider && opts.convModel)
return !hasServerPin && !opts.userPickedModel
}
const GLOBAL: Model = { providerId: 'openai', model: 'gpt-x' }
const ENABLED: EnabledModel[] = [
{ provider: 'anthropic', modelName: 'claude-x' },
{ provider: 'volcano', modelName: 'doubao-pro' },
]
describe('resolveSeedModel — 模型 seed 优先级', () => {
it('会话已有 pin 时,优先用会话 pin压过员工与全局', () => {
const r = resolveSeedModel({
convProvider: 'volcano', convModel: 'doubao-pro',
capProvider: 'anthropic', capModel: 'claude-x',
globalDefault: GLOBAL,
})
expect(r).toEqual({ providerId: 'volcano', model: 'doubao-pro' })
})
it('会话无 pin 时,用 capabilities 的员工 override不再回落全局默认', () => {
const r = resolveSeedModel({
capProvider: 'anthropic', capModel: 'claude-x',
globalDefault: GLOBAL,
})
expect(r).toEqual({ providerId: 'anthropic', model: 'claude-x' })
})
it('capabilities 未就绪(切换/失败/深链)时,用 modelName 在 enabledModels 里同步兜底', () => {
const r = resolveSeedModel({
capProvider: null, capModel: null,
agentModelName: 'claude-x', enabledModels: ENABLED,
globalDefault: GLOBAL,
})
expect(r).toEqual({ providerId: 'anthropic', model: 'claude-x' })
})
it('capabilities 与同步兜底都缺失时,回落全局默认', () => {
const r = resolveSeedModel({
agentModelName: '', enabledModels: ENABLED,
globalDefault: GLOBAL,
})
expect(r).toEqual(GLOBAL)
})
it('员工的 modelName 不在 enabledModels禁用/删除)时,同步兜底落空 → 全局默认', () => {
const r = resolveSeedModel({
agentModelName: 'ghost-model', enabledModels: ENABLED,
globalDefault: GLOBAL,
})
expect(r).toEqual(GLOBAL)
})
it('全部缺失时返回 null无可用模型', () => {
expect(resolveSeedModel({ globalDefault: null })).toBeNull()
})
})
describe('agentSeedModel — 空串 wire 契约(后端无默认模型时返回 "" 而非 null', () => {
it('capabilities 为空串对providerId:"" modelName:"")视为无 override走同步兜底', () => {
const r = agentSeedModel({
capProvider: '', capModel: '',
agentModelName: 'claude-x', enabledModels: ENABLED,
})
expect(r).toEqual({ providerId: 'anthropic', model: 'claude-x' })
})
it('半残的 capabilities 对provider 空串、model 非空)视为无 override', () => {
const r = agentSeedModel({
capProvider: '', capModel: 'claude-x',
agentModelName: null, enabledModels: ENABLED,
})
expect(r).toBeNull()
})
it('capabilities 完整时优先于同步兜底', () => {
const r = agentSeedModel({
capProvider: 'openai', capModel: 'gpt-x',
agentModelName: 'claude-x', enabledModels: ENABLED,
})
expect(r).toEqual({ providerId: 'openai', model: 'gpt-x' })
})
})
describe('shouldReseedFromAgentCaps — 能力异步到达后的 re-seed 守卫', () => {
it('新会话、无 pin、用户未手动选 → 允许 re-seed修复 agent 切换竞态)', () => {
expect(shouldReseedFromAgentCaps({ userPickedModel: false })).toBe(true)
})
it('会话已有服务端 pin → 不 re-seed不覆盖已固定的会话', () => {
expect(shouldReseedFromAgentCaps({
convProvider: 'volcano', convModel: 'doubao-pro', userPickedModel: false,
})).toBe(false)
})
it('用户已手动选过模型 → 不 re-seed不覆盖显式选择', () => {
expect(shouldReseedFromAgentCaps({ userPickedModel: true })).toBe(false)
})
})