v0.14.0 75d4151

goal_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"errors"
6	"strings"
7	"sync"
8	"testing"
9	"time"
10
11	agenttools "github.com/bnema/zut/packages/agent/tools"
12	"github.com/bnema/zut/packages/core"
13	"github.com/bnema/zut/packages/provider"
14)
15
16func goalToolRegistry() core.Registry {
17	return core.Registry{agenttools.UpdateGoalToolName: &agenttools.UpdateGoalTool{}}
18}
19
20type goalContinuationClient struct {
21	mu       sync.Mutex
22	requests []provider.Request
23	onCall   func(call int)
24}
25
26func (c *goalContinuationClient) Name() string { return "goal-continuation" }
27
28func (c *goalContinuationClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
29	c.mu.Lock()
30	c.requests = append(c.requests, req)
31	call := len(c.requests)
32	onCall := c.onCall
33	c.mu.Unlock()
34	if onCall != nil {
35		onCall(call)
36	}
37	out := make(chan provider.Event, 1)
38	out <- provider.EventDone{
39		Stop: provider.StopEnd,
40		Message: provider.Message{
41			Role:    provider.RoleAssistant,
42			Content: []provider.Content{provider.TextBlock{Text: "working"}},
43		},
44	}
45	close(out)
46	return out, nil
47}
48
49func TestActiveGoalStartsAnotherTurnWhenThreadBecomesIdle(t *testing.T) {
50	var mu sync.Mutex
51	var goal *core.SessionGoal
52	currentGoal := func() *core.SessionGoal {
53		mu.Lock()
54		defer mu.Unlock()
55		return cloneSessionGoal(goal)
56	}
57	persistGoal := func(next *core.SessionGoal) error {
58		mu.Lock()
59		goal = cloneSessionGoal(next)
60		mu.Unlock()
61		return nil
62	}
63	client := &goalContinuationClient{onCall: func(call int) {
64		if call == 2 {
65			_ = persistGoal(&core.SessionGoal{Objective: "finish issue 97", Status: core.GoalPaused})
66		}
67	}}
68	turnFlushed := make(chan struct{}, 2)
69	interactive := NewInteractive(InteractiveConfig{
70		Agent:       core.NewAgent(client, "model", "system", goalToolRegistry()),
71		CurrentGoal: currentGoal,
72		PersistGoal: persistGoal,
73		FlushSession: func() {
74			turnFlushed <- struct{}{}
75		},
76	})
77	interactive.runCtx = context.Background()
78	interactive.runSlash(context.Background(), "/goal finish issue 97")
79	for turn := 1; turn <= 2; turn++ {
80		select {
81		case <-turnFlushed:
82		case <-time.After(2 * time.Second):
83			t.Fatalf("turn %d did not finish", turn)
84		}
85	}
86
87	client.mu.Lock()
88	defer client.mu.Unlock()
89	if len(client.requests) != 2 {
90		t.Fatalf("provider requests = %d, want 2", len(client.requests))
91	}
92	messages := client.requests[1].Messages
93	if len(messages) == 0 || messages[len(messages)-1].Meta[goalContinueMetaKey] != "true" {
94		t.Fatalf("continuation request tail = %#v", messages)
95	}
96}
97
98func TestReplacementGoalContinuesAfterStaleTurn(t *testing.T) {
99	var mu sync.Mutex
100	goal := &core.SessionGoal{Objective: "replacement objective", Status: core.GoalActive}
101	currentGoal := func() *core.SessionGoal {
102		mu.Lock()
103		defer mu.Unlock()
104		return cloneSessionGoal(goal)
105	}
106	persistGoal := func(next *core.SessionGoal) error {
107		mu.Lock()
108		goal = cloneSessionGoal(next)
109		mu.Unlock()
110		return nil
111	}
112	client := &goalContinuationClient{onCall: func(call int) {
113		if call == 1 {
114			_ = persistGoal(&core.SessionGoal{Objective: "replacement objective", Status: core.GoalPaused})
115		}
116	}}
117	turnFlushed := make(chan struct{}, 1)
118	interactive := NewInteractive(InteractiveConfig{
119		Agent:       core.NewAgent(client, "model", "system", goalToolRegistry()),
120		CurrentGoal: currentGoal,
121		PersistGoal: persistGoal,
122		FlushSession: func() {
123			turnFlushed <- struct{}{}
124		},
125	})
126	interactive.runCtx = context.Background()
127	interactive.mu.Lock()
128	interactive.busy = true
129	interactive.mu.Unlock()
130	stale := provider.Message{
131		Role:    provider.RoleUser,
132		Content: []provider.Content{provider.TextBlock{Text: "original objective"}},
133		Meta:    map[string]string{goalContinueMetaKey: "true"},
134	}
135
136	interactive.startGoalContinuation(context.Background(), stale)
137	select {
138	case <-turnFlushed:
139	case <-time.After(2 * time.Second):
140		t.Fatal("replacement goal did not start")
141	}
142
143	client.mu.Lock()
144	defer client.mu.Unlock()
145	if len(client.requests) != 1 {
146		t.Fatalf("provider requests = %d, want 1", len(client.requests))
147	}
148	tail := client.requests[0].Messages[len(client.requests[0].Messages)-1]
149	if got := userMessageText(tail); !strings.Contains(got, "replacement objective") || strings.Contains(got, "original objective") {
150		t.Fatalf("continuation message = %q", got)
151	}
152}
153
154func TestToolResultPersistenceRunsOutsideTUILock(t *testing.T) {
155	var transitionMu sync.RWMutex
156	transitionCompleted := false
157	callbackStarted := make(chan struct{})
158	allowPersistence := make(chan struct{})
159	transitionHeld := make(chan struct{})
160	transitionDone := make(chan struct{})
161	handled := make(chan struct{})
162	agent := core.NewAgent(nil, "model", "system", goalToolRegistry())
163	agent.CommitToolResult = func(_ string, _ core.ToolResult) error {
164		close(callbackStarted)
165		<-allowPersistence
166		transitionMu.RLock()
167		completed := transitionCompleted
168		transitionMu.RUnlock()
169		if !completed {
170			return errors.New("session transition did not complete")
171		}
172		return nil
173	}
174	interactive := NewInteractive(InteractiveConfig{Agent: agent})
175	commitErr := make(chan error, 1)
176
177	go func() {
178		commitErr <- agent.CommitToolResult("tool", core.ToolResult{})
179		interactive.handleEvent(core.EvToolResult{ID: "tool", Result: core.ToolResult{}})
180		close(handled)
181	}()
182	select {
183	case <-callbackStarted:
184	case <-time.After(2 * time.Second):
185		t.Fatal("tool-result callback did not start")
186	}
187	go func() {
188		transitionMu.Lock()
189		close(transitionHeld)
190		interactive.ApplySessionAgent(core.NewAgent(nil, "model", "system", goalToolRegistry()), "provider", "model")
191		transitionCompleted = true
192		transitionMu.Unlock()
193		close(transitionDone)
194	}()
195	select {
196	case <-transitionHeld:
197	case <-time.After(2 * time.Second):
198		t.Fatal("session transition did not acquire its lock")
199	}
200	select {
201	case <-transitionDone:
202	case <-time.After(2 * time.Second):
203		close(allowPersistence)
204		t.Fatal("session transition deadlocked with tool-result persistence")
205	}
206	close(allowPersistence)
207	select {
208	case <-handled:
209	case <-time.After(2 * time.Second):
210		t.Fatal("tool-result callback did not finish")
211	}
212	if err := <-commitErr; err != nil {
213		t.Fatalf("commit tool result: %v", err)
214	}
215}
216
217func TestGoalBadgeUsesDurableStatusAfterPersistenceFailure(t *testing.T) {
218	goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
219	interactive := NewInteractive(InteractiveConfig{
220		Agent:       core.NewAgent(nil, "model", "system", goalToolRegistry()),
221		CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
222	})
223	// Core converts a goal update to an error result when durable persistence
224	// fails, so the presentation layer must leave the active badge unchanged.
225	result := core.ToolResult{
226		Content: []provider.Content{provider.TextBlock{Text: "tool result state could not be persisted"}},
227		IsError: true,
228	}
229
230	interactive.handleEvent(core.EvToolResult{ID: "goal", Result: result})
231	interactive.mu.Lock()
232	defer interactive.mu.Unlock()
233	if interactive.goalStatus != core.GoalActive {
234		t.Fatalf("goal badge = %q, want durable active status", interactive.goalStatus)
235	}
236}
237
238func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) {
239	var goal *core.SessionGoal
240	interactive := NewInteractive(InteractiveConfig{
241		Agent:       core.NewAgent(nil, "model", "system", goalToolRegistry()),
242		CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
243		PersistGoal: func(next *core.SessionGoal) error {
244			goal = cloneSessionGoal(next)
245			return nil
246		},
247	})
248	interactive.busy = true // exercise controls without launching a provider turn
249
250	interactive.runSlash(context.Background(), "/goal finish issue 97")
251	if goal == nil || goal.Status != core.GoalActive || goal.Objective != "finish issue 97" {
252		t.Fatalf("started goal = %#v", goal)
253	}
254	if interactive.goalStatus != core.GoalActive {
255		t.Fatalf("started badge = %q", interactive.goalStatus)
256	}
257
258	interactive.runSlash(context.Background(), "/goal pause")
259	if goal.Status != core.GoalPaused {
260		t.Fatalf("paused goal = %#v", goal)
261	}
262	if interactive.goalStatus != core.GoalPaused {
263		t.Fatalf("paused badge = %q", interactive.goalStatus)
264	}
265	interactive.runSlash(context.Background(), "/goal resume")
266	if goal.Status != core.GoalActive {
267		t.Fatalf("resumed goal = %#v", goal)
268	}
269	if interactive.goalStatus != core.GoalActive {
270		t.Fatalf("resumed badge = %q", interactive.goalStatus)
271	}
272	interactive.runSlash(context.Background(), "/goal clear")
273	if goal != nil {
274		t.Fatalf("cleared goal = %#v", goal)
275	}
276	if interactive.goalStatus != "" {
277		t.Fatalf("cleared badge = %q", interactive.goalStatus)
278	}
279	interactive.runSlash(context.Background(), "/goal pause deployment safely")
280	if goal == nil || goal.Status != core.GoalActive || goal.Objective != "pause deployment safely" {
281		t.Fatalf("multiword control-like objective = %#v", goal)
282	}
283	goal.Status = core.GoalBlocked
284	goal.Reason = "waiting for input"
285	interactive.runSlash(context.Background(), "/goal")
286	if !strings.Contains(interactive.statusOK, "waiting for input") {
287		t.Fatalf("goal status = %q", interactive.statusOK)
288	}
289}
290
291func TestGoalControlRequiresSessionPersistence(t *testing.T) {
292	interactive := NewInteractive(InteractiveConfig{})
293	interactive.runSlash(context.Background(), "/goal pause")
294	if !strings.Contains(interactive.statusErr, "session persistence is unavailable") {
295		t.Fatalf("status error = %q", interactive.statusErr)
296	}
297}
298
299func TestGoalCommandDoesNotMutateLiveStateWhenPersistenceFails(t *testing.T) {
300	goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
301	interactive := NewInteractive(InteractiveConfig{
302		Agent:       core.NewAgent(nil, "model", "system", goalToolRegistry()),
303		CurrentGoal: func() *core.SessionGoal { return goal },
304		PersistGoal: func(*core.SessionGoal) error { return errors.New("disk full") },
305	})
306	interactive.busy = true
307
308	interactive.runSlash(context.Background(), "/goal pause")
309	if goal.Status != core.GoalActive {
310		t.Fatalf("live goal status = %q, want active", goal.Status)
311	}
312	if interactive.goalStatus != core.GoalActive {
313		t.Fatalf("goal badge = %q, want active", interactive.goalStatus)
314	}
315}
316
317func TestGoalSlashCommandRequiresUpdateGoalTool(t *testing.T) {
318	var persisted bool
319	interactive := NewInteractive(InteractiveConfig{
320		Agent:       core.NewAgent(nil, "model", "system", nil),
321		CurrentGoal: func() *core.SessionGoal { return nil },
322		PersistGoal: func(*core.SessionGoal) error {
323			persisted = true
324			return nil
325		},
326	})
327
328	interactive.runSlash(context.Background(), "/goal finish issue 97")
329	if persisted {
330		t.Fatal("goal was persisted without the update_goal tool")
331	}
332	if !strings.Contains(interactive.statusErr, "update_goal is unavailable") {
333		t.Fatalf("status error = %q", interactive.statusErr)
334	}
335}
336
337func TestGoalContinuationRequiresActiveIdleGoalAndYieldsToQueuedUser(t *testing.T) {
338	goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
339	agent := core.NewAgent(nil, "model", "system", goalToolRegistry())
340	interactive := &Interactive{
341		agent: agent,
342		cfg:   InteractiveConfig{CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }},
343	}
344
345	message, ok := interactive.goalContinuationIfIdle()
346	if !ok {
347		t.Fatal("active idle goal did not request continuation")
348	}
349	if message.Meta[goalContinueMetaKey] != "true" || message.Role != provider.RoleUser {
350		t.Fatalf("continuation message = %#v", message)
351	}
352	if !isHiddenTranscriptMessage(message) {
353		t.Fatal("goal continuation was rendered as a user message")
354	}
355
356	agent.QueueMessage("user follow-up")
357	if _, ok := interactive.goalContinuationIfIdle(); ok {
358		t.Fatal("goal continuation bypassed queued user work")
359	}
360	agent.DrainQueuedMessages()
361	goal.Status = core.GoalPaused
362	if _, ok := interactive.goalContinuationIfIdle(); ok {
363		t.Fatal("paused goal requested continuation")
364	}
365}
366
367func cloneSessionGoal(goal *core.SessionGoal) *core.SessionGoal {
368	if goal == nil {
369		return nil
370	}
371	clone := *goal
372	return &clone
373}

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