fix(trace): 让 graph_update 的分支根节点 props.persona 真的前送(白名单漏登记)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

审计(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 节点未显示"以谁的身份替班")。
This commit is contained in:
AskaEth committed 2026-10-04 10:37:22 +08:00
1 parent 074ffc4ebf
commit 4bf1f7cac6
3 files changed
+155 -1

No files matched your search

+9 -1
View File
@@ -420,7 +420,15 @@ func safeSpanProps(rec SpanRecord) map[string]any {
} }
// safePropKeys are the span prop keys forwarded to a node's props. // safePropKeys are the span prop keys forwarded to a node's props.
var safePropKeys = []string{"stop_reason", "step", "error", "error_kind", "tool"} //
// persona 是子代理分支根节点上的「目标角色 id」:orchestration/scheduler.go 只在
// subagent_spawn 显式指定角色时才设置它,且该 id 必须先通过角色注册表校验
// (ErrUnknownPersona 会拒绝未注册的 id,orchestration/persona.go),因此它是注册表里
// 的角色标识,不是用户内容、不是提示词、更不是凭据——同一个 id 本来就随 GET /personas
// 与会话材料对外可见。它是 Docs/api-client.md §3.5(v0.47)对客户端的承诺
// (design/22 §8-2:让流程图显示「这个子代理是以谁的身份在替班」),漏出白名单就会被
// safeSpanProps 静默丢掉。未指定角色派发时该键不存在,符合 v0.47「纯增量」的约定。
var safePropKeys = []string{"stop_reason", "step", "error", "error_kind", "tool", "persona"}
// statusForFinished maps a span terminal status to a node status. // statusForFinished maps a span terminal status to a node status.
func statusForFinished(s SpanStatus) string { func statusForFinished(s SpanStatus) string {
@@ -0,0 +1,57 @@
package trace
import (
"context"
"testing"
)
// branchRootFor 用真实链路构建一张图:真 Tracer.StartSpan 建出子代理分支根
// (挂在 spawn 工具 span 之下,与 orchestration/scheduler.go 的父子关系一致),
// 由调用方在 span 上设 prop,然后 Finish + Snapshot 后折叠进 RunGraph。
// 返回图与分支根节点 id。
func branchRootFor(t *testing.T, props map[string]any) (*FlowGraph, string) {
t.Helper()
tr := New(nil)
toolCtx, toolSpan := tr.StartSpan(context.Background(), "tool.subagent_spawn", SpanKindTool)
_, subSpan := tr.StartSpan(toolCtx, "subagent.sub-1", SpanKindSubagent)
for k, v := range props {
if err := subSpan.SetProp(k, v); err != nil {
t.Fatalf("set prop %q: %v", k, err)
}
}
subSpan.Finish(SpanStatusOK)
toolSpan.Finish(SpanStatusOK)
// 先折叠父工具 span,再折叠分支根,模拟 loop.FlowPublisher 的到达顺序。
return graphFor([]SpanRecord{toolSpan.Snapshot(), subSpan.Snapshot()}, "q", "a"), subSpan.ID
}
// TestSnapshotCarriesPersonaProp 钉住 Docs/api-client.md §3.5(v0.47)的第一半:
// subagent_spawn 指定目标角色时,该分支根节点的 props 必须真的带 persona 键,
// 值 = 目标角色 id(design/22 §8-2)。断言落在真 Span.SetProp → 真 Snapshot()
// → 真 RunGraph.Snapshot() 的产物上,而不是白名单常量本身,所以构建器一旦
// 不再前送该键(例如从 safePropKeys 里漏掉),这里就会失败。
func TestSnapshotCarriesPersonaProp(t *testing.T) {
g, rootID := branchRootFor(t, map[string]any{"persona": "yuki"})
root := nodeByID(g, rootID)
if root.ID != rootID {
t.Fatalf("分支根 %q 没进快照: %+v", rootID, g.Nodes)
}
if root.Kind != nodeKindSubagent {
t.Fatalf("分支根 kind = %q, want %q", root.Kind, nodeKindSubagent)
}
if got := root.Props["persona"]; got != "yuki" {
t.Fatalf("分支根 props.persona = %v, want yuki (props=%+v)", got, root.Props)
}
}
// TestSnapshotOmitsPersonaPropWhenNoRole 钉住 v0.47 的另一半:不指定角色派发时
// 该键不出现(纯增量,老客户端逐字无感)。
func TestSnapshotOmitsPersonaPropWhenNoRole(t *testing.T) {
g, rootID := branchRootFor(t, nil)
root := nodeByID(g, rootID)
if _, ok := root.Props["persona"]; ok {
t.Fatalf("未指定角色却前送了 persona: %+v", root.Props)
}
}
@@ -187,6 +187,95 @@ func assertGraphMeta(t *testing.T, raw []byte) {
} }
} }
// 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,与 // sampleFlowSnapshot 构建真实的生产者侧快照(trace.RunGraph,与
// loop 的 FlowPublisher 发布的类型相同),其每个可选线上字段 // loop 的 FlowPublisher 发布的类型相同),其每个可选线上字段
// 都被填充,使上面的键集断言有意义。 // 都被填充,使上面的键集断言有意义。