diff --git a/internal/runtime/trace/graph.go b/internal/runtime/trace/graph.go index ede6531..55794aa 100644 --- a/internal/runtime/trace/graph.go +++ b/internal/runtime/trace/graph.go @@ -420,7 +420,15 @@ func safeSpanProps(rec SpanRecord) map[string]any { } // 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. func statusForFinished(s SpanStatus) string { diff --git a/internal/runtime/trace/graph_persona_test.go b/internal/runtime/trace/graph_persona_test.go new file mode 100644 index 0000000..5349d5b --- /dev/null +++ b/internal/runtime/trace/graph_persona_test.go @@ -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) + } +} diff --git a/internal/services/gateway/flow_update_ws_test.go b/internal/services/gateway/flow_update_ws_test.go index 057be21..12b26a8 100644 --- a/internal/services/gateway/flow_update_ws_test.go +++ b/internal/services/gateway/flow_update_ws_test.go @@ -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,与 // loop 的 FlowPublisher 发布的类型相同),其每个可选线上字段 // 都被填充,使上面的键集断言有意义。