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
81// TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
82// the interceptor returns an error ToolResult with the reason text.
83func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
84 rec := &recordingTool{}
85 reg := Registry{"echo": rec}
86 a := NewAgent(nil, "test", "", reg)
87
88 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
89 return false, "nope", nil
90 }
91
92 ctx := context.Background()
93 tools := a.ToolsSnapshot()
94 res := a.runOneTool(ctx, provider.ToolCallBlock{
95 ID: "T1",
96 Name: "echo",
97 Arguments: json.RawMessage(`{"command":"ls"}`),
98 }, tools, func(AgentEvent) {})
99 if !res.IsError {
100 t.Fatal("want error result, got success")
101 }
102 if len(res.Content) == 0 {
103 t.Fatal("no content")
104 }
105 tb, ok := res.Content[0].(provider.TextBlock)
106 if !ok || tb.Text != "nope" {
107 t.Errorf("want reason 'nope', got %v", res.Content[0])
108 }
109 if rec.lastArgs != nil {
110 t.Error("tool ran despite block")
111 }
112}