Files
Cyrene_v2/internal/runtime/loop/flow_snapshot_test.go
T
AskaEth 1bf85a955b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 链路图冷启动快照(v0.71)+ 面板来源说明条 —— 修「轮次结束后打开页面一片空白」
- 断点已证 = **时序层**:graph_update 只走实时帧从不重放、flowGraphs 不持久化、补拉通路此前根本不存在(EndRound 注释承诺过「刷新能重放」,代码里从来没有)
  铁证:15:16:38 页面重载抹掉 15:09 那轮图,而下一轮心跳要等 ~15:24 ⇒ 打开后台思考页时桶必然为空
- 新增只读端点 GET /api/v1/admin/flow/graph?sessionId=(owner|admin;省略=主会话;graph 与 graph_update 帧的 data 逐字同形)
  无图 ⇒ 200 + graph:null(不画假图);图源未接线 ⇒ 500 flow_unavailable(不伪装成「没有图」);不审计、不发布
- loop.FlowPublisher.LastGraph:只读快照(不发布/不改图/不动轮次计数),FlowGraph.Clone 深拷贝避免锁外编码撕裂
- 前端 useFlowSnapshot:只在「本会话还没有图」时补拉(避免每行一个请求);Flow/Agents/Backthink 三处接入 + 来源说明条(明确「最近一轮」与「多轮历史回放尚未实现」)
- 变异实测:LastGraph 恒 nil ⇒ 4 条测试红;白名单去掉 sess-backthink ⇒ 对应测试红(首次变异因未使用 import 编译失败 ⇒ 已作废重做)
- ⚠️ 诚实边界:每会话只回**最近一轮**(多轮回放属 F2);白名单外会话(IM 群聊/陌生人)永远没有图;**后端重启后未跑新轮次前端点如实返 null(图是内存资产,不落库)**—— 主人要的「本地数据」正是 F2 要补的
2026-10-05 15:38:53 +08:00

180 lines
6.4 KiB
Go
Raw 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
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)
}
}
}