v0.44.0 efd47b3

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

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