v0.18.0 f294dc4

intercept_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close