package agenttool import ( "context" "encoding/json" "fmt" "sync" "testing" "time" "github.com/smallnest/pigo/internal/agentcore" ) // registerAll builds a registry containing every tool given. func registerAll(t *testing.T, tools ...agentcore.AgentTool) *ToolRegistry { t.Helper() r := NewToolRegistry() for _, tool := range tools { if err := r.Register(tool); err != nil { t.Fatalf("register %s: %v", tool.Name(), err) } } return r } // echoTool returns its name as text; optionally terminates. func echoTool(name string, mode agentcore.ToolExecutionMode, terminate bool) execTool { return execTool{ name: name, mode: mode, run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { term := terminate return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}, Terminate: &term}, nil }, } } func callsFor(names ...string) []agentcore.AgentToolCall { calls := make([]agentcore.AgentToolCall, len(names)) for i, n := range names { calls[i] = agentcore.AgentToolCall{ID: fmt.Sprintf("c%d", i), Name: n, Arguments: json.RawMessage(`{}`)} } return calls } // TestBatchParallelPreservesOrder verifies that parallel execution backfills // results at their source index regardless of completion order. func TestBatchParallelPreservesOrder(t *testing.T) { // t0 sleeps longest, t2 shortest — so completion order is reversed, but the // result slice must still be [t0, t1, t2]. mk := func(name string, delay time.Duration) execTool { return execTool{ name: name, mode: agentcore.ToolExecutionParallel, run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { time.Sleep(delay) return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil }, } } reg := registerAll(t, mk("t0", 30*time.Millisecond), mk("t1", 15*time.Millisecond), mk("t2", 1*time.Millisecond)) cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}} results, term := ExecuteToolCalls(context.Background(), cfg, callsFor("t0", "t1", "t2"), nil) if term { t.Errorf("no tool terminates; batch must not terminate") } want := []string{"t0", "t1", "t2"} for i, w := range want { if got := textOf(results[i]); got != w { t.Errorf("result[%d] = %q, want %q (order not preserved)", i, got, w) } } } // TestBatchParallelRunsConcurrently confirms parallel tools overlap in time. func TestBatchParallelRunsConcurrently(t *testing.T) { var mu sync.Mutex running := 0 maxConcurrent := 0 block := make(chan struct{}) var started sync.WaitGroup started.Add(3) mk := func(name string) execTool { return execTool{ name: name, mode: agentcore.ToolExecutionParallel, run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { mu.Lock() running++ if running > maxConcurrent { maxConcurrent = running } mu.Unlock() started.Done() <-block // hold until all have started mu.Lock() running-- mu.Unlock() return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil }, } } reg := registerAll(t, mk("a"), mk("b"), mk("c")) cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}} go func() { started.Wait() close(block) }() ExecuteToolCalls(context.Background(), cfg, callsFor("a", "b", "c"), nil) if maxConcurrent < 3 { t.Errorf("expected 3 concurrent tools, saw max %d", maxConcurrent) } } // TestBatchSequentialWhenAnyToolSequential forces serial execution and records // the order tools actually ran in. func TestBatchSequentialWhenAnyToolSequential(t *testing.T) { var mu sync.Mutex var order []string mk := func(name string, mode agentcore.ToolExecutionMode) execTool { return execTool{ name: name, mode: mode, run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { mu.Lock() order = append(order, name) mu.Unlock() return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil }, } } // "b" is sequential → whole batch runs serially in source order. reg := registerAll(t, mk("a", agentcore.ToolExecutionParallel), mk("b", agentcore.ToolExecutionSequential), mk("c", agentcore.ToolExecutionParallel)) cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}} ExecuteToolCalls(context.Background(), cfg, callsFor("a", "b", "c"), nil) want := []string{"a", "b", "c"} for i, w := range want { if order[i] != w { t.Fatalf("sequential order = %v, want %v", order, want) } } } // TestBatchForceSequential verifies the global ForceSequential flag serializes // even all-parallel tools. func TestBatchForceSequential(t *testing.T) { var mu sync.Mutex var order []string mk := func(name string) execTool { return execTool{ name: name, mode: agentcore.ToolExecutionParallel, run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { mu.Lock() order = append(order, name) mu.Unlock() return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil }, } } reg := registerAll(t, mk("a"), mk("b")) cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}, ForceSequential: true} ExecuteToolCalls(context.Background(), cfg, callsFor("a", "b"), nil) if len(order) != 2 || order[0] != "a" || order[1] != "b" { t.Errorf("force-sequential order = %v, want [a b]", order) } } // TestBatchTerminateOnlyWhenAll checks the whole-batch terminate semantics. func TestBatchTerminateOnlyWhenAll(t *testing.T) { // Mixed: one terminates, one does not → batch must NOT terminate. reg := registerAll(t, echoTool("term", agentcore.ToolExecutionParallel, true), echoTool("noterm", agentcore.ToolExecutionParallel, false)) cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}} _, term := ExecuteToolCalls(context.Background(), cfg, callsFor("term", "noterm"), nil) if term { t.Errorf("batch with one non-terminating tool must not terminate") } // All terminate → batch terminates. reg2 := registerAll(t, echoTool("t1", agentcore.ToolExecutionParallel, true), echoTool("t2", agentcore.ToolExecutionParallel, true)) cfg2 := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg2}} _, term2 := ExecuteToolCalls(context.Background(), cfg2, callsFor("t1", "t2"), nil) if !term2 { t.Errorf("batch with all terminating tools must terminate") } } // TestBatchSequentialAbort verifies that aborting mid-batch fills the remaining // calls with aborted error results. func TestBatchSequentialAbort(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) mk := func(name string) execTool { return execTool{ name: name, mode: agentcore.ToolExecutionSequential, run: func(c context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { cancel() // abort after the first tool starts return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil }, } } reg := registerAll(t, mk("first"), echoTool("second", agentcore.ToolExecutionSequential, false)) cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: reg}} results, _ := ExecuteToolCalls(ctx, cfg, callsFor("first", "second"), nil) if len(results) != 2 { t.Fatalf("expected 2 results, got %d", len(results)) } if !results[1].IsError { t.Errorf("second (post-abort) result must be an error result") } } // TestBatchEmpty covers the empty-batch fast path. func TestBatchEmpty(t *testing.T) { cfg := BatchConfig{ToolExecutorConfig: ToolExecutorConfig{Registry: NewToolRegistry()}} results, term := ExecuteToolCalls(context.Background(), cfg, nil, nil) if results != nil || term { t.Errorf("empty batch must return (nil, false), got (%v, %v)", results, term) } }