- 新增 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/臆造回退)。
280 lines
11 KiB
Go
280 lines
11 KiB
Go
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
|
||
}
|