Files
sundynix-agentix/sundynix-dispatcher/internal/eino/compose_compiler_test.go
T
Blizzard bc7600625d feat(hitl): 增量2b —— 审批节点改 compose.Interrupt + orchestrator 识别中断
接了 checkpoint 后端时,审批节点从「阻塞 goroutine 等 5min」改为持久化中断:
首次执行发待审 + 置 waiting + compose.Interrupt → compose 把整图状态(含 board)
落进 checkpoint store 并返回中断错误 → Handle 释放 goroutine、任务停在 waiting,
不收尾不评测不判 done。抗 dispatcher 重启。

- orchestrator: 新增 errInterrupted 哨兵 + checkpoints 字段 + SetCheckpoints
  setter(沿用 guard/usageSink 的 setter 注入,不动构造签名);Handle 识别
  errInterrupted → 释放 goroutine、保留 waiting、SSE 流不关。
- compose_compiler: 编译挂 WithCheckPointStore + WithGraphName("root"),Invoke
  带 WithCheckPointID(task_id);审批节点接 checkpoint 时改走专用
  approvalInterruptLambda(须把中断错误作节点返回值上抛,泛型 lambda 会吞掉);
  ExtractInterruptInfo 识别中断 → 上抛 errInterrupted。
- graph.go: 抽出 approvalSummary / applyApprovalDecision,阻塞式与中断式审批共用,
  杜绝两路文案/语义漂移。

双路径并存:未接 checkpoint 后端(store=nil)维持阻塞模型,行为不变;main 暂不
接,生产保持阻塞,待增量3 resume 闭环补齐再打开(建在 flag 后)。

测试:中断半边端到端——errInterrupted + 置 waiting + checkpoint 落 KV(key=task_id)
+ 下游 agent 不执行。既有阻塞式审批/等价测试全绿。

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

172 lines
6.8 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)
}
}
// TestComposeApprovalInterruptCheckpoints 钉死中断式 HITL(接了 checkpoint 后端):
// 审批节点应 compose.Interrupt —— 返回 errInterrupted、置 waiting、checkpoint 落盘、下游 agent 不执行。
// 这是「释放 goroutine + 抗重启」的中断半边;恢复半边(resume)由增量3 覆盖。
func TestComposeApprovalInterruptCheckpoints(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"}
]}`
kv := newMemKV()
st := &fakeStatus{}
fs := &fakeSink{}
o := &Orchestrator{pool: echoLLM(), breaker: harness.NewCircuitBreaker(), sink: fs, status: st}
o.SetCheckpoints(kv) // 接 checkpoint 后端 → 审批走中断模型
task := &contract.Task{ID: "t_intr", Graph: []byte(graph)}
_, _, err := o.runComposeGraph(context.Background(), task, &execTracer{})
if !errors.Is(err, errInterrupted) {
t.Fatalf("审批应中断并返回 errInterruptedgot %v", err)
}
if st.last() != contract.TaskWaiting {
t.Fatalf("中断应置 waitinggot %q", st.last())
}
if _, ok, _ := kv.Get(context.Background(), task.ID); !ok {
t.Fatalf("compose 应把图状态落进 checkpointkey=task_id),但 KV 未命中")
}
if strings.Contains(fs.text(), "ANS:") {
t.Fatalf("审批中断后下游 agent 不应执行,但 sink 含 agent 产出: %q", fs.text())
}
}
// 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)
}
}