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

     1  package anthropic
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"net/http/httptest"
    10  	"reflect"
    11  	"sync"
    12  	"sync/atomic"
    13  	"testing"
    14  
    15  	"github.com/anthropics/anthropic-sdk-go/option"
    16  	"github.com/tidwall/gjson"
    17  	"github.com/tidwall/sjson"
    18  )
    19  
    20  // messagesServer scripts POST /v1/messages: the first call returns a tool_use
    21  // for "weather", the second a final text answer.
    22  func messagesServer(t *testing.T) *httptest.Server {
    23  	t.Helper()
    24  	var calls atomic.Int32
    25  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    26  		if r.Method != http.MethodPost || r.URL.Path != "/v1/messages" {
    27  			t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
    28  			http.Error(w, "unexpected", http.StatusNotImplemented)
    29  			return
    30  		}
    31  		var body string
    32  		if calls.Add(1) == 1 {
    33  			body = `{"id":"msg_1","type":"message","role":"assistant","model":"m","content":[{"type":"tool_use","id":"toolu_1","name":"weather","input":{"city":"SF"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}`
    34  		} else {
    35  			body = `{"id":"msg_2","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"done"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}`
    36  		}
    37  		w.Header().Set("Content-Type", "application/json")
    38  		_, _ = w.Write([]byte(body))
    39  	}))
    40  	t.Cleanup(server.Close)
    41  	return server
    42  }
    43  
    44  func newTestToolRunnerClient(server *httptest.Server) Client {
    45  	return NewClient(
    46  		option.WithBaseURL(server.URL),
    47  		option.WithAPIKey("test-key"),
    48  		option.WithMaxRetries(0),
    49  	)
    50  }
    51  
    52  // runToToolResults drives the runner to completion and returns every
    53  // tool_result block it appended to the conversation.
    54  func runToToolResults(t *testing.T, runner *BetaToolRunner) []*BetaToolResultBlockParam {
    55  	t.Helper()
    56  	if _, err := runner.RunToCompletion(context.Background()); err != nil {
    57  		t.Fatalf("RunToCompletion: %v", err)
    58  	}
    59  	var results []*BetaToolResultBlockParam
    60  	for _, msg := range runner.Messages() {
    61  		for _, c := range msg.Content {
    62  			if c.OfToolResult != nil {
    63  				results = append(results, c.OfToolResult)
    64  			}
    65  		}
    66  	}
    67  	return results
    68  }
    69  
    70  func toolResultJSON(t *testing.T, block *BetaToolResultBlockParam) string {
    71  	t.Helper()
    72  	data, err := json.Marshal(block)
    73  	if err != nil {
    74  		t.Fatalf("marshal tool_result: %v", err)
    75  	}
    76  	return string(data)
    77  }
    78  
    79  func systemToolChange(block BetaContentBlockParamUnion) BetaMessageParam {
    80  	return BetaMessageParam{Role: BetaMessageParamRoleSystem, Content: []BetaContentBlockParamUnion{block}}
    81  }
    82  
    83  func weatherRef() BetaToolChangeToolReferenceParam {
    84  	return BetaToolChangeToolReferenceParam{Name: "weather"}
    85  }
    86  
    87  // A tool_use for a tool dropped by tool_removal must be answered exactly like
    88  // a tool that was never registered, without invoking the local tool.
    89  func TestBetaToolRunner_ToolRemoval_MatchesUnknownTool(t *testing.T) {
    90  	weather := &stubBetaTool{name: "weather"}
    91  	removedClient := newTestToolRunnerClient(messagesServer(t))
    92  	removed := removedClient.Beta.Messages.NewToolRunner(
    93  		[]BetaTool{weather},
    94  		BetaToolRunnerParams{BetaMessageNewParams: BetaMessageNewParams{
    95  			Model:     "m",
    96  			MaxTokens: 512,
    97  			Messages: []BetaMessageParam{
    98  				systemToolChange(NewBetaToolRemovalBlock(weatherRef())),
    99  				NewBetaUserMessage(NewBetaTextBlock("What's the weather in SF?")),
   100  			},
   101  		}, MaxIterations: 5},
   102  	)
   103  	removedResults := runToToolResults(t, removed)
   104  	if weather.runs.Load() != 0 {
   105  		t.Fatalf("removed tool must not execute, ran %d times", weather.runs.Load())
   106  	}
   107  
   108  	// Reference: the same call against a runner that never had the tool.
   109  	neverClient := newTestToolRunnerClient(messagesServer(t))
   110  	never := neverClient.Beta.Messages.NewToolRunner(
   111  		nil,
   112  		BetaToolRunnerParams{BetaMessageNewParams: BetaMessageNewParams{
   113  			Model:     "m",
   114  			MaxTokens: 512,
   115  			Messages: []BetaMessageParam{
   116  				NewBetaUserMessage(NewBetaTextBlock("What's the weather in SF?")),
   117  			},
   118  		}, MaxIterations: 5},
   119  	)
   120  	neverResults := runToToolResults(t, never)
   121  
   122  	if len(removedResults) != 1 || len(neverResults) != 1 {
   123  		t.Fatalf("expected one tool_result each, got %d and %d", len(removedResults), len(neverResults))
   124  	}
   125  	if !removedResults[0].IsError.Value {
   126  		t.Fatalf("expected removed-tool result to be an error")
   127  	}
   128  	if got, want := toolResultJSON(t, removedResults[0]), toolResultJSON(t, neverResults[0]); got != want {
   129  		t.Fatalf("removed-tool result differs from never-defined tool result\n got: %s\nwant: %s", got, want)
   130  	}
   131  }
   132  
   133  // A tool_addition after an earlier tool_removal re-enables the tool.
   134  func TestBetaToolRunner_ToolAddition_ReenablesTool(t *testing.T) {
   135  	weather := &stubBetaTool{name: "weather"}
   136  	client := newTestToolRunnerClient(messagesServer(t))
   137  	runner := client.Beta.Messages.NewToolRunner(
   138  		[]BetaTool{weather},
   139  		BetaToolRunnerParams{BetaMessageNewParams: BetaMessageNewParams{
   140  			Model:     "m",
   141  			MaxTokens: 512,
   142  			Messages: []BetaMessageParam{
   143  				systemToolChange(NewBetaToolRemovalBlock(weatherRef())),
   144  				systemToolChange(NewBetaToolAdditionBlock(weatherRef())),
   145  				NewBetaUserMessage(NewBetaTextBlock("What's the weather in SF?")),
   146  			},
   147  		}, MaxIterations: 5},
   148  	)
   149  	results := runToToolResults(t, runner)
   150  	if weather.runs.Load() != 1 {
   151  		t.Fatalf("re-added tool should run once, ran %d times", weather.runs.Load())
   152  	}
   153  	if len(results) != 1 {
   154  		t.Fatalf("expected one tool_result, got %d", len(results))
   155  	}
   156  	if results[0].IsError.Value {
   157  		t.Fatalf("expected successful tool_result, got error")
   158  	}
   159  	if len(results[0].Content) != 1 || results[0].Content[0].OfText == nil || results[0].Content[0].OfText.Text != "ok from weather" {
   160  		t.Fatalf("unexpected tool_result content: %+v", results[0].Content)
   161  	}
   162  }
   163  
   164  // newWeatherRunner builds a runner whose scripted server first asks for the
   165  // "weather" tool and then answers. prefix messages (e.g. system tool changes)
   166  // are placed before the single user turn.
   167  func newWeatherRunner(t *testing.T, weather *stubBetaTool, prefix ...BetaMessageParam) *BetaToolRunner {
   168  	t.Helper()
   169  	client := newTestToolRunnerClient(messagesServer(t))
   170  	messages := append([]BetaMessageParam{}, prefix...)
   171  	messages = append(messages, NewBetaUserMessage(NewBetaTextBlock("What's the weather in SF?")))
   172  	return client.Beta.Messages.NewToolRunner(
   173  		[]BetaTool{weather},
   174  		BetaToolRunnerParams{BetaMessageNewParams: BetaMessageNewParams{
   175  			Model:     "m",
   176  			MaxTokens: 512,
   177  			Messages:  messages,
   178  		}, MaxIterations: 5},
   179  	)
   180  }
   181  
   182  func requireToolNotFound(t *testing.T, results []*BetaToolResultBlockParam) {
   183  	t.Helper()
   184  	if len(results) != 1 {
   185  		t.Fatalf("expected one tool_result, got %d", len(results))
   186  	}
   187  	if !results[0].IsError.Value {
   188  		t.Fatalf("expected removed-tool result to be an error")
   189  	}
   190  	content := results[0].Content
   191  	if len(content) != 1 || content[0].OfText == nil || content[0].OfText.Text != "Error: Tool 'weather' not found" {
   192  		t.Fatalf("expected not-found tool_result, got %+v", content)
   193  	}
   194  }
   195  
   196  // A tool_removal supplied through AppendMessages before the model is asked
   197  // (not in the initial params) must be honored: the tool_use it answers is not
   198  // executed and resolves to the not-found result.
   199  func TestBetaToolRunner_ToolRemoval_AppendMessagesBeforeCall(t *testing.T) {
   200  	weather := &stubBetaTool{name: "weather"}
   201  	runner := newWeatherRunner(t, weather)
   202  	runner.AppendMessages(systemToolChange(NewBetaToolRemovalBlock(weatherRef())))
   203  
   204  	requireToolNotFound(t, runToToolResults(t, runner))
   205  	if got := weather.runs.Load(); got != 0 {
   206  		t.Fatalf("removed tool must not execute, ran %d times", got)
   207  	}
   208  }
   209  
   210  // A tool_removal appended in the dispatch window — after NextMessage has
   211  // returned the assistant's tool_use but before the following NextMessage
   212  // executes it — must also stop the call. Uses the exported Params.Messages
   213  // mutation path.
   214  func TestBetaToolRunner_ToolRemoval_DispatchWindowParamsMutation(t *testing.T) {
   215  	weather := &stubBetaTool{name: "weather"}
   216  	runner := newWeatherRunner(t, weather)
   217  
   218  	msg, err := runner.NextMessage(context.Background())
   219  	if err != nil {
   220  		t.Fatalf("NextMessage: %v", err)
   221  	}
   222  	if msg == nil || msg.StopReason != BetaStopReasonToolUse {
   223  		t.Fatalf("expected assistant tool_use turn, got %+v", msg)
   224  	}
   225  	// Tool execution is deferred to the next NextMessage call, so this is the
   226  	// window in which a removal can still take effect.
   227  	if got := weather.runs.Load(); got != 0 {
   228  		t.Fatalf("tool must not run before the next NextMessage call, ran %d times", got)
   229  	}
   230  	runner.Params.Messages = append(runner.Params.Messages, systemToolChange(NewBetaToolRemovalBlock(weatherRef())))
   231  
   232  	requireToolNotFound(t, runToToolResults(t, runner))
   233  	if got := weather.runs.Load(); got != 0 {
   234  		t.Fatalf("removed tool must not execute, ran %d times", got)
   235  	}
   236  }
   237  
   238  // A tool_addition appended in the dispatch window re-enables a tool that
   239  // the initial params had removed.
   240  func TestBetaToolRunner_ToolAddition_AppendMessagesInDispatchWindow(t *testing.T) {
   241  	weather := &stubBetaTool{name: "weather"}
   242  	runner := newWeatherRunner(t, weather, systemToolChange(NewBetaToolRemovalBlock(weatherRef())))
   243  
   244  	if _, err := runner.NextMessage(context.Background()); err != nil {
   245  		t.Fatalf("NextMessage: %v", err)
   246  	}
   247  	runner.AppendMessages(systemToolChange(NewBetaToolAdditionBlock(weatherRef())))
   248  
   249  	results := runToToolResults(t, runner)
   250  	if got := weather.runs.Load(); got != 1 {
   251  		t.Fatalf("re-added tool should run once, ran %d times", got)
   252  	}
   253  	if len(results) != 1 || results[0].IsError.Value {
   254  		t.Fatalf("expected one successful tool_result, got %+v", results)
   255  	}
   256  }
   257  
   258  type recordingTool struct {
   259  	name  string
   260  	calls int
   261  }
   262  
   263  func (r *recordingTool) Name() string                          { return r.name }
   264  func (r *recordingTool) Description() string                   { return "records invocations" }
   265  func (r *recordingTool) InputSchema() BetaToolInputSchemaParam { return BetaToolInputSchemaParam{} }
   266  func (r *recordingTool) Execute(ctx context.Context, input json.RawMessage) ([]BetaToolResultBlockParamContentUnion, error) {
   267  	r.calls++
   268  	return []BetaToolResultBlockParamContentUnion{{OfText: &BetaTextBlockParam{Text: "ok"}}}, nil
   269  }
   270  
   271  // A cut-off turn may carry a tool call whose input was cut mid-stream, and a
   272  // finished or unfinished turn may carry one nobody is waiting on; the runner
   273  // executes tool calls only when the turn stopped for tool use.
   274  func TestExecuteToolsSkipsMaxTokensTurn(t *testing.T) {
   275  	var toolUse BetaContentBlockUnion
   276  	if err := toolUse.UnmarshalJSON([]byte(`{"type":"tool_use","id":"toolu_1","name":"rec","input":{}}`)); err != nil {
   277  		t.Fatalf("building tool_use block: %v", err)
   278  	}
   279  
   280  	for _, tt := range []struct {
   281  		name       string
   282  		stopReason BetaStopReason
   283  		wantCalls  int
   284  		wantResult bool
   285  	}{
   286  		{"max_tokens turn skips execution", BetaStopReasonMaxTokens, 0, false},
   287  		{"context window turn skips execution", BetaStopReasonModelContextWindowExceeded, 0, false},
   288  		{"refusal turn skips execution", BetaStopReasonRefusal, 0, false},
   289  		{"end_turn skips execution", BetaStopReasonEndTurn, 0, false},
   290  		{"pause_turn skips execution", BetaStopReasonPauseTurn, 0, false},
   291  		{"compaction turn skips execution", BetaStopReasonCompaction, 0, false},
   292  		{"tool_use turn executes", BetaStopReasonToolUse, 1, true},
   293  	} {
   294  		t.Run(tt.name, func(t *testing.T) {
   295  			tool := &recordingTool{name: "rec"}
   296  			base := newBetaToolRunnerBase(nil, []BetaTool{tool}, BetaToolRunnerParams{}, nil)
   297  			message := &BetaMessage{
   298  				StopReason: tt.stopReason,
   299  				Content:    []BetaContentBlockUnion{toolUse},
   300  			}
   301  			result, err := base.executeTools(context.Background(), message)
   302  			if err != nil {
   303  				t.Fatalf("executeTools: %v", err)
   304  			}
   305  			if tool.calls != tt.wantCalls {
   306  				t.Errorf("tool executed %d times, want %d", tool.calls, tt.wantCalls)
   307  			}
   308  			if (result != nil) != tt.wantResult {
   309  				t.Errorf("result presence = %v, want %v", result != nil, tt.wantResult)
   310  			}
   311  		})
   312  	}
   313  }
   314  
   315  // Go cannot check that a switch covers every BetaStopReason constant, so this
   316  // hand-listed table is the exhaustiveness check: keep it in step with the
   317  // generated constants and decide each new one's bucket here.
   318  func TestDetermineNextStepFromStopReason(t *testing.T) {
   319  	want := []struct {
   320  		reason   BetaStopReason
   321  		nextStep toolRunnerStep
   322  	}{
   323  		{BetaStopReasonEndTurn, stepStop},
   324  		{BetaStopReasonMaxTokens, stepStop},
   325  		{BetaStopReasonStopSequence, stepStop},
   326  		{BetaStopReasonToolUse, stepRunTools},
   327  		{BetaStopReasonPauseTurn, stepResume},
   328  		{BetaStopReasonCompaction, stepResume},
   329  		{BetaStopReasonRefusal, stepStop},
   330  		{BetaStopReasonModelContextWindowExceeded, stepStop},
   331  		{"", stepStop},
   332  		{"some_future_reason", stepStop},
   333  	}
   334  	for _, tt := range want {
   335  		if got := determineNextStepFromStopReason(tt.reason); got != tt.nextStep {
   336  			t.Errorf("determineNextStepFromStopReason(%q) = %d, want %d", tt.reason, got, tt.nextStep)
   337  		}
   338  	}
   339  }
   340  
   341  // containerServer scripts POST /v1/messages like messagesServer, but the
   342  // first turn ran in a server-assigned container; it records each request body.
   343  func containerServer(t *testing.T, bodies *[]map[string]any) *httptest.Server {
   344  	t.Helper()
   345  	var calls atomic.Int32
   346  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   347  		var got map[string]any
   348  		if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
   349  			t.Errorf("decode request body: %v", err)
   350  		}
   351  		*bodies = append(*bodies, got)
   352  		var body string
   353  		if calls.Add(1) == 1 {
   354  			body = `{"id":"msg_1","type":"message","role":"assistant","model":"m","content":[{"type":"tool_use","id":"toolu_1","name":"weather","input":{"city":"SF"}}],"container":{"id":"container_123","expires_at":"2025-01-01T00:00:00Z","skills":[]},"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}`
   355  		} else {
   356  			body = `{"id":"msg_2","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"done"}],"container":null,"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}`
   357  		}
   358  		w.Header().Set("Content-Type", "application/json")
   359  		_, _ = w.Write([]byte(body))
   360  	}))
   361  	t.Cleanup(server.Close)
   362  	return server
   363  }
   364  
   365  // The follow-up request must name the container the previous turn ran in,
   366  // unless the caller pinned a container themselves.
   367  func TestBetaToolRunner_ForwardsContainer(t *testing.T) {
   368  	for _, tt := range []struct {
   369  		name   string
   370  		pinned BetaMessageNewParamsContainerUnion
   371  		want   any
   372  	}{
   373  		{"adopts server container", BetaMessageNewParamsContainerUnion{}, "container_123"},
   374  		{"keeps pinned id", BetaMessageNewParamsContainerUnion{OfString: String("container_mine")}, "container_mine"},
   375  		{"fills pinned params without id", BetaMessageNewParamsContainerUnion{OfContainers: &BetaContainerParams{}}, map[string]any{"id": "container_123"}},
   376  		{"keeps pinned params id", BetaMessageNewParamsContainerUnion{OfContainers: &BetaContainerParams{ID: String("container_mine")}}, map[string]any{"id": "container_mine"}},
   377  	} {
   378  		t.Run(tt.name, func(t *testing.T) {
   379  			var bodies []map[string]any
   380  			client := newTestToolRunnerClient(containerServer(t, &bodies))
   381  			runner := client.Beta.Messages.NewToolRunner(
   382  				[]BetaTool{&stubBetaTool{name: "weather"}},
   383  				BetaToolRunnerParams{BetaMessageNewParams: BetaMessageNewParams{
   384  					Model:     "m",
   385  					MaxTokens: 512,
   386  					Container: tt.pinned,
   387  					Messages:  []BetaMessageParam{NewBetaUserMessage(NewBetaTextBlock("What's the weather in SF?"))},
   388  				}, MaxIterations: 5},
   389  			)
   390  			if _, err := runner.RunToCompletion(context.Background()); err != nil {
   391  				t.Fatalf("RunToCompletion: %v", err)
   392  			}
   393  			if len(bodies) != 2 {
   394  				t.Fatalf("expected 2 requests, got %d", len(bodies))
   395  			}
   396  			gotJSON, _ := json.Marshal(bodies[1]["container"])
   397  			wantJSON, _ := json.Marshal(tt.want)
   398  			if string(gotJSON) != string(wantJSON) {
   399  				t.Fatalf("follow-up container = %s, want %s", gotJSON, wantJSON)
   400  			}
   401  		})
   402  	}
   403  }
   404  
   405  func TestBetaToolRunnerStreaming_ForwardsContainer(t *testing.T) {
   406  	var bodies []map[string]any
   407  	var calls atomic.Int32
   408  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   409  		var got map[string]any
   410  		if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
   411  			t.Errorf("decode request body: %v", err)
   412  		}
   413  		bodies = append(bodies, got)
   414  		w.Header().Set("Content-Type", "text/event-stream")
   415  		var events []string
   416  		if calls.Add(1) == 1 {
   417  			events = []string{
   418  				`event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"m","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}`,
   419  				`event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"weather","input":{}}}`,
   420  				`event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"SF\"}"}}`,
   421  				`event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`,
   422  				`event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null,"container":{"id":"container_123","expires_at":"2025-01-01T00:00:00Z","skills":[]}},"usage":{"output_tokens":5}}`,
   423  				`event: message_stop` + "\n" + `data: {"type":"message_stop"}`,
   424  			}
   425  		} else {
   426  			events = []string{
   427  				`event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_2","type":"message","role":"assistant","model":"m","content":[],"container":null,"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}`,
   428  				`event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
   429  				`event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"done"}}`,
   430  				`event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`,
   431  				`event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":5}}`,
   432  				`event: message_stop` + "\n" + `data: {"type":"message_stop"}`,
   433  			}
   434  		}
   435  		for _, e := range events {
   436  			_, _ = w.Write([]byte(e + "\n\n"))
   437  		}
   438  	}))
   439  	t.Cleanup(server.Close)
   440  
   441  	client := newTestToolRunnerClient(server)
   442  	runner := client.Beta.Messages.NewToolRunnerStreaming(
   443  		[]BetaTool{&stubBetaTool{name: "weather"}},
   444  		BetaToolRunnerParams{BetaMessageNewParams: BetaMessageNewParams{
   445  			Model:     "m",
   446  			MaxTokens: 512,
   447  			Messages:  []BetaMessageParam{NewBetaUserMessage(NewBetaTextBlock("What's the weather in SF?"))},
   448  		}, MaxIterations: 5},
   449  	)
   450  	for events := range runner.AllStreaming(context.Background()) {
   451  		for _, err := range events {
   452  			if err != nil {
   453  				t.Fatalf("streaming: %v", err)
   454  			}
   455  		}
   456  	}
   457  	if len(bodies) != 2 {
   458  		t.Fatalf("expected 2 requests, got %d", len(bodies))
   459  	}
   460  	if got := bodies[1]["container"]; got != "container_123" {
   461  		t.Fatalf("follow-up container = %v, want container_123", got)
   462  	}
   463  }
   464  
   465  // toolUseServer answers every POST /v1/messages with a fresh tool_use turn
   466  // (msg_1, msg_2, ...), so a runner only stops at MaxIterations.
   467  func toolUseServer(t *testing.T, streaming bool) (*httptest.Server, *atomic.Int32) {
   468  	t.Helper()
   469  	var calls atomic.Int32
   470  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   471  		n := calls.Add(1)
   472  		if !streaming {
   473  			w.Header().Set("Content-Type", "application/json")
   474  			fmt.Fprintf(w, `{"id":"msg_%d","type":"message","role":"assistant","model":"m","content":[{"type":"tool_use","id":"toolu_%d","name":"weather","input":{"city":"SF"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}`, n, n)
   475  			return
   476  		}
   477  		w.Header().Set("Content-Type", "text/event-stream")
   478  		for _, e := range []string{
   479  			fmt.Sprintf(`event: message_start`+"\n"+`data: {"type":"message_start","message":{"id":"msg_%d","type":"message","role":"assistant","model":"m","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}`, n),
   480  			fmt.Sprintf(`event: content_block_start`+"\n"+`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_%d","name":"weather","input":{}}}`, n),
   481  			`event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\":\"SF\"}"}}`,
   482  			`event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`,
   483  			`event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":5}}`,
   484  			`event: message_stop` + "\n" + `data: {"type":"message_stop"}`,
   485  		} {
   486  			_, _ = w.Write([]byte(e + "\n\n"))
   487  		}
   488  	}))
   489  	t.Cleanup(server.Close)
   490  	return server, &calls
   491  }
   492  
   493  func newCappedRunnerParams(maxIterations int) BetaToolRunnerParams {
   494  	return BetaToolRunnerParams{BetaMessageNewParams: BetaMessageNewParams{
   495  		Model:     "m",
   496  		MaxTokens: 512,
   497  		Messages:  []BetaMessageParam{NewBetaUserMessage(NewBetaTextBlock("What's the weather in SF?"))},
   498  	}, MaxIterations: maxIterations}
   499  }
   500  
   501  func requireYieldedOnce(t *testing.T, ids []string, want ...string) {
   502  	t.Helper()
   503  	if fmt.Sprint(ids) != fmt.Sprint(want) {
   504  		t.Fatalf("yielded messages = %v, want %v", ids, want)
   505  	}
   506  }
   507  
   508  // All() yields one message per API call: at MaxIterations the K-th message is
   509  // yielded once and no further request is made.
   510  func TestBetaToolRunner_All_MaxIterationsYieldsEachMessageOnce(t *testing.T) {
   511  	const maxIterations = 3
   512  	server, calls := toolUseServer(t, false)
   513  	client := newTestToolRunnerClient(server)
   514  	runner := client.Beta.Messages.NewToolRunner([]BetaTool{&stubBetaTool{name: "weather"}}, newCappedRunnerParams(maxIterations))
   515  
   516  	var ids []string
   517  	for msg, err := range runner.All(context.Background()) {
   518  		if err != nil {
   519  			t.Fatalf("All: %v", err)
   520  		}
   521  		ids = append(ids, msg.ID)
   522  	}
   523  	requireYieldedOnce(t, ids, "msg_1", "msg_2", "msg_3")
   524  	if got := calls.Load(); got != maxIterations {
   525  		t.Fatalf("expected %d requests, got %d", maxIterations, got)
   526  	}
   527  	if got := runner.IterationCount(); got != maxIterations {
   528  		t.Fatalf("expected IterationCount %d, got %d", maxIterations, got)
   529  	}
   530  	if msg, err := runner.NextMessage(context.Background()); msg != nil || err != nil {
   531  		t.Fatalf("NextMessage after completion = (%v, %v), want (nil, nil)", msg, err)
   532  	}
   533  }
   534  
   535  // All() yields the final answer once when the model stops using tools.
   536  func TestBetaToolRunner_All_YieldsFinalMessageOnce(t *testing.T) {
   537  	client := newTestToolRunnerClient(messagesServer(t))
   538  	runner := client.Beta.Messages.NewToolRunner([]BetaTool{&stubBetaTool{name: "weather"}}, newCappedRunnerParams(0))
   539  
   540  	var ids []string
   541  	for msg, err := range runner.All(context.Background()) {
   542  		if err != nil {
   543  			t.Fatalf("All: %v", err)
   544  		}
   545  		ids = append(ids, msg.ID)
   546  	}
   547  	requireYieldedOnce(t, ids, "msg_1", "msg_2")
   548  	if got := runner.IterationCount(); got != 2 {
   549  		t.Fatalf("expected IterationCount 2, got %d", got)
   550  	}
   551  	if last := runner.LastMessage(); last == nil || last.ID != "msg_2" {
   552  		t.Fatalf("LastMessage = %v, want msg_2", last)
   553  	}
   554  }
   555  
   556  // AllStreaming streams one turn per API call: at MaxIterations no message is
   557  // streamed twice and no further request is made.
   558  func TestBetaToolRunnerStreaming_AllStreaming_MaxIterationsStreamsEachMessageOnce(t *testing.T) {
   559  	const maxIterations = 3
   560  	server, calls := toolUseServer(t, true)
   561  	client := newTestToolRunnerClient(server)
   562  	runner := client.Beta.Messages.NewToolRunnerStreaming([]BetaTool{&stubBetaTool{name: "weather"}}, newCappedRunnerParams(maxIterations))
   563  
   564  	var ids []string
   565  	for events, err := range runner.AllStreaming(context.Background()) {
   566  		if err != nil {
   567  			t.Fatalf("AllStreaming: %v", err)
   568  		}
   569  		for event, err := range events {
   570  			if err != nil {
   571  				t.Fatalf("streaming: %v", err)
   572  			}
   573  			if start, ok := event.AsAny().(BetaRawMessageStartEvent); ok {
   574  				ids = append(ids, start.Message.ID)
   575  			}
   576  		}
   577  	}
   578  	requireYieldedOnce(t, ids, "msg_1", "msg_2", "msg_3")
   579  	if got := calls.Load(); got != maxIterations {
   580  		t.Fatalf("expected %d requests, got %d", maxIterations, got)
   581  	}
   582  	if last := runner.LastMessage(); last == nil || last.ID != "msg_3" {
   583  		t.Fatalf("LastMessage = %v, want msg_3", last)
   584  	}
   585  }
   586  
   587  const (
   588  	pausedTurnJSON = `{"id":"msg_paused","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"Let me look that up."},{"type":"server_tool_use","id":"srvtoolu_1","name":"web_search","input":{"query":"weather in SF"}}],"stop_reason":"pause_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}`
   589  	endTurnJSON    = `{"id":"msg_end","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"done"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}`
   590  	// compactedTurnJSON is what pause_after_compaction returns: the summary
   591  	// block, handed back before the model has answered.
   592  	compactedTurnJSON = `{"id":"msg_compacted","type":"message","role":"assistant","model":"m","content":[{"type":"compaction","content":"Summary: the user asked about the weather in SF."}],"stop_reason":"compaction","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}`
   593  	// cutOffToolUseJSON is a max_tokens turn that still carries a client tool call.
   594  	cutOffToolUseJSON = `{"id":"msg_cut","type":"message","role":"assistant","model":"m","content":[{"type":"tool_use","id":"toolu_1","name":"rec","input":{}}],"stop_reason":"max_tokens","stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}`
   595  )
   596  
   597  // scriptedMessagesServer answers POST /v1/messages with the scripted message
   598  // bodies in order (as SSE when the request sets "stream") and records every
   599  // request body. A request past the end of the script fails the test.
   600  func scriptedMessagesServer(t *testing.T, script ...string) (*httptest.Server, func() [][]byte) {
   601  	t.Helper()
   602  	var (
   603  		mu     sync.Mutex
   604  		bodies [][]byte
   605  	)
   606  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   607  		body, err := io.ReadAll(r.Body)
   608  		if err != nil {
   609  			t.Errorf("reading request body: %v", err)
   610  			http.Error(w, "bad body", http.StatusBadRequest)
   611  			return
   612  		}
   613  		mu.Lock()
   614  		bodies = append(bodies, body)
   615  		n := len(bodies)
   616  		mu.Unlock()
   617  		if n > len(script) {
   618  			t.Errorf("unexpected request %d: only %d responses scripted", n, len(script))
   619  			http.Error(w, "unscripted", http.StatusInternalServerError)
   620  			return
   621  		}
   622  		if gjson.GetBytes(body, "stream").Bool() {
   623  			w.Header().Set("Content-Type", "text/event-stream")
   624  			writeMessageEvents(w, script[n-1])
   625  			return
   626  		}
   627  		w.Header().Set("Content-Type", "application/json")
   628  		_, _ = w.Write([]byte(script[n-1]))
   629  	}))
   630  	t.Cleanup(server.Close)
   631  	return server, func() [][]byte {
   632  		mu.Lock()
   633  		defer mu.Unlock()
   634  		return append([][]byte(nil), bodies...)
   635  	}
   636  }
   637  
   638  // writeMessageEvents replays a complete message body as a stream, one
   639  // content_block_start per block carrying the whole block.
   640  func writeMessageEvents(w io.Writer, message string) {
   641  	start, _ := sjson.SetRaw(message, "content", "[]")
   642  	start, _ = sjson.SetRaw(start, "stop_reason", "null")
   643  	fmt.Fprintf(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":%s}\n\n", start)
   644  	for i, block := range gjson.Get(message, "content").Array() {
   645  		fmt.Fprintf(w, "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":%d,\"content_block\":%s}\n\n", i, block.Raw)
   646  		fmt.Fprintf(w, "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":%d}\n\n", i)
   647  	}
   648  	fmt.Fprintf(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":%s,\"stop_sequence\":null},\"usage\":%s}\n\n",
   649  		gjson.Get(message, "stop_reason").Raw, gjson.Get(message, "usage").Raw)
   650  	fmt.Fprint(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
   651  }
   652  
   653  func requireJSONEqual(t *testing.T, got, want string) {
   654  	t.Helper()
   655  	var g, w any
   656  	if err := json.Unmarshal([]byte(got), &g); err != nil {
   657  		t.Fatalf("unmarshal got: %v\n%s", err, got)
   658  	}
   659  	if err := json.Unmarshal([]byte(want), &w); err != nil {
   660  		t.Fatalf("unmarshal want: %v\n%s", err, want)
   661  	}
   662  	if !reflect.DeepEqual(g, w) {
   663  		t.Fatalf("JSON mismatch\n got: %s\nwant: %s", got, want)
   664  	}
   665  }
   666  
   667  func pauseTurnParams(maxIterations int) BetaToolRunnerParams {
   668  	return BetaToolRunnerParams{BetaMessageNewParams: BetaMessageNewParams{
   669  		Model:     "m",
   670  		MaxTokens: 512,
   671  		Messages:  []BetaMessageParam{NewBetaUserMessage(NewBetaTextBlock("What's the weather in SF?"))},
   672  	}, MaxIterations: maxIterations}
   673  }
   674  
   675  // A pause_turn response has no client tool calls to answer but is not
   676  // finished: the runner must send the paused turn back so the server resumes it.
   677  func TestBetaToolRunner_PauseTurnResumes(t *testing.T) {
   678  	requireTurnResumed(t, pausedTurnJSON)
   679  }
   680  
   681  // A compaction-stopped response is handed back before the model answers: the
   682  // runner must send the compacted turn back unchanged, with no tool_result
   683  // turn, so the model continues from the summary.
   684  func TestBetaToolRunner_CompactionResumes(t *testing.T) {
   685  	requireTurnResumed(t, compactedTurnJSON)
   686  }
   687  
   688  // requireTurnResumed scripts unfinishedTurn followed by a normal end_turn and
   689  // checks, for both runners, that exactly two requests are made and the second
   690  // ends with the unfinished assistant turn echoed verbatim.
   691  func requireTurnResumed(t *testing.T, unfinishedTurn string) {
   692  	t.Helper()
   693  	for _, stream := range []bool{false, true} {
   694  		t.Run(fmt.Sprintf("stream=%v", stream), func(t *testing.T) {
   695  			server, requests := scriptedMessagesServer(t, unfinishedTurn, endTurnJSON)
   696  			client := newTestToolRunnerClient(server)
   697  			ctx := context.Background()
   698  
   699  			var final *BetaMessage
   700  			var history []BetaMessageParam
   701  			if stream {
   702  				runner := client.Beta.Messages.NewToolRunnerStreaming(nil, pauseTurnParams(5))
   703  				for events, err := range runner.AllStreaming(ctx) {
   704  					if err != nil {
   705  						t.Fatalf("AllStreaming: %v", err)
   706  					}
   707  					for _, err := range events {
   708  						if err != nil {
   709  							t.Fatalf("streaming turn: %v", err)
   710  						}
   711  					}
   712  				}
   713  				final, history = runner.LastMessage(), runner.Messages()
   714  			} else {
   715  				runner := client.Beta.Messages.NewToolRunner(nil, pauseTurnParams(5))
   716  				var err error
   717  				if final, err = runner.RunToCompletion(ctx); err != nil {
   718  					t.Fatalf("RunToCompletion: %v", err)
   719  				}
   720  				history = runner.Messages()
   721  			}
   722  
   723  			if final == nil {
   724  				t.Fatal("expected a final message, got nil")
   725  			}
   726  			if final.ID != "msg_end" || final.StopReason != BetaStopReasonEndTurn {
   727  				t.Fatalf("final message = %s (%s), want msg_end (end_turn)", final.ID, final.StopReason)
   728  			}
   729  			bodies := requests()
   730  			if len(bodies) != 2 {
   731  				t.Fatalf("expected 2 requests, got %d", len(bodies))
   732  			}
   733  			sent := gjson.GetBytes(bodies[1], "messages").Array()
   734  			if len(sent) != 2 {
   735  				t.Fatalf("second request should carry the user turn plus the unfinished turn, got %d messages", len(sent))
   736  			}
   737  			if role := sent[1].Get("role").String(); role != "assistant" {
   738  				t.Fatalf("last message role = %q, want assistant", role)
   739  			}
   740  			requireJSONEqual(t, sent[1].Get("content").Raw, gjson.Get(unfinishedTurn, "content").Raw)
   741  			if len(history) != 3 {
   742  				t.Fatalf("expected user + unfinished + resumed turns in history, got %d messages", len(history))
   743  			}
   744  		})
   745  	}
   746  }
   747  
   748  // A max_tokens turn is final even when it carries a client tool call: the
   749  // runner makes no follow-up request and never invokes the tool.
   750  func TestBetaToolRunner_MaxTokensToolUseStops(t *testing.T) {
   751  	server, requests := scriptedMessagesServer(t, cutOffToolUseJSON)
   752  	client := newTestToolRunnerClient(server)
   753  	tool := &recordingTool{name: "rec"}
   754  	runner := client.Beta.Messages.NewToolRunner([]BetaTool{tool}, pauseTurnParams(5))
   755  
   756  	final, err := runner.RunToCompletion(context.Background())
   757  	if err != nil {
   758  		t.Fatalf("RunToCompletion: %v", err)
   759  	}
   760  	if final == nil {
   761  		t.Fatal("expected a final message, got nil")
   762  	}
   763  	if final.StopReason != BetaStopReasonMaxTokens {
   764  		t.Fatalf("final stop_reason = %s, want max_tokens", final.StopReason)
   765  	}
   766  	if got := len(requests()); got != 1 {
   767  		t.Fatalf("expected 1 request, got %d", got)
   768  	}
   769  	if tool.calls != 0 {
   770  		t.Fatalf("cut-off tool call must not execute, ran %d times", tool.calls)
   771  	}
   772  	if got := len(runner.Messages()); got != 2 {
   773  		t.Fatalf("expected user + cut-off turns in history with no tool_result, got %d messages", got)
   774  	}
   775  }
   776  
   777  // A server that keeps pausing is still bounded by MaxIterations.
   778  func TestBetaToolRunner_PauseTurnStopsAtMaxIterations(t *testing.T) {
   779  	server, requests := scriptedMessagesServer(t, pausedTurnJSON, pausedTurnJSON, pausedTurnJSON)
   780  	client := newTestToolRunnerClient(server)
   781  	runner := client.Beta.Messages.NewToolRunner(nil, pauseTurnParams(3))
   782  
   783  	final, err := runner.RunToCompletion(context.Background())
   784  	if err != nil {
   785  		t.Fatalf("RunToCompletion: %v", err)
   786  	}
   787  	if final == nil {
   788  		t.Fatal("expected a final message, got nil")
   789  	}
   790  	if final.StopReason != BetaStopReasonPauseTurn {
   791  		t.Fatalf("final stop_reason = %s, want pause_turn", final.StopReason)
   792  	}
   793  	if got := len(requests()); got != 3 {
   794  		t.Fatalf("expected 3 requests, got %d", got)
   795  	}
   796  }