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 给 FlowPublisher 接上捕获式 publish // 函数与实时 tracer,跑一轮工具,并断言 ws.graph_update // 以 input→llm→tool→output 主干发布(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) } } // 从 input 节点开始走链;它必须经 llm/tool 到达 output。 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) } // 最终帧应显示该工具已完成(done),而不是仍在运行。 for _, n := range last.Nodes { if n.Kind == "tool" && n.Status != trace.FlowStatusDone { t.Fatalf("tool status = %q, want done", n.Status) } } } // nextNode 返回 cur 指向的节点;cur 没有出边时返回 ""(终端的 // output 节点)。线性链至多有一个后继。 func nextNode(g *trace.FlowGraph, cur string) string { for _, e := range g.Edges { if e.From == cur { return e.To } } return "" }