前提由 FV2 提供(图自 v0.77 起带每路 turnId、每路各落一行)。本片:
- 迁移 0023:flow_graphs 加「按路查询键」(写入时回填图的 TurnID),老行按缺席处理
- GET /api/v1/sessions/{id}/messages 加**可选** toolCalls[],形状严格照 Docs/api-client.md
v0.76 ① 已登记的:{name, status(ok|error|unknown), durationMs, argsSummary, resultSummary}
- 🔴 图取不到 ⇒ **字段整体缺席**(绝不回空数组冒充「没调过工具」)
- 一条批量 SQL(按页内 turnId 集合),禁 N+1;单条 ≤20 个工具、resultSummary ≤500 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
530 lines
21 KiB
Go
530 lines
21 KiB
Go
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
|
||
}
|