Files
Aska 44e7a5c2c5
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(flow): 实时全链路流程可视化 P1——trace→graph_update→前端全链路流程页(design/19)
后端: trace/graph.go(RunGraph 图构建器,kind/status 映射,父子优先连线,脱敏白名单+截断);
       loop/flow.go(FlowPublisher 按会话增量发布 ws.graph_update,仅主人web会话); loop span
       start/finish 折入图; run.go StartRound/EndRound; wire.go 组装+注入.
前端: Flow.tsx(全链路流程页:FlowGraphView+会话/节点/开始/耗时卡片+状态计数+节点点开日志);
       FlowGraph 组件加 onNodeClick; types/wsParse 扩展; router/sections/AdminApp 注册 flow;
       api-client.md §3.5 graph_update 契约.
验证: 后端 build/vet/test 全绿+flow_test.go 集成(span→graph→ws.graph_update主干); 前端 build 绿.
红线: 单二进制零新依赖; 工具参数/结果脱敏; 仅主人web会话; 身份靠actor元数据; 只读不虚构.
P2(并行子代理分支/泳道/节点实时日志)后置.
2026-09-07 19:10:57 +08:00

162 lines
5.2 KiB
Go

package trace
import (
"testing"
"time"
)
// spanRec builds a finished SpanRecord with the essential fields for the builder.
func spanRec(id, parent, name string, kind SpanKind, status SpanStatus, props map[string]any) SpanRecord {
now := time.Now()
return SpanRecord{
ID: id,
ParentID: parent,
Name: name,
Kind: kind,
Status: status,
StartedAt: now.Add(-time.Second),
EndedAt: now,
Duration: time.Second,
Props: props,
}
}
// graphFor builds a graph from the given finished spans plus input/output text.
func graphFor(spans []SpanRecord, input, output string) *FlowGraph {
g := NewRunGraph("web")
g.Start(1, time.Now())
g.SeedInput(input)
for _, s := range spans {
g.AddSpan(s)
}
g.SeedOutput(output)
return g.Snapshot()
}
func nodeByID(g *FlowGraph, id string) FlowNode {
for _, n := range g.Nodes {
if n.ID == id {
return n
}
}
return FlowNode{}
}
func hasEdge(g *FlowGraph, from, to string) bool {
for _, e := range g.Edges {
if e.From == from && e.To == to {
return true
}
}
return false
}
// TestBuildFlowGraphTrunk asserts the single-round main trunk assembles as
// input→llm→tool→output with final "done" status on every node.
func TestBuildFlowGraphTrunk(t *testing.T) {
llm := spanRec("s1", "step1", "llm.call", SpanKindLLM, SpanStatusOK,
map[string]any{"stop_reason": "tool_calls"})
tool := spanRec("s2", "step1", "tool.echo", SpanKindTool, SpanStatusOK,
map[string]any{"result": "hello world"})
g := graphFor([]SpanRecord{llm, tool}, "你好", "你好呀")
if g.SessionID != "web" {
t.Fatalf("SessionID = %q, want web", g.SessionID)
}
if len(g.Nodes) != 4 {
t.Fatalf("Nodes = %d, want 4 (input/llm/tool/output) -> %+v", len(g.Nodes), g.Nodes)
}
if nodeByID(g, "input").Kind != "input" {
t.Fatalf("input node kind = %q", nodeByID(g, "input").Kind)
}
if nodeByID(g, "s1").Kind != "llm" || nodeByID(g, "s1").Status != FlowStatusDone {
t.Fatalf("llm node was not folded: %+v", nodeByID(g, "s1"))
}
if nodeByID(g, "s2").Kind != "tool" || nodeByID(g, "s2").Status != FlowStatusDone {
t.Fatalf("tool node was not folded: %+v", nodeByID(g, "s2"))
}
if !hasEdge(g, "input", "s1") {
t.Fatalf("expected edge input->s1, got %+v", g.Edges)
}
if !hasEdge(g, "s1", "s2") {
t.Fatalf("expected edge s1->s2, got %+v", g.Edges)
}
if !hasEdge(g, "s2", "output") {
t.Fatalf("expected edge s2->output, got %+v", g.Edges)
}
}
// TestBuildFlowGraphErrorTool asserts a failed tool span finalizes the node as
// "error" while the rest of the trunk stays built.
func TestBuildFlowGraphErrorTool(t *testing.T) {
llm := spanRec("s1", "step1", "llm.call", SpanKindLLM, SpanStatusOK, nil)
tool := spanRec("s2", "step1", "tool.bad", SpanKindTool, SpanStatusError,
map[string]any{"error": "boom", "error_kind": "execution"})
g := graphFor([]SpanRecord{llm, tool}, "q", "")
if got := nodeByID(g, "s2").Status; got != FlowStatusError {
t.Fatalf("tool status = %q, want %q", got, FlowStatusError)
}
if got := nodeByID(g, "s2").Kind; got != "tool" {
t.Fatalf("tool kind = %q", got)
}
if nodeByID(g, "s2").Props["error_kind"] != "execution" {
t.Fatalf("tool props missing error_kind: %+v", nodeByID(g, "s2").Props)
}
if !hasEdge(g, "s1", "s2") {
t.Fatalf("expected edge s1->s2")
}
}
// TestBuildFlowGraphSkipsStateTransition asserts the step container span is not
// rendered as a node and does not break the chain.
func TestBuildFlowGraphSkipsStateTransition(t *testing.T) {
step := spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil)
llm := spanRec("s1", "step1", "llm.call", SpanKindLLM, SpanStatusOK, nil)
g := graphFor([]SpanRecord{step, llm}, "q", "a")
for _, n := range g.Nodes {
if n.ID == "step1" {
t.Fatalf("state_transition span leaked as a node: %+v", n)
}
}
if !hasEdge(g, "input", "s1") {
t.Fatalf("expected edge input->s1 (through the skipped container), got %+v", g.Edges)
}
}
// TestBuildFlowGraphRunningAndDone asserts a node created at start (via
// AddSpanStart) is "running", then becomes "done" once its span finishes.
func TestBuildFlowGraphRunningAndDone(t *testing.T) {
g := NewRunGraph("web")
g.Start(1, time.Now())
g.SeedInput("q")
g.AddSpanStart("s1", "llm.call", SpanKindLLM, "", time.Now())
if got := nodeByID(g.Snapshot(), "s1").Status; got != FlowStatusRunning {
t.Fatalf("start status = %q, want running", got)
}
g.AddSpan(spanRec("s1", "", "llm.call", SpanKindLLM, SpanStatusOK, nil))
if got := nodeByID(g.Snapshot(), "s1").Status; got != FlowStatusDone {
t.Fatalf("finished status = %q, want done", got)
}
}
// TestBuildFlowGraphParentEdgePrioritizesParent asserts that when a node's parent
// is already a node (e.g. a nested subagent span), the edge wires to that parent
// instead of the linear chain.
func TestBuildFlowGraphParentEdgePrioritizesParent(t *testing.T) {
parent := spanRec("sub", "", "subagent.foo", SpanKindSubagent, SpanStatusOK, nil)
child := spanRec("nested", "sub", "llm.call", SpanKindLLM, SpanStatusOK, nil)
g := graphFor([]SpanRecord{parent, child}, "q", "a")
if !hasEdge(g, "sub", "nested") {
t.Fatalf("expected edge sub->nested (parent priority), got %+v", g.Edges)
}
if nodeByID(g, "sub").Kind != "subagent" {
t.Fatalf("subagent kind = %q", nodeByID(g, "sub").Kind)
}
}