Files
sundynix-agentix/sundynix-dispatcher/internal/eino/compose_compiler_test.go
T
Blizzard 03225e31a9 refactor(dispatcher): compose 翻默认 —— 补平三缺口后退役 graph.go 在望
EINO_COMPOSE 默认改为开(留 =0 逃生舱回退权威 graph.go)。翻默认前补平
compose 路径三个会静默吃掉治理能力的缺口:

- HITL 审批:execDSLNode 无 approval 分支 → 审批节点被当未识别跳过、下游照跑;
  补 case 调 approvalNode。
- 忠实度评测:executeGraph 把 refs 硬写 nil → 评测静默失效;runComposeGraph
  改签名回传 refsOf(b)。
- 终态传播:rejected/fatalErr 不传播也不阻断下游 → 任务误判 done-空;加节点
  lambda 入口守卫 + branch cond 守卫 + 终态上抛 errRejected/fatalErr,并让
  runComposeConversation 模型失败置 fatalErr(对齐 graph.go 的 break 语义)。

新增等价测试:审批拒绝停下游、refs 回流。go test ./... 全绿。
soak 无回归后即可物理退役 graph.go。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 11:13:46 +08:00

138 lines
5.3 KiB
Go
Raw 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"
"errors"
"strings"
"testing"
"time"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
"github.com/sundynix/sundynix-shared/contract"
)
// echoLLM 回显最后一条 user 消息内容(确定性),便于两条执行路径逐字对比。
func echoLLM() *fakeLLM {
return &fakeLLM{
ready: true,
stream: func(m []llm.ChatMessage) string {
for i := len(m) - 1; i >= 0; i-- {
if m[i].Role == "user" {
return "ANS:" + m[i].Content
}
}
return "ANS:"
},
}
}
func runBoth(t *testing.T, graph string) (interp, comp string) {
t.Helper()
task := &contract.Task{ID: "t_eq", Graph: []byte(graph)}
o1 := &Orchestrator{pool: echoLLM(), breaker: harness.NewCircuitBreaker(), sink: &fakeSink{}}
a1, _, err := o1.runGraph(context.Background(), task, &execTracer{})
if err != nil {
t.Fatalf("runGraph: %v", err)
}
o2 := &Orchestrator{pool: echoLLM(), breaker: harness.NewCircuitBreaker(), sink: &fakeSink{}}
a2, _, err := o2.runComposeGraph(context.Background(), task, &execTracer{})
if err != nil {
t.Fatalf("runComposeGraph: %v", err)
}
return a1, a2
}
// rejectWaiter 是恒拒绝的 HITL 审批替身(fail-safe 路径回归用)。
type rejectWaiter struct{ note string }
func (r *rejectWaiter) WaitApproval(context.Context, string, time.Duration) (*contract.ApprovalDecision, error) {
return &contract.ApprovalDecision{Approved: false, Note: r.note}, nil
}
// TestComposeApprovalRejectStopsDownstream 钉死 compose 路径的 HITL 审批:
// 审批拒绝须置 errRejected、产出拒绝语,且下游 agent 绝不执行(否则等于审批形同虚设)。
// 这是翻默认前最高危的回归点——execDSLNode 一度没有 approval 分支会把审批节点当未识别跳过。
func TestComposeApprovalRejectStopsDownstream(t *testing.T) {
graph := `{"version":"1","nodes":[
{"id":"in","kind":"input","config":{"text":"hi"}},
{"id":"ap","kind":"approval","config":{"title":"上线审批"}},
{"id":"a","kind":"agent","config":{"system":"机密操作"}}
],"edges":[
{"source":"in","target":"ap"},{"source":"ap","target":"a"}
]}`
o := &Orchestrator{pool: echoLLM(), breaker: harness.NewCircuitBreaker(),
sink: &fakeSink{}, approval: &rejectWaiter{note: "不批"}}
ans, refs, err := o.runComposeGraph(context.Background(),
&contract.Task{ID: "t_ap", Graph: []byte(graph)}, &execTracer{})
if !errors.Is(err, errRejected) {
t.Fatalf("审批拒绝应返回 errRejectedgot %v", err)
}
if !strings.Contains(ans, "已被拒绝") {
t.Fatalf("应产出拒绝语,got %q", ans)
}
if strings.Contains(ans, "ANS:") {
t.Fatalf("审批拒绝后下游 agent 不应执行,但成稿含 agent 产出: %q", ans)
}
if refs != nil {
t.Fatalf("拒绝终态 refs 应为 nilgot %v", refs)
}
}
// TestComposeReturnsRefs 钉死 compose 路径回传检索来源——曾被硬写成 nil,导致忠实度评测静默失效。
// 与 runGraph 同图对照:两路径都应回流含检索片段的 refs(喂 grounded judge)。
func TestComposeReturnsRefs(t *testing.T) {
g := `{"nodes":[
{"id":"i","kind":"input","config":{"text":"介绍杭州西湖"}},
{"id":"r","kind":"retriever","config":{"kb":"travel"}},
{"id":"a","kind":"agent","config":{"system":"你是导游"}}
],"edges":[{"source":"i","target":"r"},{"source":"r","target":"a"}]}`
ll := &fakeLLM{ready: true, stream: func(m []llm.ChatMessage) string { return m[0].Content }}
o := newOrch(ll, kbTool(nil), &fakeSink{}, &fakeExec{})
tk := &contract.Task{ID: "t_refs", Graph: json.RawMessage(g), Meta: map[string]any{contract.MetaUserID: "u42"}}
_, refs, err := o.runComposeGraph(context.Background(), tk, &execTracer{})
if err != nil {
t.Fatalf("runComposeGraph: %v", err)
}
if len(refs) == 0 || !strings.Contains(strings.Join(refs, ""), ragSnippet) {
t.Fatalf("compose 路径应回流含检索片段的 refsgot %v", refs)
}
}
// TestComposeEquivalentLinear 多节点线性图:input→memory→agent→output,两路径成稿应逐字一致。
func TestComposeEquivalentLinear(t *testing.T) {
graph := `{"version":"1","nodes":[
{"id":"in","kind":"input","config":{"text":"什么是图编排"}},
{"id":"m","kind":"memory","config":{}},
{"id":"a","kind":"agent","config":{"system":"你是助手"}},
{"id":"out","kind":"output","config":{}}
],"edges":[
{"source":"in","target":"m"},{"source":"m","target":"a"},{"source":"a","target":"out"}
]}`
interp, comp := runBoth(t, graph)
if interp == "" || interp != comp {
t.Fatalf("线性图不等价: interp=%q compose=%q", interp, comp)
}
}
// TestComposeEquivalentBranch 分支图:input→branch→(真)A/(假)B,条件恒真应都走 A,两路径一致。
func TestComposeEquivalentBranch(t *testing.T) {
graph := `{"version":"1","nodes":[
{"id":"in","kind":"input","config":{"text":"hi"}},
{"id":"br","kind":"branch","config":{"condition":""}},
{"id":"a","kind":"agent","config":{"system":"A"}},
{"id":"b","kind":"agent","config":{"system":"B"}}
],"edges":[
{"source":"in","target":"br"},
{"source":"br","target":"a","sourceHandle":"true"},
{"source":"br","target":"b","sourceHandle":"false"}
]}`
interp, comp := runBoth(t, graph)
if interp == "" || interp != comp {
t.Fatalf("分支图不等价: interp=%q compose=%q", interp, comp)
}
}