v0.50.1 a637010

plan_flow_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"slices"
8	"testing"
9
10	"github.com/bnema/zut/packages/provider"
11)
12
13// planCallTool stands in for the plan tool in core's tests: executeTools only
14// requires a tool whose Details is a parsed PlanOperation. Ops are popped in
15// call order so one message can exercise a batch of plan calls.
16type planCallTool struct {
17	ops []PlanOperation
18}
19
20func (*planCallTool) Name() string            { return "plan" }
21func (*planCallTool) Description() string     { return "plan" }
22func (*planCallTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
23
24// AllowArgumentRewrite mirrors the real tool: guards may allow or refuse a call
25// but must never rewrite its arguments.
26func (*planCallTool) AllowArgumentRewrite() bool { return false }
27
28func (t *planCallTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) {
29	op := t.ops[0]
30	t.ops = t.ops[1:]
31	return ToolResult{
32		Content: []provider.Content{provider.TextBlock{Text: "raw plan result"}},
33		Details: op,
34	}, nil
35}
36
37func planToolMessage(ids ...string) provider.Message {
38	content := make([]provider.Content, 0, len(ids))
39	for _, id := range ids {
40		content = append(content, provider.ToolCallBlock{ID: id, Name: "plan", Arguments: json.RawMessage(`{}`)})
41	}
42	return provider.Message{Role: provider.RoleAssistant, Content: content}
43}
44
45func planResultTextOf(t *testing.T, msg provider.Message, index int) string {
46	t.Helper()
47	if index >= len(msg.Content) {
48		t.Fatalf("tool message has %d results, want index %d", len(msg.Content), index)
49	}
50	block, ok := msg.Content[index].(provider.ToolResultBlock)
51	if !ok {
52		t.Fatalf("result %d = %T, want provider.ToolResultBlock", index, msg.Content[index])
53	}
54	if len(block.Content) != 1 {
55		t.Fatalf("result %d content = %#v, want one text block", index, block.Content)
56	}
57	text, ok := block.Content[0].(provider.TextBlock)
58	if !ok {
59		t.Fatalf("result %d text = %T, want provider.TextBlock", index, block.Content[0])
60	}
61	return text.Text
62}
63
64// TestExecuteToolsAppliesPlanAfterCommit covers the happy path: preview
65// materializes the list, the commit hook persists it, state advances, and the
66// event carries the call ID and the materialized plan before the tool result.
67func TestExecuteToolsAppliesPlanAfterCommit(t *testing.T) {
68	want := []PlanStep{planStep("one", PlanPending), planStep("two", PlanInProgress)}
69	agent := NewAgent(nil, "test", "", Registry{
70		"plan": &planCallTool{ops: []PlanOperation{{Action: "set", Steps: []PlanStep{planStep("one", PlanPending), planStep("two", PlanInProgress)}}}},
71	})
72	var committed []PlanUpdate
73	agent.CommitToolResult = func(_ string, result ToolResult) error {
74		update, ok := result.Details.(PlanUpdate)
75		if !ok {
76			t.Errorf("CommitToolResult details = %T, want PlanUpdate", result.Details)
77		}
78		committed = append(committed, update)
79		return nil
80	}
81
82	var events []AgentEvent
83	msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1"), func(ev AgentEvent) {
84		events = append(events, ev)
85	})
86	if hadError {
87		t.Fatal("successful plan call marked the batch failed")
88	}
89	if len(committed) != 1 || !slices.Equal(committed[0].Plan, want) {
90		t.Fatalf("committed = %#v, want %#v", committed, want)
91	}
92	if got := agent.CurrentPlan(); !slices.Equal(got, want) {
93		t.Fatalf("plan = %#v, want %#v", got, want)
94	}
95
96	var order []string
97	var planEvent EvPlanUpdate
98	for _, ev := range events {
99		switch e := ev.(type) {
100		case EvPlanUpdate:
101			planEvent = e
102			order = append(order, "plan")
103		case EvToolResult:
104			order = append(order, "result")
105		}
106	}
107	if len(order) != 2 || order[0] != "plan" || order[1] != "result" {
108		t.Fatalf("event order = %#v, want plan update before tool result", order)
109	}
110	if planEvent.CallID != "call-1" || !slices.Equal(planEvent.Update.Plan, want) {
111		t.Fatalf("plan event = %#v, want call-1 and %#v", planEvent, want)
112	}
113	if got := planResultTextOf(t, msg, 0); got != "Plan updated (0/2 completed; in progress: step 2)" {
114		t.Fatalf("result text = %q", got)
115	}
116}
117
118// TestExecuteToolsPreviewFailureChangesNothing covers a state-dependent
119// validation failure: the model gets the reason, and neither state nor the
120// event stream moves.
121func TestExecuteToolsPreviewFailureChangesNothing(t *testing.T) {
122	before := []PlanStep{planStep("one", PlanPending)}
123	agent := NewAgent(nil, "test", "", Registry{
124		"plan": &planCallTool{ops: []PlanOperation{{Action: "remove", Index: 5}}},
125	})
126	agent.SetPlan(before)
127
128	var events []AgentEvent
129	msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1"), func(ev AgentEvent) {
130		if _, ok := ev.(EvPlanUpdate); ok {
131			events = append(events, ev)
132		}
133	})
134	if !hadError {
135		t.Fatal("failed preview did not mark the batch failed")
136	}
137	if len(events) != 0 {
138		t.Fatalf("events = %#v, want no plan update", events)
139	}
140	if got := agent.CurrentPlan(); !slices.Equal(got, before) {
141		t.Fatalf("plan = %#v, want it untouched", got)
142	}
143	block := msg.Content[0].(provider.ToolResultBlock)
144	if !block.IsError || planResultTextOf(t, msg, 0) != "plan: index 5 out of range (plan has 1 steps)" {
145		t.Fatalf("result = %#v, want the validation error", block)
146	}
147}
148
149// TestExecuteToolsCommitFailureChangesNothing covers a rejected persistence:
150// the existing commit-error path replaces the result, and because that clears
151// PlanOperation, nothing is committed and no event is emitted.
152func TestExecuteToolsCommitFailureChangesNothing(t *testing.T) {
153	// A pre-existing plan makes "untouched" distinguishable from "was empty".
154	existing := []PlanStep{planStep("existing", PlanPending)}
155	agent := NewAgent(nil, "test", "", Registry{
156		"plan": &planCallTool{ops: []PlanOperation{{Action: "set", Steps: []PlanStep{planStep("one", PlanPending)}}}},
157	})
158	agent.SetPlan(existing)
159	agent.CommitToolResult = func(string, ToolResult) error { return errors.New("disk full") }
160
161	var events []AgentEvent
162	msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1"), func(ev AgentEvent) {
163		if _, ok := ev.(EvPlanUpdate); ok {
164			events = append(events, ev)
165		}
166	})
167	if !hadError {
168		t.Fatal("commit failure did not mark the batch failed")
169	}
170	if len(events) != 0 {
171		t.Fatalf("events = %#v, want no plan update", events)
172	}
173	if got := agent.CurrentPlan(); !slices.Equal(got, existing) {
174		t.Fatalf("plan = %#v, want it untouched %#v", got, existing)
175	}
176	if got := planResultTextOf(t, msg, 0); got != "tool result state could not be persisted" {
177		t.Fatalf("result text = %q", got)
178	}
179}
180
181// TestExecuteToolsDeniedPlanCallChangesNothing covers a guard or confirmation
182// denial: Execute never runs, so nothing is previewed, persisted, committed, or
183// emitted.
184func TestExecuteToolsDeniedPlanCallChangesNothing(t *testing.T) {
185	existing := []PlanStep{planStep("existing", PlanPending)}
186	agent := NewAgent(nil, "test", "", Registry{
187		"plan": &planCallTool{ops: []PlanOperation{{Action: "clear"}}},
188	})
189	agent.SetPlan(existing)
190	agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
191		return false, "denied by guard", nil
192	}
193	var commitCalls int
194	agent.CommitToolResult = func(_ string, result ToolResult) error {
195		commitCalls++
196		if result.PlanOperation != nil {
197			t.Fatalf("denied call reached persistence with state: %#v", result)
198		}
199		return nil
200	}
201
202	var events []AgentEvent
203	_, hadError := agent.executeTools(context.Background(), planToolMessage("call-1"), func(ev AgentEvent) {
204		if _, ok := ev.(EvPlanUpdate); ok {
205			events = append(events, ev)
206		}
207	})
208	if !hadError {
209		t.Fatal("denial did not mark the batch failed")
210	}
211	if commitCalls != 1 {
212		t.Fatalf("commit hook calls = %d, want 1 without plan state", commitCalls)
213	}
214	if len(events) != 0 {
215		t.Fatalf("events = %#v, want no plan update", events)
216	}
217	if got := agent.CurrentPlan(); !slices.Equal(got, existing) {
218		t.Fatalf("plan = %#v, want it untouched %#v", got, existing)
219	}
220}
221
222// TestExecuteToolsRefusesPlanArgumentRewrite covers a guard that tries to rewrite
223// the plan arguments: the call fails, and state stays untouched.
224func TestExecuteToolsRefusesPlanArgumentRewrite(t *testing.T) {
225	existing := []PlanStep{planStep("existing", PlanPending)}
226	agent := NewAgent(nil, "test", "", Registry{
227		"plan": &planCallTool{ops: []PlanOperation{{Action: "clear"}}},
228	})
229	agent.SetPlan(existing)
230	agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
231		return true, "", json.RawMessage(`{"action":"clear"}`)
232	}
233
234	var events []AgentEvent
235	msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1"), func(ev AgentEvent) {
236		if _, ok := ev.(EvPlanUpdate); ok {
237			events = append(events, ev)
238		}
239	})
240	if !hadError {
241		t.Fatal("rewrite refusal did not mark the batch failed")
242	}
243	if len(events) != 0 {
244		t.Fatalf("events = %#v, want no plan update", events)
245	}
246	if got := agent.CurrentPlan(); !slices.Equal(got, existing) {
247		t.Fatalf("plan = %#v, want it untouched %#v", got, existing)
248	}
249	if got := planResultTextOf(t, msg, 0); got != "tool arguments cannot be rewritten" {
250		t.Fatalf("result text = %q", got)
251	}
252}
253
254// TestExecuteToolsBatchCommitsOnlyPersistedCalls covers a batch whose second
255// persistence fails: the first call is persisted, committed, and emitted; the
256// second changes nothing and reports the failure.
257func TestExecuteToolsBatchCommitsOnlyPersistedCalls(t *testing.T) {
258	agent := NewAgent(nil, "test", "", Registry{
259		"plan": &planCallTool{ops: []PlanOperation{
260			{Action: "set", Steps: []PlanStep{planStep("first", PlanPending)}},
261			{Action: "add", Steps: []PlanStep{planStep("second", PlanPending)}},
262		}},
263	})
264	var commitCalls int
265	agent.CommitToolResult = func(string, ToolResult) error {
266		commitCalls++
267		if commitCalls == 2 {
268			return errors.New("disk full")
269		}
270		return nil
271	}
272
273	var events []AgentEvent
274	msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1", "call-2"), func(ev AgentEvent) {
275		if _, ok := ev.(EvPlanUpdate); ok {
276			events = append(events, ev)
277		}
278	})
279	if !hadError {
280		t.Fatal("failed second commit did not mark the batch failed")
281	}
282	if len(events) != 1 {
283		t.Fatalf("events = %d, want only the first call's update", len(events))
284	}
285	if planEvent, ok := events[0].(EvPlanUpdate); !ok || planEvent.CallID != "call-1" {
286		t.Fatalf("event = %#v, want call-1's update", events[0])
287	}
288	want := []PlanStep{planStep("first", PlanPending)}
289	if got := agent.CurrentPlan(); !slices.Equal(got, want) {
290		t.Fatalf("plan = %#v, want only the committed call %#v", got, want)
291	}
292	if got := planResultTextOf(t, msg, 1); got != "tool result state could not be persisted" {
293		t.Fatalf("second result text = %q", got)
294	}
295}
296
297// TestExecuteToolsShowReadsWithoutPersisting covers the read action: the model
298// sees the listing, and nothing is committed or persisted for it.
299func TestExecuteToolsShowReadsWithoutPersisting(t *testing.T) {
300	plan := []PlanStep{planStep("done", PlanCompleted), planStep("todo", PlanPending)}
301	agent := NewAgent(nil, "test", "", Registry{
302		"plan": &planCallTool{ops: []PlanOperation{{Action: "show"}}},
303	})
304	agent.SetPlan(plan)
305	var commitCalls int
306	agent.CommitToolResult = func(_ string, result ToolResult) error {
307		commitCalls++
308		if result.PlanOperation != nil || result.Details != nil {
309			t.Fatalf("show result reached persistence with state: %#v", result)
310		}
311		return nil
312	}
313
314	var events []AgentEvent
315	msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1"), func(ev AgentEvent) {
316		if _, ok := ev.(EvPlanUpdate); ok {
317			events = append(events, ev)
318		}
319	})
320	if hadError {
321		t.Fatal("show marked the batch failed")
322	}
323	if commitCalls != 1 {
324		t.Fatalf("commit hook calls = %d, want 1", commitCalls)
325	}
326	if len(events) != 0 {
327		t.Fatalf("events = %#v, want no plan update", events)
328	}
329	if got := agent.CurrentPlan(); !slices.Equal(got, plan) {
330		t.Fatalf("plan = %#v, want it untouched", got)
331	}
332	if got := planResultTextOf(t, msg, 0); got != "1. [x] done\n2. [ ] todo" {
333		t.Fatalf("result text = %q", got)
334	}
335}
336
337// TestExecuteToolsAppliesPlanBatchInOrder covers two plan calls in one assistant
338// message: each is previewed, persisted, committed, and emitted in call order.
339func TestExecuteToolsAppliesPlanBatchInOrder(t *testing.T) {
340	agent := NewAgent(nil, "test", "", Registry{
341		"plan": &planCallTool{ops: []PlanOperation{
342			{Action: "set", Steps: []PlanStep{planStep("one", PlanPending), planStep("two", PlanPending)}},
343			{Action: "add", Steps: []PlanStep{planStep("three", PlanPending)}},
344		}},
345	})
346	var committed []PlanUpdate
347	agent.CommitToolResult = func(_ string, result ToolResult) error {
348		committed = append(committed, result.Details.(PlanUpdate))
349		return nil
350	}
351
352	var callIDs []string
353	msg, hadError := agent.executeTools(context.Background(), planToolMessage("call-1", "call-2"), func(ev AgentEvent) {
354		if planEvent, ok := ev.(EvPlanUpdate); ok {
355			callIDs = append(callIDs, planEvent.CallID)
356		}
357	})
358	if hadError {
359		t.Fatal("batch marked failed")
360	}
361	if len(committed) != 2 || len(committed[0].Plan) != 2 || len(committed[1].Plan) != 3 {
362		t.Fatalf("committed = %#v, want a 2-step then a 3-step plan", committed)
363	}
364	want := []PlanStep{planStep("one", PlanPending), planStep("two", PlanPending), planStep("three", PlanPending)}
365	if got := agent.CurrentPlan(); !slices.Equal(got, want) {
366		t.Fatalf("plan = %#v, want %#v", got, want)
367	}
368	if len(callIDs) != 2 || callIDs[0] != "call-1" || callIDs[1] != "call-2" {
369		t.Fatalf("plan events = %#v, want call-1 then call-2", callIDs)
370	}
371	if len(msg.Content) != 2 {
372		t.Fatalf("tool results = %d, want 2", len(msg.Content))
373	}
374}
375
376// TestExecuteToolsRejectsUnknownActionFromCore covers the one interface-table
377// error the tool may hand through: core owns the unknown-action message.
378func TestExecuteToolsRejectsUnknownActionFromCore(t *testing.T) {
379	agent := NewAgent(nil, "test", "", Registry{
380		"plan": &planCallTool{ops: []PlanOperation{{Action: "foo"}}},
381	})
382	if got := planResultTextOf(t, mustExecuteTools(t, agent, "call-1"), 0); got != `plan: unknown action "foo"` {
383		t.Fatalf("result text = %q", got)
384	}
385}
386
387func mustExecuteTools(t *testing.T, agent *Agent, ids ...string) provider.Message {
388	t.Helper()
389	msg, _ := agent.executeTools(context.Background(), planToolMessage(ids...), func(AgentEvent) {})
390	return msg
391}

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