返回 DeepSeek-Reasonix
refs_test.go
根目录 / internal / control / refs_test.go
1 package control
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "reflect"
8 "strings"
9 "testing"
10
11 "reasonix/internal/memory"
12 )
13
14 func TestResolveRefsInjectsOnlyNewNestedInstructionsOnce(t *testing.T) {
15 root := t.TempDir()
16 service := filepath.Join(root, "services", "api")
17 sibling := filepath.Join(root, "services", "web")
18 for _, dir := range []string{service, sibling} {
19 if err := os.MkdirAll(dir, 0o755); err != nil {
20 t.Fatal(err)
21 }
22 }
23 for path, body := range map[string]string{
24 filepath.Join(root, "AGENTS.md"): "ROOT RULE",
25 filepath.Join(root, "services", "AGENTS.md"): "SERVICES RULE",
26 filepath.Join(service, "AGENTS.md"): "API RULE",
27 filepath.Join(sibling, "AGENTS.md"): "WEB RULE",
28 filepath.Join(service, "handler.go"): "package api",
29 filepath.Join(service, "handler_test.go"): "package api",
30 } {
31 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
32 t.Fatal(err)
33 }
34 }
35
36 c := newOwnedTestController(t, Options{WorkspaceRoot: root, Memory: memory.Load(memory.Options{CWD: root})})
37 block, errs := c.ResolveRefs(context.Background(), "review @services/api/handler.go and @services/api/handler_test.go")
38 if len(errs) != 0 {
39 t.Fatalf("ResolveRefs errors = %v", errs)
40 }
41 for _, want := range []string{"<path-instructions", "SERVICES RULE", "API RULE", "package api"} {
42 if !strings.Contains(block, want) {
43 t.Fatalf("resolved block missing %q:\n%s", want, block)
44 }
45 }
46 for _, unwanted := range []string{"ROOT RULE", "WEB RULE"} {
47 if strings.Contains(block, unwanted) {
48 t.Fatalf("resolved block included %q outside the nested delta:\n%s", unwanted, block)
49 }
50 }
51 if strings.Count(block, "SERVICES RULE") != 1 || strings.Count(block, "API RULE") != 1 {
52 t.Fatalf("nested instructions were duplicated across refs:\n%s", block)
53 }
54 }
55
56 func TestFileRefLine(t *testing.T) {
57 dir := t.TempDir()
58 pdf := filepath.Join(dir, "report.pdf")
59 if err := os.WriteFile(pdf, []byte("%PDF-1.4 fake"), 0o644); err != nil {
60 t.Fatal(err)
61 }
62
63 if got, ok := FileRefLine(" " + pdf + " "); !ok || got != "@"+pdf {
64 t.Fatalf("FileRefLine(existing) = %q, %v", got, ok)
65 }
66 if got, ok := FileRefLine(`"` + pdf + `"`); !ok || got != "@"+pdf {
67 t.Fatalf("FileRefLine(quoted) = %q, %v", got, ok)
68 }
69 if _, ok := FileRefLine("/compact"); ok {
70 t.Fatal("a slash command must not resolve as a file ref")
71 }
72 if _, ok := FileRefLine(dir); ok {
73 t.Fatal("a directory must not resolve as a file ref")
74 }
75 if _, ok := FileRefLine(""); ok {
76 t.Fatal("empty must not resolve as a file ref")
77 }
78
79 spaced := filepath.Join(dir, "report 2026.pdf")
80 if err := os.WriteFile(spaced, []byte("%PDF-1.4 fake"), 0o644); err != nil {
81 t.Fatal(err)
82 }
83 if got, ok := FileRefLine(spaced); !ok || got != "@"+EscapeRefPath(spaced) {
84 t.Fatalf("FileRefLine(spaced) = %q, %v; want escaped ref", got, ok)
85 }
86 }
87
88 func TestEscapeRefPathRoundTrip(t *testing.T) {
89 cases := []struct{ in, escaped string }{
90 {"plain.txt", "plain.txt"},
91 {"my file.txt", `my\ file.txt`},
92 {"a\tb.txt", "a\\\tb.txt"},
93 {`C:\dir\file.png`, `C:\dir\file.png`},
94 }
95 for _, c := range cases {
96 if got := EscapeRefPath(c.in); got != c.escaped {
97 t.Errorf("EscapeRefPath(%q) = %q, want %q", c.in, got, c.escaped)
98 }
99 if got := UnescapeRefPath(c.escaped); got != c.in {
100 t.Errorf("UnescapeRefPath(%q) = %q, want %q", c.escaped, got, c.in)
101 }
102 }
103 }
104
105 // TestDetectRefsEscapedSpacePath closes the loop pastedFileRef and completion
106 // rely on: an @token with escaped spaces resolves to the real workspace file.
107 func TestDetectRefsEscapedSpacePath(t *testing.T) {
108 workspace := t.TempDir()
109 if err := os.WriteFile(filepath.Join(workspace, "my file.txt"), []byte("x"), 0o644); err != nil {
110 t.Fatal(err)
111 }
112 refs := (&Controller{workspaceRoot: workspace}).detectRefs(`see @my\ file.txt after`)
113 if len(refs) != 1 || refs[0].kind != refFile || refs[0].path != "my file.txt" {
114 t.Fatalf("refs = %+v, want one file ref for \"my file.txt\"", refs)
115 }
116 }
117
118 func TestSlashCodeCommentLine(t *testing.T) {
119 cases := []struct {
120 line string
121 want bool
122 }{
123 {"// explain this", true},
124 {" /* explain this */", true},
125 {"/**\n * explain this\n */", true},
126 {"/compact", false},
127 {"/mcp__server__prompt", false},
128 {"/missing/Foo.kt:12: error", false},
129 {"hello", false},
130 }
131 for _, c := range cases {
132 if got := SlashCodeCommentLine(c.line); got != c.want {
133 t.Errorf("SlashCodeCommentLine(%q) = %v, want %v", c.line, got, c.want)
134 }
135 }
136 }
137
138 func TestParseRefTokens(t *testing.T) {
139 cases := []struct {
140 line string
141 want []string
142 }{
143 {"see @docs:doc://x and @src/main.go", []string{"docs:doc://x", "src/main.go"}},
144 {"trailing @file.go.", []string{"file.go"}},
145 {"dedup @a @a", []string{"a"}},
146 {"no refs here", nil},
147 {"email a@b.com keeps token", []string{"b.com"}},
148 {`open @docs/my\ file.md now`, []string{"docs/my file.md"}},
149 {`trailing @my\ file.md.`, []string{"my file.md"}},
150 {`win @C:\dir\shot.png ok`, []string{`C:\dir\shot.png`}},
151 {`unescaped @my file.md`, []string{"my"}},
152 }
153 for _, c := range cases {
154 got := parseRefTokens(c.line)
155 if len(got) == 0 && len(c.want) == 0 {
156 continue
157 }
158 if !reflect.DeepEqual(got, c.want) {
159 t.Errorf("parseRefTokens(%q) = %v, want %v", c.line, got, c.want)
160 }
161 }
162 }
163
164 func TestClassifyRef(t *testing.T) {
165 known := map[string]bool{"docs": true}
166 files := map[string]bool{
167 "src/main.go": true,
168 "README.md": true,
169 ".reasonix/attachments/clipboard-20260601-010203.000000.png": true,
170 ".reasonix/attachments/clipboard-20260601-010203.000000.yml": true,
171 ".reasonix/attachments/clipboard-20260601-010203.000000.zip": true,
172 }
173 exists := func(p string) bool { return files[p] }
174
175 cases := []struct {
176 token string
177 wantOK bool
178 wantKnd refKind
179 }{
180 {"docs:doc://style", true, refResource}, // known server + uri
181 {"src/main.go", true, refFile}, // existing file
182 {"README.md", true, refFile}, // existing file
183 {".reasonix/attachments/clipboard-20260601-010203.000000.png", true, refImage},
184 {".reasonix/attachments/clipboard-20260601-010203.000000.yml", true, refFile},
185 {".reasonix/attachments/clipboard-20260601-010203.000000.zip", true, refFile},
186 {"ghost:issue://1", false, 0}, // unknown server, no such file
187 {"missing.go", false, 0}, // nonexistent path → not a ref
188 {"docs:", false, 0}, // empty uri → not a resource, no file
189 }
190 for _, c := range cases {
191 r, ok := classifyRef(c.token, known, exists)
192 if ok != c.wantOK {
193 t.Errorf("classifyRef(%q) ok = %v, want %v", c.token, ok, c.wantOK)
194 continue
195 }
196 if ok && r.kind != c.wantKnd {
197 t.Errorf("classifyRef(%q) kind = %v, want %v", c.token, r.kind, c.wantKnd)
198 }
199 }
200 }
201
202 func TestReadFileRef(t *testing.T) {
203 dir := t.TempDir()
204
205 textPath := filepath.Join(dir, "hello.txt")
206 if err := os.WriteFile(textPath, []byte("line one\nline two\n"), 0o644); err != nil {
207 t.Fatal(err)
208 }
209 binPath := filepath.Join(dir, "blob.bin")
210 if err := os.WriteFile(binPath, []byte{'a', 0x00, 'b'}, 0o644); err != nil {
211 t.Fatal(err)
212 }
213 bigPath := filepath.Join(dir, "big.txt")
214 if err := os.WriteFile(bigPath, []byte(strings.Repeat("a", maxFileRefBytes+100)), 0o644); err != nil {
215 t.Fatal(err)
216 }
217 imagePath := filepath.Join(dir, "shot.png")
218 if err := os.WriteFile(imagePath, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil {
219 t.Fatal(err)
220 }
221
222 // Text file: content verbatim, not a directory.
223 if got, isDir, err := readFileRef(textPath, ""); err != nil || isDir || got != "line one\nline two\n" {
224 t.Errorf("text file = (%q, %v, %v)", got, isDir, err)
225 }
226
227 // Binary file: noted, not dumped.
228 if got, _, err := readFileRef(binPath, ""); err != nil || !strings.Contains(got, "binary file") {
229 t.Errorf("binary file = (%q, %v), want a binary note", got, err)
230 }
231
232 // Image file: identified as image-specific guidance, not generic binary.
233 if got, _, err := readFileRef(imagePath, ""); err != nil || !strings.Contains(got, "image file") {
234 t.Errorf("image file = (%q, %v), want an image note", got, err)
235 }
236 if got, _, err := readFileRef(imagePath, ""); err != nil || !strings.Contains(got, "not sent as direct model image input") || !strings.Contains(got, "OCR/image/vision tool") {
237 t.Errorf("unscoped image file = (%q, %v), want a non-attached image note", got, err)
238 }
239
240 // Large file: truncated with a marker.
241 if got, _, err := readFileRef(bigPath, ""); err != nil || !strings.Contains(got, "truncated") {
242 t.Errorf("big file should be truncated, got len=%d err=%v", len(got), err)
243 }
244
245 // Directory: recursive listing with relative paths including a trailing slash for subdirs.
246 if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
247 t.Fatal(err)
248 }
249 if err := os.WriteFile(filepath.Join(dir, "sub", "nested.txt"), []byte("nested"), 0o644); err != nil {
250 t.Fatal(err)
251 }
252 if err := os.MkdirAll(filepath.Join(dir, "node_modules", "pkg"), 0o755); err != nil {
253 t.Fatal(err)
254 }
255 if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "noise.js"), []byte("noise"), 0o644); err != nil {
256 t.Fatal(err)
257 }
258 got, isDir, err := readFileRef(dir, "")
259 if err != nil || !isDir {
260 t.Fatalf("dir = (isDir=%v, err=%v)", isDir, err)
261 }
262 if !strings.Contains(got, "directory listing only") || !strings.Contains(got, "file contents are not inlined") {
263 t.Errorf("dir listing = %q, want a directory reference note", got)
264 }
265 if !strings.Contains(got, "hello.txt") || !strings.Contains(got, "sub/") || !strings.Contains(got, "sub/nested.txt") {
266 t.Errorf("dir listing = %q, want hello.txt, sub/, and sub/nested.txt", got)
267 }
268 if strings.Contains(got, "node_modules") || strings.Contains(got, "noise.js") {
269 t.Errorf("dir listing = %q, want generated/vendor directories skipped", got)
270 }
271
272 // Missing path: error.
273 if _, _, err := readFileRef(filepath.Join(dir, "nope"), ""); err == nil {
274 t.Error("missing path should error")
275 }
276 }
277
278 func TestReadFileRefPDFExtraction(t *testing.T) {
279 dir := t.TempDir()
280 pdfPath := filepath.Join(dir, "report.pdf")
281 if err := os.WriteFile(pdfPath, []byte("%PDF-1.4 fake"), 0o644); err != nil {
282 t.Fatal(err)
283 }
284
285 oldExtract := extractPDFText
286 t.Cleanup(func() { extractPDFText = oldExtract })
287
288 extractPDFText = func(path string) (pdfExtractResult, error) {
289 if path != pdfPath {
290 t.Fatalf("extract path = %q, want %q", path, pdfPath)
291 }
292 return pdfExtractResult{text: "Quarterly results\nRevenue up", tool: "test-extractor"}, nil
293 }
294 got, isDir, err := readFileRef(pdfPath, "")
295 if err != nil || isDir {
296 t.Fatalf("pdf text = (isDir=%v, err=%v)", isDir, err)
297 }
298 if !strings.Contains(got, "PDF text extracted") || !strings.Contains(got, "Revenue up") {
299 t.Fatalf("pdf text extraction missing from output: %s", got)
300 }
301
302 extractPDFText = func(string) (pdfExtractResult, error) {
303 return pdfExtractResult{text: " ", tool: "test-extractor"}, nil
304 }
305 got, _, err = readFileRef(pdfPath, "")
306 if err != nil {
307 t.Fatalf("empty pdf text err = %v", err)
308 }
309 if !strings.Contains(got, "no extractable text") || !strings.Contains(got, "OCR") {
310 t.Fatalf("empty pdf should ask for OCR, got: %s", got)
311 }
312
313 extractPDFText = func(string) (pdfExtractResult, error) {
314 return pdfExtractResult{}, os.ErrNotExist
315 }
316 got, _, err = readFileRef(pdfPath, "")
317 if err != nil {
318 t.Fatalf("failed pdf text err = %v", err)
319 }
320 if !strings.Contains(got, "text extraction unavailable") || !strings.Contains(got, "multimodal/vision") {
321 t.Fatalf("failed pdf should mention OCR/vision fallback, got: %s", got)
322 }
323 }
324
325 func TestRunPDFTextCommandCapsStderr(t *testing.T) {
326 t.Setenv("GO_WANT_PDF_STDERR_HELPER", "1")
327
328 _, _, err := runPDFTextCommand(os.Args[0], []string{"-test.run=TestPDFStderrHelperProcess", "--"})
329 if err == nil {
330 t.Fatal("expected helper command to fail")
331 }
332 msg := err.Error()
333 if !strings.Contains(msg, "truncated") {
334 t.Fatalf("expected stderr truncation marker, got: %q", msg)
335 }
336 if len(msg) > maxFileRefBytes+1024 {
337 t.Fatalf("stderr error grew too large: len=%d", len(msg))
338 }
339 }
340
341 func TestPDFStderrHelperProcess(t *testing.T) {
342 if os.Getenv("GO_WANT_PDF_STDERR_HELPER") != "1" {
343 return
344 }
345 _, _ = os.Stderr.WriteString(strings.Repeat("x", maxFileRefBytes+4096))
346 os.Exit(7)
347 }
348
349 func TestResolveBareNamesDuplicates(t *testing.T) {
350 temp := t.TempDir()
351
352 if err := os.MkdirAll(filepath.Join(temp, "a"), 0o755); err != nil {
353 t.Fatal(err)
354 }
355 if err := os.MkdirAll(filepath.Join(temp, "b"), 0o755); err != nil {
356 t.Fatal(err)
357 }
358 if err := os.MkdirAll(filepath.Join(temp, "c"), 0o755); err != nil {
359 t.Fatal(err)
360 }
361
362 if err := os.WriteFile(filepath.Join(temp, "a", "helper.go"), []byte("package a"), 0o644); err != nil {
363 t.Fatal(err)
364 }
365 if err := os.WriteFile(filepath.Join(temp, "b", "helper.go"), []byte("package b"), 0o644); err != nil {
366 t.Fatal(err)
367 }
368 if err := os.WriteFile(filepath.Join(temp, "c", "main.go"), []byte("package c"), 0o644); err != nil {
369 t.Fatal(err)
370 }
371
372 oldCwd, err := os.Getwd()
373 if err != nil {
374 t.Fatal(err)
375 }
376 if err := os.Chdir(temp); err != nil {
377 t.Fatal(err)
378 }
379 t.Cleanup(func() {
380 if err := os.Chdir(oldCwd); err != nil {
381 t.Error(err)
382 }
383 })
384
385 refs := []ref{
386 {kind: refFile, raw: "helper.go"},
387 {kind: refFile, raw: "main.go"},
388 }
389
390 resolved := resolveBareNames(refs, "")
391
392 if len(resolved) != 2 {
393 t.Fatalf("expected 2 resolved refs, got %d", len(resolved))
394 }
395
396 helperRef := resolved[0]
397 mainRef := resolved[1]
398
399 if helperRef.path != "a/helper.go" && helperRef.path != "b/helper.go" {
400 t.Errorf("expected helper.go path to be a/helper.go or b/helper.go, got %q", helperRef.path)
401 }
402 if mainRef.path != "c/main.go" {
403 t.Errorf("expected main.go path to be c/main.go, got %q", mainRef.path)
404 }
405 }
406
407 func TestReadFileRefWithBaseDir(t *testing.T) {
408 base := t.TempDir()
409 sub := filepath.Join(base, "proj")
410 if err := os.MkdirAll(sub, 0o755); err != nil {
411 t.Fatal(err)
412 }
413 if err := os.WriteFile(filepath.Join(sub, "hello.txt"), []byte("hello"), 0o644); err != nil {
414 t.Fatal(err)
415 }
416
417 // Relative path "proj/hello.txt" resolves via baseDir when not in CWD.
418 got, isDir, err := readFileRef("proj/hello.txt", base)
419 if err != nil {
420 t.Fatalf("readFileRef with baseDir: %v", err)
421 }
422 if isDir {
423 t.Error("expected file, not directory")
424 }
425 if got != "hello" {
426 t.Errorf("got %q, want %q", got, "hello")
427 }
428
429 // Empty baseDir falls back to direct path (absolute).
430 got2, _, err2 := readFileRef(filepath.Join(sub, "hello.txt"), "")
431 if err2 != nil {
432 t.Fatalf("readFileRef with empty baseDir: %v", err2)
433 }
434 if got2 != "hello" {
435 t.Errorf("got %q, want %q", got2, "hello")
436 }
437
438 if err := os.MkdirAll(filepath.Join(sub, "src"), 0o755); err != nil {
439 t.Fatal(err)
440 }
441 if err := os.WriteFile(filepath.Join(sub, "src", "main.go"), []byte("package main"), 0o644); err != nil {
442 t.Fatal(err)
443 }
444 if err := os.MkdirAll(filepath.Join(sub, "dist"), 0o755); err != nil {
445 t.Fatal(err)
446 }
447 if err := os.WriteFile(filepath.Join(sub, "dist", "bundle.js"), []byte("generated"), 0o644); err != nil {
448 t.Fatal(err)
449 }
450 gotDir, isDir, err := readFileRef("proj", base)
451 if err != nil || !isDir {
452 t.Fatalf("readFileRef scoped dir = (isDir=%v, err=%v)", isDir, err)
453 }
454 if !strings.Contains(gotDir, "directory listing only") || !strings.Contains(gotDir, "src/") || !strings.Contains(gotDir, "src/main.go") {
455 t.Fatalf("scoped dir listing missing contract or nested file:\n%s", gotDir)
456 }
457 if strings.Contains(gotDir, "dist/") || strings.Contains(gotDir, "bundle.js") {
458 t.Fatalf("scoped dir listing should skip generated dirs:\n%s", gotDir)
459 }
460 }
461
462 func TestResolveBareNamesWithWorkspaceRoot(t *testing.T) {
463 root := t.TempDir()
464 if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
465 t.Fatal(err)
466 }
467 if err := os.WriteFile(filepath.Join(root, "src", "main.go"), []byte("package main"), 0o644); err != nil {
468 t.Fatal(err)
469 }
470
471 refs := []ref{{kind: refFile, raw: "main.go"}}
472 resolved := resolveBareNames(refs, root)
473
474 if len(resolved) != 1 {
475 t.Fatalf("expected 1 ref, got %d", len(resolved))
476 }
477 if resolved[0].path != "src/main.go" {
478 t.Errorf("expected src/main.go, got %q", resolved[0].path)
479 }
480 }
481
482 func TestResolveBareNamesSkipsAlreadyResolvedRefs(t *testing.T) {
483 refs := []ref{{kind: refFile, raw: "main.go", path: "main.go"}}
484
485 resolved := resolveBareNames(refs, t.TempDir())
486
487 if len(resolved) != 1 {
488 t.Fatalf("expected 1 ref, got %d", len(resolved))
489 }
490 if resolved[0].path != "main.go" {
491 t.Fatalf("already resolved ref path = %q, want main.go", resolved[0].path)
492 }
493 }
494
495 func TestResolveBareNamesWithWorkspaceRootStoresRootFilePath(t *testing.T) {
496 root := t.TempDir()
497 if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main"), 0o644); err != nil {
498 t.Fatal(err)
499 }
500
501 refs := []ref{{kind: refFile, raw: "main.go"}}
502 resolved := resolveBareNames(refs, root)
503
504 if len(resolved) != 1 {
505 t.Fatalf("expected 1 ref, got %d", len(resolved))
506 }
507 if resolved[0].path != "main.go" {
508 t.Fatalf("root workspace ref path = %q, want main.go", resolved[0].path)
509 }
510 }
511
512 func TestResolveBareNamesRejectsUnsafeBareNames(t *testing.T) {
513 root := t.TempDir()
514 if err := os.WriteFile(filepath.Join(root, "safe.txt"), []byte("safe"), 0o644); err != nil {
515 t.Fatal(err)
516 }
517 if err := os.WriteFile(filepath.Join(root, "bad..name.txt"), []byte("unsafe"), 0o644); err != nil {
518 t.Fatal(err)
519 }
520
521 refs := []ref{
522 {kind: refFile, raw: "safe.txt"},
523 {kind: refFile, raw: "bad..name.txt"},
524 {kind: refFile, raw: ".."},
525 }
526 resolved := resolveBareNames(refs, root)
527
528 if resolved[0].path != "safe.txt" {
529 t.Fatalf("safe bare name path = %q, want safe.txt", resolved[0].path)
530 }
531 if resolved[1].path != "" {
532 t.Fatalf("unsafe bare name should stay unresolved, got %q", resolved[1].path)
533 }
534 if resolved[2].path != "" {
535 t.Fatalf("parent-dir bare name should stay unresolved, got %q", resolved[2].path)
536 }
537 }
538
539 func TestResolveAbsRef(t *testing.T) {
540 temp := t.TempDir()
541
542 _, _, ok := resolveAbsRef("foo.txt", "")
543 if !ok {
544 t.Errorf("empty base: expected ok=true with CLI fallback")
545 }
546
547 absInBase := filepath.Join(temp, "foo.txt")
548 absPath, absBase, ok := resolveAbsRef(absInBase, temp)
549 if !ok || absPath != absInBase || absBase != temp {
550 t.Errorf("absolute path under base: got (%q, %q, %v), want (%q, %q, true)", absPath, absBase, ok, absInBase, temp)
551 }
552
553 if _, _, ok := resolveAbsRef(filepath.Join(temp, "..", "outside.txt"), temp); ok {
554 t.Errorf("absolute path outside base should be rejected")
555 }
556
557 want := filepath.Join(temp, "sub", "file.txt")
558 absPath, absBase, ok = resolveAbsRef(filepath.Join("sub", "file.txt"), temp)
559 if !ok || absPath != want || absBase != temp {
560 t.Errorf("relative in base: got (%q, %q, %v), want (%q, %q, true)", absPath, absBase, ok, want, temp)
561 }
562
563 if _, _, ok := resolveAbsRef(".."+string(filepath.Separator)+"outside.txt", temp); ok {
564 t.Errorf("path traversal should be rejected")
565 }
566 if _, _, ok := resolveAbsRef("sub/../../escape.txt", temp); ok {
567 t.Errorf("path traversal should be rejected")
568 }
569 }
570
571 func TestReadFileRefBlocksPathTraversal(t *testing.T) {
572 temp := t.TempDir()
573 if err := os.WriteFile(filepath.Join(temp, "safe.txt"), []byte("safe"), 0o644); err != nil {
574 t.Fatal(err)
575 }
576 if err := os.WriteFile(filepath.Join(temp, "..", "outside.txt"), []byte("outside"), 0o644); err != nil {
577 t.Fatal(err)
578 }
579 t.Cleanup(func() { _ = os.Remove(filepath.Join(temp, "..", "outside.txt")) })
580
581 if _, isDir, err := readFileRef(".."+string(filepath.Separator)+"outside.txt", temp); err == nil {
582 t.Errorf("expected traversal to fail, got isDir=%v err=%v", isDir, err)
583 }
584 }
585
586 func TestDetectRefsUsesWorkspaceRootNotProcessCWD(t *testing.T) {
587 cwd := t.TempDir()
588 workspace := t.TempDir()
589 if err := os.WriteFile(filepath.Join(cwd, "cwd-only.txt"), []byte("wrong"), 0o644); err != nil {
590 t.Fatal(err)
591 }
592 if err := os.WriteFile(filepath.Join(workspace, "workspace.txt"), []byte("right"), 0o644); err != nil {
593 t.Fatal(err)
594 }
595
596 oldCwd, err := os.Getwd()
597 if err != nil {
598 t.Fatal(err)
599 }
600 if err := os.Chdir(cwd); err != nil {
601 t.Fatal(err)
602 }
603 t.Cleanup(func() {
604 if err := os.Chdir(oldCwd); err != nil {
605 t.Error(err)
606 }
607 })
608
609 refs := (&Controller{workspaceRoot: workspace}).detectRefs("see @cwd-only.txt and @workspace.txt")
610 if len(refs) != 1 || refs[0].raw != "workspace.txt" {
611 t.Fatalf("detectRefs should only see workspace files, got %+v", refs)
612 }
613
614 block, errs := (&Controller{workspaceRoot: workspace}).ResolveRefs(context.Background(), "see @cwd-only.txt")
615 if block != "" || len(errs) != 0 {
616 t.Fatalf("cwd-only file should not be treated as a ref, block=%q errs=%v", block, errs)
617 }
618 }
619
620 func TestScopedRefsRequireExternalFolderRegistration(t *testing.T) {
621 workspace := t.TempDir()
622 external := t.TempDir()
623 if err := os.WriteFile(filepath.Join(external, "outside.txt"), []byte("outside"), 0o644); err != nil {
624 t.Fatal(err)
625 }
626
627 c := &Controller{workspaceRoot: workspace}
628 block, errs := c.ResolveScopedRefs(context.Background(), "see @"+external)
629 if block != "" || len(errs) != 0 {
630 t.Fatalf("unregistered external dir should not resolve, block=%q errs=%v", block, errs)
631 }
632 }
633
634 func TestRegisterExternalFolderRefResolvesScopedDir(t *testing.T) {
635 workspace := t.TempDir()
636 parent := t.TempDir()
637 external := filepath.Join(parent, "Folder With Spaces")
638 if err := os.MkdirAll(filepath.Join(external, "sub"), 0o755); err != nil {
639 t.Fatal(err)
640 }
641 if err := os.WriteFile(filepath.Join(external, "sub", "outside.txt"), []byte("outside"), 0o644); err != nil {
642 t.Fatal(err)
643 }
644 expectedExternal := external
645 if resolved, err := filepath.EvalSymlinks(external); err == nil {
646 expectedExternal = resolved
647 }
648 expectedDisplayPath := filepath.ToSlash(expectedExternal)
649
650 registrar := &recordingExternalFolderToolRefs{}
651 c := &Controller{workspaceRoot: workspace, externalFolderToolRefs: registrar}
652 token, displayPath, err := c.RegisterExternalFolderRef(external)
653 if err != nil {
654 t.Fatalf("RegisterExternalFolderRef: %v", err)
655 }
656 if registrar.token != token || registrar.root != expectedExternal {
657 t.Fatalf("tool read root registration = (%q, %q), want (%q, %q)", registrar.token, registrar.root, token, expectedExternal)
658 }
659 if strings.ContainsAny(token, " \t\r\n") {
660 t.Fatalf("external folder token must be whitespace-free, got %q", token)
661 }
662 if displayPath != expectedDisplayPath {
663 t.Fatalf("display path = %q, want %q", displayPath, expectedDisplayPath)
664 }
665
666 refs := c.detectRefs("see @" + token + "/")
667 if len(refs) != 1 {
668 t.Fatalf("detectRefs registered external folder = %+v, want 1 ref", refs)
669 }
670 if refs[0].path != "." || refs[0].baseDir != expectedExternal || refs[0].displayPath != expectedDisplayPath {
671 t.Fatalf("external ref = %+v, want path '.' baseDir/displayPath for external folder", refs[0])
672 }
673
674 block, errs := c.ResolveScopedRefs(context.Background(), "see @"+token+"/")
675 if len(errs) != 0 {
676 t.Fatalf("ResolveScopedRefs errors = %v", errs)
677 }
678 if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) ||
679 !strings.Contains(block, "directory listing only") ||
680 !strings.Contains(block, "sub/") ||
681 !strings.Contains(block, "sub/outside.txt") {
682 t.Fatalf("registered external folder should resolve as a dir listing:\n%s", block)
683 }
684
685 block, errs = c.ResolveScopedRefs(context.Background(), "read @"+token+"/sub/outside.txt")
686 if len(errs) != 0 {
687 t.Fatalf("ResolveScopedRefs child errors = %v", errs)
688 }
689 if !strings.Contains(block, `<file path="`+expectedDisplayPath+`/sub/outside.txt">`) ||
690 !strings.Contains(block, "outside") {
691 t.Fatalf("registered external child should resolve as file content:\n%s", block)
692 }
693
694 block, errs = c.ResolveScopedRefs(context.Background(), "escape @"+token+"/../secret.txt")
695 if block != "" || len(errs) != 0 {
696 t.Fatalf("external folder ref must not resolve escaping subpaths, block=%q errs=%v", block, errs)
697 }
698 }
699
700 type recordingExternalFolderToolRefs struct {
701 token string
702 root string
703 }
704
705 func (r *recordingExternalFolderToolRefs) RegisterReadRoot(token, root string) {
706 r.token = token
707 r.root = root
708 }
709
710 func TestExternalFolderRefListAndSearch(t *testing.T) {
711 parent := t.TempDir()
712 external := filepath.Join(parent, "Folder With Spaces")
713 if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil {
714 t.Fatal(err)
715 }
716 if err := os.WriteFile(filepath.Join(external, "src", "outside.txt"), []byte("outside"), 0o644); err != nil {
717 t.Fatal(err)
718 }
719 if err := os.MkdirAll(filepath.Join(external, "node_modules"), 0o755); err != nil {
720 t.Fatal(err)
721 }
722 if err := os.WriteFile(filepath.Join(external, "node_modules", "outside.txt"), []byte("noise"), 0o644); err != nil {
723 t.Fatal(err)
724 }
725 expectedExternal := external
726 if resolved, err := filepath.EvalSymlinks(external); err == nil {
727 expectedExternal = resolved
728 }
729 expectedDisplayPath := filepath.ToSlash(expectedExternal)
730
731 c := &Controller{}
732 token, _, err := c.RegisterExternalFolderRef(external)
733 if err != nil {
734 t.Fatalf("RegisterExternalFolderRef: %v", err)
735 }
736
737 rootEntries, handled := c.ListExternalFolderRefDir(token + "/")
738 if !handled {
739 t.Fatal("ListExternalFolderRefDir should handle the registered root token")
740 }
741 if len(rootEntries) != 1 || rootEntries[0].Name != "src" || !rootEntries[0].IsDir {
742 t.Fatalf("root entries = %+v, want src/ and skipped node_modules", rootEntries)
743 }
744
745 srcEntries, handled := c.ListExternalFolderRefDir(token + "/src/")
746 if !handled {
747 t.Fatal("ListExternalFolderRefDir should handle registered child dirs")
748 }
749 if len(srcEntries) != 1 ||
750 srcEntries[0].Name != "outside.txt" ||
751 srcEntries[0].Path != token+"/src/outside.txt" ||
752 srcEntries[0].DisplayPath != expectedDisplayPath+"/src/outside.txt" {
753 t.Fatalf("src entries = %+v, want outside.txt token/display path", srcEntries)
754 }
755
756 results := c.SearchExternalFolderRefs("outside", 10)
757 if len(results) != 1 ||
758 results[0].Path != token+"/src/outside.txt" ||
759 results[0].DisplayName != "Folder With Spaces/src/outside.txt" ||
760 results[0].DisplayPath != expectedDisplayPath+"/src/outside.txt" {
761 t.Fatalf("search results = %+v, want external outside.txt with token and display paths", results)
762 }
763 }
764
765 func TestResolveRefsWithWorkspaceRootStoresRelativePath(t *testing.T) {
766 workspace := t.TempDir()
767 absPath := filepath.Join(workspace, "docs", "note.txt")
768 if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
769 t.Fatal(err)
770 }
771 if err := os.WriteFile(absPath, []byte("workspace note"), 0o644); err != nil {
772 t.Fatal(err)
773 }
774
775 c := &Controller{workspaceRoot: workspace}
776 refs := c.detectRefs("see @" + absPath)
777 if len(refs) != 1 {
778 t.Fatalf("detectRefs absolute workspace path = %+v, want 1 ref", refs)
779 }
780 if refs[0].path != "docs/note.txt" {
781 t.Fatalf("ref path = %q, want workspace-relative path", refs[0].path)
782 }
783 block, errs := c.ResolveRefs(context.Background(), "see @"+absPath)
784 if len(errs) != 0 {
785 t.Fatalf("ResolveRefs errors = %v", errs)
786 }
787 if !strings.Contains(block, `<file path="docs/note.txt">`) || !strings.Contains(block, "workspace note") {
788 t.Fatalf("ResolveRefs block did not use relative workspace path:\n%s", block)
789 }
790 }
791
792 func TestWorkspaceImageRefsAlsoAttachAsModelImages(t *testing.T) {
793 workspace := t.TempDir()
794 diagram := filepath.Join(workspace, "docs", "diagram.png")
795 if err := os.MkdirAll(filepath.Dir(diagram), 0o755); err != nil {
796 t.Fatal(err)
797 }
798 if err := os.WriteFile(diagram, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil {
799 t.Fatal(err)
800 }
801 attachment := filepath.Join(workspace, ".reasonix", "attachments", "shot.png")
802 if err := os.MkdirAll(filepath.Dir(attachment), 0o755); err != nil {
803 t.Fatal(err)
804 }
805 if err := os.WriteFile(attachment, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil {
806 t.Fatal(err)
807 }
808
809 writeVisionTestConfig(t, workspace)
810 c := &Controller{workspaceRoot: workspace, selection: modelSelection{ref: "custom/vision-pro"}}
811 refs := c.detectRefs("see @" + diagram + " @" + attachment)
812 if len(refs) != 2 {
813 t.Fatalf("detectRefs = %+v, want two refs", refs)
814 }
815 if refs[0].kind != refFile || refs[0].path != "docs/diagram.png" {
816 t.Fatalf("workspace png ref = %+v, want file ref", refs[0])
817 }
818 if refs[1].kind != refImage || refs[1].path != ".reasonix/attachments/shot.png" {
819 t.Fatalf("attachment png ref = %+v, want image attachment ref", refs[1])
820 }
821
822 block, errs := c.ResolveRefs(context.Background(), "see @"+diagram)
823 if len(errs) != 0 {
824 t.Fatalf("ResolveRefs errors = %v", errs)
825 }
826 if !strings.Contains(block, `<file path="docs/diagram.png">`) || !strings.Contains(block, "attached as visual input") || strings.Contains(block, "OCR/image/vision tool") {
827 t.Fatalf("workspace png on a vision model should ask the model to look at the image, not OCR it:\n%s", block)
828 }
829 if urls := c.inputImages("see @" + diagram); len(urls) != 1 || !strings.HasPrefix(urls[0], "data:image/png;base64,") {
830 t.Fatalf("workspace png inputImages = %v, want one png data URL", urls)
831 }
832 }
833
834 func TestResolveRefsWithoutWorkspaceDoesNotClaimImageAttachment(t *testing.T) {
835 dir := t.TempDir()
836 imagePath := filepath.Join(dir, "shot.png")
837 if err := os.WriteFile(imagePath, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil {
838 t.Fatal(err)
839 }
840
841 block, errs := newOwnedTestController(t, Options{}).ResolveRefs(context.Background(), "see @"+imagePath)
842 if len(errs) != 1 || !strings.Contains(errs[0], "workspace root is required") {
843 t.Fatalf("ResolveRefs errors = %v, want an explicit unscoped-image error", errs)
844 }
845 if strings.Contains(block, "attached as visual input") {
846 t.Fatalf("unscoped image ref claimed model image attachment:\n%s", block)
847 }
848 }
849
850 func TestReadFileRefPDFExtractionWithBaseDirUsesAbsPath(t *testing.T) {
851 base := t.TempDir()
852 pdfPath := filepath.Join(base, "docs", "report.pdf")
853 if err := os.MkdirAll(filepath.Dir(pdfPath), 0o755); err != nil {
854 t.Fatal(err)
855 }
856 if err := os.WriteFile(pdfPath, []byte("%PDF-1.4 fake"), 0o644); err != nil {
857 t.Fatal(err)
858 }
859
860 outside := t.TempDir()
861 oldCwd, err := os.Getwd()
862 if err != nil {
863 t.Fatal(err)
864 }
865 if err := os.Chdir(outside); err != nil {
866 t.Fatal(err)
867 }
868 t.Cleanup(func() {
869 if err := os.Chdir(oldCwd); err != nil {
870 t.Error(err)
871 }
872 })
873
874 oldExtract := extractPDFText
875 t.Cleanup(func() { extractPDFText = oldExtract })
876 extractPDFText = func(path string) (pdfExtractResult, error) {
877 if path != pdfPath {
878 t.Fatalf("extract path = %q, want %q", path, pdfPath)
879 }
880 return pdfExtractResult{text: "workspace pdf", tool: "test-extractor"}, nil
881 }
882
883 got, isDir, err := readFileRef("docs/report.pdf", base)
884 if err != nil || isDir {
885 t.Fatalf("scoped pdf = (isDir=%v, err=%v)", isDir, err)
886 }
887 if !strings.Contains(got, "workspace pdf") {
888 t.Fatalf("scoped pdf extraction missing text: %s", got)
889 }
890 }
891
891 lines GO