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

@@ -0,0 +1,8 @@
-- 0023 的回滚:先删索引、再删列(SQLite 3.35+ 的 DROP COLUMN;索引必须先走,
-- 否则「被索引的列不能删」会让回滚失败)。
--
-- 本迁移只**加**一列与一个索引,不改任何既有列/行语义 ⇒ 回滚不触碰 0022 的
-- 表结构、唯一键与保留期索引,也不丢图(图仍在 graph_json 里)。
DROP INDEX IF EXISTS idx_flow_graphs_lane_turn_id;
ALTER TABLE flow_graphs DROP COLUMN lane_turn_id;
@@ -0,0 +1,48 @@
-- 0023_flow_graphs_lane_turn_id: 给 `flow_graphs` 一张**按路查询的键**(切片 CODE-TC)。
--
-- 动因(安卓侧需求,主人 2026-10-05 裁定):`GET /api/v1/sessions/{id}/messages`
-- 要能回看「这条回复当时调了什么工具」,数据源是**已落盘的轮次链路图**
-- (`flow_graphs`,迁移 0022),**不从正文反解、不靠时间窗猜**。要做到这一点,
-- 消息行里的 `chat_messages.turn_id`(= 编排器生成的**路标识**,迁移 0018)必须能
-- 按**等值**查到它那一轮的图 —— 而 0022 的 `turn_id` 列存的是 `trace.FlowGraph.ID`
-- (形如 `web-7` / `web-7#turn-a`),与消息的 turn_id **不同源**。这正是
-- Docs/api-client.md v0.76 把 `toolCalls[]` 登记为「未实现」的实测阻塞。
--
-- 列名刻意**不叫** `turn_id`(那个名字已被 0022 占用,语义是图 id)。`lane_turn_id`
-- 的语义一句话:**这一路**的权威回合标识,与 `chat_messages.turn_id` 同值同源
-- (`trace.FlowGraph.TurnID` / design/15 §2.5.9)。写入侧 `RecordRound` 每次都盖;
-- 它为空时该行**不可按路查到**,读取侧据此让 `toolCalls` 整体缺席。
--
-- 兼容老行(三类,都不做假):
-- ① **v0.77 起写入的真并行路行**:图 JSON 里已带 `$.turnId` ⇒ 本迁移用
-- `json_extract` 就地回填,不必重跑任何轮次;
-- ② **v0.77 之前写入的行**:图里根本没有这个字段(那时还没加)⇒ 回填后仍是 `''`;
-- ③ **单路回合的行**:它的图**当前不带** `$.turnId`(该键只对真并行路存在,
-- 见 `internal/runtime/loop/flow.go` 的 `flowOwner`)⇒ 同样回填成 `''`。
-- ②③ 的空串是**诚实的空**:读侧因此查不到这一轮的工具、`toolCalls` 整体缺席 ——
-- 正是契约登记的语义(「图取不到 ⇒ 字段缺席」,**绝不回空数组**冒充「没调过工具」)。
-- 这三类都不是本迁移能补齐的(缺口在写侧),因此本迁移只**如实落列**、不编造值。
--
-- 列口径与 0018 的 `chat_messages.turn_id` 逐字一致:`TEXT NOT NULL DEFAULT ''`,
-- **绝不用 NULL** ⇒「旧行」与「没写」是同一个值,读侧只有一种空情形要处理。
-- 命名沿用既有迁移口径(表名复数、索引 idx_{table}_{col};援引 AGENTS §10 的
-- 已登记例外)。
ALTER TABLE flow_graphs ADD COLUMN lane_turn_id TEXT NOT NULL DEFAULT '';
-- 一次性回填:只碰**还空着**的行。本迁移是第一次写这一列,此刻全空;条件仍写上,
-- 使本语句重复执行时幂等,且日后绝不会抹掉 RecordRound 写进去的值。
-- `json_type(...) = 'text'` 把「不是字符串的值」(畸形外部改动)排除在外;
-- `json_valid` 兜底被写坏的 JSON。两者都不成立的行保持 `''` —— 让整次迁移因一条
-- 坏行而回滚、后端起不来,是远超它应有代价的处置。
UPDATE flow_graphs
SET lane_turn_id = json_extract(graph_json, '$.turnId')
WHERE lane_turn_id = ''
AND json_valid(graph_json)
AND json_type(graph_json, '$.turnId') = 'text';
-- 读侧的**唯一**依赖:一页消息的路标识集合一次 `IN` 查完(禁 N+1)。
-- 前导列是 session_id —— 所有查询都带 `session_id = ?` 收敛到本会话,只索引
-- lane_turn_id 一列会让规划器退化成扫本会话全表。
CREATE INDEX IF NOT EXISTS idx_flow_graphs_lane_turn_id
ON flow_graphs (session_id, lane_turn_id);
+23 -8
View File
@@ -3,8 +3,9 @@
// 每次重载之后页面就没数据了」—— 在此之前图只活在 `loop.FlowPublisher` 的**内存**里,
// 后端一重启、或该会话还没跑新轮次,就什么都查不到。
//
// 只做一件事:把**每一轮的收尾图**(`trace.FlowGraph`)连会话与轮次标识落进
// `flow_graphs`(迁移 0022),并支持按轮次读回、按保留期清扫。
// 只做两件事:把**每一路的收尾图**(`trace.FlowGraph`)连会话与轮次标识落进
// `flow_graphs`(迁移 0022 / 0023),并支持按图 id 单轮读回、按**回合标识**批量读回
// (`GraphsByTurnID`,消息面回看工具调用的读侧入口)、按保留期清扫。
//
// 三条职责边界(别在这里越界):
//
@@ -153,20 +154,34 @@ func sourcedLog(l *slog.Logger) *slog.Logger {
return logger.WithSource(l, flowgraphLogSource)
}
// upsertRoundSQL 是落盘的唯一语句:列按迁移 0022 显式列出、值全部参数化。冲突键是
// `(session_id, turn_id)`(迁移里的唯一索引承载)—— 同一会话同一轮的二次收尾是同轮
// 覆盖(更新图与 updated_at,**不动 created_at**),而不是插出两行「同一轮」。
// upsertRoundSQL 是落盘的唯一语句:列按迁移 0022 / 0023 显式列出、值全部参数化。
// 冲突键是 `(session_id, turn_id)`(迁移里的唯一索引承载)—— 同一会话同一轮的二次
// 收尾是同轮覆盖(更新图与 updated_at,**不动 created_at**),而不是插出两行
// 「同一轮」。
//
// `lane_turn_id`(迁移 0023)与 `turn_id` **不是同一个东西**:前者是这一**路**的权威
// 回合标识(`trace.FlowGraph.TurnID`,与 `chat_messages.turn_id` 同值同源),后者是
// 图 id(`trace.FlowGraph.ID`,也是本表的冲突键)。落盘时两个都写,使「按消息的那一路
// 查图」(`GraphsByTurnID`)是一次等值索引查询,而不是拿图 id 去反解。二次收尾同样
// 更新它:同一条路的图又被收一次时,标识必须跟着走到最新的那一份。
const upsertRoundSQL = `INSERT INTO flow_graphs
(session_id, turn_id, graph_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
(session_id, turn_id, lane_turn_id, graph_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, turn_id) DO UPDATE SET
graph_json = excluded.graph_json,
lane_turn_id = excluded.lane_turn_id,
updated_at = excluded.updated_at`
// RecordRound 把一条**已收尾轮次**的图落盘(每轮一行)。它是发布器 `EndRound` 的落盘
// 出口,因此签名与 `loop.RoundRecorder` 一致:**不接收 ctx、自己用一个有界的后台
// context**(会话收尾路径不该被库拖住)。
//
// 它同时盖上这一路的**回合标识**(`g.TurnID` → `lane_turn_id`,迁移 0023),使消息面上
// 「这条回复当时调了什么工具」能按**等值**查到这一轮的图(见 `GraphsByTurnID`)。
// 标识为空时如实写空串:那一行因此按路查不到,读取侧让 `toolCalls` 整体缺席 ——
// 这正是契约的语义,绝不用时间窗或正文去猜(`g.TurnID` 为空的两类情形见迁移 0023
// 的注释:v0.77 之前的旧行与**当前**的单路回合)。
//
// 失败**上抛**给调用方(发布器记 WARN 后继续),绝不 panic、绝不改变图 —— 图是可视化
// 资产,落盘失败不该影响会话本身。
func (s *SQLStore) RecordRound(g *trace.FlowGraph) error {
@@ -184,7 +199,7 @@ func (s *SQLStore) RecordRound(g *trace.FlowGraph) error {
defer cancel()
stamp := sqliteTimeStamp(s.now())
if _, err := s.db.ExecContext(ctx, upsertRoundSQL,
g.SessionID, g.ID, string(payload), stamp, stamp); err != nil {
g.SessionID, g.ID, g.TurnID, string(payload), stamp, stamp); err != nil {
return fmt.Errorf("flowgraph: 落轮次图(%s/%s): %w", g.SessionID, g.ID, err)
}
return nil
+121
View File
@@ -0,0 +1,121 @@
package flowgraph
// 本文件是**按路批量查图**的读侧入口(切片 CODE-TC;迁移 0023 的 `lane_turn_id`)。
//
// 它补的是契约 v0.76 ① `toolCalls[]` 一直缺的那一件东西:v0.77 起每路各落一行、图上
// 带 `turnId`,但读侧只能按**图 id**(`turn_id` 列 = `trace.FlowGraph.ID`)单轮取,
// 而消息面上的 `chat_messages.turn_id` 是**路标识**(`turn-<hex>`)—— 两者不同源,
// 按图 id 查不到。本文件给出「一次把一页消息的路标识换成图」的那个批量入口。
//
// 三条口径:
//
// - **一次批量 SQL**:调用方给一页消息的路标识集合,本方法拼一条 `IN (...)` 查完,
// 绝不按消息逐条查(N+1)。集合超过 `maxTurnIDParams` 才分块(见该常量)。
// - **查不到就是查不到**:缺席的路标识不会出现在返回的 map 里,调用方据此让整个
// `toolCalls` 字段**缺席**;本方法绝不回「空图」占位(那也是编造)。
// - **只读**:不写库、不改图、不碰保留期。
import (
"context"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxTurnIDParams 是一次 `IN` 查询里最多绑定的路标识个数(分块上限)。
//
// 依据:① 上游 `GET /api/v1/sessions/{id}/messages` 的一页被 history 的
// `maxPageLimit = 200`(`internal/runtime/history/store.go`)钳住,因此一页最多 200 个
// 不同路标识 ⇒ **正常情况下只跑一次**(200 + 1 个绑定参数);② SQLite 的绑定参数上限
// 在不同构建里是 999 或 32766,201 离两者都还有一个量级余量。分块只在日后下游放宽
// 页大小时才生效,使「按页内集合批量查」不会某天突然撞上参数上限而让整页读接口报错。
const maxTurnIDParams = 200
// GraphsByTurnID 按**路标识**批量取图:返回 `turnID → 该路的图` 的映射。
//
// `laneTurnIDs` 是消息行的 `turn_id` 集合(迁移 0018),本方法只认 `lane_turn_id`
// (迁移 0023)—— 两者同源,因此这是一次等值索引查询(`idx_flow_graphs_lane_turn_id`)。
//
// 语义:
// - 空/全空白的集合、空会话 ⇒ 返回**空 map**(不发查询)。空 map 是「没东西可查」,
// 与「查了但没有」对调用方是同一件事:没有可用作回看依据的图。
// - 查不到的路标识**不出现在 map 里**(不塞 `nil` 占位):调用方必须据此让
// `toolCalls` 整体缺席,绝不回空数组。
// - 重复的标识只查一次;结果 map 的键是**查询用的原值**。
// - 一行 JSON 解不开 ⇒ 返回错误(与 `Graph` / `Latest` 同一口径:读不懂就如实报错,
// 绝不把坏行当成「这一轮没有图」)。调用方决定是整页失败还是只放弃回看面。
func (s *SQLStore) GraphsByTurnID(ctx context.Context, sessionID string, laneTurnIDs []string) (map[string]*trace.FlowGraph, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("flowgraph: query rounds by lane: %w", ErrNilDB)
}
ids := uniqueNonBlank(laneTurnIDs)
if strings.TrimSpace(sessionID) == "" || len(ids) == 0 {
return map[string]*trace.FlowGraph{}, nil
}
out := make(map[string]*trace.FlowGraph, len(ids))
for start := 0; start < len(ids); start += maxTurnIDParams {
end := start + maxTurnIDParams
if end > len(ids) {
end = len(ids)
}
if err := s.queryLaneChunk(ctx, sessionID, ids[start:end], out); err != nil {
return nil, err
}
}
return out, nil
}
// queryLaneChunk 查一块路标识并把解出来的图并进 out。
func (s *SQLStore) queryLaneChunk(ctx context.Context, sessionID string, ids []string, out map[string]*trace.FlowGraph) error {
args := make([]any, 0, len(ids)+1)
args = append(args, sessionID)
placeholders := make([]string, 0, len(ids))
for _, id := range ids {
placeholders = append(placeholders, "?")
args = append(args, id)
}
query := `SELECT lane_turn_id, graph_json FROM flow_graphs
WHERE session_id = ? AND lane_turn_id IN (` + strings.Join(placeholders, ", ") + `)`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("flowgraph: 按路批量查图(%s, %d 路): %w", sessionID, len(ids), err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
laneID string
payload string
)
if err := rows.Scan(&laneID, &payload); err != nil {
return fmt.Errorf("flowgraph: 读按路查图行(%s): %w", sessionID, err)
}
g, err := decodeGraph(sessionID, laneID, payload)
if err != nil {
return err
}
// 键取**行里那一列**而不是查询用的值:两者同值(查询就是按等值匹配),
// 但用行值使 map 的键与「库里真实存了什么」永远一致。
out[laneID] = g
}
if err := rows.Err(); err != nil {
return fmt.Errorf("flowgraph: 遍历按路查图行(%s): %w", sessionID, err)
}
return nil
}
// uniqueNonBlank 去掉空白项与重复项,保持首次出现的顺序(查询参数因此稳定可复现)。
func uniqueNonBlank(in []string) []string {
seen := make(map[string]bool, len(in))
out := make([]string, 0, len(in))
for _, raw := range in {
id := strings.TrimSpace(raw)
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
+313
View File
@@ -0,0 +1,313 @@
package flowgraph
// 本文件钉住**按路查图**(切片 CODE-TC;迁移 0023 的 `lane_turn_id`)。它是契约
// v0.76 ① 的 `toolCalls[]` 一直缺的那块读侧地基:消息行带的是**路标识**
// (`chat_messages.turn_id`),图行必须能按**同一个值**查到,否则「回看这条回复当时
// 调了什么工具」只能靠猜时间窗 —— 那是项目口径明令禁止的。
//
// 断言的是**库里的事实**(列里的值、查回来的行、索引在不在),不按日志文本断言,
// 与 flowgraph_test.go / flowgraph_lane_test.go 同一口径。
import (
"context"
"errors"
"sort"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// laneTurnMigrationID 是 0023 的迁移 id(与文件名逐字一致 —— 回填用例靠它把迁移
// 记账行撤回,使 `MigrateUp` **真的**重新应用一次 0023)。
const laneTurnMigrationID = "0023_flow_graphs_lane_turn_id"
// laneRoundGraph 造一条路的收尾图:图 id 与真建图器同形(`{会话}-{序号}[#{路}]`),
// laneTurn 是这一路的权威回合标识(= 消息行 `chat_messages.turn_id` 的同值)。
func laneRoundGraph(session, graphID, laneTurn string) *trace.FlowGraph {
return &trace.FlowGraph{
ID: graphID,
SessionID: session,
TurnID: laneTurn,
Nodes: []trace.FlowNode{
{ID: "input", Label: "问", Kind: "input", Status: trace.FlowStatusDone},
{ID: "output", Label: "答", Kind: "output", Status: trace.FlowStatusDone},
},
}
}
// TestRecordRound_WritesLaneTurnID_正常 钉住写入侧:落盘时把这一路的回合标识写进
// `lane_turn_id`(迁移 0023),空标识如实写**空串而不是 NULL**(与 0018 的
// `chat_messages.turn_id` 同一口径),且二次收尾同轮覆盖时标识跟着更新。
func TestRecordRound_WritesLaneTurnID_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, conn := newTestStore(t, nil)
if err := st.RecordRound(laneRoundGraph("web", "web-1#turn-a", "turn-a")); err != nil {
t.Fatalf("RecordRound(带路标识): %v", err)
}
var (
lane string
isNul int
)
if err := conn.QueryRowContext(ctx,
`SELECT lane_turn_id, lane_turn_id IS NULL FROM flow_graphs WHERE session_id = ? AND turn_id = ?`,
"web", "web-1#turn-a").Scan(&lane, &isNul); err != nil {
t.Fatalf("读 lane_turn_id: %v", err)
}
if lane != "turn-a" {
t.Fatalf("lane_turn_id = %q, want turn-a", lane)
}
if isNul != 0 {
t.Fatal("lane_turn_id 绝不允许为 NULL(旧的「没写」必须与「空」同值)")
}
// 单路回合(图上没有路标识):如实写空串。
if err := st.RecordRound(laneRoundGraph("web", "web-2", "")); err != nil {
t.Fatalf("RecordRound(无路标识): %v", err)
}
if err := conn.QueryRowContext(ctx,
`SELECT lane_turn_id FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-2").Scan(&lane); err != nil {
t.Fatalf("读单路行的 lane_turn_id: %v", err)
}
if lane != "" {
t.Fatalf("无路标识的行 lane_turn_id = %q, want 空串", lane)
}
// 二次收尾(同一图 id):一行,且标识跟着更新到最新那份。
if err := st.RecordRound(laneRoundGraph("web", "web-1#turn-a", "turn-a2")); err != nil {
t.Fatalf("RecordRound(二次收尾): %v", err)
}
var rows int
if err := conn.QueryRowContext(ctx,
`SELECT count(*) FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-1#turn-a").Scan(&rows); err != nil {
t.Fatalf("count rows: %v", err)
}
if rows != 1 {
t.Fatalf("同一图 id 的行数 = %d,want 1(唯一键仍必须合并)", rows)
}
if err := conn.QueryRowContext(ctx,
`SELECT lane_turn_id FROM flow_graphs WHERE session_id = ? AND turn_id = ?`, "web", "web-1#turn-a").Scan(&lane); err != nil {
t.Fatalf("读二次收尾后的 lane_turn_id: %v", err)
}
if lane != "turn-a2" {
t.Fatalf("二次收尾后 lane_turn_id = %q, want turn-a2", lane)
}
}
// TestGraphsByTurnID_BatchAndMiss_正常 钉住读侧:一次批量查询把页内路标识换成图,
// **取不到的标识不出现在映射里**(调用方据此让 `toolCalls` 整体缺席,绝不回空数组),
// 且不跨会话串图。
func TestGraphsByTurnID_BatchAndMiss_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
for _, g := range []*trace.FlowGraph{
laneRoundGraph("web", "web-1#turn-a", "turn-a"),
laneRoundGraph("web", "web-2#turn-b", "turn-b"),
laneRoundGraph("sess-other", "sess-other-1#turn-c", "turn-c"),
} {
if err := st.RecordRound(g); err != nil {
t.Fatalf("RecordRound(%s): %v", g.ID, err)
}
}
got, err := st.GraphsByTurnID(ctx, "web", []string{"turn-a", "turn-b", "turn-missing"})
if err != nil {
t.Fatalf("GraphsByTurnID: %v", err)
}
if len(got) != 2 {
t.Fatalf("命中 %d 条,want 2(缺席的标识必须**不在**映射里): %v", len(got), keysOf(got))
}
if g := got["turn-a"]; g == nil || g.ID != "web-1#turn-a" || g.TurnID != "turn-a" {
t.Fatalf("turn-a 的图 = %+v,want web-1#turn-a", g)
}
if _, ok := got["turn-missing"]; ok {
t.Fatal("缺席的标识不得出现在映射里(那会让调用方以为查到了图)")
}
// 另一个会话的同名标识绝不串过来。
other, err := st.GraphsByTurnID(ctx, "web", []string{"turn-c"})
if err != nil {
t.Fatalf("GraphsByTurnID(别的会话的路): %v", err)
}
if len(other) != 0 {
t.Fatalf("跨会话命中了 %v,want 空(session_id 必须参与过滤)", keysOf(other))
}
// 空集合 / 全空白集合 / 空会话:都不发查询,返回空映射而不是错误。
for name, ids := range map[string][]string{
"nil 集合": nil,
"空集合": {},
"全空白": {"", " "},
"空会话": {"turn-a"},
} {
session := "web"
if name == "空会话" {
session = " "
}
empty, err := st.GraphsByTurnID(ctx, session, ids)
if err != nil {
t.Fatalf("%s: err = %v, want nil", name, err)
}
if len(empty) != 0 {
t.Fatalf("%s: 命中 %v,want 空", name, keysOf(empty))
}
}
// 重复标识只算一次,结果仍按原值可查。
dedup, err := st.GraphsByTurnID(ctx, "web", []string{"turn-a", "turn-a", " turn-a "})
if err != nil {
t.Fatalf("GraphsByTurnID(重复标识): %v", err)
}
if len(dedup) != 1 || dedup["turn-a"] == nil {
t.Fatalf("重复标识的结果 = %v,want 恰好 turn-a 一条", keysOf(dedup))
}
}
// TestGraphsByTurnID_OverChunk_边界 钉住分块上限之外仍能查全:绑定参数集合一旦超过
// 单次 `IN` 的上限就必须分块,否则整页读接口会因为参数上限报错。这里给 250 个标识
// (> maxTurnIDParams)而只有 2 个真有图。
func TestGraphsByTurnID_OverChunk_边界(t *testing.T) {
t.Parallel()
ctx := context.Background()
st, _ := newTestStore(t, nil)
if err := st.RecordRound(laneRoundGraph("web", "web-1#turn-a", "turn-a")); err != nil {
t.Fatalf("RecordRound: %v", err)
}
// 第 250 个标识故意放在最后一块里(分块写错时最容易漏掉末尾)。
const total = 250
ids := make([]string, 0, total)
for i := 0; i < total-1; i++ {
ids = append(ids, "turn-none-"+itoa(i))
}
ids = append(ids, "turn-a")
got, err := st.GraphsByTurnID(ctx, "web", ids)
if err != nil {
t.Fatalf("GraphsByTurnID(%d 个标识): %v", total, err)
}
if len(got) != 1 || got["turn-a"] == nil {
t.Fatalf("命中 %v,want 恰好 turn-a(最后一块也必须被查到)", keysOf(got))
}
}
// TestGraphsByTurnID_NilDB_异常 钉住「没有数据库」是明确的错误(ErrNilDB),与
// Graph / Latest 同一口径 —— 绝不回一个「查不到任何图」的空映射去冒充「没有工具」。
func TestGraphsByTurnID_NilDB_异常(t *testing.T) {
t.Parallel()
var nilStore *SQLStore
for name, st := range map[string]*SQLStore{"nil 接收者": nilStore, "空存储": {}} {
if _, err := st.GraphsByTurnID(context.Background(), "web", []string{"turn-a"}); !errors.Is(err, ErrNilDB) {
t.Fatalf("%s: err = %v, want ErrNilDB", name, err)
}
}
}
// TestMigrate0023_BackfillLegacyRows_正常 钉住迁移 0023 对**老行**的处置:
// - v0.77 起写入、图 JSON 里带 `$.turnId` 的行 ⇒ 就地回填,不必重跑任何轮次;
// - 图里没有该键的行(v0.77 之前 / 单路回合)⇒ 如实留空串(查不到,`toolCalls` 缺席);
// - JSON 被写坏的行 ⇒ 保持空串而**不让整次迁移失败**(一条坏行不该让后端起不来);
// - 重复应用不覆盖已有值(`WHERE lane_turn_id = ”` 的幂等承诺)。
//
// 手法:把 0023 的列与记账行先撤回(等价于它的 down),插入老行,再跑一次真的
// `MigrateUp`。这样验的是**嵌入的那份迁移文件**,而不是测试里抄的一份 SQL。
func TestMigrate0023_BackfillLegacyRows_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
_, conn := newTestStore(t, nil)
for _, stmt := range []string{
`DROP INDEX IF EXISTS idx_flow_graphs_lane_turn_id`,
`ALTER TABLE flow_graphs DROP COLUMN lane_turn_id`,
} {
if _, err := conn.ExecContext(ctx, stmt); err != nil {
t.Fatalf("撤回 0023 的结构(%s): %v", stmt, err)
}
}
if _, err := conn.ExecContext(ctx, `DELETE FROM schema_migrations WHERE id = ?`, laneTurnMigrationID); err != nil {
t.Fatalf("撤回 0023 的记账行: %v", err)
}
legacy := []struct {
graphID string
graphRaw string
wantLane string
}{
{"web-1#turn-a", `{"id":"web-1#turn-a","sessionId":"web","turnId":"turn-a"}`, "turn-a"},
{"web-2", `{"id":"web-2","sessionId":"web"}`, ""},
{"web-3", `{"id":"web-3","sessionId":"web","turnId":""}`, ""},
{"web-4", `not json at all`, ""},
}
for _, row := range legacy {
if _, err := conn.ExecContext(ctx,
`INSERT INTO flow_graphs (session_id, turn_id, graph_json) VALUES (?, ?, ?)`,
"web", row.graphID, row.graphRaw); err != nil {
t.Fatalf("插入老行 %s: %v", row.graphID, err)
}
}
if err := db.MigrateUp(ctx, conn, nil); err != nil {
t.Fatalf("重新应用 0023: %v", err)
}
for _, row := range legacy {
var lane string
if err := conn.QueryRowContext(ctx,
`SELECT lane_turn_id FROM flow_graphs WHERE session_id = ? AND turn_id = ?`,
"web", row.graphID).Scan(&lane); err != nil {
t.Fatalf("读回填后的 %s: %v", row.graphID, err)
}
if lane != row.wantLane {
t.Fatalf("%s 的 lane_turn_id = %q, want %q", row.graphID, lane, row.wantLane)
}
}
var idx int
if err := conn.QueryRowContext(ctx,
`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_flow_graphs_lane_turn_id'`).Scan(&idx); err != nil {
t.Fatalf("查索引: %v", err)
}
if idx != 1 {
t.Fatal("0023 必须建出 idx_flow_graphs_lane_turn_id(按路批量查图靠它)")
}
// 下-上往返(开发库上最常见的动作:回滚一步再前滚):结构恢复,且回填结果与
// 第一次**逐字相同** —— 回填的唯一依据是图 JSON,因此往返是幂等的,绝不会
// 因为「跑过第二次」而丢标识或把值改坏。
for _, stmt := range []string{
`DROP INDEX IF EXISTS idx_flow_graphs_lane_turn_id`,
`ALTER TABLE flow_graphs DROP COLUMN lane_turn_id`,
} {
if _, err := conn.ExecContext(ctx, stmt); err != nil {
t.Fatalf("往返撤回(%s): %v", stmt, err)
}
}
if _, err := conn.ExecContext(ctx, `DELETE FROM schema_migrations WHERE id = ?`, laneTurnMigrationID); err != nil {
t.Fatalf("往返撤回记账行: %v", err)
}
if err := db.MigrateUp(ctx, conn, nil); err != nil {
t.Fatalf("往返后再次应用 0023: %v", err)
}
for _, row := range legacy {
var lane string
if err := conn.QueryRowContext(ctx,
`SELECT lane_turn_id FROM flow_graphs WHERE session_id = ? AND turn_id = ?`,
"web", row.graphID).Scan(&lane); err != nil {
t.Fatalf("往返后读 %s: %v", row.graphID, err)
}
if lane != row.wantLane {
t.Fatalf("往返后 %s 的 lane_turn_id = %q, want %q(往返必须幂等)", row.graphID, lane, row.wantLane)
}
}
}
// keysOf 把映射的键排序后列出(失败信息里要看清「到底命中了哪几个」)。
func keysOf(m map[string]*trace.FlowGraph) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
+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
}