mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33:43 +08:00
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.
171 lines
6.4 KiB
TypeScript
171 lines
6.4 KiB
TypeScript
// @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)
|
||
})
|
||
})
|