Files
Blizzard c2811e79a3 fix(desktop,dispatcher): 从编排执行的多 agent 图看不到「团队」tab
现象:编排里并排三个 agent(研究/撰写/审查)跑完,运行页没有团队 tab。

两处卡住,不是一处:
1) isMultiAgent 只认 coordinator: 节点。用户自己在图里并排多个 agent 也是
   团队,却被整个漏掉。改成:有协调者,或 ≥2 个 agent: 节点。
2) 就算放宽 1,deriveTeam 仍按 kind==="agent" 挑工位——而两条产生 agent 的
   路径 kind 并不一致:协调者派发的专家是 kind=agent,图里的 agent 节点是
   kind=model。改成按节点名前缀(agent:/tool:)判,这本来就是后端一直遵守的
   约定;顺带天然把 retriever:/map:/render: 这些同为 kind=tool 的节点挡在
   工位之外(之前它们会混进来当工位)。

连带修一个更要命的:runAgent 把轨迹标签写死成"模型流式推理"、runReactAgent
写死成"ReAct 智能体(自主调工具)",用户在编排里给节点起的名字(研究 Agent /
撰写 Agent / 审查 Agent)整个丢了。后果不止办公室:执行轨迹里三行同名,根本
分不出谁是谁;团队视图只能退回节点 ID,工位显示成 r/w/rev。
改成一律 labelOf(n, 兜底) 由调用方传入,+2 单测钉住。

另:没有协调者时不再凭空画一个"协调者"小人(白板改挂「任务产出」),也不演
递简报那一程(没人可递),✓ 气泡改为收工即冒。

注意:已存的历史轨迹是落库的,仍是旧标签;只有新跑的任务才有节点名。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:41:21 +08:00

152 lines
5.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package eino
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
"github.com/sundynix/sundynix-shared/contract"
)
// stubModel 是实现 Eino model.BaseChatModel 的测试桩,固定回一段文本(确定性)。
type stubModel struct{ reply string }
func (s *stubModel) Generate(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
return schema.AssistantMessage(s.reply, nil), nil
}
func (s *stubModel) Stream(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
sr, sw := schema.Pipe[*schema.Message](1)
go func() {
sw.Send(schema.AssistantMessage(s.reply, nil), nil)
sw.Close()
}()
return sr, nil
}
// TestComposeGraphRuns 直接验证 compose.GraphSTART→ChatModel→END)能编译并流式产出。
func TestComposeGraphRuns(t *testing.T) {
g := compose.NewGraph[[]*schema.Message, *schema.Message]()
if err := g.AddChatModelNode("model", &stubModel{reply: "你好世界"}); err != nil {
t.Fatal(err)
}
_ = g.AddEdge(compose.START, "model")
_ = g.AddEdge("model", compose.END)
r, err := g.Compile(context.Background())
if err != nil {
t.Fatalf("compile: %v", err)
}
out, err := r.Invoke(context.Background(), []*schema.Message{schema.UserMessage("hi")})
if err != nil {
t.Fatalf("invoke: %v", err)
}
if out.Content != "你好世界" {
t.Fatalf("got %q", out.Content)
}
}
// TestComposeConversation 验证对话主流程走 compose 路径:模型输出流式回流到 sink 并累计成稿。
func TestComposeConversation(t *testing.T) {
fs := &fakeSink{}
o := &Orchestrator{
pool: &fakeLLM{ready: true, cm: &stubModel{reply: "我是 compose 路径的回答"}},
breaker: harness.NewCircuitBreaker(),
sink: fs,
}
b := &board{query: "你好"}
o.runComposeConversation(context.Background(), "task_compose", b, "", &execTracer{}, "agent", "模型流式推理")
if !strings.Contains(b.answer, "compose 路径") {
t.Fatalf("成稿未含模型输出: %q", b.answer)
}
if !strings.Contains(fs.text(), "compose 路径") {
t.Fatalf("sink 未收到流式 token: %q", fs.text())
}
}
// TestComposeConversationDegradesToRunAgent 验证无 Eino ChatModel 时降级回 runAgent(流式回流不丢)。
func TestComposeConversationDegradesToRunAgent(t *testing.T) {
fs := &fakeSink{}
o := &Orchestrator{
// cm 为 nil → ChatModel() 返回 nil → runComposeConversation 降级 runAgent(走 stream 桩)。
pool: &fakeLLM{ready: true, stream: func([]llm.ChatMessage) string { return "降级路径回答" }},
breaker: harness.NewCircuitBreaker(),
sink: fs,
}
b := &board{query: "你好"}
o.runComposeConversation(context.Background(), "task_degrade", b, "", &execTracer{}, "agent", "模型流式推理")
if !strings.Contains(b.answer, "降级路径") {
t.Fatalf("无 ChatModel 应降级 runAgent 出稿: %q", b.answer)
}
}
// 图里可以并排好几个 agent(研究/撰写/审查)。之前 runAgent 把轨迹标签写死成
// "模型流式推理",用户在编排里起的节点名整个丢了 —— 轨迹里三行同名,团队视图里
// 工位只能退回节点 ID(r/w/rev),谁是谁全靠猜。这里钉住:标签必须来自节点 label。
type execCapture struct {
mu sync.Mutex
buf [][]byte
}
func (c *execCapture) PublishExec(_ string, data []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
c.buf = append(c.buf, append([]byte(nil), data...))
return nil
}
func (c *execCapture) CompleteExec(string) error { return nil }
func (c *execCapture) labels() []string {
c.mu.Lock()
defer c.mu.Unlock()
var out []string
for _, b := range c.buf {
var e contract.ExecEvent
if json.Unmarshal(b, &e) == nil {
out = append(out, e.Label)
}
}
return out
}
func TestRunAgentUsesNodeLabel(t *testing.T) {
cap := &execCapture{}
o := &Orchestrator{
pool: &fakeLLM{ready: true, cm: &stubModel{reply: "要点若干"}},
breaker: harness.NewCircuitBreaker(),
sink: &fakeSink{},
exec: cap,
}
o.runAgent(context.Background(), "task_label", &board{query: "选型"}, "", o.tracer("task_label"), "agent:r", "研究 Agent")
got := strings.Join(cap.labels(), "|")
if !strings.Contains(got, "研究 Agent") {
t.Fatalf("轨迹标签应为节点名「研究 Agent」,实际: %q", got)
}
if strings.Contains(got, "模型流式推理") {
t.Fatalf("不该再出现写死的兜底标签: %q", got)
}
}
func TestRunAgentLabelFallback(t *testing.T) {
cap := &execCapture{}
o := &Orchestrator{
pool: &fakeLLM{ready: true, cm: &stubModel{reply: "x"}},
breaker: harness.NewCircuitBreaker(),
sink: &fakeSink{},
exec: cap,
}
o.runAgent(context.Background(), "task_fb", &board{query: "q"}, "", o.tracer("task_fb"), "agent:x", "")
if got := strings.Join(cap.labels(), "|"); !strings.Contains(got, "模型流式推理") {
t.Fatalf("节点没起名时应回兜底标签: %q", got)
}
}