github.com/anthropics/anthropic-sdk-go@v1.70.1/betasessiontoolrunner_test.go (about) 1 package anthropic 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io" 8 "log/slog" 9 "net/http" 10 "net/http/httptest" 11 "sort" 12 "strings" 13 "sync" 14 "sync/atomic" 15 "testing" 16 "time" 17 18 "github.com/anthropics/anthropic-sdk-go/internal/sendwindow" 19 "github.com/anthropics/anthropic-sdk-go/option" 20 "github.com/anthropics/anthropic-sdk-go/packages/param" 21 "github.com/stretchr/testify/require" 22 ) 23 24 // --------------------------------------------------------------------------- 25 // Test helpers 26 // --------------------------------------------------------------------------- 27 28 var sessionRunnerSilentLogger = slog.New(slog.NewTextHandler(io.Discard, nil)) 29 30 // stubBetaTool is a BetaTool with controllable Execute behavior, used to 31 // exercise both happy and failure paths in the runner. 32 type stubBetaTool struct { 33 name string 34 run func(ctx context.Context, input json.RawMessage) (string, bool) 35 runs atomic.Int32 36 } 37 38 func (s *stubBetaTool) Name() string { return s.name } 39 func (s *stubBetaTool) Description() string { return s.name } 40 func (s *stubBetaTool) InputSchema() BetaToolInputSchemaParam { 41 return BetaToolInputSchemaParam{Properties: map[string]any{}} 42 } 43 func (s *stubBetaTool) Execute(ctx context.Context, input json.RawMessage) ([]BetaToolResultBlockParamContentUnion, error) { 44 s.runs.Add(1) 45 content, isErr := "ok from "+s.name, false 46 if s.run != nil { 47 content, isErr = s.run(ctx, input) 48 } 49 if isErr { 50 return nil, fmt.Errorf("%s", content) 51 } 52 return []BetaToolResultBlockParamContentUnion{{OfText: &BetaTextBlockParam{Text: content}}}, nil 53 } 54 55 // dispatchedResultText joins the text blocks of the result event the runner 56 // built for call — CustomResult when call.Custom, otherwise Result. Replaces 57 // the removed flat DispatchedToolCall.Content convenience field. 58 func dispatchedResultText(call DispatchedToolCall) string { 59 var out string 60 add := func(s string) { 61 if out != "" { 62 out += "\n" 63 } 64 out += s 65 } 66 if call.Custom { 67 for _, b := range call.CustomResult.Content { 68 if b.OfText != nil { 69 add(b.OfText.Text) 70 } 71 } 72 return out 73 } 74 for _, b := range call.Result.Content { 75 if b.OfText != nil { 76 add(b.OfText.Text) 77 } 78 } 79 return out 80 } 81 82 func sseLine(eventType string, payload any) string { 83 body, _ := json.Marshal(payload) 84 return fmt.Sprintf("event: %s\ndata: %s\n\n", eventType, body) 85 } 86 87 func toolUseEvt(id, name string, input map[string]any) map[string]any { 88 return map[string]any{ 89 "type": "agent.tool_use", 90 "id": id, 91 "name": name, 92 "input": input, 93 "processed_at": "2026-05-11T12:00:00Z", 94 } 95 } 96 97 func customToolUseEvt(id, name string, input map[string]any) map[string]any { 98 return map[string]any{ 99 "type": "agent.custom_tool_use", 100 "id": id, 101 "name": name, 102 "input": input, 103 "processed_at": "2026-05-11T12:00:00Z", 104 } 105 } 106 107 func plainEvt(id, eventType string) map[string]any { 108 return map[string]any{"type": eventType, "id": id, "processed_at": "2026-05-11T12:00:00Z"} 109 } 110 111 // askToolUseEvt is a toolUseEvt with evaluated_permission set (e.g. "ask" 112 // for an always_ask tool, "deny" for a server-blocked one). 113 func askToolUseEvt(id, name string, input map[string]any, permission string) map[string]any { 114 ev := toolUseEvt(id, name, input) 115 ev["evaluated_permission"] = permission 116 return ev 117 } 118 119 // toolConfirmationEvt is a user.tool_confirmation resolving a held tool_use: 120 // "allow" releases it to run, "deny" drops it. 121 func toolConfirmationEvt(toolUseID, result string) map[string]any { 122 return map[string]any{ 123 "type": "user.tool_confirmation", 124 "id": "evt_conf_" + toolUseID, 125 "tool_use_id": toolUseID, 126 "result": result, 127 "processed_at": "2026-05-11T12:00:00Z", 128 } 129 } 130 131 func idleEndTurnEvt(id string) map[string]any { 132 return map[string]any{ 133 "type": "session.status_idle", 134 "id": id, 135 "stop_reason": map[string]any{"type": "end_turn"}, 136 "processed_at": "2026-05-11T12:00:00Z", 137 } 138 } 139 140 // idleRequiresActionEvt is a session.status_idle with stop_reason 141 // "requires_action" — the server parks here waiting on the listed events 142 // (tool confirmations, tool results). 143 func idleRequiresActionEvt(id string, eventIDs ...string) map[string]any { 144 return map[string]any{ 145 "type": "session.status_idle", 146 "id": id, 147 "stop_reason": map[string]any{"type": "requires_action", "event_ids": eventIDs}, 148 "processed_at": "2026-05-11T12:00:00Z", 149 } 150 } 151 152 func emptyEventList() string { 153 body, _ := json.Marshal(map[string]any{"data": []any{}, "first_id": nil, "has_more": false, "last_id": nil}) 154 return string(body) 155 } 156 157 func sendOK() string { 158 body, _ := json.Marshal(map[string]any{"type": "send_session_events"}) 159 return string(body) 160 } 161 162 // sessionEventsServer is a minimal httptest server scripting the 163 // /v1/sessions/{id}/events{,/stream} endpoints the SessionToolRunner uses. 164 type sessionEventsServer struct { 165 t *testing.T 166 server *httptest.Server 167 168 HandleStream http.HandlerFunc // GET /v1/sessions/{id}/events/stream 169 HandleSend http.HandlerFunc // POST /v1/sessions/{id}/events 170 HandleList http.HandlerFunc // GET /v1/sessions/{id}/events 171 } 172 173 func newSessionEventsServer(t *testing.T) *sessionEventsServer { 174 s := &sessionEventsServer{t: t} 175 s.server = httptest.NewServer(http.HandlerFunc(s.serveHTTP)) 176 t.Cleanup(s.server.Close) 177 return s 178 } 179 180 func (s *sessionEventsServer) serveHTTP(w http.ResponseWriter, r *http.Request) { 181 switch { 182 case strings.HasSuffix(r.URL.Path, "/events/stream"): 183 require.NotNil(s.t, s.HandleStream, "unscripted StreamEvents call") 184 s.HandleStream(w, r) 185 case strings.HasSuffix(r.URL.Path, "/events"): 186 switch r.Method { 187 case http.MethodPost: 188 require.NotNil(s.t, s.HandleSend, "unscripted events.Send call") 189 s.HandleSend(w, r) 190 case http.MethodGet: 191 if s.HandleList != nil { 192 s.HandleList(w, r) 193 return 194 } 195 w.Header().Set("Content-Type", "application/json") 196 _, _ = w.Write([]byte(emptyEventList())) 197 default: 198 http.Error(w, "unexpected", http.StatusMethodNotAllowed) 199 } 200 default: 201 s.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) 202 http.Error(w, "unexpected", http.StatusNotImplemented) 203 } 204 } 205 206 func (s *sessionEventsServer) Client() Client { 207 return NewClient( 208 option.WithBaseURL(s.server.URL), 209 option.WithAPIKey("test-key"), 210 option.WithMaxRetries(0), 211 ) 212 } 213 214 // streamWriter writes scripted SSE events and optionally holds the connection 215 // open until the request context is cancelled. 216 func streamWriter(w http.ResponseWriter, r *http.Request, events []string, holdOpen bool) { 217 w.Header().Set("Content-Type", "text/event-stream") 218 flusher, ok := w.(http.Flusher) 219 if !ok { 220 http.Error(w, "no flusher", http.StatusInternalServerError) 221 return 222 } 223 w.WriteHeader(http.StatusOK) 224 flusher.Flush() 225 for _, ev := range events { 226 if _, err := w.Write([]byte(ev)); err != nil { 227 return 228 } 229 flusher.Flush() 230 } 231 if holdOpen { 232 <-r.Context().Done() 233 } 234 } 235 236 func newShortIdleRunner(t *testing.T, ctx context.Context, client Client, tools []BetaTool, maxIdle time.Duration) *SessionToolRunner { 237 r := client.Beta.Sessions.Events.NewToolRunner(ctx, "sesn_test", SessionToolRunnerOptions{ 238 Tools: tools, 239 MaxIdle: &maxIdle, 240 Logger: sessionRunnerSilentLogger, 241 }) 242 t.Cleanup(func() { _ = r.Close() }) 243 return r 244 } 245 246 // --------------------------------------------------------------------------- 247 // Tests 248 // --------------------------------------------------------------------------- 249 250 func TestSessionToolRunner_RequiresSessionID(t *testing.T) { 251 client := NewClient(option.WithAPIKey("k")) 252 r := client.Beta.Sessions.Events.NewToolRunner(context.Background(), "", SessionToolRunnerOptions{}) 253 require.False(t, r.Next()) 254 require.Error(t, r.Err()) 255 require.NoError(t, r.Close()) 256 } 257 258 func TestSessionToolRunner_YieldsAndPostsResult(t *testing.T) { 259 server := newSessionEventsServer(t) 260 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 261 streamWriter(w, r, []string{ 262 sseLine("agent.tool_use", toolUseEvt("evt_1", "echo", map[string]any{"text": "hi"})), 263 }, true) 264 } 265 var sent atomic.Int32 266 server.HandleSend = func(w http.ResponseWriter, r *http.Request) { 267 body, _ := io.ReadAll(r.Body) 268 require.Contains(t, string(body), "evt_1") 269 require.Contains(t, string(body), "user.tool_result") 270 sent.Add(1) 271 w.Header().Set("Content-Type", "application/json") 272 _, _ = w.Write([]byte(sendOK())) 273 } 274 275 echo := &stubBetaTool{name: "echo", run: func(_ context.Context, input json.RawMessage) (string, bool) { 276 return "echoed: " + string(input), false 277 }} 278 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 279 defer cancel() 280 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{echo}, 500*time.Millisecond) 281 282 require.True(t, r.Next(), "first Next must yield the dispatched tool call") 283 call := r.Current() 284 require.Equal(t, "evt_1", call.ToolUseID) 285 require.Equal(t, "echo", call.Name) 286 require.False(t, call.IsError) 287 require.True(t, call.Posted) 288 require.Contains(t, dispatchedResultText(call), "echoed") 289 require.Equal(t, int32(1), echo.runs.Load()) 290 require.Equal(t, int32(1), sent.Load()) 291 require.NoError(t, r.Close()) 292 } 293 294 func TestSessionToolRunner_EmptyTextResultPostsPlaceholder(t *testing.T) { 295 server := newSessionEventsServer(t) 296 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 297 streamWriter(w, r, []string{ 298 sseLine("agent.tool_use", toolUseEvt("evt_1", "silent", map[string]any{})), 299 }, true) 300 } 301 var posted atomic.Value 302 server.HandleSend = func(w http.ResponseWriter, r *http.Request) { 303 body, _ := io.ReadAll(r.Body) 304 posted.Store(string(body)) 305 w.Header().Set("Content-Type", "application/json") 306 _, _ = w.Write([]byte(sendOK())) 307 } 308 309 // A tool that succeeds with no output (e.g. bash `true`) yields a single 310 // empty text block; the Sessions API rejects those, so the runner must 311 // post a non-empty placeholder instead of wedging the session on a 400. 312 silent := &stubBetaTool{name: "silent", run: func(context.Context, json.RawMessage) (string, bool) { 313 return "", false 314 }} 315 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 316 defer cancel() 317 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{silent}, 500*time.Millisecond) 318 319 require.True(t, r.Next()) 320 call := r.Current() 321 require.True(t, call.Posted) 322 require.False(t, call.IsError) 323 body, _ := posted.Load().(string) 324 require.NotContains(t, body, `"text":""`, "an empty text block must never reach the wire") 325 require.Contains(t, body, `"text":"(no output)"`) 326 require.Equal(t, "(no output)", dispatchedResultText(call)) 327 require.NoError(t, r.Close()) 328 } 329 330 func TestSessionToolRunner_DispatchesCustomTool(t *testing.T) { 331 server := newSessionEventsServer(t) 332 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 333 streamWriter(w, r, []string{ 334 sseLine("agent.custom_tool_use", customToolUseEvt("cevt_1", "lookup", map[string]any{"q": "x"})), 335 }, true) 336 } 337 var sent atomic.Int32 338 server.HandleSend = func(w http.ResponseWriter, r *http.Request) { 339 body, _ := io.ReadAll(r.Body) 340 require.Contains(t, string(body), "user.custom_tool_result", 341 "a custom tool call must be answered with user.custom_tool_result") 342 require.Contains(t, string(body), "custom_tool_use_id") 343 require.Contains(t, string(body), "cevt_1") 344 require.NotContains(t, string(body), "user.tool_result") 345 sent.Add(1) 346 w.Header().Set("Content-Type", "application/json") 347 _, _ = w.Write([]byte(sendOK())) 348 } 349 350 lookup := &stubBetaTool{name: "lookup", run: func(_ context.Context, input json.RawMessage) (string, bool) { 351 return "looked up: " + string(input), false 352 }} 353 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 354 defer cancel() 355 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{lookup}, 500*time.Millisecond) 356 357 require.True(t, r.Next(), "first Next must yield the dispatched custom tool call") 358 call := r.Current() 359 require.True(t, call.Custom, "dispatch must be flagged as a custom tool call") 360 require.Equal(t, "cevt_1", call.ToolUseID) 361 require.Equal(t, "cevt_1", call.CustomToolUse.ID, "the embedded agent.custom_tool_use event must be populated") 362 require.Equal(t, "lookup", call.Name) 363 require.Equal(t, "cevt_1", call.CustomResult.CustomToolUseID, "the posted user.custom_tool_result must reference the event id") 364 require.False(t, call.IsError) 365 require.True(t, call.Posted) 366 require.Contains(t, dispatchedResultText(call), "looked up") 367 require.Equal(t, int32(1), lookup.runs.Load()) 368 require.Equal(t, int32(1), sent.Load()) 369 require.NoError(t, r.Close()) 370 } 371 372 func TestSessionToolRunner_DispatchesBuiltinAndCustom(t *testing.T) { 373 server := newSessionEventsServer(t) 374 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 375 streamWriter(w, r, []string{ 376 sseLine("agent.tool_use", toolUseEvt("evt_b", "echo", map[string]any{})), 377 sseLine("agent.custom_tool_use", customToolUseEvt("evt_c", "echo", map[string]any{})), 378 }, true) 379 } 380 var builtinResults, customResults atomic.Int32 381 server.HandleSend = func(w http.ResponseWriter, r *http.Request) { 382 body, _ := io.ReadAll(r.Body) 383 switch { 384 case strings.Contains(string(body), "user.custom_tool_result"): 385 require.Contains(t, string(body), "evt_c") 386 customResults.Add(1) 387 case strings.Contains(string(body), "user.tool_result"): 388 require.Contains(t, string(body), "evt_b") 389 builtinResults.Add(1) 390 default: 391 t.Errorf("send body carries no tool result: %s", body) 392 } 393 w.Header().Set("Content-Type", "application/json") 394 _, _ = w.Write([]byte(sendOK())) 395 } 396 397 echo := &stubBetaTool{name: "echo"} 398 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 399 defer cancel() 400 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{echo}, 500*time.Millisecond) 401 402 seen := map[string]DispatchedToolCall{} 403 for range 2 { 404 require.True(t, r.Next()) 405 call := r.Current() 406 seen[call.ToolUseID] = call 407 } 408 require.NoError(t, r.Close()) 409 410 builtin, ok := seen["evt_b"] 411 require.True(t, ok, "the builtin agent.tool_use was not dispatched") 412 require.False(t, builtin.Custom) 413 require.Equal(t, "evt_b", builtin.Result.ToolUseID) 414 require.True(t, builtin.Posted) 415 416 custom, ok := seen["evt_c"] 417 require.True(t, ok, "the agent.custom_tool_use was not dispatched") 418 require.True(t, custom.Custom) 419 require.Equal(t, "evt_c", custom.CustomResult.CustomToolUseID) 420 require.True(t, custom.Posted) 421 422 require.Equal(t, int32(1), builtinResults.Load()) 423 require.Equal(t, int32(1), customResults.Load()) 424 require.Equal(t, int32(2), echo.runs.Load()) 425 } 426 427 // TestSessionToolRunner_ReconcileRetriesFailedPost covers the regression where 428 // a tool_use whose result post failed was marked answered anyway and so never 429 // retried. The post must only count as answered once it actually succeeds, so 430 // the reconcile after a reconnect re-dispatches the still-unanswered call. 431 func TestSessionToolRunner_ReconcileRetriesFailedPost(t *testing.T) { 432 server := newSessionEventsServer(t) 433 434 var streamConns atomic.Int32 435 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 436 if streamConns.Add(1) == 1 { 437 // First connection: deliver the tool_use, then close so the runner 438 // reconnects and reconciles. 439 streamWriter(w, r, []string{ 440 sseLine("agent.tool_use", toolUseEvt("evt_1", "echo", map[string]any{})), 441 }, false) 442 return 443 } 444 streamWriter(w, r, nil, true) // later connections: hold open, no events 445 } 446 447 var lists atomic.Int32 448 server.HandleList = func(w http.ResponseWriter, _ *http.Request) { 449 w.Header().Set("Content-Type", "application/json") 450 // The reconcile after the reconnect must still see the unanswered 451 // tool_use in history so it can re-dispatch it. 452 if lists.Add(1) >= 2 { 453 body, _ := json.Marshal(map[string]any{ 454 "data": []any{toolUseEvt("evt_1", "echo", map[string]any{})}, 455 "first_id": "evt_1", "has_more": false, "last_id": "evt_1", 456 }) 457 _, _ = w.Write(body) 458 return 459 } 460 _, _ = w.Write([]byte(emptyEventList())) 461 } 462 463 var sends atomic.Int32 464 server.HandleSend = func(w http.ResponseWriter, _ *http.Request) { 465 if sends.Add(1) == 1 { 466 // First post fails permanently — the call must NOT be marked 467 // answered, so the next reconcile retries it. 468 http.Error(w, "bad", http.StatusBadRequest) 469 return 470 } 471 w.Header().Set("Content-Type", "application/json") 472 _, _ = w.Write([]byte(sendOK())) 473 } 474 475 echo := &stubBetaTool{name: "echo"} 476 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 477 defer cancel() 478 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{echo}, 0) 479 480 require.True(t, r.Next(), "first dispatch should be yielded") 481 first := r.Current() 482 require.Equal(t, "evt_1", first.ToolUseID) 483 require.False(t, first.Posted, "the first result post fails (permanent 4xx)") 484 485 require.True(t, r.Next(), "reconcile after reconnect must re-dispatch the unanswered tool_use") 486 second := r.Current() 487 require.Equal(t, "evt_1", second.ToolUseID) 488 require.True(t, second.Posted, "the retried result post succeeds") 489 490 require.GreaterOrEqual(t, echo.runs.Load(), int32(2), "the tool is re-executed on the retry") 491 require.NoError(t, r.Close()) 492 } 493 494 // TestSessionToolRunner_SkipsUnownedToolByDefault pins the default 495 // split-client behavior: a tool-use event whose Name is not in the runner's 496 // registry belongs to the other client servicing the session (e.g. the 497 // customer's app backend handling custom tools). The runner must post NO 498 // result for it, claim nothing, and leave the tool_use_id pending — while 499 // still yielding the DispatchedToolCall so the caller can observe the unowned 500 // dispatch (Posted=false, IsError=false, no result event populated). It must 501 // not panic on the registry miss. 502 func TestSessionToolRunner_SkipsUnownedToolByDefault(t *testing.T) { 503 server := newSessionEventsServer(t) 504 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 505 streamWriter(w, r, []string{ 506 sseLine("agent.tool_use", toolUseEvt("evt_99", "not_ours", map[string]any{})), 507 sseLine("agent.custom_tool_use", customToolUseEvt("cevt_99", "app_backend_tool", map[string]any{})), 508 }, true) 509 } 510 server.HandleSend = func(http.ResponseWriter, *http.Request) { 511 t.Fatal("runner must not post any result for a tool it does not own") 512 } 513 514 // The runner owns "echo" but neither streamed event is for it. 515 echo := &stubBetaTool{name: "echo"} 516 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 517 defer cancel() 518 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{echo}, 500*time.Millisecond) 519 520 seen := map[string]DispatchedToolCall{} 521 for range 2 { 522 require.True(t, r.Next(), "the unowned call must still be surfaced to the caller") 523 call := r.Current() 524 seen[call.ToolUseID] = call 525 } 526 527 builtin, ok := seen["evt_99"] 528 require.True(t, ok, "the unowned agent.tool_use must still be yielded") 529 require.Equal(t, "not_ours", builtin.Name) 530 require.False(t, builtin.Custom) 531 require.False(t, builtin.IsError, "a skipped call is not an error") 532 require.False(t, builtin.Posted, "nothing was sent for an unowned tool") 533 require.Equal(t, "evt_99", builtin.ToolUse.ID, "the triggering event is still surfaced") 534 require.Empty(t, builtin.Result.ToolUseID, "no user.tool_result was ever built") 535 require.Empty(t, dispatchedResultText(builtin)) 536 537 custom, ok := seen["cevt_99"] 538 require.True(t, ok, "the unowned agent.custom_tool_use must still be yielded") 539 require.Equal(t, "app_backend_tool", custom.Name) 540 require.True(t, custom.Custom) 541 require.False(t, custom.IsError, "a skipped call is not an error") 542 require.False(t, custom.Posted, "nothing was sent for an unowned custom tool") 543 require.Equal(t, "cevt_99", custom.CustomToolUse.ID, "the triggering event is still surfaced") 544 require.Empty(t, custom.CustomResult.CustomToolUseID, "no user.custom_tool_result was ever built") 545 require.Empty(t, dispatchedResultText(custom)) 546 547 require.Equal(t, int32(0), echo.runs.Load(), "no registered tool should have run") 548 require.NoError(t, r.Close()) 549 require.NoError(t, r.Err(), "skipping an unowned tool is not a terminal error") 550 } 551 552 // A skipped (unanswered) unowned tool_use stays OUT of the end-turn 553 // accounting: reconcile sees history ending on an end_turn idle but with the 554 // unowned tool_use still unanswered, so it must NOT arm the countdown — the 555 // runner has not handled that call, its owner still has to. 556 func TestSessionToolRunner_SkippedUnownedToolDoesNotTripIdle(t *testing.T) { 557 server := newSessionEventsServer(t) 558 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 559 streamWriter(w, r, nil, true) // no live events; reconcile drives the test 560 } 561 server.HandleList = func(w http.ResponseWriter, _ *http.Request) { 562 w.Header().Set("Content-Type", "application/json") 563 body, _ := json.Marshal(map[string]any{ 564 "data": []any{ 565 toolUseEvt("evt_pending", "not_ours", map[string]any{}), 566 idleEndTurnEvt("evt_idle"), 567 }, 568 "first_id": "evt_pending", "has_more": false, "last_id": "evt_idle", 569 }) 570 _, _ = w.Write(body) 571 } 572 server.HandleSend = func(http.ResponseWriter, *http.Request) { 573 t.Fatal("runner must not post any result for a tool it does not own") 574 } 575 576 ctx, cancel := context.WithTimeout(context.Background(), time.Second) 577 defer cancel() 578 r := newShortIdleRunner(t, ctx, server.Client(), nil, 100*time.Millisecond) 579 580 require.True(t, r.Next(), "reconcile must still surface the unowned call") 581 call := r.Current() 582 require.Equal(t, "evt_pending", call.ToolUseID) 583 require.False(t, call.Posted) 584 require.False(t, call.IsError) 585 require.Empty(t, call.Result.ToolUseID, "no result was built for the skipped call") 586 587 for r.Next() { 588 t.Fatalf("unexpected extra yield: %+v", r.Current()) 589 } 590 require.NotErrorIs(t, r.Err(), ErrIdleTimeout, 591 "runner idled out with an unowned tool_use still unanswered — reconcile must not arm over outstanding work") 592 } 593 594 func TestSessionToolRunner_SessionTerminatedEndsIteration(t *testing.T) { 595 server := newSessionEventsServer(t) 596 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 597 streamWriter(w, r, []string{ 598 sseLine("session.status_terminated", plainEvt("evt_term", "session.status_terminated")), 599 }, false) 600 } 601 server.HandleSend = func(http.ResponseWriter, *http.Request) { 602 t.Fatal("send must not be called when no tool_use ever fires") 603 } 604 605 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 606 defer cancel() 607 r := newShortIdleRunner(t, ctx, server.Client(), nil, 0) 608 for r.Next() { 609 t.Fatalf("unexpected yield: %+v", r.Current()) 610 } 611 require.ErrorIs(t, r.Err(), ErrSessionTerminated) 612 } 613 614 // The session terminates as soon as the last result lands, so that call is 615 // surfaced while the runner is already shutting down; it must still reach the 616 // caller before Next reports the end. 617 func TestSessionToolRunner_CallPostedAtTerminationIsYielded(t *testing.T) { 618 for i := range 20 { 619 server := newSessionEventsServer(t) 620 terminate := make(chan struct{}) 621 streamGone := make(chan struct{}) 622 var terminateOnce, goneOnce sync.Once 623 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 624 defer goneOnce.Do(func() { close(streamGone) }) 625 w.Header().Set("Content-Type", "text/event-stream") 626 w.WriteHeader(http.StatusOK) 627 flusher := w.(http.Flusher) 628 _, _ = io.WriteString(w, sseLine("agent.tool_use", toolUseEvt("evt_1", "echo", map[string]any{}))) 629 flusher.Flush() 630 select { 631 case <-terminate: 632 case <-r.Context().Done(): 633 return 634 } 635 _, _ = io.WriteString(w, sseLine("session.status_terminated", plainEvt("evt_term", "session.status_terminated"))) 636 flusher.Flush() 637 <-r.Context().Done() 638 } 639 server.HandleSend = func(w http.ResponseWriter, _ *http.Request) { 640 // Acknowledge the post only once the runner has consumed the 641 // termination and dropped the stream. 642 terminateOnce.Do(func() { close(terminate) }) 643 select { 644 case <-streamGone: 645 case <-time.After(2 * time.Second): 646 } 647 w.Header().Set("Content-Type", "application/json") 648 _, _ = w.Write([]byte(sendOK())) 649 } 650 651 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 652 t.Cleanup(cancel) 653 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{&stubBetaTool{name: "echo"}}, 0) 654 require.Truef(t, r.Next(), "run %d: the call posted at termination was never yielded (err=%v)", i, r.Err()) 655 require.Equal(t, "evt_1", r.Current().ToolUseID) 656 require.True(t, r.Current().Posted) 657 require.Falsef(t, r.Next(), "run %d: unexpected second yield: %+v", i, r.Current()) 658 require.ErrorIs(t, r.Err(), ErrSessionTerminated) 659 require.NoError(t, r.Close()) 660 } 661 } 662 663 // TestSessionToolRunner_ReconcileSurfacesSessionTerminatedFromHistory pins 664 // the guarantee that reconcile() shuts down streamLoop when the listed 665 // history contains a session.status_terminated or session.deleted event. 666 // The live stream only replays current events, not history, so a session 667 // that terminated before the runner attached is only visible to reconcile; 668 // without surfacing it, streamLoop would reconnect forever against a dead 669 // session after the (eventless) live stream disconnected. 670 // 671 // The test scripts an event-list response containing a single 672 // session.status_terminated entry and a stream handler that simply holds 673 // the connection open (no events ever fire). reconcile() returns 674 // ErrSessionTerminated, streamLoop propagates it, and the runner exits 675 // with ErrSessionTerminated well within the outer ctx. 676 func TestSessionToolRunner_ReconcileSurfacesSessionTerminatedFromHistory(t *testing.T) { 677 server := newSessionEventsServer(t) 678 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 679 // Stream is eager-opened by streamLoop BEFORE reconcile, so this 680 // handler always runs. With the fix, reconcile then returns the 681 // sentinel and the stream is closed; without the fix, the runner 682 // sits here forever waiting for events that never come. 683 streamWriter(w, r, nil, true) 684 } 685 server.HandleList = func(w http.ResponseWriter, _ *http.Request) { 686 w.Header().Set("Content-Type", "application/json") 687 body, _ := json.Marshal(map[string]any{ 688 "data": []any{plainEvt("evt_term", "session.status_terminated")}, 689 "first_id": "evt_term", 690 "has_more": false, 691 "last_id": "evt_term", 692 }) 693 _, _ = w.Write(body) 694 } 695 server.HandleSend = func(http.ResponseWriter, *http.Request) { 696 t.Fatal("send must not be called when no tool_use ever fires") 697 } 698 699 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 700 defer cancel() 701 r := newShortIdleRunner(t, ctx, server.Client(), nil, 0) 702 703 start := time.Now() 704 for r.Next() { 705 t.Fatalf("unexpected yield: %+v", r.Current()) 706 } 707 elapsed := time.Since(start) 708 709 require.ErrorIs(t, r.Err(), ErrSessionTerminated, "reconcile must surface ErrSessionTerminated when the listed history contains session.status_terminated") 710 require.Less(t, elapsed, 3*time.Second, "runner ran too long — reconcile didn't shut down streamLoop (got %s)", elapsed) 711 } 712 713 func TestSessionToolRunner_IdleTimeoutEndsIteration(t *testing.T) { 714 server := newSessionEventsServer(t) 715 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 716 streamWriter(w, r, []string{sseLine("session.status_idle", idleEndTurnEvt("evt_idle"))}, true) 717 } 718 719 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 720 defer cancel() 721 r := newShortIdleRunner(t, ctx, server.Client(), nil, 100*time.Millisecond) 722 for r.Next() { 723 t.Fatalf("unexpected yield: %+v", r.Current()) 724 } 725 require.ErrorIs(t, r.Err(), ErrIdleTimeout) 726 } 727 728 // shrinkSendBackoff makes tool-result send retries back off in milliseconds 729 // for the duration of the test. 730 func shrinkSendBackoff(t *testing.T) { 731 prevStart, prevCap := sessionRunnerSendBackoffStart, sessionRunnerSendBackoffCap 732 sessionRunnerSendBackoffStart, sessionRunnerSendBackoffCap = 20*time.Millisecond, 50*time.Millisecond 733 t.Cleanup(func() { sessionRunnerSendBackoffStart, sessionRunnerSendBackoffCap = prevStart, prevCap }) 734 } 735 736 // alwaysToolUse streams a single agent.tool_use for "echo" and holds the 737 // connection open. 738 func alwaysToolUse(w http.ResponseWriter, r *http.Request) { 739 streamWriter(w, r, []string{ 740 sseLine("agent.tool_use", toolUseEvt("evt_1", "echo", map[string]any{})), 741 }, true) 742 } 743 744 // TestSessionToolRunner_SendRetriesOnTransientError covers the regression 745 // where a tool-result send gave up after three attempts while the enclosing 746 // worker kept the lease alive, stranding the call: transient failures keep 747 // being retried with backoff until the send lands. 748 func TestSessionToolRunner_SendRetriesOnTransientError(t *testing.T) { 749 shrinkSendBackoff(t) 750 server := newSessionEventsServer(t) 751 server.HandleStream = alwaysToolUse 752 var attempts atomic.Int32 753 server.HandleSend = func(w http.ResponseWriter, _ *http.Request) { 754 if attempts.Add(1) <= 3 { 755 http.Error(w, "boom", http.StatusServiceUnavailable) 756 return 757 } 758 w.Header().Set("Content-Type", "application/json") 759 _, _ = w.Write([]byte(sendOK())) 760 } 761 762 ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) 763 defer cancel() 764 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{&stubBetaTool{name: "echo"}}, 500*time.Millisecond) 765 766 require.True(t, r.Next()) 767 require.True(t, r.Current().Posted, "send should keep retrying past three attempts and land") 768 require.Equal(t, int32(4), attempts.Load()) 769 } 770 771 // TestSessionToolRunner_SendGivesUpAfterRetryWindow checks the retry loop is 772 // bounded by the send retry window (supplied here the way EnvironmentWorker 773 // supplies the lease TTL, via internal/sendwindow): a send that never succeeds 774 // is retried for the whole window, then surfaced with Posted=false. 775 func TestSessionToolRunner_SendGivesUpAfterRetryWindow(t *testing.T) { 776 shrinkSendBackoff(t) 777 server := newSessionEventsServer(t) 778 server.HandleStream = alwaysToolUse 779 var attempts atomic.Int32 780 server.HandleSend = func(w http.ResponseWriter, _ *http.Request) { 781 attempts.Add(1) 782 http.Error(w, "boom", http.StatusInternalServerError) 783 } 784 785 ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) 786 defer cancel() 787 window := 300 * time.Millisecond 788 var w sendwindow.Window 789 w.Set(window) 790 r := newShortIdleRunner(t, sendwindow.NewContext(ctx, &w), server.Client(), []BetaTool{&stubBetaTool{name: "echo"}}, 500*time.Millisecond) 791 792 start := time.Now() 793 require.True(t, r.Next()) 794 elapsed := time.Since(start) 795 require.False(t, r.Current().Posted, "send should give up once the retry window is exhausted") 796 require.GreaterOrEqual(t, elapsed, window, "retries should span the whole window") 797 require.Less(t, elapsed, window+2*time.Second) 798 require.GreaterOrEqual(t, attempts.Load(), int32(3)) 799 } 800 801 // TestSessionToolRunner_SendRetryWindowUpdateAppliesMidRetry checks that a 802 // window that changes while a send is already retrying bounds that send — 803 // EnvironmentWorker updates it with the lease TTL each heartbeat reports. 804 func TestSessionToolRunner_SendRetryWindowUpdateAppliesMidRetry(t *testing.T) { 805 shrinkSendBackoff(t) 806 server := newSessionEventsServer(t) 807 server.HandleStream = alwaysToolUse 808 firstSend := make(chan struct{}) 809 var sendOnce sync.Once 810 server.HandleSend = func(w http.ResponseWriter, _ *http.Request) { 811 sendOnce.Do(func() { close(firstSend) }) 812 http.Error(w, "boom", http.StatusInternalServerError) 813 } 814 815 ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) 816 defer cancel() 817 var w sendwindow.Window 818 r := newShortIdleRunner(t, sendwindow.NewContext(ctx, &w), server.Client(), []BetaTool{&stubBetaTool{name: "echo"}}, 500*time.Millisecond) 819 820 done := make(chan DispatchedToolCall, 1) 821 go func() { 822 require.True(t, r.Next()) 823 done <- r.Current() 824 }() 825 select { 826 case <-firstSend: 827 case <-ctx.Done(): 828 t.Fatal("send never attempted") 829 } 830 time.Sleep(200 * time.Millisecond) // still retrying under the 5m default 831 w.Set(100 * time.Millisecond) 832 select { 833 case call := <-done: 834 require.False(t, call.Posted) 835 case <-time.After(2 * time.Second): 836 t.Fatal("the shortened window must cut off the retry loop that started under the 5m default") 837 } 838 } 839 840 func TestIsFatal4xxStatus(t *testing.T) { 841 tests := []struct { 842 description string 843 err error 844 want bool 845 }{ 846 {"400 is fatal because the request body cannot succeed on retry", &Error{StatusCode: 400}, true}, 847 {"401 is fatal because a bad credential never starts working", &Error{StatusCode: 401}, true}, 848 {"403 is fatal because a forbidden caller stays forbidden", &Error{StatusCode: 403}, true}, 849 {"404 is fatal because a missing session stays missing", &Error{StatusCode: 404}, true}, 850 {"408 is excluded because timeouts deserve backoff, not teardown", &Error{StatusCode: 408}, false}, 851 {"409 is excluded because a conflict is retried by the core client too", &Error{StatusCode: 409}, false}, 852 {"412 stays fatal", &Error{StatusCode: 412}, true}, 853 {"422 is fatal because an unprocessable request cannot succeed on retry", &Error{StatusCode: 422}, true}, 854 {"429 is excluded because rate-limits deserve backoff, not teardown", &Error{StatusCode: 429}, false}, 855 {"500 is excluded because server-side errors retry, not abort", &Error{StatusCode: 500}, false}, 856 {"non-api error is never classified as a permanent client error", fmt.Errorf("409 conflict"), false}, 857 {"nil error is not fatal", nil, false}, 858 } 859 for _, tc := range tests { 860 t.Run(tc.description, func(t *testing.T) { 861 require.Equal(t, tc.want, isFatal4xxStatus(tc.err)) 862 }) 863 } 864 } 865 866 // TestToToolResultContent_BlockPassthrough verifies that block kinds shared by 867 // both unions round-trip natively, and block kinds the destination union 868 // cannot represent fall back to a text block holding the raw JSON. 869 func TestToToolResultContent_BlockPassthrough(t *testing.T) { 870 src := []BetaToolResultBlockParamContentUnion{ 871 {OfText: &BetaTextBlockParam{Text: "hello"}}, 872 {OfImage: &BetaImageBlockParam{ 873 Source: BetaImageBlockParamSourceUnion{ 874 OfBase64: &BetaBase64ImageSourceParam{ 875 Data: "QUJD", 876 MediaType: BetaBase64ImageSourceMediaTypeImagePNG, 877 }, 878 }, 879 }}, 880 {OfDocument: &BetaRequestDocumentBlockParam{ 881 Source: BetaRequestDocumentBlockSourceUnionParam{ 882 OfBase64: &BetaBase64PDFSourceParam{Data: "UERG"}, 883 }, 884 }}, 885 {OfDocument: &BetaRequestDocumentBlockParam{ 886 Source: BetaRequestDocumentBlockSourceUnionParam{ 887 OfText: &BetaPlainTextSourceParam{Data: "plain text doc"}, 888 }, 889 }}, 890 {OfDocument: &BetaRequestDocumentBlockParam{ 891 Source: BetaRequestDocumentBlockSourceUnionParam{ 892 OfContent: &BetaContentBlockSourceParam{ 893 Content: BetaContentBlockSourceContentUnionParam{ 894 OfString: param.NewOpt("nested content"), 895 }, 896 }, 897 }, 898 }}, 899 {OfSearchResult: &BetaSearchResultBlockParam{ 900 Source: "https://example.com", 901 Title: "result", 902 Content: []BetaTextBlockParam{{Text: "snippet"}}, 903 }}, 904 {OfToolReference: &BetaToolReferenceBlockParam{ToolName: "weather"}}, 905 } 906 907 got := toToolResultContent(src) 908 require.Len(t, got, 7) 909 910 require.NotNil(t, got[0].OfText) 911 require.Equal(t, "hello", got[0].OfText.Text, "text block passes through unchanged") 912 913 require.NotNil(t, got[1].OfImage, "base64 image block passes through natively") 914 require.Nil(t, got[1].OfText) 915 require.NotNil(t, got[1].OfImage.Source.OfBase64) 916 require.Equal(t, "QUJD", got[1].OfImage.Source.OfBase64.Data) 917 918 require.NotNil(t, got[2].OfDocument, "base64 document block passes through natively") 919 require.Nil(t, got[2].OfText) 920 require.NotNil(t, got[2].OfDocument.Source.OfBase64) 921 require.Equal(t, "UERG", got[2].OfDocument.Source.OfBase64.Data) 922 923 require.NotNil(t, got[3].OfDocument, "plain-text document block passes through natively") 924 require.Nil(t, got[3].OfText) 925 require.NotNil(t, got[3].OfDocument.Source.OfText) 926 require.Equal(t, "plain text doc", got[3].OfDocument.Source.OfText.Data) 927 928 require.NotNil(t, got[4].OfText, "content-source document block falls back to stringified JSON") 929 require.Nil(t, got[4].OfDocument) 930 require.Contains(t, got[4].OfText.Text, `"type":"document"`) 931 require.Contains(t, got[4].OfText.Text, "nested content") 932 933 require.NotNil(t, got[5].OfSearchResult, "search_result block passes through natively") 934 require.Nil(t, got[5].OfText) 935 require.Equal(t, "https://example.com", got[5].OfSearchResult.Source) 936 require.Equal(t, "result", got[5].OfSearchResult.Title) 937 938 require.NotNil(t, got[6].OfText, "tool_reference block falls back to stringified JSON") 939 require.Contains(t, got[6].OfText.Text, "tool_reference") 940 require.Contains(t, got[6].OfText.Text, "weather") 941 942 // The custom-tool variant must produce equivalent content. 943 custom := toCustomToolResultContent(src) 944 require.Len(t, custom, 7) 945 for i := range got { 946 gotJSON, err := json.Marshal(got[i]) 947 require.NoError(t, err) 948 customJSON, err := json.Marshal(custom[i]) 949 require.NoError(t, err) 950 require.JSONEq(t, string(gotJSON), string(customJSON)) 951 } 952 require.NotNil(t, custom[1].OfImage) 953 require.NotNil(t, custom[2].OfDocument) 954 require.NotNil(t, custom[5].OfSearchResult) 955 } 956 957 func TestToToolResultContent_EmptyTextBecomesPlaceholder(t *testing.T) { 958 got := toToolResultContent([]BetaToolResultBlockParamContentUnion{ 959 {OfText: &BetaTextBlockParam{Text: ""}}, 960 {OfText: &BetaTextBlockParam{Text: "kept"}}, 961 }) 962 require.Len(t, got, 2) 963 require.Equal(t, "(no output)", got[0].OfText.Text) 964 require.Equal(t, "kept", got[1].OfText.Text, "non-empty text is unchanged") 965 966 custom := toCustomToolResultContent([]BetaToolResultBlockParamContentUnion{{OfText: &BetaTextBlockParam{Text: ""}}}) 967 require.Len(t, custom, 1) 968 require.Equal(t, "(no output)", custom[0].OfText.Text, "custom tool results get the same placeholder") 969 } 970 971 // ===== tool calls that need user approval ===== 972 // 973 // The server marks each agent.tool_use with evaluated_permission. Only "allow" 974 // (or no mark) runs on arrival; "ask" waits for the user's 975 // user.tool_confirmation; "deny" and unrecognized values never run. 976 977 func TestSessionToolRunner_OnlyToolsTheServerOrUserAllowedEverRun(t *testing.T) { 978 server := newSessionEventsServer(t) 979 // From the history endpoint (read once when the runner connects): 980 server.HandleList = func(w http.ResponseWriter, _ *http.Request) { 981 body, _ := json.Marshal(map[string]any{ 982 "data": []any{ 983 askToolUseEvt("ask_answered_in_history", "echo", nil, "ask"), 984 toolConfirmationEvt("ask_answered_in_history", "allow"), 985 }, 986 "has_more": false, "first_id": "ask_answered_in_history", "last_id": "evt_conf_ask_answered_in_history", 987 }) 988 w.Header().Set("Content-Type", "application/json") 989 _, _ = w.Write(body) 990 } 991 // From the live event stream (held open — the dispatcher is pipelined, so a 992 // trailing terminated would race the last dispatch): 993 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 994 streamWriter(w, r, []string{ 995 sseLine("agent.tool_use", askToolUseEvt("marked_allow", "echo", nil, "allow")), 996 sseLine("agent.tool_use", askToolUseEvt("marked_deny", "echo", nil, "deny")), 997 sseLine("agent.tool_use", askToolUseEvt("ask_then_allowed", "echo", nil, "ask")), 998 sseLine("agent.tool_use", askToolUseEvt("ask_then_denied", "echo", nil, "ask")), 999 sseLine("agent.tool_use", askToolUseEvt("ask_never_answered", "echo", nil, "ask")), 1000 sseLine("agent.tool_use", askToolUseEvt("ask_verdict_unrecognized", "echo", nil, "ask")), 1001 sseLine("agent.tool_use", askToolUseEvt("unrecognized_mark", "echo", nil, "something_new")), 1002 sseLine("user.tool_confirmation", toolConfirmationEvt("ask_then_allowed", "allow")), 1003 sseLine("user.tool_confirmation", toolConfirmationEvt("ask_then_denied", "deny")), 1004 sseLine("user.tool_confirmation", toolConfirmationEvt("ask_verdict_unrecognized", "escalate")), 1005 }, true) 1006 } 1007 var posted []string 1008 var mu sync.Mutex 1009 server.HandleSend = func(w http.ResponseWriter, r *http.Request) { 1010 body, _ := io.ReadAll(r.Body) 1011 var payload struct { 1012 Events []struct { 1013 ToolUseID string `json:"tool_use_id"` 1014 } `json:"events"` 1015 } 1016 _ = json.Unmarshal(body, &payload) 1017 mu.Lock() 1018 for _, e := range payload.Events { 1019 posted = append(posted, e.ToolUseID) 1020 } 1021 mu.Unlock() 1022 w.Header().Set("Content-Type", "application/json") 1023 _, _ = w.Write([]byte(sendOK())) 1024 } 1025 1026 echo := &stubBetaTool{name: "echo"} 1027 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 1028 defer cancel() 1029 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{echo}, 0) 1030 1031 // Allowed calls execute and post; denied calls (server "deny", user "deny", 1032 // unrecognised verdict) are yielded with Confirmation="deny" but never run. 1033 // Calls whose confirmation never arrives stay held and are not yielded. 1034 got := map[string]DispatchedToolCall{} 1035 for range 6 { 1036 require.True(t, r.Next(), "expected six resolved calls; got %v then stopped: %v", got, r.Err()) 1037 c := r.Current() 1038 got[c.ToolUseID] = c 1039 } 1040 require.NoError(t, r.Close()) 1041 1042 allowed := []string{"ask_answered_in_history", "ask_then_allowed", "marked_allow"} 1043 denied := []string{"ask_then_denied", "ask_verdict_unrecognized", "marked_deny"} 1044 var ran, blocked []string 1045 for id, c := range got { 1046 if c.Confirmation == "deny" { 1047 blocked = append(blocked, id) 1048 require.False(t, c.Posted, "%s: a denied call posts nothing", id) 1049 } else { 1050 ran = append(ran, id) 1051 require.True(t, c.Posted, "%s: an allowed call posts its result", id) 1052 } 1053 } 1054 sort.Strings(ran) 1055 sort.Strings(blocked) 1056 sort.Strings(posted) 1057 require.Equal(t, allowed, ran, "only explicitly allowed calls execute") 1058 require.Equal(t, denied, blocked, "denied calls are yielded with Confirmation=\"deny\"") 1059 require.Equal(t, allowed, posted) 1060 require.Equal(t, int32(3), echo.runs.Load()) 1061 require.NotContains(t, got, "ask_never_answered", "a call whose confirmation never arrives stays held") 1062 require.NotContains(t, got, "unrecognized_mark", "an unrecognised permission with no verdict stays held") 1063 } 1064 1065 // A denied call is still an outcome the consumer needs to observe: the 1066 // agent tried to invoke a tool and was blocked. Dropping it silently makes 1067 // "the agent called nothing" indistinguishable from "the agent called five 1068 // tools and the user denied every one", which breaks audit trails and any 1069 // UI that surfaces per-call outcomes. So a denied call — whether the server 1070 // evaluated permission to "deny" or the user's confirmation verdict was a 1071 // deny — must be yielded with DispatchedToolCall.Confirmation == "deny" 1072 // (Posted=false, IsError=false, no result), and an ask-then-allow call must 1073 // carry Confirmation == "allow" so the consumer can tell it was gated. 1074 func TestSessionToolRunner_DeniedCallsAreYieldedWithConfirmation(t *testing.T) { 1075 server := newSessionEventsServer(t) 1076 server.HandleList = func(w http.ResponseWriter, _ *http.Request) { 1077 body, _ := json.Marshal(map[string]any{ 1078 "data": []any{ 1079 askToolUseEvt("srv_deny", "echo", nil, "deny"), 1080 askToolUseEvt("usr_deny", "echo", nil, "ask"), 1081 askToolUseEvt("usr_allow", "echo", nil, "ask"), 1082 idleRequiresActionEvt("evt_ra", "usr_deny", "usr_allow"), 1083 }, 1084 "has_more": false, "first_id": "srv_deny", "last_id": "evt_ra", 1085 }) 1086 w.Header().Set("Content-Type", "application/json") 1087 _, _ = w.Write(body) 1088 } 1089 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 1090 streamWriter(w, r, []string{ 1091 sseLine("user.tool_confirmation", toolConfirmationEvt("usr_deny", "deny")), 1092 sseLine("user.tool_confirmation", toolConfirmationEvt("usr_allow", "allow")), 1093 sseLine("session.status_idle", idleEndTurnEvt("evt_et")), 1094 }, true) 1095 } 1096 var sends atomic.Int32 1097 server.HandleSend = func(w http.ResponseWriter, _ *http.Request) { 1098 sends.Add(1) 1099 w.Header().Set("Content-Type", "application/json") 1100 _, _ = w.Write([]byte(sendOK())) 1101 } 1102 1103 echo := &stubBetaTool{name: "echo"} 1104 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 1105 defer cancel() 1106 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{echo}, 200*time.Millisecond) 1107 1108 got := map[string]DispatchedToolCall{} 1109 for r.Next() { 1110 c := r.Current() 1111 got[c.ToolUseID] = c 1112 } 1113 require.ErrorIs(t, r.Err(), ErrIdleTimeout) 1114 1115 require.Contains(t, got, "srv_deny", "server-denied call was not yielded") 1116 require.Contains(t, got, "usr_deny", "user-denied call was not yielded") 1117 require.Contains(t, got, "usr_allow", "user-allowed call was not yielded") 1118 1119 for _, id := range []string{"srv_deny", "usr_deny"} { 1120 c := got[id] 1121 require.Equal(t, "deny", c.Confirmation, "%s: Confirmation must be \"deny\"", id) 1122 require.False(t, c.Posted, "%s: a denied call posts no result", id) 1123 require.False(t, c.IsError, "%s: a denial is not a tool error", id) 1124 require.Empty(t, c.Result.ToolUseID, "%s: no result was built for a denied call", id) 1125 } 1126 require.Equal(t, "allow", got["usr_allow"].Confirmation, "an ask-then-allow call must carry Confirmation == \"allow\"") 1127 require.True(t, got["usr_allow"].Posted) 1128 1129 require.Equal(t, int32(1), echo.runs.Load(), "only the allowed call executes") 1130 require.Equal(t, int32(1), sends.Load(), "only the allowed call posts a result") 1131 } 1132 1133 // A verdict this SDK cannot read must fail closed, and a server-side deny 1134 // outranks a stray allow. Each verdict here lands BEFORE its tool_use, so the 1135 // gate resolves it out of the recorded verdicts rather than through the 1136 // held-call path — and that read must tell "no verdict recorded" apart from 1137 // "a verdict recorded that we can't interpret", which a bare map lookup on 1138 // Go's zero value does not. 1139 func TestSessionToolRunner_GateFailsClosedOnStrayVerdicts(t *testing.T) { 1140 server := newSessionEventsServer(t) 1141 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 1142 streamWriter(w, r, []string{ 1143 // Verdicts first: each is recorded before its call is routed. 1144 sseLine("user.tool_confirmation", toolConfirmationEvt("ungated_stray_deny", "deny")), 1145 sseLine("user.tool_confirmation", toolConfirmationEvt("ungated_stray_empty", "")), 1146 sseLine("user.tool_confirmation", toolConfirmationEvt("pre_denied", "allow")), 1147 1148 sseLine("agent.tool_use", toolUseEvt("ungated_stray_deny", "echo", nil)), 1149 sseLine("agent.tool_use", toolUseEvt("ungated_stray_empty", "echo", nil)), 1150 sseLine("agent.tool_use", askToolUseEvt("pre_denied", "echo", nil, "deny")), 1151 sseLine("agent.tool_use", askToolUseEvt("marked_allow", "echo", nil, "allow")), 1152 sseLine("session.status_idle", idleEndTurnEvt("evt_idle")), 1153 }, true) 1154 } 1155 server.HandleSend = func(w http.ResponseWriter, _ *http.Request) { 1156 w.Header().Set("Content-Type", "application/json") 1157 _, _ = w.Write([]byte(sendOK())) 1158 } 1159 1160 echo := &stubBetaTool{name: "echo"} 1161 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 1162 defer cancel() 1163 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{echo}, 200*time.Millisecond) 1164 1165 got := map[string]DispatchedToolCall{} 1166 for r.Next() { 1167 c := r.Current() 1168 got[c.ToolUseID] = c 1169 } 1170 require.ErrorIs(t, r.Err(), ErrIdleTimeout, "nothing is held, so the end_turn countdown must run") 1171 1172 require.Equal(t, int32(1), echo.runs.Load(), "only marked_allow may execute") 1173 require.True(t, got["marked_allow"].Posted) 1174 require.Empty(t, got["marked_allow"].Confirmation, "an allow-marked call needed no confirmation") 1175 1176 for _, id := range []string{"ungated_stray_deny", "ungated_stray_empty", "pre_denied"} { 1177 c, ok := got[id] 1178 require.True(t, ok, "%s: a resolved-denied call must still be yielded", id) 1179 require.Equal(t, "deny", c.Confirmation, "%s: must fail closed as a denial", id) 1180 require.False(t, c.Posted, "%s: a denied call posts no result", id) 1181 } 1182 } 1183 1184 // A permission value this SDK does not recognize must hold the call like 1185 // "ask" — never dispatch it unconfirmed — which also defers the idle 1186 // countdown for as long as it stays held. 1187 func TestSessionToolRunner_UnrecognizedPermissionHoldsTheCall(t *testing.T) { 1188 server := newSessionEventsServer(t) 1189 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 1190 streamWriter(w, r, []string{ 1191 sseLine("agent.tool_use", askToolUseEvt("tu", "echo", nil, "something_new")), 1192 sseLine("session.status_idle", idleEndTurnEvt("evt_idle")), 1193 }, true) 1194 } 1195 server.HandleSend = func(http.ResponseWriter, *http.Request) { t.Error("a held call posted a result") } 1196 1197 echo := &stubBetaTool{name: "echo"} 1198 ctx, cancel := context.WithTimeout(context.Background(), time.Second) 1199 defer cancel() 1200 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{echo}, 100*time.Millisecond) 1201 1202 for r.Next() { 1203 t.Fatalf("a held call must not be yielded: %+v", r.Current()) 1204 } 1205 require.NotErrorIs(t, r.Err(), ErrIdleTimeout, 1206 "the held call must defer the countdown; the runner ends on ctx, not MaxIdle") 1207 require.Zero(t, echo.runs.Load()) 1208 } 1209 1210 // A call held from the live stream whose allow verdict only ever appears in a 1211 // reconcile history — the tool_use itself scrolled out of the listed window — 1212 // must still be dispatched, exactly once. 1213 func TestSessionToolRunner_ReconcileVerdictResolvesCallMissingFromHistory(t *testing.T) { 1214 server := newSessionEventsServer(t) 1215 1216 var streamConns atomic.Int32 1217 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 1218 if streamConns.Add(1) == 1 { 1219 // First connection: the gated call arrives live (and is held), then 1220 // the stream closes so the runner reconnects and reconciles. 1221 streamWriter(w, r, []string{ 1222 sseLine("agent.tool_use", askToolUseEvt("tu", "echo", nil, "ask")), 1223 }, false) 1224 return 1225 } 1226 streamWriter(w, r, nil, true) // later connections: hold open, no events 1227 } 1228 1229 var lists atomic.Int32 1230 server.HandleList = func(w http.ResponseWriter, _ *http.Request) { 1231 w.Header().Set("Content-Type", "application/json") 1232 // The reconcile after the reconnect sees only the verdict: the 1233 // tool_use itself is outside the listed window. 1234 if lists.Add(1) >= 2 { 1235 body, _ := json.Marshal(map[string]any{ 1236 "data": []any{toolConfirmationEvt("tu", "allow")}, 1237 "has_more": false, "first_id": "evt_conf_tu", "last_id": "evt_conf_tu", 1238 }) 1239 _, _ = w.Write(body) 1240 return 1241 } 1242 _, _ = w.Write([]byte(emptyEventList())) 1243 } 1244 1245 server.HandleSend = func(w http.ResponseWriter, _ *http.Request) { 1246 w.Header().Set("Content-Type", "application/json") 1247 _, _ = w.Write([]byte(sendOK())) 1248 } 1249 1250 echo := &stubBetaTool{name: "echo"} 1251 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 1252 defer cancel() 1253 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{echo}, 0) 1254 1255 require.True(t, r.Next(), "the approved held call must be dispatched: %v", r.Err()) 1256 call := r.Current() 1257 require.Equal(t, "tu", call.ToolUseID) 1258 require.Equal(t, "allow", call.Confirmation) 1259 require.True(t, call.Posted) 1260 require.NoError(t, r.Close()) 1261 require.Equal(t, int32(1), echo.runs.Load(), "the approved call must run exactly once") 1262 } 1263 1264 // Deny flavor of the window-eviction case: the verdict must resolve the held 1265 // copy (nothing runs, nothing posts) and clear the hold so the end_turn idle 1266 // in the same history can stop the runner — instead of the occupied gate 1267 // deferring idle-out forever. 1268 func TestSessionToolRunner_ReconcileDenyForCallMissingFromHistoryLetsRunnerStop(t *testing.T) { 1269 server := newSessionEventsServer(t) 1270 1271 var streamConns atomic.Int32 1272 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 1273 if streamConns.Add(1) == 1 { 1274 streamWriter(w, r, []string{ 1275 sseLine("agent.tool_use", askToolUseEvt("tu", "echo", nil, "ask")), 1276 }, false) 1277 return 1278 } 1279 streamWriter(w, r, nil, true) 1280 } 1281 1282 var lists atomic.Int32 1283 server.HandleList = func(w http.ResponseWriter, _ *http.Request) { 1284 w.Header().Set("Content-Type", "application/json") 1285 if lists.Add(1) >= 2 { 1286 body, _ := json.Marshal(map[string]any{ 1287 "data": []any{toolConfirmationEvt("tu", "deny"), idleEndTurnEvt("evt_idle")}, 1288 "has_more": false, "first_id": "evt_conf_tu", "last_id": "evt_idle", 1289 }) 1290 _, _ = w.Write(body) 1291 return 1292 } 1293 _, _ = w.Write([]byte(emptyEventList())) 1294 } 1295 1296 server.HandleSend = func(http.ResponseWriter, *http.Request) { t.Error("a denied call posted a result") } 1297 1298 echo := &stubBetaTool{name: "echo"} 1299 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 1300 defer cancel() 1301 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{echo}, 100*time.Millisecond) 1302 1303 var yielded []DispatchedToolCall 1304 for r.Next() { 1305 yielded = append(yielded, r.Current()) 1306 } 1307 require.ErrorIs(t, r.Err(), ErrIdleTimeout, "must stop on its own once the deny resolves the held call") 1308 require.Len(t, yielded, 1, "the denied held call is yielded exactly once") 1309 require.Equal(t, "tu", yielded[0].ToolUseID) 1310 require.Equal(t, "deny", yielded[0].Confirmation) 1311 require.Equal(t, int32(0), echo.runs.Load(), "the denied call must never run") 1312 } 1313 1314 // ===== the idle countdown vs. confirmation-gated calls ===== 1315 // 1316 // The server up-converts a legacy v1beta idle to stop_reason "end_turn" 1317 // unconditionally, so a client CAN see an end_turn while a call is held for 1318 // confirmation. The countdown must never run over gated work: stopping then 1319 // drops the held call when its verdict later arrives, or cuts the runner off 1320 // before a released call's result can drive the next turn. 1321 1322 // A call held awaiting its user.tool_confirmation defers the countdown even 1323 // though the live stream reported an end_turn idle right behind it. 1324 func TestSessionToolRunner_HeldCallBlocksIdleTimeout(t *testing.T) { 1325 server := newSessionEventsServer(t) 1326 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 1327 // An ask-gated call, then an end_turn idle, then silence — no verdict. 1328 streamWriter(w, r, []string{ 1329 sseLine("agent.tool_use", askToolUseEvt("evt_ask", "echo", map[string]any{}, "ask")), 1330 sseLine("session.status_idle", idleEndTurnEvt("evt_idle")), 1331 }, true) 1332 } 1333 server.HandleSend = func(http.ResponseWriter, *http.Request) { 1334 t.Error("nothing may be posted while the call is held") 1335 } 1336 1337 echo := &stubBetaTool{name: "echo"} 1338 // Short MaxIdle: a wrongly-armed countdown would end the runner ~150ms in; 1339 // a correct runner instead blocks until this ctx expires ~2s in. 1340 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 1341 defer cancel() 1342 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{echo}, 150*time.Millisecond) 1343 1344 start := time.Now() 1345 for r.Next() { 1346 t.Fatalf("unexpected yield while holding a call: %+v", r.Current()) 1347 } 1348 elapsed := time.Since(start) 1349 1350 require.NotErrorIs(t, r.Err(), ErrIdleTimeout, 1351 "runner idle-timed-out while a call was held awaiting confirmation") 1352 require.GreaterOrEqual(t, elapsed, time.Second, 1353 "runner ended after only %s — it idle-timed-out instead of holding the call", elapsed) 1354 require.Equal(t, int32(0), echo.runs.Load()) 1355 require.NoError(t, r.Close()) 1356 } 1357 1358 // Reconcile flavor: the held call and its end_turn both come from history, so 1359 // the arm the reconcile pass owes must be held pending until the verdict lands. 1360 func TestSessionToolRunner_OpenApprovalKeepsRunnerAliveThenAnswerLetsItStop(t *testing.T) { 1361 // The user's answer closes the approval — a deny, or an unrecognized 1362 // verdict resolved as one (fail closed without wedging the runner open). 1363 for _, verdict := range []string{"deny", "not_a_verdict"} { 1364 t.Run(verdict, func(t *testing.T) { 1365 server := newSessionEventsServer(t) 1366 const wait = 600 * time.Millisecond 1367 // History shows a held call and the turn ending. 1368 server.HandleList = func(w http.ResponseWriter, _ *http.Request) { 1369 body, _ := json.Marshal(map[string]any{ 1370 "data": []any{askToolUseEvt("tu", "echo", nil, "ask"), idleEndTurnEvt("evt_idle")}, 1371 "has_more": false, "first_id": "tu", "last_id": "evt_idle", 1372 }) 1373 w.Header().Set("Content-Type", "application/json") 1374 _, _ = w.Write(body) 1375 } 1376 // The live stream stays silent while the answer is open, then 1377 // delivers the user's verdict. 1378 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 1379 f := w.(http.Flusher) 1380 w.Header().Set("Content-Type", "text/event-stream") 1381 w.WriteHeader(http.StatusOK) 1382 f.Flush() 1383 time.Sleep(wait) 1384 _, _ = io.WriteString(w, sseLine("user.tool_confirmation", toolConfirmationEvt("tu", verdict))) 1385 f.Flush() 1386 <-r.Context().Done() 1387 } 1388 server.HandleSend = func(http.ResponseWriter, *http.Request) { t.Error("a denied call posted a result") } 1389 1390 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 1391 defer cancel() 1392 r := newShortIdleRunner(t, ctx, server.Client(), nil, 100*time.Millisecond) 1393 1394 start := time.Now() 1395 var yielded []DispatchedToolCall 1396 for r.Next() { 1397 yielded = append(yielded, r.Current()) 1398 } 1399 require.ErrorIs(t, r.Err(), ErrIdleTimeout, "must stop on its own via MaxIdle once the answer closes the approval") 1400 require.GreaterOrEqual(t, time.Since(start), wait, "stopped while an approval was still open") 1401 require.Len(t, yielded, 1, "the denied held call is yielded exactly once") 1402 require.Equal(t, "deny", yielded[0].Confirmation) 1403 }) 1404 } 1405 } 1406 1407 // The mirror image of the two tests above: once the deny retires the last 1408 // blocker, the end_turn the runner saw mid-hold applies and it stops on its 1409 // own — a held call defers the countdown, it must not cancel it outright. 1410 func TestSessionToolRunner_DenyAfterEndTurnResumesIdle(t *testing.T) { 1411 server := newSessionEventsServer(t) 1412 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 1413 // Hold the call, go idle on end_turn, then deny it — and stay connected. 1414 streamWriter(w, r, []string{ 1415 sseLine("agent.tool_use", askToolUseEvt("evt_ask", "echo", map[string]any{}, "ask")), 1416 sseLine("session.status_idle", idleEndTurnEvt("evt_idle")), 1417 sseLine("user.tool_confirmation", toolConfirmationEvt("evt_ask", "deny")), 1418 }, true) 1419 } 1420 server.HandleSend = func(http.ResponseWriter, *http.Request) { t.Error("a denied tool must post nothing") } 1421 1422 echo := &stubBetaTool{name: "echo"} 1423 // MaxIdle well under the ctx bound: a correct runner times out shortly after 1424 // the deny; a runner that mis-counts the released call as outstanding hangs 1425 // until ctx. 1426 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 1427 defer cancel() 1428 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{echo}, 200*time.Millisecond) 1429 1430 start := time.Now() 1431 var yielded []DispatchedToolCall 1432 for r.Next() { 1433 yielded = append(yielded, r.Current()) 1434 } 1435 elapsed := time.Since(start) 1436 1437 require.ErrorIs(t, r.Err(), ErrIdleTimeout, 1438 "the idle countdown must resume after a deny releases the last held call") 1439 require.Less(t, elapsed, 4*time.Second, 1440 "runner took %s — the released call was wrongly counted as outstanding and the countdown never resumed", elapsed) 1441 require.Len(t, yielded, 1, "the denied call is yielded exactly once") 1442 require.Equal(t, "deny", yielded[0].Confirmation) 1443 require.Equal(t, int32(0), echo.runs.Load()) 1444 require.NoError(t, r.Close()) 1445 } 1446 1447 // An end_turn armed the countdown, then the stream dropped. The reconciled 1448 // history ends with an ask-gated call (held) and its end_turn, so the arm 1449 // defers — but the pre-disconnect countdown is now stale evidence and must be 1450 // cancelled, or the runner stops MaxIdle after the *old* end_turn while the 1451 // confirmation is still pending on a human. 1452 func TestSessionToolRunner_DeferredArmCancelsStalePreDisconnectCountdown(t *testing.T) { 1453 server := newSessionEventsServer(t) 1454 const maxIdle = 800 * time.Millisecond 1455 const wait = 2 * time.Second // > stale-stamp expiry; the deny arrives only after it 1456 1457 var streamConns atomic.Int32 1458 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 1459 if streamConns.Add(1) == 1 { 1460 // The agent finishes a turn (end_turn arms the countdown)… then 1461 // the now-quiet SSE connection is dropped — exactly what load 1462 // balancers do to idle streams. The runner reconnects. 1463 streamWriter(w, r, []string{ 1464 sseLine("session.status_idle", idleEndTurnEvt("evt_idle_1")), 1465 }, false) 1466 return 1467 } 1468 // Second connection: the approver has stepped away — silence past 1469 // the stale stamp's expiry — then they come back and deny, letting 1470 // the runner stop on a fresh window. 1471 f := w.(http.Flusher) 1472 w.Header().Set("Content-Type", "text/event-stream") 1473 w.WriteHeader(http.StatusOK) 1474 f.Flush() 1475 time.Sleep(wait) 1476 _, _ = io.WriteString(w, sseLine("user.tool_confirmation", toolConfirmationEvt("tu", "deny"))) 1477 f.Flush() 1478 <-r.Context().Done() 1479 } 1480 1481 var lists atomic.Int32 1482 server.HandleList = func(w http.ResponseWriter, _ *http.Request) { 1483 w.Header().Set("Content-Type", "application/json") 1484 // While the runner was reconnecting, the user sent a follow-up from 1485 // another client (the product UI); the agent hit a tool the policy 1486 // gates on approval, and its turn ended parked on that ask. The 1487 // reconnect's reconcile is how the runner learns all of this. 1488 if lists.Add(1) >= 2 { 1489 body, _ := json.Marshal(map[string]any{ 1490 "data": []any{askToolUseEvt("tu", "echo", nil, "ask"), idleEndTurnEvt("evt_idle_2")}, 1491 "has_more": false, "first_id": "tu", "last_id": "evt_idle_2", 1492 }) 1493 _, _ = w.Write(body) 1494 return 1495 } 1496 _, _ = w.Write([]byte(emptyEventList())) 1497 } 1498 server.HandleSend = func(http.ResponseWriter, *http.Request) { t.Error("a held call posted a result") } 1499 1500 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 1501 defer cancel() 1502 r := newShortIdleRunner(t, ctx, server.Client(), nil, maxIdle) 1503 1504 start := time.Now() 1505 var yielded []DispatchedToolCall 1506 for r.Next() { 1507 yielded = append(yielded, r.Current()) 1508 } 1509 require.ErrorIs(t, r.Err(), ErrIdleTimeout, "must stop on its own once the deny resolves the held call") 1510 require.GreaterOrEqual(t, time.Since(start), wait, 1511 "stopped off the stale pre-disconnect stamp while a confirmation was pending") 1512 require.Len(t, yielded, 1, "the denied held call is yielded exactly once") 1513 require.Equal(t, "deny", yielded[0].Confirmation) 1514 } 1515 1516 // The turn ended while the call was held, so an idle countdown is owed — but 1517 // it must not start until the approved call has fully dispatched, or a tool 1518 // slower than MaxIdle has the runner stop mid-flight and orphan the turn its 1519 // result starts. 1520 func TestSessionToolRunner_ApprovedCallStillExecutingDefersIdleStop(t *testing.T) { 1521 server := newSessionEventsServer(t) 1522 server.HandleStream = func(w http.ResponseWriter, r *http.Request) { 1523 streamWriter(w, r, []string{ 1524 sseLine("agent.tool_use", askToolUseEvt("tu", "slow", nil, "ask")), 1525 sseLine("session.status_idle", idleEndTurnEvt("evt_idle")), 1526 sseLine("user.tool_confirmation", toolConfirmationEvt("tu", "allow")), 1527 }, true) 1528 } 1529 var postedAt atomic.Int64 1530 server.HandleSend = func(w http.ResponseWriter, _ *http.Request) { 1531 postedAt.Store(time.Now().UnixNano()) 1532 w.Header().Set("Content-Type", "application/json") 1533 _, _ = w.Write([]byte(sendOK())) 1534 } 1535 1536 const maxIdle = 400 * time.Millisecond 1537 slow := &stubBetaTool{name: "slow", run: func(_ context.Context, _ json.RawMessage) (string, bool) { 1538 time.Sleep(600 * time.Millisecond) 1539 return "ran", false 1540 }} 1541 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 1542 defer cancel() 1543 r := newShortIdleRunner(t, ctx, server.Client(), []BetaTool{slow}, maxIdle) 1544 1545 require.True(t, r.Next(), "the approved call must complete and be yielded: %v", r.Err()) 1546 require.True(t, r.Current().Posted) 1547 require.Equal(t, "allow", r.Current().Confirmation) 1548 for r.Next() { 1549 t.Fatalf("yielded: %+v", r.Current()) 1550 } 1551 stoppedAt := time.Now() 1552 require.ErrorIs(t, r.Err(), ErrIdleTimeout) 1553 // A runner that armed the countdown at the verdict has it expire during 1554 // the 600ms execute and stops as soon as the call is done; deferring 1555 // grants a full fresh window after the dispatch instead. 1556 require.Greater(t, stoppedAt.Sub(time.Unix(0, postedAt.Load())), maxIdle*6/10, 1557 "idle countdown ran over the executing approved call") 1558 } 1559 1560 // A disarm racing the pending-arm apply must win: before the apply it clears 1561 // the pending arm, after it clears the stamp the apply wrote — a cancelled 1562 // countdown must never resurrect. 1563 func TestIdleClock_DisarmWinsAgainstPendingArmApply(t *testing.T) { 1564 for i := range 10000 { 1565 c := newIdleClock(time.Hour) 1566 // A call is held for approval when the turn ends: the countdown is 1567 // owed but deferred (pending, unstamped). 1568 c.block("tu") 1569 c.arm() 1570 startGate := make(chan struct{}) 1571 var wg sync.WaitGroup 1572 wg.Add(2) 1573 // The approved call finishes dispatching — the dispatch goroutine 1574 // retires the blocker, which applies the owed arm… 1575 go func() { defer wg.Done(); <-startGate; c.unblock("tu") }() 1576 // …while the stream goroutine processes a new event (e.g. the echoed 1577 // user.tool_result of that very call): the session is not idle, so 1578 // it disarms. 1579 go func() { defer wg.Done(); <-startGate; c.disarm() }() 1580 close(startGate) 1581 wg.Wait() 1582 c.mu.Lock() 1583 armed := !c.armedAt.IsZero() || c.armPending 1584 c.mu.Unlock() 1585 require.False(t, armed, 1586 "iteration %d: countdown left armed after a disarm — the pending-arm apply raced past it", i) 1587 } 1588 }