1package core
2
3import (
4 "context"
5 "encoding/json"
6 "testing"
7
8 "github.com/bnema/zut/packages/provider"
9)
10
11// recordingTool captures the args it was invoked with so the test
12// can verify the interceptor-rewritten args reached execution.
13type recordingTool struct {
14 lastArgs json.RawMessage
15}
16
17func (r *recordingTool) Name() string { return "echo" }
18func (r *recordingTool) Description() string { return "echoes" }
19func (r *recordingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
20
21func (r *recordingTool) Execute(_ context.Context, args json.RawMessage, _ func(string)) (ToolResult, error) {
22 r.lastArgs = append(json.RawMessage(nil), args...)
23 return ToolResult{
24 Content: []provider.Content{provider.TextBlock{Text: "ok"}},
25 }, nil
26}
27
28// TestBeforeToolExecuteModifiesArgs verifies that a non-nil
29// modifiedArgs returned from BeforeToolExecute is what the tool
30// actually sees.
31func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
32 rec := &recordingTool{}
33 reg := Registry{"echo": rec}
34 a := NewAgent(nil, "test", "", reg)
35
36 newArgs := json.RawMessage(`{"command":"echo GUARDED: ls"}`)
37 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
38 return true, "", newArgs
39 }
40
41 ctx := context.Background()
42 tools := a.ToolsSnapshot()
43 res := a.runOneTool(ctx, provider.ToolCallBlock{
44 ID: "T1",
45 Name: "echo",
46 Arguments: json.RawMessage(`{"command":"ls"}`),
47 }, tools, func(AgentEvent) {})
48 if res.IsError {
49 t.Fatalf("unexpected error result: %v", res.Content)
50 }
51 if string(rec.lastArgs) != string(newArgs) {
52 t.Errorf("tool saw %s, want %s", string(rec.lastArgs), string(newArgs))
53 }
54}
55
56// TestBeforeToolExecuteInvalidJSONIgnored verifies that returning
57// malformed JSON as modifiedArgs leaves the original args intact
58// (safety: a buggy interceptor can't corrupt the call).
59func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
60 rec := &recordingTool{}
61 reg := Registry{"echo": rec}
62 a := NewAgent(nil, "test", "", reg)
63
64 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
65 return true, "", json.RawMessage(`{not json`)
66 }
67
68 ctx := context.Background()
69 tools := a.ToolsSnapshot()
70 orig := json.RawMessage(`{"command":"ls"}`)
71 a.runOneTool(ctx, provider.ToolCallBlock{
72 ID: "T1",
73 Name: "echo",
74 Arguments: orig,
75 }, tools, func(AgentEvent) {})
76 if string(rec.lastArgs) != string(orig) {
77 t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig))
78 }
79}
80
81func TestRunOneToolEmitsExecutionStartedAfterApproval(t *testing.T) {
82 rec := &recordingTool{}
83 a := NewAgent(nil, "test", "", Registry{"echo": rec})
84
85 var started []EvToolExecutionStarted
86 res := a.runOneTool(context.Background(), provider.ToolCallBlock{
87 ID: "T1", Name: "echo", Arguments: json.RawMessage(`{"command":"echo ok"}`),
88 }, a.ToolsSnapshot(), func(ev AgentEvent) {
89 if e, ok := ev.(EvToolExecutionStarted); ok {
90 started = append(started, e)
91 }
92 })
93 if res.IsError {
94 t.Fatalf("runOneTool error = %#v", res)
95 }
96 if len(started) != 1 || started[0].ID != "T1" || started[0].Name != "echo" {
97 t.Fatalf("execution events = %#v", started)
98 }
99}
100
101// TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
102// the interceptor returns an error ToolResult with the reason text.
103func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
104 rec := &recordingTool{}
105 reg := Registry{"echo": rec}
106 a := NewAgent(nil, "test", "", reg)
107
108 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
109 return false, "nope", nil
110 }
111
112 ctx := context.Background()
113 tools := a.ToolsSnapshot()
114 res := a.runOneTool(ctx, provider.ToolCallBlock{
115 ID: "T1",
116 Name: "echo",
117 Arguments: json.RawMessage(`{"command":"ls"}`),
118 }, tools, func(AgentEvent) {})
119 if !res.IsError {
120 t.Fatal("want error result, got success")
121 }
122 if len(res.Content) == 0 {
123 t.Fatal("no content")
124 }
125 tb, ok := res.Content[0].(provider.TextBlock)
126 if !ok || tb.Text != "nope" {
127 t.Errorf("want reason 'nope', got %v", res.Content[0])
128 }
129 if rec.lastArgs != nil {
130 t.Error("tool ran despite block")
131 }
132}