Files
opsxdb/server/internal/ai/agent_test.go
T
logan 2336c9433c feat(ai): Agent SSE + Anthropic/responses tools + P1 确认写
- Agent 支持 SSE(步骤边推、正文分块),JSON 非流式 fallback
- tool calling 覆盖 openai-chat / anthropic / responses(单测解析回灌)
- P1:mcp.json mode=read_write 才可写;需 confirm_danger;Agent 剥离模型自带令牌
- GET /api/ai/mcp-status、POST /api/ai/agent/confirm;审计 source=mcp|agent
2026-10-15 13:00:40 +08:00

210 lines
6.1 KiB
Go

package ai
import (
"context"
"encoding/json"
"strings"
"testing"
"opsxdb/server/internal/aitools"
"opsxdb/server/internal/db"
)
// scriptedLLM 按调用次序返回预设 ToolsTurn,用于证明 Agent tool loop 不依赖真 LLM。
type scriptedLLM struct {
turns []ToolsTurn
i int
seen [][]Message
}
func (s *scriptedLLM) ChatWithTools(_ context.Context, _ string, messages []Message, tools []aitools.OpenAITool) (ToolsTurn, error) {
cp := append([]Message(nil), messages...)
s.seen = append(s.seen, cp)
if len(tools) < 7 {
return ToolsTurn{}, nil // 测试里仍继续;下面断言
}
if s.i >= len(s.turns) {
return ToolsTurn{Content: "fallback"}, nil
}
t := s.turns[s.i]
s.i++
return t, nil
}
func TestOpenAIToolsMatchRuntime(t *testing.T) {
tools := aitools.OpenAITools()
names := aitools.ToolNames()
if len(tools) != len(names) {
t.Fatalf("tools %d vs names %d", len(tools), len(names))
}
for i, n := range names {
if tools[i].Function.Name != n {
t.Fatalf("tool[%d]=%s want %s", i, tools[i].Function.Name, n)
}
if tools[i].Type != "function" {
t.Fatalf("type want function")
}
}
}
func TestRunAgentToolLoop(t *testing.T) {
rt := aitools.NewRuntime(aitools.Policy{
Mode: aitools.ModeReadOnly,
AllowedConnectionIDs: []string{"c1"},
DefaultLimit: 100,
MaxLimit: 500,
})
rt.FindConn = func(id string) (*aitools.SavedConnection, error) {
return &aitools.SavedConnection{ID: id, Name: "demo", Driver: "mysql", Host: "127.0.0.1", Port: 3306, Database: "demo"}, nil
}
rt.ListTbl = func(_ context.Context, c db.ConnReq) ([]string, error) {
if c.Database != "demo" && c.Database != "" {
return nil, nil
}
return []string{"users", "orders"}, nil
}
rt.ExecSQL = func(_ context.Context, c db.ConnReq) db.ExecResult {
if !strings.Contains(strings.ToUpper(c.SQL), "SELECT") {
return db.ExecResult{Error: "unexpected"}
}
return db.ExecResult{
Columns: []string{"id", "name"},
Rows: []map[string]interface{}{{"id": 1, "name": "alice"}, {"id": 2, "name": "bob"}},
RowCount: 2,
TimeMs: 1,
}
}
rt.Audit = func(aitools.AuditEntry) {}
argsList, _ := json.Marshal(map[string]any{"connection_id": "c1", "database": "demo"})
argsQuery, _ := json.Marshal(map[string]any{
"connection_id": "c1",
"database": "demo",
"sql": "SELECT id, name FROM users LIMIT 5",
})
llm := &scriptedLLM{turns: []ToolsTurn{
{
ToolCalls: []ToolCall{{
ID: "call_1", Type: "function",
Function: ToolCallFunc{Name: aitools.ToolListTables, Arguments: string(argsList)},
}},
},
{
ToolCalls: []ToolCall{{
ID: "call_2", Type: "function",
Function: ToolCallFunc{Name: aitools.ToolExecuteQuery, Arguments: string(argsQuery)},
}},
},
{Content: "demo 库有 users、orders。users 样例两行。\n\n```sql\nSELECT id, name FROM users LIMIT 5;\n```"},
}}
res, err := RunAgent(context.Background(), llm, rt, AgentReq{
Prompt: "列出 demo 库有哪些表,并查 users 几行",
ConnectionID: "c1",
Driver: "mysql",
Database: "demo",
})
if err != nil {
t.Fatalf("RunAgent: %v", err)
}
if len(res.Steps) != 2 {
t.Fatalf("want 2 steps, got %d: %+v", len(res.Steps), res.Steps)
}
if !res.Steps[0].OK || res.Steps[0].Tool != aitools.ToolListTables {
t.Fatalf("step0: %+v", res.Steps[0])
}
if !strings.Contains(res.Steps[0].Summary, "2 tables") {
t.Fatalf("list_tables summary: %s", res.Steps[0].Summary)
}
if !res.Steps[1].OK || res.Steps[1].Tool != aitools.ToolExecuteQuery {
t.Fatalf("step1: %+v", res.Steps[1])
}
if !strings.Contains(res.Steps[1].Summary, "rows=2") {
t.Fatalf("execute_query summary: %s", res.Steps[1].Summary)
}
if !strings.Contains(res.Content, "users") {
t.Fatalf("final content: %s", res.Content)
}
if res.SQL == "" {
t.Fatal("expected extracted SQL")
}
if res.Rounds != 3 {
t.Fatalf("rounds=%d", res.Rounds)
}
// 第二轮 messages 应已含 tool 角色回灌
if len(llm.seen) < 2 {
t.Fatal("expected multiple LLM calls")
}
foundTool := false
for _, m := range llm.seen[1] {
if m.Role == "tool" && m.ToolCallID == "call_1" {
foundTool = true
if !strings.Contains(m.Content, "users") {
t.Fatalf("tool result missing tables: %s", m.Content)
}
}
}
if !foundTool {
t.Fatal("second LLM call should include tool result for call_1")
}
}
func TestRunAgentRejectsWriteViaRuntime(t *testing.T) {
rt := aitools.NewRuntime(aitools.Policy{
Mode: aitools.ModeReadOnly,
AllowedConnectionIDs: []string{"c1"},
DefaultLimit: 100,
MaxLimit: 500,
})
rt.FindConn = func(id string) (*aitools.SavedConnection, error) {
return &aitools.SavedConnection{ID: id, Name: "demo", Driver: "mysql"}, nil
}
rt.Audit = func(aitools.AuditEntry) {}
args, _ := json.Marshal(map[string]any{"connection_id": "c1", "sql": "DROP TABLE users"})
llm := &scriptedLLM{turns: []ToolsTurn{
{ToolCalls: []ToolCall{{
ID: "x", Type: "function",
Function: ToolCallFunc{Name: aitools.ToolExecuteQuery, Arguments: string(args)},
}}},
{Content: "无法删除表:只读策略拒绝。"},
}}
res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "删掉 users"})
if err != nil {
t.Fatal(err)
}
if res.Steps[0].OK {
t.Fatal("DROP should fail")
}
errMsg := strings.ToLower(res.Steps[0].Error + " " + res.Steps[0].Summary)
if !strings.Contains(errMsg, "read-only") && !strings.Contains(errMsg, "not a read-only") {
t.Fatalf("expected read-only deny, got %+v", res.Steps[0])
}
}
func TestParseToolsTurn(t *testing.T) {
content := "hi"
raw, _ := json.Marshal(map[string]any{
"choices": []map[string]any{{
"finish_reason": "tool_calls",
"message": map[string]any{
"role": "assistant",
"content": content,
"tool_calls": []map[string]any{{
"id": "c1",
"type": "function",
"function": map[string]string{
"name": "opsxdb_list_tables",
"arguments": `{"connection_id":"x"}`,
},
}},
},
}},
})
turn, err := parseToolsTurnChat(raw, 200)
if err != nil || len(turn.ToolCalls) != 1 || turn.ToolCalls[0].Function.Name != "opsxdb_list_tables" {
t.Fatalf("parse: %+v %v", turn, err)
}
}