package skills import ( "context" "errors" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件专测步骤调度与 guardrail 强制点(design/09 §6.4):并行上限、拓扑顺序、 // 中止语义、取消传播、收尾全量复核。 // barrierExecutor 让每一步执行前先登记,再停住等测试放行 —— 用来**观测**两路是否 // 真的同时在飞(串行实现会让第二个登记永远不来,测试超时变红)。 type barrierExecutor struct { entered chan string release chan struct{} } func (e *barrierExecutor) ExecuteStep(ctx context.Context, req StepRequest) (StepResult, error) { select { case e.entered <- req.Step.Name: case <-ctx.Done(): return StepResult{}, ctx.Err() } select { case <-e.release: return StepResult{Output: req.Step.Name}, nil case <-ctx.Done(): return StepResult{}, ctx.Err() } } // ctxWaitExecutor 一直等到 ctx 取消才返回,用于验证取消传播。 type ctxWaitExecutor struct { started chan string } func (e *ctxWaitExecutor) ExecuteStep(ctx context.Context, req StepRequest) (StepResult, error) { select { case e.started <- req.Step.Name: default: } <-ctx.Done() return StepResult{}, ctx.Err() } // TestRunner_RunSkill_无依赖步骤并行 覆盖 design/09 §6.4-1:无依赖的步骤可并行。 func TestRunner_RunSkill_无依赖步骤并行(t *testing.T) { t.Parallel() skill := mustSkill(t, "parallel", []sdk.Step{ {Name: "a", Prompt: "p"}, {Name: "b", Prompt: "p"}, }) exec := &barrierExecutor{entered: make(chan string, 2), release: make(chan struct{})} result := make(chan RunResult, 1) errs := make(chan error, 1) go func() { res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}}) result <- res errs <- err }() waitEntered(t, exec.entered, 2) close(exec.release) if err := <-errs; err != nil { t.Fatalf("RunSkill: %v", err) } res := <-result if len(res.Steps) != 2 { t.Fatalf("Steps = %+v, want 2 条", res.Steps) } } // TestRunner_RunSkill_并行上限夹到硬顶 断言 maxStepParallel=3 是真的咬得住的: // 4 个无依赖步骤 + 申请 100 路 ⇒ 同时最多 3 路在飞,第 4 个不会被提前放出去。 func TestRunner_RunSkill_并行上限夹到硬顶(t *testing.T) { t.Parallel() steps := []sdk.Step{ {Name: "a", Prompt: "p"}, {Name: "b", Prompt: "p"}, {Name: "c", Prompt: "p"}, {Name: "d", Prompt: "p"}, } skill := mustSkill(t, "wide", steps) exec := &barrierExecutor{entered: make(chan string, 4), release: make(chan struct{})} errs := make(chan error, 1) go func() { _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}, MaxParallel: 100}) errs <- err }() waitEntered(t, exec.entered, maxStepParallel) select { case got := <-exec.entered: t.Fatalf("第 %q 步在 3 路已满时仍被放出,并行上限没咬住", got) case <-time.After(200 * time.Millisecond): } close(exec.release) if err := <-errs; err != nil { t.Fatalf("RunSkill: %v", err) } } // TestRunner_RunSkill_依赖未就绪不执行 覆盖拓扑语义:两条链 a→b、c→d, // 每个后继都必须在自己前序完成之后才开始。 func TestRunner_RunSkill_依赖未就绪不执行(t *testing.T) { t.Parallel() skill := mustSkill(t, "chain", []sdk.Step{ {Name: "a", Prompt: "p"}, {Name: "b", Prompt: "p", Depends: []string{"a"}}, {Name: "c", Prompt: "p"}, {Name: "d", Prompt: "p", Depends: []string{"c"}}, }) exec := &orderGuardExecutor{after: map[string]string{"b": "a", "d": "c"}} if _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}}); err != nil { t.Fatalf("RunSkill: %v", err) } if exec.violation != "" { t.Fatalf("依赖未就绪就执行了:%s", exec.violation) } } // orderGuardExecutor 记录完成过的步骤,并断言某步开始时它的前序已完成。 type orderGuardExecutor struct { mu sync.Mutex done map[string]bool after map[string]string violation string } func (e *orderGuardExecutor) ExecuteStep(_ context.Context, req StepRequest) (StepResult, error) { e.mu.Lock() defer e.mu.Unlock() if e.done == nil { e.done = make(map[string]bool) } if want := e.after[req.Step.Name]; want != "" && !e.done[want] { e.violation = req.Step.Name + " 在 " + want + " 之前就开始" } e.done[req.Step.Name] = true return StepResult{Output: req.Step.Name}, nil } // TestRunner_RunSkill_Enforced护栏违规中止 覆盖 design/09 §6.4-4:enforced 违反 // ⇒ 中止,且错误里如实带「哪条 guardrail、哪一步」,后续步骤不再执行。 func TestRunner_RunSkill_Enforced护栏违规中止(t *testing.T) { t.Parallel() skill := mustRunSkill(t, "guarded", []sdk.Step{{Name: "one", Prompt: "p"}, {Name: "two", Prompt: "p", Depends: []string{"one"}}}, []sdk.Guardrail{{ID: "no-pii", Message: "不许输出个人信息", Enforced: true}}) exec := &recordingExecutor{} _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{ Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}, Checker: GuardrailFunc(func(_ context.Context, g sdk.Guardrail, subject, _ string) (bool, error) { return g.ID == "no-pii" && subject == "one", nil }), }) if !errors.Is(err, ErrGuardrailViolation) { t.Fatalf("err = %v, want ErrGuardrailViolation", err) } if !strings.Contains(err.Error(), "no-pii") || !strings.Contains(err.Error(), "one") { t.Fatalf("错误没如实带出哪条 guardrail / 哪一步:%v", err) } if got := strings.Join(exec.order(), ","); got != "one" { t.Fatalf("执行顺序 = %q, want 只有 one(中止后 two 不得执行)", got) } } // TestRunner_RunSkill_非Enforced护栏只告警 覆盖「false ⇒ WARN 后继续」。 func TestRunner_RunSkill_非Enforced护栏只告警(t *testing.T) { t.Parallel() skill := mustRunSkill(t, "warned", []sdk.Step{{Name: "one", Prompt: "p"}}, []sdk.Guardrail{{ID: "tone", Message: "语气要软", Enforced: false}}) exec := &recordingExecutor{} res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{ Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}, Checker: GuardrailFunc(func(_ context.Context, _ sdk.Guardrail, subject, _ string) (bool, error) { return subject == "one", nil }), }) if err != nil { t.Fatalf("RunSkill: %v", err) } if len(res.Steps) != 1 { t.Fatalf("Steps = %+v, want 1 条(WARN 不阻断)", res.Steps) } if len(res.Violations) == 0 || res.Violations[0].GuardrailID != "tone" || res.Violations[0].Enforced { t.Fatalf("Violations = %+v, want tone/非 enforced 一条", res.Violations) } } // TestRunner_RunSkill_护栏校验器失败即中止 断言「判不了」不等于「通过」。 func TestRunner_RunSkill_护栏校验器失败即中止(t *testing.T) { t.Parallel() skill := mustRunSkill(t, "badcheck", []sdk.Step{{Name: "one", Prompt: "p"}}, []sdk.Guardrail{{ID: "g", Message: "m", Enforced: false}}) boom := errors.New("checker down") _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{ Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}, Checker: GuardrailFunc(func(context.Context, sdk.Guardrail, string, string) (bool, error) { return false, boom }), }) if !errors.Is(err, ErrGuardrailCheck) || !errors.Is(err, boom) { t.Fatalf("err = %v, want 同时命中 ErrGuardrailCheck 与原始错误", err) } } // TestRunner_RunSkill_步骤失败不静默跳过 覆盖 design/09 §6.4-5:任一步失败 ⇒ // 技能失败,错误带步骤名,后继步骤不再执行,结果只含已完成的部分。 func TestRunner_RunSkill_步骤失败不静默跳过(t *testing.T) { t.Parallel() skill := mustSkill(t, "failing", []sdk.Step{ {Name: "one", Prompt: "p"}, {Name: "two", Prompt: "p", Depends: []string{"one"}}, }) boom := errors.New("model exploded") exec := &recordingExecutor{errs: map[string]error{"one": boom}} res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{ Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}, }) if !errors.Is(err, ErrStepFailed) || !errors.Is(err, boom) { t.Fatalf("err = %v, want 同时命中 ErrStepFailed 与原始错误", err) } if !strings.Contains(err.Error(), "one") { t.Fatalf("错误没带出失败的步骤名:%v", err) } if got := strings.Join(exec.order(), ","); got != "one" { t.Fatalf("执行顺序 = %q, want 只有 one", got) } if len(res.Steps) != 0 { t.Fatalf("Steps = %+v, want 空(失败步不算完成)", res.Steps) } } // TestRunner_RunSkill_取消传播 覆盖 design/09 §6.4-5:父 ctx 取消 ⇒ 技能随之取消。 func TestRunner_RunSkill_取消传播(t *testing.T) { t.Parallel() skill := mustSkill(t, "cancelled", []sdk.Step{ {Name: "a", Prompt: "p"}, {Name: "b", Prompt: "p"}, }) exec := &ctxWaitExecutor{started: make(chan string, 2)} ctx, cancel := context.WithCancel(context.Background()) errs := make(chan error, 1) go func() { _, err := NewRunner(registry.New(), testLogger()).RunSkill(ctx, skill, RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}}) errs <- err }() waitEntered(t, exec.started, 1) cancel() select { case err := <-errs: if !errors.Is(err, context.Canceled) { t.Fatalf("err = %v, want context.Canceled", err) } case <-time.After(2 * time.Second): t.Fatal("取消后 RunSkill 没收敛(疑似遗留 goroutine 或死等)") } } // TestRunner_RunSkill_收尾全量复核 覆盖 design/09 §6.4-4 的「再全量复核一次」: // 逐步校验都通过,但累计输出违反 enforced guardrail ⇒ 仍然中止。 func TestRunner_RunSkill_收尾全量复核(t *testing.T) { t.Parallel() skill := mustRunSkill(t, "final-check", []sdk.Step{{Name: "one", Prompt: "p"}, {Name: "two", Prompt: "p"}}, []sdk.Guardrail{{ID: "budget", Message: "总长受限", Enforced: true}}) _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{ Executor: &recordingExecutor{outputs: map[string]string{"one": "a", "two": "b"}}, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}, Checker: GuardrailFunc(func(_ context.Context, _ sdk.Guardrail, subject, _ string) (bool, error) { return subject == GuardrailFinalSubject, nil }), }) if !errors.Is(err, ErrGuardrailViolation) { t.Fatalf("err = %v, want ErrGuardrailViolation", err) } if !strings.Contains(err.Error(), GuardrailFinalSubject) { t.Fatalf("错误没说明违规发生在全量复核阶段:%v", err) } } // TestRunner_RunSkill_无步骤直接成功 断言空工作流不报错、也不造出假步骤。 func TestRunner_RunSkill_无步骤直接成功(t *testing.T) { t.Parallel() res, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), mustSkill(t, "empty", nil), RunOptions{Executor: &recordingExecutor{}, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}}) if err != nil { t.Fatalf("RunSkill: %v", err) } if len(res.Steps) != 0 { t.Fatalf("Steps = %+v, want 空", res.Steps) } } // TestRunner_RunSkill_下游输出有界 断言传给下游的步骤输出受 maxStepOutputChars 约束, // 且截断有显式标记(绝不静默)。 func TestRunner_RunSkill_下游输出有界(t *testing.T) { t.Parallel() skill := mustSkill(t, "bounded", []sdk.Step{ {Name: "one", Prompt: "p"}, {Name: "two", Prompt: "p", Depends: []string{"one"}}, }) exec := &recordingExecutor{outputs: map[string]string{"one": strings.Repeat("x", maxStepOutputChars+50)}} if _, err := NewRunner(registry.New(), testLogger()).RunSkill(context.Background(), skill, RunOptions{Executor: exec, Gate: NewNameSet([]string{"t"}), ToolNames: []string{"t"}}); err != nil { t.Fatalf("RunSkill: %v", err) } got := exec.depsOf("two")["one"] if len([]rune(got)) != maxStepOutputChars+len([]rune(stepTruncatedMarker)) { t.Fatalf("传给下游的输出长度 = %d,want %d", len([]rune(got)), maxStepOutputChars+len([]rune(stepTruncatedMarker))) } if !strings.Contains(got, "truncated") { t.Fatalf("截断没有显式标记:%q", got[len(got)-20:]) } } func TestClampParallel(t *testing.T) { t.Parallel() cases := map[int]int{ 0: defaultStepParallel, -3: defaultStepParallel, 1: 1, 2: 2, 3: maxStepParallel, 100: maxStepParallel, } for in, want := range cases { if got := clampParallel(in); got != want { t.Fatalf("clampParallel(%d) = %d, want %d", in, got, want) } } } // mustRunSkill 构造一个带 guardrail 的技能;guardrail 的存在使 Validate 之外的分支 // 也被覆盖(重复 id、冲突工具在 validate_test.go 里测)。 func mustRunSkill(t *testing.T, name string, steps []sdk.Step, guardrails []sdk.Guardrail) *Skill { t.Helper() skill, err := New(name, "desc "+name, "base", steps, nil, guardrails) if err != nil { t.Fatalf("New(%s): %v", name, err) } return skill } // waitEntered 从 ch 收 n 个登记;超时即测试失败(说明并行没发生 / 步骤没被执行)。 func waitEntered(t *testing.T, ch <-chan string, n int) { t.Helper() for i := 0; i < n; i++ { select { case <-ch: case <-time.After(3 * time.Second): t.Fatalf("只等到 %d/%d 个步骤进入执行(疑似串行或未调度)", i, n) } } }