| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "reflect" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/internal/provider" |
| 9 | "reasonix/internal/tool" |
| 10 | ) |
| 11 | |
| 12 | func TestNewTaskToolWithOptionsMatchesPositional(t *testing.T) { |
| 13 | prov := &mockProvider{name: "sub"} |
| 14 | pricing := &provider.Pricing{Input: 1.5, Output: 2.5} |
| 15 | reg := tool.NewRegistry() |
| 16 | gate := &stubGate{} |
| 17 | resolve := func(model, effort string) (provider.Provider, *provider.Pricing, int, error) { |
| 18 | return &mockProvider{name: "resolved-" + model}, pricing, 8192, nil |
| 19 | } |
| 20 | |
| 21 | cases := []struct { |
| 22 | name string |
| 23 | opts TaskToolOptions |
| 24 | }{ |
| 25 | { |
| 26 | name: "empty-sys-prompt-defaults", |
| 27 | opts: TaskToolOptions{ |
| 28 | Provider: prov, |
| 29 | ParentRegistry: reg, |
| 30 | MaxSteps: 20, |
| 31 | }, |
| 32 | }, |
| 33 | { |
| 34 | name: "zero-value-config", |
| 35 | opts: TaskToolOptions{ |
| 36 | Provider: prov, |
| 37 | ParentRegistry: reg, |
| 38 | }, |
| 39 | }, |
| 40 | { |
| 41 | name: "non-empty-gate-and-overrides", |
| 42 | opts: TaskToolOptions{ |
| 43 | Provider: prov, |
| 44 | Pricing: pricing, |
| 45 | ParentRegistry: reg, |
| 46 | MaxSteps: 12, |
| 47 | ContextWindow: 64000, |
| 48 | RecentKeep: 7, |
| 49 | SoftCompactRatio: 0.55, |
| 50 | ToolResultSnipRatio: 0.4, |
| 51 | CompactRatio: 0.8, |
| 52 | CompactForceRatio: 0.95, |
| 53 | Temperature: 0.2, |
| 54 | ArchiveDir: t.TempDir(), |
| 55 | SysPrompt: "custom sub-agent prompt", |
| 56 | Gate: gate, |
| 57 | KeepPolicy: KeepErrors | KeepUserMarked, |
| 58 | SubagentModel: "deepseek-chat", |
| 59 | SubagentEffort: "high", |
| 60 | ResolveProvider: resolve, |
| 61 | }, |
| 62 | }, |
| 63 | } |
| 64 | |
| 65 | for _, tc := range cases { |
| 66 | t.Run(tc.name, func(t *testing.T) { |
| 67 | legacy := NewTaskTool( |
| 68 | tc.opts.Provider, |
| 69 | tc.opts.Pricing, |
| 70 | tc.opts.ParentRegistry, |
| 71 | tc.opts.MaxSteps, |
| 72 | tc.opts.ContextWindow, |
| 73 | tc.opts.RecentKeep, |
| 74 | tc.opts.SoftCompactRatio, |
| 75 | tc.opts.ToolResultSnipRatio, |
| 76 | tc.opts.CompactRatio, |
| 77 | tc.opts.CompactForceRatio, |
| 78 | tc.opts.Temperature, |
| 79 | tc.opts.ArchiveDir, |
| 80 | tc.opts.SysPrompt, |
| 81 | tc.opts.Gate, |
| 82 | tc.opts.KeepPolicy, |
| 83 | tc.opts.SubagentModel, |
| 84 | tc.opts.SubagentEffort, |
| 85 | tc.opts.ResolveProvider, |
| 86 | ) |
| 87 | modern := NewTaskToolWithOptions(tc.opts) |
| 88 | assertTaskToolConfigEqual(t, legacy, modern) |
| 89 | if !reflect.DeepEqual(legacy.Schema(), modern.Schema()) { |
| 90 | t.Fatalf("schema mismatch:\nlegacy=%s\nmodern=%s", legacy.Schema(), modern.Schema()) |
| 91 | } |
| 92 | if legacy.Name() != modern.Name() || legacy.Description() != modern.Description() || legacy.ReadOnly() != modern.ReadOnly() { |
| 93 | t.Fatalf("tool identity mismatch: name=%q/%q desc-len=%d/%d readOnly=%v/%v", |
| 94 | legacy.Name(), modern.Name(), len(legacy.Description()), len(modern.Description()), legacy.ReadOnly(), modern.ReadOnly()) |
| 95 | } |
| 96 | }) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestNewTaskToolWithOptionsEmptySysPromptUsesDefault(t *testing.T) { |
| 101 | task := NewTaskToolWithOptions(TaskToolOptions{ |
| 102 | Provider: &mockProvider{name: "sub"}, |
| 103 | ParentRegistry: tool.NewRegistry(), |
| 104 | MaxSteps: 5, |
| 105 | SysPrompt: "", |
| 106 | }) |
| 107 | if task.sysPrompt != DefaultTaskSystemPrompt { |
| 108 | t.Fatalf("sysPrompt = %q, want DefaultTaskSystemPrompt", task.sysPrompt) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestNewTaskToolWithOptionsAndLegacyExecuteEquivalence(t *testing.T) { |
| 113 | chunks := []provider.Chunk{ |
| 114 | {Type: provider.ChunkText, Text: "options-equivalent-answer"}, |
| 115 | {Type: provider.ChunkDone}, |
| 116 | } |
| 117 | legacyProv := &mockProvider{name: "sub", chunks: chunks} |
| 118 | modernProv := &mockProvider{name: "sub", chunks: append([]provider.Chunk(nil), chunks...)} |
| 119 | reg := tool.NewRegistry() |
| 120 | sys := "sys-for-equivalence" |
| 121 | storeDir := t.TempDir() |
| 122 | workspace := t.TempDir() |
| 123 | |
| 124 | legacy := NewTaskTool(legacyProv, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", sys, nil, 0, "", "", nil). |
| 125 | WithTranscripts(NewSubagentStore(storeDir), workspace, "base-model", "base-effort") |
| 126 | modern := NewTaskToolWithOptions(TaskToolOptions{ |
| 127 | Provider: modernProv, |
| 128 | ParentRegistry: reg, |
| 129 | MaxSteps: 20, |
| 130 | SysPrompt: sys, |
| 131 | }).WithTranscripts(NewSubagentStore(storeDir), workspace, "base-model", "base-effort") |
| 132 | |
| 133 | legacyOut, legacyErr := legacy.Execute(testTaskContext(), []byte(`{"prompt":"equivalence prompt"}`)) |
| 134 | modernOut, modernErr := modern.Execute(testTaskContext(), []byte(`{"prompt":"equivalence prompt"}`)) |
| 135 | if legacyErr != nil || modernErr != nil { |
| 136 | t.Fatalf("Execute errors: legacy=%v modern=%v", legacyErr, modernErr) |
| 137 | } |
| 138 | // Transcript refs differ by id; compare semantic body and system prompt routing. |
| 139 | if !strings.Contains(legacyOut, "options-equivalent-answer") || !strings.Contains(modernOut, "options-equivalent-answer") { |
| 140 | t.Fatalf("final answers missing:\nlegacy=%q\nmodern=%q", legacyOut, modernOut) |
| 141 | } |
| 142 | if legacySys := legacyProv.lastReq.Messages[0].Content; legacySys != sys { |
| 143 | t.Fatalf("legacy system prompt = %q, want %q", legacySys, sys) |
| 144 | } |
| 145 | if modernSys := modernProv.lastReq.Messages[0].Content; modernSys != sys { |
| 146 | t.Fatalf("modern system prompt = %q, want %q", modernSys, sys) |
| 147 | } |
| 148 | if !strings.Contains(lastUser(legacyProv.lastReq), "equivalence prompt") || |
| 149 | !strings.Contains(lastUser(modernProv.lastReq), "equivalence prompt") { |
| 150 | t.Fatalf("user prompts not routed:\nlegacy=%q\nmodern=%q", lastUser(legacyProv.lastReq), lastUser(modernProv.lastReq)) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func TestNewTaskToolWithOptionsProviderResolverAndOverrides(t *testing.T) { |
| 155 | base := &mockProvider{name: "base"} |
| 156 | resolved := &mockProvider{name: "resolved-child"} |
| 157 | pricing := &provider.Pricing{Input: 3} |
| 158 | var sawModel, sawEffort string |
| 159 | resolve := func(model, effort string) (provider.Provider, *provider.Pricing, int, error) { |
| 160 | sawModel, sawEffort = model, effort |
| 161 | return resolved, pricing, 4096, nil |
| 162 | } |
| 163 | task := NewTaskToolWithOptions(TaskToolOptions{ |
| 164 | Provider: base, |
| 165 | ParentRegistry: tool.NewRegistry(), |
| 166 | MaxSteps: 8, |
| 167 | SubagentModel: "child-model", |
| 168 | SubagentEffort: "max", |
| 169 | ResolveProvider: resolve, |
| 170 | }) |
| 171 | gotProv, gotPrice, gotWin, err := task.resolveSubSessionRuntime("child-model", "max") |
| 172 | if err != nil { |
| 173 | t.Fatalf("resolveSubSessionRuntime: %v", err) |
| 174 | } |
| 175 | if gotProv != resolved || gotPrice != pricing || gotWin != 4096 { |
| 176 | t.Fatalf("resolver result = (%v,%v,%d), want resolved pricing/window", gotProv.Name(), gotPrice, gotWin) |
| 177 | } |
| 178 | if sawModel != "child-model" || sawEffort != "max" { |
| 179 | t.Fatalf("resolver args = (%q,%q), want child-model/max", sawModel, sawEffort) |
| 180 | } |
| 181 | if task.subagentModel != "child-model" || task.subagentEffort != "max" { |
| 182 | t.Fatalf("stored overrides = (%q,%q)", task.subagentModel, task.subagentEffort) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func assertTaskToolConfigEqual(t *testing.T, a, b *TaskTool) { |
| 187 | t.Helper() |
| 188 | if a.prov != b.prov { |
| 189 | t.Fatalf("prov mismatch") |
| 190 | } |
| 191 | if a.pricing != b.pricing { |
| 192 | t.Fatalf("pricing mismatch") |
| 193 | } |
| 194 | if a.parentReg != b.parentReg { |
| 195 | t.Fatalf("parentReg mismatch") |
| 196 | } |
| 197 | if a.maxSteps != b.maxSteps || a.contextWindow != b.contextWindow || a.recentKeep != b.recentKeep { |
| 198 | t.Fatalf("step/window/keep mismatch: %+v vs %+v", |
| 199 | [3]int{a.maxSteps, a.contextWindow, a.recentKeep}, |
| 200 | [3]int{b.maxSteps, b.contextWindow, b.recentKeep}) |
| 201 | } |
| 202 | if a.softCompactRatio != b.softCompactRatio || a.toolResultSnipRatio != b.toolResultSnipRatio || |
| 203 | a.compactRatio != b.compactRatio || a.compactForceRatio != b.compactForceRatio || a.temperature != b.temperature { |
| 204 | t.Fatalf("ratio/temp mismatch") |
| 205 | } |
| 206 | if a.archiveDir != b.archiveDir || a.sysPrompt != b.sysPrompt || a.keepPolicy != b.keepPolicy { |
| 207 | t.Fatalf("archive/sys/keep mismatch: archive=%q/%q sys=%q/%q keep=%v/%v", |
| 208 | a.archiveDir, b.archiveDir, a.sysPrompt, b.sysPrompt, a.keepPolicy, b.keepPolicy) |
| 209 | } |
| 210 | if a.gate != b.gate { |
| 211 | t.Fatalf("gate mismatch") |
| 212 | } |
| 213 | if a.subagentModel != b.subagentModel || a.subagentEffort != b.subagentEffort { |
| 214 | t.Fatalf("model/effort mismatch: %q/%q vs %q/%q", a.subagentModel, a.subagentEffort, b.subagentModel, b.subagentEffort) |
| 215 | } |
| 216 | // Function pointers are compared by identity for the same options value. |
| 217 | if reflect.ValueOf(a.resolveProvider).Pointer() != reflect.ValueOf(b.resolveProvider).Pointer() { |
| 218 | t.Fatalf("resolveProvider identity mismatch") |
| 219 | } |
| 220 | if a.maxSubagentDepth != b.maxSubagentDepth { |
| 221 | t.Fatalf("maxSubagentDepth = %d/%d", a.maxSubagentDepth, b.maxSubagentDepth) |
| 222 | } |
| 223 | } |
| 224 |