package loop import ( "context" "sort" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // TestToolSurface_MutedPathMatchesStreamingPath 钉住审计 P0-12 的修复结论: // 同一 persona/actor 条件下,静音/非流式路径(step → agentStep → RunStep) // 向模型广告的工具面必须与流式主路径**逐条一致** —— capacity 允许集与会话 // 环境安全门(design/11 §3)都在两条路径上同样生效,agent 不再自行取 // dispatcher 全量(否则「模型只见允许集」在这条路径上不成立)。 // // 两条路径由同一个循环、同一份会话环境(含 persona)、同一条用户消息驱动, // 唯一区别是 isMutedBroadcast —— 静音即内部自主流程(backthink / 子代理), // 它们同样要按同一份允许集广告,只是不做 UI 广播。 func TestToolSurface_MutedPathMatchesStreamingPath(t *testing.T) { t.Parallel() cases := []struct { name string env SessionEnv // want 是两个过滤层(capacity 静息允许集 ∩ 会话环境安全门) // 叠加后的期望广告面。 want []string }{ { // 主人私聊:安全门全放行,只剩 capacity 静息允许集 // (terminal + file;web-search/json 被收掉)。 name: "owner", env: OwnerEnv(), want: []string{"file", "terminal"}, }, { // 群聊:capacity 静息允许集上再叠一层安全门 —— 只有带 // AllowInGroup 标记的 file 留下,terminal 被身份闸收掉。 name: "group", env: SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false, PersonaID: "cyrene"}, want: []string{"file"}, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() reg, d := newPolicyDispatcher(t, []string{"terminal", "file", "web-search", "json"}) // 只有 file 对群聊开放;其余保持默认关闭(fail-closed)。 mustSetPolicy(t, reg, "file", registry.ToolPolicy{AllowInGroup: true}) // 闲聊消息:capacity 判为静息,允许集 = terminal + file。 // 这条输入对两个过滤层都有判别力。 msgs := []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")} ad := &mockAdapter{ stream: []mgResp{{resp: model.Response{Content: "陪你"}}}, chat: []mgResp{{resp: model.Response{Content: "陪你"}}}, } a := newTestAgent(t, ad, d) l := New(Options{ Agent: a, MaxRounds: 8, OnStream: func(model.StreamEvent) {}, Capacity: capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6}), }) // 流式主路径(未静音):请求在循环里组装,工具面直接写进 // model.Request,记录在 streamReqs 上。 if _, err := l.Run(WithSessionEnv(context.Background(), tc.env), "sess", msgs); err != nil { t.Fatalf("streaming Run: %v", err) } // 静音路径:同一循环、同一会话环境,只是抑制广播; // 请求由 agent.RunStep 组装,记录在 reqs 上。 mutedCtx := WithMutedBroadcast(WithSessionEnv(context.Background(), tc.env)) if _, err := l.Run(mutedCtx, "sess", msgs); err != nil { t.Fatalf("muted Run: %v", err) } if len(ad.streamReqs) == 0 || len(ad.reqs) == 0 { t.Fatalf("captured requests: stream=%d chat=%d, want both paths exercised", len(ad.streamReqs), len(ad.reqs)) } streamFace := sortedToolNames(ad.streamReqs[0].Tools) mutedFace := sortedToolNames(ad.reqs[0].Tools) if !sameToolFace(streamFace, mutedFace) { t.Fatalf("静音路径广告面 %v != 流式路径广告面 %v(audit P0-12 回归)", mutedFace, streamFace) } if !sameToolFace(streamFace, tc.want) { t.Fatalf("广告面 = %v, want %v(capacity 允许集 + 会话环境安全门)", streamFace, tc.want) } }) } } // sortedToolNames 把工具名按字典序排列,使两条路径的广告面可以做 // 与注册顺序无关的逐条比较(registry.List 遍历 map,顺序不保留)。 func sortedToolNames(specs []model.ToolSpec) []string { names := toolNames(specs) sort.Strings(names) return names } // sameToolFace 报告两份工具名列表逐条相同。 func sameToolFace(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }