v0.22.0 fae6c20

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() {
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() {
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 TestGoalRuntimeBudgetIsOptionalAndEnforcedWhenSet(t *testing.T) {
135	for _, test := range []struct {
136		name       string
137		budget     *uint64
138		wantStatus core.GoalStatus
139		wantNext   bool
140	}{
141		{name: "unlimited", wantStatus: core.GoalActive, wantNext: true},
142		{name: "limited", budget: uint64ptr(100), wantStatus: core.GoalBudgetLimited},
143	} {
144		t.Run(test.name, func(t *testing.T) {
145			var mu sync.Mutex
146			goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: test.budget}
147			current := func() *core.SessionGoal {
148				mu.Lock()
149				defer mu.Unlock()
150				return cloneSessionGoal(goal)
151			}
152			persist := func(next *core.SessionGoal) error {
153				mu.Lock()
154				goal = cloneSessionGoal(next)
155				mu.Unlock()
156				return nil
157			}
158			interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
159			run, err := interactive.startGoalRun(current())
160			if err != nil || run == nil {
161				t.Fatalf("start goal run = (%v, %v)", run, err)
162			}
163			interactive.observeGoalRun(core.EvToolCall{Name: "bash"})
164			interactive.observeGoalRun(core.EvUsage{Usage: provider.Usage{InputTokens: 40, OutputTokens: 60}})
165			if got := interactive.finishGoalRun(); got != test.wantNext {
166				t.Fatalf("finish goal run = %t, want %t", got, test.wantNext)
167			}
168			got := current()
169			if got.Status != test.wantStatus || got.TokensUsed != 100 {
170				t.Fatalf("goal = %#v", got)
171			}
172		})
173	}
174}
175
176func TestGoalRuntimeLimitsPersistedExhaustedBudgetBeforeRun(t *testing.T) {
177	budget := uint64(100)
178	var mu sync.Mutex
179	goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, TokenBudget: &budget, TokensUsed: budget}
180	current := func() *core.SessionGoal {
181		mu.Lock()
182		defer mu.Unlock()
183		return cloneSessionGoal(goal)
184	}
185	persist := func(next *core.SessionGoal) error {
186		mu.Lock()
187		goal = cloneSessionGoal(next)
188		mu.Unlock()
189		return nil
190	}
191	interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist})
192	if !interactive.limitGoalBeforeRun(current()) {
193		t.Fatal("exhausted budget was not limited before starting a new provider turn")
194	}
195	if got := current(); got.Status != core.GoalBudgetLimited || got.ContinuationID != "" {
196		t.Fatalf("limited goal = %#v", got)
197	}
198}
199
200func TestGoalRuntimeRejectsStaleRun(t *testing.T) {
201	var mu sync.Mutex
202	goal := &core.SessionGoal{ID: "goal-1", Objective: "first goal", Status: core.GoalActive}
203	current := func() *core.SessionGoal {
204		mu.Lock()
205		defer mu.Unlock()
206		return cloneSessionGoal(goal)
207	}
208	persist := func(next *core.SessionGoal) error {
209		mu.Lock()
210		goal = cloneSessionGoal(next)
211		mu.Unlock()
212		return nil
213	}
214	interactive := NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoal: persist, PersistGoalRuntime: persist})
215	run, err := interactive.startGoalRun(current())
216	if err != nil || run == nil {
217		t.Fatalf("start goal run = (%v, %v)", run, err)
218	}
219	if err := persist(&core.SessionGoal{ID: "goal-2", Objective: "new goal", Status: core.GoalActive}); err != nil {
220		t.Fatal(err)
221	}
222	if interactive.finishGoalRun() {
223		t.Fatal("stale run scheduled a continuation")
224	}
225	if got := current(); got.ID != "goal-2" || got.ContinuationID != "" || got.TokensUsed != 0 {
226		t.Fatalf("stale run changed current goal: %#v", got)
227	}
228}
229
230func TestGoalRuntimeRecoversAbandonedLease(t *testing.T) {
231	var mu sync.Mutex
232	goal := &core.SessionGoal{ID: "goal-1", Objective: "finish the work", Status: core.GoalActive, ContinuationID: "abandoned"}
233	current := func() *core.SessionGoal {
234		mu.Lock()
235		defer mu.Unlock()
236		return cloneSessionGoal(goal)
237	}
238	persist := func(next *core.SessionGoal) error {
239		mu.Lock()
240		goal = cloneSessionGoal(next)
241		mu.Unlock()
242		return nil
243	}
244	_ = NewInteractive(InteractiveConfig{CurrentGoal: current, PersistGoalRuntime: persist})
245	if got := current(); got.ContinuationID != "" {
246		t.Fatalf("abandoned lease was not cleared: %#v", got)
247	}
248}
249
250func 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