feat(gateway): 面 1 补齐 —— 消息读取面 toolCalls[](工具回看)

前提由 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 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
This commit is contained in:
AskaEth committed 2026-10-06 09:03:55 +08:00
1 parent 99a644d545
commit c907a34510
10 files changed
+1429 -16

No files matched your search

+21 -6
View File
@@ -317,14 +317,17 @@ type FlowGraphSource interface {
// FlowGraphStore 是**已落盘轮次图**的只读出口(契约 v0.73 的
// `GET /api/v1/admin/flow/graph` 的 `?turnId=` 与 `turns[]`;design/19 §4 的 P3
// 「多轮历史回放」)。生产实现是 `internal/runtime/flowgraph` 的 `*SQLStore`,
// 组合根把**同一个**句柄交给发布器(写入面:EndRound 每轮落一条)与本查询面。
// 「多轮历史回放」;**v0.76 ① 登记的工具回看面已由本包的 `GraphsByTurnID` 接上**
// (版本号由主会话在 `Docs/api-client.md` 统一登记))。生产实现是
// `internal/runtime/flowgraph` 的 `*SQLStore`,组合根把**同一个**句柄交给
// 发布器(写入面:EndRound 每路落一条)与本查询面(管理面板 + 消息读取面)。
//
// 它刻意只声明三个**读**方法:写入(RecordRound)与保留期清扫(Sweep)是发布器与
// 组合根的职责,管理查询面拿不到、也不该拿到它们(最小权限)。
// 它刻意只声明四个**读**方法:写入(RecordRound)与保留期清扫(Sweep)是发布器与
// 组合根的职责,查询面拿不到、也不该拿到它们(最小权限)。
//
// 为 nil 时端点回退到**只有内存**的行为(v0.71 口径):`turns[]` 为空、`?turnId=`
// 只能命中内存里那一轮。内存与落盘**都没接线**时才回 500 `flow_unavailable`。
// 为 nil 时管理端点回退到**只有内存**的行为(v0.71 口径):`turns[]` 为空、`?turnId=`
// 只能命中内存里那一轮;消息读取面则整体失去工具回看面(`toolCalls` 缺席,正文不受
// 影响)。内存与落盘**都没接线**时才回 500 `flow_unavailable`。
type FlowGraphStore interface {
// Graph 按 (sessionID, turnID) 取回一轮的图;该轮不存在时返回 (nil, nil)。
Graph(ctx context.Context, sessionID, turnID string) (*trace.FlowGraph, error)
@@ -332,4 +335,16 @@ type FlowGraphStore interface {
Latest(ctx context.Context, sessionID string) (*trace.FlowGraph, error)
// Turns 返回该会话已落盘轮次的摘要(最新在前),供面板选历史轮。
Turns(ctx context.Context, sessionID string, limit int) ([]flowgraph.Turn, error)
// GraphsByTurnID 按**路标识**(`chat_messages.turn_id`,迁移 0018)批量取图:
// 返回 `路标识 → 该路的图` 的映射,**取不到的标识不出现在映射里**。
//
// 它是消息读取面「工具回看面」(契约 v0.76 ① 的 `toolCalls[]`;切片 CODE-TC)
// 的读侧入口,补的正是 v0.76 登记的那块缺口:`Graph` 按**图 id**取(`turn_id`
// 列),而消息面手里只有**路标识**(`lane_turn_id` 列,迁移 0023)—— 两者不同源,
// 因此必须有这个按路查的入口。一页消息的标识集合**一次查完**,调用方绝不按消息
// 逐条查(禁 N+1)。
//
// 查询失败如实上抛(调用方据此让回看面缺席并记 WARN);**取不到不是错误**,
// 缺席天然落在「映射里没有这个键」上。
GraphsByTurnID(ctx context.Context, sessionID string, laneTurnIDs []string) (map[string]*trace.FlowGraph, error)
}
+18 -2
View File
@@ -106,8 +106,20 @@ type historyMessage struct {
// 一路回答尚未确定),因此它们的那一帧 `message_persisted` 不带 turnId ——
// 客户端从 `turn_begin`(帧壳 turnId + data.messageIds)得知归属,行本身随后
// 由它实际被处理的那一路补写。
TurnID string `json:"turnId,omitempty"`
CreatedAt int64 `json:"createdAt"`
TurnID string `json:"turnId,omitempty"`
// ToolCalls 是这条消息所属那**一轮**的工具回看面(契约 v0.76 ①;切片 CODE-TC):
// 形状 `[{name,status,durationMs,argsSummary,resultSummary}, ...]`,内容逐字来自
// **已落盘的轮次链路图**(`flow_graphs`),因此是出图时**已脱敏**的那一份。
//
// 🔴 缺席语义:该键**只在这条消息真的回看到工具调用时才出现**。图取不到
// (超保留期 / 该轮未落盘 / 隔离渠道会话从不入库 / 该路标识查不到图)、以及这一路
// 确实没调过工具,都是**键缺席** —— **绝不**回 `[]` 冒充「没调过工具」。客户端
// 因此只能做正向断言:「有这个键 ⇒ 这一轮调用过工具」。
//
// 它**只由读接口填充**(`handleListMessages`):其余共用本结构的出口
// (`message_persisted` / `message_edited` / 握手回放 / 渠道日志)逐字不变。
ToolCalls []historyToolCall `json:"toolCalls,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
// handleHistorySessions 先返回主会话,再返回每个子会话(最旧
@@ -283,6 +295,10 @@ func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
for _, m := range msgs {
out = append(out, toHistoryMessage(m))
}
// 工具回看面(契约 v0.76 ①;切片 CODE-TC):只在本端点补,且**一次批量查图**
// (按页内路标识集合,禁 N+1)。图取不到 ⇒ 字段整体缺席(attachToolCalls 只在
// 真的回看到工具调用时才写),绝不回空数组。
s.attachToolCalls(r.Context(), id, out)
writeJSON(w, http.StatusOK, out)
}
@@ -0,0 +1,259 @@
package gateway
// 本文件是消息读取面的**工具回看面**(契约 v0.76 ① 的 `toolCalls[]`;切片 CODE-TC)。
//
// 它回答安卓侧那个问题:「回看这条回复时,它当时到底调了什么工具、结果如何」。答案是
// **已落盘的轮次链路图**(`flow_graphs`,迁移 0022/0023,保留期 168h)——**不从正文
// 反解、不从内存态猜、不按时间窗凑**。链路:消息行的 `chat_messages.turn_id`(路标识,
// 迁移 0018)→ `flow_graphs.lane_turn_id`(迁移 0023,等值索引)→ 图里的 `tool` 节点
// → 本文件的 DTO。
//
// 四条硬口径:
//
// - **缺席而非空数组**:`toolCalls` 出现 ⇔ 这一轮**确实调用过工具**。图取不到
// (超保留期 / 该轮没落盘 / 隔离渠道会话从不入库 / 该路标识查不到图)时字段
// **整体缺席**,绝不回 `[]` 去冒充「这一轮没调过工具」(契约明写)。
// - **只搬运、不加工**:`argsSummary` / `resultSummary` 逐字取自图里**出图时已脱敏**
// 的 props(design/19 §3 的放行表 + 凭据形状清洗 + 截断)。这里**不做二次脱敏**
// (那会掩盖「图侧漏了」这个真问题),也**绝不**放宽 —— 图上没有的内容,这里就没有。
// - **一次批量 SQL**:一页消息的路标识集合交给 `FlowGraphStore.GraphsByTurnID` 一次
// 查完,绝不按消息逐条查(禁 N+1)。
// - **回看面失败不拖垮正文**:查图失败只记一条带来源的 WARN 并让字段缺席 —— 消息本身
// 已经读到了,让一个**可视化存档**的故障把整页历史变成 500 是反的。这也不是
// 「静默」:每一次失败都有日志,且字段缺席本就是契约登记的语义。
import (
"context"
"encoding/json"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// historyToolCall 是消息 DTO 里一条工具调用的线上结构(契约 v0.76 ① 登记的**五个键**)。
//
// `name` 与 `status` **恒在**(一条工具调用至少有个名字与一个状态结论);后三个键
// `omitempty` —— 图上给不出那一项时它**缺席**,绝不用 `0` 或空串去编一个值
// (与 `turnId` / `clientMsgId` / `documents` 同一口径)。
type historyToolCall struct {
// Name 是工具名(图节点的 `props.tool`,回落节点 label —— 两者同源:建图时
// label 就是去掉 `tool.` 前缀的 span 名)。
Name string `json:"name"`
// Status ∈ ok | error | unknown(闭集三态)。unknown 覆盖图上一切非终态结论
// (running / idle / 空),因此客户端**永远**能按三态分支,不必猜。
Status string `json:"status"`
// DurationMS 是这次调用在图上量到的耗时(毫秒)。缺席 = 图上给不出
// (缺起止时间戳,或不足 1 毫秒)—— 绝不估算。
DurationMS int64 `json:"durationMs,omitempty"`
// ArgsSummary 是图上**已脱敏**的参数摘要,逐字搬运(对象则压成紧凑 JSON)。
// 缺席 = 图里没有这一项(例如这次调用没有任何放行表内的参数值)。
ArgsSummary string `json:"argsSummary,omitempty"`
// ResultSummary 是图上**已脱敏**的结果摘要,逐字搬运。失败的调用在图上只有
// error 摘要(写侧二选一),这里如实回它 —— 否则 `status:"error"` 就只剩
// 一个「失败了」而没有任何原因。
ResultSummary string `json:"resultSummary,omitempty"`
}
// 工具调用的状态闭集(契约 v0.76 ①:`ok` | `error` | `unknown`)。它们是**线上
// 契约值**,与图节点的状态枚举(idle/running/done/error)刻意不同名:后者是面板的
// 渲染状态,前者是「这次调用成了没有」的结论,映射见 toolCallStatusOf。
const (
toolCallStatusOK = "ok"
toolCallStatusError = "error"
toolCallStatusUnknown = "unknown"
)
// 图上的判据常量:工具节点的 kind,以及该节点的 props 键。
//
// 都是**图的线上形状**(`trace.FlowNode` 的 JSON 字段),不是 API,因此 `trace` 包没有
// 导出它们。取值在 trace 侧由 `nodeKindTool` / `safePropKeys` 定义
// (`internal/runtime/trace/graph_build.go` / `graph_props.go`);两处必须同值,
// 由本包的测试用**真建图器**(`trace.RunGraph` + `AddSpan`)钉住 —— 只按字面量断言
// 会与实现一起漂移。
const (
flowNodeKindTool = "tool"
flowPropTool = "tool"
flowPropArgsSummary = "args_summary"
flowPropResultSummary = "result_summary"
flowPropError = "error"
)
const (
// maxToolCallsPerMessage 是单条消息最多回看的工具调用条数,超出按**图序**
// (建图顺序 ≈ 时序)取前 N 条。
//
// 依据:AGENTS §9 的「工具循环上限 8 轮」,每轮可并发调用若干个工具 ⇒ 20 覆盖
// 8 轮 × 2~3 个并发的现实上界。上限的存在是因为这段内容挂在**每一条**消息上:
// 一页 50 条消息若各自背着整轮的工具历史,读接口的响应会随工具数无界增长。
// 丢弃的是**最旧之外的尾巴**而不是随机截断,客户端因此看到的永远是「最先发生的
// 那些调用」。
maxToolCallsPerMessage = 20
// maxToolCallSummaryRunes 是 `argsSummary` / `resultSummary` 各自的最大 rune 数
// (契约 v0.76 ① 登记的 `resultSummary ≤ 500`,两个摘要取同一上界)。
//
// 依据:① 图上写入侧的结果摘要是 `RedactText(summarize(result))` ⇒ ≤ 80 rune
// (`internal/runtime/trace/redact.go` 的 `maxSummaryRunes` 截断),因此对结果摘要
// 这只是**独立兜底**:写入侧一旦放宽、或出现绕过它直接写 graph_json 的写入者,读侧
// 仍保证单条消息的响应不被撑爆;② `args_summary` 是**键值对象**,最坏 24 个键 ×
// 80 rune 值(同目录 `loop/spanlog.go` 的 `maxArgSummaryKeys` / `maxArgValueRunes`)
// ≈ 2.4k rune,这个上界**确实会生效**。
maxToolCallSummaryRunes = 500
)
// attachToolCalls 给一页消息补上各自的工具回看面(就地写进 DTO 切片)。
//
// 它只认页内**非空**的 `turnId`:老行 / IM 镜像 / 落库那刻还没有路的消息没有标识,
// 因此本就无从查图 —— 它们的 `toolCalls` 缺席,与「图取不到」得到同一个诚实结果。
func (s *Server) attachToolCalls(ctx context.Context, sessionID string, msgs []historyMessage) {
ids := pageTurnIDs(msgs)
if len(ids) == 0 || s.flowGraphs == nil {
// 无标识可查 / 图源未接线:整体缺席(后者不是错误,是「没有这一面」)。
return
}
graphs, err := s.flowGraphs.GraphsByTurnID(ctx, sessionID, ids)
if err != nil {
// 只让回看面缺席,不让正文陪葬;失败必须可见(带来源的 WARN,只记结构化字段:
// 会话 / 待查标识数 / 错误,绝不打印图内容或正文)。
s.log.Warn("history tool calls unavailable", "err", err, "session", sessionID, "turns", len(ids))
return
}
for i := range msgs {
g := graphs[msgs[i].TurnID]
if g == nil {
continue // 该路没有已落盘的图:缺席,绝不回空数组
}
if calls := toolCallsOf(g); len(calls) > 0 {
msgs[i].ToolCalls = calls
}
}
}
// pageTurnIDs 收集页内非空且**互不相同**的路标识(保持首次出现顺序)。去重在这里做,
// 是因为同一路的两条消息(提问 + 回答)必然带来重复:集合小一点、查询参数少一点,
// 且「一页一次批量、集合就是这页的路」这件事在读侧一眼可见(存储侧另有自己的去重
// 兜底,两处都不依赖对方)。
func pageTurnIDs(msgs []historyMessage) []string {
seen := make(map[string]bool, len(msgs))
out := make([]string, 0, len(msgs))
for _, m := range msgs {
id := strings.TrimSpace(m.TurnID)
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
// toolCallsOf 把一轮的图投影成工具回看面:按图序取 `kind == "tool"` 的节点,逐个映射为
// `historyToolCall`,最多 `maxToolCallsPerMessage` 条。没有任何工具节点时返回 nil
// (调用方据此让字段缺席)。
func toolCallsOf(g *trace.FlowGraph) []historyToolCall {
if g == nil {
return nil
}
out := make([]historyToolCall, 0, 4)
for i := range g.Nodes {
if len(out) >= maxToolCallsPerMessage {
break
}
n := g.Nodes[i]
if n.Kind != flowNodeKindTool {
continue
}
call := historyToolCall{
Name: toolCallNameOf(n),
Status: toolCallStatusOf(n.Status),
DurationMS: toolCallDurationMS(n),
ResultSummary: toolCallResultSummary(n),
}
call.ArgsSummary = toolCallArgsSummary(n)
out = append(out, call)
}
if len(out) == 0 {
return nil
}
return out
}
// toolCallStatusOf 把图节点的状态映射进契约的三态闭集:done ⇒ ok、error ⇒ error、
// 其余(running / idle / 未知)⇒ unknown。未知一律落进 unknown 而不是 ok —— 三态里
// 唯一的安全默认是「不知道」,绝不把没结论的调用说成成功。
func toolCallStatusOf(nodeStatus string) string {
switch nodeStatus {
case trace.FlowStatusDone:
return toolCallStatusOK
case trace.FlowStatusError:
return toolCallStatusError
default:
return toolCallStatusUnknown
}
}
// toolCallNameOf 取工具名:优先节点 props 里的 `tool`(落盘的就是这个键),缺失时回落
// 节点 label(建图时 label = 去掉 `tool.` 前缀的 span 名,两者同源)。
func toolCallNameOf(n trace.FlowNode) string {
if name, ok := n.Props[flowPropTool].(string); ok && strings.TrimSpace(name) != "" {
return name
}
return n.Label
}
// toolCallDurationMS 从节点的起止时间戳量耗时。任一时间戳缺失(图里没有 = 量不出)
// 或差值为非正时返回 0(线上随之省略该键)—— 绝不估算,也绝不回一个负数。
func toolCallDurationMS(n trace.FlowNode) int64 {
if n.StartedAt.IsZero() || n.EndedAt.IsZero() {
return 0
}
d := n.EndedAt.Sub(n.StartedAt)
if d <= 0 {
return 0
}
return d.Milliseconds()
}
// toolCallArgsSummary 把图上已脱敏的 `args_summary` 渲染成一行文本:对象压成紧凑 JSON
// (键序由 encoding/json 排序 ⇒ 同一份参数每次都渲染成同一串),本就是字符串时原样用。
// 值不在这里再清洗 —— 图侧写入时已逐个过凭据形状清洗与截断(design/19 §3)。
func toolCallArgsSummary(n trace.FlowNode) string {
v, ok := n.Props[flowPropArgsSummary]
if !ok {
return ""
}
if s, isStr := v.(string); isStr {
return truncateToolSummary(s)
}
b, err := json.Marshal(v)
if err != nil {
// 图上写的是 JSON 可编码的值,走到这里只能是外部改动;**绝不**把无法渲染的
// 值悄悄换成 `{}` 或 `null` 那种看起来像真摘要的东西 —— 如实缺席。
return ""
}
return truncateToolSummary(string(b))
}
// toolCallResultSummary 取图上已脱敏的结果摘要;没有它时回落 `error` 摘要(写侧对一次
// 调用**二选一**写这两个键,见 `loop/spanlog.go` 的 recordToolDone)—— 失败的调用因此
// 也带得出原因。两者都没有时返回空串(该键随 omitempty 缺席)。
func toolCallResultSummary(n trace.FlowNode) string {
if s, ok := n.Props[flowPropResultSummary].(string); ok && s != "" {
return truncateToolSummary(s)
}
if s, ok := n.Props[flowPropError].(string); ok && s != "" {
return truncateToolSummary(s)
}
return ""
}
// truncateToolSummary 把摘要截到 maxToolCallSummaryRunes 个 rune,确实被截断时末位换成
// 省略号(与图上写入侧同一手法:面板/客户端一眼看得出「这里被截过」)。按 rune 截,
// 绝不切开多字节字符。
func truncateToolSummary(s string) string {
r := []rune(s)
if len(r) <= maxToolCallSummaryRunes {
return s
}
return string(r[:maxToolCallSummaryRunes-1]) + "…"
}
@@ -0,0 +1,89 @@
package gateway
// 本文件钉住**单路回合的工具回看面缺口**(切片 CODE-TC 的诚实边界 —— 本用例钉的是
// **现状,不是期望行为**)。
//
// 事实链(三处代码,全部可核对):
//
// 1. 发布器建图时把「这一路的路标识」取自 `loop.flowOwner` → `ParallelLegFrom(ctx)`
// (`internal/runtime/loop/flow_branch.go`);
// 2. `WithParallelLeg` **只给真并行路**挂(`internal/services/gateway/session_parallel.go`
// 的 `beginLegLocked`:`if parallel { ... }`),普通回合 / 队首续跑路都不挂;
// 3. 于是普通回合的图 `TurnID == ""`(既有回归 `internal/runtime/loop/flow_lane_test.go`
// 的 `TestFlowPublisher_SingleLegUnchanged` 正是这么钉的),落盘后
// `flow_graphs.lane_turn_id` 为空 ⇒ 按消息的 `turn_id` **查不到图** ⇒ `toolCalls`
// 整体缺席(这是契约允许的「图取不到」,但**不是**我们要的覆盖率)。
//
// 因此本特性在当前代码下只覆盖**真并行路**的回复。要覆盖普通回合,必须让发布器把
// **ctx 上的权威 turnId**(`loop.TurnIDFrom`,普通回合也有)盖在图上 —— 那是
// `internal/runtime/loop/**` 的改动,**不属于本切片的面**,故此处只如实钉住。
//
// ⚠️ 本用例**红了**只可能意味着 loop 已经把权威 turnId 盖上了单路图:那时请把它改成
// 「单路回合也能回看到工具」的**正向断言**(并把 `loop` 侧那条改动一并交代),
// **不要**删掉它。
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// TestSingleLegRoundLacksLaneTurnID_现状缺口 演练一次**普通回合**(没有
// `WithParallelLeg`,与生产里 Web 单路聊天逐字同形):它的图确实落了盘、也确实带工具
// 节点,但图上没有路标识 ⇒ 消息侧无论用哪个值都查不到它 ⇒ `toolCalls` 缺席。
func TestSingleLegRoundLacksLaneTurnID_现状缺口(t *testing.T) {
t.Parallel()
env := newToolCallEnv(t, Deps{}, nil, true)
ctx := context.Background()
// 走生产那条落盘路径(发布器 → flowgraph.SQLStore.RecordRound)。
pub := flowPublisherWithStore(env.store, func(string) bool { return true })
pub.StartRound(history.MainSessionID, "问题")
sp := &trace.Span{
ID: "span-tool", Name: "tool.web_search", Kind: trace.SpanKindTool, StartedAt: time.Now(),
// 生产里的 span 由 Tracer 建(Props 已初始化);这里手搓一个,必须自己给 map,
// 否则 SetProp 会往 nil map 里写。
Props: map[string]any{},
}
pub.SpanStarted(history.MainSessionID, sp)
_ = sp.SetProp("tool", "web_search")
_ = sp.SetProp("result", "晴,18~26℃")
sp.Finish(trace.SpanStatusOK)
pub.SpanFinished(history.MainSessionID, sp)
pub.EndRound(history.MainSessionID, "答复")
snap := pub.LastGraph(history.MainSessionID)
if snap == nil {
t.Fatal("单路回合应当建出一张图")
}
if snap.TurnID != "" {
t.Fatalf("现状已变:单路图带上了 turnId=%q(loop 已盖上权威回合标识)—— 请把本用例改成"+
"「单路也能回看」的正向断言,不要删掉它", snap.TurnID)
}
// 库里那一行的 `lane_turn_id` 为空:拿消息侧的 turnId 查不到,拿图 id 也查不到
// (两者不同源,这正是 v0.76 登记的阻塞,只被 v0.77 解掉了一半)。
found, err := env.store.GraphsByTurnID(ctx, history.MainSessionID, []string{"turn-abc", snap.ID})
if err != nil {
t.Fatalf("GraphsByTurnID: %v", err)
}
if len(found) != 0 {
t.Fatalf("单路图竟然按路查到了:%v(现状已变,请更新本用例)", found)
}
// 端到端:这条普通回合的回复在读接口上没有 toolCalls(缺席,不是空数组)。
if err := env.history.Append(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "答复", TurnID: "turn-abc", CreatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
tok := signAny(t, env.jwt, "acct", auth.RoleOwner)
raw := env.pageMessages(t, tok)["答复"]
if v, present := raw["toolCalls"]; present {
t.Fatalf("单路回合本应查不到图(缺口),却给出了 toolCalls=%s", v)
}
}
@@ -0,0 +1,529 @@
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
}