| 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 |