| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "reflect" |
| 8 | "strings" |
| 9 | "sync/atomic" |
| 10 | "testing" |
| 11 | "time" |
| 12 | "unicode/utf8" |
| 13 | |
| 14 | "reasonix/internal/event" |
| 15 | "reasonix/internal/provider" |
| 16 | "reasonix/internal/tool" |
| 17 | _ "reasonix/internal/tool/builtin" |
| 18 | ) |
| 19 | |
| 20 | // TestTruncateToolOutputUnderCap leaves small payloads alone — the cap should |
| 21 | // never rewrite content that already fits. |
| 22 | func TestTruncateToolOutputUnderCap(t *testing.T) { |
| 23 | in := strings.Repeat("a", maxToolOutputBytes) |
| 24 | got, notice := truncateToolOutput(in) |
| 25 | if got != in { |
| 26 | t.Errorf("payload at exactly the cap was rewritten") |
| 27 | } |
| 28 | if notice != "" { |
| 29 | t.Errorf("at-cap payload should not emit a notice, got %q", notice) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | // TestTruncateToolOutputHeadTail keeps head+tail of an oversize payload and |
| 34 | // inserts a marker; the notice must report the elided byte count truthfully. |
| 35 | func TestTruncateToolOutputHeadTail(t *testing.T) { |
| 36 | head := strings.Repeat("H", maxToolOutputBytes) |
| 37 | tail := strings.Repeat("T", maxToolOutputBytes) |
| 38 | in := head + tail |
| 39 | out, notice := truncateToolOutput(in) |
| 40 | if !strings.HasPrefix(out, "H") || !strings.HasSuffix(out, "T") { |
| 41 | t.Errorf("head/tail not preserved at the edges: %q…%q", out[:20], out[len(out)-20:]) |
| 42 | } |
| 43 | if !strings.Contains(out, "truncated") { |
| 44 | t.Errorf("truncation marker missing: %q", out) |
| 45 | } |
| 46 | if len(out) >= len(in) { |
| 47 | t.Errorf("output not shorter than input: in=%d out=%d", len(in), len(out)) |
| 48 | } |
| 49 | if !strings.Contains(notice, "truncated") { |
| 50 | t.Errorf("notice missing: %q", notice) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | // TestTruncateToolOutputRuneBoundaries puts multibyte runes exactly across the |
| 55 | // head and tail cut points; the result must still be valid UTF-8. |
| 56 | func TestTruncateToolOutputRuneBoundaries(t *testing.T) { |
| 57 | in := strings.Repeat("中", maxToolOutputBytes) // 3 bytes each — guarantees a cut inside a rune |
| 58 | out, _ := truncateToolOutput(in) |
| 59 | if !utf8.ValidString(out) { |
| 60 | t.Errorf("truncated output is not valid UTF-8") |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // TestFinishReasonMessage only yields a warning for abnormal terminations. |
| 65 | // Normal stops are silent (ok=false) so the per-turn line stays clean. |
| 66 | func TestFinishReasonMessage(t *testing.T) { |
| 67 | silent := []string{"", "stop", "tool_calls"} |
| 68 | for _, r := range silent { |
| 69 | if msg, ok := finishReasonMessage(&provider.Usage{FinishReason: r}); ok { |
| 70 | t.Errorf("finish_reason=%q should be silent, got %q", r, msg) |
| 71 | } |
| 72 | } |
| 73 | loud := map[string]string{ |
| 74 | "length": "max output", |
| 75 | "client_reasoning_limit": "client reasoning safety limit", |
| 76 | "content_filter": "content filter", |
| 77 | "repetition_truncation": "repetition", |
| 78 | } |
| 79 | for reason, fragment := range loud { |
| 80 | msg, ok := finishReasonMessage(&provider.Usage{FinishReason: reason}) |
| 81 | if !ok || !strings.Contains(msg, fragment) { |
| 82 | t.Errorf("finish_reason=%q: got (%q, %v), want fragment %q", reason, msg, ok, fragment) |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // TestEmptyFinalNotice keeps the user-facing line short while preserving the |
| 88 | // diagnostics that tell empty-answer causes apart in expandable details. |
| 89 | func TestEmptyFinalNotice(t *testing.T) { |
| 90 | msg := emptyFinalNotice() |
| 91 | for _, hidden := range []string{"blocked", "finish=", "reasoning="} { |
| 92 | if strings.Contains(msg, hidden) { |
| 93 | t.Errorf("notice %q should not expose internal diagnostic %q", msg, hidden) |
| 94 | } |
| 95 | } |
| 96 | detail := emptyFinalNoticeDetail("deepseek-flash", &provider.Usage{FinishReason: "stop"}, 512) |
| 97 | for _, want := range []string{"deepseek-flash", "finish=stop", "reasoning=512"} { |
| 98 | if !strings.Contains(detail, want) { |
| 99 | t.Errorf("notice detail %q missing %q", detail, want) |
| 100 | } |
| 101 | } |
| 102 | if got := emptyFinalNoticeDetail("p", nil, 0); !strings.Contains(got, "finish=unknown") { |
| 103 | t.Errorf("nil usage should report finish=unknown, got %q", got) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // --- parallel-dispatch tests --- |
| 108 | |
| 109 | // fakeTool is a minimal Tool stand-in for dispatch tests; ReadOnly is |
| 110 | // configurable and Execute sleeps a fixed duration so we can measure |
| 111 | // serial vs parallel behaviour by wall-clock. |
| 112 | type fakeTool struct { |
| 113 | name string |
| 114 | readOnly bool |
| 115 | delay time.Duration |
| 116 | err error |
| 117 | calls *int32 // shared counter to assert all dispatched |
| 118 | } |
| 119 | |
| 120 | func (f fakeTool) Name() string { return f.name } |
| 121 | func (f fakeTool) Description() string { return "" } |
| 122 | func (f fakeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } |
| 123 | func (f fakeTool) ReadOnly() bool { return f.readOnly } |
| 124 | func (f fakeTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) { |
| 125 | if f.calls != nil { |
| 126 | atomic.AddInt32(f.calls, 1) |
| 127 | } |
| 128 | select { |
| 129 | case <-time.After(f.delay): |
| 130 | case <-ctx.Done(): |
| 131 | return "", ctx.Err() |
| 132 | } |
| 133 | if f.err != nil { |
| 134 | return "", f.err |
| 135 | } |
| 136 | return f.name + " done", nil |
| 137 | } |
| 138 | |
| 139 | func TestPartitionToolCallsAllReadOnly(t *testing.T) { |
| 140 | reg := tool.NewRegistry() |
| 141 | reg.Add(fakeTool{name: "ro1", readOnly: true}) |
| 142 | reg.Add(fakeTool{name: "ro2", readOnly: true}) |
| 143 | calls := []provider.ToolCall{{Name: "ro1"}, {Name: "ro2"}} |
| 144 | got := partitionToolCalls(reg, calls) |
| 145 | want := []toolCallBatch{{start: 0, end: 2, parallel: true}} |
| 146 | if !reflect.DeepEqual(got, want) { |
| 147 | t.Fatalf("partitionToolCalls = %+v, want %+v", got, want) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // TestPartitionToolCallsSegmentsAroundWriters verifies a writer only serializes |
| 152 | // its own provider-order position; read-only runs on either side stay batchable. |
| 153 | func TestPartitionToolCallsSegmentsAroundWriters(t *testing.T) { |
| 154 | reg := tool.NewRegistry() |
| 155 | reg.Add(fakeTool{name: "ro", readOnly: true}) |
| 156 | reg.Add(fakeTool{name: "rw", readOnly: false}) |
| 157 | calls := []provider.ToolCall{{Name: "ro"}, {Name: "rw"}, {Name: "ro"}} |
| 158 | got := partitionToolCalls(reg, calls) |
| 159 | want := []toolCallBatch{ |
| 160 | {start: 0, end: 1, parallel: true}, |
| 161 | {start: 1, end: 2}, |
| 162 | {start: 2, end: 3, parallel: true}, |
| 163 | } |
| 164 | if !reflect.DeepEqual(got, want) { |
| 165 | t.Fatalf("partitionToolCalls = %+v, want %+v", got, want) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // TestPartitionToolCallsUnknownToolSerial keeps unknown-tool errors |
| 170 | // deterministic by forcing unknown calls into single-call serial batches. |
| 171 | func TestPartitionToolCallsUnknownToolSerial(t *testing.T) { |
| 172 | reg := tool.NewRegistry() |
| 173 | reg.Add(fakeTool{name: "ro", readOnly: true}) |
| 174 | calls := []provider.ToolCall{{Name: "ro"}, {Name: "vanished"}, {Name: "ro"}} |
| 175 | got := partitionToolCalls(reg, calls) |
| 176 | want := []toolCallBatch{ |
| 177 | {start: 0, end: 1, parallel: true}, |
| 178 | {start: 1, end: 2}, |
| 179 | {start: 2, end: 3, parallel: true}, |
| 180 | } |
| 181 | if !reflect.DeepEqual(got, want) { |
| 182 | t.Fatalf("partitionToolCalls = %+v, want %+v", got, want) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // TestPartitionToolCallsCompleteStepSerial verifies complete_step never joins a |
| 187 | // parallel read-only run: it reads the turn's receipts, so the prior reads must |
| 188 | // finish (and record) in an earlier batch before it runs in its own serial one. |
| 189 | func TestPartitionToolCallsCompleteStepSerial(t *testing.T) { |
| 190 | reg := tool.NewRegistry() |
| 191 | reg.Add(fakeTool{name: "read_file", readOnly: true}) |
| 192 | reg.Add(fakeTool{name: "complete_step", readOnly: true}) |
| 193 | |
| 194 | calls := []provider.ToolCall{{Name: "read_file"}, {Name: "complete_step"}} |
| 195 | got := partitionToolCalls(reg, calls) |
| 196 | want := []toolCallBatch{ |
| 197 | {start: 0, end: 1, parallel: true}, |
| 198 | {start: 1, end: 2}, |
| 199 | } |
| 200 | if !reflect.DeepEqual(got, want) { |
| 201 | t.Fatalf("partitionToolCalls = %+v, want %+v", got, want) |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | func TestPartitionToolCallsTodoWriteSerial(t *testing.T) { |
| 206 | reg := tool.NewRegistry() |
| 207 | reg.Add(fakeTool{name: "read_file", readOnly: true}) |
| 208 | reg.Add(fakeTool{name: "todo_write", readOnly: true}) |
| 209 | |
| 210 | calls := []provider.ToolCall{{Name: "read_file"}, {Name: "todo_write"}, {Name: "read_file"}} |
| 211 | got := partitionToolCalls(reg, calls) |
| 212 | want := []toolCallBatch{ |
| 213 | {start: 0, end: 1, parallel: true}, |
| 214 | {start: 1, end: 2}, |
| 215 | {start: 2, end: 3, parallel: true}, |
| 216 | } |
| 217 | if !reflect.DeepEqual(got, want) { |
| 218 | t.Fatalf("partitionToolCalls = %+v, want %+v", got, want) |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | func TestPartitionToolCallsBackgroundCollectorsSerial(t *testing.T) { |
| 223 | reg := tool.NewRegistry() |
| 224 | reg.Add(fakeTool{name: "read_file", readOnly: true}) |
| 225 | reg.Add(fakeTool{name: "wait", readOnly: true}) |
| 226 | reg.Add(fakeTool{name: "bash_output", readOnly: true}) |
| 227 | |
| 228 | calls := []provider.ToolCall{{Name: "read_file"}, {Name: "wait"}, {Name: "bash_output"}, {Name: "read_file"}} |
| 229 | got := partitionToolCalls(reg, calls) |
| 230 | want := []toolCallBatch{ |
| 231 | {start: 0, end: 1, parallel: true}, |
| 232 | {start: 1, end: 2}, |
| 233 | {start: 2, end: 3}, |
| 234 | {start: 3, end: 4, parallel: true}, |
| 235 | } |
| 236 | if !reflect.DeepEqual(got, want) { |
| 237 | t.Fatalf("partitionToolCalls = %+v, want %+v", got, want) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // TestExecuteBatchParallelReadOnly checks that three 80ms read-only calls |
| 242 | // complete in well under 3×80ms — the wall-clock proof of true parallelism. |
| 243 | func TestExecuteBatchParallelReadOnly(t *testing.T) { |
| 244 | const delay = 80 * time.Millisecond |
| 245 | calls := int32(0) |
| 246 | reg := tool.NewRegistry() |
| 247 | reg.Add(fakeTool{name: "a", readOnly: true, delay: delay, calls: &calls}) |
| 248 | reg.Add(fakeTool{name: "b", readOnly: true, delay: delay, calls: &calls}) |
| 249 | reg.Add(fakeTool{name: "c", readOnly: true, delay: delay, calls: &calls}) |
| 250 | |
| 251 | a := New(nil, reg, NewSession(""), Options{}, event.Discard) |
| 252 | |
| 253 | start := time.Now() |
| 254 | batch := a.executeBatch(context.Background(), []provider.ToolCall{{Name: "a"}, {Name: "b"}, {Name: "c"}}) |
| 255 | results := batch.results |
| 256 | elapsed := time.Since(start) |
| 257 | |
| 258 | if calls != 3 { |
| 259 | t.Errorf("dispatched %d calls, want 3", calls) |
| 260 | } |
| 261 | if len(results) != 3 || results[0] != "a done" || results[1] != "b done" || results[2] != "c done" { |
| 262 | t.Errorf("results out of order or wrong: %v", results) |
| 263 | } |
| 264 | // Allow generous slack for CI; even 2x serial would prove we got parallelism. |
| 265 | if elapsed >= 2*delay { |
| 266 | t.Errorf("read-only batch took %v (>= %v) — not parallel", elapsed, 2*delay) |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | // TestExecuteBatchSegmentsAroundWrites ensures a write call only serializes its |
| 271 | // own position in the provider-ordered batch: read-only runs before and after it |
| 272 | // may still parallelise within their contiguous segments. |
| 273 | func TestExecuteBatchSegmentsAroundWrites(t *testing.T) { |
| 274 | // A larger per-call delay keeps fixed scheduler jitter on loaded CI a small |
| 275 | // fraction of the segment time, so the tight relative bound below stays |
| 276 | // reliable instead of being widened toward the serial floor. |
| 277 | const delay = 150 * time.Millisecond |
| 278 | reg := tool.NewRegistry() |
| 279 | reg.Add(fakeTool{name: "ro1", readOnly: true, delay: delay}) |
| 280 | reg.Add(fakeTool{name: "ro2", readOnly: true, delay: delay}) |
| 281 | reg.Add(fakeTool{name: "ro3", readOnly: true, delay: delay}) |
| 282 | reg.Add(fakeTool{name: "ro4", readOnly: true, delay: delay}) |
| 283 | reg.Add(fakeTool{name: "rw", readOnly: false, delay: delay}) |
| 284 | |
| 285 | a := New(nil, reg, NewSession(""), Options{}, event.Discard) |
| 286 | |
| 287 | start := time.Now() |
| 288 | batch := a.executeBatch(context.Background(), []provider.ToolCall{ |
| 289 | {Name: "ro1"}, |
| 290 | {Name: "ro2"}, |
| 291 | {Name: "rw"}, |
| 292 | {Name: "ro3"}, |
| 293 | {Name: "ro4"}, |
| 294 | }) |
| 295 | results := batch.results |
| 296 | elapsed := time.Since(start) |
| 297 | |
| 298 | want := []string{"ro1 done", "ro2 done", "rw done", "ro3 done", "ro4 done"} |
| 299 | if len(results) != len(want) { |
| 300 | t.Fatalf("got %d results, want %d: %v", len(results), len(want), results) |
| 301 | } |
| 302 | for i := range want { |
| 303 | if results[i] != want[i] { |
| 304 | t.Fatalf("results out of order or wrong: got %v want %v", results, want) |
| 305 | } |
| 306 | } |
| 307 | // Desired shape is roughly 3*delay: (ro1|ro2), then rw, then (ro3|ro4). |
| 308 | // Old all-serial behaviour is roughly 5*delay and should fail this bound. |
| 309 | if elapsed >= 4*delay { |
| 310 | t.Errorf("mixed batch took %v (>= %v) — read-only segments did not parallelise", elapsed, 4*delay) |
| 311 | } |
| 312 | if elapsed < 2*delay { |
| 313 | t.Errorf("mixed batch took only %v — write call appears to have overlapped a read-only segment", elapsed) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | func TestExecuteBatchFeedsReceiptsToCompleteStep(t *testing.T) { |
| 318 | completeStep, ok := tool.LookupBuiltin("complete_step") |
| 319 | if !ok { |
| 320 | t.Fatal("complete_step builtin not registered") |
| 321 | } |
| 322 | reg := tool.NewRegistry() |
| 323 | reg.Add(fakeTool{name: "bash", readOnly: false}) |
| 324 | reg.Add(completeStep) |
| 325 | a := New(nil, reg, NewSession(""), Options{}, event.Discard) |
| 326 | |
| 327 | batch := a.executeBatch(context.Background(), []provider.ToolCall{ |
| 328 | {Name: "bash", Arguments: `{"command":"go test ./internal/..."}`}, |
| 329 | {Name: "complete_step", Arguments: `{ |
| 330 | "step":"Run checks", |
| 331 | "result":"checks passed", |
| 332 | "evidence":[{"kind":"verification","summary":"tests passed","command":"go test ./internal/..."}] |
| 333 | }`}, |
| 334 | }) |
| 335 | results := batch.results |
| 336 | |
| 337 | if len(results) != 2 { |
| 338 | t.Fatalf("got %d results, want 2", len(results)) |
| 339 | } |
| 340 | if !strings.Contains(results[1], "host-verified 1") { |
| 341 | t.Fatalf("complete_step did not see bash receipt: %q", results[1]) |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | func TestExecuteOneFailedReceiptDoesNotVerify(t *testing.T) { |
| 346 | reg := tool.NewRegistry() |
| 347 | reg.Add(fakeTool{name: "bash", readOnly: false, err: errors.New("boom")}) |
| 348 | a := New(nil, reg, NewSession(""), Options{}, event.Discard) |
| 349 | |
| 350 | out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: `{"command":"go test ./..."}`}) |
| 351 | if out.errMsg == "" { |
| 352 | t.Fatal("failing fake tool should return an error outcome") |
| 353 | } |
| 354 | if a.evidence.HasSuccessfulCommand("go test ./...") { |
| 355 | t.Fatal("failed bash receipt must not verify") |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | func TestRunResetsEvidenceLedger(t *testing.T) { |
| 360 | reg := tool.NewRegistry() |
| 361 | reg.Add(fakeTool{name: "bash", readOnly: false}) |
| 362 | prov := &mockProvider{name: "p", chunks: []provider.Chunk{{Type: provider.ChunkText, Text: "done"}}} |
| 363 | a := New(prov, reg, NewSession(""), Options{}, event.Discard) |
| 364 | |
| 365 | a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: `{"command":"go test ./..."}`}) |
| 366 | if !a.evidence.HasSuccessfulCommand("go test ./...") { |
| 367 | t.Fatal("setup failed to record evidence") |
| 368 | } |
| 369 | |
| 370 | if err := a.Run(context.Background(), "next turn"); err != nil { |
| 371 | t.Fatalf("Run: %v", err) |
| 372 | } |
| 373 | if a.evidence.HasSuccessfulCommand("go test ./...") { |
| 374 | t.Fatal("new user turn should not inherit previous receipts") |
| 375 | } |
| 376 | } |
| 377 |