1package core
2
3import (
4 "context"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "testing"
13 "time"
14
15 "github.com/bnema/zut/packages/provider"
16)
17
18// continuationEchoTool is a deterministic test tool: it records how often
19// it ran so the continuation regression can assert exactly one execution.
20type continuationEchoTool struct {
21 calls int32
22}
23
24func (t *continuationEchoTool) Name() string { return "echo" }
25func (t *continuationEchoTool) Description() string { return "echoes" }
26func (t *continuationEchoTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
27
28func (t *continuationEchoTool) Execute(_ context.Context, _ json.RawMessage, _ func(string)) (ToolResult, error) {
29 atomic.AddInt32(&t.calls, 1)
30 return ToolResult{
31 Content: []provider.Content{provider.TextBlock{Text: "tool ok"}},
32 }, nil
33}
34
35func (t *continuationEchoTool) Calls() int32 { return atomic.LoadInt32(&t.calls) }
36
37// TestAgentContinuationExplicitEndTurnFalse scripts a real Responses SSE
38// stack: interim text with explicit end_turn:false, then a tool call, then
39// final text. The agent must issue three requests, execute the tool once,
40// keep transcript pairing/order without injected user rows, and finish with
41// exactly one terminal EvDone.
42func TestAgentContinuationExplicitEndTurnFalse(t *testing.T) {
43 var (
44 mu sync.Mutex
45 requests int
46 )
47 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48 mu.Lock()
49 requests++
50 n := requests
51 mu.Unlock()
52 w.Header().Set("content-type", "text/event-stream")
53 switch n {
54 case 1:
55 _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\"}}\n\n")
56 _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"part one\"}\n\n")
57 _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"end_turn\":false,\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
58 case 2:
59 _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"echo\"}}\n\n")
60 _, _ = io.WriteString(w, "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{}\"}\n\n")
61 _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\"}}\n\n")
62 _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-2\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
63 default:
64 _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\"}}\n\n")
65 _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"final\"}\n\n")
66 _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-3\",\"end_turn\":true,\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
67 }
68 }))
69 defer srv.Close()
70
71 client := provider.NewOpenAIResponsesNamed("synthetic-key", srv.URL, "openai-responses")
72 tool := &continuationEchoTool{}
73 agent := NewAgent(client, "gpt-5.2", "system", Registry{"echo": tool})
74
75 var (
76 evMu sync.Mutex
77 events []AgentEvent
78 doneSeen int
79 )
80 sink := func(ev AgentEvent) {
81 evMu.Lock()
82 defer evMu.Unlock()
83 events = append(events, ev)
84 if _, ok := ev.(EvDone); ok {
85 doneSeen++
86 }
87 }
88
89 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
90 defer cancel()
91 if err := agent.Prompt(ctx, "start", nil, sink); err != nil {
92 t.Fatalf("Prompt returned %v", err)
93 }
94
95 mu.Lock()
96 gotRequests := requests
97 mu.Unlock()
98 if gotRequests != 3 {
99 t.Fatalf("provider requests = %d, want 3 (continuation + tool + final)", gotRequests)
100 }
101 if got := tool.Calls(); got != 1 {
102 t.Fatalf("tool executions = %d, want 1", got)
103 }
104
105 evMu.Lock()
106 defer evMu.Unlock()
107 if doneSeen != 1 {
108 t.Fatalf("terminal EvDone count = %d, want 1", doneSeen)
109 }
110
111 // Transcript pairing/order: user, interim assistant, tool call,
112 // tool result, final assistant. No injected user rows.
113 msgs := agent.Messages()
114 if len(msgs) != 5 {
115 var roles []string
116 for _, m := range msgs {
117 roles = append(roles, string(m.Role))
118 }
119 t.Fatalf("transcript messages = %d (%v), want 5", len(msgs), roles)
120 }
121 wantRoles := []provider.Role{
122 provider.RoleUser,
123 provider.RoleAssistant,
124 provider.RoleAssistant,
125 provider.RoleTool,
126 provider.RoleAssistant,
127 }
128 for i, want := range wantRoles {
129 if msgs[i].Role != want {
130 t.Fatalf("message %d role = %q, want %q", i, msgs[i].Role, want)
131 }
132 }
133 if got := extractText(msgs[1]); got != "part one" {
134 t.Fatalf("interim assistant text = %q, want %q", got, "part one")
135 }
136 var callID string
137 for _, c := range msgs[2].Content {
138 if tc, ok := c.(provider.ToolCallBlock); ok {
139 callID = tc.ID
140 if tc.Name != "echo" {
141 t.Fatalf("tool call name = %q, want echo", tc.Name)
142 }
143 }
144 }
145 if callID == "" {
146 t.Fatalf("assistant tool message carries no tool call: %#v", msgs[2].Content)
147 }
148 var resultCallID string
149 for _, c := range msgs[3].Content {
150 if tr, ok := c.(provider.ToolResultBlock); ok {
151 resultCallID = tr.CallID
152 }
153 }
154 if resultCallID != callID {
155 t.Fatalf("tool result call ID = %q, want paired %q", resultCallID, callID)
156 }
157 if got := extractText(msgs[4]); !strings.Contains(got, "final") {
158 t.Fatalf("final assistant text = %q, want it to contain %q", got, "final")
159 }
160}