github.com/anthropics/anthropic-sdk-go@v1.70.1/toolrunner/runner_test.go (about)

     1  package toolrunner_test
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"sort"
     8  	"strings"
     9  	"sync"
    10  	"sync/atomic"
    11  	"testing"
    12  	"time"
    13  
    14  	anthropic "github.com/anthropics/anthropic-sdk-go"
    15  	"github.com/anthropics/anthropic-sdk-go/internal/testutil"
    16  	"github.com/anthropics/anthropic-sdk-go/option"
    17  	"github.com/anthropics/anthropic-sdk-go/toolrunner"
    18  )
    19  
    20  // schemaToBytes converts a map schema to JSON bytes for use with NewBetaToolFromBytes.
    21  func schemaToBytes(t *testing.T, schema map[string]any) []byte {
    22  	t.Helper()
    23  	bytes, err := json.Marshal(schema)
    24  	if err != nil {
    25  		t.Fatalf("marshal schema: %v", err)
    26  	}
    27  	return bytes
    28  }
    29  
    30  // assistantText extracts concatenated assistant text blocks from a BetaMessage.
    31  func assistantText(msg *anthropic.BetaMessage) string {
    32  	var b strings.Builder
    33  	for _, c := range msg.Content {
    34  		if tb, ok := c.AsAny().(anthropic.BetaTextBlock); ok {
    35  			b.WriteString(tb.Text)
    36  		}
    37  	}
    38  	return b.String()
    39  }
    40  
    41  // Shared weather tool used by tests
    42  type weatherRequest struct {
    43  	City  string `json:"city"`
    44  	Units string `json:"units,omitempty"`
    45  }
    46  
    47  var weatherSchema = map[string]any{
    48  	"type": "object",
    49  	"properties": map[string]any{
    50  		"city":  map[string]any{"type": "string"},
    51  		"units": map[string]any{"type": "string", "enum": []string{"celsius", "fahrenheit"}},
    52  	},
    53  	"required": []string{"city"},
    54  }
    55  
    56  func weatherTool(t *testing.T) anthropic.BetaTool {
    57  	t.Helper()
    58  	tool, err := toolrunner.NewBetaToolFromBytes("get_weather", "Get weather", schemaToBytes(t, weatherSchema),
    59  		func(ctx context.Context, req weatherRequest) (anthropic.BetaToolResultBlockParamContentUnion, error) {
    60  			temp := 20
    61  			if req.Units == "fahrenheit" {
    62  				temp = 68
    63  			}
    64  			return anthropic.BetaToolResultBlockParamContentUnion{
    65  				OfText: &anthropic.BetaTextBlockParam{Text: fmt.Sprintf("The weather in %s is %d degrees %s.", req.City, temp, req.Units)},
    66  			}, nil
    67  		})
    68  	if err != nil {
    69  		t.Fatalf("create weather tool: %v", err)
    70  	}
    71  	return tool
    72  }
    73  
    74  func newClientWithVCR(t *testing.T, cassette string) anthropic.Client {
    75  	t.Helper()
    76  	httpClient, _ := testutil.NewVCRHTTPClient(t, cassette)
    77  	return anthropic.NewClient(
    78  		option.WithHTTPClient(httpClient),
    79  		option.WithAPIKey("test-key"),
    80  	)
    81  }
    82  
    83  // Test All() end-to-end
    84  
    85  func TestToolRunner_All_Basic(t *testing.T) {
    86  	t.Parallel()
    87  	client := newClientWithVCR(t, "tool_runner_basic")
    88  	tool := weatherTool(t)
    89  
    90  	runner := client.Beta.Messages.NewToolRunner([]anthropic.BetaTool{tool}, anthropic.BetaToolRunnerParams{
    91  		BetaMessageNewParams: anthropic.BetaMessageNewParams{
    92  			Model:     anthropic.ModelClaudeSonnet4_5,
    93  			MaxTokens: 512,
    94  			Messages: []anthropic.BetaMessageParam{
    95  				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("What's the weather in San Francisco? Use fahrenheit.")),
    96  			},
    97  		},
    98  		MaxIterations: 5,
    99  	})
   100  
   101  	ctx := context.Background()
   102  	var last *anthropic.BetaMessage
   103  	for msg, err := range runner.All(ctx) {
   104  		if err != nil {
   105  			t.Fatalf("runner error: %v", err)
   106  		}
   107  		last = msg
   108  	}
   109  	if last == nil {
   110  		t.Fatalf("no final message produced")
   111  	}
   112  
   113  	// Extract assistant text content concisely
   114  	got := []byte(assistantText(last) + "\n")
   115  	testutil.CompareGolden(t, "testdata/snapshots/tool_runner_basic.golden", got)
   116  }
   117  
   118  func TestToolRunner_RunToCompletion(t *testing.T) {
   119  	t.Parallel()
   120  	client := newClientWithVCR(t, "tool_runner_run_to_completion")
   121  	tool := weatherTool(t)
   122  
   123  	runner := client.Beta.Messages.NewToolRunner([]anthropic.BetaTool{tool}, anthropic.BetaToolRunnerParams{
   124  		BetaMessageNewParams: anthropic.BetaMessageNewParams{
   125  			Model:     anthropic.ModelClaudeSonnet4_5,
   126  			MaxTokens: 512,
   127  			Messages: []anthropic.BetaMessageParam{
   128  				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("What's the weather in San Francisco? Use fahrenheit.")),
   129  			},
   130  		},
   131  		MaxIterations: 5,
   132  	})
   133  
   134  	ctx := context.Background()
   135  	last, err := runner.RunToCompletion(ctx)
   136  	if err != nil {
   137  		t.Fatalf("RunToCompletion: %v", err)
   138  	}
   139  
   140  	// Extract assistant text content concisely
   141  	got := []byte(assistantText(last) + "\n")
   142  	testutil.CompareGolden(t, "testdata/snapshots/tool_runner_run_to_completion.golden", got)
   143  }
   144  
   145  // Test NextMessage step-wise, ensuring an intermediate tool_result is appended, then final answer
   146  
   147  func TestToolRunner_NextMessage_Step(t *testing.T) {
   148  	t.Parallel()
   149  	client := newClientWithVCR(t, "tool_runner_next_message")
   150  	tool := weatherTool(t)
   151  
   152  	runner := client.Beta.Messages.NewToolRunner([]anthropic.BetaTool{tool}, anthropic.BetaToolRunnerParams{
   153  		BetaMessageNewParams: anthropic.BetaMessageNewParams{
   154  			Model:     anthropic.ModelClaudeSonnet4_5,
   155  			MaxTokens: 512,
   156  			Messages: []anthropic.BetaMessageParam{
   157  				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("What's the weather in SF? Use celsius.")),
   158  			},
   159  		},
   160  		MaxIterations: 5,
   161  	})
   162  
   163  	ctx := context.Background()
   164  	// Turn 1: expect tool_use
   165  	msg, err := runner.NextMessage(ctx)
   166  	if err != nil {
   167  		t.Fatalf("NextMessage 1: %v", err)
   168  	}
   169  	if msg == nil {
   170  		t.Fatalf("expected first message")
   171  	}
   172  
   173  	got := []byte(assistantText(msg) + "\n")
   174  	testutil.CompareGolden(t, "testdata/snapshots/tool_runner_next_message_step_1.golden", got)
   175  	// Turn 2: tool results sent, expect final assistant
   176  	msg, err = runner.NextMessage(ctx)
   177  	if err != nil {
   178  		t.Fatalf("NextMessage 2: %v", err)
   179  	}
   180  	if msg == nil {
   181  		t.Fatalf("expected final message")
   182  	}
   183  
   184  	got = []byte(assistantText(msg) + "\n")
   185  	testutil.CompareGolden(t, "testdata/snapshots/tool_runner_next_message_step_2.golden", got)
   186  }
   187  
   188  // Test AllStreaming end-to-end collects final text and compares
   189  
   190  func TestToolRunner_AllStreaming(t *testing.T) {
   191  	t.Parallel()
   192  	client := newClientWithVCR(t, "tool_runner_streaming_all")
   193  	tool := weatherTool(t)
   194  
   195  	runner := client.Beta.Messages.NewToolRunnerStreaming([]anthropic.BetaTool{tool}, anthropic.BetaToolRunnerParams{
   196  		BetaMessageNewParams: anthropic.BetaMessageNewParams{
   197  			Model:     anthropic.ModelClaudeSonnet4_5,
   198  			MaxTokens: 512,
   199  			Messages: []anthropic.BetaMessageParam{
   200  				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("Weather in SF in fahrenheit?")),
   201  			},
   202  		},
   203  		MaxIterations: 5,
   204  	})
   205  
   206  	ctx := context.Background()
   207  	assistantMessages := []string{}
   208  	for iterator, err := range runner.AllStreaming(ctx) {
   209  		if err != nil {
   210  			t.Fatalf("stream err: %v", err)
   211  		}
   212  		stringBuilder := strings.Builder{}
   213  		for ev := range iterator {
   214  			switch evVariant := ev.AsAny().(type) {
   215  			case anthropic.BetaRawContentBlockDeltaEvent:
   216  				switch deltaVariant := evVariant.Delta.AsAny().(type) {
   217  				case anthropic.BetaTextDelta:
   218  					stringBuilder.WriteString(deltaVariant.Text)
   219  				}
   220  			}
   221  		}
   222  		assistantMessages = append(assistantMessages, stringBuilder.String())
   223  	}
   224  
   225  	got := []byte(strings.Join(assistantMessages, "\n"))
   226  	testutil.CompareGolden(t, "testdata/snapshots/tool_runner_streaming_all.golden", got)
   227  }
   228  
   229  // Test NextStreaming for a single turn; verify event types set is stable
   230  
   231  func TestToolRunner_NextStreaming_EventTypes(t *testing.T) {
   232  	t.Parallel()
   233  	client := newClientWithVCR(t, "tool_runner_next_streaming")
   234  	tool := weatherTool(t)
   235  
   236  	runner := client.Beta.Messages.NewToolRunnerStreaming([]anthropic.BetaTool{tool}, anthropic.BetaToolRunnerParams{
   237  		BetaMessageNewParams: anthropic.BetaMessageNewParams{
   238  			Model:     anthropic.ModelClaudeSonnet4_5,
   239  			MaxTokens: 512,
   240  			Messages: []anthropic.BetaMessageParam{
   241  				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("Weather in SF?")),
   242  			},
   243  		},
   244  		MaxIterations: 1,
   245  	})
   246  
   247  	ctx := context.Background()
   248  	events := runner.NextStreaming(ctx)
   249  
   250  	eventsTypes := []string{}
   251  	for ev := range events {
   252  		eventsTypes = append(eventsTypes, ev.Type)
   253  	}
   254  
   255  	sort.Strings(eventsTypes)
   256  
   257  	got := []byte(strings.Join(eventsTypes, "\n") + "\n")
   258  	testutil.CompareGolden(t, "testdata/snapshots/tool_runner_next_streaming_types.golden", got)
   259  }
   260  
   261  // Test that tool error is surfaced as a tool_result with is_error and the flow completes
   262  
   263  func TestToolRunner_ToolCallError_ThenSuccess(t *testing.T) {
   264  	t.Parallel()
   265  	client := newClientWithVCR(t, "tool_runner_tool_call_error")
   266  	called := false
   267  	tool, err := toolrunner.NewBetaToolFromBytes("get_weather", "Get weather", schemaToBytes(t, weatherSchema),
   268  		func(ctx context.Context, req weatherRequest) (anthropic.BetaToolResultBlockParamContentUnion, error) {
   269  			if !called {
   270  				called = true
   271  				return anthropic.BetaToolResultBlockParamContentUnion{}, fmt.Errorf("Unexpected error, try again")
   272  			}
   273  			return anthropic.BetaToolResultBlockParamContentUnion{OfText: &anthropic.BetaTextBlockParam{Text: "Sunny 68°F"}}, nil
   274  		})
   275  	if err != nil {
   276  		t.Fatalf("create tool: %v", err)
   277  	}
   278  
   279  	runner := client.Beta.Messages.NewToolRunner([]anthropic.BetaTool{tool}, anthropic.BetaToolRunnerParams{
   280  		BetaMessageNewParams: anthropic.BetaMessageNewParams{
   281  			Model:     anthropic.ModelClaudeSonnet4_5,
   282  			MaxTokens: 512,
   283  			Messages: []anthropic.BetaMessageParam{
   284  				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("Weather in San Francisco?")),
   285  			},
   286  		},
   287  	})
   288  
   289  	ctx := context.Background()
   290  	msg, err := runner.RunToCompletion(ctx)
   291  	if err != nil {
   292  		t.Fatalf("RunToCompletion: %v", err)
   293  	}
   294  
   295  	messages := runner.Messages()
   296  	if len(messages) == 0 {
   297  		t.Fatalf("expected messages in runner")
   298  	}
   299  
   300  	// look through all the messages to find the tool results
   301  	// 1. should be an error
   302  	// 2. should be a success
   303  	toolResultBlocks := []*anthropic.BetaToolResultBlockParam{}
   304  	for _, msg := range messages {
   305  		for _, c := range msg.Content {
   306  			if tr := c.OfToolResult; tr != nil {
   307  				toolResultBlocks = append(toolResultBlocks, tr)
   308  			}
   309  		}
   310  	}
   311  
   312  	if len(toolResultBlocks) != 2 {
   313  		t.Fatalf("expected 2 tool result blocks, got %d", len(toolResultBlocks))
   314  	}
   315  
   316  	errorToolResultBlock := toolResultBlocks[0]
   317  	if !errorToolResultBlock.IsError.Value {
   318  		t.Fatalf("expected first tool result to be an error")
   319  	}
   320  	errorText := errorToolResultBlock.Content[0].OfText.Text
   321  	if !strings.Contains(errorText, "Unexpected error") {
   322  		t.Fatalf("expected error message in tool result, got: %s", errorText)
   323  	}
   324  
   325  	successToolResultBlock := toolResultBlocks[1]
   326  	if successToolResultBlock.IsError.Value {
   327  		t.Fatalf("expected second tool result to be a success")
   328  	}
   329  	successText := successToolResultBlock.Content[0].OfText.Text
   330  	if successText != "Sunny 68°F" {
   331  		t.Fatalf("expected success message in tool result, got: %s", successText)
   332  	}
   333  
   334  	// Final assistant golden snapshot and iteration count
   335  	testutil.CompareGolden(t, "testdata/snapshots/tool_runner_tool_call_error_assistant.golden", []byte(assistantText(msg)+"\n"))
   336  	if runner.IterationCount() != 3 {
   337  		t.Fatalf("expected 3 iterations, got %d", runner.IterationCount())
   338  	}
   339  }
   340  
   341  // Test custom handling: intercept tool_use, push our own tool_result, and disable tools for next turn
   342  
   343  func TestToolRunner_CustomHandlingWithPushMessages(t *testing.T) {
   344  	t.Parallel()
   345  	client := newClientWithVCR(t, "tool_runner_custom_handling")
   346  	tool := weatherTool(t)
   347  
   348  	runner := client.Beta.Messages.NewToolRunner([]anthropic.BetaTool{tool}, anthropic.BetaToolRunnerParams{
   349  		BetaMessageNewParams: anthropic.BetaMessageNewParams{
   350  			Model:     anthropic.ModelClaudeSonnet4_5,
   351  			MaxTokens: 512,
   352  			Messages: []anthropic.BetaMessageParam{
   353  				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("What's the weather in SF in celsius?")),
   354  			},
   355  		},
   356  	})
   357  
   358  	ctx := context.Background()
   359  	// First assistant message with tool_use
   360  	msg, err := runner.NextMessage(ctx)
   361  	if err != nil || msg == nil {
   362  		t.Fatalf("turn 1: %v %v", msg, err)
   363  	}
   364  	// Find first tool_use id
   365  	var toolUseID string
   366  	for _, c := range msg.Content {
   367  		if tu, ok := c.AsAny().(anthropic.BetaToolUseBlock); ok {
   368  			toolUseID = tu.ID
   369  			break
   370  		}
   371  	}
   372  	if toolUseID == "" {
   373  		t.Fatalf("expected a tool_use block")
   374  	}
   375  	// Build a new runner with our custom tool_result appended to messages to avoid
   376  	// automatic execution for the prior assistant tool_use turn.
   377  	msgs := runner.Messages()
   378  	msgs = append(msgs, anthropic.NewBetaUserMessage(
   379  		anthropic.BetaContentBlockParamUnion{OfToolResult: &anthropic.BetaToolResultBlockParam{ToolUseID: toolUseID, Content: []anthropic.BetaToolResultBlockParamContentUnion{{OfText: &anthropic.BetaTextBlockParam{Text: "Celsius 20°C"}}}}},
   380  	))
   381  
   382  	// No tools so the next turn is just the assistant producing final text
   383  	runner2 := client.Beta.Messages.NewToolRunner(nil, anthropic.BetaToolRunnerParams{
   384  		BetaMessageNewParams: anthropic.BetaMessageNewParams{
   385  			Model:     anthropic.ModelClaudeSonnet4_5,
   386  			MaxTokens: 512,
   387  			Messages:  msgs,
   388  		},
   389  	})
   390  
   391  	// Next turn should finalize with assistant text
   392  	msg, err = runner2.NextMessage(ctx)
   393  	if err != nil || msg == nil {
   394  		t.Fatalf("turn 2: %v %v", msg, err)
   395  	}
   396  }
   397  
   398  // Test max iterations stops further calls
   399  
   400  func TestToolRunner_MaxIterations(t *testing.T) {
   401  	t.Parallel()
   402  	client := newClientWithVCR(t, "tool_runner_max_iterations")
   403  	tool := weatherTool(t)
   404  
   405  	runner := client.Beta.Messages.NewToolRunner([]anthropic.BetaTool{tool}, anthropic.BetaToolRunnerParams{
   406  		BetaMessageNewParams: anthropic.BetaMessageNewParams{
   407  			Model:     anthropic.ModelClaudeSonnet4_5,
   408  			MaxTokens: 512,
   409  			Messages: []anthropic.BetaMessageParam{
   410  				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("Check weather in SF and NY, step by step")),
   411  			},
   412  		},
   413  		MaxIterations: 2,
   414  	})
   415  
   416  	ctx := context.Background()
   417  	for {
   418  		msg, err := runner.NextMessage(ctx)
   419  		if msg == nil {
   420  			if err != nil {
   421  				t.Fatalf("runner error: %v", err)
   422  			}
   423  			break
   424  		}
   425  	}
   426  	if got := runner.IterationCount(); got != 2 {
   427  		t.Fatalf("expected 2 iterations, got %d", got)
   428  	}
   429  }
   430  
   431  // Test concurrent tool execution (multiple tools in one message)
   432  
   433  func TestToolRunner_ConcurrentToolExecution(t *testing.T) {
   434  	t.Parallel()
   435  	client := newClientWithVCR(t, "tool_runner_concurrent")
   436  
   437  	// Track execution with timing to verify concurrency
   438  	var callCount atomic.Int32
   439  	var executionTimes sync.Map
   440  	startTime := time.Now()
   441  
   442  	weatherTool, err := toolrunner.NewBetaToolFromBytes("get_weather", "Get weather for a city", schemaToBytes(t, weatherSchema),
   443  		func(ctx context.Context, req weatherRequest) (anthropic.BetaToolResultBlockParamContentUnion, error) {
   444  			executionTimes.Store(req.City, time.Since(startTime))
   445  			callCount.Add(1)
   446  			// Small delay - if sequential this would take 3x longer
   447  			time.Sleep(50 * time.Millisecond)
   448  			return anthropic.BetaToolResultBlockParamContentUnion{
   449  				OfText: &anthropic.BetaTextBlockParam{Text: fmt.Sprintf("Weather in %s: Sunny 72°F", req.City)},
   450  			}, nil
   451  		})
   452  	if err != nil {
   453  		t.Fatalf("create weather tool: %v", err)
   454  	}
   455  
   456  	runner := client.Beta.Messages.NewToolRunner([]anthropic.BetaTool{weatherTool}, anthropic.BetaToolRunnerParams{
   457  		BetaMessageNewParams: anthropic.BetaMessageNewParams{
   458  			Model:     anthropic.ModelClaudeSonnet4_5,
   459  			MaxTokens: 512,
   460  			Messages: []anthropic.BetaMessageParam{
   461  				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock(
   462  					"What's the weather in San Francisco, New York, and London? Check all three cities at once.",
   463  				)),
   464  			},
   465  		},
   466  		MaxIterations: 5,
   467  	})
   468  
   469  	ctx := context.Background()
   470  	_, err = runner.RunToCompletion(ctx)
   471  	if err != nil {
   472  		t.Fatalf("RunToCompletion: %v", err)
   473  	}
   474  
   475  	// Verify multiple tools were called
   476  	count := callCount.Load()
   477  	if count < 2 {
   478  		t.Fatalf("expected at least 2 concurrent tool calls, got %d", count)
   479  	}
   480  
   481  	// Verify tool results are in the messages
   482  	messages := runner.Messages()
   483  	toolResultCount := 0
   484  	for _, msg := range messages {
   485  		for _, c := range msg.Content {
   486  			if c.OfToolResult != nil {
   487  				toolResultCount++
   488  			}
   489  		}
   490  	}
   491  	if toolResultCount < 2 {
   492  		t.Fatalf("expected at least 2 tool results, got %d", toolResultCount)
   493  	}
   494  }
   495  
   496  // Test context cancellation during tool execution
   497  
   498  func TestToolRunner_ContextCancellation(t *testing.T) {
   499  	t.Parallel()
   500  	client := newClientWithVCR(t, "tool_runner_context_cancel")
   501  
   502  	toolStarted := make(chan struct{})
   503  	toolCompleted := make(chan struct{})
   504  
   505  	slowSchema := map[string]any{
   506  		"type":       "object",
   507  		"properties": map[string]any{"input": map[string]any{"type": "string"}},
   508  	}
   509  	slowTool, err := toolrunner.NewBetaToolFromBytes("slow_tool", "A slow tool", schemaToBytes(t, slowSchema),
   510  		func(ctx context.Context, req struct{ Input string }) (anthropic.BetaToolResultBlockParamContentUnion, error) {
   511  			close(toolStarted)
   512  			// Wait for context cancellation or timeout
   513  			select {
   514  			case <-ctx.Done():
   515  				return anthropic.BetaToolResultBlockParamContentUnion{}, ctx.Err()
   516  			case <-time.After(5 * time.Second):
   517  				close(toolCompleted)
   518  				return anthropic.BetaToolResultBlockParamContentUnion{
   519  					OfText: &anthropic.BetaTextBlockParam{Text: "completed"},
   520  				}, nil
   521  			}
   522  		})
   523  	if err != nil {
   524  		t.Fatalf("create slow tool: %v", err)
   525  	}
   526  
   527  	runner := client.Beta.Messages.NewToolRunner([]anthropic.BetaTool{slowTool}, anthropic.BetaToolRunnerParams{
   528  		BetaMessageNewParams: anthropic.BetaMessageNewParams{
   529  			Model:     anthropic.ModelClaudeSonnet4_5,
   530  			MaxTokens: 512,
   531  			Messages: []anthropic.BetaMessageParam{
   532  				anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("Call the slow_tool with input 'test'")),
   533  			},
   534  		},
   535  		MaxIterations: 5,
   536  	})
   537  
   538  	ctx, cancel := context.WithCancel(context.Background())
   539  	defer cancel()
   540  
   541  	// Start the runner in a goroutine
   542  	done := make(chan struct{})
   543  	go func() {
   544  		defer close(done)
   545  		runner.RunToCompletion(ctx)
   546  	}()
   547  
   548  	// Wait for tool to start, then cancel
   549  	select {
   550  	case <-toolStarted:
   551  		cancel()
   552  	case <-time.After(10 * time.Second):
   553  		t.Fatal("tool never started")
   554  	}
   555  
   556  	// Verify runner completes quickly after cancellation (not waiting 5 seconds)
   557  	select {
   558  	case <-done:
   559  		// Good - runner completed
   560  	case <-toolCompleted:
   561  		t.Fatal("tool completed without cancellation being respected")
   562  	case <-time.After(2 * time.Second):
   563  		t.Fatal("runner did not complete promptly after cancellation")
   564  	}
   565  }
   566  
   567  // Test malformed JSON input error handling through Execute
   568  
   569  func TestToolRunner_MalformedJSONInput(t *testing.T) {
   570  	t.Parallel()
   571  
   572  	type StrictInput struct {
   573  		RequiredField string `json:"required_field"`
   574  		NumberField   int    `json:"number_field"`
   575  	}
   576  
   577  	strictSchema := map[string]any{
   578  		"type": "object",
   579  		"properties": map[string]any{
   580  			"required_field": map[string]any{"type": "string"},
   581  			"number_field":   map[string]any{"type": "integer"},
   582  		},
   583  		"required": []string{"required_field"},
   584  	}
   585  	tool, err := toolrunner.NewBetaToolFromBytes("strict_tool", "A tool with strict input", schemaToBytes(t, strictSchema),
   586  		func(ctx context.Context, input StrictInput) (anthropic.BetaToolResultBlockParamContentUnion, error) {
   587  			return anthropic.BetaToolResultBlockParamContentUnion{
   588  				OfText: &anthropic.BetaTextBlockParam{Text: "success"},
   589  			}, nil
   590  		})
   591  	if err != nil {
   592  		t.Fatalf("create tool: %v", err)
   593  	}
   594  
   595  	// Test Execute with valid JSON
   596  	validJSON := json.RawMessage(`{"required_field": "test", "number_field": 42}`)
   597  	results, err := tool.Execute(context.Background(), validJSON)
   598  	if err != nil {
   599  		t.Fatalf("Execute valid JSON failed: %v", err)
   600  	}
   601  	if len(results) != 1 || results[0].OfText == nil || results[0].OfText.Text != "success" {
   602  		t.Fatalf("Execute returned unexpected result: %+v", results)
   603  	}
   604  
   605  	// Test Execute with malformed JSON (invalid syntax)
   606  	malformedJSON := json.RawMessage(`{"required_field": "test", "number_field": }`)
   607  	_, err = tool.Execute(context.Background(), malformedJSON)
   608  	if err == nil {
   609  		t.Fatal("expected error for malformed JSON, got nil")
   610  	}
   611  
   612  	// Test Execute with type mismatch (string instead of int)
   613  	typeMismatchJSON := json.RawMessage(`{"required_field": "test", "number_field": "not a number"}`)
   614  	_, err = tool.Execute(context.Background(), typeMismatchJSON)
   615  	if err == nil {
   616  		t.Fatal("expected error for type mismatch, got nil")
   617  	}
   618  
   619  	// Test Execute with invalid JSON propagates error
   620  	invalidJSON := json.RawMessage(`{invalid json}`)
   621  	_, err = tool.Execute(context.Background(), invalidJSON)
   622  	if err == nil {
   623  		t.Fatal("expected error for invalid JSON in Execute")
   624  	}
   625  }