package loop import ( "context" "strings" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // TestRun_OutsideAllowSet_SoftJudgeFedBackAndAudited 锁住裁决 14.4.2 // 的「加软判并回」全链路:闲聊轮只广告 quiet 允许集,模型却调了 // 允许集外的 web-search —— 调用不被硬阻断(确实执行,能力≠边界), // 但判定并回:not_allowed 提示随 tool result 回灌给模型, // 同一条判定落进既有审计通道(design/11 §3 §8)。 func TestRun_OutsideAllowSet_SoftJudgeFedBackAndAudited(t *testing.T) { t.Parallel() call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{ ID: "call_soft_1", Name: "web-search", Args: map[string]any{"q": "天气"}, }}} ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "done"}}}} td := dispatcherWith(t, &mockTool{name: "terminal", result: "ok"}, &mockTool{name: "web-search", result: "web result"}, ) a := newTestAgent(t, ad, td) var mu sync.Mutex var audited []AuditEvent l := New(Options{ Agent: a, MaxRounds: 8, Capacity: capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6}), OnStream: func(model.StreamEvent) {}, OnAudit: func(ev AuditEvent) { mu.Lock() audited = append(audited, ev) mu.Unlock() }, }) res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")}) if err != nil { t.Fatalf("Run: %v", err) } // ① 不硬阻断:越界调用确实执行了。 if res.ToolCallsExecuted != 1 { t.Fatalf("ToolCallsExecuted = %d, want 1(软判不得阻断执行)", res.ToolCallsExecuted) } // ② 该轮模型只被广告了 quiet 允许集 —— 发布出去的允许集与它一致。 if len(ad.streamReqs) == 0 { t.Fatal("no streaming request captured") } set := toolSet(ad.streamReqs[0].Tools) if !hasAll(set, "terminal") || set["web-search"] { t.Fatalf("quiet tools = %v, want terminal only", toolNames(ad.streamReqs[0].Tools)) } // ③ 软判并回:模型收到的 tool result 带 not_allowed 提示与来源标签。 last := ad.streamReqs[len(ad.streamReqs)-1].Messages msg := last[len(last)-1] if msg.Role != model.RoleTool { t.Fatalf("last message role = %q, want tool", msg.Role) } text := msg.Content[0].Text for _, want := range []string{"not_allowed", "web-search", "capacity:quiet", "web result"} { if !strings.Contains(text, want) { t.Fatalf("tool result %q missing %q", text, want) } } // ④ 可观测:判定落进既有审计通道,前端小字与审计轨迹都能看到。 mu.Lock() defer mu.Unlock() if len(audited) != 1 { t.Fatalf("audit events = %d, want 1 (%+v)", len(audited), audited) } if audited[0].What != auditToolRejectNotAllowed { t.Fatalf("audit what = %q, want %q", audited[0].What, auditToolRejectNotAllowed) } if audited[0].Who != "owner" { t.Fatalf("audit who = %q, want owner", audited[0].Who) } for _, want := range []string{"not_allowed", "web-search"} { if !strings.Contains(audited[0].Result, want) { t.Fatalf("audit result = %q missing %q", audited[0].Result, want) } } } // TestRun_InsideAllowSet_NoSoftJudge 是对照组:调用就在本轮允许集内时, // 不产生软判 —— 结果不被包装、也不落审计(软判只在越界时说话)。 func TestRun_InsideAllowSet_NoSoftJudge(t *testing.T) { t.Parallel() call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{ ID: "call_soft_2", Name: "terminal", Args: map[string]any{}, }}} ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "done"}}}} td := dispatcherWith(t, &mockTool{name: "terminal", result: "ok"}, &mockTool{name: "web-search", result: "web result"}, ) a := newTestAgent(t, ad, td) var mu sync.Mutex var audited []AuditEvent l := New(Options{ Agent: a, MaxRounds: 8, Capacity: capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6}), OnStream: func(model.StreamEvent) {}, OnAudit: func(ev AuditEvent) { mu.Lock() audited = append(audited, ev) mu.Unlock() }, }) res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")}) if err != nil { t.Fatalf("Run: %v", err) } if res.ToolCallsExecuted != 1 { t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted) } last := ad.streamReqs[len(ad.streamReqs)-1].Messages text := last[len(last)-1].Content[0].Text if strings.Contains(text, "not_allowed") { t.Fatalf("tool result %q must not carry a soft notice for an allowed call", text) } mu.Lock() defer mu.Unlock() if len(audited) != 0 { t.Fatalf("audit events = %d, want 0 for an allowed call", len(audited)) } } // TestRun_CapacityDisabled_NoSoftJudge 守住 design/14 §1 红线:没有一个 // 允许集被收敛(capacity 未启用)时,调用任何工具都不产生软判 —— // 软判只描述「本轮允许集」,绝不把决策层变成能力边界。 func TestRun_CapacityDisabled_NoSoftJudge(t *testing.T) { t.Parallel() call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{ ID: "call_soft_3", Name: "web-search", Args: map[string]any{"q": "天气"}, }}} ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "done"}}}} td := dispatcherWith(t, &mockTool{name: "terminal", result: "ok"}, &mockTool{name: "web-search", result: "web result"}, ) a := newTestAgent(t, ad, td) var mu sync.Mutex var audited []AuditEvent l := New(Options{ Agent: a, MaxRounds: 8, OnStream: func(model.StreamEvent) {}, OnAudit: func(ev AuditEvent) { mu.Lock() audited = append(audited, ev) mu.Unlock() }, }) res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")}) if err != nil { t.Fatalf("Run: %v", err) } if res.ToolCallsExecuted != 1 { t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted) } last := ad.streamReqs[len(ad.streamReqs)-1].Messages if text := last[len(last)-1].Content[0].Text; strings.Contains(text, "not_allowed") { t.Fatalf("tool result %q must not carry a soft notice without an allow set", text) } mu.Lock() defer mu.Unlock() if len(audited) != 0 { t.Fatalf("audit events = %d, want 0 without an allow set", len(audited)) } }