1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "charm.land/fantasy"
14 "git.secluded.site/crush/internal/filetracker"
15 "git.secluded.site/crush/internal/permission"
16 "git.secluded.site/crush/internal/pubsub"
17 "github.com/stretchr/testify/require"
18)
19
20func TestReadTextFileBoundaryCases(t *testing.T) {
21 t.Parallel()
22
23 tmpDir := t.TempDir()
24 filePath := filepath.Join(tmpDir, "sample.txt")
25
26 var allLines []string
27 for i := range 5 {
28 allLines = append(allLines, fmt.Sprintf("line %d", i+1))
29 }
30 require.NoError(t, os.WriteFile(filePath, []byte(strings.Join(allLines, "\n")), 0o644))
31
32 tests := []struct {
33 name string
34 offset int
35 limit int
36 wantContent string
37 wantHasMore bool
38 }{
39 {
40 name: "exactly limit lines remaining",
41 offset: 0,
42 limit: 5,
43 wantContent: "line 1\nline 2\nline 3\nline 4\nline 5",
44 wantHasMore: false,
45 },
46 {
47 name: "limit plus one line remaining",
48 offset: 0,
49 limit: 4,
50 wantContent: "line 1\nline 2\nline 3\nline 4",
51 wantHasMore: true,
52 },
53 {
54 name: "offset at last line",
55 offset: 4,
56 limit: 3,
57 wantContent: "line 5",
58 wantHasMore: false,
59 },
60 {
61 name: "offset beyond eof",
62 offset: 10,
63 limit: 3,
64 wantContent: "",
65 wantHasMore: false,
66 },
67 }
68
69 for _, tt := range tests {
70 t.Run(tt.name, func(t *testing.T) {
71 t.Parallel()
72
73 gotContent, gotHasMore, err := readTextFile(filePath, tt.offset, tt.limit, 0)
74 require.NoError(t, err)
75 require.Equal(t, tt.wantContent, gotContent)
76 require.Equal(t, tt.wantHasMore, gotHasMore)
77 })
78 }
79}
80
81func TestReadTextFileTruncatesLongLines(t *testing.T) {
82 t.Parallel()
83
84 tmpDir := t.TempDir()
85 filePath := filepath.Join(tmpDir, "longline.txt")
86
87 longLine := strings.Repeat("a", MaxLineLength+10)
88 require.NoError(t, os.WriteFile(filePath, []byte(longLine), 0o644))
89
90 content, hasMore, err := readTextFile(filePath, 0, 1, 0)
91 require.NoError(t, err)
92 require.False(t, hasMore)
93 require.Equal(t, strings.Repeat("a", MaxLineLength)+"...", content)
94}
95
96func TestReadTextFileLineExceeding1MB(t *testing.T) {
97 t.Parallel()
98
99 tmpDir := t.TempDir()
100 filePath := filepath.Join(tmpDir, "huge_line.txt")
101
102 hugeLine := strings.Repeat("A", 2*1024*1024) // 2MB — exceeds bufio.Scanner max
103 require.NoError(t, os.WriteFile(filePath, []byte(hugeLine), 0o644))
104
105 content, hasMore, err := readTextFile(filePath, 0, 1, 0)
106 require.NoError(t, err)
107 require.False(t, hasMore)
108 require.Equal(t, strings.Repeat("A", MaxLineLength)+"...", content)
109}
110
111func TestViewToolAllowsSmallSectionsOfLargeFiles(t *testing.T) {
112 t.Parallel()
113
114 workingDir := t.TempDir()
115 filePath := filepath.Join(workingDir, "large.txt")
116 lines := []string{strings.Repeat("a", MaxViewSize+1), "target line", "after target"}
117 require.NoError(t, os.WriteFile(filePath, []byte(strings.Join(lines, "\n")), 0o644))
118
119 tool := newViewToolForTest(workingDir)
120 ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
121 resp := runViewTool(t, tool, ctx, ViewParams{
122 FilePath: filePath,
123 Offset: 1,
124 Limit: 1,
125 })
126
127 require.False(t, resp.IsError)
128 require.Contains(t, resp.Content, " 2|target line")
129 require.NotContains(t, resp.Content, "File is too large")
130
131 var meta ViewResponseMetadata
132 require.NoError(t, json.Unmarshal([]byte(resp.Metadata), &meta))
133 require.Equal(t, "target line", meta.Content)
134}
135
136func TestViewToolBlocksOversizedReturnedSections(t *testing.T) {
137 t.Parallel()
138
139 workingDir := t.TempDir()
140 filePath := filepath.Join(workingDir, "large-section.txt")
141 lines := make([]string, DefaultReadLimit)
142 for i := range lines {
143 lines[i] = strings.Repeat("a", MaxLineLength)
144 }
145 require.NoError(t, os.WriteFile(filePath, []byte(strings.Join(lines, "\n")), 0o644))
146
147 tool := newViewToolForTest(workingDir)
148 ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
149 resp := runViewTool(t, tool, ctx, ViewParams{
150 FilePath: filePath,
151 })
152
153 require.True(t, resp.IsError)
154 require.Contains(t, resp.Content, "Content section is too large")
155}
156
157func TestViewToolBlocksOversizedImages(t *testing.T) {
158 t.Parallel()
159
160 workingDir := t.TempDir()
161 filePath := filepath.Join(workingDir, "large.png")
162 require.NoError(t, os.WriteFile(filePath, []byte(strings.Repeat("a", MaxViewSize+1)), 0o644))
163
164 tool := newViewToolForTest(workingDir)
165 ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
166 ctx = context.WithValue(ctx, SupportsImagesContextKey, true)
167 resp := runViewTool(t, tool, ctx, ViewParams{
168 FilePath: filePath,
169 })
170
171 require.True(t, resp.IsError)
172 require.Contains(t, resp.Content, "Image file is too large")
173}
174
175func TestReadTextFileEnforcesMaxContentSize(t *testing.T) {
176 t.Parallel()
177
178 workingDir := t.TempDir()
179 filePath := filepath.Join(workingDir, "oversized.txt")
180 lines := []string{
181 strings.Repeat("a", MaxLineLength),
182 strings.Repeat("b", MaxLineLength),
183 "target line",
184 }
185 require.NoError(t, os.WriteFile(filePath, []byte(strings.Join(lines, "\n")), 0o644))
186
187 content, hasMore, err := readTextFile(filePath, 0, len(lines), MaxLineLength)
188 require.ErrorAs(t, err, &contentTooLargeError{})
189 require.Empty(t, content)
190 require.False(t, hasMore)
191
192 content, hasMore, err = readTextFile(filePath, 2, 1, MaxLineLength)
193 require.NoError(t, err)
194 require.Equal(t, "target line", content)
195 require.False(t, hasMore)
196}
197
198func TestReadTextFileAllowsExactMaxContentSize(t *testing.T) {
199 t.Parallel()
200
201 workingDir := t.TempDir()
202 filePath := filepath.Join(workingDir, "exact-size.txt")
203 require.NoError(t, os.WriteFile(filePath, []byte("abcd\nefgh"), 0o644))
204
205 content, hasMore, err := readTextFile(filePath, 0, 2, len("abcd\nefgh"))
206 require.NoError(t, err)
207 require.Equal(t, "abcd\nefgh", content)
208 require.False(t, hasMore)
209}
210
211type mockViewPermissionService struct {
212 *pubsub.Broker[permission.PermissionRequest]
213}
214
215func (m *mockViewPermissionService) Request(ctx context.Context, req permission.CreatePermissionRequest) (bool, error) {
216 return true, nil
217}
218
219func (m *mockViewPermissionService) Grant(req permission.PermissionRequest) {}
220
221func (m *mockViewPermissionService) Deny(req permission.PermissionRequest) {}
222
223func (m *mockViewPermissionService) GrantPersistent(req permission.PermissionRequest) {}
224
225func (m *mockViewPermissionService) AutoApproveSession(sessionID string) {}
226
227func (m *mockViewPermissionService) SetSkipRequests(skip bool) {}
228
229func (m *mockViewPermissionService) SkipRequests() bool {
230 return false
231}
232
233func (m *mockViewPermissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[permission.PermissionNotification] {
234 return make(<-chan pubsub.Event[permission.PermissionNotification])
235}
236
237type mockFileTracker struct{}
238
239func (m mockFileTracker) RecordRead(ctx context.Context, sessionID, path string) {}
240
241func (m mockFileTracker) LastReadTime(ctx context.Context, sessionID, path string) time.Time {
242 return time.Time{}
243}
244
245func (m mockFileTracker) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
246 return nil, nil
247}
248
249func newViewToolForTest(workingDir string) fantasy.AgentTool {
250 permissions := &mockViewPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
251 return NewViewTool(nil, permissions, mockFileTracker{}, nil, workingDir)
252}
253
254func runViewTool(t *testing.T, tool fantasy.AgentTool, ctx context.Context, params ViewParams) fantasy.ToolResponse {
255 t.Helper()
256
257 input, err := json.Marshal(params)
258 require.NoError(t, err)
259
260 call := fantasy.ToolCall{
261 ID: "test-call",
262 Name: ViewToolName,
263 Input: string(input),
264 }
265
266 resp, err := tool.Run(ctx, call)
267 require.NoError(t, err)
268 return resp
269}
270
271var _ filetracker.Service = mockFileTracker{}
272
273func TestReadBuiltinFile(t *testing.T) {
274 t.Parallel()
275
276 t.Run("reads crush-config skill", func(t *testing.T) {
277 t.Parallel()
278
279 resp, err := readBuiltinFile(ViewParams{
280 FilePath: "crush://skills/crush-config/SKILL.md",
281 }, nil)
282 require.NoError(t, err)
283 require.NotEmpty(t, resp.Content)
284 require.Contains(t, resp.Content, "Crush Configuration")
285 })
286
287 t.Run("not found", func(t *testing.T) {
288 t.Parallel()
289
290 resp, err := readBuiltinFile(ViewParams{
291 FilePath: "crush://skills/nonexistent/SKILL.md",
292 }, nil)
293 require.NoError(t, err)
294 require.True(t, resp.IsError)
295 })
296
297 t.Run("metadata has skill info", func(t *testing.T) {
298 t.Parallel()
299
300 resp, err := readBuiltinFile(ViewParams{
301 FilePath: "crush://skills/crush-config/SKILL.md",
302 }, nil)
303 require.NoError(t, err)
304
305 var meta ViewResponseMetadata
306 require.NoError(t, json.Unmarshal([]byte(resp.Metadata), &meta))
307 require.Equal(t, ViewResourceSkill, meta.ResourceType)
308 require.Equal(t, "crush-config", meta.ResourceName)
309 require.NotEmpty(t, meta.ResourceDescription)
310 })
311
312 t.Run("respects offset", func(t *testing.T) {
313 t.Parallel()
314
315 resp, err := readBuiltinFile(ViewParams{
316 FilePath: "crush://skills/crush-config/SKILL.md",
317 Offset: 5,
318 }, nil)
319 require.NoError(t, err)
320 require.NotContains(t, resp.Content, " 1|")
321 })
322}
323
324func TestSniffImageMimeType(t *testing.T) {
325 t.Parallel()
326
327 jpegMagic := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
328 pngMagic := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
329 gifMagic := []byte("GIF89a")
330 // Minimal RIFF/WEBP header.
331 webpMagic := append([]byte("RIFF\x00\x00\x00\x00WEBPVP8 "), make([]byte, 16)...)
332 random := []byte("not an image at all, just text")
333
334 cases := []struct {
335 name string
336 data []byte
337 fallback string
338 want string
339 }{
340 {"jpeg bytes in .png file uses sniffed", jpegMagic, "image/png", "image/jpeg"},
341 {"png bytes in .jpg file uses sniffed", pngMagic, "image/jpeg", "image/png"},
342 {"gif bytes uses sniffed", gifMagic, "image/png", "image/gif"},
343 {"webp bytes uses sniffed", webpMagic, "image/png", "image/webp"},
344 {"matching extension and content keeps sniffed", pngMagic, "image/png", "image/png"},
345 {"unsniffable content falls back", random, "image/png", "image/png"},
346 {"empty content falls back", nil, "image/jpeg", "image/jpeg"},
347 }
348 for _, tc := range cases {
349 t.Run(tc.name, func(t *testing.T) {
350 t.Parallel()
351 require.Equal(t, tc.want, sniffImageMimeType(tc.data, tc.fallback))
352 })
353 }
354}