test(dispatcher): Handle 入口级集成测试 —— 钉死核心编排链 + harness 治理栈协同
此前测试都直打 runGraph/evaluate,绕过真正的任务入口 Handle()——熔断、输入护栏门控、 状态机流转、token 预算、异步评测/用量这些治理逻辑的协同从未在入口级被覆盖。 新增 handle_test.go 6 例(全 fake 依赖,可断言各出口): - HappyPath:running→done + token 流出/收尾 + 异步评测落库(ok) - ToolFeedsAgent:图执行→工具→agent 全程经 Handle,工具被调、产出注入 - CircuitBreakerOpen:熔断开 → 快速 failed、不执行图 - BudgetExceeded:meta token_budget=3 → failed + 用量回写带 Exceeded - GuardrailBlocks:灰区 + LLM 分类器命中 → rejected、不进 running - EmptyTaskDropped:空 id 直接丢弃、无状态回写 配套 fakeStatus/fakeUsageSink;fakeEvalSink 加锁(Handle 异步评测在 goroutine 写)。 go test -race ./internal/eino 干净,四模块全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user