Add docs/16 for session state machine, incomplete/resume, queue semantics, and agent-loop/harness/MCP gap review. Wire RunOutcome on max-rounds, run_id through audit, TrimMessages/Redact hooks, ConfirmBindHash on write confirm path; session file skeleton without HTTP yet. No push.
156 lines
4.8 KiB
Go
156 lines
4.8 KiB
Go
package ai
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"opsxdb/server/internal/aitools"
|
|
)
|
|
|
|
func TestTrimMessagesKeepsSystemAndDropsOrphanTool(t *testing.T) {
|
|
h := DefaultHarness()
|
|
h.MaxContextTokens = 40
|
|
msgs := []Message{
|
|
{Role: "system", Content: "sys"},
|
|
{Role: "user", Content: strings.Repeat("u", 400)},
|
|
{Role: "assistant", Content: "a", ToolCalls: []ToolCall{{ID: "c1", Type: "function", Function: ToolCallFunc{Name: "opsxdb_list_tables", Arguments: "{}"}}}},
|
|
{Role: "tool", ToolCallID: "c1", Name: "opsxdb_list_tables", Content: `{"tables":["users"]}`},
|
|
{Role: "user", Content: "continue please"},
|
|
}
|
|
out := TrimMessages(msgs, h)
|
|
hasSys := false
|
|
for _, m := range out {
|
|
if m.Role == "system" {
|
|
hasSys = true
|
|
}
|
|
}
|
|
if !hasSys {
|
|
t.Fatal("system dropped")
|
|
}
|
|
orphan := TrimMessages([]Message{
|
|
{Role: "system", Content: "s"},
|
|
{Role: "tool", ToolCallID: "x", Content: "nope"},
|
|
{Role: "user", Content: "hi"},
|
|
}, h)
|
|
for _, m := range orphan {
|
|
if m.Role == "tool" {
|
|
t.Fatalf("expected orphan tool dropped: %+v", orphan)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectRepeatedToolCall(t *testing.T) {
|
|
fp := ToolCallFingerprint("opsxdb_list_tables", `{"connection_id":"c1"}`)
|
|
hist := []string{fp, fp, fp}
|
|
if !DetectRepeatedToolCall(hist, fp, 3) {
|
|
t.Fatal("want reject at 3")
|
|
}
|
|
if DetectRepeatedToolCall(hist[:2], fp, 3) {
|
|
t.Fatal("should allow under limit")
|
|
}
|
|
}
|
|
|
|
func TestRedactSensitive(t *testing.T) {
|
|
out := RedactSensitive("Bearer tokensecret password=secret mysql://user:pass@host/db")
|
|
if strings.Contains(out, "tokensecret") || strings.Contains(out, "password=secret") || strings.Contains(out, ":pass@") {
|
|
t.Fatalf("not redacted: %s", out)
|
|
}
|
|
if !strings.Contains(RedactSensitive("sk-abcdefghijklmnop"), "[REDACTED]") {
|
|
t.Fatal("sk key")
|
|
}
|
|
}
|
|
|
|
func TestRunAgentMaxRoundsIncomplete(t *testing.T) {
|
|
rt := aitools.NewRuntime(aitools.Policy{
|
|
Mode: aitools.ModeReadOnly,
|
|
AllowedConnectionIDs: []string{"c1"},
|
|
DefaultLimit: 100,
|
|
MaxLimit: 500,
|
|
})
|
|
rt.LoadConns = func() ([]aitools.SavedConnection, error) {
|
|
return []aitools.SavedConnection{{ID: "c1", Name: "d", Driver: "mysql"}}, nil
|
|
}
|
|
sawRunID := false
|
|
rt.Audit = func(e aitools.AuditEntry) {
|
|
if e.RunID != "" {
|
|
sawRunID = true
|
|
}
|
|
}
|
|
var turns []ToolsTurn
|
|
for i := 0; i < 3; i++ {
|
|
turns = append(turns, ToolsTurn{ToolCalls: []ToolCall{{
|
|
ID: "c", Type: "function",
|
|
Function: ToolCallFunc{Name: aitools.ToolListConnections, Arguments: "{}"},
|
|
}}})
|
|
}
|
|
llm := &scriptedLLM{turns: turns}
|
|
res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "loop", MaxRounds: 2})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Outcome != OutcomeIncomplete {
|
|
t.Fatalf("outcome=%s want incomplete", res.Outcome)
|
|
}
|
|
if res.IncompleteReason != ReasonMaxSteps {
|
|
t.Fatalf("reason=%s", res.IncompleteReason)
|
|
}
|
|
if !res.Resumable || res.RunID == "" {
|
|
t.Fatalf("resumable/runId: %+v", res)
|
|
}
|
|
if !sawRunID {
|
|
t.Fatal("expected audit run_id")
|
|
}
|
|
}
|
|
|
|
func TestRunAgentCompletedHasOutcome(t *testing.T) {
|
|
rt := aitools.NewRuntime(aitools.Policy{Mode: aitools.ModeReadOnly, AllowedConnectionIDs: []string{"c1"}, DefaultLimit: 100, MaxLimit: 500})
|
|
rt.Audit = func(aitools.AuditEntry) {}
|
|
llm := &scriptedLLM{turns: []ToolsTurn{{Content: "hello"}}}
|
|
res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "hi"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Outcome != OutcomeCompleted {
|
|
t.Fatalf("outcome=%s", res.Outcome)
|
|
}
|
|
if res.RunID == "" || res.StepsBudget != defaultAgentMaxRounds {
|
|
t.Fatalf("%+v", res)
|
|
}
|
|
}
|
|
|
|
func TestRunAgentRepeatCallRejected(t *testing.T) {
|
|
rt := aitools.NewRuntime(aitools.Policy{Mode: aitools.ModeReadOnly, AllowedConnectionIDs: []string{"c1"}, DefaultLimit: 100, MaxLimit: 500})
|
|
rt.LoadConns = func() ([]aitools.SavedConnection, error) {
|
|
return []aitools.SavedConnection{{ID: "c1", Name: "d", Driver: "mysql"}}, nil
|
|
}
|
|
rt.Audit = func(aitools.AuditEntry) {}
|
|
args := `{"connection_id":"c1"}`
|
|
var turns []ToolsTurn
|
|
// 同一轮多个相同 tool_call,以及跨轮:SameCallLimit=3 指 history 已有 3 次则拒第 4 次
|
|
// 每轮一个相同调用,第 4 轮应被拒但仍继续到最终答案
|
|
for i := 0; i < 4; i++ {
|
|
turns = append(turns, ToolsTurn{ToolCalls: []ToolCall{{
|
|
ID: "c" + string(rune('1'+i)), Type: "function",
|
|
Function: ToolCallFunc{Name: aitools.ToolListConnections, Arguments: args},
|
|
}}})
|
|
}
|
|
turns = append(turns, ToolsTurn{Content: "done"})
|
|
llm := &scriptedLLM{turns: turns}
|
|
res, err := RunAgent(context.Background(), llm, rt, AgentReq{Prompt: "x", MaxRounds: 8})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rejected := 0
|
|
for _, s := range res.Steps {
|
|
if strings.Contains(s.Summary, "repeated tool call") {
|
|
rejected++
|
|
}
|
|
}
|
|
if rejected < 1 {
|
|
t.Fatalf("expected at least one repeated rejection, steps=%+v", res.Steps)
|
|
}
|
|
_ = json.Marshal
|
|
}
|