Files
Agent-Squad/squad/intent_catalog_test.go
T
weichen b6ff0ab55f
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Failing after 2s
Add catalog-driven orchestration: intent catalog injection
Details (original message):
Add catalog-driven orchestration: intent catalog injection, deterministic plan validation, and system-function steps.
2026-09-29 16:37:46 +00:00

172 lines
6.2 KiB
Go

package squad
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"agent-native/exec"
)
type stubRunner struct {
calls []IntentStep
text string
err error
}
func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*exec.Result, error) {
r.calls = append(r.calls, step)
if r.err != nil {
return nil, r.err
}
patches := map[string][]byte{}
for _, t := range step.Targets {
patches[t] = []byte(r.text)
}
return &exec.Result{Success: true, Patches: patches, Logs: "stub-intent"}, nil
}
func testCatalog() *IntentCatalog {
return &IntentCatalog{
Revision: 1,
Markdown: "## demo.run.status_query — 查询运行进度\n- 槽位 run_id(必填)\n - 提问建议:要查询哪个运行?\n",
Specs: map[string]IntentSpecView{
"demo.run.status_query": {
ID: "demo.run.status_query", Description: "查询运行进度", Handler: "taskrun.query_status",
Slots: []IntentSlotView{
{Key: "run_id", Required: true, Label: "运行编号", Question: "要查询哪个运行?"},
{Key: "env", Enum: []string{"prod", "staging"}},
},
RequiredSlots: []string{"run_id"},
},
},
}
}
func TestValidateIntentSteps(t *testing.T) {
step := func(id, intent string, slots map[string]string) SquadPlan {
return SquadPlan{Workflow: WorkflowPlan{Steps: []StepSpec{{ID: id, IntentID: intent, IntentSlots: slots}}}}
}
cases := []struct {
name string
plan SquadPlan
catalog *IntentCatalog
wantErr string
}{
{"ok", step("s1", "demo.run.status_query", map[string]string{"run_id": "run-1"}), testCatalog(), ""},
{"no_catalog", step("s1", "demo.run.status_query", nil), nil, "未装配意图目录"},
{"unknown_intent", step("s1", "demo.nope.x", nil), testCatalog(), "不存在意图"},
{"missing_required", step("s1", "demo.run.status_query", nil), testCatalog(), "缺必填槽位 run_id(澄清建议:要查询哪个运行?)"},
{"enum_violation", step("s1", "demo.run.status_query", map[string]string{"run_id": "run-1", "env": "dev"}), testCatalog(), "不在枚举"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateIntentSteps(&tc.plan, tc.catalog)
if tc.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
}
})
}
}
func newCatalogTestEngine(t *testing.T, runner IntentRunner, catalog *IntentCatalog) *SquadEngine {
t.Helper()
engine, _ := newTestEngine(t, NewRulePlanner(),
map[string]map[string]string{"02_writer": {"output.md": goodReport}}, nil)
if err := engine.Blueprints().Register(Blueprint{
Key: "mixed_demo", Title: "混合演示", Match: []string{"混合演示"},
Steps: []StepSpec{
{ID: "sys1", Role: "researcher", Title: "查询运行进度",
IntentID: "demo.run.status_query", IntentSlots: map[string]string{"run_id": "run-42"}},
{ID: "write1", Role: "writer", Title: "汇总成文", Targets: []string{"output.md"}},
},
}); err != nil {
t.Fatal(err)
}
// 产品侧经 Options 在 NewEngine 传入;测试在构造后注入,需同时更新
// engineDeps(步骤执行走 deps 副本,构建期已定型)。
engine.opts.IntentCatalog = catalog
engine.opts.IntentRunner = runner
engine.deps.IntentRunner = runner
return engine
}
func TestIntentStepExecutedByRunnerNotBackend(t *testing.T) {
runner := &stubRunner{text: "# 查询结果\n\nrun-42 进度 100%,内容满足最小质检长度要求,作为系统功能步产物。\n"}
engine := newCatalogTestEngine(t, runner, testCatalog())
res, err := engine.Run(context.Background(), Goal{Text: "做一次混合演示", HitlMode: HitlAuto}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("expected completed, got %s (err=%s)", res.Status, res.Error)
}
if len(runner.calls) != 1 || runner.calls[0].IntentID != "demo.run.status_query" || runner.calls[0].Slots["run_id"] != "run-42" {
t.Fatalf("runner calls: %+v", runner.calls)
}
// 系统功能步产物落盘。
sysOut := filepath.Join(res.WorkDir, "tasks", "01_researcher", "output.md")
if data, err := os.ReadFile(sysOut); err != nil || !strings.Contains(string(data), "查询结果") {
t.Fatalf("intent step deliverable missing: %v", err)
}
}
func TestIntentStepWithoutRunnerFailsClosed(t *testing.T) {
engine := newCatalogTestEngine(t, nil, testCatalog())
res, err := engine.Run(context.Background(), Goal{Text: "做一次混合演示", HitlMode: HitlAuto}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "IntentRunner") {
t.Fatalf("expected fail-closed runner error, got %+v", res)
}
}
func TestIntentStepPlanValidationFailsClosed(t *testing.T) {
// 目录缺席时,含 intent 步的蓝图在计划校验即被拒(不到执行层)。
engine := newCatalogTestEngine(t, &stubRunner{text: "x"}, nil)
res, err := engine.Run(context.Background(), Goal{Text: "做一次混合演示", HitlMode: HitlAuto}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "未装配意图目录") {
t.Fatalf("expected validation failure, got %+v", res)
}
}
func TestLLMPlannerPromptIncludesCatalog(t *testing.T) {
p := NewLLMPlanner(nil, "m")
input := PlanInput{
Goal: Goal{Text: "目标"},
Registry: NewRoleRegistry(),
IntentCatalog: testCatalog(),
}
got := p.buildPrompt(input)
for _, want := range []string{"系统能力目录", "demo.run.status_query", "提问建议:要查询哪个运行"} {
if !strings.Contains(got, want) {
t.Fatalf("prompt missing %q", want)
}
}
if strings.Contains(NewLLMPlanner(nil, "m").buildPrompt(PlanInput{Goal: Goal{Text: "g"}, Registry: NewRoleRegistry()}), "系统能力目录") {
t.Fatal("catalog must be absent when not configured")
}
}
func TestLeaderPromptIncludesCatalog(t *testing.T) {
md := leaderPrompt(nil, testCatalog().Markdown)
if !strings.Contains(md, "系统能力目录") || !strings.Contains(md, "demo.run.status_query") {
t.Fatalf("leader prompt missing catalog:\n%s", md)
}
if strings.Contains(leaderPrompt(nil, ""), "系统能力目录") {
t.Fatal("empty catalog must not add section")
}
}