v0.1.1 7141aff

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	call := atomic.AddInt32(&c.calls, 1)
24	out := make(chan provider.Event, 4)
25	go func() {
26		defer close(out)
27		out <- provider.EventStart{Provider: "retry-fake", Model: req.Model}
28		if call == 1 {
29			out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("anthropic overloaded_error: Overloaded")}
30			return
31		}
32		out <- provider.EventTextDelta{Delta: "ok"}
33		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
34			Role:    provider.RoleAssistant,
35			Content: []provider.Content{provider.TextBlock{Text: "ok"}},
36		}}
37	}()
38	return out, nil
39}
40
41func TestAgentRetriesOverloadedStreamError(t *testing.T) {
42	client := &retryFakeClient{}
43	a := NewAgent(client, "fake-model", "system", Registry{})
44	a.RetryBaseDelay = time.Millisecond
45
46	var turnErrs []string
47	err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) {
48		if e, ok := ev.(EvTurnEnd); ok && e.Err != nil {
49			turnErrs = append(turnErrs, e.Err.Error())
50		}
51	})
52	if err != nil {
53		t.Fatalf("Prompt returned %v", err)
54	}
55	if got := atomic.LoadInt32(&client.calls); got != 2 {
56		t.Fatalf("Stream calls = %d; want 2", got)
57	}
58	if len(turnErrs) != 1 || !strings.Contains(turnErrs[0], "overloaded_error") {
59		t.Fatalf("turn errors = %v; want one overloaded error before retry", turnErrs)
60	}
61	msgs := a.Messages()
62	if len(msgs) != 2 {
63		t.Fatalf("message count = %d; want user + final assistant", len(msgs))
64	}
65	if got := extractText(msgs[1]); got != "ok" {
66		t.Fatalf("final assistant text = %q; want ok", got)
67	}
68}
69
70// codexRetryFakeClient reproduces a transient OpenAI Codex backend
71// failure on the first call and succeeds afterwards.
72type codexRetryFakeClient struct {
73	calls    int32
74	firstErr string
75}
76
77func (c *codexRetryFakeClient) Name() string { return "openai-codex" }
78
79func (c *codexRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
80	call := atomic.AddInt32(&c.calls, 1)
81	out := make(chan provider.Event, 4)
82	go func() {
83		defer close(out)
84		out <- provider.EventStart{Provider: "openai-codex", Model: req.Model}
85		if call == 1 {
86			out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("%s", c.firstErr)}
87			return
88		}
89		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
90			Role:    provider.RoleAssistant,
91			Content: []provider.Content{provider.TextBlock{Text: "ok"}},
92		}}
93	}()
94	return out, nil
95}
96
97func TestAgentRetriesCodexProcessingError(t *testing.T) {
98	cases := []struct {
99		name string
100		err  string
101	}{
102		{
103			name: "processing error",
104			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.",
105		},
106		{
107			name: "servers overloaded",
108			err:  "codex error: Our servers are currently overloaded. Please try again later.",
109		},
110		{
111			name: "try again later only",
112			err:  "codex error: Something went wrong. Please try again later.",
113		},
114	}
115	for _, tc := range cases {
116		t.Run(tc.name, func(t *testing.T) {
117			client := &codexRetryFakeClient{firstErr: tc.err}
118			a := NewAgent(client, "gpt-5.6-sol", "system", Registry{})
119			a.RetryBaseDelay = time.Millisecond
120
121			if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
122				t.Fatalf("Prompt returned %v; want retry to succeed", err)
123			}
124			if got := atomic.LoadInt32(&client.calls); got != 2 {
125				t.Fatalf("Stream calls = %d; want 2", got)
126			}
127			msgs := a.Messages()
128			if len(msgs) != 2 || extractText(msgs[1]) != "ok" {
129				t.Fatalf("messages = %v; want user + ok assistant", msgs)
130			}
131		})
132	}
133}
134
135// TestCanRetryErrorCapacityMessages pins the classification of Codex
136// capacity wording and makes sure quota/usage limits stay terminal even
137// when they carry "try again later" style advice.
138func TestCanRetryErrorCapacityMessages(t *testing.T) {
139	a := NewAgent(nil, "gpt-5.6-sol", "system", Registry{})
140	cases := []struct {
141		msg  string
142		want bool
143	}{
144		{"codex error: Our servers are currently overloaded. Please try again later.", true},
145		{"codex error: Our servers are busy right now.", true},
146		{"codex error: Please try again later.", true},
147		{"codex error: You have hit your monthly usage limit. Try again later.", false},
148		{"codex error: quota exceeded, try again later", false},
149		{"codex error: unsupported parameter: reasoning", false},
150	}
151	for _, tc := range cases {
152		if got := a.canRetryError(errors.New(tc.msg), 0); got != tc.want {
153			t.Errorf("canRetryError(%q) = %v; want %v", tc.msg, got, tc.want)
154		}
155	}
156}
157
158type partialRetryFakeClient struct {
159	calls int32
160}
161
162func (c *partialRetryFakeClient) Name() string { return "partial-retry-fake" }
163
164func (c *partialRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
165	call := atomic.AddInt32(&c.calls, 1)
166	out := make(chan provider.Event, 4)
167	go func() {
168		defer close(out)
169		out <- provider.EventStart{Provider: "partial-retry-fake", Model: req.Model}
170		if call == 1 {
171			out <- provider.EventTextDelta{Delta: "partial"}
172			out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("provider returned error: 503"), Message: provider.Message{
173				Role:    provider.RoleAssistant,
174				Content: []provider.Content{provider.TextBlock{Text: "partial"}},
175			}}
176			return
177		}
178		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
179			Role:    provider.RoleAssistant,
180			Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
181		}}
182	}()
183	return out, nil
184}
185
186func TestAgentDropsPartialAssistantBeforeRetry(t *testing.T) {
187	client := &partialRetryFakeClient{}
188	a := NewAgent(client, "fake-model", "system", Registry{})
189	a.RetryBaseDelay = time.Millisecond
190
191	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
192		t.Fatalf("Prompt returned %v", err)
193	}
194	msgs := a.Messages()
195	if len(msgs) != 2 {
196		t.Fatalf("message count = %d; want user + recovered assistant", len(msgs))
197	}
198	if got := extractText(msgs[1]); got != "recovered" {
199		t.Fatalf("final assistant text = %q; want recovered", got)
200	}
201}
202
203// captureClient records the last Request it received so tests can
204// assert what the agent put on the wire.
205type captureClient struct {
206	lastReq provider.Request
207}
208
209func (c *captureClient) Name() string { return "capture" }
210
211func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
212	c.lastReq = req
213	out := make(chan provider.Event, 3)
214	go func() {
215		defer close(out)
216		out <- provider.EventStart{Provider: "capture", Model: req.Model}
217		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
218			Role:    provider.RoleAssistant,
219			Content: []provider.Content{provider.TextBlock{Text: "ok"}},
220		}}
221	}()
222	return out, nil
223}
224
225func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) {
226	client := &captureClient{}
227	a := NewAgent(client, "fake-model", "system", Registry{})
228	a.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
229		return true, "", "current phase: parse files"
230	}
231	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
232		t.Fatalf("Prompt returned %v", err)
233	}
234	if !strings.Contains(client.lastReq.System, "current phase: parse files") {
235		t.Fatalf("request system prompt = %q; missing hidden context", client.lastReq.System)
236	}
237	if len(a.Messages()) != 2 {
238		t.Fatalf("transcript message count = %d; hidden context must not be persisted", len(a.Messages()))
239	}
240	for _, msg := range a.Messages() {
241		if strings.Contains(extractText(msg), "current phase: parse files") {
242			t.Fatal("hidden turn context leaked into transcript")
243		}
244	}
245}
246
247func TestBoundedTurnContextPreservesUTF8AndLimit(t *testing.T) {
248	contextText := strings.Repeat("界", maxTurnContextBytes)
249	got := boundedTurnContext(contextText)
250	if len(got) > maxTurnContextBytes {
251		t.Fatalf("bounded context bytes = %d, want <= %d", len(got), maxTurnContextBytes)
252	}
253	if !utf8.ValidString(got) {
254		t.Fatal("bounded context is not valid UTF-8")
255	}
256	if !strings.HasSuffix(got, turnContextTruncatedMarker) {
257		tail := got
258		if len(tail) > 64 {
259			tail = tail[len(tail)-64:]
260		}
261		t.Fatalf("bounded context missing truncation marker: %q", tail)
262	}
263}
264
265func TestAgentPropagatesMaxTokens(t *testing.T) {
266	client := &captureClient{}
267	a := NewAgent(client, "fake-model", "system", Registry{})
268	a.MaxTokens = 64000
269
270	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
271		t.Fatalf("Prompt returned %v", err)
272	}
273	if client.lastReq.MaxTokens != 64000 {
274		t.Fatalf("request MaxTokens = %d; want 64000 (Agent.MaxTokens not propagated)", client.lastReq.MaxTokens)
275	}
276}
277
278func TestAgentPropagatesTemperature(t *testing.T) {
279	client := &captureClient{}
280	a := NewAgent(client, "fake-model", "system", Registry{})
281	temp := float32(0)
282	a.Temperature = &temp
283
284	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
285		t.Fatalf("Prompt returned %v", err)
286	}
287	if client.lastReq.Temperature == nil || *client.lastReq.Temperature != temp {
288		t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp)
289	}
290}

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