Files
Cyrene_v2/internal/services/gateway/flow_update_ws_test.go
T
AskaEth 4bf1f7cac6
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(trace): 让 graph_update 的分支根节点 props.persona 真的前送(白名单漏登记)
审计(DevDocs/audit/11-trace-observability.md 发现 #1):scheduler 用
span.SetProp("persona", sub.PersonaID) 设了这个 prop,api-client v0.47 也已把它写进
对外承诺,但 trace/graph.go 的 safePropKeys 白名单不含 "persona",而 safeSpanProps
只遍历白名单 ⇒ 该键在到达客户端前被静默丢弃(实测:整个 props 键都消失)。

修法:把 "persona" 加进 safePropKeys,并在同处写清它为什么可以前送 ——
① 它必然是注册表校验过的角色 id(scheduler.checkPersona 未知 id 一律 ErrUnknownPersona
拒绝整批派发);② 它本来就随 GET /personas 与 message_persisted.personaId 对外可见;
③ 值仍过 redactValue → RedactText(凭据形状清洗 + 80 rune 截断)。

测试:新增 trace/graph_persona_test.go(真 Tracer → 真 SetProp → 真 Snapshot,两条断言:
有 persona / 不指定角色时键不出现 = 钉住 v0.47 的"纯增量")+ 网关 flow_update_ws_test.go
追加 TestFlowGraphUpdateRelaysPersonaProp(真 bus Publish → 真 WS 帧 → 断言帧内节点
props.persona=="yuki")。

验证:go build/vet 绿;trace + gateway 包测试绿;全树 go test ./... = 58 包 ok / 0 FAIL;
linux/amd64 + darwin/arm64 交叉编译绿;gofmt -l 干净;三文件 471/57/370 行、最长函数 64 行。
变异验证:把 persona 从白名单摘掉 → 两条断言分别 FAIL(trace 层 props 为空;
WS 帧里 span-sub 节点的 props 键整个消失),还原后复绿。

遗留(登记):前端尚未消费该键(flow 节点未显示"以谁的身份替班")。
2026-10-04 10:37:22 +08:00

371 lines
12 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 gateway
import (
"context"
"encoding/json"
"sort"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 这些测试覆盖 design/19 流程链中此前唯一没有测试的一段:
// 生产者的 bus 事件 → hub 中继 → 下行 WS 帧。loop 一侧由
// internal/runtime/loop/flow_test.go 钉住;这里钉住网关中继以及
// 前端 parseGraph 所读的线上字段名,因此任一侧改名都会
// 在这里失败,而不是静默让全链路流程页面变空。
// TestFlowGraphUpdateRelayedToWS 在生产主题(ws.graph_update)上
// 发布真实的 trace.RunGraph 快照,并断言客户端收到一个
// 原样携带该快照的 `graph_update` 帧。
func TestFlowGraphUpdateRelayedToWS(t *testing.T) {
env := newEnv(t, nil, false)
tok := env.token(t, auth.RoleOwner)
wsURL := "ws" + strings.TrimPrefix(env.ts.URL, "http") + "/ws/chat?token=" + tok
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
// 先往返一次 mode_switch,使连接在发布前已注册
// (与 TestWSUpgradeAndBroadcast 相同的保护)。
if err := conn.WriteJSON(wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}); err != nil {
t.Fatalf("write mode_switch: %v", err)
}
if got := readWS(t, conn); got.Type != "mode_switch" {
t.Fatalf("first frame type = %q, want mode_switch", got.Type)
}
snap := sampleFlowSnapshot()
if err := env.bus.Publish(context.Background(), "ws.graph_update", snap); err != nil {
t.Fatalf("publish graph: %v", err)
}
raw := readRawWS(t, conn)
var frame struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(raw, &frame); err != nil {
t.Fatalf("decode frame %s: %v", raw, err)
}
if frame.Type != "graph_update" {
t.Fatalf("frame type = %q, want graph_update (frame=%s)", frame.Type, raw)
}
// 中继的载荷必须是快照原文 —— 同样的 JSON,未被重塑。
want, err := json.Marshal(snap)
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
if string(frame.Data) != string(want) {
t.Fatalf("relayed data differs from the published snapshot:\n got %s\nwant %s", frame.Data, want)
}
}
// TestFlowGraphUpdateWireFields 把快照每一层的 JSON 键名钉到
// 前端读取器(lib/wsParse.ts 中的 parseGraph)所需的字段上。
// 它刻意做精确键集比较:新增、改名或丢弃
// 前端所读的字段必须在这里失败。
func TestFlowGraphUpdateWireFields(t *testing.T) {
raw, err := json.Marshal(sampleFlowSnapshot())
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
var graph map[string]json.RawMessage
if err := json.Unmarshal(raw, &graph); err != nil {
t.Fatalf("decode graph: %v", err)
}
assertGraphKeys(t, graph)
nodes := decodeObjects(t, graph["nodes"], "nodes")
assertNodeKeys(t, nodes)
assertEdgeKeys(t, decodeObjects(t, graph["edges"], "edges"))
assertLogKeys(t, nodes)
assertGraphMeta(t, raw)
}
// assertGraphKeys 钉住 parseGraph/Flow.tsx 所读的 FlowGraph 顶层键。
func assertGraphKeys(t *testing.T, graph map[string]json.RawMessage) {
t.Helper()
want := []string{"id", "nodes", "edges", "sessionId", "startedAt", "endedAt"}
if got := sortedKeys(graph); !equalKeys(got, want) {
t.Fatalf("graph keys = %v, want %v", got, want)
}
}
// assertNodeKeys 检查每个发出的节点键都是前端会读的、每个
// 节点都携带始终读取的 id/label,以及持有 props+log 的节点具备
// 完整的键集(这也钉住了 omitempty 标签)。
func assertNodeKeys(t *testing.T, nodes []map[string]json.RawMessage) {
t.Helper()
want := []string{"id", "label", "kind", "status", "startedAt", "endedAt", "props", "log"}
fullSeen := false
for i, n := range nodes {
keys := sortedKeys(n)
for _, k := range keys {
if !containsKey(want, k) {
t.Fatalf("node[%d] has unexpected key %q; frontend reads %v", i, k, want)
}
}
if !containsKey(keys, "id") || !containsKey(keys, "label") {
t.Fatalf("node[%d] keys = %v, must contain id and label", i, keys)
}
if containsKey(keys, "props") && containsKey(keys, "log") {
fullSeen = true
if !equalKeys(keys, want) {
t.Fatalf("node[%d] keys = %v, want the full set %v", i, keys, want)
}
}
}
if !fullSeen {
t.Fatal("no node carried both props and log; the omitempty tags are unpinned")
}
}
// assertEdgeKeys 钉住 FlowEdge 形状(parseGraph 读取 from/to)。
func assertEdgeKeys(t *testing.T, edges []map[string]json.RawMessage) {
t.Helper()
if len(edges) == 0 {
t.Fatal("snapshot has no edges")
}
for i, e := range edges {
if got := sortedKeys(e); !equalKeys(got, []string{"from", "to"}) {
t.Fatalf("edge[%d] keys = %v, want [from to]", i, got)
}
}
}
// assertLogKeys 钉住节点的 log 条目形状(parseGraph 读取 time/type/props)。
func assertLogKeys(t *testing.T, nodes []map[string]json.RawMessage) {
t.Helper()
var logged []map[string]json.RawMessage
for _, n := range nodes {
rawLog, ok := n["log"]
if !ok {
continue
}
if err := json.Unmarshal(rawLog, &logged); err != nil {
t.Fatalf("decode log: %v", err)
}
}
if len(logged) == 0 {
t.Fatal("snapshot has no log entries to pin")
}
for i, ev := range logged {
if got := sortedKeys(ev); !equalKeys(got, []string{"time", "type", "props"}) {
t.Fatalf("log[%d] keys = %v, want [props time type]", i, got)
}
}
}
// assertGraphMeta 钉住前端渲染的信封值(graph id/session
// 对),以及时间戳以 RFC3339Nano 抵达(Date.parse 所期望的格式)。
func assertGraphMeta(t *testing.T, raw []byte) {
t.Helper()
var meta struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
StartedAt string `json:"startedAt"`
}
if err := json.Unmarshal(raw, &meta); err != nil {
t.Fatalf("decode meta: %v", err)
}
if meta.ID != "web-1" || meta.SessionID != "web" {
t.Fatalf("graph id/sessionId = %q/%q, want web-1/web", meta.ID, meta.SessionID)
}
if _, err := time.Parse(time.RFC3339Nano, meta.StartedAt); err != nil {
t.Fatalf("startedAt %q is not RFC3339Nano: %v", meta.StartedAt, err)
}
}
// TestFlowGraphUpdateRelaysPersonaProp 端到端钉住 v0.47 契约的后半段:
// 图构建器放行的 `props.persona`(子代理分支根节点上的目标角色 id)
// 经真实 bus 中继后,客户端收到的 graph_update 帧里仍然看得到它。
// 只在 trace 包断言白名单或快照不足以证明「客户端真的收得到」——
// 中继链路上任何一环重塑载荷都会让该键丢失,所以这条从生产者
// 快照一路查到下行 WS 帧。
func TestFlowGraphUpdateRelaysPersonaProp(t *testing.T) {
env := newEnv(t, nil, false)
tok := env.token(t, auth.RoleOwner)
wsURL := "ws" + strings.TrimPrefix(env.ts.URL, "http") + "/ws/chat?token=" + tok
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
// 与 TestFlowGraphUpdateRelayedToWS 相同:先往返一次 mode_switch,
// 保证连接在发布前已注册。
if err := conn.WriteJSON(wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}); err != nil {
t.Fatalf("write mode_switch: %v", err)
}
if got := readWS(t, conn); got.Type != "mode_switch" {
t.Fatalf("first frame type = %q, want mode_switch", got.Type)
}
if err := env.bus.Publish(context.Background(), "ws.graph_update", samplePersonaSnapshot()); err != nil {
t.Fatalf("publish graph: %v", err)
}
raw := readRawWS(t, conn)
var frame struct {
Type string `json:"type"`
Data struct {
Nodes []struct {
ID string `json:"id"`
Props map[string]any `json:"props"`
} `json:"nodes"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &frame); err != nil {
t.Fatalf("decode frame %s: %v", raw, err)
}
if frame.Type != "graph_update" {
t.Fatalf("frame type = %q, want graph_update (frame=%s)", frame.Type, raw)
}
for _, n := range frame.Data.Nodes {
if n.ID != personaBranchNodeID {
continue
}
if got, _ := n.Props["persona"].(string); got != personaBranchRole {
t.Fatalf("线上帧里分支根 props.persona = %q, want %q (frame=%s)", got, personaBranchRole, raw)
}
return
}
t.Fatalf("线上帧里找不到分支根节点 %q: %s", personaBranchNodeID, raw)
}
// personaBranchNodeID / personaBranchRole 是下面 fixture 里分支根
// 的节点 id 与它所扮演的角色 id,供上面的断言引用。
const (
personaBranchNodeID = "span-sub"
personaBranchRole = "yuki"
)
// samplePersonaSnapshot 构建一张带子代理分支的真实生产者侧快照:
// 分支根节点经 AddSpan 折叠(因此走 safeSpanProps 白名单,与生产路径
// 同一道过滤),persona 值就是 orchestration/scheduler.go 会设进
// span prop 的目标角色 id。
func samplePersonaSnapshot() *trace.FlowGraph {
base := time.Date(2026, 10, 3, 21, 0, 0, 0, time.UTC)
g := trace.NewRunGraph("web")
g.Start(1, base)
g.SeedInput("帮我查一下天气")
g.AddSpan(trace.SpanRecord{
ID: personaBranchNodeID,
ParentID: "input",
Name: "subagent.sub-1",
Kind: trace.SpanKindSubagent,
Status: trace.SpanStatusOK,
StartedAt: base.Add(time.Millisecond),
EndedAt: base.Add(9 * time.Millisecond),
Props: map[string]any{"persona": personaBranchRole},
})
g.SeedOutput("今天晴,26 度。")
return g.Snapshot()
}
// sampleFlowSnapshot 构建真实的生产者侧快照(trace.RunGraph,与
// loop 的 FlowPublisher 发布的类型相同),其每个可选线上字段
// 都被填充,使上面的键集断言有意义。
func sampleFlowSnapshot() *trace.FlowGraph {
base := time.Date(2026, 10, 3, 21, 0, 0, 0, time.UTC)
g := trace.NewRunGraph("web")
g.Start(1, base)
g.SeedInput("帮我查一下天气")
g.AddSpanStart("span-llm", "llm.call", trace.SpanKindLLM, "", base.Add(time.Millisecond))
g.AddSpan(trace.SpanRecord{
ID: "span-llm",
Name: "llm.call",
Kind: trace.SpanKindLLM,
Status: trace.SpanStatusOK,
StartedAt: base.Add(time.Millisecond),
EndedAt: base.Add(20 * time.Millisecond),
Props: map[string]any{"stop_reason": "tool_calls", "step": 1},
Events: []trace.Event{{Time: base.Add(2 * time.Millisecond), Type: "llm.done", Props: map[string]any{"tools": 1}}},
})
g.AddSpanStart("span-tool", "tool.get_weather", trace.SpanKindTool, "span-llm", base.Add(21*time.Millisecond))
g.AddSpan(trace.SpanRecord{
ID: "span-tool",
ParentID: "span-llm",
Name: "tool.get_weather",
Kind: trace.SpanKindTool,
Status: trace.SpanStatusOK,
StartedAt: base.Add(21 * time.Millisecond),
EndedAt: base.Add(30 * time.Millisecond),
Props: map[string]any{"tool": "get_weather", "result": "晴 26℃"},
Events: []trace.Event{{Time: base.Add(25 * time.Millisecond), Type: "tool.done", Props: map[string]any{"ok": true}}},
})
g.SeedOutput("今天晴,26 度。")
return g.Snapshot()
}
// decodeObjects 解码原始 JSON 对象数组,形状畸形时失败。
func decodeObjects(t *testing.T, raw json.RawMessage, what string) []map[string]json.RawMessage {
t.Helper()
var out []map[string]json.RawMessage
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode %s: %v", what, err)
}
if len(out) == 0 {
t.Fatalf("snapshot has no %s", what)
}
return out
}
// readRawWS 读取一个文本帧并返回其原始字节。
func readRawWS(t *testing.T, conn *websocket.Conn) []byte {
t.Helper()
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read ws: %v", err)
}
return msg
}
// sortedKeys 按排序后的顺序返回该对象的 JSON 键。
func sortedKeys(obj map[string]json.RawMessage) []string {
out := make([]string, 0, len(obj))
for k := range obj {
out = append(out, k)
}
sort.Strings(out)
return out
}
// equalKeys 报告 got 是否恰好拥有 want 的全部成员(二者均已排序)。
func equalKeys(got []string, want []string) bool {
want = append([]string(nil), want...)
sort.Strings(want)
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
// containsKey 报告(已排序的)键列表中是否含有 k。
func containsKey(keys []string, k string) bool {
for _, key := range keys {
if key == k {
return true
}
}
return false
}