package eino import ( "context" "fmt" "strings" "testing" "github.com/cloudwego/eino/schema" "github.com/sundynix/sundynix-dispatcher/internal/dsl" "github.com/sundynix/sundynix-dispatcher/internal/harness" "github.com/sundynix/sundynix-dispatcher/internal/llm" ) // TestParseSpecialists 钉死节点 config 的 agents 列表解析(含 tools 子集、无名跳过)。 func TestParseSpecialists(t *testing.T) { cfg := map[string]any{"agents": []any{ map[string]any{"name": "legal", "use": "法律条款", "system": "你是律师", "tools": []any{"wiki_search", "kb_search"}}, map[string]any{"name": "finance", "use": "财务测算"}, map[string]any{"use": "无名字应被跳过"}, }} specs := parseSpecialists(cfg) if len(specs) != 2 { t.Fatalf("应解析出 2 个专家(无名跳过),got %d", len(specs)) } if specs[0].Name != "legal" || specs[0].Use != "法律条款" || len(specs[0].Tools) != 2 { t.Fatalf("legal 解析有误: %+v", specs[0]) } if specs[1].Name != "finance" || len(specs[1].Tools) != 0 { t.Fatalf("finance 解析有误: %+v", specs[1]) } } // TestSpecialistToolInvoke 钉死 agent-as-tool 包装:lead 写的 brief 透传给专家、结论回传、 // 空 brief 兜底、专家失败作为"观察"返回而非中断协调。 func TestSpecialistToolInvoke(t *testing.T) { var gotBrief string st := &specialistTool{ name: "legal", tr: &execTracer{}, info: &schema.ToolInfo{Name: "legal"}, run: func(_ context.Context, brief string) (string, error) { gotBrief = brief; return "结论:条款合规", nil }, } out, err := st.InvokableRun(context.Background(), `{"brief":"审查合同第3条违约金"}`) if err != nil { t.Fatal(err) } if gotBrief != "审查合同第3条违约金" { t.Fatalf("lead 简报应透传给专家,got %q", gotBrief) } if out != "结论:条款合规" { t.Fatalf("专家结论应回传,got %q", out) } // 空 brief → 兜底文案(不把空串丢给专家)。 st.run = func(_ context.Context, brief string) (string, error) { gotBrief = brief; return "ok", nil } _, _ = st.InvokableRun(context.Background(), `{}`) if !strings.Contains(gotBrief, "未给简报") { t.Fatalf("空 brief 应兜底,got %q", gotBrief) } // 专家失败 → 返回失败观察,err 为 nil(不中断 orchestrator 的 ReAct 循环)。 st.run = func(_ context.Context, _ string) (string, error) { return "", fmt.Errorf("boom") } out, err = st.InvokableRun(context.Background(), `{"brief":"x"}`) if err != nil { t.Fatalf("专家失败不应上抛 error(应作观察返回): %v", err) } if !strings.Contains(out, "执行失败") { t.Fatalf("应返回失败观察,got %q", out) } } // TestRunCoordinatorDegrade 钉死降级:模型不支持函数调用(ToolCallingModel=nil)→ 退回普通对话出稿, // 不因"多智能体"而卡死或空答。 func TestRunCoordinatorDegrade(t *testing.T) { fs := &fakeSink{} o := &Orchestrator{ pool: &fakeLLM{ready: true, stream: func([]llm.ChatMessage) string { return "降级直答" }}, breaker: harness.NewCircuitBreaker(), sink: fs, } b := &board{query: "你好"} n := dsl.Node{ID: "c", Kind: "coordinator", Config: map[string]any{ "agents": []any{map[string]any{"name": "x", "use": "y"}}, }} o.runCoordinator(context.Background(), "t_co", b, "", n, &execTracer{}, "coordinator:c") if !strings.Contains(b.answer, "降级直答") { t.Fatalf("ToolCallingModel 缺失应降级 runAgent 出答案,got %q", b.answer) } } // 专家名常是中文,直接当 LLM 函数名会被 OpenAI 兼容 API 400 拒绝(^[a-zA-Z0-9_-]+$), // 整个多智能体协调会挂。toolFuncName 负责规范化 + 保证唯一。 func TestToolFuncName(t *testing.T) { used := map[string]bool{} if got := toolFuncName("条款专家", 0, used); got != "expert_1" { t.Errorf("纯中文名应退化为 expert_1,got %q", got) } if got := toolFuncName("clause_expert", 1, used); got != "clause_expert" { t.Errorf("合法 ASCII 名应原样保留,got %q", got) } if got := toolFuncName("法务expert", 2, used); got != "expert" { t.Errorf("混合名应剥出 ASCII 部分,got %q", got) } // 与上一个 "expert" 撞名 → 加序号保证唯一 if got := toolFuncName("风控expert", 3, used); got != "expert_4" { t.Errorf("撞名应加序号,got %q", got) } for _, n := range []string{"expert_1", "clause_expert", "expert", "expert_4"} { if !used[n] { t.Errorf("%q 应已登记进 used", n) } } }