package eino import ( "context" "encoding/json" "sync" "testing" "time" "github.com/sundynix/sundynix-dispatcher/internal/harness" "github.com/sundynix/sundynix-dispatcher/internal/llm" "github.com/sundynix/sundynix-shared/contract" ) // ---- Handle 集成测试:覆盖真正的任务入口(熔断→输入护栏→状态机→执行→评测/用量), // 而非直接打 runGraph。把核心编排链 + harness 治理栈的协同钉死。---- // fakeStatus 记录任务状态流转序列。 type fakeStatus struct { mu sync.Mutex seq []string detail string } func (s *fakeStatus) PublishTaskStatus(_, status, detail string) error { s.mu.Lock() s.seq = append(s.seq, status) s.detail = detail s.mu.Unlock() return nil } func (s *fakeStatus) snapshot() ([]string, string) { s.mu.Lock() defer s.mu.Unlock() return append([]string{}, s.seq...), s.detail } func (s *fakeStatus) last() string { seq, _ := s.snapshot() if len(seq) == 0 { return "" } return seq[len(seq)-1] } // fakeUsageSink 捕获 token 用量回写(并发安全:Handle 在 defer/goroutine 收尾时发)。 type fakeUsageSink struct { mu sync.Mutex ev *contract.UsageEvent } func (u *fakeUsageSink) PublishUsage(ev *contract.UsageEvent) error { u.mu.Lock() u.ev = ev u.mu.Unlock() return nil } func (u *fakeUsageSink) get() *contract.UsageEvent { u.mu.Lock() defer u.mu.Unlock() return u.ev } // handleKit 是 Handle 测试的依赖套件(全 fake,可断言各出口)。 type handleKit struct { o *Orchestrator sink *fakeSink status *fakeStatus exec *fakeExec eval *fakeEvalSink usage *fakeUsageSink tools *fakeTools } func newHandleKit(ll *fakeLLM) *handleKit { k := &handleKit{ sink: &fakeSink{}, status: &fakeStatus{}, exec: &fakeExec{}, eval: &fakeEvalSink{}, usage: &fakeUsageSink{}, tools: &fakeTools{}, } o := &Orchestrator{ pool: ll, breaker: harness.NewCircuitBreaker(), sink: k.sink, tools: k.tools, exec: k.exec, status: k.status, evalSink: k.eval, } o.SetUsageSink(k.usage) k.o = o return k } // eventually 在 timeout 内轮询 cond,成立即返回 true(等异步评测/用量回写)。 func eventually(timeout time.Duration, cond func() bool) bool { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if cond() { return true } time.Sleep(10 * time.Millisecond) } return cond() } const convGraph = `{"nodes":[ {"id":"i","kind":"input","config":{"text":"介绍杭州"}}, {"id":"a","kind":"agent","config":{"system":"助手"}} ],"edges":[{"source":"i","target":"a"}]}` // happy path:状态 running→done,token 流出 + 收尾,异步评测落库。 func TestHandle_HappyPath(t *testing.T) { ll := &fakeLLM{ready: true, stream: func([]llm.ChatMessage) string { return "杭州是浙江省会,以西湖闻名。" }} k := newHandleKit(ll) k.o.eval = harness.NewEvaluator(func() bool { return true }, func(context.Context, string, string) (string, error) { return `{"score":5,"reason":"好"}`, nil }) if err := k.o.Handle(context.Background(), task(convGraph)); err != nil { t.Fatalf("Handle err: %v", err) } seq, _ := k.status.snapshot() if len(seq) < 2 || seq[0] != contract.TaskRunning || seq[len(seq)-1] != contract.TaskDone { t.Fatalf("状态应 running→…→done,got %v", seq) } if !k.sink.done { t.Error("应收尾 token 流") } if k.sink.text() == "" { t.Error("应有 token 流出") } if !eventually(2*time.Second, func() bool { return k.eval.get() != nil }) { t.Fatal("异步评测应落库") } if ev := k.eval.get(); ev.Level != contract.EvalOK { t.Errorf("评测应 ok,got %s", ev.Level) } } // 工具→agent 链路经 Handle 全程跑通:工具被调,产出注入答案。 func TestHandle_ToolFeedsAgent(t *testing.T) { g := `{"nodes":[ {"id":"i","kind":"input","config":{"text":"hi"}}, {"id":"t","kind":"tool","config":{"tool":"wiki_search"}}, {"id":"a","kind":"agent","config":{"system":"S"}} ],"edges":[{"source":"i","target":"t"},{"source":"t","target":"a"}]}` ll := &fakeLLM{ready: true, stream: func(m []llm.ChatMessage) string { return m[0].Content }} k := newHandleKit(ll) k.tools.fn = func(*contract.ToolCall) *contract.ToolResult { return &contract.ToolResult{OK: true, Content: "TOOLDATA"} } if err := k.o.Handle(context.Background(), task(g)); err != nil { t.Fatalf("Handle err: %v", err) } if !k.tools.called("wiki_search") { t.Error("应调用 wiki_search") } if k.status.last() != contract.TaskDone { t.Errorf("应 done,got %s", k.status.last()) } } // 熔断开启:快速拒绝,不执行,状态 failed。 func TestHandle_CircuitBreakerOpen(t *testing.T) { ll := &fakeLLM{ready: true, stream: func([]llm.ChatMessage) string { return "不该被调用" }} k := newHandleKit(ll) for i := 0; i < 10; i++ { // 连续失败打开熔断 k.o.breaker.Report(false) } if k.o.breaker.Allow() { t.Skip("熔断阈值未达(实现差异),跳过") } if err := k.o.Handle(context.Background(), task(convGraph)); err != nil { t.Fatalf("Handle err: %v", err) } if k.status.last() != contract.TaskFailed { t.Errorf("熔断应 failed,got %s", k.status.last()) } if k.sink.text() != "" && k.sink.text() != "⚠️ 服务繁忙(已触发熔断保护),请稍后重试。" { t.Errorf("熔断不应执行图,got token %q", k.sink.text()) } } // token 预算触顶:状态 failed(带原因),用量回写带 Exceeded。 func TestHandle_BudgetExceeded(t *testing.T) { ll := &fakeLLM{ready: true, stream: func([]llm.ChatMessage) string { return "答案" }} k := newHandleKit(ll) tk := task(convGraph) tk.Meta[contract.MetaTokenBudget] = float64(3) // 极小预算,入口计输入即触顶 if err := k.o.Handle(context.Background(), tk); err != nil { t.Fatalf("Handle err: %v", err) } if k.status.last() != contract.TaskFailed { t.Fatalf("预算超限应 failed,got %s", k.status.last()) } if !eventually(2*time.Second, func() bool { u := k.usage.get(); return u != nil && u.Exceeded }) { t.Fatal("应回写用量且标记 Exceeded") } } // 输入护栏 Tier2:灰区任务被 LLM 分类器拦截 → rejected,不执行。 func TestHandle_GuardrailBlocks(t *testing.T) { ll := &fakeLLM{ready: true, stream: func([]llm.ChatMessage) string { return "不该被调用" }} k := newHandleKit(ll) k.o.SetGuardian(harness.NewClassifier(func() bool { return true }, func(context.Context, string, string) (string, error) { return `{"jailbreak":true,"severity":0.95,"reason":"越狱尝试"}`, nil })) tk := task(convGraph) tk.Meta[contract.MetaSafetyCheck] = true if err := k.o.Handle(context.Background(), tk); err != nil { t.Fatalf("Handle err: %v", err) } if k.status.last() != contract.TaskRejected { t.Fatalf("应 rejected,got %s", k.status.last()) } seq, _ := k.status.snapshot() for _, s := range seq { if s == contract.TaskRunning { t.Error("被护栏拦截不应进入 running") } } } // 空 id 任务:直接丢弃,无状态回写(防脏数据自我放大)。 func TestHandle_EmptyTaskDropped(t *testing.T) { k := newHandleKit(&fakeLLM{ready: true}) if err := k.o.Handle(context.Background(), &contract.Task{ID: "", Graph: json.RawMessage(convGraph)}); err != nil { t.Fatalf("Handle err: %v", err) } if seq, _ := k.status.snapshot(); len(seq) != 0 { t.Errorf("空任务不应有状态回写,got %v", seq) } }