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  }