Files
2026-07-13 13:00:08 +08:00

459 lines
13 KiB
Go

package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/agent/testutil"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// slowTool is a tool that takes a noticeable amount of time to execute,
// simulating a long-running bash command or other blocking operation.
type slowTool struct{}
func (slowTool) Name() string { return "slow_tool" }
func (slowTool) Description() string { return "A tool that executes slowly" }
func (slowTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"duration_ms":{"type":"number","description":"How long to sleep in milliseconds"}},"required":["duration_ms"]}`)
}
func (slowTool) ReadOnly() bool { return false }
func (slowTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
DurationMs int `json:"duration_ms"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", err
}
if p.DurationMs <= 0 {
p.DurationMs = 500
}
// Simulate work that respects context cancellation
select {
case <-time.After(time.Duration(p.DurationMs) * time.Millisecond):
return "done", nil
case <-ctx.Done():
return "", ctx.Err()
}
}
// trackingTool is a tool that records when it was executed and can simulate delays.
type trackingTool struct {
name string
readOnly bool
}
func (t trackingTool) Name() string {
if t.name != "" {
return t.name
}
return "tracking"
}
func (trackingTool) Description() string { return "Tracks execution" }
func (trackingTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"},"delay_ms":{"type":"number"},"should_fail":{"type":"boolean"}},"required":["name"]}`)
}
func (t trackingTool) ReadOnly() bool { return t.readOnly }
func (trackingTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
Name string `json:"name"`
DelayMs int `json:"delay_ms"`
ShouldFail bool `json:"should_fail"`
}
if err := json.Unmarshal(args, &p); err != nil {
return "", err
}
executedMu.Lock()
executed = append(executed, p.Name+"_start")
executedMu.Unlock()
if p.ShouldFail {
return "", context.Canceled
}
// Simulate work that respects context cancellation
if p.DelayMs > 0 {
select {
case <-time.After(time.Duration(p.DelayMs) * time.Millisecond):
// Completed the delay successfully
case <-ctx.Done():
executedMu.Lock()
executed = append(executed, p.Name+"_cancelled")
executedMu.Unlock()
return "", ctx.Err()
}
}
executedMu.Lock()
executed = append(executed, p.Name+"_done")
executedMu.Unlock()
return p.Name + " done", nil
}
// Global variables for tracking across tests
var (
executedMu sync.Mutex
executed []string
)
type stuckStreamProvider struct{}
func (stuckStreamProvider) Name() string { return "stuck-stream" }
func (stuckStreamProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
return make(chan provider.Chunk), nil
}
type closedStreamProvider struct{}
func (closedStreamProvider) Name() string { return "closed-stream" }
func (closedStreamProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
ch := make(chan provider.Chunk)
close(ch)
return ch, nil
}
func TestCanceledContextClosedProviderStreamReturnsCancel(t *testing.T) {
for i := 0; i < 100; i++ {
ctx, cancel := context.WithCancel(context.Background())
cancel()
a := New(closedStreamProvider{}, tool.NewRegistry(), NewSession(""), Options{}, &recordSink{})
err := a.Run(ctx, "already cancelled")
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run error on iteration %d = %v, want context cancellation", i, err)
}
}
}
func TestCancelDuringStuckProviderStreamReturnsPromptly(t *testing.T) {
a := New(stuckStreamProvider{}, tool.NewRegistry(), NewSession(""), Options{}, &recordSink{})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- a.Run(ctx, "wait on provider")
}()
time.Sleep(50 * time.Millisecond)
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("Run returned nil after context cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run error = %v, want context cancellation", err)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("Run did not return promptly after provider stream context cancellation")
}
}
// TestCancelDuringToolExecutionBreaksOutPromptly verifies that when the context
// is cancelled while tools are executing, the agent loop breaks out immediately
// rather than continuing to execute remaining tools.
func TestCancelDuringToolExecutionBreaksOutPromptly(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(slowTool{})
// Script: first turn calls two slow tools, but we'll cancel after the first starts
mp := testutil.NewMock("m",
testutil.Turn{
Text: "",
ToolCalls: []provider.ToolCall{
{ID: "call-1", Name: "slow_tool", Arguments: `{"duration_ms": 2000}`}, // 2 second tool
{ID: "call-2", Name: "slow_tool", Arguments: `{"duration_ms": 2000}`}, // another 2 second tool
},
},
)
sink := &recordSink{}
a := New(mp, reg, NewSession(""), Options{}, sink)
// Create a cancellable context and cancel it shortly after starting
ctx, cancel := context.WithCancel(context.Background())
start := time.Now()
done := make(chan error, 1)
go func() {
done <- a.Run(ctx, "test cancel during tool execution")
}()
// Cancel after a short delay to simulate user pressing Esc mid-execution
go func() {
time.Sleep(300 * time.Millisecond)
cancel()
}()
// Wait for the run to complete (should be fast due to cancel, not 4+ seconds)
var err error
select {
case err = <-done:
case <-time.After(5 * time.Second):
t.Fatal("Run did not complete within 5s after cancel — context cancellation did not interrupt tool execution")
}
elapsed := time.Since(start)
// Should have run until the cancel (~300ms) but not completed both tools (4s+)
if elapsed < 250*time.Millisecond {
t.Fatalf("command exited too fast (%v) — cancel didn't actually interrupt execution; err=%v", elapsed, err)
}
if elapsed > 2*time.Second {
t.Fatalf("cancel took too long (%v) — should have broken out after first tool, not waited for all tools", elapsed)
}
// The error should be related to context cancellation
if err == nil {
t.Log("Run returned nil error after cancel (acceptable if tools detected ctx.Done)")
} else {
t.Logf("Run returned error after cancel: %v (elapsed: %v)", err, elapsed)
}
}
// TestCancelDuringBatchStopsRemainingTools verifies that when context is
// cancelled during a batch of tool executions, remaining tools are not executed.
func TestCancelDuringBatchStopsRemainingTools(t *testing.T) {
// Reset tracking
executedMu.Lock()
executed = nil
executedMu.Unlock()
reg := tool.NewRegistry()
reg.Add(trackingTool{})
// Script: model wants to execute three tools in sequence
mp := testutil.NewMock("m",
testutil.Turn{
Text: "",
ToolCalls: []provider.ToolCall{
{ID: "call-1", Name: "tracking", Arguments: `{"name": "tool1", "delay_ms": 50}`},
{ID: "call-2", Name: "tracking", Arguments: `{"name": "tool2", "delay_ms": 5000}`}, // Long-running tool
{ID: "call-3", Name: "tracking", Arguments: `{"name": "tool3", "delay_ms": 50}`},
},
},
)
sink := &recordSink{}
a := New(mp, reg, NewSession(""), Options{}, sink)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- a.Run(ctx, "test batch cancel")
}()
// Cancel while tool2 is still running (after tool1 completes but during tool2)
go func() {
time.Sleep(300 * time.Millisecond)
cancel()
}()
var err error
select {
case err = <-done:
case <-time.After(10 * time.Second):
t.Fatal("Run did not complete within 10s")
}
executedMu.Lock()
executedCopy := make([]string, len(executed))
copy(executedCopy, executed)
executedMu.Unlock()
t.Logf("Executed tools: %v (err=%v)", executedCopy, err)
// We expect tool1 to have completed, tool2 to have been cancelled mid-execution,
// and tool3 to NOT have started at all due to our ctx.Err() check after each tool.
if len(executedCopy) < 2 { // At least tool1_start should be there
t.Error("Expected at least one tool to start execution")
}
// Check that tool3 never started
for _, name := range executedCopy {
if strings.HasPrefix(name, "tool3") {
t.Error("tool3 should not have executed after cancel interrupted the batch")
}
}
// Verify tool2 was cancelled
foundTool2Cancelled := false
for _, name := range executedCopy {
if name == "tool2_cancelled" {
foundTool2Cancelled = true
}
}
if !foundTool2Cancelled {
t.Log("Note: tool2 may have completed or been cancelled - check timing")
}
toolsByID := toolMessagesByID(a.Session().Messages)
if got := toolsByID["call-1"]; !strings.Contains(got, "tool1 done") {
t.Fatalf("completed tool result was not persisted before cancellation: %q", got)
}
if got := toolsByID["call-3"]; !strings.Contains(got, "cancelled") {
t.Fatalf("skipped tool result was not persisted as cancelled: %q", got)
}
}
// TestCancelBeforeParallelBatchSkipsTheWholeRemainingBatch verifies that a
// cancellation in a serial writer segment prevents the next read-only parallel
// segment from starting.
func TestCancelBeforeParallelBatchSkipsTheWholeRemainingBatch(t *testing.T) {
executedMu.Lock()
executed = nil
executedMu.Unlock()
reg := tool.NewRegistry()
reg.Add(trackingTool{})
reg.Add(trackingTool{name: "readonly_tracking", readOnly: true})
mp := testutil.NewMock("m",
testutil.Turn{
Text: "",
ToolCalls: []provider.ToolCall{
{ID: "call-1", Name: "tracking", Arguments: `{"name": "writer", "delay_ms": 5000}`},
{ID: "call-2", Name: "readonly_tracking", Arguments: `{"name": "read1", "delay_ms": 50}`},
{ID: "call-3", Name: "readonly_tracking", Arguments: `{"name": "read2", "delay_ms": 50}`},
},
},
)
sink := &recordSink{}
a := New(mp, reg, NewSession(""), Options{}, sink)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- a.Run(ctx, "test cancel before parallel batch")
}()
go func() {
time.Sleep(300 * time.Millisecond)
cancel()
}()
select {
case err := <-done:
if err == nil {
t.Fatal("Run returned nil, want context cancellation")
}
case <-time.After(5 * time.Second):
t.Fatal("Run did not complete within 5s")
}
executedMu.Lock()
executedCopy := append([]string(nil), executed...)
executedMu.Unlock()
for _, name := range executedCopy {
if strings.HasPrefix(name, "read") {
t.Fatalf("read-only parallel batch should not start after cancel, executed: %v", executedCopy)
}
}
results := sink.kinds(event.ToolResult)
if len(results) != 3 {
t.Fatalf("ToolResult events = %d, want 3", len(results))
}
for _, e := range results[1:] {
if e.Tool.Err == "" {
t.Fatalf("cancelled unstarted tool result should carry an error: %+v", e.Tool)
}
if !strings.Contains(e.Tool.Output, "cancelled") {
t.Fatalf("cancelled unstarted tool result should explain cancellation: %+v", e.Tool)
}
}
}
func TestCancelInsideLargeParallelBatchStopsSchedulingNewTools(t *testing.T) {
executedMu.Lock()
executed = nil
executedMu.Unlock()
reg := tool.NewRegistry()
reg.Add(trackingTool{name: "readonly_tracking", readOnly: true})
var calls []provider.ToolCall
for i := 0; i < 12; i++ {
calls = append(calls, provider.ToolCall{
ID: fmt.Sprintf("call-%02d", i),
Name: "readonly_tracking",
Arguments: fmt.Sprintf(`{"name": "read%02d", "delay_ms": 5000}`, i),
})
}
mp := testutil.NewMock("m", testutil.Turn{ToolCalls: calls})
a := New(mp, reg, NewSession(""), Options{}, &recordSink{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- a.Run(ctx, "test cancel inside parallel batch")
}()
go func() {
time.Sleep(300 * time.Millisecond)
cancel()
}()
select {
case err := <-done:
if err == nil {
t.Fatal("Run returned nil, want context cancellation")
}
case <-time.After(5 * time.Second):
t.Fatal("Run did not complete within 5s")
}
executedMu.Lock()
executedCopy := append([]string(nil), executed...)
executedMu.Unlock()
for _, name := range executedCopy {
for i := 8; i < 12; i++ {
if strings.HasPrefix(name, fmt.Sprintf("read%02d", i)) {
t.Fatalf("parallel scheduler started a tool after cancellation: %v", executedCopy)
}
}
}
toolsByID := toolMessagesByID(a.Session().Messages)
if len(toolsByID) != len(calls) {
t.Fatalf("persisted tool messages = %d, want %d: %#v", len(toolsByID), len(calls), toolsByID)
}
if got := toolsByID["call-08"]; !strings.Contains(got, "cancelled") {
t.Fatalf("unstarted parallel tool result was not persisted as cancelled: %q", got)
}
}
func toolMessagesByID(msgs []provider.Message) map[string]string {
out := make(map[string]string)
for _, m := range msgs {
if m.Role == provider.RoleTool {
out[m.ToolCallID] = m.Content
}
}
return out
}