v0.32.0 362913c

agent_retry_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"io"
9	"reflect"
10	"strings"
11	"sync/atomic"
12	"testing"
13	"time"
14	"unicode/utf8"
15
16	"github.com/bnema/zut/packages/provider"
17)
18
19type retryFakeClient struct {
20	calls    int32
21	firstErr error
22}
23
24func (c *retryFakeClient) Name() string { return "retry-fake" }
25
26func (c *retryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
27	if req.Lifecycle != nil {
28		req.Lifecycle.RequestAttempt(1, 1)
29	}
30	call := atomic.AddInt32(&c.calls, 1)
31	out := make(chan provider.Event, 4)
32	go func() {
33		defer close(out)
34		out <- provider.EventStart{Provider: "retry-fake", Model: req.Model}
35		if call == 1 {
36			err := c.firstErr
37			if err == nil {
38				err = fmt.Errorf("anthropic overloaded_error: Overloaded")
39			}
40			out <- provider.EventDone{Stop: provider.StopError, Err: err}
41			return
42		}
43		out <- provider.EventTextDelta{Delta: "ok"}
44		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
45			Role:    provider.RoleAssistant,
46			Content: []provider.Content{provider.TextBlock{Text: "ok"}},
47		}}
48	}()
49	return out, nil
50}
51
52type silentStreamClient struct{}
53
54func (silentStreamClient) Name() string { return "silent-stream" }
55
56func (silentStreamClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
57	out := make(chan provider.Event)
58	go func() {
59		defer close(out)
60		<-ctx.Done()
61	}()
62	return out, nil
63}
64
65type activeStreamClient struct{}
66
67func (activeStreamClient) Name() string { return "active-stream" }
68
69func (activeStreamClient) Stream(context.Context, provider.Request) (<-chan provider.Event, error) {
70	out := make(chan provider.Event, 2)
71	out <- provider.EventStart{}
72	out <- provider.EventDone{Stop: provider.StopEnd}
73	close(out)
74	return out, nil
75}
76
77type postToolSilentClient struct {
78	calls atomic.Int32
79}
80
81func (c *postToolSilentClient) Name() string { return "post-tool-silent" }
82
83func (c *postToolSilentClient) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Event, error) {
84	if c.calls.Add(1) == 1 {
85		out := make(chan provider.Event, 1)
86		out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{
87			Role: provider.RoleAssistant,
88			Content: []provider.Content{provider.ToolCallBlock{
89				ID: "call-1", Name: "result", Arguments: json.RawMessage(`{}`),
90			}},
91		}}
92		close(out)
93		return out, nil
94	}
95	out := make(chan provider.Event)
96	go func() {
97		defer close(out)
98		<-ctx.Done()
99	}()
100	return out, nil
101}
102
103type requestOpenFailureClient struct{ calls int32 }
104
105func (c *requestOpenFailureClient) Name() string { return "request-open-failure" }
106
107func (c *requestOpenFailureClient) Stream(context.Context, provider.Request) (<-chan provider.Event, error) {
108	atomic.AddInt32(&c.calls, 1)
109	return nil, errors.New("provider returned error: 503 service unavailable")
110}
111
112type readableAfterCancellationClient struct {
113	cancel  context.CancelFunc
114	drained chan struct{}
115}
116
117func (c readableAfterCancellationClient) Name() string { return "readable-after-cancellation" }
118
119func (c readableAfterCancellationClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
120	out := make(chan provider.Event, 1)
121	out <- provider.EventStart{}
122	c.cancel()
123	go func() {
124		out <- provider.EventTextDelta{Delta: "discard after cancellation"}
125		close(out)
126		close(c.drained)
127	}()
128	return out, nil
129}
130
131func TestNewAgentUsesCodexStreamGuardDefaults(t *testing.T) {
132	a := NewAgent(&retryFakeClient{}, "fake-model", "system", Registry{})
133	if a.MaxSteps != 0 {
134		t.Fatalf("max steps = %d, want unlimited", a.MaxSteps)
135	}
136	if a.MaxRetries != 5 {
137		t.Fatalf("stream retries = %d, want 5", a.MaxRetries)
138	}
139	if a.StreamIdleTimeout != 5*time.Minute {
140		t.Fatalf("stream idle timeout = %s, want 5m", a.StreamIdleTimeout)
141	}
142}
143
144func TestAgentStopsReadingStreamAfterCancellation(t *testing.T) {
145	ctx, cancel := context.WithCancel(context.Background())
146	drained := make(chan struct{})
147	a := NewAgent(readableAfterCancellationClient{cancel: cancel, drained: drained}, "fake-model", "system", Registry{})
148	a.MaxRetries = 0
149	if err := a.Prompt(ctx, "hello", nil, nil); !errors.Is(err, context.Canceled) {
150		t.Fatalf("Prompt error = %v, want context.Canceled", err)
151	}
152	select {
153	case <-drained:
154	case <-time.After(time.Second):
155		t.Fatal("provider producer remained blocked after turn cancellation")
156	}
157}
158
159func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) {
160	a := NewAgent(silentStreamClient{}, "fake-model", "system", Registry{})
161	a.MaxRetries = 0
162	a.StreamIdleTimeout = time.Minute
163	idle := make(chan time.Time, 1)
164	a.streamIdleTimer = func(time.Duration) (<-chan time.Time, func(), func()) {
165		idle <- time.Now()
166		return idle, func() {}, func() {}
167	}
168	if err := a.Prompt(context.Background(), "hello", nil, nil); !errors.Is(err, ErrStreamIdleTimeout) {
169		t.Fatalf("Prompt error = %v, want ErrStreamIdleTimeout", err)
170	}
171}
172
173func TestAgentResetsIdleDeadlineForEveryStreamEvent(t *testing.T) {
174	a := NewAgent(activeStreamClient{}, "fake-model", "system", Registry{})
175	a.MaxRetries = 0
176	a.StreamIdleTimeout = time.Minute
177	var resets atomic.Int32
178	a.streamIdleTimer = func(time.Duration) (<-chan time.Time, func(), func()) {
179		return make(chan time.Time), func() {}, func() { resets.Add(1) }
180	}
181	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
182		t.Fatal(err)
183	}
184	if got := resets.Load(); got != 2 {
185		t.Fatalf("idle timer resets = %d, want one for each stream event", got)
186	}
187}
188
189func TestAgentDoesNotRetrySilentStream(t *testing.T) {
190	client := &retryFakeClient{firstErr: ErrStreamIdleTimeout}
191	a := NewAgent(client, "fake-model", "system", Registry{})
192	a.RetryBaseDelay = time.Millisecond
193	if err := a.Prompt(context.Background(), "hello", nil, nil); !errors.Is(err, ErrStreamIdleTimeout) {
194		t.Fatalf("Prompt error = %v, want ErrStreamIdleTimeout", err)
195	}
196	if got := atomic.LoadInt32(&client.calls); got != 1 {
197		t.Fatalf("Stream calls = %d; want no reconnect after idle deadline", got)
198	}
199}
200
201func TestAgentFailsSilentContinuationAfterToolResult(t *testing.T) {
202	client := &postToolSilentClient{}
203	a := NewAgent(client, "fake-model", "system", Registry{
204		"result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}},
205	})
206	a.RetryBaseDelay = time.Millisecond
207	var timers atomic.Int32
208	a.streamIdleTimer = func(time.Duration) (<-chan time.Time, func(), func()) {
209		idle := make(chan time.Time, 1)
210		if timers.Add(1) > 1 {
211			idle <- time.Now()
212		}
213		return idle, func() {}, func() {}
214	}
215	var durable []string
216	err := a.Prompt(context.Background(), "review", nil, func(event AgentEvent) {
217		switch event.(type) {
218		case EvUserMessage, EvAssistantMessage, EvToolCall, EvToolResult:
219			durable = append(durable, event.Type())
220		}
221	})
222	if !errors.Is(err, ErrStreamIdleTimeout) {
223		t.Fatalf("Prompt error = %v, want ErrStreamIdleTimeout", err)
224	}
225	if got := client.calls.Load(); got != 2 {
226		t.Fatalf("provider calls = %d, want tool request plus one bounded continuation", got)
227	}
228	if len(durable) == 0 {
229		t.Fatal("agent emitted no durable events")
230	}
231	if got, want := durable[len(durable)-1], "tool_result"; got != want {
232		t.Fatalf("last durable event = %q, want %q; events = %v", got, want, durable)
233	}
234}
235
236func TestAgentRetriesOverloadedStreamError(t *testing.T) {
237	client := &retryFakeClient{}
238	a := NewAgent(client, "fake-model", "system", Registry{})
239	a.RetryBaseDelay = time.Millisecond
240
241	var turnErrs []string
242	err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) {
243		if e, ok := ev.(EvTurnEnd); ok && e.Err != nil {
244			turnErrs = append(turnErrs, e.Err.Error())
245		}
246	})
247	if err != nil {
248		t.Fatalf("Prompt returned %v", err)
249	}
250	if got := atomic.LoadInt32(&client.calls); got != 2 {
251		t.Fatalf("Stream calls = %d; want 2", got)
252	}
253	if len(turnErrs) != 1 || !strings.Contains(turnErrs[0], "overloaded_error") {
254		t.Fatalf("turn errors = %v; want one overloaded error before retry", turnErrs)
255	}
256	msgs := a.Messages()
257	if len(msgs) != 2 {
258		t.Fatalf("message count = %d; want user + final assistant", len(msgs))
259	}
260	if got := extractText(msgs[1]); got != "ok" {
261		t.Fatalf("final assistant text = %q; want ok", got)
262	}
263}
264
265func TestAgentDoesNotReplayProviderRequestRetriesAtStreamLayer(t *testing.T) {
266	client := &requestOpenFailureClient{}
267	a := NewAgent(client, "fake-model", "system", Registry{})
268	a.RetryBaseDelay = time.Millisecond
269
270	err := a.Prompt(context.Background(), "hello", nil, nil)
271	if err == nil || !strings.Contains(err.Error(), "503") {
272		t.Fatalf("Prompt error = %v, want provider failure", err)
273	}
274	if got := atomic.LoadInt32(&client.calls); got != 1 {
275		t.Fatalf("Stream calls = %d, want one provider-owned request sequence", got)
276	}
277}
278
279func TestAgentRetriesUnexpectedEOFStreamError(t *testing.T) {
280	client := &retryFakeClient{firstErr: fmt.Errorf("read SSE: %w", io.ErrUnexpectedEOF)}
281	a := NewAgent(client, "fake-model", "system", Registry{})
282	a.RetryBaseDelay = time.Millisecond
283
284	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
285		t.Fatalf("Prompt returned %v", err)
286	}
287	if got := atomic.LoadInt32(&client.calls); got != 2 {
288		t.Fatalf("Stream calls = %d; want 2", got)
289	}
290}
291
292func TestAgentRetriesBareEOFStreamError(t *testing.T) {
293	client := &retryFakeClient{firstErr: fmt.Errorf("read SSE: %w", io.EOF)}
294	a := NewAgent(client, "fake-model", "system", Registry{})
295	a.RetryBaseDelay = time.Millisecond
296	var records []RetryLifecycleRecord
297	a.OnRetryLifecycle = func(record RetryLifecycleRecord) {
298		records = append(records, record)
299	}
300
301	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
302		t.Fatalf("Prompt returned %v", err)
303	}
304	if got := atomic.LoadInt32(&client.calls); got != 2 {
305		t.Fatalf("Stream calls = %d; want 2", got)
306	}
307	if len(records) != 2 || records[0].Reason != RetryReasonNetwork || records[1].Reason != RetryReasonNetwork {
308		t.Fatalf("retry lifecycle = %#v; want network failure and retry", records)
309	}
310}
311
312func TestAgentEmitsRetryLifecycleEvents(t *testing.T) {
313	client := &retryFakeClient{}
314	a := NewAgent(client, "fake-model", "system", Registry{})
315	a.RetryBaseDelay = time.Millisecond
316
317	var lifecycle []string
318	if err := a.Prompt(context.Background(), "hello", nil, func(ev AgentEvent) {
319		switch e := ev.(type) {
320		case EvRequestStarted:
321			lifecycle = append(lifecycle, fmt.Sprintf("request:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts))
322		case EvRetryScheduled:
323			lifecycle = append(lifecycle, fmt.Sprintf("retry:%s:%d/%d", e.Scope, e.Attempt, e.MaxAttempts))
324		}
325	}); err != nil {
326		t.Fatalf("Prompt returned %v", err)
327	}
328
329	want := []string{
330		"request:agent:1/6",
331		"request:provider:1/1",
332		"retry:agent:2/6",
333		"request:agent:2/6",
334		"request:provider:1/1",
335	}
336	if strings.Join(lifecycle, ",") != strings.Join(want, ",") {
337		t.Fatalf("lifecycle = %v, want %v", lifecycle, want)
338	}
339}
340
341func TestAgentReportsSanitizedRetryLifecycle(t *testing.T) {
342	client := &retryFakeClient{}
343	a := NewAgent(client, "fake-model", "system", Registry{})
344	a.RetryBaseDelay = time.Millisecond
345
346	var records []RetryLifecycleRecord
347	a.OnRetryLifecycle = func(record RetryLifecycleRecord) {
348		records = append(records, record)
349	}
350	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
351		t.Fatalf("Prompt returned %v", err)
352	}
353
354	want := []RetryLifecycleRecord{
355		{
356			Event: RetryLifecycleRequestFailed, Scope: RetryScopeAgent,
357			Attempt: 1, MaxAttempts: 6, Reason: RetryReasonOverload,
358		},
359		{
360			Event: RetryLifecycleRetryScheduled, Scope: RetryScopeAgent,
361			Attempt: 2, MaxAttempts: 6, Reason: RetryReasonOverload, DelayMS: 1,
362		},
363	}
364	if !reflect.DeepEqual(records, want) {
365		t.Fatalf("retry lifecycle = %#v, want %#v", records, want)
366	}
367}
368
369func TestRequestLifecycleSinkForwardsProviderFailureReason(t *testing.T) {
370	var records []RetryLifecycleRecord
371	sink := &requestLifecycleSink{
372		sink:    func(AgentEvent) {},
373		observe: func(record RetryLifecycleRecord) { records = append(records, record) },
374	}
375	sink.RequestFailed(1, 3, provider.RequestFailureOverload, false)
376	sink.RetryScheduled(2, 3, 250*time.Millisecond)
377
378	want := []RetryLifecycleRecord{
379		{
380			Event: RetryLifecycleRequestFailed, Scope: RetryScopeProvider,
381			Attempt: 1, MaxAttempts: 3, Reason: RetryReasonOverload,
382		},
383		{
384			Event: RetryLifecycleRetryScheduled, Scope: RetryScopeProvider,
385			Attempt: 2, MaxAttempts: 3, Reason: RetryReasonOverload, DelayMS: 250,
386		},
387	}
388	if !reflect.DeepEqual(records, want) {
389		t.Fatalf("provider retry lifecycle = %#v, want %#v", records, want)
390	}
391}
392
393func TestAgentReportsTerminalRetryFailure(t *testing.T) {
394	client := &retryFakeClient{}
395	a := NewAgent(client, "fake-model", "system", Registry{})
396	a.MaxRetries = 0
397
398	var records []RetryLifecycleRecord
399	a.OnRetryLifecycle = func(record RetryLifecycleRecord) {
400		records = append(records, record)
401	}
402	if err := a.Prompt(context.Background(), "hello", nil, nil); err == nil {
403		t.Fatal("Prompt succeeded; want terminal provider error")
404	}
405	if len(records) != 1 {
406		t.Fatalf("retry lifecycle = %#v, want one terminal record", records)
407	}
408	record := records[0]
409	if record.Event != RetryLifecycleRequestFailed || record.Scope != RetryScopeAgent ||
410		record.Attempt != 1 || record.MaxAttempts != 1 || record.Reason != RetryReasonOverload || !record.Terminal {
411		t.Fatalf("terminal retry record = %#v", record)
412	}
413}
414
415// codexRetryFakeClient reproduces a transient OpenAI Codex backend
416// failure on the first call and succeeds afterwards.
417type codexRetryFakeClient struct {
418	calls    int32
419	firstErr string
420}
421
422func (c *codexRetryFakeClient) Name() string { return "openai-codex" }
423
424func (c *codexRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
425	call := atomic.AddInt32(&c.calls, 1)
426	out := make(chan provider.Event, 4)
427	go func() {
428		defer close(out)
429		out <- provider.EventStart{Provider: "openai-codex", Model: req.Model}
430		if call == 1 {
431			out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("%s", c.firstErr)}
432			return
433		}
434		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
435			Role:    provider.RoleAssistant,
436			Content: []provider.Content{provider.TextBlock{Text: "ok"}},
437		}}
438	}()
439	return out, nil
440}
441
442func TestAgentRetriesCodexProcessingError(t *testing.T) {
443	cases := []struct {
444		name string
445		err  string
446	}{
447		{
448			name: "processing error",
449			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.",
450		},
451		{
452			name: "servers overloaded",
453			err:  "codex error: Our servers are currently overloaded. Please try again later.",
454		},
455		{
456			name: "try again later only",
457			err:  "codex error: Something went wrong. Please try again later.",
458		},
459	}
460	for _, tc := range cases {
461		t.Run(tc.name, func(t *testing.T) {
462			client := &codexRetryFakeClient{firstErr: tc.err}
463			a := NewAgent(client, "gpt-5.6-sol", "system", Registry{})
464			a.RetryBaseDelay = time.Millisecond
465
466			if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
467				t.Fatalf("Prompt returned %v; want retry to succeed", err)
468			}
469			if got := atomic.LoadInt32(&client.calls); got != 2 {
470				t.Fatalf("Stream calls = %d; want 2", got)
471			}
472			msgs := a.Messages()
473			if len(msgs) != 2 || extractText(msgs[1]) != "ok" {
474				t.Fatalf("messages = %v; want user + ok assistant", msgs)
475			}
476		})
477	}
478}
479
480// TestCanRetryErrorCapacityMessages pins the classification of Codex
481// capacity wording and makes sure quota/usage limits stay terminal even
482// when they carry "try again later" style advice.
483func TestCanRetryErrorCapacityMessages(t *testing.T) {
484	a := NewAgent(nil, "gpt-5.6-sol", "system", Registry{})
485	cases := []struct {
486		msg  string
487		want bool
488	}{
489		{"codex error: Our servers are currently overloaded. Please try again later.", true},
490		{"codex error: Our servers are busy right now.", true},
491		{"codex error: Please try again later.", true},
492		{"codex error: You have hit your monthly usage limit. Try again later.", false},
493		{"codex error: quota exceeded, try again later", false},
494		{"codex error: unsupported parameter: reasoning", false},
495	}
496	for _, tc := range cases {
497		if got := a.canRetryError(errors.New(tc.msg), 0); got != tc.want {
498			t.Errorf("canRetryError(%q) = %v; want %v", tc.msg, got, tc.want)
499		}
500	}
501}
502
503type partialRetryFakeClient struct {
504	calls int32
505}
506
507func (c *partialRetryFakeClient) Name() string { return "partial-retry-fake" }
508
509func (c *partialRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
510	call := atomic.AddInt32(&c.calls, 1)
511	out := make(chan provider.Event, 4)
512	go func() {
513		defer close(out)
514		out <- provider.EventStart{Provider: "partial-retry-fake", Model: req.Model}
515		if call == 1 {
516			out <- provider.EventTextDelta{Delta: "partial"}
517			out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("provider returned error: 503"), Message: provider.Message{
518				Role:    provider.RoleAssistant,
519				Content: []provider.Content{provider.TextBlock{Text: "partial"}},
520			}}
521			return
522		}
523		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
524			Role:    provider.RoleAssistant,
525			Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
526		}}
527	}()
528	return out, nil
529}
530
531func TestAgentDropsPartialAssistantBeforeRetry(t *testing.T) {
532	client := &partialRetryFakeClient{}
533	a := NewAgent(client, "fake-model", "system", Registry{})
534	a.RetryBaseDelay = time.Millisecond
535
536	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
537		t.Fatalf("Prompt returned %v", err)
538	}
539	msgs := a.Messages()
540	if len(msgs) != 2 {
541		t.Fatalf("message count = %d; want user + recovered assistant", len(msgs))
542	}
543	if got := extractText(msgs[1]); got != "recovered" {
544		t.Fatalf("final assistant text = %q; want recovered", got)
545	}
546}
547
548// captureClient records the last Request it received so tests can
549// assert what the agent put on the wire.
550type captureClient struct {
551	lastReq provider.Request
552}
553
554func (c *captureClient) Name() string { return "capture" }
555
556func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
557	c.lastReq = req
558	out := make(chan provider.Event, 3)
559	go func() {
560		defer close(out)
561		out <- provider.EventStart{Provider: "capture", Model: req.Model}
562		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
563			Role:    provider.RoleAssistant,
564			Content: []provider.Content{provider.TextBlock{Text: "ok"}},
565		}}
566	}()
567	return out, nil
568}
569
570func TestAgentPersistsTurnContextAsDeveloperHistory(t *testing.T) {
571	client := &captureClient{}
572	a := NewAgent(client, "fake-model", "system", Registry{})
573	a.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
574		return true, "", "current phase: parse files"
575	}
576	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
577		t.Fatalf("Prompt returned %v", err)
578	}
579	if strings.Contains(client.lastReq.System, "current phase: parse files") {
580		t.Fatalf("stable request system prompt = %q; dynamic context must not rewrite it", client.lastReq.System)
581	}
582	if len(client.lastReq.Messages) != 2 || client.lastReq.Messages[0].Role != provider.RoleDeveloper || !strings.Contains(extractText(client.lastReq.Messages[0]), "current phase: parse files") {
583		t.Fatalf("request context history = %#v, want developer context before user task", client.lastReq.Messages)
584	}
585	if len(a.Messages()) != 3 || a.Messages()[0].Role != provider.RoleDeveloper {
586		t.Fatalf("persisted transcript = %#v, want developer context", a.Messages())
587	}
588}
589
590func TestBoundedTurnContextPreservesUTF8AndLimit(t *testing.T) {
591	contextText := strings.Repeat("界", maxTurnContextBytes)
592	got := boundedTurnContext(contextText)
593	if len(got) > maxTurnContextBytes {
594		t.Fatalf("bounded context bytes = %d, want <= %d", len(got), maxTurnContextBytes)
595	}
596	if !utf8.ValidString(got) {
597		t.Fatal("bounded context is not valid UTF-8")
598	}
599	if !strings.HasSuffix(got, turnContextTruncatedMarker) {
600		tail := got
601		if len(tail) > 64 {
602			tail = tail[len(tail)-64:]
603		}
604		t.Fatalf("bounded context missing truncation marker: %q", tail)
605	}
606}
607
608func TestAgentPropagatesMaxTokens(t *testing.T) {
609	client := &captureClient{}
610	a := NewAgent(client, "fake-model", "system", Registry{})
611	a.MaxTokens = 64000
612
613	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
614		t.Fatalf("Prompt returned %v", err)
615	}
616	if client.lastReq.MaxTokens != 64000 {
617		t.Fatalf("request MaxTokens = %d; want 64000 (Agent.MaxTokens not propagated)", client.lastReq.MaxTokens)
618	}
619}
620
621func TestAgentPropagatesTemperature(t *testing.T) {
622	client := &captureClient{}
623	a := NewAgent(client, "fake-model", "system", Registry{})
624	temp := float32(0)
625	a.Temperature = &temp
626
627	if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
628		t.Fatalf("Prompt returned %v", err)
629	}
630	if client.lastReq.Temperature == nil || *client.lastReq.Temperature != temp {
631		t.Fatalf("request Temperature = %v; want %v", client.lastReq.Temperature, temp)
632	}
633}
634
635type cancelledRetryClient struct {
636	cancel context.CancelFunc
637}
638
639func (c *cancelledRetryClient) Name() string { return "cancelled-retry" }
640
641func (c *cancelledRetryClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
642	out := make(chan provider.Event, 1)
643	c.cancel()
644	out <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider overloaded")}
645	close(out)
646	return out, nil
647}
648
649func TestAgentDoesNotReportRetryAfterCancellation(t *testing.T) {
650	ctx, cancel := context.WithCancel(context.Background())
651	client := &cancelledRetryClient{cancel: cancel}
652	agent := NewAgent(client, "fake-model", "system", Registry{})
653	agent.MaxRetries = 1
654
655	var retries []EvRetryScheduled
656	var lifecycle []RetryLifecycleRecord
657	agent.OnRetryLifecycle = func(record RetryLifecycleRecord) {
658		lifecycle = append(lifecycle, record)
659	}
660	err := agent.Prompt(ctx, "hello", nil, func(event AgentEvent) {
661		if retry, ok := event.(EvRetryScheduled); ok {
662			retries = append(retries, retry)
663		}
664	})
665	if !errors.Is(err, context.Canceled) {
666		t.Fatalf("Prompt error = %v, want context.Canceled", err)
667	}
668	if len(retries) != 0 {
669		t.Fatalf("retry events = %#v, want none after cancellation", retries)
670	}
671	if len(lifecycle) != 1 || lifecycle[0].Event != RetryLifecycleRequestFailed || !lifecycle[0].Terminal {
672		t.Fatalf("retry lifecycle = %#v, want one terminal failure after cancellation", lifecycle)
673	}
674}

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