Why: F13 - the squad factory read the default account with an empty project, pairing the run user's model name with another account's endpoint and credentials; direct and squad paths disagreed on reasoning split and route contract. Evidence: app/runtime/squadadapter tests pass (verify-u10b.json); run-scoped snapshot, project override, profile override and fail-closed cases added with synthetic keys. Impact: squad execution now uses the same effective-config rules as the direct path; default setups unchanged. Refs: feature/agent-stack-audit-remediation-b1 U10
224 lines
9.0 KiB
Go
224 lines
9.0 KiB
Go
package runtime
|
|
|
|
import (
|
|
"testing"
|
|
|
|
core "agent-system-core"
|
|
)
|
|
|
|
func effOf(kv map[string]any) core.EffectiveSettings { return core.EffectiveSettings(kv) }
|
|
|
|
// 三层覆盖链解析:defaults → user → project 由 settings 服务合并(server 集成测试覆盖);
|
|
// 此处验证解析器对「设置层结果 + 画像层」的最后一级覆盖与回落。
|
|
func TestResolveChatModelLayers(t *testing.T) {
|
|
eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{
|
|
"model": "model-settings", "baseUrl": "https://s.example", "apiKey": "k0",
|
|
}}})
|
|
prof := core.AgentProfile{}
|
|
|
|
// 仅设置层。
|
|
got := ResolveChatModel(prof, eff)
|
|
if got.Model != "model-settings" || got.Base != "https://s.example" || got.Key != "k0" {
|
|
t.Fatalf("settings layer: %+v", got)
|
|
}
|
|
// 画像覆盖模型(端点沿用设置层)。
|
|
prof.Model.Chat = "model-profile"
|
|
got = ResolveChatModel(prof, eff)
|
|
if got.Model != "model-profile" || got.Base != "https://s.example" {
|
|
t.Fatalf("profile layer should override model only: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveLightweightFallback(t *testing.T) {
|
|
eff := effOf(map[string]any{"models": map[string]any{
|
|
"chat": map[string]any{"model": "main-m", "baseUrl": "https://main", "apiKey": "k"},
|
|
"lightweight": map[string]any{"model": ""},
|
|
}})
|
|
got := ResolveLightweightModel(core.AgentProfile{}, eff)
|
|
if got.Model != "main-m" || got.Base != "https://main" {
|
|
t.Fatalf("lightweight should fall back to chat: %+v", got)
|
|
}
|
|
// 轻量档只配模型名:模型用轻量、端点回落主档。
|
|
eff["models"].(map[string]any)["lightweight"] = map[string]any{"model": "lite-m"}
|
|
got = ResolveLightweightModel(core.AgentProfile{Model: core.ModelRoute{Lightweight: "prof-lite"}}, eff)
|
|
if got.Model != "prof-lite" || got.Base != "https://main" {
|
|
t.Fatalf("profile lightweight + chat endpoint: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestChatFallbacksDefaultEmpty(t *testing.T) {
|
|
if got := ChatFallbacks(effOf(nil)); got != nil {
|
|
t.Fatalf("default %#v", got)
|
|
}
|
|
eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{
|
|
"fallbacks": " backup , primary ,backup",
|
|
}}})
|
|
got := ChatFallbacks(eff)
|
|
if len(got) != 2 || got[0] != "backup" || got[1] != "primary" {
|
|
t.Fatalf("%#v", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveVisionModel(t *testing.T) {
|
|
eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{
|
|
"model": "deepseek-v4.1-flash", "baseUrl": "https://gw.example/v1", "apiKey": "k",
|
|
"fallbacks": "plain-text,ZHIPU/GLM-5.3-FlashX",
|
|
}, "vision": map[string]any{
|
|
"model": "qwen3.8-flash",
|
|
"capable": "deepseek-v4.1-flash,qwen3.8-flash,ZHIPU/GLM-5.3-FlashX",
|
|
}}})
|
|
prof := core.AgentProfile{}
|
|
got := ResolveVisionModel(prof, eff, false)
|
|
if got.Model != "deepseek-v4.1-flash" {
|
|
t.Fatalf("no image should keep chat model: %+v", got)
|
|
}
|
|
got = ResolveVisionModel(prof, eff, true)
|
|
if got.Model != "deepseek-v4.1-flash" || got.Base != "https://gw.example/v1" || got.Key != "k" {
|
|
t.Fatalf("capable model should stay: %+v", got)
|
|
}
|
|
prof.Model.Chat = "plain-text"
|
|
got = ResolveVisionModel(prof, eff, true)
|
|
if got.Model != "qwen3.8-flash" || got.Base != "https://gw.example/v1" || got.Key != "k" {
|
|
t.Fatalf("incapable model should swap, keeping endpoint: %+v", got)
|
|
}
|
|
fb := ChatFallbacksFor(eff, true)
|
|
if len(fb) != 1 || fb[0] != "ZHIPU/GLM-5.3-FlashX" {
|
|
t.Fatalf("image fallbacks %#v", fb)
|
|
}
|
|
fb = ChatFallbacksFor(eff, false)
|
|
if len(fb) != 2 || fb[0] != "plain-text" {
|
|
t.Fatalf("text fallbacks %#v", fb)
|
|
}
|
|
}
|
|
|
|
// 只有 Settings 端口的宿主:快照可取,没有就报缺失(调用方自己决定回落)。
|
|
type settingsOnlyCtx struct{ eff core.EffectiveSettings }
|
|
|
|
func (c settingsOnlyCtx) Settings() core.EffectiveSettings { return c.eff }
|
|
|
|
// 只有 Run 端口的宿主:属主只能来自 run。
|
|
type runOnlyCtx struct{ owner string }
|
|
|
|
func (c runOnlyCtx) Run() core.Run { return core.Run{ID: "run_t", Owner: core.ID(c.owner)} }
|
|
|
|
// 两个端口都有的宿主(runmgr 的 runCtx 形态)。
|
|
type runProfileCtx struct {
|
|
settingsOnlyCtx
|
|
runOnlyCtx
|
|
prof core.AgentProfile
|
|
}
|
|
|
|
func (c runProfileCtx) Profile() core.AgentProfile { return c.prof }
|
|
|
|
func TestRunEffectiveSettingsNeedsHostPort(t *testing.T) {
|
|
eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "m"}}})
|
|
if got, ok := RunEffectiveSettings(settingsOnlyCtx{eff: eff}); !ok || got.GetString("models.chat.model", "") != "m" {
|
|
t.Fatalf("run snapshot = %+v ok=%v, want the host snapshot", got, ok)
|
|
}
|
|
if _, ok := RunEffectiveSettings(settingsOnlyCtx{}); ok {
|
|
t.Fatal("empty snapshot must be reported as missing so the caller can fall back")
|
|
}
|
|
if _, ok := RunEffectiveSettings(runOnlyCtx{owner: "alice"}); ok {
|
|
t.Fatal("host without a Settings port must not be reported as having one")
|
|
}
|
|
}
|
|
|
|
func TestRunProfileNeverFallsBackToDefaultOwner(t *testing.T) {
|
|
prof := core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "alice-model"}}
|
|
both := runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "alice"}, prof: prof}
|
|
if got := RunProfile(both); got.Owner != "alice" || got.Model.Chat != "alice-model" {
|
|
t.Fatalf("profile port must win: %+v", got)
|
|
}
|
|
if got := RunProfile(both).Owner; got != "alice" {
|
|
t.Fatalf("owner = %q, want alice", got)
|
|
}
|
|
// 画像端口缺席(或没带 Owner)时取 run.Owner——密钥层归属不能退化成默认账号。
|
|
bare := runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "bob"}}
|
|
if got := RunProfile(bare).Owner; got != "bob" {
|
|
t.Fatalf("owner = %q, want the run owner bob", got)
|
|
}
|
|
if got := RunProfile(runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "bob"}, prof: core.AgentProfile{}}).Owner; got != "bob" {
|
|
t.Fatalf("empty profile owner = %q, want the run owner bob", got)
|
|
}
|
|
if got := RunProfile(runOnlyCtx{}).Owner; got != "" {
|
|
t.Fatalf("owner without any port = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// 带自定义快照的运行上下文(其余端口由 fakeRunCtx 满足)。
|
|
type runSnapCtx struct {
|
|
*fakeRunCtx
|
|
eff core.EffectiveSettings
|
|
}
|
|
|
|
func (c *runSnapCtx) Settings() core.EffectiveSettings { return c.eff }
|
|
|
|
// 运行内解析主路由:快照 + 画像覆盖 + 属主密钥层,与直连同一条规则。
|
|
func TestRunChatRouteUsesRunSnapshotAndOwnerKey(t *testing.T) {
|
|
saved := SecretLookup
|
|
defer func() { SecretLookup = saved }()
|
|
secrets := map[string]string{"alice": "sk-alice-synthetic", "bob": "sk-bob-synthetic"}
|
|
SecretLookup = func(owner, _ string) (string, string, bool) {
|
|
if owner == "" {
|
|
owner = "local"
|
|
}
|
|
v, ok := secrets[owner]
|
|
return v, "owner", ok
|
|
}
|
|
eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{
|
|
"model": "settings-model", "baseUrl": "https://shared.example/v1", "apiKey": "sk-settings-synthetic",
|
|
}}})
|
|
for _, owner := range []string{"alice", "bob"} {
|
|
rc := &runSnapCtx{fakeRunCtx: &fakeRunCtx{prof: core.AgentProfile{Owner: owner, Model: core.ModelRoute{Chat: owner + "-profile-model"}}}, eff: eff}
|
|
got := RunChatRoute(rc)
|
|
want := ResolveChatModel(rc.Profile(), rc.Settings())
|
|
if got != want {
|
|
t.Fatalf("%s: run route %+v != direct rule %+v", owner, got, want)
|
|
}
|
|
if got.Model != owner+"-profile-model" || got.Base != "https://shared.example/v1" {
|
|
t.Fatalf("%s: route = %+v, want the profile model on the shared endpoint", owner, got)
|
|
}
|
|
if got.Key != "sk-"+owner+"-synthetic" || got.KeySource != "owner" {
|
|
t.Fatalf("%s: key = %q (%s), want that owner's own secret", owner, got.Key, got.KeySource)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 客户端构造:reasoning split 与受限 EOF 契约都只看本路由自己的 model+base,
|
|
// 画像覆盖后的路由重新判定(与直连路径同一个构造器)。
|
|
func TestOpenRouteClientDecidesPerRoute(t *testing.T) {
|
|
const base = "https://api.example.com/v1"
|
|
contract := routeEOFMatching(routeEOFModel, base)
|
|
eff := routeEOFEff(map[string]any{"model": routeEOFModel, "baseUrl": base, "apiKey": "k"}, contract)
|
|
|
|
route := ResolvedModel{Kind: "chat", Model: routeEOFModel, Base: base, Key: "k"}
|
|
client := OpenRouteClient(route, eff)
|
|
if client.BaseURL != base || client.APIKey != "k" {
|
|
t.Fatalf("client route = %q/%q, want %q/k", client.BaseURL, client.APIKey, base)
|
|
}
|
|
if client.ReasoningSplit {
|
|
t.Fatal("non-MiniMax model must not enable reasoning split")
|
|
}
|
|
if client.RouteContract == nil {
|
|
t.Fatal("matching configuration must attach the contract")
|
|
}
|
|
// 画像覆盖改的是模型名:分流与契约都按覆盖后的路由重新判定。
|
|
for _, tc := range []struct {
|
|
model string
|
|
wantSplit bool
|
|
wantEOfRule bool
|
|
}{
|
|
{"MiniMax-M2", true, false},
|
|
{routeEOFModel, false, true},
|
|
{"other-model", false, false},
|
|
} {
|
|
prof := core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: tc.model}}
|
|
overridden := ResolveChatModel(prof, eff)
|
|
got := OpenRouteClient(overridden, eff)
|
|
if got.ReasoningSplit != tc.wantSplit || (got.RouteContract != nil) != tc.wantEOfRule {
|
|
t.Fatalf("profile model %q: split=%v contract=%v, want split=%v contract=%v",
|
|
tc.model, got.ReasoningSplit, got.RouteContract != nil, tc.wantSplit, tc.wantEOfRule)
|
|
}
|
|
}
|
|
}
|