Files
Cyrene_v2/internal/runtime/trace/graph_lane_test.go
T
AskaEth 2c100fe3d9
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(flow,web): 全链路可视化 P2 — 泳道/并行分支 + 节点活动状态 + 节点实时日志
- 后端 trace/graph.go(395→470):泳道感知游标 —— parents 记住被跳过容器(state_transition)的父链、laneLast 按泳道分别的末节点游标;泳道首节点挂 subagent 根、后续步骤在本泳道内串链;**子代理根不再推进主干游标**(否则 spawn 之后的主干节点会被算成最后一条分支的步骤);node kind 抽具名常量。+ graph_lane_test.go(并行双泳道断言精确边集/嵌套泳道/主干游标不回归/laneOf 环保护)
- 前端新增 flow/{tone,lanes,layout,NodeDetail,LaneSummary}:分组=沿入边反向 BFS 取最近 kind=subagent 祖先(纯 edges,故无需新增 wire 字段);布局=主干沿用 P1 最长路径分层 + 每泳道一条独立横带(带内最长路径深度、逐带下推 y 不重叠、横向滚动);Flow.tsx/FlowGraph.tsx 加泳道框/虚线泳道边/运行闪烁(既有 mu-blink)/选中高亮/图例
- 实时日志:选中态按 node id 保存,随每帧 graph_update 重新 find → 自动显示追加后的 log[];空时区分「运行中(事件随 span 结束回填)」与「未记录事件」,不合成事件
- 未新增 wire 字段 → 无契约变化(因此未动 lib/types.ts / wsParse.ts);<FlowGraphView> 旧签名兼容,Agents/Backthink 页不受影响
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + npm run build(177 模块) + 逻辑测试 lanes 40 / channelStatus 45 / enums 47 / messageSync 54
- ⚠️ 已核实、未伪造的缺口:生产路径当前产不出子代理数据 —— Scheduler.Spawn 全仓仅测试调用方(0 生产)、编排包无 bus 订阅入口、无「派子代理」工具/capability → 泳道要真的亮需先接线;Span.Record 同样无生产调用方 → log[] 恒空(UI 已诚实显示空态)
2026-10-03 19:21:10 +08:00

156 lines
5.5 KiB
Go

package trace
import (
"fmt"
"sort"
"strings"
"testing"
"time"
)
// edgeSet renders a graph's edges as a sorted "from>to" set, so a test can assert
// the exact wiring (an extra cross-lane edge fails even though the wanted edges
// are present).
func edgeSet(g *FlowGraph) []string {
out := make([]string, 0, len(g.Edges))
for _, e := range g.Edges {
out = append(out, e.From+">"+e.To)
}
sort.Strings(out)
return out
}
// assertEdges asserts the graph's edges match want exactly.
func assertEdges(t *testing.T, g *FlowGraph, want ...string) {
t.Helper()
sort.Strings(want)
got := edgeSet(g)
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("edges = %v, want %v", got, want)
}
}
// TestBuildFlowGraphParallelLanesStaySeparate folds two sub-agent lanes whose
// spans interleave (A's second step arrives after B started) and asserts the
// wiring stays per-lane: each lane chains from its own root through its own
// steps, with no edge crossing lanes (design/19 §2 泳道).
func TestBuildFlowGraphParallelLanesStaySeparate(t *testing.T) {
spans := []SpanRecord{
spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("t1", "step1", "tool.spawn", SpanKindTool, SpanStatusOK, nil),
spanRec("subA", "t1", "subagent.a", SpanKindSubagent, SpanStatusOK, nil),
spanRec("stA", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llmA", "stA", "llm.call", SpanKindLLM, SpanStatusOK, nil),
// Lane B starts before lane A's second step finishes.
spanRec("subB", "t1", "subagent.b", SpanKindSubagent, SpanStatusOK, nil),
spanRec("stB", "subB", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llmB", "stB", "llm.call", SpanKindLLM, SpanStatusOK, nil),
spanRec("stA2", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("toolA", "stA2", "tool.search", SpanKindTool, SpanStatusOK, nil),
// The main loop continues on its own step after the spawn tool call.
spanRec("st2", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llm2", "st2", "llm.call", SpanKindLLM, SpanStatusOK, nil),
}
g := graphFor(spans, "q", "a")
assertEdges(t, g,
"input>t1",
"t1>subA",
"t1>subB",
"subA>llmA",
"llmA>toolA",
"subB>llmB",
// The trunk resumes from the spawn node, not from a sub-agent's branch.
"t1>llm2",
"llm2>output",
)
}
// TestBuildFlowGraphNestedSubagentLane folds a sub-agent spawned inside another
// sub-agent and asserts the nested lane chains off its own root while staying
// chained inside the parent lane.
func TestBuildFlowGraphNestedSubagentLane(t *testing.T) {
spans := []SpanRecord{
spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("t1", "step1", "tool.spawn", SpanKindTool, SpanStatusOK, nil),
spanRec("subA", "t1", "subagent.a", SpanKindSubagent, SpanStatusOK, nil),
spanRec("stA", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llmA", "stA", "llm.call", SpanKindLLM, SpanStatusOK, nil),
spanRec("stN", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("subN", "stN", "subagent.nested", SpanKindSubagent, SpanStatusOK, nil),
spanRec("stN2", "subN", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llmN", "stN2", "llm.call", SpanKindLLM, SpanStatusOK, nil),
}
g := graphFor(spans, "q", "a")
assertEdges(t, g,
"input>t1",
"t1>subA",
"subA>llmA",
// The nested sub-agent is a step of lane A...
"llmA>subN",
// ...and its own steps hang off it.
"subN>llmN",
// The trunk resumes from the spawn node even though the round ended inside
// a nested branch.
"t1>output",
)
if got := nodeByID(g, "subN").Kind; got != nodeKindSubagent {
t.Fatalf("nested node kind = %q, want %q", got, nodeKindSubagent)
}
}
// TestBuildFlowGraphTrunkCursorUnchanged asserts the lane-aware cursor degrades to
// the plain left-to-right trunk when no sub-agent is in flight (no P1 regression).
func TestBuildFlowGraphTrunkCursorUnchanged(t *testing.T) {
spans := []SpanRecord{
spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llm1", "step1", "llm.call", SpanKindLLM, SpanStatusOK, nil),
spanRec("step2", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("llm2", "step2", "llm.call", SpanKindLLM, SpanStatusOK, nil),
spanRec("step3", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
spanRec("tool2", "step3", "tool.echo", SpanKindTool, SpanStatusOK, nil),
}
g := graphFor(spans, "q", "a")
assertEdges(t, g,
"input>llm1",
"llm1>llm2",
"llm2>tool2",
"tool2>output",
)
}
// TestLaneOfWalkBound asserts a malformed (over-deep or cyclic) parent chain
// cannot spin laneOf forever: past the walk bound the node simply lands on the
// trunk, while a shallow chain still resolves to its sub-agent root.
func TestLaneOfWalkBound(t *testing.T) {
g := NewRunGraph("web")
g.Start(1, time.Now())
g.AddSpan(spanRec("sub", "", "subagent.a", SpanKindSubagent, SpanStatusOK, nil))
g.rememberParent("near", "sub")
if got := g.laneOf("near"); got != "sub" {
t.Fatalf("laneOf near = %q, want sub", got)
}
prev := "sub"
for i := 0; i <= maxLaneWalk+5; i++ {
id := fmt.Sprintf("st%d", i)
g.rememberParent(id, prev)
prev = id
}
if got := g.laneOf(prev); got != "" {
t.Fatalf("laneOf over the walk bound = %q, want \"\"", got)
}
g.rememberParent("cycA", "cycB")
g.rememberParent("cycB", "cycA")
if got := g.laneOf("cycA"); got != "" {
t.Fatalf("laneOf cycle = %q, want \"\"", got)
}
}