后端: 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(并行子代理分支/泳道/节点实时日志)后置.
119 lines
3.1 KiB
Go
119 lines
3.1 KiB
Go
package loop
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
|
)
|
|
|
|
// TestRun_PublishesFlowGraph_Trunk wires a FlowPublisher with a capturing publish
|
|
// function and a live tracer, runs one tool round and asserts ws.graph_update was
|
|
// published with the input→llm→tool→output trunk (design/19 §1 P1).
|
|
func TestRun_PublishesFlowGraph_Trunk(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var seen []*trace.FlowGraph
|
|
flow := NewFlowPublisher(func(subject string, payload any) {
|
|
if subject != graphUpdateSubject {
|
|
return
|
|
}
|
|
if g, ok := payload.(*trace.FlowGraph); ok {
|
|
mu.Lock()
|
|
seen = append(seen, g)
|
|
mu.Unlock()
|
|
}
|
|
}, func(string) bool { return true })
|
|
|
|
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: call}},
|
|
{resp: model.Response{Content: "Echoed!"}},
|
|
}}
|
|
td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"})
|
|
a := newTestAgent(t, ad, td)
|
|
l := New(Options{Agent: a, MaxRounds: 8, Flow: flow, Tracer: trace.New(nil)})
|
|
|
|
res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "echo this")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.Text != "Echoed!" {
|
|
t.Fatalf("Text = %q, want Echoed!", res.Text)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(seen) == 0 {
|
|
t.Fatal("no ws.graph_update published")
|
|
}
|
|
last := seen[len(seen)-1]
|
|
if last.SessionID != "web" {
|
|
t.Fatalf("SessionID = %q, want web", last.SessionID)
|
|
}
|
|
|
|
kinds := make(map[string]string)
|
|
for _, n := range last.Nodes {
|
|
kinds[n.ID] = n.Kind
|
|
}
|
|
nodeKind := map[string]bool{"input": false, "llm": false, "tool": false, "output": false}
|
|
inputID, outputID := "", ""
|
|
for _, n := range last.Nodes {
|
|
if n.Kind == "input" {
|
|
inputID = n.ID
|
|
}
|
|
if n.Kind == "output" {
|
|
outputID = n.ID
|
|
}
|
|
if _, ok := nodeKind[n.Kind]; ok {
|
|
nodeKind[n.Kind] = true
|
|
}
|
|
}
|
|
for _, want := range []string{"input", "llm", "tool", "output"} {
|
|
if !nodeKind[want] {
|
|
t.Fatalf("trunk incomplete: no %q node; nodes=%+v", want, last.Nodes)
|
|
}
|
|
}
|
|
|
|
// Walk the chain from the input node; it must reach output through llm/tool.
|
|
chain := []string{}
|
|
cur := inputID
|
|
for cur != "" {
|
|
chain = append(chain, kinds[cur])
|
|
if cur == outputID {
|
|
break
|
|
}
|
|
cur = nextNode(last, cur)
|
|
}
|
|
hasLLM, hasTool := false, false
|
|
for _, k := range chain {
|
|
if k == "llm" {
|
|
hasLLM = true
|
|
}
|
|
if k == "tool" {
|
|
hasTool = true
|
|
}
|
|
}
|
|
if chain[len(chain)-1] != "output" || !hasLLM || !hasTool {
|
|
t.Fatalf("trunk chain broken: %v", chain)
|
|
}
|
|
// The final frame should show the tool completed (done), not still running.
|
|
for _, n := range last.Nodes {
|
|
if n.Kind == "tool" && n.Status != trace.FlowStatusDone {
|
|
t.Fatalf("tool status = %q, want done", n.Status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// nextNode returns the node that cur points to, or "" when cur has no outgoing
|
|
// edge (the terminal output node). A linear chain has at most one successor.
|
|
func nextNode(g *trace.FlowGraph, cur string) string {
|
|
for _, e := range g.Edges {
|
|
if e.From == cur {
|
|
return e.To
|
|
}
|
|
}
|
|
return ""
|
|
}
|