v0.35.0 21873e8

goal_runtime_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"sync"
6	"testing"
7	"time"
8
9	"github.com/bnema/zut/packages/agent/tools"
10	"github.com/bnema/zut/packages/core"
11	"github.com/bnema/zut/packages/provider"
12)
13
14type noProgressGoalClient struct {
15	mu    sync.Mutex
16	calls int
17	hits  chan int
18}
19
20func (c *noProgressGoalClient) Name() string { return "goal-no-progress-test" }
21
22func (c *noProgressGoalClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
23	c.mu.Lock()
24	c.calls++
25	call := c.calls
26	c.mu.Unlock()
27	c.hits <- call
28	out := make(chan provider.Event, 1)
29	out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
30		Role:    provider.RoleAssistant,
31		Content: []provider.Content{provider.TextBlock{Text: "Continuing autonomously."}},
32	}}
33	close(out)
34	return out, nil
35}
36
37func (c *noProgressGoalClient) count() int {
38	c.mu.Lock()
39	defer c.mu.Unlock()
40	return c.calls
41}
42
43func TestGoalContinuationDoesNotResendNoProgressMessagesIndefinitely(t *testing.T) {
44	var mu sync.Mutex
45	var goal *core.SessionGoal
46	current := func() *core.SessionGoal {
47		mu.Lock()
48		defer mu.Unlock()
49		return cloneSessionGoal(goal)
50	}
51	persist := func(next *core.SessionGoal) error {
52		mu.Lock()
53		goal = cloneSessionGoal(next)
54		mu.Unlock()
55		return nil
56	}
57	client := &noProgressGoalClient{hits: make(chan int, 3)}
58	agent := core.NewAgent(client, "test", "", core.Registry{tools.UpdateGoalToolName: &tools.UpdateGoalTool{}})
59	interactive := NewInteractive(InteractiveConfig{
60		Agent:              agent,
61		CurrentGoal:        current,
62		PersistGoal:        persist,
63		PersistGoalRuntime: persist,
64	})
65	interactive.runCtx = context.Background()
66	interactive.runGoalCommand(context.Background(), "/goal finish the work", []string{"/goal", "finish", "the", "work"})
67
68	for want := 1; want <= maxConsecutiveGoalNoProgressTurns; want++ {
69		select {
70		case got := <-client.hits:
71			if got != want {
72				t.Fatalf("provider call = %d, want %d", got, want)
73			}
74		case <-time.After(2 * time.Second):
75			t.Fatalf("provider call %d did not start", want)
76		}
77	}
78	waitInteractiveIdle(t, interactive)
79	if got := current(); got == nil || got.Status != core.GoalStalled {
80		t.Fatalf("goal after repeated no-progress responses = %#v", got)
81	}
82	if got := client.count(); got != maxConsecutiveGoalNoProgressTurns {
83		t.Fatalf("provider call count = %d, want %d", got, maxConsecutiveGoalNoProgressTurns)
84	}
85}
86
87func TestGoalRuntimeStallsAfterRepeatedNoProgress(t *testing.T) {
88	var mu sync.Mutex
89	goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive}
90	current := func() *core.SessionGoal {
91		mu.Lock()
92		defer mu.Unlock()
93		return cloneSessionGoal(goal)
94	}
95	persistRuntime := func(next *core.SessionGoal) error {
96		mu.Lock()
97		goal = cloneSessionGoal(next)
98		mu.Unlock()
99		return nil
100	}
101	interactive := NewInteractive(InteractiveConfig{
102		CurrentGoal:        current,
103		PersistGoal:        persistRuntime,
104		PersistGoalRuntime: persistRuntime,
105	})
106
107	first, err := interactive.startGoalRun(current())
108	if err != nil || first == nil {
109		t.Fatalf("start first goal run = (%v, %v), want run without error", first, err)
110	}
111	if !interactive.finishGoalRun(false) {
112		t.Fatal("first non-progress run should receive one corrective continuation")
113	}
114	if got := current(); got.ConsecutiveNoProgressTurns != 1 || got.Status != core.GoalActive || got.ContinuationID != "" {
115		t.Fatalf("first run state = %#v", got)
116	}
117
118	second, err := interactive.startGoalRun(current())
119	if err != nil || second == nil {
120		t.Fatalf("start second goal run = (%v, %v), want run without error", second, err)
121	}
122	if interactive.finishGoalRun(false) {
123		t.Fatal("second non-progress run scheduled another continuation")
124	}
125	got := current()
126	if got.Status != core.GoalStalled || got.ConsecutiveNoProgressTurns != maxConsecutiveGoalNoProgressTurns {
127		t.Fatalf("stalled goal = %#v", got)
128	}
129	if got.Reason == "" || got.ContinuationID != "" {
130		t.Fatalf("stalled goal diagnostics = %#v", got)
131	}
132}
133
134func TestGoalRuntimeContextLimitDoesNotConsumeNoProgressAllowance(t *testing.T) {
135	var mu sync.Mutex
136	goal := &core.SessionGoal{
137		ID:                         "goal-1",
138		Objective:                  "finish the work",
139		Status:                     core.GoalActive,
140		ConsecutiveNoProgressTurns: 1,
141	}
142	current := func() *core.SessionGoal {
143		mu.Lock()
144		defer mu.Unlock()
145		return cloneSessionGoal(goal)
146	}
147	persist := func(next *core.SessionGoal) error {
148		mu.Lock()
149		goal = cloneSessionGoal(next)
150		mu.Unlock()
151		return nil
152	}
153	interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
154	if run, err := interactive.startGoalRun(current()); err != nil || run == nil {
155		t.Fatalf("start goal run = (%v, %v)", run, err)
156	}
157	if !interactive.finishGoalRun(true) {
158		t.Fatal("context-limited run did not request a fresh continuation")
159	}
160	if got := current(); got.Status != core.GoalActive || got.ConsecutiveNoProgressTurns != 1 {
161		t.Fatalf("context-limited goal = %#v", got)
162	}
163}
164
165func TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) {
166	for _, test := range []struct {
167		name       string
168		budget     *uint64
169		wantStatus core.GoalStatus
170		wantNext   bool
171	}{
172		{name: "unlimited", wantStatus: core.GoalActive, wantNext: true},
173		{name: "limited", budget: uint64ptr(100), wantStatus: core.GoalBudgetLimited},
174	} {
175		t.Run(test.name, func(t *testing.T) {
176			var mu sync.Mutex
177			goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: test.budget}
178			current := func() *core.SessionGoal {
179				mu.Lock()
180				defer mu.Unlock()
181				return cloneSessionGoal(goal)
182			}
183			persist := func(next *core.SessionGoal) error {
184				mu.Lock()
185				goal = cloneSessionGoal(next)
186				mu.Unlock()
187				return nil
188			}
189			interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
190			run, err := interactive.startGoalRun(current())
191			if err != nil || run == nil {
192				t.Fatalf("start goal run = (%v, %v)", run, err)
193			}
194			interactive.observeGoalRun(core.EvToolCall{Name: "bash"})
195			interactive.observeGoalRun(core.EvUsage{Usage: provider.Usage{InputTokens: 40, OutputTokens: 60}})
196			if got := interactive.finishGoalRun(false); got != test.wantNext {
197				t.Fatalf("finish goal run = %t, want %t", got, test.wantNext)
198			}
199			got := current()
200			if got.Status != test.wantStatus || got.TokensUsed != 100 {
201				t.Fatalf("goal = %#v", got)
202			}
203		})
204	}
205}
206
207func TestGoalRuntimeLimitsPersistedExhaustedBudgetBeforeRun(t *testing.T) {
208	budget := uint64(100)
209	var mu sync.Mutex
210	goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: &budget, TokensUsed: budget}
211	current := func() *core.SessionGoal {
212		mu.Lock()
213		defer mu.Unlock()
214		return cloneSessionGoal(goal)
215	}
216	persist := func(next *core.SessionGoal) error {
217		mu.Lock()
218		goal = cloneSessionGoal(next)
219		mu.Unlock()
220		return nil
221	}
222	interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist})
223	if !interactive.limitGoalBeforeRun(current()) {
224		t.Fatal("exhausted budget was not limited before starting a new provider turn")
225	}
226	if got := current(); got.Status != core.GoalBudgetLimited || got.ContinuationID != "" {
227		t.Fatalf("limited goal = %#v", got)
228	}
229}
230
231func TestGoalRuntimeRejectsStaleRun(t *testing.T) {
232	var mu sync.Mutex
233	goal := &core.SessionGoal{ID: "goal-1", Objective: "first goal", Status: core.GoalActive}
234	current := func() *core.SessionGoal {
235		mu.Lock()
236		defer mu.Unlock()
237		return cloneSessionGoal(goal)
238	}
239	persist := func(next *core.SessionGoal) error {
240		mu.Lock()
241		goal = cloneSessionGoal(next)
242		mu.Unlock()
243		return nil
244	}
245	interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
246	run, err := interactive.startGoalRun(current())
247	if err != nil || run == nil {
248		t.Fatalf("start goal run = (%v, %v)", run, err)
249	}
250	if err := persist(&core.SessionGoal{ID: "goal-2", Objective: "new goal", Status: core.GoalActive}); err != nil {
251		t.Fatal(err)
252	}
253	if interactive.finishGoalRun(false) {
254		t.Fatal("stale run scheduled a continuation")
255	}
256	if got := current(); got.ID != "goal-2" || got.ContinuationID != "" || got.TokensUsed != 0 {
257		t.Fatalf("stale run changed current goal: %#v", got)
258	}
259}
260
261func TestGoalRuntimeRecoversAbandonedLease(t *testing.T) {
262	var mu sync.Mutex
263	goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, ContinuationID: "abandoned"}
264	current := func() *core.SessionGoal {
265		mu.Lock()
266		defer mu.Unlock()
267		return cloneSessionGoal(goal)
268	}
269	persist := func(next *core.SessionGoal) error {
270		mu.Lock()
271		goal = cloneSessionGoal(next)
272		mu.Unlock()
273		return nil
274	}
275	_ = NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoalRuntime: persist})
276	if got := current(); got.ContinuationID != "" {
277		t.Fatalf("abandoned lease was not cleared: %#v", got)
278	}
279}
280
281func uint64ptr(value uint64) *uint64 { return &value }

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