v0.33.0 653c635

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close