v0.3.0 761f038

agent_retry_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"strings"
8	"sync/atomic"
9	"testing"
10	"time"
11	"unicode/utf8"
12
13	"github.com/bnema/zut/packages/provider"
14)
15
16type retryFakeClient struct {
17	calls int32
18}
19
20func (c *retryFakeClient) Name() string { return "retry-fake" }
21
22func (c *retryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
23	if req.Lifecycle != nil {
24		req.Lifecycle.RequestAttempt(1, 1)
25	}
26	call := atomic.AddInt32(&c.calls, 1)
27	out := make(chan provider.Event, 4)
28	go func() {
29		defer close(out)
30		out <- provider.EventStart{Provider: "retry-fake", Model: req.Model}
31		if call == 1 {
32			out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("anthropic overloaded_error: Overloaded")}
33			return
34		}
35		out <- provider.EventTextDelta{Delta: "ok"}
36		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
37			Role:    provider.RoleAssistant,
38			Content: []provider.Content{provider.TextBlock{Text: "ok"}},
39		}}
40	}()
41	return out, nil
42}
43
44func TestAgentRetriesOverloadedStreamError(t *testing.T) {
45	client := &retryFakeClient{}
46	a := NewAgent(client, "fake-model", "system", Registry{})
47	a.RetryBaseDelay = time.Millisecond
48
49	var turnErrs []string
50	err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) {
51		if e, ok := ev.(EvTurnEnd); ok && e.Err != nil {
52			turnErrs = append(turnErrs, e.Err.Error())
53		}
54	})
55	if err != nil {
56		t.Fatalf("Prompt returned %v", err)
57	}
58	if got := atomic.LoadInt32(&client.calls); got != 2 {
59		t.Fatalf("Stream calls = %d; want 2", got)
60	}
61	if len(turnErrs) != 1 || !strings.Contains(turnErrs[0], "overloaded_error") {
62		t.Fatalf("turn errors = %v; want one overloaded error before retry", turnErrs)
63	}
64	msgs := a.Messages()
65	if len(msgs) != 2 {
66		t.Fatalf("message count = %d; want user + final assistant", len(msgs))
67	}
68	if got := extractText(msgs[1]); got != "ok" {
69		t.Fatalf("final assistant text = %q; want ok", got)
70	}
71}
72
73func TestAgentEmitsRetryLifecycleEvents(t *testing.T) {
74	client := &retryFakeClient{}
75	a := NewAgent(client, "fake-model", "system", Registry{})
76	a.RetryBaseDelay = time.Millisecond
77
78	var lifecycle []string
79	if err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) {
80		switch e := ev.(type) {
81		case EvRequestStarted:
82			lifecycle = append(lifecycle, fmt.Sprintf("request:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts))
83		case EvRetryScheduled:
84			lifecycle = append(lifecycle, fmt.Sprintf("retry:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts))
85		}
86	}); err != nil {
87		t.Fatalf("Prompt returned %v", err)
88	}
89
90	want := []string{
91		"request:agent:1/4",
92		"request:provider:1/1",
93		"retry:agent:2/4",
94		"request:agent:2/4",
95		"request:provider:1/1",
96	}
97	if strings.Join(lifecycle, ",") != strings.Join(want, ",") {
98		t.Fatalf("lifecycle = %v, want %v", lifecycle, want)
99	}
100}
101
102// codexRetryFakeClient reproduces a transient OpenAI Codex backend
103// failure on the first call and succeeds afterwards.
104type codexRetryFakeClient struct {
105	calls    int32
106	firstErr string
107}
108
109func (c *codexRetryFakeClient) Name() string { return "openai-codex" }
110
111func (c *codexRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
112	call := atomic.AddInt32(&c.calls, 1)
113	out := make(chan provider.Event, 4)
114	go func() {
115		defer close(out)
116		out <- provider.EventStart{Provider: "openai-codex", Model: req.Model}
117		if call == 1 {
118			out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("%s", c.firstErr)}
119			return
120		}
121		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
122			Role:    provider.RoleAssistant,
123			Content: []provider.Content{provider.TextBlock{Text: "ok"}},
124		}}
125	}()
126	return out, nil
127}
128
129func TestAgentRetriesCodexProcessingError(t *testing.T) {
130	cases := []struct {
131		name string
132		err  string
133	}{
134		{
135			name: "processing error",
136			err:  "codex error: An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID 60c8ebbd-20bd-42e4-b756-6e844041cfc0 in your message.",
137		},
138		{
139			name: "servers overloaded",
140			err:  "codex error: Our servers are currently overloaded. Please try again later.",
141		},
142		{
143			name: "try again later only",
144			err:  "codex error: Something went wrong. Please try again later.",
145		},
146	}
147	for _, tc := range cases {
148		t.Run(tc.name, func(t *testing.T) {
149			client := &codexRetryFakeClient{firstErr: tc.err}
150			a := NewAgent(client, "gpt-5.6-sol", "system", Registry{})
151			a.RetryBaseDelay = time.Millisecond
152
153			if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
154				t.Fatalf("Prompt returned %v; want retry to succeed", err)
155			}
156			if got := atomic.LoadInt32(&client.calls); got != 2 {
157				t.Fatalf("Stream calls = %d; want 2", got)
158			}
159			msgs := a.Messages()
160			if len(msgs) != 2 || extractText(msgs[1]) != "ok" {
161				t.Fatalf("messages = %v; want user + ok assistant", msgs)
162			}
163		})
164	}
165}
166
167// TestCanRetryErrorCapacityMessages pins the classification of Codex
168// capacity wording and makes sure quota/usage limits stay terminal even
169// when they carry "try again later" style advice.
170func TestCanRetryErrorCapacityMessages(t *testing.T) {
171	a := NewAgent(nil, "gpt-5.6-sol", "system", Registry{})
172	cases := []struct {
173		msg  string
174		want bool
175	}{
176		{"codex error: Our servers are currently overloaded. Please try again later.", true},
177		{"codex error: Our servers are busy right now.", true},
178		{"codex error: Please try again later.", true},
179		{"codex error: You have hit your monthly usage limit. Try again later.", false},
180		{"codex error: quota exceeded, try again later", false},
181		{"codex error: unsupported parameter: reasoning", false},
182	}
183	for _, tc := range cases {
184		if got := a.canRetryError(errors.New(tc.msg), 0); got != tc.want {
185			t.Errorf("canRetryError(%q) = %v; want %v", tc.msg, got, tc.want)
186		}
187	}
188}
189
190type partialRetryFakeClient struct {
191	calls int32
192}
193
194func (c *partialRetryFakeClient) Name() string { return "partial-retry-fake" }
195
196func (c *partialRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
197	call := atomic.AddInt32(&c.calls, 1)
198	out := make(chan provider.Event, 4)
199	go func() {
200		defer close(out)
201		out <- provider.EventStart{Provider: "partial-retry-fake", Model: req.Model}
202		if call == 1 {
203			out <- provider.EventTextDelta{Delta: "partial"}
204			out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("provider returned error: 503"), Message: provider.Message{
205				Role:    provider.RoleAssistant,
206				Content: []provider.Content{provider.TextBlock{Text: "partial"}},
207			}}
208			return
209		}
210		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
211			Role:    provider.RoleAssistant,
212			Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
213		}}
214	}()
215	return out, nil
216}
217
218func TestAgentDropsPartialAssistantBeforeRetry(t *testing.T) {
219	client := &partialRetryFakeClient{}
220	a := NewAgent(client, "fake-model", "system", Registry{})
221	a.RetryBaseDelay = time.Millisecond
222
223	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
224		t.Fatalf("Prompt returned %v", err)
225	}
226	msgs := a.Messages()
227	if len(msgs) != 2 {
228		t.Fatalf("message count = %d; want user + recovered assistant", len(msgs))
229	}
230	if got := extractText(msgs[1]); got != "recovered" {
231		t.Fatalf("final assistant text = %q; want recovered", got)
232	}
233}
234
235// captureClient records the last Request it received so tests can
236// assert what the agent put on the wire.
237type captureClient struct {
238	lastReq provider.Request
239}
240
241func (c *captureClient) Name() string { return "capture" }
242
243func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
244	c.lastReq = req
245	out := make(chan provider.Event, 3)
246	go func() {
247		defer close(out)
248		out <- provider.EventStart{Provider: "capture", Model: req.Model}
249		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
250			Role:    provider.RoleAssistant,
251			Content: []provider.Content{provider.TextBlock{Text: "ok"}},
252		}}
253	}()
254	return out, nil
255}
256
257func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) {
258	client := &captureClient{}
259	a := NewAgent(client, "fake-model", "system", Registry{})
260	a.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
261		return true, "", "current phase: parse files"
262	}
263	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
264		t.Fatalf("Prompt returned %v", err)
265	}
266	if !strings.Contains(client.lastReq.System, "current phase: parse files") {
267		t.Fatalf("request system prompt = %q; missing hidden context", client.lastReq.System)
268	}
269	if len(a.Messages()) != 2 {
270		t.Fatalf("transcript message count = %d; hidden context must not be persisted", len(a.Messages()))
271	}
272	for _, msg := range a.Messages() {
273		if strings.Contains(extractText(msg), "current phase: parse files") {
274			t.Fatal("hidden turn context leaked into transcript")
275		}
276	}
277}
278
279func TestBoundedTurnContextPreservesUTF8AndLimit(t *testing.T) {
280	contextText := strings.Repeat("界", maxTurnContextBytes)
281	got := boundedTurnContext(contextText)
282	if len(got) > maxTurnContextBytes {
283		t.Fatalf("bounded context bytes = %d, want <= %d", len(got), maxTurnContextBytes)
284	}
285	if !utf8.ValidString(got) {
286		t.Fatal("bounded context is not valid UTF-8")
287	}
288	if !strings.HasSuffix(got, turnContextTruncatedMarker) {
289		tail := got
290		if len(tail) > 64 {
291			tail = tail[len(tail)-64:]
292		}
293		t.Fatalf("bounded context missing truncation marker: %q", tail)
294	}
295}
296
297func TestAgentPropagatesMaxTokens(t *testing.T) {
298	client := &captureClient{}
299	a := NewAgent(client, "fake-model", "system", Registry{})
300	a.MaxTokens = 64000
301
302	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
303		t.Fatalf("Prompt returned %v", err)
304	}
305	if client.lastReq.MaxTokens != 64000 {
306		t.Fatalf("request MaxTokens = %d; want 64000 (Agent.MaxTokens not propagated)", client.lastReq.MaxTokens)
307	}
308}
309
310func TestAgentPropagatesTemperature(t *testing.T) {
311	client := &captureClient{}
312	a := NewAgent(client, "fake-model", "system", Registry{})
313	temp := float32(0)
314	a.Temperature = &temp
315
316	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
317		t.Fatalf("Prompt returned %v", err)
318	}
319	if client.lastReq.Temperature == nil || *client.lastReq.Temperature != temp {
320		t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp)
321	}
322}
323
324type cancelledRetryClient struct {
325	cancel context.CancelFunc
326}
327
328func (c *cancelledRetryClient) Name() string { return "cancelled-retry" }
329
330func (c *cancelledRetryClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
331	out := make(chan provider.Event, 1)
332	c.cancel()
333	out <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider overloaded")}
334	close(out)
335	return out, nil
336}
337
338func TestAgentDoesNotReportRetryAfterCancellation(t *testing.T) {
339	ctx, cancel := context.WithCancel(context.Background())
340	client := &cancelledRetryClient{cancel: cancel}
341	agent := NewAgent(client, "fake-model", "system", Registry{})
342	agent.MaxRetries = 1
343
344	var retries []EvRetryScheduled
345	err := agent.Prompt(ctx, "hello", nil, func(event AgentEvent) {
346		if retry, ok := event.(EvRetryScheduled); ok {
347			retries = append(retries, retry)
348		}
349	})
350	if !errors.Is(err, context.Canceled) {
351		t.Fatalf("Prompt error = %v, want context.Canceled", err)
352	}
353	if len(retries) != 0 {
354		t.Fatalf("retry events = %#v, want none after cancellation", retries)
355	}
356}

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 477

↑↓ moveenter openesc close