Details (original message): Add catalog-driven orchestration: intent catalog injection, deterministic plan validation, and system-function steps.
172 lines
6.2 KiB
Go
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")
|
|
}
|
|
}
|