Files
AskaEth 4a937bd269 feat(flow): toolCalls 覆盖率追到全部编排回合(L3)—— 图带权威回合标识
- 新增 StartRoundWithTurnID(ctx, owner, text):从 ctx 取权威回合标识(与 chat_messages.turn_id
  同值);Run 的唯一生产建图入口切到它;原 StartRound 语义逐字不变(1 行包装)
- 归属桶(并行按路分图)保持不变;落盘查询键 lane_turn_id 对单路也有值 ⇒ 消息面按等值查得到图
- 无权威标识时**保持缺席**(不拿桶里的路标识顶上:不许臆造)
- 不需要迁移 0024(0023 的列+索引已在位;老单路行标识根本没存过,回填=编造)

形状变化(已登记契约):graph.id 对单路也追加 #{回合标识}(web-7 → web-7#turn-…),
顺带修掉「重启后单路轮次 upsert 掉旧行」的隐患。3 个变异自检(退回老入口/无视 ctx/臆造回退)。
2026-10-06 14:12:25 +08:00

280 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loop
// 本文件钉住**权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2):`toolCalls[]` 的
// 覆盖率此前只到**真并行路** —— 图的 `TurnID` 取自归属桶里的路标识,而桶里的路标识
// 只有真并行路才有 ⇒ 普通单路回合(Web 单路聊天、队首续跑路)的图没有权威标识,
// 落盘后 `flow_graphs.lane_turn_id` 为空,消息面拿 `chat_messages.turn_id` 查不到图
// ⇒ `toolCalls` 整体缺席(契约允许,但不是需求本意)。
//
// 三条判据都用**真 `Loop.Run`**(或网关实际挂的那几条 ctx 通道)驱动,不靠手搓图:
//
// 1. 单路回合(只挂 `WithTurnID`)⇒ 帧 / 图 / 落盘行都带权威回合标识,且能按它查回
// (`GraphsByTurnID` 正是 gateway 的消息读取面取图的那个入口);
// 2. 没挂权威标识(后台思考 / 未接编排器)⇒ 如实缺席,**绝不**拿会话 id 之类的去臆造;
// 3. 真并行两路 ⇒ 归属桶一字未改:两路各一张图、各落一行、各按自己的回合标识查得回。
import (
"context"
"database/sql"
"strings"
"sync"
"testing"
"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/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// frameCapture 收集发布闭包收到的每一帧图快照(发布器可能被多路并发驱动,故加锁)。
type frameCapture struct {
mu sync.Mutex
frames []*trace.FlowGraph
}
func (c *frameCapture) add(g *trace.FlowGraph) {
c.mu.Lock()
defer c.mu.Unlock()
c.frames = append(c.frames, g)
}
func (c *frameCapture) snapshot() []*trace.FlowGraph {
c.mu.Lock()
defer c.mu.Unlock()
return append([]*trace.FlowGraph(nil), c.frames...)
}
// newFlowGraphStore 开一个临时库(内存)并把迁移跑满,返回**真落盘出口**与连接。
// 断言走真 store(`GraphsByTurnID` / `Turns`)与真 SQL(列里的字面值),与
// `internal/runtime/flowgraph` 的 newTestStore 同一手法 —— 本文件要验的是「库里真的
// 落下了那个值」,替身做不到。
func newFlowGraphStore(t *testing.T) (*flowgraph.SQLStore, *sql.DB) {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("打开内存库: %v", err)
}
if err := db.MigrateUp(ctx, conn, nil); err != nil {
_ = conn.Close()
t.Fatalf("内存库跑迁移: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
st, err := flowgraph.NewStore(flowgraph.Config{DB: conn, Log: testLogger()})
if err != nil {
t.Fatalf("flowgraph.NewStore: %v", err)
}
return st, conn
}
// captureFrames 造一台接了**真落盘出口**的发布器,并把每一帧图快照收集下来。
// 发布闭包只认本片的主题(`ws.graph_update`),与生产同一个 subject。
func captureFrames(t *testing.T, store *flowgraph.SQLStore) (*FlowPublisher, *frameCapture) {
t.Helper()
cap := &frameCapture{}
pub := NewFlowPublisher(func(subject string, payload any) {
if subject != graphUpdateSubject {
return
}
if g, ok := payload.(*trace.FlowGraph); ok {
cap.add(g)
}
}, func(string) bool { return true }, WithRoundRecorder(store))
return pub, cap
}
// toolCallAdapter 是「先请求一次工具、再给最终答复」的最小脚本适配器:它的存在只为
// 让图里真的长出一个工具节点(`toolCalls[]` 的数据源就是它)。
func toolCallAdapter(tool string) *mockAdapter {
return &mockAdapter{chat: []mgResp{
{resp: model.Response{Content: `{"tool_calls":[{"name":"` + tool + `","args":{}}]}`}},
{resp: model.Response{Content: "答复"}},
}}
}
// toolCallLoop 装一台「一次工具调用 + 最终答复」的循环,接上给定发布器。
func toolCallLoop(t *testing.T, pub *FlowPublisher) *Loop {
t.Helper()
return New(Options{
Agent: newTestAgent(t, toolCallAdapter("probe"), dispatcherWith(t, &mockTool{name: "probe", result: "工具结果"})),
MaxRounds: 8,
Flow: pub,
Tracer: trace.New(nil),
})
}
// TestRun_SingleLegRoundCarriesAuthoritativeTurnID_正常 是本片的核心:一次**普通单路
// 回合**(只挂网关那条 `WithTurnID`,不挂 `WithParallelLeg`)跑完之后,权威回合标识
// 一路到达「帧 → 图 → 落盘行」,且能被 `GraphsByTurnID` 按**消息那一个 id** 查回。
func TestRun_SingleLegRoundCarriesAuthoritativeTurnID_正常(t *testing.T) {
t.Parallel()
const turnID = "turn-single-1"
ctx := context.Background()
store, conn := newFlowGraphStore(t)
pub, frames := captureFrames(t, store)
// 网关在驱动每一路之前就这么挂(ws_hub_turn.go / beginLegLocked):单路也有。
res, err := toolCallLoop(t, pub).Run(WithTurnID(ctx, turnID), "web",
[]model.Message{model.TextMessage(model.RoleUser, "帮我跑一次工具")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Text != "答复" {
t.Fatalf("答复 = %q, want 答复", res.Text)
}
// ① 帧:单路的每一帧都带权威 `turnId`(此前缺席),会话归属与轮次号照旧。
all := frames.snapshot()
if len(all) == 0 {
t.Fatal("一帧都没发布")
}
for i, g := range all {
if g.TurnID != turnID {
t.Fatalf("第 %d 帧 turnId = %q, want %q(单路也必须带权威回合标识)", i, g.TurnID, turnID)
}
if g.SessionID != "web" {
t.Fatalf("第 %d 帧 sessionId = %q, want web(路不能改会话归属)", i, g.SessionID)
}
if g.Round == 0 {
t.Fatalf("第 %d 帧缺轮次号", i)
}
}
// ② 落盘行:`lane_turn_id` 就是那个权威回合标识(与 `chat_messages.turn_id` 同值,
// 因此消息面按等值就能查到本轮的图 —— gateway 的 `toolCalls[]` 正是这么取的)。
wantGraphID := "web-1#" + turnID
var lane string
if err := conn.QueryRowContext(ctx,
`SELECT lane_turn_id FROM flow_graphs WHERE session_id = ?`, "web").Scan(&lane); err != nil {
t.Fatalf("读落盘行的 lane_turn_id: %v", err)
}
if lane != turnID {
t.Fatalf("落盘 lane_turn_id = %q, want %q(单路行不再为空)", lane, turnID)
}
graphs, err := store.GraphsByTurnID(ctx, "web", []string{turnID})
if err != nil {
t.Fatalf("GraphsByTurnID: %v", err)
}
got := graphs[turnID]
if got == nil {
t.Fatalf("按 %q 查不到本轮图(命中 %d 条)—— 消息面因此给不出 toolCalls", turnID, len(graphs))
}
if got.ID != wantGraphID {
t.Fatalf("图 id = %q, want %q(会话级序号 + 权威回合标识)", got.ID, wantGraphID)
}
if !hasNode(got, "probe") {
t.Fatalf("查回来的图里没有工具节点(toolCalls 的数据源):%s", describeNodes(got))
}
// 每轮一行(不是每 span 一行),且 `Turns` 的键就是这张图的 id(面板翻历史轮用它)。
turns, err := store.Turns(ctx, "web", 0)
if err != nil {
t.Fatalf("Turns: %v", err)
}
if len(turns) != 1 || turns[0].TurnID != wantGraphID {
t.Fatalf("Turns = %+v, want 恰好一轮 %s", turns, wantGraphID)
}
}
// TestRun_NoAuthorityStaysAbsent_边界 钉住**没有权威标识就不许臆造**:未挂 `WithTurnID`
// 的运行(后台思考 / 未接编排器的调用方)图上 `turnId` 缺席、落盘 `lane_turn_id` 为空串
// (既不是 NULL,也不是会话 id、时间戳之类的替身)⇒ 读侧按任何值都查不到这一轮。
func TestRun_NoAuthorityStaysAbsent_边界(t *testing.T) {
t.Parallel()
const session = "sess-backthink"
ctx := context.Background()
store, conn := newFlowGraphStore(t)
pub, frames := captureFrames(t, store)
if _, err := toolCallLoop(t, pub).Run(ctx, session,
[]model.Message{model.TextMessage(model.RoleUser, "跑一次工具")}); err != nil {
t.Fatalf("Run: %v", err)
}
for i, g := range frames.snapshot() {
if g.TurnID != "" {
t.Fatalf("第 %d 帧凭空有了 turnId=%q(没有权威标识时必须缺席)", i, g.TurnID)
}
}
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 = ?`,
session).Scan(&lane, &isNul); err != nil {
t.Fatalf("读落盘行的 lane_turn_id: %v", err)
}
if lane != "" || isNul != 0 {
t.Fatalf("lane_turn_id = %q (is null = %d), want 空串且非 NULL", lane, isNul)
}
// 读侧的两个候选值都查不到:会话 id 与任意回合标识都不是「这一轮的图」,诚实地缺席。
found, err := store.GraphsByTurnID(ctx, session, []string{session, "turn-any"})
if err != nil {
t.Fatalf("GraphsByTurnID: %v", err)
}
if len(found) != 0 {
t.Fatalf("没有权威标识的轮次竟被查到 %d 条:%v", len(found), found)
}
}
// TestFlowPublisher_ParallelLanesKeepSeparateRows_正常 钉住**归属桶一字未改**:
// 同轮两路(网关挂的那两条 ctx 通道:`WithTurnID` + `WithParallelLeg`)仍各起各的图、
// 各落一行,各按自己的回合标识查得回 —— 换成权威标识当落盘/查询键,绝不能让两路
// 互相顶掉,也绝不能让两路混进同一张图。
func TestFlowPublisher_ParallelLanesKeepSeparateRows_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
store, conn := newFlowGraphStore(t)
pub, _ := captureFrames(t, store)
// 两路都在飞(同一轮):A、B 先后起轮,再各自收轮。
for _, lane := range []string{"turn-a", "turn-b"} {
legCtx := WithParallelLeg(WithTurnID(ctx, lane), lane)
pub.StartRoundWithTurnID(legCtx, FlowOwnerKey("web", lane), "标记"+lane)
}
for _, lane := range []string{"turn-a", "turn-b"} {
pub.EndRound(FlowOwnerKey("web", lane), "答复"+lane)
}
// 建图序号是**会话级**递增的(A 先起 ⇒ 1),因此两路的 id 各带自己的后缀。
wantID := map[string]string{"turn-a": "web-1#turn-a", "turn-b": "web-2#turn-b"}
var rows int
if err := conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM flow_graphs WHERE session_id = ?`, "web").Scan(&rows); err != nil {
t.Fatalf("COUNT 行数: %v", err)
}
if rows != 2 {
t.Fatalf("flow_graphs 行数 = %d, want 2(两路各一行,谁都不许顶掉谁)", rows)
}
found, err := store.GraphsByTurnID(ctx, "web", []string{"turn-a", "turn-b"})
if err != nil {
t.Fatalf("GraphsByTurnID: %v", err)
}
for _, lane := range []string{"turn-a", "turn-b"} {
g := found[lane]
if g == nil {
t.Fatalf("%s 那一路按自己的回合标识查不到(被另一路顶掉了?命中 %d 条)", lane, len(found))
}
if g.ID != wantID[lane] {
t.Fatalf("%s 的图 id = %q, want %q(各带自己那一路的后缀)", lane, g.ID, wantID[lane])
}
if !hasInputMark(g, "标记"+lane) {
t.Fatalf("%s 的图里没有自己那一路的输入(两路混进同一张图了):%s", lane, describeNodes(g))
}
}
// 最近一轮的两路都在内存里,页面按会话取图仍是两条泳道。
if lanes := pub.LatestLanes("web"); len(lanes) != 2 {
t.Fatalf("最近一轮的路数 = %d, want 2", len(lanes))
}
}
// hasInputMark 报告图里有没有标签含 mark 的 input 节点(两路各自被自己的用户文本播种)。
func hasInputMark(g *trace.FlowGraph, mark string) bool {
for _, n := range g.Nodes {
if n.Kind == "input" && strings.Contains(n.Label, mark) {
return true
}
}
return false
}