package gateway // 本文件是**工具回看面**(契约 v0.76 ① 的 `toolCalls[]`;切片 CODE-TC)的验收。 // // 它走**真路径**:真内存 SQLite(history 库 + flow_graphs 库各一)、真 `flowgraph.SQLStore` // 落盘、真 HTTP handler 读回,并用**真建图器**(`trace.RunGraph` + `AddSpan`)造图 —— // 因此节点的形状与脱敏都是生产同一条路径的产物,而不是测试手搓的形状。 // // 钉住的四件事(缺一件这个特性就是错的): // // - **形状**:五个键(name/status/durationMs/argsSummary/resultSummary),三态闭集 // (ok|error|unknown),**没有第六个键**; // - **缺席语义**:图取不到 ⇒ 字段**整体缺席**,绝不回 `[]`(那是「没调过工具」的谎); // - **一次批量 SQL**:整页只调一次 `GraphsByTurnID`(禁 N+1); // - **只搬运、不放宽**:摘要逐字来自图里已脱敏的 props,读侧不新增、不改写。 import ( "bytes" "context" "encoding/json" "errors" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // toolCallEnv 是本文件的夹具:history 与 flow_graphs 各一个隔离内存库,两者都经 // 真实存储实现注入网关。 type toolCallEnv struct { ts *httptest.Server jwt *auth.JWTService history *history.Store store *flowgraph.SQLStore } // newToolCallEnv 打开两个内存库(history / flow_graphs)、注入网关并把 store 交回 // 给用例写图。log 为 nil 时用丢弃 logger;wireStore 为 false 时**不接线**图存储 // (演练「图源未接线」这一支)。 func newToolCallEnv(t *testing.T, deps Deps, log *slog.Logger, wireStore bool) *toolCallEnv { t.Helper() ctx := context.Background() if log == nil { log = testLogger() } histConn, err := db.Open(ctx, ":memory:", log) if err != nil { t.Fatalf("open history db: %v", err) } t.Cleanup(func() { _ = histConn.Close() }) st, err := history.NewStore(ctx, histConn, log) if err != nil { t.Fatalf("new history store: %v", err) } flowConn, err := db.Open(ctx, ":memory:", log) if err != nil { t.Fatalf("open flow db: %v", err) } t.Cleanup(func() { _ = flowConn.Close() }) if err := db.MigrateUp(ctx, flowConn, log); err != nil { t.Fatalf("migrate flow db: %v", err) } store, err := flowgraph.NewStore(flowgraph.Config{DB: flowConn, Log: log}) if err != nil { t.Fatalf("new flowgraph store: %v", err) } if deps.History == nil { deps.History = st } if deps.FlowGraphs == nil && wireStore { deps.FlowGraphs = store } deps.Log = log _, ts, jwt := buildEnv(t, cfgDefault(), deps) return &toolCallEnv{ts: ts, jwt: jwt, history: st, store: store} } // pageMessages 取一页消息并按内容映射成「线上 JSON 对象」,便于逐条断言「键在不在」 // 与「键的形状」—— 只有原始 JSON 才看得见「缺席」与「空数组」的区别。 func (e *toolCallEnv) pageMessages(t *testing.T, tok string) map[string]map[string]json.RawMessage { t.Helper() resp, body := doReq(t, http.MethodGet, e.ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list messages status = %d, want 200 (body=%s)", resp.StatusCode, body) } var raw []map[string]json.RawMessage decodeDataSlice(t, body, &raw) out := make(map[string]map[string]json.RawMessage, len(raw)) for _, m := range raw { var content string if err := json.Unmarshal(m["content"], &content); err != nil { t.Fatalf("decode content: %v", err) } out[content] = m } return out } // seedTurnMessages 落两条同一路的消息(user + assistant)与一条没有路的旧行, // 返回它们的 createdAt(断言顺序用不上,但保留可读的坐标)。 func seedTurnMessages(t *testing.T, st *history.Store, lane string) { t.Helper() ctx := context.Background() for _, m := range []history.Message{ {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "问:" + lane, TurnID: lane, CreatedAt: 1000}, {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "答:" + lane, TurnID: lane, CreatedAt: 1500}, {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "旧行(没有路)", CreatedAt: 2000}, } { if err := st.Append(ctx, m); err != nil { t.Fatalf("seed %q: %v", m.Content, err) } } } // toolSpan 造一条**已完成**的工具 span 记录:props 就是生产写入侧(loop/spanlog.go 的 // recordToolDone)会落在 span 上的那几个键,因此图构建器会按真脱敏路径处理它。 func toolSpan(id, name string, status trace.SpanStatus, started, ended time.Time, props map[string]any) trace.SpanRecord { return trace.SpanRecord{ ID: id, ParentID: "input", Name: "tool." + name, Kind: trace.SpanKindTool, Status: status, StartedAt: started, EndedAt: ended, Props: props, } } // laneGraphWithTools 用**真建图器**造一条路的收尾图(输入 → 工具 ×N → 输出)。 func laneGraphWithTools(session, lane string, seq int, tools []trace.SpanRecord) *trace.FlowGraph { start := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) g := trace.NewRunGraph(session) g.SetTurnID(lane) g.SetSeq(seq) g.Start(seq, start) g.SeedInput("问题") for _, rec := range tools { g.AddSpan(rec) } g.SeedOutput("答复") return g.Snapshot() } // toolCallKeys 是契约 v0.76 ① 允许出现的**全部**键(形状是闭集:多一个键就是扩了契约)。 var toolCallKeys = map[string]bool{ "name": true, "status": true, "durationMs": true, "argsSummary": true, "resultSummary": true, } // assertOnlyContractKeys 钉住「五个键之内」:任何多余键都说明契约被悄悄扩了。 func assertOnlyContractKeys(t *testing.T, where string, entry map[string]json.RawMessage) { t.Helper() for k := range entry { if !toolCallKeys[k] { t.Fatalf("%s: 出现了契约外的键 %q(形状:%v)", where, k, entry) } } } // TestMessagesToolCallsShaped_正常 是本文件的核心:一轮跑过两个工具(成功 + 失败)的 // 回复,读接口把五个键如实带出来,且**同一路的两条消息**都带(提问那条也带 —— 客户端 // 因此能在提问气泡上显示「这一问触发了什么」)。 func TestMessagesToolCallsShaped_正常(t *testing.T) { t.Parallel() env := newToolCallEnv(t, Deps{}, nil, true) seedTurnMessages(t, env.history, "turn-a") start := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) g := laneGraphWithTools(history.MainSessionID, "turn-a", 1, []trace.SpanRecord{ toolSpan("span-ok", "web_search", trace.SpanStatusOK, start, start.Add(1200*time.Millisecond), map[string]any{ "tool": "web_search", "args_summary": map[string]any{"query": "明天天气"}, // `result`(工具返回的原始结果)才是写入侧真实落的键:图构建器在出图时 // 把它洗成 `result_summary`(RedactText + 80 rune 截断),这里刻意给原始 // 形状,走的就是生产那条路。 "result": "晴,18~26℃", }), toolSpan("span-err", "read_file", trace.SpanStatusError, start, start.Add(30*time.Millisecond), map[string]any{ "tool": "read_file", "error_kind": "not_found", "error": "文件不存在:/tmp/x.txt", }), }) // 上面这些键都在 trace 的 props 放行表内(tool / args_summary / result / error / // error_kind),因此图里真实存下来的内容就是它们(`result` 会在出图时被洗成 // `result_summary`),而不是测试硬塞的形状。 if err := env.store.RecordRound(g); err != nil { t.Fatalf("落盘轮次图: %v", err) } tok := signAny(t, env.jwt, "acct", auth.RoleOwner) msgs := env.pageMessages(t, tok) for _, content := range []string{"问:turn-a", "答:turn-a"} { raw, ok := msgs[content] if !ok { t.Fatalf("响应里找不到 %q(body 里的内容 = %v)", content, keysOfRaw(msgs)) } calls := decodeToolCalls(t, content, raw) if len(calls) != 2 { t.Fatalf("%s 的 toolCalls 条数 = %d,want 2(%v)", content, len(calls), calls) } if calls[0]["name"] != "web_search" || calls[0]["status"] != "ok" { t.Fatalf("%s 第 1 条 = %v,want web_search/ok", content, calls[0]) } if calls[0]["durationMs"] != float64(1200) { t.Fatalf("%s 第 1 条 durationMs = %v,want 1200", content, calls[0]["durationMs"]) } if calls[0]["argsSummary"] != `{"query":"明天天气"}` { t.Fatalf("%s 第 1 条 argsSummary = %v,want 紧凑 JSON(图上那份已脱敏内容)", content, calls[0]["argsSummary"]) } if calls[0]["resultSummary"] != "晴,18~26℃" { t.Fatalf("%s 第 1 条 resultSummary = %v", content, calls[0]["resultSummary"]) } if calls[1]["name"] != "read_file" || calls[1]["status"] != "error" { t.Fatalf("%s 第 2 条 = %v,want read_file/error", content, calls[1]) } // 失败的调用在图上只有 error 摘要:读侧如实回它,否则 status:"error" 只剩 // 一个「失败了」而没有任何原因。 if calls[1]["resultSummary"] != "文件不存在:/tmp/x.txt" { t.Fatalf("%s 第 2 条 resultSummary = %v,want 图上的 error 摘要", content, calls[1]["resultSummary"]) } } // 没有路标识的旧行:**键不存在**(不是空数组)。 if legacy, ok := msgs["旧行(没有路)"]; ok { if _, present := legacy["toolCalls"]; present { t.Fatalf("无 turnId 的行不该有 toolCalls:%s", legacy["toolCalls"]) } } else { t.Fatal("响应里找不到旧行") } // 其余出口共用同一个结构体,但**绝不**被填充:把 DTO 直接编码也没有该键 // (本特性只挂在读接口上)。 b, err := json.Marshal(toHistoryMessage(history.Message{ID: "m", SessionID: "web", Content: "x", TurnID: "turn-a"})) if err != nil { t.Fatalf("marshal: %v", err) } if strings.Contains(string(b), "toolCalls") { t.Fatalf("toHistoryMessage 不该产出 toolCalls(其余出口必须逐字不变):%s", b) } } // TestMessagesToolCallsAbsentWhenNoGraph 钉住缺席语义:**图取不到 ⇒ 字段整体缺席**, // 绝不回 `[]` 去冒充「这一轮没调过工具」。 func TestMessagesToolCallsAbsentWhenNoGraph(t *testing.T) { t.Parallel() env := newToolCallEnv(t, Deps{}, nil, true) seedTurnMessages(t, env.history, "turn-a") // 落一条**没有工具节点**的图:这一路确实没调过工具 ⇒ 仍然缺席(客户端只能做 // 正向断言:有键 ⇒ 调过工具)。 noTools := laneGraphWithTools(history.MainSessionID, "turn-a", 1, nil) if err := env.store.RecordRound(noTools); err != nil { t.Fatalf("落盘无工具的图: %v", err) } // 另落一条别的路的图,确认不会被错配到这条路。 other := laneGraphWithTools(history.MainSessionID, "turn-z", 2, []trace.SpanRecord{ toolSpan("span-z", "some_tool", trace.SpanStatusOK, time.Now(), time.Now().Add(time.Millisecond), map[string]any{"tool": "some_tool"}), }) if err := env.store.RecordRound(other); err != nil { t.Fatalf("落盘别的路的图: %v", err) } tok := signAny(t, env.jwt, "acct", auth.RoleOwner) msgs := env.pageMessages(t, tok) for _, content := range []string{"问:turn-a", "答:turn-a", "旧行(没有路)"} { raw, ok := msgs[content] if !ok { t.Fatalf("响应里找不到 %q", content) } if v, present := raw["toolCalls"]; present { t.Fatalf("%s 不该有 toolCalls(没有可用作回看依据的图),得到 %s", content, v) } } } // TestMessagesToolCallsOneBatchQuery 钉住**禁 N+1**:一页消息无论多少条、多少个路标识, // 只允许调一次批量查图,且集合就是页内非空的那些标识。 func TestMessagesToolCallsOneBatchQuery(t *testing.T) { t.Parallel() stub := &stubFlowGraphStore{} env := newToolCallEnv(t, Deps{FlowGraphs: stub}, nil, true) ctx := context.Background() for _, m := range []history.Message{ {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "问1", TurnID: "turn-a", CreatedAt: 1000}, {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "问2", TurnID: "turn-b", CreatedAt: 1100}, {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "问3", TurnID: "turn-a", CreatedAt: 1200}, {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "问4", CreatedAt: 1300}, {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "问5", TurnID: "turn-c", CreatedAt: 1400}, } { if err := env.history.Append(ctx, m); err != nil { t.Fatalf("seed %q: %v", m.Content, err) } } tok := signAny(t, env.jwt, "acct", auth.RoleOwner) _ = env.pageMessages(t, tok) if len(stub.calls) != 1 { t.Fatalf("查图调用次数 = %d,want 1(一页一次批量,禁 N+1)", len(stub.calls)) } if got := len(stub.calls[0]); got != 3 { t.Fatalf("批量集合大小 = %d,want 3(页内去重前的非空路标识:turn-a ×2 + turn-b + turn-c 去重后 3): %v", got, stub.calls[0]) } seen := map[string]int{} for _, id := range stub.calls[0] { seen[id]++ } for _, want := range []string{"turn-a", "turn-b", "turn-c"} { if seen[want] == 0 { t.Fatalf("批量集合里缺 %q:%v", want, stub.calls[0]) } } if seen[""] > 0 { t.Fatalf("空标识不该进查询:%v", stub.calls[0]) } } // TestMessagesToolCallsQueryFailureDegrades 钉住失败处置:查图失败**不让正文陪葬** // (仍 200、内容照旧),只让回看面缺席,并留下一条带来源的 WARN —— 绝不静默。 func TestMessagesToolCallsQueryFailureDegrades(t *testing.T) { t.Parallel() var logs bytes.Buffer log := slog.New(slog.NewTextHandler(&logs, nil)) stub := &stubFlowGraphStore{err: errors.New("boom")} env := newToolCallEnv(t, Deps{FlowGraphs: stub}, log, true) seedTurnMessages(t, env.history, "turn-a") tok := signAny(t, env.jwt, "acct", auth.RoleOwner) msgs := env.pageMessages(t, tok) raw := msgs["答:turn-a"] if raw == nil { t.Fatal("查图失败不该影响正文") } if _, present := raw["toolCalls"]; present { t.Fatalf("查图失败时必须缺席,得到 %s", raw["toolCalls"]) } if !strings.Contains(logs.String(), "history tool calls unavailable") { t.Fatalf("查图失败必须留下可见的 WARN,日志 = %q", logs.String()) } } // TestMessagesToolCallsCapsAndStatus_边界 钉住三个上界与三态映射: // - 单条消息最多 20 条工具(多出来的按图序截断); // - argsSummary / resultSummary 各自 ≤ 500 rune(图上写入侧只截到 240 —— 这里的 // fixture 刻意**手搓**一条更长的摘要,模拟「写入侧放宽/第三方写入者」); // - 状态闭集:done⇒ok、error⇒error、其余(running/idle/空)⇒unknown。 func TestMessagesToolCallsCapsAndStatus_边界(t *testing.T) { t.Parallel() env := newToolCallEnv(t, Deps{}, nil, true) ctx := context.Background() if err := env.history.Append(ctx, history.Message{ SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "回复", TurnID: "turn-cap", CreatedAt: 1000, }); err != nil { t.Fatalf("seed: %v", err) } longResult := strings.Repeat("结", 600) longArgs := map[string]any{"query": strings.Repeat("查", 80), "path": strings.Repeat("p", 80), "name": strings.Repeat("n", 80), "url": strings.Repeat("u", 80), "id": strings.Repeat("i", 80), "kind": strings.Repeat("k", 80)} nodes := make([]trace.FlowNode, 0, 35) for i := 0; i < 25; i++ { status := trace.FlowStatusDone switch { case i == 1: status = trace.FlowStatusError case i == 2: status = trace.FlowStatusRunning case i == 3: status = trace.FlowStatusIdle case i == 4: status = "" } nodes = append(nodes, trace.FlowNode{ ID: "tool-" + itoa(i), Label: "tool-" + itoa(i), Kind: "tool", Status: status, StartedAt: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC), EndedAt: time.Date(2026, 10, 6, 12, 0, 1, 0, time.UTC), Props: map[string]any{ "tool": "tool-" + itoa(i), "args_summary": longArgs, "result_summary": longResult, }, }) } // 非工具节点绝不能被当工具回看。 nodes = append(nodes, trace.FlowNode{ID: "llm-1", Label: "思考", Kind: "llm", Status: trace.FlowStatusDone}, trace.FlowNode{ID: "output", Label: "答复", Kind: "output", Status: trace.FlowStatusDone}) if err := env.store.RecordRound(&trace.FlowGraph{ ID: "web-1#turn-cap", SessionID: history.MainSessionID, TurnID: "turn-cap", Nodes: nodes, }); err != nil { t.Fatalf("落盘: %v", err) } tok := signAny(t, env.jwt, "acct", auth.RoleOwner) raw := env.pageMessages(t, tok)["回复"] calls := decodeToolCalls(t, "回复", raw) if len(calls) != maxToolCallsPerMessage { t.Fatalf("toolCalls 条数 = %d,want %d(单条消息的工具上限)", len(calls), maxToolCallsPerMessage) } if calls[0]["name"] != "tool-0" || calls[len(calls)-1]["name"] != "tool-"+itoa(maxToolCallsPerMessage-1) { t.Fatalf("截断必须按图序取前 %d 条:首=%v 尾=%v", maxToolCallsPerMessage, calls[0]["name"], calls[len(calls)-1]["name"]) } wantStatus := map[int]string{0: "ok", 1: "error", 2: "unknown", 3: "unknown", 4: "unknown"} for i, want := range wantStatus { if calls[i]["status"] != want { t.Fatalf("tool-%d status = %v,want %v(三态闭集)", i, calls[i]["status"], want) } } for i, call := range calls { if call["status"] != "ok" && call["status"] != "error" && call["status"] != "unknown" { t.Fatalf("tool-%d status = %v 不在闭集内", i, call["status"]) } if args, _ := call["argsSummary"].(string); len([]rune(args)) > maxToolCallSummaryRunes { t.Fatalf("tool-%d argsSummary 长度 = %d rune,超过上限 %d", i, len([]rune(args)), maxToolCallSummaryRunes) } if res, _ := call["resultSummary"].(string); len([]rune(res)) > maxToolCallSummaryRunes { t.Fatalf("tool-%d resultSummary 长度 = %d rune,超过上限 %d", i, len([]rune(res)), maxToolCallSummaryRunes) } } // 截断必须看得见(末位是省略号),否则客户端会以为这就是完整的摘要。 if res := calls[0]["resultSummary"].(string); !strings.HasSuffix(res, "…") { t.Fatalf("被截断的 resultSummary 必须以省略号结尾(长度 %d rune)", len([]rune(res))) } } // TestMessagesToolCallsNilStore 钉住「图源未接线」:字段缺席、端点照常 200、正文 // 一字不差 —— 未接线不是错误,只是没有这一面(与 `flow_unavailable` 的管理端点不同: // 消息读取面绝不因回看面缺失而 5xx)。 func TestMessagesToolCallsNilStore(t *testing.T) { t.Parallel() env := newToolCallEnv(t, Deps{}, nil, false) seedTurnMessages(t, env.history, "turn-a") tok := signAny(t, env.jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200(回看面缺失不该让正文 5xx)", resp.StatusCode) } if strings.Contains(string(body), "toolCalls") { t.Fatalf("图源未接线时不该出现 toolCalls:%s", body) } } // stubFlowGraphStore 是只读查询桩:记录每次批量查询的标识集合(钉住「一页一次」), // 并可按需注入失败。 type stubFlowGraphStore struct { calls [][]string err error } func (s *stubFlowGraphStore) Graph(context.Context, string, string) (*trace.FlowGraph, error) { return nil, nil } func (s *stubFlowGraphStore) Latest(context.Context, string) (*trace.FlowGraph, error) { return nil, nil } func (s *stubFlowGraphStore) Turns(context.Context, string, int) ([]flowgraph.Turn, error) { return nil, nil } func (s *stubFlowGraphStore) GraphsByTurnID(_ context.Context, _ string, ids []string) (map[string]*trace.FlowGraph, error) { s.calls = append(s.calls, append([]string(nil), ids...)) if s.err != nil { return nil, s.err } return map[string]*trace.FlowGraph{}, nil } // decodeToolCalls 解码某条消息的 toolCalls,并顺手钉住形状闭集。 func decodeToolCalls(t *testing.T, where string, raw map[string]json.RawMessage) []map[string]any { t.Helper() payload, present := raw["toolCalls"] if !present { t.Fatalf("%s 缺 toolCalls(want 有)", where) } var entries []map[string]json.RawMessage if err := json.Unmarshal(payload, &entries); err != nil { t.Fatalf("%s 的 toolCalls 不是数组: %v (%s)", where, err, payload) } out := make([]map[string]any, 0, len(entries)) for i, entry := range entries { assertOnlyContractKeys(t, where+"["+itoa(i)+"]", entry) var m map[string]any b, err := json.Marshal(entry) if err != nil { t.Fatalf("re-marshal entry: %v", err) } if err := json.Unmarshal(b, &m); err != nil { t.Fatalf("decode entry: %v", err) } for _, must := range []string{"name", "status"} { if _, ok := entry[must]; !ok { t.Fatalf("%s[%d] 缺必在键 %q:%v", where, i, must, entry) } } out = append(out, m) } return out } // keysOfRaw 列出响应里出现过哪些内容(失败信息用)。 func keysOfRaw(m map[string]map[string]json.RawMessage) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } return out }