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}