From c907a34510ef4aeb7fcd8c82ea58b04c42590546 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 09:03:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E9=9D=A2=201=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20=E2=80=94=E2=80=94=20=E6=B6=88=E6=81=AF=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E9=9D=A2=20toolCalls[]=EF=BC=88=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=9B=9E=E7=9C=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前提由 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 字符(常量+依据) - 只放图里已有的、已脱敏内容,不放宽脱敏通道 --- .../0023_flow_graphs_lane_turn_id.down.sql | 8 + .../0023_flow_graphs_lane_turn_id.up.sql | 48 ++ internal/runtime/flowgraph/flowgraph.go | 31 +- internal/runtime/flowgraph/turnkey.go | 121 ++++ internal/runtime/flowgraph/turnkey_test.go | 313 +++++++++++ internal/services/gateway/contracts.go | 27 +- internal/services/gateway/history.go | 20 +- .../services/gateway/history_toolcalls.go | 259 +++++++++ .../gateway/history_toolcalls_gap_test.go | 89 +++ .../gateway/history_toolcalls_test.go | 529 ++++++++++++++++++ 10 files changed, 1429 insertions(+), 16 deletions(-) create mode 100644 internal/db/migrations/0023_flow_graphs_lane_turn_id.down.sql create mode 100644 internal/db/migrations/0023_flow_graphs_lane_turn_id.up.sql create mode 100644 internal/runtime/flowgraph/turnkey.go create mode 100644 internal/runtime/flowgraph/turnkey_test.go create mode 100644 internal/services/gateway/history_toolcalls.go create mode 100644 internal/services/gateway/history_toolcalls_gap_test.go create mode 100644 internal/services/gateway/history_toolcalls_test.go diff --git a/internal/db/migrations/0023_flow_graphs_lane_turn_id.down.sql b/internal/db/migrations/0023_flow_graphs_lane_turn_id.down.sql new file mode 100644 index 0000000..e2d7925 --- /dev/null +++ b/internal/db/migrations/0023_flow_graphs_lane_turn_id.down.sql @@ -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; diff --git a/internal/db/migrations/0023_flow_graphs_lane_turn_id.up.sql b/internal/db/migrations/0023_flow_graphs_lane_turn_id.up.sql new file mode 100644 index 0000000..679ed0f --- /dev/null +++ b/internal/db/migrations/0023_flow_graphs_lane_turn_id.up.sql @@ -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); diff --git a/internal/runtime/flowgraph/flowgraph.go b/internal/runtime/flowgraph/flowgraph.go index 09ccd2b..8f79270 100644 --- a/internal/runtime/flowgraph/flowgraph.go +++ b/internal/runtime/flowgraph/flowgraph.go @@ -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 diff --git a/internal/runtime/flowgraph/turnkey.go b/internal/runtime/flowgraph/turnkey.go new file mode 100644 index 0000000..af6e57b --- /dev/null +++ b/internal/runtime/flowgraph/turnkey.go @@ -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-`)—— 两者不同源, +// 按图 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 +} diff --git a/internal/runtime/flowgraph/turnkey_test.go b/internal/runtime/flowgraph/turnkey_test.go new file mode 100644 index 0000000..891af47 --- /dev/null +++ b/internal/runtime/flowgraph/turnkey_test.go @@ -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 +} diff --git a/internal/services/gateway/contracts.go b/internal/services/gateway/contracts.go index e07d15d..63dade2 100644 --- a/internal/services/gateway/contracts.go +++ b/internal/services/gateway/contracts.go @@ -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) } diff --git a/internal/services/gateway/history.go b/internal/services/gateway/history.go index 5041a4d..26efba6 100644 --- a/internal/services/gateway/history.go +++ b/internal/services/gateway/history.go @@ -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) } diff --git a/internal/services/gateway/history_toolcalls.go b/internal/services/gateway/history_toolcalls.go new file mode 100644 index 0000000..1ec5c16 --- /dev/null +++ b/internal/services/gateway/history_toolcalls.go @@ -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]) + "…" +} diff --git a/internal/services/gateway/history_toolcalls_gap_test.go b/internal/services/gateway/history_toolcalls_gap_test.go new file mode 100644 index 0000000..c65c3bc --- /dev/null +++ b/internal/services/gateway/history_toolcalls_gap_test.go @@ -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) + } +} diff --git a/internal/services/gateway/history_toolcalls_test.go b/internal/services/gateway/history_toolcalls_test.go new file mode 100644 index 0000000..207d51f --- /dev/null +++ b/internal/services/gateway/history_toolcalls_test.go @@ -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 +}