package loop import ( "context" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 本文件钉住契约 v0.71 的只读快照出口 `FlowPublisher.LastGraph`: // 「轮次结束后才打开的页面也能看到该轮的图」。它是 loop 侧的半边; // 网关侧的半边(`GET /api/v1/admin/flow/graph`)在 // internal/services/gateway/admin_flow_graph_test.go。 // countingPublisher 接上捕获式 publish 并计数,用来断言 // 「只读查询不发布」。 func countingPublisher(allow func(string) bool) (*FlowPublisher, func() int) { var mu sync.Mutex frames := 0 p := NewFlowPublisher(func(string, any) { mu.Lock() frames++ mu.Unlock() }, allow) return p, func() int { mu.Lock() defer mu.Unlock() return frames } } // TestFlowPublisher_LastGraph_AfterRoundEnded 跑一轮**真实**循环,然后在 // Run 返回之后(= 所有实时帧都已发完、晚开的页面一帧都收不到)取快照: // 它必须仍然带上该轮的主干与回复 —— 这就是主人报的那个场景。 func TestFlowPublisher_LastGraph_AfterRoundEnded(t *testing.T) { flow, frames := countingPublisher(func(string) bool { return true }) call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}` ad := &mockAdapter{chat: []mgResp{ {resp: model.Response{Content: call}}, {resp: model.Response{Content: "Echoed!"}}, }} l := New(Options{ Agent: newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "echo", result: "hi"})), MaxRounds: 8, Flow: flow, Tracer: trace.New(nil), }) if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "echo this")}); err != nil { t.Fatalf("Run: %v", err) } before := frames() g := flow.LastGraph("web") if g == nil { t.Fatal("LastGraph = nil,但该会话刚跑完一整轮(轮次结束后打开的页面将看不到图)") } kinds := map[string]bool{} for _, n := range g.Nodes { kinds[n.Kind] = true } for _, want := range []string{"input", "llm", "tool", "output"} { if !kinds[want] { t.Fatalf("快照缺少 %q 节点:%+v", want, g.Nodes) } } if g.SessionID != "web" { t.Fatalf("SessionID = %q, want web", g.SessionID) } if g.EndedAt.IsZero() { t.Fatal("EndedAt 为零值:快照不是收轮之后的那一份") } // 只读查询绝不发布 —— 否则补拉会在面板里凭空造出一帧 graph_update。 if got := frames(); got != before { t.Fatalf("LastGraph 发布了 %d 帧新图(%d → %d)", got-before, before, got) } } // TestFlowPublisher_LastGraph_KeepsLatestRound 钉住「最近一轮」的语义: // 第二轮开始后快照就是第二轮的,而不是第一轮残留。 func TestFlowPublisher_LastGraph_KeepsLatestRound(t *testing.T) { flow, _ := countingPublisher(func(string) bool { return true }) flow.StartRound("web", "第一轮") flow.EndRound("web", "第一轮回复") first := flow.LastGraph("web") if first == nil || first.ID != "web-1" { t.Fatalf("第一轮快照 = %+v, want id web-1", first) } flow.StartRound("web", "第二轮") flow.EndRound("web", "第二轮回复") second := flow.LastGraph("web") if second == nil || second.ID != "web-2" { t.Fatalf("第二轮快照 = %+v, want id web-2", second) } for _, n := range second.Nodes { if n.Kind == "input" && n.Label == "第一轮" { t.Fatalf("快照仍是第一轮的输入节点: %+v", n) } } } // TestFlowPublisher_LastGraph_EmptyCasesAreNil 钉住「确实没有图」的三种形态: // nil 接收者、空会话 id、从未建过图的会话,一律 nil(调用方据此渲染诚实空态, // 绝不允许补画节点)。 func TestFlowPublisher_LastGraph_EmptyCasesAreNil(t *testing.T) { flow, _ := countingPublisher(func(string) bool { return true }) flow.StartRound("web", "你好") var nilPub *FlowPublisher if g := nilPub.LastGraph("web"); g != nil { t.Fatalf("nil 接收者应返回 nil,得到 %+v", g) } if g := flow.LastGraph(""); g != nil { t.Fatalf("空会话 id 应返回 nil,得到 %+v", g) } if g := flow.LastGraph("sess-never-ran"); g != nil { t.Fatalf("从未建图的会话应返回 nil,得到 %+v", g) } } // TestFlowPublisher_LastGraph_WhitelistStillApplies 钉住只读查询**不绕过** // 发布会话白名单(design/19 §1 裁决 01.4.5 未被本版改动):白名单外的会话 // 从未建图 ⇒ 查询也拿不到。 func TestFlowPublisher_LastGraph_WhitelistStillApplies(t *testing.T) { flow, _ := countingPublisher(func(session string) bool { return session == "web" }) flow.StartRound("web", "你好") flow.StartRound("sess-im-stranger", "群里的消息") if g := flow.LastGraph("web"); g == nil { t.Fatal("白名单内的会话应当有图") } if g := flow.LastGraph("sess-im-stranger"); g != nil { t.Fatalf("白名单外的会话不该有图,得到 %+v", g) } } // TestFlowPublisher_LastGraph_IsDetachedCopy 钉住快照是**深拷贝**: // 读者拿到之后在锁外编码时,活图可能正被下一帧 span 就地合并 // (RunGraph.merge 往同一个 map 写、往同一个切片追加)—— 共享底层存储 // 会撕裂,Go 里并发读写 map 直接是致命错误。因此改读者手里的那份, // 绝不能影响发布器内部的图。 func TestFlowPublisher_LastGraph_IsDetachedCopy(t *testing.T) { flow, _ := countingPublisher(func(string) bool { return true }) flow.StartRound("web", "你好") flow.MarkInputSteered("web") first := flow.LastGraph("web") if first == nil { t.Fatal("LastGraph = nil") } for i := range first.Nodes { if first.Nodes[i].ID != "input" { continue } first.Nodes[i].Props["tampered"] = true first.Nodes[i].Props["steered"] = false } first.Nodes = append(first.Nodes, trace.FlowNode{ID: "ghost", Kind: "input"}) second := flow.LastGraph("web") if len(second.Nodes) == len(first.Nodes) { t.Fatal("第二次快照的节点数与读者改过的切片相同 ⇒ 返回的不是独立副本") } for _, n := range second.Nodes { if n.ID == "ghost" { t.Fatal("读者追加的节点漏进了发布器内部的图 ⇒ 不是深拷贝") } if n.ID != "input" { continue } if _, ok := n.Props["tampered"]; ok { t.Fatal("读者改写 props 影响到了内部图 ⇒ props 未深拷贝") } if steered, ok := n.Props["steered"].(bool); !ok || !steered { t.Fatalf("内部图原有的 steered 标记被读者改坏: %+v", n.Props) } } }