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
83func TestToolResultCommitErrorUsesSafeMessage(t *testing.T) {
84 agent := NewAgent(nil, "test", "", Registry{
85 "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "done"}}}},
86 })
87 agent.CommitToolResult = func(string, ToolResult) error {
88 return &ToolResultCommitError{Message: "goal transition rejected: the current goal is still active", Err: errors.New("database path /private/session is unavailable")}
89 }
90 message := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "result", Arguments: json.RawMessage(`{}`)}}}
91 toolMessage, hadError := agent.executeTools(context.Background(), message, func(AgentEvent) {})
92 if !hadError {
93 t.Fatal("commit error did not mark the tool batch failed")
94 }
95 block := toolMessage.Content[0].(provider.ToolResultBlock)
96 if got := block.Content[0].(provider.TextBlock).Text; got != "goal transition rejected: the current goal is still active" {
97 t.Fatalf("tool error text = %q", got)
98 }
99}
100
101// TestBeforeToolExecuteModifiesArgs verifies that a non-nil
102// modifiedArgs returned from BeforeToolExecute is what the tool
103// actually sees.
104func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
105 rec := &recordingTool{}
106 reg := Registry{"echo": rec}
107 a := NewAgent(nil, "test", "", reg)
108
109 newArgs := json.RawMessage(`{"command":"echo GUARDED: ls"}`)
110 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
111 return true, "", newArgs
112 }
113
114 ctx := context.Background()
115 tools := a.ToolsSnapshot()
116 res := a.runOneTool(ctx, provider.ToolCallBlock{
117 ID: "T1",
118 Name: "echo",
119 Arguments: json.RawMessage(`{"command":"ls"}`),
120 }, tools, func(AgentEvent) {})
121 if res.IsError {
122 t.Fatalf("unexpected error result: %v", res.Content)
123 }
124 assertToolTiming(t, res.Timing)
125 if string(rec.lastArgs) != string(newArgs) {
126 t.Errorf("tool saw %s, want %s", string(rec.lastArgs), string(newArgs))
127 }
128}
129
130// TestBeforeToolExecuteInvalidJSONIgnored verifies that returning
131// malformed JSON as modifiedArgs leaves the original args intact
132// (safety: a buggy interceptor can't corrupt the call).
133func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
134 rec := &recordingTool{}
135 reg := Registry{"echo": rec}
136 a := NewAgent(nil, "test", "", reg)
137
138 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
139 return true, "", json.RawMessage(`{not json`)
140 }
141
142 ctx := context.Background()
143 tools := a.ToolsSnapshot()
144 orig := json.RawMessage(`{"command":"ls"}`)
145 a.runOneTool(ctx, provider.ToolCallBlock{
146 ID: "T1",
147 Name: "echo",
148 Arguments: orig,
149 }, tools, func(AgentEvent) {})
150 if string(rec.lastArgs) != string(orig) {
151 t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig))
152 }
153}
154
155func TestRunOneToolEmitsExecutionStartedAfterApproval(t *testing.T) {
156 rec := &recordingTool{}
157 a := NewAgent(nil, "test", "", Registry{"echo": rec})
158
159 var started []EvToolExecutionStarted
160 res := a.runOneTool(context.Background(), provider.ToolCallBlock{
161 ID: "T1", Name: "echo", Arguments: json.RawMessage(`{"command":"echo ok"}`),
162 }, a.ToolsSnapshot(), func(ev AgentEvent) {
163 if e, ok := ev.(EvToolExecutionStarted); ok {
164 started = append(started, e)
165 }
166 })
167 if res.IsError {
168 t.Fatalf("runOneTool error = %#v", res)
169 }
170 if len(started) != 1 || started[0].ID != "T1" || started[0].Name != "echo" {
171 t.Fatalf("execution events = %#v", started)
172 }
173}
174
175func TestRunOneToolDoesNotEmitExecutionStartedForMissingOrDeniedTools(t *testing.T) {
176 for _, tc := range []struct {
177 name string
178 tools Registry
179 guard func(provider.ToolCallBlock) (bool, string, json.RawMessage)
180 }{
181 {name: "missing tool", tools: Registry{}},
182 {
183 name: "denied tool",
184 tools: Registry{"echo": &recordingTool{}},
185 guard: func(provider.ToolCallBlock) (bool, string, json.RawMessage) { return false, "denied", nil },
186 },
187 } {
188 t.Run(tc.name, func(t *testing.T) {
189 a := NewAgent(nil, "test", "", tc.tools)
190 a.BeforeToolExecute = tc.guard
191 var started int
192 a.runOneTool(context.Background(), provider.ToolCallBlock{ID: "T1", Name: "echo"}, a.ToolsSnapshot(), func(event AgentEvent) {
193 if _, ok := event.(EvToolExecutionStarted); ok {
194 started++
195 }
196 })
197 if started != 0 {
198 t.Fatalf("execution-started events = %d, want none", started)
199 }
200 })
201 }
202}
203
204// TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
205// the interceptor returns an error ToolResult with the reason text.
206func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
207 rec := &recordingTool{}
208 reg := Registry{"echo": rec}
209 a := NewAgent(nil, "test", "", reg)
210
211 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
212 return false, "nope", nil
213 }
214
215 ctx := context.Background()
216 tools := a.ToolsSnapshot()
217 res := a.runOneTool(ctx, provider.ToolCallBlock{
218 ID: "T1",
219 Name: "echo",
220 Arguments: json.RawMessage(`{"command":"ls"}`),
221 }, tools, func(AgentEvent) {})
222 if !res.IsError {
223 t.Fatal("want error result, got success")
224 }
225 if len(res.Content) == 0 {
226 t.Fatal("no content")
227 }
228 tb, ok := res.Content[0].(provider.TextBlock)
229 if !ok || tb.Text != "nope" {
230 t.Errorf("want reason 'nope', got %v", res.Content[0])
231 }
232 if rec.lastArgs != nil {
233 t.Error("tool ran despite block")
234 }
235 assertToolTiming(t, res.Timing)
236}
237
238func TestRunOneToolTimingCoversOutcomes(t *testing.T) {
239 cancelled, cancel := context.WithCancel(context.Background())
240 cancel()
241 cases := []struct {
242 name string
243 tools Registry
244 ctx context.Context
245 want string
246 }{
247 {name: "success", tools: Registry{"result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}}}, ctx: context.Background()},
248 {name: "ordinary error", tools: Registry{"result": &resultTool{err: errors.New("ordinary")}}, ctx: context.Background()},
249 {name: "cancellation", tools: Registry{"result": &resultTool{err: context.Canceled}}, ctx: cancelled},
250 {name: "missing", tools: Registry{}, ctx: context.Background()},
251 {name: "panic", tools: Registry{"result": &resultTool{panic: true}}, ctx: context.Background()},
252 }
253 for _, tc := range cases {
254 t.Run(tc.name, func(t *testing.T) {
255 a := NewAgent(nil, "test", "", tc.tools)
256 callName := "result"
257 if tc.name == "missing" {
258 callName = "missing"
259 }
260 res := a.runOneTool(tc.ctx, provider.ToolCallBlock{ID: "timed", Name: callName}, tc.tools, func(AgentEvent) {})
261 assertToolTiming(t, res.Timing)
262 if tc.name == "panic" {
263 text, ok := res.Content[0].(provider.TextBlock)
264 if !ok || text.Text != "tool execution failed" {
265 t.Fatalf("panic result = %#v, want generic tool execution error", res.Content)
266 }
267 }
268 })
269 }
270}
271
272func assertToolTiming(t *testing.T, timing *provider.ToolTiming) {
273 t.Helper()
274 if timing == nil {
275 t.Fatal("tool result has no timing")
276 }
277 if timing.StartedAt.IsZero() || timing.CompletedAt.IsZero() {
278 t.Fatalf("tool timing has zero wall-clock bound: %#v", timing)
279 }
280 if timing.CompletedAt.Before(timing.StartedAt) {
281 t.Fatalf("tool completed before start: %#v", timing)
282 }
283 if timing.Duration < 0 {
284 t.Fatalf("tool duration = %s, want non-negative", timing.Duration)
285 }
286}