v0.29.0 73d6864

agent_retry_test.go RawHistoryFolder

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

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