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 }