package gateway // 本文件钉住契约 v0.77 的**按路**形状(切片 FV2,修 design/15 §2.5.10-1 登记的那条 // 「graph_update 互相顶掉」): // // - 帧 / 快照的图上多了 `turnId`(这张图属于哪一路的权威回合标识)与 `round` // (会话轮次号),两者都是**纯增量**(老客户端忽略未知键仍可用); // - `GET /api/v1/admin/flow/graph` 多了 `lanes[]`:真并行时同一会话同一轮有多条路, // 只回 `graph` 一棵会把其余路从面板上抹掉。 import ( "encoding/json" "net/http" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // startLane 让发布器开始**一条路**的一轮(起轮 + 一个在飞的工具 span),并把归属桶与 // 该 span 交回调用方,供稍后收尾用。两条路必须**交错**起轮(B 在 A 在飞时起)—— // 那才是真并行的现场:轮次号正是在「上一轮的路全部收尾」之后才推进的。 func (e *flowTestEnv) startLane(t *testing.T, session, lane, input string) (string, *trace.Span) { t.Helper() owner := loop.FlowOwnerKey(session, lane) e.flow.StartRound(owner, input) sp := &trace.Span{ID: "span-" + lane, Name: "tool." + lane, Kind: trace.SpanKindTool, StartedAt: time.Now()} e.flow.SpanStarted(owner, sp) sp.Finish(trace.SpanStatusOK) e.flow.SpanFinished(owner, sp) return owner, sp } // finishLane 收尾一条路(答复播种 output 节点并发布最终帧)。 func (e *flowTestEnv) finishLane(t *testing.T, owner, output string) { t.Helper() e.flow.EndRound(owner, output) } // flowLanesData 解码响应体里的 `lanes[]`(契约 v0.77)。缺键时回 nil(调用方据此判红)。 func flowLanesData(t *testing.T, body []byte) []map[string]json.RawMessage { t.Helper() env := decodeBody(t, body) var data struct { Lanes []map[string]json.RawMessage `json:"lanes"` } if err := json.Unmarshal(env["data"], &data); err != nil { t.Fatalf("decode lanes %s: %v", body, err) } return data.Lanes } // TestAdminFlowGraph_ReturnsEveryLaneOfLatestRound 是本次快照侧的核心断言: // 真并行两路各自收轮之后打开页面,`lanes[]` 必须**每一路一条**、彼此不缺, // 且 `graph`(向后兼容的那一棵)是其中最新的一路。 func TestAdminFlowGraph_ReturnsEveryLaneOfLatestRound(t *testing.T) { env := newFlowTestEnv(t, func(string) bool { return true }) ownerA, _ := env.startLane(t, "web", "turn-a", "标记A") ownerB, _ := env.startLane(t, "web", "turn-b", "标记B") env.finishLane(t, ownerA, "答复A") env.finishLane(t, ownerB, "答复B") resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, env.token(t, auth.RoleOwner)) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } _, graph, present := flowGraphData(t, body) if !present || graph == nil { t.Fatalf("`graph` 必须恒在且非空(两路都收轮了):%s", body) } lanes := flowLanesData(t, body) if len(lanes) != 2 { t.Fatalf("lanes 条数 = %d,want 2(同一轮的两路各一条):%s", len(lanes), body) } seen := map[string]string{} for _, lane := range lanes { var ( turnID string gid string nodes []struct { Kind string `json:"kind"` Label string `json:"label"` } ) _ = json.Unmarshal(lane["turnId"], &turnID) var inner map[string]json.RawMessage if err := json.Unmarshal(lane["graph"], &inner); err != nil { t.Fatalf("lanes[].graph 不是图对象:%v (%s)", err, body) } _ = json.Unmarshal(inner["id"], &gid) if err := json.Unmarshal(inner["nodes"], &nodes); err != nil { t.Fatalf("lanes[].graph.nodes: %v", err) } seen[turnID] = gid for _, n := range nodes { if n.Kind == "tool" && n.Label != turnID { t.Fatalf("路 %s 的图里出现了另一路的工具节点 %q(两路又混进同一张图了)", turnID, n.Label) } } } for _, want := range []string{"turn-a", "turn-b"} { if seen[want] == "" { t.Fatalf("lanes[] 里缺 %s 那一路:%v (%s)", want, seen, body) } } // graph(兼容字段)= 最新一路;两条路 id 必须不同(否则落盘会互相顶掉)。 if seen["turn-a"] == seen["turn-b"] { t.Fatalf("两路拿到同一个图 id %q:%v", seen["turn-a"], seen) } var graphID string _ = json.Unmarshal(graph["id"], &graphID) if graphID != seen["turn-b"] { t.Fatalf("graph.id = %q,want 最新一路 %q(后起的那一路最新)", graphID, seen["turn-b"]) } } // TestAdminFlowGraph_SingleLaneHasOneEntry 钉住单路口径:没有真并行时 `lanes[]` 是 // 恰好一条(无路标识),`graph` 与它同源 —— 「每一路」在单路下就是那一路, // 不因为引入本键而多出一棵假的第二路。 func TestAdminFlowGraph_SingleLaneHasOneEntry(t *testing.T) { env := newFlowTestEnv(t, func(string) bool { return true }) env.runFinishedRound(t, "web") resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=web", nil, env.token(t, auth.RoleOwner)) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } lanes := flowLanesData(t, body) if len(lanes) != 1 { t.Fatalf("单路的 lanes 条数 = %d,want 1:%s", len(lanes), body) } var turnID string _ = json.Unmarshal(lanes[0]["turnId"], &turnID) if turnID != "" { t.Fatalf("单路的 lanes[0].turnId = %q,want 空(没有路标识 ⇒ 客户端按会话那一张画)", turnID) } } // TestAdminFlowGraph_LanesEmptyOnEmptySession 钉住空态:该会话还没有图时 `lanes` // 是空数组(恒非 null)—— 与 `graph:null` 同一诚实口径,客户端不必判空。 func TestAdminFlowGraph_LanesEmptyOnEmptySession(t *testing.T) { env := newFlowTestEnv(t, func(string) bool { return true }) resp, body := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/flow/graph?sessionId=sess-backthink", nil, env.token(t, auth.RoleOwner)) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } lanes := flowLanesData(t, body) if lanes == nil { t.Fatalf("`lanes` 键必须恒在且是数组(空时是 []):%s", body) } if len(lanes) != 0 { t.Fatalf("没有图时会话的 lanes = %s,want []", body) } } // TestFlowGraphDispatchLaneFields 钉住**帧载荷**上的两个新键与它们的缺席语义: // 有路时 `turnId` 在场且与图 id 同源,无路时该键**缺席**(老后端/单路的兼容口径), // `round` 则每次建图都在(客户端据此只保留最新一轮的泳道)。 func TestFlowGraphDispatchLaneFields(t *testing.T) { laneGraph := trace.NewRunGraph("web") laneGraph.SetTurnID("turn-a") laneGraph.SetSeq(3) laneGraph.Start(1, time.Now()) laneGraph.SeedInput("标记A") raw, err := json.Marshal(laneGraph.Snapshot()) if err != nil { t.Fatalf("marshal: %v", err) } var got map[string]json.RawMessage if err := json.Unmarshal(raw, &got); err != nil { t.Fatalf("decode: %v", err) } var id, turnID string var round int _ = json.Unmarshal(got["id"], &id) if _, ok := got["turnId"]; !ok { t.Fatalf("有路的帧缺 `turnId` 键(客户端分不出两条泳道):%s", raw) } _ = json.Unmarshal(got["turnId"], &turnID) _ = json.Unmarshal(got["round"], &round) if id != "web-3#turn-a" || turnID != "turn-a" { t.Fatalf("id/turnId = %q/%q,want web-3#turn-a / turn-a(同源)", id, turnID) } if round != 1 { t.Fatalf("round = %d,want 1(轮次号与会话级序号是两个量)", round) } // 无路的图:`turnId` 必须**缺席**(omitempty),客户端据此回落到「会话那一张」。 plain := trace.NewRunGraph("web") plain.Start(4, time.Now()) plainRaw, err := json.Marshal(plain.Snapshot()) if err != nil { t.Fatalf("marshal plain: %v", err) } var plainGot map[string]json.RawMessage if err := json.Unmarshal(plainRaw, &plainGot); err != nil { t.Fatalf("decode plain: %v", err) } if _, ok := plainGot["turnId"]; ok { t.Fatalf("单路的帧不该带 `turnId` 键:%s", plainRaw) } var plainRound int _ = json.Unmarshal(plainGot["round"], &plainRound) if plainRound != 4 { t.Fatalf("单路 round = %d,want 4", plainRound) } } // TestFlowGraphSourceLanesIsReadOnly 钉住 `lanes[]` 的只读性:补拉快照**不会**发布 // 任何新帧、也不会改任何路的图(与 LastGraph 同一纪律)。 func TestFlowGraphSourceLanesIsReadOnly(t *testing.T) { published := 0 pub := loop.NewFlowPublisher(func(string, any) { published++ }, func(string) bool { return true }) pub.StartRound(loop.FlowOwnerKey("web", "turn-a"), "标记A") before := published v := pub.LatestLanes("web") if len(v) == 0 { t.Fatal("LatestLanes 为空,但该会话刚起了一路") } if published != before { t.Fatalf("LatestLanes 发布了 %d 帧新图(只读查询绝不能造帧)", published-before) } // 快照是深拷贝:改它不能碰到发布器内部的图。 v[0].Nodes = append(v[0].Nodes, trace.FlowNode{ID: "ghost", Kind: "input"}) again := pub.LatestLanes("web") for _, g := range again { for _, n := range g.Nodes { if n.ID == "ghost" { t.Fatal("读者追加的节点漏进了发布器内部的图 ⇒ 不是深拷贝") } } } } // 保证 flowTestEnv 的图形源仍实现 FlowGraphSource(v0.77 多了 LatestLanes)。 var _ FlowGraphSource = (*loop.FlowPublisher)(nil)