1package core
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "testing"
8
9 "github.com/bnema/zut/packages/provider"
10)
11
12// recordingTool captures the args it was invoked with so the test
13// can verify the interceptor-rewritten args reached execution.
14type recordingTool struct {
15 lastArgs json.RawMessage
16}
17
18type resultTool struct {
19 result ToolResult
20 err error
21 panic bool
22}
23
24func (t *resultTool) Name() string { return "result" }
25func (t *resultTool) Description() string { return "result" }
26func (t *resultTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
27func (t *resultTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) {
28 if t.panic {
29 panic("tool boom")
30 }
31 return t.result, t.err
32}
33
34func (r *recordingTool) Name() string { return "echo" }
35func (r *recordingTool) Description() string { return "echoes" }
36func (r *recordingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
37
38func (r *recordingTool) Execute(_ context.Context, args json.RawMessage, _ func(string)) (ToolResult, error) {
39 r.lastArgs = append(json.RawMessage(nil), args...)
40 return ToolResult{
41 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
42 }, nil
43}
44
45func TestToolResultPersistenceFailureBecomesToolError(t *testing.T) {
46 agent := NewAgent(nil, "test", "", Registry{
47 "result": &resultTool{result: ToolResult{
48 Content: []provider.Content{provider.TextBlock{Text: "done"}},
49 Details: "durable update",
50 }},
51 })
52 agent.CommitToolResult = func(string, ToolResult) error { return errors.New("disk full") }
53 message := provider.Message{
54 Role: provider.RoleAssistant,
55 Content: []provider.Content{provider.ToolCallBlock{
56 ID: "call-1",
57 Name: "result",
58 Arguments: json.RawMessage(`{}`),
59 }},
60 }
61 var event EvToolResult
62 toolMessage, hadError := agent.executeTools(context.Background(), message, func(ev AgentEvent) {
63 if result, ok := ev.(EvToolResult); ok {
64 event = result
65 }
66 })
67
68 if !hadError {
69 t.Fatal("persistence failure did not mark the tool batch failed")
70 }
71 if !event.Result.IsError || event.Result.Details != nil {
72 t.Fatalf("tool event result = %#v", event.Result)
73 }
74 block, ok := toolMessage.Content[0].(provider.ToolResultBlock)
75 if !ok || !block.IsError {
76 t.Fatalf("tool transcript block = %#v", toolMessage.Content)
77 }
78 if got := block.Content[0].(provider.TextBlock).Text; got != "tool result state could not be persisted" {
79 t.Fatalf("tool error text = %q", got)
80 }
81}
82
83// TestBeforeToolExecuteModifiesArgs verifies that a non-nil
84// modifiedArgs returned from BeforeToolExecute is what the tool
85// actually sees.
86func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
87 rec := &recordingTool{}
88 reg := Registry{"echo": rec}
89 a := NewAgent(nil, "test", "", reg)
90
91 newArgs := json.RawMessage(`{"command":"echo GUARDED: ls"}`)
92 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
93 return true, "", newArgs
94 }
95
96 ctx := context.Background()
97 tools := a.ToolsSnapshot()
98 res := a.runOneTool(ctx, provider.ToolCallBlock{
99 ID: "T1",
100 Name: "echo",
101 Arguments: json.RawMessage(`{"command":"ls"}`),
102 }, tools, func(AgentEvent) {})
103 if res.IsError {
104 t.Fatalf("unexpected error result: %v", res.Content)
105 }
106 assertToolTiming(t, res.Timing)
107 if string(rec.lastArgs) != string(newArgs) {
108 t.Errorf("tool saw %s, want %s", string(rec.lastArgs), string(newArgs))
109 }
110}
111
112// TestBeforeToolExecuteInvalidJSONIgnored verifies that returning
113// malformed JSON as modifiedArgs leaves the original args intact
114// (safety: a buggy interceptor can't corrupt the call).
115func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
116 rec := &recordingTool{}
117 reg := Registry{"echo": rec}
118 a := NewAgent(nil, "test", "", reg)
119
120 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
121 return true, "", json.RawMessage(`{not json`)
122 }
123
124 ctx := context.Background()
125 tools := a.ToolsSnapshot()
126 orig := json.RawMessage(`{"command":"ls"}`)
127 a.runOneTool(ctx, provider.ToolCallBlock{
128 ID: "T1",
129 Name: "echo",
130 Arguments: orig,
131 }, tools, func(AgentEvent) {})
132 if string(rec.lastArgs) != string(orig) {
133 t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig))
134 }
135}
136
137func TestRunOneToolEmitsExecutionStartedAfterApproval(t *testing.T) {
138 rec := &recordingTool{}
139 a := NewAgent(nil, "test", "", Registry{"echo": rec})
140
141 var started []EvToolExecutionStarted
142 res := a.runOneTool(context.Background(), provider.ToolCallBlock{
143 ID: "T1", Name: "echo", Arguments: json.RawMessage(`{"command":"echo ok"}`),
144 }, a.ToolsSnapshot(), func(ev AgentEvent) {
145 if e, ok := ev.(EvToolExecutionStarted); ok {
146 started = append(started, e)
147 }
148 })
149 if res.IsError {
150 t.Fatalf("runOneTool error = %#v", res)
151 }
152 if len(started) != 1 || started[0].ID != "T1" || started[0].Name != "echo" {
153 t.Fatalf("execution events = %#v", started)
154 }
155}
156
157func TestRunOneToolDoesNotEmitExecutionStartedForMissingOrDeniedTools(t *testing.T) {
158 for _, tc := range []struct {
159 name string
160 tools Registry
161 guard func(provider.ToolCallBlock) (bool, string, json.RawMessage)
162 }{
163 {name: "missing tool", tools: Registry{}},
164 {
165 name: "denied tool",
166 tools: Registry{"echo": &recordingTool{}},
167 guard: func(provider.ToolCallBlock) (bool, string, json.RawMessage) { return false, "denied", nil },
168 },
169 } {
170 t.Run(tc.name, func(t *testing.T) {
171 a := NewAgent(nil, "test", "", tc.tools)
172 a.BeforeToolExecute = tc.guard
173 var started int
174 a.runOneTool(context.Background(), provider.ToolCallBlock{ID: "T1", Name: "echo"}, a.ToolsSnapshot(), func(event AgentEvent) {
175 if _, ok := event.(EvToolExecutionStarted); ok {
176 started++
177 }
178 })
179 if started != 0 {
180 t.Fatalf("execution-started events = %d, want none", started)
181 }
182 })
183 }
184}
185
186// TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
187// the interceptor returns an error ToolResult with the reason text.
188func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
189 rec := &recordingTool{}
190 reg := Registry{"echo": rec}
191 a := NewAgent(nil, "test", "", reg)
192
193 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
194 return false, "nope", nil
195 }
196
197 ctx := context.Background()
198 tools := a.ToolsSnapshot()
199 res := a.runOneTool(ctx, provider.ToolCallBlock{
200 ID: "T1",
201 Name: "echo",
202 Arguments: json.RawMessage(`{"command":"ls"}`),
203 }, tools, func(AgentEvent) {})
204 if !res.IsError {
205 t.Fatal("want error result, got success")
206 }
207 if len(res.Content) == 0 {
208 t.Fatal("no content")
209 }
210 tb, ok := res.Content[0].(provider.TextBlock)
211 if !ok || tb.Text != "nope" {
212 t.Errorf("want reason 'nope', got %v", res.Content[0])
213 }
214 if rec.lastArgs != nil {
215 t.Error("tool ran despite block")
216 }
217 assertToolTiming(t, res.Timing)
218}
219
220func TestRunOneToolTimingCoversOutcomes(t *testing.T) {
221 cancelled, cancel := context.WithCancel(context.Background())
222 cancel()
223 cases := []struct {
224 name string
225 tools Registry
226 ctx context.Context
227 want string
228 }{
229 {name: "success", tools: Registry{"result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}}}, ctx: context.Background()},
230 {name: "ordinary error", tools: Registry{"result": &resultTool{err: errors.New("ordinary")}}, ctx: context.Background()},
231 {name: "cancellation", tools: Registry{"result": &resultTool{err: context.Canceled}}, ctx: cancelled},
232 {name: "missing", tools: Registry{}, ctx: context.Background()},
233 {name: "panic", tools: Registry{"result": &resultTool{panic: true}}, ctx: context.Background()},
234 }
235 for _, tc := range cases {
236 t.Run(tc.name, func(t *testing.T) {
237 a := NewAgent(nil, "test", "", tc.tools)
238 callName := "result"
239 if tc.name == "missing" {
240 callName = "missing"
241 }
242 res := a.runOneTool(tc.ctx, provider.ToolCallBlock{ID: "timed", Name: callName}, tc.tools, func(AgentEvent) {})
243 assertToolTiming(t, res.Timing)
244 if tc.name == "panic" {
245 text, ok := res.Content[0].(provider.TextBlock)
246 if !ok || text.Text != "tool execution failed" {
247 t.Fatalf("panic result = %#v, want generic tool execution error", res.Content)
248 }
249 }
250 })
251 }
252}
253
254func assertToolTiming(t *testing.T, timing *provider.ToolTiming) {
255 t.Helper()
256 if timing == nil {
257 t.Fatal("tool result has no timing")
258 }
259 if timing.StartedAt.IsZero() || timing.CompletedAt.IsZero() {
260 t.Fatalf("tool timing has zero wall-clock bound: %#v", timing)
261 }
262 if timing.CompletedAt.Before(timing.StartedAt) {
263 t.Fatalf("tool completed before start: %#v", timing)
264 }
265 if timing.Duration < 0 {
266 t.Fatalf("tool duration = %s, want non-negative", timing.Duration)
267 }
268}