返回 DeepSeek-Reasonix
cancel_test.go
根目录 / internal / agent / cancel_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "reasonix/internal/agent/testutil"
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 "reasonix/internal/tool"
17 )
18
19 // slowTool is a tool that takes a noticeable amount of time to execute,
20 // simulating a long-running bash command or other blocking operation.
21 type slowTool struct{}
22
23 func (slowTool) Name() string { return "slow_tool" }
24
25 func (slowTool) Description() string { return "A tool that executes slowly" }
26
27 func (slowTool) Schema() json.RawMessage {
28 return json.RawMessage(`{"type":"object","properties":{"duration_ms":{"type":"number","description":"How long to sleep in milliseconds"}},"required":["duration_ms"]}`)
29 }
30
31 func (slowTool) ReadOnly() bool { return false }
32
33 func (slowTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
34 var p struct {
35 DurationMs int `json:"duration_ms"`
36 }
37 if err := json.Unmarshal(args, &p); err != nil {
38 return "", err
39 }
40 if p.DurationMs <= 0 {
41 p.DurationMs = 500
42 }
43
44 // Simulate work that respects context cancellation
45 select {
46 case <-time.After(time.Duration(p.DurationMs) * time.Millisecond):
47 return "done", nil
48 case <-ctx.Done():
49 return "", ctx.Err()
50 }
51 }
52
53 // trackingTool is a tool that records when it was executed and can simulate delays.
54 type trackingTool struct {
55 name string
56 readOnly bool
57 }
58
59 func (t trackingTool) Name() string {
60 if t.name != "" {
61 return t.name
62 }
63 return "tracking"
64 }
65 func (trackingTool) Description() string { return "Tracks execution" }
66 func (trackingTool) Schema() json.RawMessage {
67 return json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"},"delay_ms":{"type":"number"},"should_fail":{"type":"boolean"}},"required":["name"]}`)
68 }
69 func (t trackingTool) ReadOnly() bool { return t.readOnly }
70 func (trackingTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
71 var p struct {
72 Name string `json:"name"`
73 DelayMs int `json:"delay_ms"`
74 ShouldFail bool `json:"should_fail"`
75 }
76 if err := json.Unmarshal(args, &p); err != nil {
77 return "", err
78 }
79
80 executedMu.Lock()
81 executed = append(executed, p.Name+"_start")
82 executedMu.Unlock()
83
84 if p.ShouldFail {
85 return "", context.Canceled
86 }
87
88 // Simulate work that respects context cancellation
89 if p.DelayMs > 0 {
90 select {
91 case <-time.After(time.Duration(p.DelayMs) * time.Millisecond):
92 // Completed the delay successfully
93 case <-ctx.Done():
94 executedMu.Lock()
95 executed = append(executed, p.Name+"_cancelled")
96 executedMu.Unlock()
97 return "", ctx.Err()
98 }
99 }
100
101 executedMu.Lock()
102 executed = append(executed, p.Name+"_done")
103 executedMu.Unlock()
104
105 return p.Name + " done", nil
106 }
107
108 // Global variables for tracking across tests
109 var (
110 executedMu sync.Mutex
111 executed []string
112 )
113
114 type stuckStreamProvider struct{}
115
116 func (stuckStreamProvider) Name() string { return "stuck-stream" }
117
118 func (stuckStreamProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
119 return make(chan provider.Chunk), nil
120 }
121
122 type closedStreamProvider struct{}
123
124 func (closedStreamProvider) Name() string { return "closed-stream" }
125
126 func (closedStreamProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
127 ch := make(chan provider.Chunk)
128 close(ch)
129 return ch, nil
130 }
131
132 func TestCanceledContextClosedProviderStreamReturnsCancel(t *testing.T) {
133 for i := 0; i < 100; i++ {
134 ctx, cancel := context.WithCancel(context.Background())
135 cancel()
136
137 a := New(closedStreamProvider{}, tool.NewRegistry(), NewSession(""), Options{}, &recordSink{})
138 err := a.Run(ctx, "already cancelled")
139 if !errors.Is(err, context.Canceled) {
140 t.Fatalf("Run error on iteration %d = %v, want context cancellation", i, err)
141 }
142 }
143 }
144
145 func TestCancelDuringStuckProviderStreamReturnsPromptly(t *testing.T) {
146 a := New(stuckStreamProvider{}, tool.NewRegistry(), NewSession(""), Options{}, &recordSink{})
147
148 ctx, cancel := context.WithCancel(context.Background())
149 done := make(chan error, 1)
150 go func() {
151 done <- a.Run(ctx, "wait on provider")
152 }()
153
154 time.Sleep(50 * time.Millisecond)
155 cancel()
156
157 select {
158 case err := <-done:
159 if err == nil {
160 t.Fatal("Run returned nil after context cancellation")
161 }
162 if !errors.Is(err, context.Canceled) {
163 t.Fatalf("Run error = %v, want context cancellation", err)
164 }
165 case <-time.After(500 * time.Millisecond):
166 t.Fatal("Run did not return promptly after provider stream context cancellation")
167 }
168 }
169
170 type activeReasoningUntilCancelProvider struct{}
171
172 func (activeReasoningUntilCancelProvider) Name() string { return "active-reasoning" }
173
174 func (p activeReasoningUntilCancelProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
175 ch := make(chan provider.Chunk)
176 go func() {
177 defer close(ch)
178 for offset := 224; ; offset += 4 {
179 select {
180 case <-ctx.Done():
181 return
182 case ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: fmt.Sprintf("%d unknown\n", offset)}:
183 }
184 }
185 }()
186 return ch, nil
187 }
188
189 type reasoningGuardCancelProvider struct {
190 canceled chan struct{}
191 }
192
193 func (reasoningGuardCancelProvider) Name() string { return "reasoning-guard-cancel" }
194
195 func (p reasoningGuardCancelProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
196 ch := make(chan provider.Chunk)
197 go func() {
198 defer close(ch)
199 defer close(p.canceled)
200 for {
201 select {
202 case <-ctx.Done():
203 return
204 case ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: "0123456789abcdef"}:
205 }
206 }
207 }()
208 return ch, nil
209 }
210
211 func TestRunawayReasoningStopsAtAgentSideByteGuard(t *testing.T) {
212 sink := &recordSink{}
213 a := New(activeReasoningUntilCancelProvider{}, tool.NewRegistry(), NewSession(""), Options{ReasoningByteLimit: 64}, sink)
214
215 ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
216 defer cancel()
217
218 err := a.Run(ctx, "parse this binary by offset")
219 if !errors.Is(err, errReasoningByteLimitExceeded) {
220 t.Fatalf("Run error = %v, want reasoning limit guard", err)
221 }
222 if got := len(sink.kinds(event.Reasoning)); got == 0 {
223 t.Fatal("no reasoning chunks emitted; repro did not exercise the active-output path")
224 }
225 usages := sink.kinds(event.Usage)
226 if len(usages) != 1 {
227 t.Fatalf("usage events = %d, want one best-effort usage event", len(usages))
228 }
229 if u := usages[0].Usage; u == nil || u.FinishReason != "client_reasoning_limit" || !u.Estimated || u.TotalTokens <= 0 || u.ReasoningTokens <= 0 {
230 t.Fatalf("usage = %+v, want client reasoning limit with estimated reasoning tokens", u)
231 }
232 }
233
234 func TestReasoningByteGuardCancelsProviderStream(t *testing.T) {
235 canceled := make(chan struct{})
236 a := New(reasoningGuardCancelProvider{canceled: canceled}, tool.NewRegistry(), NewSession(""), Options{ReasoningByteLimit: 32}, event.Discard)
237
238 if err := a.Run(context.Background(), "trigger the reasoning guard"); !errors.Is(err, errReasoningByteLimitExceeded) {
239 t.Fatalf("Run error = %v, want reasoning limit guard", err)
240 }
241 select {
242 case <-canceled:
243 case <-time.After(time.Second):
244 t.Fatal("provider context remained live after the reasoning guard returned")
245 }
246 }
247
248 func TestInterruptedReasoningEmitsBestEffortUsage(t *testing.T) {
249 sink := &recordSink{}
250 a := New(activeReasoningUntilCancelProvider{}, tool.NewRegistry(), NewSession(""), Options{}, sink)
251
252 ctx, cancel := context.WithCancel(context.Background())
253 done := make(chan error, 1)
254 go func() {
255 done <- a.Run(ctx, "parse this binary by offset")
256 }()
257
258 deadline := time.After(500 * time.Millisecond)
259 for len(sink.kinds(event.Reasoning)) == 0 {
260 select {
261 case <-deadline:
262 t.Fatal("timed out waiting for streamed reasoning")
263 default:
264 time.Sleep(time.Millisecond)
265 }
266 }
267 cancel()
268
269 select {
270 case err := <-done:
271 if !errors.Is(err, context.Canceled) {
272 t.Fatalf("Run error = %v, want context cancellation", err)
273 }
274 case <-time.After(500 * time.Millisecond):
275 t.Fatal("Run did not return after cancellation")
276 }
277
278 usages := sink.kinds(event.Usage)
279 if len(usages) != 1 {
280 t.Fatalf("usage events = %d, want one best-effort usage event", len(usages))
281 }
282 if u := usages[0].Usage; u == nil || u.FinishReason != "interrupted" || !u.Estimated || u.TotalTokens <= 0 || u.ReasoningTokens <= 0 {
283 t.Fatalf("usage = %+v, want interrupted finish with estimated reasoning tokens", u)
284 }
285 }
286
287 func TestReasoningByteGuardDoesNotSetProviderOutputBudget(t *testing.T) {
288 tests := []struct {
289 name string
290 limit int
291 }{
292 {name: "default"},
293 {name: "custom", limit: 65},
294 {name: "disabled", limit: -1},
295 }
296 for _, tt := range tests {
297 t.Run(tt.name, func(t *testing.T) {
298 prov := testutil.NewMock("m", testutil.Turn{Text: "done"})
299 a := New(prov, tool.NewRegistry(), NewSession(""), Options{ReasoningByteLimit: tt.limit}, event.Discard)
300 if err := a.Run(context.Background(), "go"); err != nil {
301 t.Fatal(err)
302 }
303 req := prov.LastRequest()
304 if req == nil || req.MaxTokens != 0 {
305 t.Fatalf("request = %+v, reasoning bytes must not become a total output budget", req)
306 }
307 })
308 }
309
310 t.Run("stable across tool loop", func(t *testing.T) {
311 prov := testutil.NewMock("m",
312 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "read", Arguments: `{}`}}},
313 testutil.Turn{Text: "done"},
314 )
315 registry := tool.NewRegistry()
316 registry.Add(fakeTool{name: "read", readOnly: true})
317 a := New(prov, registry, NewSession(""), Options{MaxOutputTokens: 8192}, event.Discard)
318 if err := a.Run(context.Background(), "go"); err != nil {
319 t.Fatal(err)
320 }
321 requests := prov.Requests()
322 if len(requests) != 2 {
323 t.Fatalf("requests = %d, want two provider turns", len(requests))
324 }
325 for i, req := range requests {
326 if req.MaxTokens != 8192 {
327 t.Fatalf("request %d max_tokens = %d, want stable 8192", i+1, req.MaxTokens)
328 }
329 }
330 })
331 }
332
333 func TestBestEffortStreamUsageMarksOnlySyntheticCountsEstimated(t *testing.T) {
334 exact := &provider.Usage{PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30, ReasoningTokens: 15}
335 got := bestEffortStreamUsage(exact, 4, 4, "interrupted")
336 if got.Estimated {
337 t.Fatalf("usage = %+v, exact counts should remain exact", got)
338 }
339 if got.FinishReason != "interrupted" {
340 t.Fatalf("finish reason = %q, want interrupted", got.FinishReason)
341 }
342
343 got = bestEffortStreamUsage(exact, 200, 400, "interrupted")
344 if !got.Estimated || got.CompletionTokens != 150 || got.ReasoningTokens != 100 || got.TotalTokens != 160 {
345 t.Fatalf("usage = %+v, want byte-derived estimates", got)
346 }
347 }
348
349 // TestCancelDuringToolExecutionBreaksOutPromptly verifies that when the context
350 // is cancelled while tools are executing, the agent loop breaks out immediately
351 // rather than continuing to execute remaining tools.
352 func TestCancelDuringToolExecutionBreaksOutPromptly(t *testing.T) {
353 reg := tool.NewRegistry()
354 reg.Add(slowTool{})
355
356 // Script: first turn calls two slow tools, but we'll cancel after the first starts
357 mp := testutil.NewMock("m",
358 testutil.Turn{
359 Text: "",
360 ToolCalls: []provider.ToolCall{
361 {ID: "call-1", Name: "slow_tool", Arguments: `{"duration_ms": 2000}`}, // 2 second tool
362 {ID: "call-2", Name: "slow_tool", Arguments: `{"duration_ms": 2000}`}, // another 2 second tool
363 },
364 },
365 )
366
367 sink := &recordSink{}
368 a := New(mp, reg, NewSession(""), Options{}, sink)
369
370 // Create a cancellable context and cancel it shortly after starting
371 ctx, cancel := context.WithCancel(context.Background())
372
373 start := time.Now()
374 done := make(chan error, 1)
375 go func() {
376 done <- a.Run(ctx, "test cancel during tool execution")
377 }()
378
379 // Cancel after a short delay to simulate user pressing Esc mid-execution
380 go func() {
381 time.Sleep(300 * time.Millisecond)
382 cancel()
383 }()
384
385 // Wait for the run to complete (should be fast due to cancel, not 4+ seconds)
386 var err error
387 select {
388 case err = <-done:
389 case <-time.After(5 * time.Second):
390 t.Fatal("Run did not complete within 5s after cancel — context cancellation did not interrupt tool execution")
391 }
392
393 elapsed := time.Since(start)
394
395 // Should have run until the cancel (~300ms) but not completed both tools (4s+)
396 if elapsed < 250*time.Millisecond {
397 t.Fatalf("command exited too fast (%v) — cancel didn't actually interrupt execution; err=%v", elapsed, err)
398 }
399 if elapsed > 2*time.Second {
400 t.Fatalf("cancel took too long (%v) — should have broken out after first tool, not waited for all tools", elapsed)
401 }
402
403 // The error should be related to context cancellation
404 if err == nil {
405 t.Log("Run returned nil error after cancel (acceptable if tools detected ctx.Done)")
406 } else {
407 t.Logf("Run returned error after cancel: %v (elapsed: %v)", err, elapsed)
408 }
409 }
410
411 // TestCancelDuringBatchStopsRemainingTools verifies that when context is
412 // cancelled during a batch of tool executions, remaining tools are not executed.
413 func TestCancelDuringBatchStopsRemainingTools(t *testing.T) {
414 // Reset tracking
415 executedMu.Lock()
416 executed = nil
417 executedMu.Unlock()
418
419 reg := tool.NewRegistry()
420 reg.Add(trackingTool{})
421
422 // Script: model wants to execute three tools in sequence
423 mp := testutil.NewMock("m",
424 testutil.Turn{
425 Text: "",
426 ToolCalls: []provider.ToolCall{
427 {ID: "call-1", Name: "tracking", Arguments: `{"name": "tool1", "delay_ms": 50}`},
428 {ID: "call-2", Name: "tracking", Arguments: `{"name": "tool2", "delay_ms": 5000}`}, // Long-running tool
429 {ID: "call-3", Name: "tracking", Arguments: `{"name": "tool3", "delay_ms": 50}`},
430 },
431 },
432 )
433
434 sink := &recordSink{}
435 a := New(mp, reg, NewSession(""), Options{}, sink)
436
437 ctx, cancel := context.WithCancel(context.Background())
438 defer cancel()
439
440 done := make(chan error, 1)
441 go func() {
442 done <- a.Run(ctx, "test batch cancel")
443 }()
444
445 // Cancel while tool2 is still running (after tool1 completes but during tool2)
446 go func() {
447 time.Sleep(300 * time.Millisecond)
448 cancel()
449 }()
450
451 var err error
452 select {
453 case err = <-done:
454 case <-time.After(10 * time.Second):
455 t.Fatal("Run did not complete within 10s")
456 }
457
458 executedMu.Lock()
459 executedCopy := make([]string, len(executed))
460 copy(executedCopy, executed)
461 executedMu.Unlock()
462
463 t.Logf("Executed tools: %v (err=%v)", executedCopy, err)
464
465 // We expect tool1 to have completed, tool2 to have been cancelled mid-execution,
466 // and tool3 to NOT have started at all due to our ctx.Err() check after each tool.
467 if len(executedCopy) < 2 { // At least tool1_start should be there
468 t.Error("Expected at least one tool to start execution")
469 }
470
471 // Check that tool3 never started
472 for _, name := range executedCopy {
473 if strings.HasPrefix(name, "tool3") {
474 t.Error("tool3 should not have executed after cancel interrupted the batch")
475 }
476 }
477
478 // Verify tool2 was cancelled
479 foundTool2Cancelled := false
480 for _, name := range executedCopy {
481 if name == "tool2_cancelled" {
482 foundTool2Cancelled = true
483 }
484 }
485 if !foundTool2Cancelled {
486 t.Log("Note: tool2 may have completed or been cancelled - check timing")
487 }
488
489 toolsByID := toolMessagesByID(a.Session().Messages)
490 if got := toolsByID["call-1"]; !strings.Contains(got, "tool1 done") {
491 t.Fatalf("completed tool result was not persisted before cancellation: %q", got)
492 }
493 if got := toolsByID["call-3"]; !strings.Contains(got, "cancelled") {
494 t.Fatalf("skipped tool result was not persisted as cancelled: %q", got)
495 }
496 }
497
498 // TestCancelBeforeParallelBatchSkipsTheWholeRemainingBatch verifies that a
499 // cancellation in a serial writer segment prevents the next read-only parallel
500 // segment from starting.
501 func TestCancelBeforeParallelBatchSkipsTheWholeRemainingBatch(t *testing.T) {
502 executedMu.Lock()
503 executed = nil
504 executedMu.Unlock()
505
506 reg := tool.NewRegistry()
507 reg.Add(trackingTool{})
508 reg.Add(trackingTool{name: "readonly_tracking", readOnly: true})
509
510 mp := testutil.NewMock("m",
511 testutil.Turn{
512 Text: "",
513 ToolCalls: []provider.ToolCall{
514 {ID: "call-1", Name: "tracking", Arguments: `{"name": "writer", "delay_ms": 5000}`},
515 {ID: "call-2", Name: "readonly_tracking", Arguments: `{"name": "read1", "delay_ms": 50}`},
516 {ID: "call-3", Name: "readonly_tracking", Arguments: `{"name": "read2", "delay_ms": 50}`},
517 },
518 },
519 )
520
521 sink := &recordSink{}
522 a := New(mp, reg, NewSession(""), Options{}, sink)
523
524 ctx, cancel := context.WithCancel(context.Background())
525 defer cancel()
526
527 done := make(chan error, 1)
528 go func() {
529 done <- a.Run(ctx, "test cancel before parallel batch")
530 }()
531 go func() {
532 time.Sleep(300 * time.Millisecond)
533 cancel()
534 }()
535
536 select {
537 case err := <-done:
538 if err == nil {
539 t.Fatal("Run returned nil, want context cancellation")
540 }
541 case <-time.After(5 * time.Second):
542 t.Fatal("Run did not complete within 5s")
543 }
544
545 executedMu.Lock()
546 executedCopy := append([]string(nil), executed...)
547 executedMu.Unlock()
548 for _, name := range executedCopy {
549 if strings.HasPrefix(name, "read") {
550 t.Fatalf("read-only parallel batch should not start after cancel, executed: %v", executedCopy)
551 }
552 }
553
554 results := sink.kinds(event.ToolResult)
555 if len(results) != 3 {
556 t.Fatalf("ToolResult events = %d, want 3", len(results))
557 }
558 for _, e := range results[1:] {
559 if e.Tool.Err == "" {
560 t.Fatalf("cancelled unstarted tool result should carry an error: %+v", e.Tool)
561 }
562 if !strings.Contains(e.Tool.Output, "cancelled") {
563 t.Fatalf("cancelled unstarted tool result should explain cancellation: %+v", e.Tool)
564 }
565 }
566 }
567
568 func TestCancelInsideLargeParallelBatchStopsSchedulingNewTools(t *testing.T) {
569 executedMu.Lock()
570 executed = nil
571 executedMu.Unlock()
572
573 reg := tool.NewRegistry()
574 reg.Add(trackingTool{name: "readonly_tracking", readOnly: true})
575
576 var calls []provider.ToolCall
577 for i := 0; i < 12; i++ {
578 calls = append(calls, provider.ToolCall{
579 ID: fmt.Sprintf("call-%02d", i),
580 Name: "readonly_tracking",
581 Arguments: fmt.Sprintf(`{"name": "read%02d", "delay_ms": 5000}`, i),
582 })
583 }
584
585 mp := testutil.NewMock("m", testutil.Turn{ToolCalls: calls})
586 a := New(mp, reg, NewSession(""), Options{}, &recordSink{})
587
588 ctx, cancel := context.WithCancel(context.Background())
589 defer cancel()
590
591 done := make(chan error, 1)
592 go func() {
593 done <- a.Run(ctx, "test cancel inside parallel batch")
594 }()
595 go func() {
596 time.Sleep(300 * time.Millisecond)
597 cancel()
598 }()
599
600 select {
601 case err := <-done:
602 if err == nil {
603 t.Fatal("Run returned nil, want context cancellation")
604 }
605 case <-time.After(5 * time.Second):
606 t.Fatal("Run did not complete within 5s")
607 }
608
609 executedMu.Lock()
610 executedCopy := append([]string(nil), executed...)
611 executedMu.Unlock()
612 for _, name := range executedCopy {
613 for i := 8; i < 12; i++ {
614 if strings.HasPrefix(name, fmt.Sprintf("read%02d", i)) {
615 t.Fatalf("parallel scheduler started a tool after cancellation: %v", executedCopy)
616 }
617 }
618 }
619
620 toolsByID := toolMessagesByID(a.Session().Messages)
621 if len(toolsByID) != len(calls) {
622 t.Fatalf("persisted tool messages = %d, want %d: %#v", len(toolsByID), len(calls), toolsByID)
623 }
624 if got := toolsByID["call-08"]; !strings.Contains(got, "cancelled") {
625 t.Fatalf("unstarted parallel tool result was not persisted as cancelled: %q", got)
626 }
627 }
628
629 func toolMessagesByID(msgs []provider.Message) map[string]string {
630 out := make(map[string]string)
631 for _, m := range msgs {
632 if m.Role == provider.RoleTool && !m.LocalOnly {
633 out[m.ToolCallID] = m.Content
634 }
635 }
636 return out
637 }
638
638 lines GO