v0.50.1 a637010

interactive_completion_error_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"errors"
6	"strings"
7	"sync/atomic"
8	"testing"
9	"time"
10
11	"github.com/bnema/zut/packages/agent/subagents"
12	"github.com/bnema/zut/packages/core"
13	"github.com/bnema/zut/packages/provider"
14)
15
16type completionErrorClient struct {
17	calls    atomic.Int32
18	started  chan struct{}
19	release  chan struct{}
20	requests chan provider.Request
21}
22
23func (*completionErrorClient) Name() string { return "completion-error-test" }
24func (c *completionErrorClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
25	if c.calls.Add(1) == 1 {
26		close(c.started)
27		select {
28		case <-ctx.Done():
29			return nil, ctx.Err()
30		case <-c.release:
31			return nil, errors.New("synthetic request failure")
32		}
33	}
34	c.requests <- req
35	out := make(chan provider.Event, 1)
36	out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}}}
37	close(out)
38	return out, nil
39}
40
41func TestCompletionSurvivesParentError(t *testing.T) {
42	for _, name := range []string{"provider", "compaction", "idle-pre-turn-compaction"} {
43		t.Run(name, func(t *testing.T) {
44			ctx, cancel := context.WithCancel(context.Background())
45			defer cancel()
46			client := &completionErrorClient{started: make(chan struct{}), release: make(chan struct{}), requests: make(chan provider.Request, 4)}
47			ag := core.NewAgent(client, "test-model", "", nil)
48			ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "existing task"}}}})
49			threshold := 70
50			i := NewInteractive(InteractiveConfig{Agent: ag, Provider: "anthropic", Model: "claude-sonnet-4-5", AutoCompactThreshold: &threshold})
51			i.runCtx = ctx
52			report := func() {
53				i.TrackResidentSubagent("worker", "turn")
54				i.ReportResidentSubagent(subagents.ResidentCompletion{ChildID: "worker", TurnID: "turn", Summary: "retained evidence"})
55			}
56			switch name {
57			case "idle-pre-turn-compaction":
58				i.lastCtxInput = 150000
59				report()
60			case "compaction":
61				i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual})
62			default:
63				i.startTurn(ctx, "work")
64			}
65			select {
66			case <-client.started:
67			case <-time.After(2 * time.Second):
68				t.Fatal("request did not start")
69			}
70			if name != "idle-pre-turn-compaction" {
71				report()
72			}
73			deadline := time.Now().Add(2 * time.Second)
74			for {
75				i.mu.Lock()
76				count := len(i.queued) + ag.QueuedMessageCount()
77				i.mu.Unlock()
78				if count != 0 {
79					break
80				}
81				if time.Now().After(deadline) {
82					t.Fatal("completion did not enter queue")
83				}
84				time.Sleep(time.Millisecond)
85			}
86			i.submitOrQueue("stale user input", nil, true)
87			close(client.release)
88			waitInteractiveIdle(t, i)
89			pending := ag.PendingQueuedMessages()
90			if len(pending) != 1 || !pending[0].HostEvent || !strings.Contains(pending[0].Text, "retained evidence") {
91				t.Fatalf("pending after error = %#v", pending)
92			}
93			if client.calls.Load() != 1 {
94				t.Fatal("error automatically retried")
95			}
96			i.mu.Lock()
97			i.lastCtxInput = 0 // the explicit retry no longer needs compaction
98			i.mu.Unlock()
99			i.startTurn(ctx, "retry now")
100			select {
101			case req := <-client.requests:
102				if got := requestUserTextCount(req, pending[0].Text); got != 1 {
103					t.Fatalf("completion copies = %d, want 1", got)
104				}
105				if requestUserTextCount(req, "stale user input") != 0 {
106					t.Fatal("stale user input survived error")
107				}
108			case <-time.After(2 * time.Second):
109				t.Fatal("explicit retry did not start")
110			}
111			waitInteractiveIdle(t, i)
112			if ag.QueuedMessageCount() != 0 {
113				t.Fatal("completion remained queued after delivery")
114			}
115		})
116	}
117}
118
119func TestExplicitCancellationDiscardsQueuedHostEvents(t *testing.T) {
120	ag := core.NewAgent(nil, "test-model", "", nil)
121	ag.QueuePrompt(core.QueuedMessage{Text: "agent event", HostEvent: true})
122	i := &Interactive{agent: ag, queued: []core.QueuedMessage{{Text: "host event", HostEvent: true}}}
123	i.mu.Lock()
124	i.discardQueuedMessagesLocked(false)
125	i.mu.Unlock()
126	if ag.QueuedMessageCount() != 0 || len(i.queued) != 0 {
127		t.Fatal("explicit cancellation retained queued events")
128	}
129}

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