v0.48.1 deaab59

repetition_guard_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"strings"
8	"sync/atomic"
9	"testing"
10	"time"
11
12	"github.com/bnema/zut/packages/core"
13	"github.com/bnema/zut/packages/provider"
14)
15
16type repetitiveInteractiveRequest struct {
17	call            int32
18	hasQueuedPrompt bool
19	userText        []string
20}
21
22type repetitiveInteractiveClient struct {
23	calls    atomic.Int32
24	entered  chan struct{}
25	resume   chan struct{}
26	requests chan repetitiveInteractiveRequest
27}
28
29func (*repetitiveInteractiveClient) Name() string { return "repetitive-interactive-test" }
30
31func (c *repetitiveInteractiveClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
32	call := c.calls.Add(1)
33	hasQueuedPrompt := false
34	var userText []string
35	for _, message := range req.Messages {
36		if message.Role != provider.RoleUser {
37			continue
38		}
39		for _, content := range message.Content {
40			if text, ok := content.(provider.TextBlock); ok {
41				userText = append(userText, text.Text)
42				if strings.Contains(text.Text, "new request") {
43					hasQueuedPrompt = true
44				}
45			}
46		}
47	}
48	if c.requests != nil {
49		c.requests <- repetitiveInteractiveRequest{call: call, hasQueuedPrompt: hasQueuedPrompt, userText: userText}
50	}
51	if call == 8 && c.entered != nil {
52		close(c.entered)
53		<-c.resume
54	}
55	if call > 8 {
56		out := make(chan provider.Event, 1)
57		out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
58			Role:    provider.RoleAssistant,
59			Content: []provider.Content{provider.TextBlock{Text: "answered"}},
60		}}
61		close(out)
62		return out, nil
63	}
64	out := make(chan provider.Event, 4)
65	out <- provider.EventStart{Provider: c.Name(), Model: req.Model}
66	out <- provider.EventToolStart{ID: "call", Name: "repeat"}
67	out <- provider.EventToolEnd{ID: "call"}
68	out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{
69		Role: provider.RoleAssistant,
70		Content: []provider.Content{provider.ToolCallBlock{
71			ID: "call", Name: "repeat", Arguments: json.RawMessage(`{}`),
72		}},
73	}}
74	close(out)
75	return out, nil
76}
77
78type repetitiveInteractiveTool struct{}
79
80func (*repetitiveInteractiveTool) Name() string        { return "repeat" }
81func (*repetitiveInteractiveTool) Description() string { return "repeats" }
82func (*repetitiveInteractiveTool) Schema() json.RawMessage {
83	return json.RawMessage(`{"type":"object"}`)
84}
85func (*repetitiveInteractiveTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
86	return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "same"}}}, nil
87}
88
89func TestInteractiveRepetitionGuardPreservesQueuedUserPrompt(t *testing.T) {
90	client := &repetitiveInteractiveClient{
91		entered:  make(chan struct{}),
92		resume:   make(chan struct{}),
93		requests: make(chan repetitiveInteractiveRequest, 9),
94	}
95	defer func() {
96		select {
97		case <-client.resume:
98		default:
99			close(client.resume)
100		}
101	}()
102	agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}})
103	interactive := NewInteractive(InteractiveConfig{Agent: agent})
104
105	interactive.startTurn(context.Background(), "start")
106	select {
107	case <-client.entered:
108	case <-time.After(3 * time.Second):
109		t.Fatal("eighth provider request did not start")
110	}
111	interactive.submitOrQueue("new request", nil, true)
112	for call := 1; call <= 8; call++ {
113		select {
114		case request := <-client.requests:
115			if request.call != int32(call) || request.hasQueuedPrompt {
116				t.Fatalf("provider request before loop stop = %#v; want call %d without queued prompt", request, call)
117			}
118			if call == 8 && len(request.userText) == 0 {
119				t.Fatal("eighth provider request was not observed; queueing raced with loop teardown")
120			}
121		case <-time.After(3 * time.Second):
122			t.Fatalf("provider request %d was not observed", call)
123		}
124	}
125	close(client.resume)
126
127	select {
128	case request := <-client.requests:
129		if request.call != 9 || !request.hasQueuedPrompt {
130			t.Fatalf("provider request after loop stop = %#v, want call 9 with queued user prompt", request)
131		}
132	case <-time.After(3 * time.Second):
133		t.Fatal("queued user prompt was not sent after loop stop")
134	}
135	waitInteractiveIdle(t, interactive)
136	if got := client.calls.Load(); got != 9 {
137		t.Fatalf("provider calls = %d, want 9 including the user prompt", got)
138	}
139}
140
141func TestInteractiveRepetitionGuardCanResumeStalledGoal(t *testing.T) {
142	client := &repetitiveInteractiveClient{}
143	store := &interruptGoalStore{}
144	registry := goalToolRegistry()
145	registry["repeat"] = &repetitiveInteractiveTool{}
146	interactive := NewInteractive(InteractiveConfig{
147		Agent:              core.NewAgent(client, "model", "system", registry),
148		CurrentGoal:        store.current,
149		PersistGoal:        store.persist,
150		PersistGoalRuntime: store.persist,
151	})
152	interactive.runCtx = context.Background()
153
154	interactive.runGoalCommand(context.Background(), "/goal finish the work", []string{"/goal", "finish", "the", "work"})
155	waitInteractiveIdle(t, interactive)
156	if got := store.current(); got == nil || got.Status != core.GoalStalled {
157		t.Fatalf("goal after repetitive loop = %#v, want stalled", got)
158	}
159
160	interactive.runGoalCommand(context.Background(), "/goal resume", []string{"/goal", "resume"})
161	waitInteractiveIdle(t, interactive)
162	if got := client.calls.Load(); got < 9 {
163		t.Fatalf("provider requests after resume = %d, want at least 9", got)
164	}
165	if got := store.current(); got == nil || (got.Status != core.GoalActive && got.Status != core.GoalStalled) {
166		t.Fatalf("goal after resume = %#v, want an answered goal run", got)
167	}
168}
169
170func TestInteractiveRepetitionGuardResolvesScheduledFollowUp(t *testing.T) {
171	client := &repetitiveInteractiveClient{
172		entered: make(chan struct{}),
173		resume:  make(chan struct{}),
174	}
175	defer func() {
176		select {
177		case <-client.resume:
178		default:
179			close(client.resume)
180		}
181	}()
182	agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}})
183	interactive := NewInteractive(InteractiveConfig{Agent: agent})
184	ctx, cancel := context.WithCancel(context.Background())
185	defer cancel()
186
187	interactive.startTurn(ctx, "start")
188	select {
189	case <-client.entered:
190	case <-time.After(3 * time.Second):
191		t.Fatal("eighth provider request did not start")
192	}
193	followUp := make(chan error, 1)
194	go func() { followUp <- interactive.SubmitFollowUp(ctx, "scheduled task") }()
195
196	// SubmitFollowUp blocks until teardown resolves it. Hold the eighth
197	// provider call until the scheduled prompt is actually registered, not
198	// merely until its goroutine has been launched.
199	deadline := time.NewTimer(3 * time.Second)
200	defer deadline.Stop()
201	poll := time.NewTicker(time.Millisecond)
202	defer poll.Stop()
203	for {
204		interactive.mu.Lock()
205		queued := len(interactive.scheduled) == 1
206		interactive.mu.Unlock()
207		if queued {
208			break
209		}
210		select {
211		case <-deadline.C:
212			t.Fatal("scheduled follow-up did not enter the queue")
213		case <-poll.C:
214		}
215	}
216	close(client.resume)
217
218	select {
219	case err := <-followUp:
220		if !errors.Is(err, core.ErrRepetitiveLoop) {
221			t.Fatalf("scheduled follow-up error = %v, want ErrRepetitiveLoop", err)
222		}
223	case <-time.After(3 * time.Second):
224		t.Fatal("scheduled follow-up remained blocked after repetitive loop stop")
225	}
226	waitInteractiveIdle(t, interactive)
227	if got := client.calls.Load(); got != 8 {
228		t.Fatalf("provider calls = %d, want 8", got)
229	}
230}
231
232// SubmitFollowUp on an idle interactive without a Run context must start a
233// turn instead of panicking in context.WithCancel(nil parent). This pins the
234// fallback deterministically; the racy busy-to-idle interleaving behind the
235// Windows CI panic needs no timing to cover the nil-parent path itself.
236func TestSubmitFollowUpWithoutRunContextStartsTurn(t *testing.T) {
237	client := &repetitiveInteractiveClient{requests: make(chan repetitiveInteractiveRequest, 16)}
238	agent := core.NewAgent(client, "model", "system", core.Registry{"repeat": &repetitiveInteractiveTool{}})
239	interactive := NewInteractive(InteractiveConfig{Agent: agent})
240	if interactive.runCtx != nil {
241		t.Fatalf("runCtx = %#v, want nil without Run", interactive.runCtx)
242	}
243	if err := interactive.SubmitFollowUp(context.Background(), "scheduled task"); err != nil {
244		t.Fatalf("SubmitFollowUp = %v, want nil", err)
245	}
246	select {
247	case request := <-client.requests:
248		if request.call != 1 {
249			t.Fatalf("provider request = %#v, want first call", request)
250		}
251	case <-time.After(3 * time.Second):
252		t.Fatal("provider request was not observed")
253	}
254	waitInteractiveIdle(t, interactive)
255}

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