// capability_test.go -- ToolCapability 协议的单元测试. // // 覆盖场景: // - GetCapability 对未实现 CapabilityProvider 的工具返回零值 // - GetCapability 对实现了 CapabilityProvider 的工具返回声明值 // - IsDryRunnable 检测 // - IsReversible 检测 // - SafetyLevel 三个等级 package tools import ( "context" "encoding/json" "strings" "testing" ) // ───────────────────────────────────────────────────────────────────── // Mock 工具(测试用) // ───────────────────────────────────────────────────────────────────── // plainTool 不实现任何可选接口的工具. type plainTool struct{} func (t *plainTool) Name() string { return "plain" } func (t *plainTool) Description(ctx context.Context) string { return "a plain tool" } func (t *plainTool) InputSchema() json.RawMessage { return json.RawMessage(`{}`) } func (t *plainTool) Execute(ctx context.Context, input json.RawMessage, progress ProgressFunc) (*Result, error) { return &Result{Output: "ok"}, nil } // capableTool 实现了所有可选接口的工具. type capableTool struct { plainTool } func (t *capableTool) Capability() ToolCapability { return ToolCapability{ DryRun: true, Reversible: true, UndoMethod: "tool", UndoToolName: "UndoCapable", MinConfidence: 80, AffectedResources: []string{"file", "database"}, } } func (t *capableTool) DryRun(ctx context.Context, input json.RawMessage) (*DryRunResult, error) { return &DryRunResult{ WouldAffect: "test.txt", Preview: "would edit test.txt", }, nil } func (t *capableTool) GenerateUndo(ctx context.Context, input json.RawMessage, result *Result) (*UndoInfo, error) { return &UndoInfo{ ToolName: "UndoCapable", Description: "undo the capable tool", }, nil } // reversibleOnlyTool 只实现 Reversible 但不实现 DryRunnable. type reversibleOnlyTool struct { plainTool } func (t *reversibleOnlyTool) Capability() ToolCapability { return ToolCapability{ Reversible: true, UndoMethod: "tool", } } func (t *reversibleOnlyTool) GenerateUndo(ctx context.Context, input json.RawMessage, result *Result) (*UndoInfo, error) { return &UndoInfo{ToolName: "Undo"}, nil } // ───────────────────────────────────────────────────────────────────── // 测试 // ───────────────────────────────────────────────────────────────────── func TestGetCapability_PlainTool(t *testing.T) { tool := &plainTool{} cap := GetCapability(tool) if cap.DryRun { t.Error("普通工具不应该支持 DryRun") } if cap.Reversible { t.Error("普通工具不应该支持 Reversible") } if cap.UndoMethod != "" { t.Errorf("普通工具的 UndoMethod 应该为空,实际: %q", cap.UndoMethod) } } func TestGetCapability_CapableTool(t *testing.T) { tool := &capableTool{} cap := GetCapability(tool) if !cap.DryRun { t.Error("capable 工具应该支持 DryRun") } if !cap.Reversible { t.Error("capable 工具应该支持 Reversible") } if cap.UndoMethod != "tool" { t.Errorf("UndoMethod 应该是 'tool',实际: %q", cap.UndoMethod) } if cap.UndoToolName != "UndoCapable" { t.Errorf("UndoToolName 应该是 'UndoCapable',实际: %q", cap.UndoToolName) } if cap.MinConfidence != 80 { t.Errorf("MinConfidence 应该是 80,实际: %d", cap.MinConfidence) } if len(cap.AffectedResources) != 2 { t.Errorf("AffectedResources 应该有 2 项,实际: %d", len(cap.AffectedResources)) } } func TestIsDryRunnable(t *testing.T) { if IsDryRunnable(&plainTool{}) { t.Error("plainTool 不应该是 DryRunnable") } if !IsDryRunnable(&capableTool{}) { t.Error("capableTool 应该是 DryRunnable") } if IsDryRunnable(&reversibleOnlyTool{}) { t.Error("reversibleOnlyTool 不应该是 DryRunnable") } } func TestIsReversible(t *testing.T) { if IsReversible(&plainTool{}) { t.Error("plainTool 不应该是 Reversible") } if !IsReversible(&capableTool{}) { t.Error("capableTool 应该是 Reversible") } if !IsReversible(&reversibleOnlyTool{}) { t.Error("reversibleOnlyTool 应该是 Reversible") } } func TestSafetyLevel(t *testing.T) { tests := []struct { name string cap ToolCapability expected int }{ { name: "Level 0: 无安全能力", cap: ToolCapability{}, expected: 0, }, { name: "Level 1: 只支持 Reversible", cap: ToolCapability{Reversible: true}, expected: 1, }, { name: "Level 2: 支持 DryRun", cap: ToolCapability{DryRun: true}, expected: 2, }, { name: "Level 2: DryRun + Reversible", cap: ToolCapability{DryRun: true, Reversible: true}, expected: 2, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { level := tt.cap.SafetyLevel() if level != tt.expected { t.Errorf("期望 SafetyLevel=%d,实际=%d", tt.expected, level) } }) } } func TestDryRunnable_Execute(t *testing.T) { tool := &capableTool{} result, err := tool.DryRun(context.Background(), json.RawMessage(`{}`)) if err != nil { t.Fatalf("DryRun 失败: %v", err) } if result.WouldAffect != "test.txt" { t.Errorf("WouldAffect 应该是 'test.txt',实际: %q", result.WouldAffect) } if result.Preview != "would edit test.txt" { t.Errorf("Preview 不匹配: %q", result.Preview) } } func TestReversible_GenerateUndo(t *testing.T) { tool := &capableTool{} result := &Result{Output: "done"} undo, err := tool.GenerateUndo(context.Background(), json.RawMessage(`{}`), result) if err != nil { t.Fatalf("GenerateUndo 失败: %v", err) } if undo.ToolName != "UndoCapable" { t.Errorf("ToolName 应该是 'UndoCapable',实际: %q", undo.ToolName) } } func TestResult_UndoInfo(t *testing.T) { // 验证 Result 结构体包含 UndoInfo 字段 result := &Result{ Output: "edited file", UndoInfo: &UndoInfo{ ToolName: "Write", Description: "恢复原文件", }, } if result.UndoInfo == nil { t.Fatal("Result.UndoInfo 不应该为 nil") } if result.UndoInfo.ToolName != "Write" { t.Errorf("UndoInfo.ToolName 应该是 'Write',实际: %q", result.UndoInfo.ToolName) } } // ───────────────────────────────────────────────────────────────────── // CheckConfidenceGate -- Agent Tool Safety Protocol 置信度门控 // 一 sub-claim 一 test: gate disabled / 缺失 / 低于阈值 / 边界 / 高于 // / 非整数 / 非对象 input, 覆盖 godoc 承诺的所有分支. // ───────────────────────────────────────────────────────────────────── func TestCheckConfidenceGate_Disabled_BypassesCompletely(t *testing.T) { cap := ToolCapability{MinConfidence: 0} input := json.RawMessage(`{"foo":"bar"}`) pass, msg, stripped := CheckConfidenceGate(cap, input) if !pass { t.Fatalf("MinConfidence=0 应放行, 实际 fail: %s", msg) } if string(stripped) != string(input) { t.Errorf("disabled gate 应原样返回 input, 实际: %s", string(stripped)) } } func TestCheckConfidenceGate_Missing_RejectsWithMinInMessage(t *testing.T) { cap := ToolCapability{MinConfidence: 80} input := json.RawMessage(`{"path":"/tmp/x"}`) pass, msg, _ := CheckConfidenceGate(cap, input) if pass { t.Fatal("缺失 _flyto_confidence 应拒绝") } if !containsAll(msg, []string{"_flyto_confidence", "min=80"}) { t.Errorf("错误消息应含字段名和 min 阈值, 实际: %q", msg) } } func TestCheckConfidenceGate_Below_RejectsWithActualVsRequired(t *testing.T) { cap := ToolCapability{MinConfidence: 80} input := json.RawMessage(`{"_flyto_confidence":65,"path":"/tmp/x"}`) pass, msg, _ := CheckConfidenceGate(cap, input) if pass { t.Fatal("confidence=65 < min=80 应拒绝") } if !containsAll(msg, []string{"65", "80"}) { t.Errorf("错误消息应含实际值 65 和 required 80, 实际: %q", msg) } } func TestCheckConfidenceGate_AtBoundary_PassesAndStripsField(t *testing.T) { cap := ToolCapability{MinConfidence: 80} input := json.RawMessage(`{"_flyto_confidence":80,"path":"/tmp/x"}`) pass, msg, stripped := CheckConfidenceGate(cap, input) if !pass { t.Fatalf("confidence=80 == min=80 应放行, fail msg: %s", msg) } var obj map[string]any if err := json.Unmarshal(stripped, &obj); err != nil { t.Fatalf("stripped 应是合法 JSON 对象: %v", err) } if _, exists := obj["_flyto_confidence"]; exists { t.Error("stripped input 不应含 _flyto_confidence") } if obj["path"] != "/tmp/x" { t.Errorf("业务字段 path 应保留, 实际: %v", obj["path"]) } } func TestCheckConfidenceGate_NonInteger_RejectsWithTypeMessage(t *testing.T) { cap := ToolCapability{MinConfidence: 80} input := json.RawMessage(`{"_flyto_confidence":"high","path":"/tmp/x"}`) pass, msg, _ := CheckConfidenceGate(cap, input) if pass { t.Fatal("字符串形式的 _flyto_confidence 应拒绝") } if !containsAll(msg, []string{"integer", "_flyto_confidence"}) { t.Errorf("错误消息应说明需整数, 实际: %q", msg) } } func TestCheckConfidenceGate_EmptyInput_RejectsWhenGateEnabled(t *testing.T) { cap := ToolCapability{MinConfidence: 50} pass, msg, _ := CheckConfidenceGate(cap, nil) if pass { t.Fatal("gate enabled + 空 input 应拒绝 (无法定位字段)") } if !containsAll(msg, []string{"_flyto_confidence", "min=50"}) { t.Errorf("错误消息应含字段名和 min, 实际: %q", msg) } } func TestCheckConfidenceGate_NonObjectInput_RejectsWhenGateEnabled(t *testing.T) { cap := ToolCapability{MinConfidence: 80} input := json.RawMessage(`"not_an_object"`) pass, msg, _ := CheckConfidenceGate(cap, input) if pass { t.Fatal("非对象 input + gate enabled 应拒绝") } if !containsAll(msg, []string{"_flyto_confidence", "JSON object"}) { t.Errorf("错误消息应说明 input 非对象, 实际: %q", msg) } } func containsAll(s string, parts []string) bool { for _, p := range parts { if !strings.Contains(s, p) { return false } } return true }