// defense_test.go -- 工具错误友好提示防御点的单元测试(任务 7.6). // // 覆盖场景: // // a) 工具不存在 → 错误消息包含可用工具列表 // b) 工具输入 JSON 无效 → 错误消息包含 InputSchema 提示 // c) 工具输出含非 UTF-8 字节 → 替换为 '?' 并追加警告 // d) 输入过大(>100K 字符)→ 截断并返回 WarningEvent(在 engine 层测试) package tools import ( "context" "encoding/json" "strings" "testing" "unicode/utf8" ) // schemaAwareTool 是一个有 InputSchema 的 mock 工具,用于测试 b 场景. type schemaAwareTool struct { name string schema string } func (s *schemaAwareTool) Name() string { return s.name } func (s *schemaAwareTool) Description(_ context.Context) string { return "test tool" } func (s *schemaAwareTool) InputSchema() json.RawMessage { if s.schema == "" { return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}}}`) } return json.RawMessage(s.schema) } func (s *schemaAwareTool) Execute(_ context.Context, input json.RawMessage, _ ProgressFunc) (*Result, error) { return &Result{Output: "ok"}, nil } // binaryOutputTool 是一个输出含非 UTF-8 字节的 mock 工具,用于测试 c 场景. type binaryOutputTool struct{ name string } func (b *binaryOutputTool) Name() string { return b.name } func (b *binaryOutputTool) Description(_ context.Context) string { return "binary output tool" } func (b *binaryOutputTool) InputSchema() json.RawMessage { return json.RawMessage(`{}`) } func (b *binaryOutputTool) Execute(_ context.Context, _ json.RawMessage, _ ProgressFunc) (*Result, error) { // 返回含非 UTF-8 字节的输出(0xFF 0xFE 是非法 UTF-8 序列) return &Result{Output: "valid prefix \xFF\xFE invalid bytes"}, nil } // TestDefenseA_ToolNotFound 测试防御点 a: 工具不存在时提供可用工具列表. func TestDefenseA_ToolNotFound(t *testing.T) { registry := NewRegistry() registry.Register(&concurrentTool{name: "Glob"}) registry.Register(&serialTool{name: "Edit"}) o := NewOrchestrator(registry, 10) result := o.executeSingle(context.Background(), ToolCall{ ID: "1", Name: "NonExistent", Input: json.RawMessage(`{}`), }) if !result.IsError { t.Fatal("不存在的工具应报错") } // 错误消息中应包含工具名 if !strings.Contains(result.Output, "NonExistent") { t.Errorf("错误消息应包含工具名 'NonExistent',实际: %q", result.Output) } // 错误消息中应包含可用工具列表 if !strings.Contains(result.Output, "Available tools") { t.Errorf("错误消息应包含 'Available tools',实际: %q", result.Output) } if !strings.Contains(result.Output, "Glob") { t.Errorf("错误消息应包含可用工具 'Glob',实际: %q", result.Output) } if !strings.Contains(result.Output, "Edit") { t.Errorf("错误消息应包含可用工具 'Edit',实际: %q", result.Output) } t.Logf("防御点 a 触发,错误: %s", result.Output) } // TestDefenseA_ToolNotFound_EmptyRegistry 测试注册表为空时工具不存在的错误消息. func TestDefenseA_ToolNotFound_EmptyRegistry(t *testing.T) { registry := NewRegistry() o := NewOrchestrator(registry, 10) result := o.executeSingle(context.Background(), ToolCall{ ID: "1", Name: "SomeTool", Input: json.RawMessage(`{}`), }) if !result.IsError { t.Fatal("不存在的工具应报错") } // 空注册表时应有特殊说明 if !strings.Contains(result.Output, "no tools registered") { t.Errorf("空注册表时应说明 'no tools registered',实际: %q", result.Output) } t.Logf("防御点 a(空注册表)触发,错误: %s", result.Output) } // TestDefenseB_InvalidJSON 测试防御点 b: 工具输入 JSON 无效时提供 InputSchema 提示. func TestDefenseB_InvalidJSON(t *testing.T) { registry := NewRegistry() tool := &schemaAwareTool{ name: "BashTool", schema: `{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}`, } registry.Register(tool) o := NewOrchestrator(registry, 10) result := o.executeSingle(context.Background(), ToolCall{ ID: "1", Name: "BashTool", Input: json.RawMessage(`{invalid json`), // 故意无效的 JSON }) if !result.IsError { t.Fatal("无效 JSON 应报错") } // 错误消息中应包含工具名 if !strings.Contains(result.Output, "BashTool") { t.Errorf("错误消息应包含工具名,实际: %q", result.Output) } // 错误消息中应提示 invalid input if !strings.Contains(result.Output, "invalid input") { t.Errorf("错误消息应包含 'invalid input',实际: %q", result.Output) } // 错误消息中应包含 InputSchema if !strings.Contains(result.Output, "Expected schema") { t.Errorf("错误消息应包含 'Expected schema',实际: %q", result.Output) } if !strings.Contains(result.Output, "command") { t.Errorf("错误消息中应包含 schema 内容 'command',实际: %q", result.Output) } t.Logf("防御点 b 触发,错误: %s", result.Output) } // TestDefenseB_ValidJSON_NoError 测试防御点 b 的 happy path: 有效 JSON 不触发错误. func TestDefenseB_ValidJSON_NoError(t *testing.T) { registry := NewRegistry() registry.Register(&schemaAwareTool{name: "BashTool"}) o := NewOrchestrator(registry, 10) result := o.executeSingle(context.Background(), ToolCall{ ID: "1", Name: "BashTool", Input: json.RawMessage(`{"command": "echo hi"}`), }) if result.IsError { t.Errorf("有效 JSON 不应报错,实际: %s", result.Output) } } // TestDefenseB_EmptyInput_NoError 测试防御点 b: 空输入不触发 JSON 错误. func TestDefenseB_EmptyInput_NoError(t *testing.T) { registry := NewRegistry() registry.Register(&schemaAwareTool{name: "BashTool"}) o := NewOrchestrator(registry, 10) // 空 input(nil 或空切片)不应被视为无效 JSON result := o.executeSingle(context.Background(), ToolCall{ ID: "1", Name: "BashTool", Input: json.RawMessage(nil), }) if result.IsError { t.Errorf("空输入不应报 JSON 解析错误,实际: %s", result.Output) } } // TestDefenseC_NonUTF8Output 测试防御点 c: 工具输出含非 UTF-8 字节时替换并追加警告. func TestDefenseC_NonUTF8Output(t *testing.T) { registry := NewRegistry() registry.Register(&binaryOutputTool{name: "BinaryTool"}) o := NewOrchestrator(registry, 10) result := o.executeSingle(context.Background(), ToolCall{ ID: "1", Name: "BinaryTool", Input: json.RawMessage(`{}`), }) if result.IsError { t.Fatalf("工具执行不应报错(只是输出有非 UTF-8 字节),实际: %s", result.Output) } // 输出中非 UTF-8 字节应被替换为 '?' if !utf8.ValidString(result.Output) { t.Error("处理后输出应为合法 UTF-8") } // 输出中应包含警告 if !strings.Contains(result.Output, "[warning: output contained non-UTF-8 bytes") { t.Errorf("应追加 UTF-8 警告,实际: %q", result.Output) } // 合法前缀应被保留 if !strings.Contains(result.Output, "valid prefix") { t.Errorf("合法前缀应被保留,实际: %q", result.Output) } t.Logf("防御点 c 触发,输出: %q", result.Output) } // TestDefenseC_ValidUTF8_NoWarning 测试防御点 c 的 happy path: 合法 UTF-8 输出不追加警告. func TestDefenseC_ValidUTF8_NoWarning(t *testing.T) { registry := NewRegistry() registry.Register(&concurrentTool{name: "Glob"}) o := NewOrchestrator(registry, 10) result := o.executeSingle(context.Background(), ToolCall{ ID: "1", Name: "Glob", Input: json.RawMessage(`{}`), }) if result.IsError { t.Fatalf("工具不应报错: %s", result.Output) } // 合法 UTF-8 输出不应追加警告 if strings.Contains(result.Output, "[warning: output contained non-UTF-8") { t.Errorf("合法 UTF-8 输出不应有警告,实际: %q", result.Output) } } // TestDefenseC_ChineseUTF8_NoWarning 测试中文(多字节 UTF-8)不触发警告. func TestDefenseC_ChineseUTF8_NoWarning(t *testing.T) { // 返回中文内容的 mock 工具 chineseTool := &chineseOutputTool{name: "ChineseTool"} registry := NewRegistry() registry.Register(chineseTool) o := NewOrchestrator(registry, 10) result := o.executeSingle(context.Background(), ToolCall{ ID: "1", Name: "ChineseTool", Input: json.RawMessage(`{}`), }) if result.IsError { t.Fatalf("工具不应报错: %s", result.Output) } if !utf8.ValidString(result.Output) { t.Error("输出应为合法 UTF-8") } if strings.Contains(result.Output, "[warning") { t.Errorf("中文 UTF-8 不应触发警告,实际: %q", result.Output) } } // chineseOutputTool 是输出中文内容的 mock 工具. type chineseOutputTool struct{ name string } func (c *chineseOutputTool) Name() string { return c.name } func (c *chineseOutputTool) Description(_ context.Context) string { return "中文工具" } func (c *chineseOutputTool) InputSchema() json.RawMessage { return json.RawMessage(`{}`) } func (c *chineseOutputTool) Execute(_ context.Context, _ json.RawMessage, _ ProgressFunc) (*Result, error) { return &Result{Output: "这是中文输出:文件已保存到 /tmp/result.txt"}, nil } // TestDefenseABC_ExecuteBatch_Integration 集成测试:通过 ExecuteBatch 触发 a/b/c 防御点. func TestDefenseABC_ExecuteBatch_Integration(t *testing.T) { registry := NewRegistry() registry.Register(&binaryOutputTool{name: "BinaryTool"}) o := NewOrchestrator(registry, 10) calls := []ToolCall{ // a: 不存在的工具 {ID: "1", Name: "NonExistent", Input: json.RawMessage(`{}`)}, // b: JSON 无效 {ID: "2", Name: "BinaryTool", Input: json.RawMessage(`{bad json`)}, // c: 正常调用但输出含非 UTF-8 {ID: "3", Name: "BinaryTool", Input: json.RawMessage(`{}`)}, } resultCh := make(chan ToolCallResult, 10) go func() { o.ExecuteBatch(context.Background(), calls, resultCh) close(resultCh) }() var results []ToolCallResult for r := range resultCh { results = append(results, r) } if len(results) != 3 { t.Fatalf("期望 3 个结果,实际: %d", len(results)) } // 找各结果(顺序可能因串/并行不同) byID := make(map[string]ToolCallResult) for _, r := range results { byID[r.ID] = r } // 验证 a if r, ok := byID["1"]; ok { if !r.IsError || !strings.Contains(r.Output, "Available tools") { t.Errorf("a: 期望含 Available tools,实际: %q", r.Output) } } // 验证 b if r, ok := byID["2"]; ok { if !r.IsError || !strings.Contains(r.Output, "invalid input") { t.Errorf("b: 期望含 invalid input,实际: %q", r.Output) } } // 验证 c if r, ok := byID["3"]; ok { if r.IsError { t.Errorf("c: 不应报错,实际: %q", r.Output) } if !strings.Contains(r.Output, "[warning: output contained non-UTF-8 bytes") { t.Errorf("c: 期望含 UTF-8 警告,实际: %q", r.Output) } } }