session_responses_reasoning_test.go RawHistoryFolder
1package core
2
3import (
4 "context"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "github.com/bnema/zut/packages/provider"
16)
17
18// responsesReasoningRecorder serves synthetic Responses SSE while recording
19// raw request bodies for stateless-replay assertions.
20type responsesReasoningRecorder struct {
21 mu sync.Mutex
22 bodies []string
23}
24
25func (r *responsesReasoningRecorder) handler(summary string) http.HandlerFunc {
26 return func(w http.ResponseWriter, req *http.Request) {
27 body, _ := io.ReadAll(req.Body)
28 r.mu.Lock()
29 r.bodies = append(r.bodies, string(body))
30 r.mu.Unlock()
31 w.Header().Set("content-type", "text/event-stream")
32 _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\"}}\n\n")
33 encoded, _ := json.Marshal(summary)
34 _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":"+string(encoded)+"}\n\n")
35 _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-test\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
36 }
37}
38
39func (r *responsesReasoningRecorder) requestBodies() []string {
40 r.mu.Lock()
41 defer r.mu.Unlock()
42 return append([]string(nil), r.bodies...)
43}
44
45func consumeResponsesStream(t *testing.T, client provider.Client, req provider.Request) {
46 t.Helper()
47 stream, err := client.Stream(context.Background(), req)
48 if err != nil {
49 t.Fatal(err)
50 }
51 for ev := range stream {
52 if done, ok := ev.(provider.EventDone); ok && done.Err != nil {
53 t.Fatalf("stream error: %v", done.Err)
54 }
55 }
56}
57
58func findStoredReasoning(msgs []provider.Message, id string) *provider.ReasoningBlock {
59 for _, msg := range msgs {
60 for _, c := range msg.Content {
61 if rb, ok := c.(provider.ReasoningBlock); ok && rb.ID == id {
62 clone := rb
63 return &clone
64 }
65 }
66 }
67 return nil
68}
69
70func TestSessionResponsesReasoningReplay(t *testing.T) {
71 root := t.TempDir()
72 cwd := t.TempDir()
73 session, err := NewSession(root, cwd, "openai-responses", "gpt-5", "test")
74 if err != nil {
75 t.Fatal(err)
76 }
77 if err := session.AppendMessage(provider.Message{
78 Role: provider.RoleUser,
79 Content: []provider.Content{provider.TextBlock{Text: "hello"}},
80 Time: time.Now(),
81 }); err != nil {
82 t.Fatal(err)
83 }
84 if err := session.AppendMessage(provider.Message{
85 Role: provider.RoleAssistant,
86 Content: []provider.Content{
87 provider.ReasoningBlock{ID: "rs_stale"},
88 provider.TextBlock{Text: "visible answer"},
89 provider.ToolCallBlock{ID: "call-1", Name: "lookup", Arguments: json.RawMessage(`{"q":"x"}`)},
90 },
91 Time: time.Now(),
92 }); err != nil {
93 t.Fatal(err)
94 }
95 if err := session.AppendMessage(provider.Message{
96 Role: provider.RoleTool,
97 Content: []provider.Content{provider.ToolResultBlock{
98 CallID: "call-1",
99 Content: []provider.Content{provider.TextBlock{Text: "result"}},
100 }},
101 Time: time.Now(),
102 }); err != nil {
103 t.Fatal(err)
104 }
105 path := session.Path
106 if err := session.Close(); err != nil {
107 t.Fatal(err)
108 }
109 before, err := os.ReadFile(path)
110 if err != nil {
111 t.Fatal(err)
112 }
113
114 snapshot, err := ReadSessionSnapshot(path)
115 if err != nil {
116 t.Fatal(err)
117 }
118 if stored := findStoredReasoning(snapshot.Messages, "rs_stale"); stored == nil {
119 t.Fatalf("snapshot lost ID-only reasoning: %#v", snapshot.Messages)
120 }
121
122 forkedPath, err := BranchSession(path, root, cwd, "test", len(snapshot.Messages))
123 if err != nil {
124 t.Fatal(err)
125 }
126 forked, err := ReadSessionSnapshot(forkedPath)
127 if err != nil {
128 t.Fatal(err)
129 }
130 if stored := findStoredReasoning(forked.Messages, "rs_stale"); stored == nil {
131 t.Fatalf("forked transcript lost ID-only reasoning: %#v", forked.Messages)
132 }
133
134 for _, msgs := range [][]provider.Message{snapshot.Messages, forked.Messages} {
135 recorder := &responsesReasoningRecorder{}
136 server := httptest.NewServer(recorder.handler("ok"))
137 client := provider.NewOpenAIResponsesNamed("synthetic-key", server.URL, "openai-responses")
138 consumeResponsesStream(t, client, provider.Request{
139 Model: "gpt-5",
140 Messages: msgs,
141 })
142 server.Close()
143
144 bodies := recorder.requestBodies()
145 if len(bodies) != 1 {
146 t.Fatalf("requests = %d, want 1", len(bodies))
147 }
148 body := bodies[0]
149 if strings.Contains(body, `"type":"reasoning"`) || strings.Contains(body, "rs_stale") {
150 t.Fatalf("ID-only reasoning leaked into Responses request: %s", body)
151 }
152 for _, want := range []string{"visible answer", "lookup", "result"} {
153 if !strings.Contains(body, want) {
154 t.Fatalf("request body missing %q: %s", want, body)
155 }
156 }
157 }
158
159 after, err := os.ReadFile(path)
160 if err != nil {
161 t.Fatal(err)
162 }
163 if string(before) != string(after) {
164 t.Fatal("source session bytes changed during replay")
165 }
166 reread, err := ReadSessionSnapshot(path)
167 if err != nil {
168 t.Fatal(err)
169 }
170 if stored := findStoredReasoning(reread.Messages, "rs_stale"); stored == nil {
171 t.Fatal("stored reasoning was modified by replay")
172 }
173}
174
175func TestCompactResponsesReasoningReplay(t *testing.T) {
176 recorder := &responsesReasoningRecorder{}
177 server := httptest.NewServer(recorder.handler("synthetic summary"))
178 defer server.Close()
179 client := provider.NewOpenAIResponsesNamed("synthetic-key", server.URL, "openai-responses")
180
181 agent := NewAgent(client, "gpt-5", "system", Registry{})
182 agent.SetMessages([]provider.Message{
183 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "start"}}},
184 {Role: provider.RoleAssistant, Content: []provider.Content{
185 provider.ToolCallBlock{ID: "call-1", Name: "lookup", Arguments: json.RawMessage(`{"q":"x"}`)},
186 }},
187 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},
188 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
189 CallID: "call-1",
190 Content: []provider.Content{provider.TextBlock{Text: "orphaned result"}},
191 }}},
192 {Role: provider.RoleAssistant, Content: []provider.Content{
193 provider.ReasoningBlock{ID: "rs_stale"},
194 provider.TextBlock{Text: "tail answer"},
195 }},
196 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "next question"}}},
197 })
198
199 if _, err := agent.Compact(context.Background(), 4, nil); err != nil {
200 t.Fatal(err)
201 }
202 bodies := recorder.requestBodies()
203 if len(bodies) < 1 {
204 t.Fatal("summary request was not recorded")
205 }
206 if strings.Contains(bodies[0], `"type":"reasoning"`) || strings.Contains(bodies[0], "rs_stale") {
207 t.Fatalf("summary request contains reasoning item: %s", bodies[0])
208 }
209
210 compacted := agent.Messages()
211 if stored := findStoredReasoning(compacted, "rs_stale"); stored == nil {
212 t.Fatalf("compacted tail lost retained reasoning block: %#v", compacted)
213 }
214
215 consumeResponsesStream(t, client, provider.Request{Model: "gpt-5", Messages: compacted})
216 bodies = recorder.requestBodies()
217 if len(bodies) != 2 {
218 t.Fatalf("requests = %d, want summary plus replay", len(bodies))
219 }
220 replay := bodies[1]
221 if strings.Contains(replay, "rs_stale") || strings.Contains(replay, `"type":"reasoning"`) {
222 t.Fatalf("retained invalid reference reached Responses request: %s", replay)
223 }
224 if strings.Contains(replay, "orphaned result") || strings.Contains(replay, "call-1") {
225 t.Fatalf("orphaned tool result reached Responses request: %s", replay)
226 }
227 for _, want := range []string{"tail answer", "next question"} {
228 if !strings.Contains(replay, want) {
229 t.Fatalf("replay request missing %q: %s", want, replay)
230 }
231 }
232}