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) } }