package agent import ( "context" "errors" "strings" "testing" "time" "go-micro.dev/v6/ai" "go-micro.dev/v6/flow" "go-micro.dev/v6/store" ) func TestAskCancellationAbortsPromptly(t *testing.T) { fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) { <-ctx.Done() return nil, ctx.Err() } defer func() { fakeGen = nil }() a := newTestAgent(Name("cancel"), ModelCallTimeout(time.Second), ModelRetry(3, time.Millisecond)) ctx, cancel := context.WithCancel(context.Background()) cancel() start := time.Now() _, err := a.Ask(ctx, "stop") if !errors.Is(err, context.Canceled) { t.Fatalf("Ask error = %v, want context canceled", err) } if elapsed := time.Since(start); elapsed > 100*time.Millisecond { t.Fatalf("Ask took %s after cancellation, want prompt abort", elapsed) } } func TestAskRetriesTransientErrorsThenSucceeds(t *testing.T) { attempts := 0 fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) { attempts++ if attempts < 3 { return nil, context.DeadlineExceeded } return &ai.Response{Reply: "ok"}, nil } defer func() { fakeGen = nil }() a := newTestAgent(Name("retry-success"), ModelRetry(3, time.Millisecond)) resp, err := a.Ask(context.Background(), "hello") if err != nil { t.Fatalf("Ask returned error: %v", err) } if resp.Reply != "ok" { t.Fatalf("reply = %q, want ok", resp.Reply) } if attempts != 3 { t.Fatalf("attempts = %d, want 3", attempts) } } func TestAskRetriesTransientErrorsThenSurfacesStructuredError(t *testing.T) { attempts := 0 fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) { attempts++ return nil, context.DeadlineExceeded } defer func() { fakeGen = nil }() a := newTestAgent(Name("retry-fail"), ModelRetry(2, time.Millisecond)) _, err := a.Ask(context.Background(), "hello") var retryErr *ai.RetryError if !errors.As(err, &retryErr) { t.Fatalf("Ask error = %T %v, want *ai.RetryError", err, err) } if retryErr.Attempts != 2 { t.Fatalf("retry attempts = %d, want 2", retryErr.Attempts) } if attempts != 2 { t.Fatalf("model attempts = %d, want 2", attempts) } if !strings.Contains(err.Error(), "micro inspect agent --status timeout") || !strings.Contains(err.Error(), "docs/guides/debugging-agents.md") { t.Fatalf("Ask error = %q, want actionable timeout/debugging guidance", err.Error()) } } func TestModelRetryDoesNotDuplicateCheckpointedToolSideEffects(t *testing.T) { ctx := context.Background() cp := flow.StoreCheckpoint(store.NewMemoryStore(), "retry-tool-dedupe-agent") attempts := 0 toolRuns := 0 fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) { attempts++ if opts.ToolHandler == nil { t.Fatal("missing tool handler") } res := opts.ToolHandler(ctx, ai.ToolCall{ID: "create-1", Name: "external.create", Input: map[string]any{"title": "Retry safe"}}) if res.Content != "created Retry safe" { t.Fatalf("tool result = %q, want cached create result", res.Content) } if attempts == 1 { return nil, testStatusError{code: 503} } return &ai.Response{Reply: "done", ToolCalls: []ai.ToolCall{{ID: "create-1", Name: "external.create", Input: map[string]any{"title": "Retry safe"}, Result: res.Content}}}, nil } defer func() { fakeGen = nil }() a := newTestAgent( Name("retry-tool-dedupe-agent"), WithCheckpoint(cp), ModelRetry(2, time.Millisecond), WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) { toolRuns++ return "created Retry safe", nil }), ) resp, err := a.Ask(ctx, "create once despite a transient provider retry") if err != nil { t.Fatalf("Ask: %v", err) } if resp.Reply != "done" { t.Fatalf("reply = %q, want done", resp.Reply) } if attempts != 2 { t.Fatalf("model attempts = %d, want retry after transient provider failure", attempts) } if toolRuns != 1 { t.Fatalf("tool executions = %d, want checkpointed side effect reused across retry", toolRuns) } runs, err := cp.List(ctx) if err != nil { t.Fatalf("List: %v", err) } if len(runs) != 1 { t.Fatalf("checkpointed runs = %d, want 1", len(runs)) } if _, ok := findStep(runs[0].Steps, `tool:external.create:{"title":"Retry safe"}`); !ok { t.Fatalf("checkpoint steps = %#v, want completed external.create step", runs[0].Steps) } } func TestAskRateLimitFailureSuggestsPreflightAndInspect(t *testing.T) { fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) { return nil, testStatusError{code: 429} } defer func() { fakeGen = nil }() a := newTestAgent(Name("rate-limit-guidance"), ModelRetry(1, time.Millisecond)) _, err := a.Ask(context.Background(), "hello") if err == nil { t.Fatal("Ask succeeded, want rate-limit failure") } if !strings.Contains(err.Error(), "micro inspect agent --status rate_limited") || !strings.Contains(err.Error(), "micro agent preflight") { t.Fatalf("Ask error = %q, want inspect and preflight guidance", err.Error()) } if ai.ClassifyError(err) != ai.ErrorKindRateLimited { t.Fatalf("ClassifyError(wrapped error) = %q, want rate_limited", ai.ClassifyError(err)) } } func TestCanceledAskContextSkipsToolExecution(t *testing.T) { fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) { if opts.ToolHandler == nil { t.Fatal("missing tool handler") } canceled, cancel := context.WithCancel(ctx) cancel() res := opts.ToolHandler(canceled, ai.ToolCall{ID: "call-1", Name: toolPlan, Input: map[string]any{ "steps": []any{map[string]any{"task": "should not persist", "status": "pending"}}, }}) if !strings.Contains(res.Content, context.Canceled.Error()) { t.Fatalf("tool result = %q, want cancellation error", res.Content) } return &ai.Response{Reply: "ok"}, nil } defer func() { fakeGen = nil }() a := newTestAgent(Name("cancel-tools")) if _, err := a.Ask(context.Background(), "try a canceled tool"); err != nil { t.Fatalf("Ask: %v", err) } if plan := a.loadPlan(); plan != "" { t.Fatalf("plan persisted after canceled tool context: %q", plan) } } func TestToolCallTimeoutPropagatesDeadlineToCustomTool(t *testing.T) { var sawDeadline bool a := newTestAgent( Name("tool-timeout"), ToolCallTimeout(10*time.Millisecond), WithTool("slow", "slow tool", nil, func(ctx context.Context, input map[string]any) (string, error) { if _, ok := ctx.Deadline(); ok { sawDeadline = true } <-ctx.Done() return "", ctx.Err() }), ) start := time.Now() content := toolContent(a.toolHandler(), "slow", nil) if !sawDeadline { t.Fatal("custom tool did not receive a deadline") } if !strings.Contains(content, context.DeadlineExceeded.Error()) { t.Fatalf("tool result = %q, want deadline exceeded", content) } if elapsed := time.Since(start); elapsed > 200*time.Millisecond { t.Fatalf("tool call took %s, want bounded timeout", elapsed) } } func TestAskCancellationDuringToolCallFailsRun(t *testing.T) { fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) { if opts.ToolHandler == nil { t.Fatal("missing tool handler") } res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "cancel-self"}) if !strings.Contains(res.Content, context.Canceled.Error()) { t.Fatalf("tool result = %q, want cancellation error", res.Content) } return &ai.Response{Reply: "should not succeed"}, nil } defer func() { fakeGen = nil }() ctx, cancel := context.WithCancel(context.Background()) a := newTestAgent( Name("cancel-during-tool"), WithTool("cancel-self", "cancel the run context", nil, func(context.Context, map[string]any) (string, error) { cancel() return "", context.Canceled }), ) _, err := a.Ask(ctx, "cancel during tool") if !errors.Is(err, context.Canceled) { t.Fatalf("Ask error = %v, want context canceled", err) } } func TestSlowProviderTimeoutPreventsLateToolSideEffects(t *testing.T) { started := make(chan struct{}) release := make(chan struct{}) done := make(chan struct{}) fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) { close(started) <-release defer close(done) if opts.ToolHandler == nil { t.Fatal("missing tool handler") } res := opts.ToolHandler(ctx, ai.ToolCall{ID: "late-1", Name: "external.create", Input: map[string]any{"title": "too late"}}) if !strings.Contains(res.Content, context.DeadlineExceeded.Error()) { t.Errorf("late tool result = %q, want deadline exceeded", res.Content) } return &ai.Response{Reply: "late", ToolCalls: []ai.ToolCall{{ID: "late-1", Name: "external.create", Input: map[string]any{"title": "too late"}, Result: res.Content}}}, nil } defer func() { fakeGen = nil }() toolRuns := 0 a := newTestAgent( Name("slow-provider-late-tool"), ModelCallTimeout(10*time.Millisecond), WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) { toolRuns++ return "created", nil }), ) _, err := a.Ask(context.Background(), "provider times out before tool") if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("Ask error = %v, want deadline exceeded", err) } select { case <-started: default: t.Fatal("provider was not called") } close(release) select { case <-done: case <-time.After(time.Second): t.Fatal("late provider call did not finish") } if toolRuns != 0 { t.Fatalf("late tool executions = %d, want 0", toolRuns) } } func TestAskCheckpointRecordsTerminalOperationalFailureStatus(t *testing.T) { tests := []struct { name string err error want string }{ {name: "canceled", err: context.Canceled, want: "canceled"}, {name: "timeout", err: context.DeadlineExceeded, want: "timeout"}, {name: "rate limited", err: testStatusError{code: 429}, want: "rate_limited"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cp := flow.StoreCheckpoint(store.NewMemoryStore(), "terminal-"+strings.ReplaceAll(tt.name, " ", "-")) fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) { return nil, tt.err } defer func() { fakeGen = nil }() a := newTestAgent(Name("terminal-"+strings.ReplaceAll(tt.name, " ", "-")), WithCheckpoint(cp)) _, err := a.Ask(context.Background(), "fail safely") if err == nil { t.Fatal("Ask succeeded, want failure") } runs, err := cp.List(context.Background()) if err != nil { t.Fatalf("List: %v", err) } if len(runs) != 1 { t.Fatalf("checkpointed runs = %d, want 1", len(runs)) } if runs[0].Status != tt.want { t.Fatalf("run status = %q, want %q", runs[0].Status, tt.want) } if len(runs[0].Steps) == 0 || runs[0].Steps[0].Status != tt.want { t.Fatalf("step status = %#v, want %q", runs[0].Steps, tt.want) } if runs[0].Steps[0].Attempts != 1 { t.Fatalf("step attempts = %d, want 1", runs[0].Steps[0].Attempts) } if got := runs[0].Steps[0].ErrorKind; got != string(ai.ClassifyError(tt.err)) { t.Fatalf("step error kind = %q, want %q", got, ai.ClassifyError(tt.err)) } if pending, err := Pending(context.Background(), a); err != nil || len(pending) != 0 { t.Fatalf("Pending = %#v, %v; want no terminal run", pending, err) } }) } } type testStatusError struct { code int } func (e testStatusError) Error() string { return "provider status error" } func (e testStatusError) StatusCode() int { return e.code } func TestToolRetryRetriesTransientToolErrorsThenSucceeds(t *testing.T) { attempts := 0 a := newTestAgent( Name("tool-retry-success"), ToolRetry(3, time.Millisecond), WithTool("flaky", "flaky tool", nil, func(context.Context, map[string]any) (string, error) { attempts++ if attempts < 3 { return "", context.DeadlineExceeded } return "ok", nil }), ) content := toolContent(a.toolHandler(), "flaky", nil) if content != "ok" { t.Fatalf("tool result = %q, want ok", content) } if attempts != 3 { t.Fatalf("attempts = %d, want 3", attempts) } } func TestToolRetryDoesNotRetryGuardrailRefusals(t *testing.T) { attempts := 0 a := newTestAgent( Name("tool-retry-refusal"), MaxSteps(1), ToolRetry(3, time.Millisecond), WithTool("counted", "counted tool", nil, func(context.Context, map[string]any) (string, error) { attempts++ return "ok", nil }), ) h := a.toolHandler() _ = toolContent(h, "counted", nil) content := toolContent(h, "counted", nil) if !strings.Contains(content, "step limit reached") { t.Fatalf("tool result = %q, want step-limit refusal", content) } if attempts != 1 { t.Fatalf("attempts = %d, want only the allowed tool call to execute", attempts) } }