package squad import ( "context" "os" "path/filepath" "strings" "testing" ) type stubRunner struct { calls []IntentStep text string err error } func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*ExecResult, 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 &ExecResult{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") } }