返回 DeepSeek-Reasonix
profile_boundary_test.go
根目录 / internal / agent / profile_boundary_test.go
1 package agent
2
3 import (
4 "reflect"
5 "slices"
6 "strings"
7 "testing"
8
9 "reasonix/internal/skill"
10 )
11
12 // The criterion these guards enforce. It is repeated in every failure message
13 // because the whole point is that the next person adding a field reads it.
14 const profileBoundaryRule = `A field belongs to the member that DECIDES its value:
15 ProfileDefinition / WorkerSpec — follows from the worker's identity (how it thinks, which model, its capability ceiling)
16 TaskSpec — decided per call (objective, criteria, result contract)
17 CapabilityGrant — what this call may touch (ceiling ∩ request)
18 ContextRequest — what the child starts from
19 SchedulerPolicy — when and how it runs
20 Fields like max_turns, write_paths, retry or verification policy are decided by
21 the task or the scheduler, never by the worker, so they must not enter a profile.`
22
23 func fieldNames(t *testing.T, v any) []string {
24 t.Helper()
25 rt := reflect.TypeOf(v)
26 if rt.Kind() != reflect.Struct {
27 t.Fatalf("%T is not a struct", v)
28 }
29 names := make([]string, 0, rt.NumField())
30 for i := range rt.NumField() {
31 names = append(names, rt.Field(i).Name)
32 }
33 slices.Sort(names)
34 return names
35 }
36
37 func assertFieldSet(t *testing.T, what string, v any, want []string) {
38 t.Helper()
39 got := fieldNames(t, v)
40 slices.Sort(want)
41 if slices.Equal(got, want) {
42 return
43 }
44 t.Fatalf("%s fields changed.\n got: %s\n want: %s\n\n%s\n\nIf the new field really is decided by this member, add it to the guard in the same commit.",
45 what, strings.Join(got, ", "), strings.Join(want, ", "), profileBoundaryRule)
46 }
47
48 // A profile describes a worker, not a run. Widening it is how a profile turns
49 // into a workflow definition language.
50 func TestProfileDefinitionStaysWorkerIdentityOnly(t *testing.T) {
51 assertFieldSet(t, "ProfileDefinition", ProfileDefinition{}, []string{
52 "Name", "Body", "AllowedTools", "Model", "Effort", "ReadOnly", "Invocation", "NamedBuiltin",
53 })
54 }
55
56 func TestDelegationSpecMembersStaySeparate(t *testing.T) {
57 assertFieldSet(t, "ProfileExecSpec", ProfileExecSpec{}, []string{
58 "Task", "Worker", "Grant", "Context", "Sched",
59 })
60 assertFieldSet(t, "TaskSpec", TaskSpec{}, []string{"Objective", "Description"})
61 assertFieldSet(t, "WorkerSpec", WorkerSpec{}, []string{
62 "Kind", "Name", "Profile", "SystemPrompt", "UseProfilePrompt", "Model", "Effort",
63 })
64 assertFieldSet(t, "CapabilityGrant", CapabilityGrant{}, []string{
65 "ReadOnly", "AllowNoTools", "CallTools", "ProfileTools", "WritePaths",
66 })
67 assertFieldSet(t, "ContextRequest", ContextRequest{}, []string{
68 "ContinueFrom", "ForkFrom", "Ephemeral", "Decisions", "EvidenceSummary", "FileAnchors", "OutputFormat",
69 })
70 assertFieldSet(t, "SchedulerPolicy", SchedulerPolicy{}, []string{
71 "MaxSteps", "MaxOutputTokens", "RunInBackground", "BackgroundWriter", "Nested",
72 })
73 }
74
75 // Routing metadata decides when a worker is picked, not how it thinks, so the
76 // projection must leave it in the Skill store.
77 func TestProfileFromSkillLeavesRoutingMetadataBehind(t *testing.T) {
78 projected := fieldNames(t, ProfileDefinition{})
79 for _, routing := range []string{"Triggers", "NegativeTriggers", "AutoUse", "NeedsFreshData", "Cost", "Requires", "Plugin", "Path", "SlashPrefix", "Color"} {
80 if slices.Contains(projected, routing) {
81 t.Errorf("Skill routing field %q reached ProfileDefinition.\n\n%s", routing, profileBoundaryRule)
82 }
83 }
84 }
85
86 // The opposite failure: a field that legitimately belongs to the worker is
87 // declared on both types but never wired through, so profiles silently lose it.
88 func TestProfileFromSkillPopulatesEveryIdentityField(t *testing.T) {
89 got := ProfileFromSkill(skill.Skill{
90 Name: "reviewer",
91 Body: "you review code",
92 AllowedTools: []string{"read_file"},
93 Model: "some-model",
94 Effort: "high",
95 ReadOnly: true,
96 Invocation: "manual",
97 })
98 rv := reflect.ValueOf(got)
99 rt := rv.Type()
100 for i := range rt.NumField() {
101 if rt.Field(i).Name == "NamedBuiltin" {
102 continue // derived from the name, not carried on the Skill
103 }
104 if rv.Field(i).IsZero() {
105 t.Errorf("ProfileFromSkill left %s unset — the projection dropped a worker identity field", rt.Field(i).Name)
106 }
107 }
108 if got.NamedBuiltin {
109 t.Error("a custom profile must not be flagged as a named built-in")
110 }
111 }
112
112 lines GO