返回 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 started chan struct{}
116 once sync.Once
117 }
118
119 func (*stuckStreamProvider) Name() string { return "stuck-stream" }
120
121 func (p *stuckStreamProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
122 p.once.Do(func() {
123 if p.started != nil {
124 close(p.started)
125 }
126 })
127 return make(chan provider.Chunk), nil
128 }
129
130 type closedStreamProvider struct{}
131
132 func (closedStreamProvider) Name() string { return "closed-stream" }
133
134 func (closedStreamProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
135 ch := make(chan provider.Chunk)
136 close(ch)
137 return ch, nil
138 }
139
140 func TestCanceledContextClosedProviderStreamReturnsCancel(t *testing.T) {
141 for i := range 100 {
142 ctx, cancel := context.WithCancel(context.Background())
143 cancel()
144
145 a := New(closedStreamProvider{}, tool.NewRegistry(), NewSession(""), Options{}, &recordSink{})
146 err := a.Run(ctx, "already cancelled")
147 if !errors.Is(err, context.Canceled) {
148 t.Fatalf("Run error on iteration %d = %v, want context cancellation", i, err)
149 }
150 }
151 }
152
153 func TestCancelDuringStuckProviderStreamReturns(t *testing.T) {
154 prov := &stuckStreamProvider{started: make(chan struct{})}
155 a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, &recordSink{})
156
157 ctx, cancel := context.WithCancel(context.Background())
158 done := make(chan error, 1)
159 go func() {
160 done <- a.Run(ctx, "wait on provider")
161 }()
162
163 select {
164 case <-prov.started:
165 case <-time.After(5 * time.Second):
166 t.Fatal("provider stream did not start")
167 }
168 cancel()
169
170 select {
171 case err := <-done:
172 if err == nil {
173 t.Fatal("Run returned nil after context cancellation")
174 }
175 if !errors.Is(err, context.Canceled) {
176 t.Fatalf("Run error = %v, want context cancellation", err)
177 }
178 case <-time.After(5 * time.Second):
179 t.Fatal("Run did not return after provider stream context cancellation")
180 }
181 }
182
183 func TestTaskToolCancelDuringStuckProviderReturns(t *testing.T) {
184 prov := &stuckStreamProvider{started: make(chan struct{})}
185 task := newTestTaskTool(t, prov, tool.NewRegistry(), "sys", "", "", nil)
186
187 ctx, cancel := context.WithCancel(testTaskContext())
188 done := make(chan error, 1)
189 go func() {
190 _, err := task.Execute(ctx, []byte(`{"prompt":"wait on stuck provider"}`))
191 done <- err
192 }()
193
194 select {
195 case <-prov.started:
196 case <-time.After(5 * time.Second):
197 t.Fatal("provider stream did not start")
198 }
199 cancel()
200
201 select {
202 case err := <-done:
203 if err == nil {
204 t.Fatal("Execute returned nil after context cancellation")
205 }
206 if !errors.Is(err, context.Canceled) {
207 t.Fatalf("Execute error = %v, want context cancellation", err)
208 }
209 case <-time.After(5 * time.Second):
210 t.Fatal("TaskTool.Execute did not return after cancellation")
211 }
212 }
213
214 type activeReasoningUntilCancelProvider struct{}
215
216 func (activeReasoningUntilCancelProvider) Name() string { return "active-reasoning" }
217
218 func (p activeReasoningUntilCancelProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
219 ch := make(chan provider.Chunk)
220 go func() {
221 defer close(ch)
222 for offset := 224; ; offset += 4 {
223 select {
224 case <-ctx.Done():
225 return
226 case ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: fmt.Sprintf("%d unknown\n", offset)}:
227 }
228 }
229 }()
230 return ch, nil
231 }
232
233 type finiteReasoningThenTextProvider struct {
234 canceled chan struct{}
235 reasoning, text string
236 finished bool
237 }
238
239 func (finiteReasoningThenTextProvider) Name() string { return "finite-reasoning" }
240
241 func (p *finiteReasoningThenTextProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
242 ch := make(chan provider.Chunk)
243 go func() {
244 defer close(ch)
245 defer close(p.canceled)
246 select {
247 case <-ctx.Done():
248 return
249 case ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: p.reasoning}:
250 }
251 select {
252 case <-ctx.Done():
253 return
254 case ch <- provider.Chunk{Type: provider.ChunkText, Text: p.text}:
255 }
256 select {
257 case <-ctx.Done():
258 return
259 case ch <- provider.Chunk{Type: provider.ChunkDone}:
260 p.finished = true
261 }
262 }()
263 return ch, nil
264 }
265
266 func TestReasoningByteGuardDoesNotAbortTurn(t *testing.T) {
267 sink := &recordSink{}
268 reasoning := strings.Repeat("abcd", 64)
269 prov := testutil.NewMock("m", testutil.Turn{Reasoning: reasoning, Text: "svg done"})
270 a := New(prov, tool.NewRegistry(), NewSession(""), Options{ReasoningByteLimit: 32}, sink)
271
272 if err := a.Run(context.Background(), "draw the compound bow"); err != nil {
273 t.Fatalf("Run error = %v, byte guard must not fail the turn", err)
274 }
275 if got := sink.kinds(event.Text); len(got) == 0 || !strings.Contains(got[0].Text, "svg done") {
276 t.Fatal("visible answer was dropped after the reasoning buffer cap")
277 }
278 for _, notice := range sink.kinds(event.Notice) {
279 if strings.Contains(notice.Text, "client reasoning safety limit") {
280 t.Fatalf("unexpected abort notice %q", notice.Text)
281 }
282 }
283 }
284
285 func TestDefaultReasoningGuardAllowsFormer128KiBStream(t *testing.T) {
286 // 128KiB is ~32K estimated tokens — a legitimate DeepSeek V4 Pro think.
287 reasoning := strings.Repeat("abcd", 128*1024/4+1)
288 prov := testutil.NewMock("m", testutil.Turn{Reasoning: reasoning, Text: "svg done"})
289 a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
290 if err := a.Run(context.Background(), "draw the compound bow"); err != nil {
291 t.Fatal(err)
292 }
293 }
294
295 func TestReasoningByteGuardDoesNotCancelProviderStream(t *testing.T) {
296 canceled := make(chan struct{})
297 prov := &finiteReasoningThenTextProvider{canceled: canceled, reasoning: strings.Repeat("x", 64), text: "done"}
298 a := New(prov, tool.NewRegistry(), NewSession(""), Options{ReasoningByteLimit: 16}, event.Discard)
299
300 if err := a.Run(context.Background(), "keep generating"); err != nil {
301 t.Fatalf("Run error = %v, byte guard must not cancel the provider", err)
302 }
303 select {
304 case <-canceled:
305 case <-time.After(time.Second):
306 t.Fatal("provider stream did not finish after the answer")
307 }
308 if !prov.finished {
309 t.Fatal("provider stream was cut off before the final text")
310 }
311 }
312
313 func TestInterruptedReasoningEmitsBestEffortUsage(t *testing.T) {
314 sink := &recordSink{}
315 a := New(activeReasoningUntilCancelProvider{}, tool.NewRegistry(), NewSession(""), Options{}, sink)
316
317 ctx, cancel := context.WithCancel(context.Background())
318 done := make(chan error, 1)
319 go func() {
320 done <- a.Run(ctx, "parse this binary by offset")
321 }()
322
323 deadline := time.After(500 * time.Millisecond)
324 for len(sink.kinds(event.Reasoning)) == 0 {
325 select {
326 case <-deadline:
327 t.Fatal("timed out waiting for streamed reasoning")
328 default:
329 time.Sleep(time.Millisecond)
330 }
331 }
332 cancel()
333
334 select {
335 case err := <-done:
336 if !errors.Is(err, context.Canceled) {
337 t.Fatalf("Run error = %v, want context cancellation", err)
338 }
339 case <-time.After(500 * time.Millisecond):
340 t.Fatal("Run did not return after cancellation")
341 }
342
343 usages := sink.kinds(event.Usage)
344 if len(usages) != 1 {
345 t.Fatalf("usage events = %d, want one best-effort usage event", len(usages))
346 }
347 if u := usages[0].Usage; u == nil || u.FinishReason != "interrupted" || !u.Estimated || u.TotalTokens <= 0 || u.ReasoningTokens <= 0 {
348 t.Fatalf("usage = %+v, want interrupted finish with estimated reasoning tokens", u)
349 }
350 }
351
352 func TestReasoningByteGuardDoesNotSetProviderOutputBudget(t *testing.T) {
353 tests := []struct {
354 name string
355 limit int
356 }{
357 {name: "default"},
358 {name: "custom", limit: 65},
359 {name: "disabled", limit: -1},
360 }
361 for _, tt := range tests {
362 t.Run(tt.name, func(t *testing.T) {
363 prov := testutil.NewMock("m", testutil.Turn{Text: "done"})
364 a := New(prov, tool.NewRegistry(), NewSession(""), Options{ReasoningByteLimit: tt.limit}, event.Discard)
365 if err := a.Run(context.Background(), "go"); err != nil {
366 t.Fatal(err)
367 }
368 req := prov.LastRequest()
369 if req == nil || req.MaxTokens != 0 {
370 t.Fatalf("request = %+v, reasoning bytes must not become a total output budget", req)
371 }
372 })
373 }
374
375 t.Run("stable across tool loop", func(t *testing.T) {
376 prov := testutil.NewMock("m",
377 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "read", Arguments: `{}`}}},
378 testutil.Turn{Text: "done"},
379 )
380 registry := tool.NewRegistry()
381 registry.Add(fakeTool{name: "read", readOnly: true})
382 a := New(prov, registry, NewSession(""), Options{MaxOutputTokens: 8192}, event.Discard)
383 if err := a.Run(context.Background(), "go"); err != nil {
384 t.Fatal(err)
385 }
386 requests := prov.Requests()
387 if len(requests) != 2 {
388 t.Fatalf("requests = %d, want two provider turns", len(requests))
389 }
390 for i, req := range requests {
391 if req.MaxTokens != 8192 {
392 t.Fatalf("request %d max_tokens = %d, want stable 8192", i+1, req.MaxTokens)
393 }
394 }
395 })
396 }
397
398 func TestBestEffortStreamUsageMarksOnlySyntheticCountsEstimated(t *testing.T) {
399 exact := &provider.Usage{PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30, ReasoningTokens: 15}
400 got := bestEffortStreamUsage(exact, 4, 4, "interrupted")
401 if got.Estimated {
402 t.Fatalf("usage = %+v, exact counts should remain exact", got)
403 }
404 if got.FinishReason != "interrupted" {
405 t.Fatalf("finish reason = %q, want interrupted", got.FinishReason)
406 }
407
408 got = bestEffortStreamUsage(exact, 200, 400, "interrupted")
409 if !got.Estimated || got.CompletionTokens != 150 || got.ReasoningTokens != 100 || got.TotalTokens != 160 {
410 t.Fatalf("usage = %+v, want byte-derived estimates", got)
411 }
412 }
413
414 // TestCancelDuringToolExecutionBreaksOutPromptly verifies that when the context
415 // is cancelled while tools are executing, the agent loop breaks out immediately
416 // rather than continuing to execute remaining tools.
417 func TestCancelDuringToolExecutionBreaksOutPromptly(t *testing.T) {
418 reg := tool.NewRegistry()
419 reg.Add(slowTool{})
420
421 // Script: first turn calls two slow tools, but we'll cancel after the first starts
422 mp := testutil.NewMock("m",
423 testutil.Turn{
424 Text: "",
425 ToolCalls: []provider.ToolCall{
426 {ID: "call-1", Name: "slow_tool", Arguments: `{"duration_ms": 2000}`}, // 2 second tool
427 {ID: "call-2", Name: "slow_tool", Arguments: `{"duration_ms": 2000}`}, // another 2 second tool
428 },
429 },
430 )
431
432 sink := &recordSink{}
433 a := New(mp, reg, NewSession(""), Options{}, sink)
434
435 // Create a cancellable context and cancel it shortly after starting
436 ctx, cancel := context.WithCancel(context.Background())
437
438 start := time.Now()
439 done := make(chan error, 1)
440 go func() {
441 done <- a.Run(withNoClosedLoop(ctx), "test cancel during tool execution")
442 }()
443
444 // Cancel after a short delay to simulate user pressing Esc mid-execution
445 go func() {
446 time.Sleep(300 * time.Millisecond)
447 cancel()
448 }()
449
450 // Wait for the run to complete (should be fast due to cancel, not 4+ seconds)
451 var err error
452 select {
453 case err = <-done:
454 case <-time.After(5 * time.Second):
455 t.Fatal("Run did not complete within 5s after cancel — context cancellation did not interrupt tool execution")
456 }
457
458 elapsed := time.Since(start)
459
460 // Should have run until the cancel (~300ms) but not completed both tools (4s+)
461 if elapsed < 250*time.Millisecond {
462 t.Fatalf("command exited too fast (%v) — cancel didn't actually interrupt execution; err=%v", elapsed, err)
463 }
464 if elapsed > 2*time.Second {
465 t.Fatalf("cancel took too long (%v) — should have broken out after first tool, not waited for all tools", elapsed)
466 }
467
468 // The error should be related to context cancellation
469 if err == nil {
470 t.Log("Run returned nil error after cancel (acceptable if tools detected ctx.Done)")
471 } else {
472 t.Logf("Run returned error after cancel: %v (elapsed: %v)", err, elapsed)
473 }
474 }
475
476 // TestCancelDuringBatchStopsRemainingTools verifies that when context is
477 // cancelled during a batch of tool executions, remaining tools are not executed.
478 func TestCancelDuringBatchStopsRemainingTools(t *testing.T) {
479 // Reset tracking
480 executedMu.Lock()
481 executed = nil
482 executedMu.Unlock()
483
484 reg := tool.NewRegistry()
485 reg.Add(trackingTool{})
486
487 // Script: model wants to execute three tools in sequence
488 mp := testutil.NewMock("m",
489 testutil.Turn{
490 Text: "",
491 ToolCalls: []provider.ToolCall{
492 {ID: "call-1", Name: "tracking", Arguments: `{"name": "tool1", "delay_ms": 50}`},
493 {ID: "call-2", Name: "tracking", Arguments: `{"name": "tool2", "delay_ms": 5000}`}, // Long-running tool
494 {ID: "call-3", Name: "tracking", Arguments: `{"name": "tool3", "delay_ms": 50}`},
495 },
496 },
497 )
498
499 sink := &recordSink{}
500 a := New(mp, reg, NewSession(""), Options{}, sink)
501
502 ctx, cancel := context.WithCancel(context.Background())
503 defer cancel()
504
505 done := make(chan error, 1)
506 go func() {
507 done <- a.Run(withNoClosedLoop(ctx), "test batch cancel")
508 }()
509
510 // Cancel while tool2 is still running (after tool1 completes but during tool2)
511 go func() {
512 time.Sleep(300 * time.Millisecond)
513 cancel()
514 }()
515
516 var err error
517 select {
518 case err = <-done:
519 case <-time.After(10 * time.Second):
520 t.Fatal("Run did not complete within 10s")
521 }
522
523 executedMu.Lock()
524 executedCopy := make([]string, len(executed))
525 copy(executedCopy, executed)
526 executedMu.Unlock()
527
528 t.Logf("Executed tools: %v (err=%v)", executedCopy, err)
529
530 // We expect tool1 to have completed, tool2 to have been cancelled mid-execution,
531 // and tool3 to NOT have started at all due to our ctx.Err() check after each tool.
532 if len(executedCopy) < 2 { // At least tool1_start should be there
533 t.Error("Expected at least one tool to start execution")
534 }
535
536 // Check that tool3 never started
537 for _, name := range executedCopy {
538 if strings.HasPrefix(name, "tool3") {
539 t.Error("tool3 should not have executed after cancel interrupted the batch")
540 }
541 }
542
543 // Verify tool2 was cancelled
544 foundTool2Cancelled := false
545 for _, name := range executedCopy {
546 if name == "tool2_cancelled" {
547 foundTool2Cancelled = true
548 }
549 }
550 if !foundTool2Cancelled {
551 t.Log("Note: tool2 may have completed or been cancelled - check timing")
552 }
553
554 toolsByID := toolMessagesByID(a.Session().Messages)
555 if got := toolsByID["call-1"]; !strings.Contains(got, "tool1 done") {
556 t.Fatalf("completed tool result was not persisted before cancellation: %q", got)
557 }
558 if got := toolsByID["call-3"]; !strings.Contains(got, "cancelled") {
559 t.Fatalf("skipped tool result was not persisted as cancelled: %q", got)
560 }
561 }
562
563 // TestCancelBeforeParallelBatchSkipsTheWholeRemainingBatch verifies that a
564 // cancellation in a serial writer segment prevents the next read-only parallel
565 // segment from starting.
566 func TestCancelBeforeParallelBatchSkipsTheWholeRemainingBatch(t *testing.T) {
567 executedMu.Lock()
568 executed = nil
569 executedMu.Unlock()
570
571 reg := tool.NewRegistry()
572 reg.Add(trackingTool{})
573 reg.Add(trackingTool{name: "readonly_tracking", readOnly: true})
574
575 mp := testutil.NewMock("m",
576 testutil.Turn{
577 Text: "",
578 ToolCalls: []provider.ToolCall{
579 {ID: "call-1", Name: "tracking", Arguments: `{"name": "writer", "delay_ms": 5000}`},
580 {ID: "call-2", Name: "readonly_tracking", Arguments: `{"name": "read1", "delay_ms": 50}`},
581 {ID: "call-3", Name: "readonly_tracking", Arguments: `{"name": "read2", "delay_ms": 50}`},
582 },
583 },
584 )
585
586 sink := &recordSink{}
587 a := New(mp, reg, NewSession(""), Options{}, sink)
588
589 ctx, cancel := context.WithCancel(context.Background())
590 defer cancel()
591
592 done := make(chan error, 1)
593 go func() {
594 done <- a.Run(withNoClosedLoop(ctx), "test cancel before parallel batch")
595 }()
596 go func() {
597 time.Sleep(300 * time.Millisecond)
598 cancel()
599 }()
600
601 select {
602 case err := <-done:
603 if err == nil {
604 t.Fatal("Run returned nil, want context cancellation")
605 }
606 case <-time.After(5 * time.Second):
607 t.Fatal("Run did not complete within 5s")
608 }
609
610 executedMu.Lock()
611 executedCopy := append([]string(nil), executed...)
612 executedMu.Unlock()
613 for _, name := range executedCopy {
614 if strings.HasPrefix(name, "read") {
615 t.Fatalf("read-only parallel batch should not start after cancel, executed: %v", executedCopy)
616 }
617 }
618
619 results := sink.kinds(event.ToolResult)
620 if len(results) != 3 {
621 t.Fatalf("ToolResult events = %d, want 3", len(results))
622 }
623 for _, e := range results[1:] {
624 if e.Tool.Err == "" {
625 t.Fatalf("cancelled unstarted tool result should carry an error: %+v", e.Tool)
626 }
627 if !strings.Contains(e.Tool.Output, "cancelled") {
628 t.Fatalf("cancelled unstarted tool result should explain cancellation: %+v", e.Tool)
629 }
630 }
631 }
632
633 func TestCancelInsideLargeParallelBatchStopsSchedulingNewTools(t *testing.T) {
634 executedMu.Lock()
635 executed = nil
636 executedMu.Unlock()
637
638 reg := tool.NewRegistry()
639 reg.Add(trackingTool{name: "readonly_tracking", readOnly: true})
640
641 var calls []provider.ToolCall
642 for i := range 12 {
643 calls = append(calls, provider.ToolCall{
644 ID: fmt.Sprintf("call-%02d", i),
645 Name: "readonly_tracking",
646 Arguments: fmt.Sprintf(`{"name": "read%02d", "delay_ms": 5000}`, i),
647 })
648 }
649
650 mp := testutil.NewMock("m", testutil.Turn{ToolCalls: calls})
651 a := New(mp, reg, NewSession(""), Options{}, &recordSink{})
652
653 ctx, cancel := context.WithCancel(context.Background())
654 defer cancel()
655
656 done := make(chan error, 1)
657 go func() {
658 done <- a.Run(ctx, "test cancel inside parallel batch")
659 }()
660 go func() {
661 time.Sleep(300 * time.Millisecond)
662 cancel()
663 }()
664
665 select {
666 case err := <-done:
667 if err == nil {
668 t.Fatal("Run returned nil, want context cancellation")
669 }
670 case <-time.After(5 * time.Second):
671 t.Fatal("Run did not complete within 5s")
672 }
673
674 executedMu.Lock()
675 executedCopy := append([]string(nil), executed...)
676 executedMu.Unlock()
677 for _, name := range executedCopy {
678 for i := 8; i < 12; i++ {
679 if strings.HasPrefix(name, fmt.Sprintf("read%02d", i)) {
680 t.Fatalf("parallel scheduler started a tool after cancellation: %v", executedCopy)
681 }
682 }
683 }
684
685 toolsByID := toolMessagesByID(a.Session().Messages)
686 if len(toolsByID) != len(calls) {
687 t.Fatalf("persisted tool messages = %d, want %d: %#v", len(toolsByID), len(calls), toolsByID)
688 }
689 if got := toolsByID["call-08"]; !strings.Contains(got, "cancelled") {
690 t.Fatalf("unstarted parallel tool result was not persisted as cancelled: %q", got)
691 }
692 }
693
694 func toolMessagesByID(msgs []provider.Message) map[string]string {
695 out := make(map[string]string)
696 for _, m := range msgs {
697 if m.Role == provider.RoleTool && !m.LocalOnly {
698 out[m.ToolCallID] = m.Content
699 }
700 }
701 return out
702 }
703
704 func TestMissingTerminalUsageRemainsUnknownAfterEstimationAndMerge(t *testing.T) {
705 estimated := bestEffortStreamUsage(nil, 20, 40, "interrupted")
706 if estimated == nil || !estimated.Unknown || !estimated.Estimated {
707 t.Fatalf("estimated=%+v", estimated)
708 }
709 exact := &provider.Usage{PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30, RequestCount: 1}
710 combined := finalizeSamplingUsage(mergeSamplingUsage(estimated, exact), exact)
711 if !combined.Unknown || combined.RequestCount != 2 {
712 t.Fatalf("combined=%+v", combined)
713 }
714 if exact.Unknown {
715 t.Fatal("mutated exact usage")
716 }
717 }
718
718 lines GO