返回 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 := New(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 TestResolveRefsAttachmentKinds(t *testing.T) {
203 temp := t.TempDir()
204 attachmentsDir := filepath.Join(temp, ".reasonix", "attachments")
205 if err := os.MkdirAll(attachmentsDir, 0o755); err != nil {
206 t.Fatal(err)
207 }
208 ymlRef := filepath.ToSlash(".reasonix/attachments/config.yml")
209 zipRef := filepath.ToSlash(".reasonix/attachments/archive.zip")
210 pngRef := filepath.ToSlash(".reasonix/attachments/shot.png")
211 if err := os.WriteFile(filepath.Join(temp, filepath.FromSlash(ymlRef)), []byte("name: reasonix\n"), 0o644); err != nil {
212 t.Fatal(err)
213 }
214 if err := os.WriteFile(filepath.Join(temp, filepath.FromSlash(zipRef)), []byte{'P', 'K', 0x03, 0x04, 0x00}, 0o644); err != nil {
215 t.Fatal(err)
216 }
217 if err := os.WriteFile(filepath.Join(temp, filepath.FromSlash(pngRef)), []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil {
218 t.Fatal(err)
219 }
220
221 oldCwd, err := os.Getwd()
222 if err != nil {
223 t.Fatal(err)
224 }
225 if err := os.Chdir(temp); err != nil {
226 t.Fatal(err)
227 }
228 t.Cleanup(func() {
229 if err := os.Chdir(oldCwd); err != nil {
230 t.Error(err)
231 }
232 })
233
234 line := "check @" + ymlRef + " @" + zipRef + " @" + pngRef
235 block, errs := (&Controller{}).ResolveRefs(context.Background(), line)
236 if len(errs) != 0 {
237 t.Fatalf("ResolveRefs errors = %v", errs)
238 }
239 if !strings.Contains(block, `<file path="`+ymlRef+`">`) || !strings.Contains(block, "name: reasonix") {
240 t.Fatalf("expected yml attachment to resolve as file content, got: %s", block)
241 }
242 if !strings.Contains(block, `<file path="`+zipRef+`">`) || !strings.Contains(block, "[binary file "+zipRef) {
243 t.Fatalf("expected zip attachment to resolve as binary file note, got: %s", block)
244 }
245 if !strings.Contains(block, `<image path="`+pngRef+`">`) {
246 t.Fatalf("expected png attachment to resolve as image block, got: %s", block)
247 }
248 if !strings.Contains(block, "OCR/image/vision tool") || !strings.Contains(block, "image bytes are not inlined") {
249 t.Fatalf("expected image attachment note to mention tool-readable path without inlined bytes, got: %s", block)
250 }
251 }
252
253 func TestReadFileRef(t *testing.T) {
254 dir := t.TempDir()
255
256 textPath := filepath.Join(dir, "hello.txt")
257 if err := os.WriteFile(textPath, []byte("line one\nline two\n"), 0o644); err != nil {
258 t.Fatal(err)
259 }
260 binPath := filepath.Join(dir, "blob.bin")
261 if err := os.WriteFile(binPath, []byte{'a', 0x00, 'b'}, 0o644); err != nil {
262 t.Fatal(err)
263 }
264 bigPath := filepath.Join(dir, "big.txt")
265 if err := os.WriteFile(bigPath, []byte(strings.Repeat("a", maxFileRefBytes+100)), 0o644); err != nil {
266 t.Fatal(err)
267 }
268 imagePath := filepath.Join(dir, "shot.png")
269 if err := os.WriteFile(imagePath, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil {
270 t.Fatal(err)
271 }
272
273 // Text file: content verbatim, not a directory.
274 if got, isDir, err := readFileRef(textPath, ""); err != nil || isDir || got != "line one\nline two\n" {
275 t.Errorf("text file = (%q, %v, %v)", got, isDir, err)
276 }
277
278 // Binary file: noted, not dumped.
279 if got, _, err := readFileRef(binPath, ""); err != nil || !strings.Contains(got, "binary file") {
280 t.Errorf("binary file = (%q, %v), want a binary note", got, err)
281 }
282
283 // Image file: identified as image-specific guidance, not generic binary.
284 if got, _, err := readFileRef(imagePath, ""); err != nil || !strings.Contains(got, "image file") {
285 t.Errorf("image file = (%q, %v), want an image note", got, err)
286 }
287 if got, _, err := readFileRef(imagePath, ""); err != nil || !strings.Contains(got, "not sent as direct model image input") || !strings.Contains(got, "OCR/image/vision tool") {
288 t.Errorf("unscoped image file = (%q, %v), want a non-attached image note", got, err)
289 }
290
291 // Large file: truncated with a marker.
292 if got, _, err := readFileRef(bigPath, ""); err != nil || !strings.Contains(got, "truncated") {
293 t.Errorf("big file should be truncated, got len=%d err=%v", len(got), err)
294 }
295
296 // Directory: recursive listing with relative paths including a trailing slash for subdirs.
297 if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
298 t.Fatal(err)
299 }
300 if err := os.WriteFile(filepath.Join(dir, "sub", "nested.txt"), []byte("nested"), 0o644); err != nil {
301 t.Fatal(err)
302 }
303 if err := os.MkdirAll(filepath.Join(dir, "node_modules", "pkg"), 0o755); err != nil {
304 t.Fatal(err)
305 }
306 if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "noise.js"), []byte("noise"), 0o644); err != nil {
307 t.Fatal(err)
308 }
309 got, isDir, err := readFileRef(dir, "")
310 if err != nil || !isDir {
311 t.Fatalf("dir = (isDir=%v, err=%v)", isDir, err)
312 }
313 if !strings.Contains(got, "directory listing only") || !strings.Contains(got, "file contents are not inlined") {
314 t.Errorf("dir listing = %q, want a directory reference note", got)
315 }
316 if !strings.Contains(got, "hello.txt") || !strings.Contains(got, "sub/") || !strings.Contains(got, "sub/nested.txt") {
317 t.Errorf("dir listing = %q, want hello.txt, sub/, and sub/nested.txt", got)
318 }
319 if strings.Contains(got, "node_modules") || strings.Contains(got, "noise.js") {
320 t.Errorf("dir listing = %q, want generated/vendor directories skipped", got)
321 }
322
323 // Missing path: error.
324 if _, _, err := readFileRef(filepath.Join(dir, "nope"), ""); err == nil {
325 t.Error("missing path should error")
326 }
327 }
328
329 func TestReadFileRefPDFExtraction(t *testing.T) {
330 dir := t.TempDir()
331 pdfPath := filepath.Join(dir, "report.pdf")
332 if err := os.WriteFile(pdfPath, []byte("%PDF-1.4 fake"), 0o644); err != nil {
333 t.Fatal(err)
334 }
335
336 oldExtract := extractPDFText
337 t.Cleanup(func() { extractPDFText = oldExtract })
338
339 extractPDFText = func(path string) (pdfExtractResult, error) {
340 if path != pdfPath {
341 t.Fatalf("extract path = %q, want %q", path, pdfPath)
342 }
343 return pdfExtractResult{text: "Quarterly results\nRevenue up", tool: "test-extractor"}, nil
344 }
345 got, isDir, err := readFileRef(pdfPath, "")
346 if err != nil || isDir {
347 t.Fatalf("pdf text = (isDir=%v, err=%v)", isDir, err)
348 }
349 if !strings.Contains(got, "PDF text extracted") || !strings.Contains(got, "Revenue up") {
350 t.Fatalf("pdf text extraction missing from output: %s", got)
351 }
352
353 extractPDFText = func(string) (pdfExtractResult, error) {
354 return pdfExtractResult{text: " ", tool: "test-extractor"}, nil
355 }
356 got, _, err = readFileRef(pdfPath, "")
357 if err != nil {
358 t.Fatalf("empty pdf text err = %v", err)
359 }
360 if !strings.Contains(got, "no extractable text") || !strings.Contains(got, "OCR") {
361 t.Fatalf("empty pdf should ask for OCR, got: %s", got)
362 }
363
364 extractPDFText = func(string) (pdfExtractResult, error) {
365 return pdfExtractResult{}, os.ErrNotExist
366 }
367 got, _, err = readFileRef(pdfPath, "")
368 if err != nil {
369 t.Fatalf("failed pdf text err = %v", err)
370 }
371 if !strings.Contains(got, "text extraction unavailable") || !strings.Contains(got, "multimodal/vision") {
372 t.Fatalf("failed pdf should mention OCR/vision fallback, got: %s", got)
373 }
374 }
375
376 func TestRunPDFTextCommandCapsStderr(t *testing.T) {
377 t.Setenv("GO_WANT_PDF_STDERR_HELPER", "1")
378
379 _, _, err := runPDFTextCommand(os.Args[0], []string{"-test.run=TestPDFStderrHelperProcess", "--"})
380 if err == nil {
381 t.Fatal("expected helper command to fail")
382 }
383 msg := err.Error()
384 if !strings.Contains(msg, "truncated") {
385 t.Fatalf("expected stderr truncation marker, got: %q", msg)
386 }
387 if len(msg) > maxFileRefBytes+1024 {
388 t.Fatalf("stderr error grew too large: len=%d", len(msg))
389 }
390 }
391
392 func TestPDFStderrHelperProcess(t *testing.T) {
393 if os.Getenv("GO_WANT_PDF_STDERR_HELPER") != "1" {
394 return
395 }
396 _, _ = os.Stderr.WriteString(strings.Repeat("x", maxFileRefBytes+4096))
397 os.Exit(7)
398 }
399
400 func TestResolveBareNamesDuplicates(t *testing.T) {
401 temp := t.TempDir()
402
403 if err := os.MkdirAll(filepath.Join(temp, "a"), 0o755); err != nil {
404 t.Fatal(err)
405 }
406 if err := os.MkdirAll(filepath.Join(temp, "b"), 0o755); err != nil {
407 t.Fatal(err)
408 }
409 if err := os.MkdirAll(filepath.Join(temp, "c"), 0o755); err != nil {
410 t.Fatal(err)
411 }
412
413 if err := os.WriteFile(filepath.Join(temp, "a", "helper.go"), []byte("package a"), 0o644); err != nil {
414 t.Fatal(err)
415 }
416 if err := os.WriteFile(filepath.Join(temp, "b", "helper.go"), []byte("package b"), 0o644); err != nil {
417 t.Fatal(err)
418 }
419 if err := os.WriteFile(filepath.Join(temp, "c", "main.go"), []byte("package c"), 0o644); err != nil {
420 t.Fatal(err)
421 }
422
423 oldCwd, err := os.Getwd()
424 if err != nil {
425 t.Fatal(err)
426 }
427 if err := os.Chdir(temp); err != nil {
428 t.Fatal(err)
429 }
430 t.Cleanup(func() {
431 if err := os.Chdir(oldCwd); err != nil {
432 t.Error(err)
433 }
434 })
435
436 refs := []ref{
437 {kind: refFile, raw: "helper.go"},
438 {kind: refFile, raw: "main.go"},
439 }
440
441 resolved := resolveBareNames(refs, "")
442
443 if len(resolved) != 2 {
444 t.Fatalf("expected 2 resolved refs, got %d", len(resolved))
445 }
446
447 helperRef := resolved[0]
448 mainRef := resolved[1]
449
450 if helperRef.path != "a/helper.go" && helperRef.path != "b/helper.go" {
451 t.Errorf("expected helper.go path to be a/helper.go or b/helper.go, got %q", helperRef.path)
452 }
453 if mainRef.path != "c/main.go" {
454 t.Errorf("expected main.go path to be c/main.go, got %q", mainRef.path)
455 }
456 }
457
458 func TestReadFileRefWithBaseDir(t *testing.T) {
459 base := t.TempDir()
460 sub := filepath.Join(base, "proj")
461 if err := os.MkdirAll(sub, 0o755); err != nil {
462 t.Fatal(err)
463 }
464 if err := os.WriteFile(filepath.Join(sub, "hello.txt"), []byte("hello"), 0o644); err != nil {
465 t.Fatal(err)
466 }
467
468 // Relative path "proj/hello.txt" resolves via baseDir when not in CWD.
469 got, isDir, err := readFileRef("proj/hello.txt", base)
470 if err != nil {
471 t.Fatalf("readFileRef with baseDir: %v", err)
472 }
473 if isDir {
474 t.Error("expected file, not directory")
475 }
476 if got != "hello" {
477 t.Errorf("got %q, want %q", got, "hello")
478 }
479
480 // Empty baseDir falls back to direct path (absolute).
481 got2, _, err2 := readFileRef(filepath.Join(sub, "hello.txt"), "")
482 if err2 != nil {
483 t.Fatalf("readFileRef with empty baseDir: %v", err2)
484 }
485 if got2 != "hello" {
486 t.Errorf("got %q, want %q", got2, "hello")
487 }
488
489 if err := os.MkdirAll(filepath.Join(sub, "src"), 0o755); err != nil {
490 t.Fatal(err)
491 }
492 if err := os.WriteFile(filepath.Join(sub, "src", "main.go"), []byte("package main"), 0o644); err != nil {
493 t.Fatal(err)
494 }
495 if err := os.MkdirAll(filepath.Join(sub, "dist"), 0o755); err != nil {
496 t.Fatal(err)
497 }
498 if err := os.WriteFile(filepath.Join(sub, "dist", "bundle.js"), []byte("generated"), 0o644); err != nil {
499 t.Fatal(err)
500 }
501 gotDir, isDir, err := readFileRef("proj", base)
502 if err != nil || !isDir {
503 t.Fatalf("readFileRef scoped dir = (isDir=%v, err=%v)", isDir, err)
504 }
505 if !strings.Contains(gotDir, "directory listing only") || !strings.Contains(gotDir, "src/") || !strings.Contains(gotDir, "src/main.go") {
506 t.Fatalf("scoped dir listing missing contract or nested file:\n%s", gotDir)
507 }
508 if strings.Contains(gotDir, "dist/") || strings.Contains(gotDir, "bundle.js") {
509 t.Fatalf("scoped dir listing should skip generated dirs:\n%s", gotDir)
510 }
511 }
512
513 func TestResolveBareNamesWithWorkspaceRoot(t *testing.T) {
514 root := t.TempDir()
515 if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
516 t.Fatal(err)
517 }
518 if err := os.WriteFile(filepath.Join(root, "src", "main.go"), []byte("package main"), 0o644); err != nil {
519 t.Fatal(err)
520 }
521
522 refs := []ref{{kind: refFile, raw: "main.go"}}
523 resolved := resolveBareNames(refs, root)
524
525 if len(resolved) != 1 {
526 t.Fatalf("expected 1 ref, got %d", len(resolved))
527 }
528 if resolved[0].path != "src/main.go" {
529 t.Errorf("expected src/main.go, got %q", resolved[0].path)
530 }
531 }
532
533 func TestResolveBareNamesSkipsAlreadyResolvedRefs(t *testing.T) {
534 refs := []ref{{kind: refFile, raw: "main.go", path: "main.go"}}
535
536 resolved := resolveBareNames(refs, t.TempDir())
537
538 if len(resolved) != 1 {
539 t.Fatalf("expected 1 ref, got %d", len(resolved))
540 }
541 if resolved[0].path != "main.go" {
542 t.Fatalf("already resolved ref path = %q, want main.go", resolved[0].path)
543 }
544 }
545
546 func TestResolveBareNamesWithWorkspaceRootStoresRootFilePath(t *testing.T) {
547 root := t.TempDir()
548 if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main"), 0o644); err != nil {
549 t.Fatal(err)
550 }
551
552 refs := []ref{{kind: refFile, raw: "main.go"}}
553 resolved := resolveBareNames(refs, root)
554
555 if len(resolved) != 1 {
556 t.Fatalf("expected 1 ref, got %d", len(resolved))
557 }
558 if resolved[0].path != "main.go" {
559 t.Fatalf("root workspace ref path = %q, want main.go", resolved[0].path)
560 }
561 }
562
563 func TestResolveBareNamesRejectsUnsafeBareNames(t *testing.T) {
564 root := t.TempDir()
565 if err := os.WriteFile(filepath.Join(root, "safe.txt"), []byte("safe"), 0o644); err != nil {
566 t.Fatal(err)
567 }
568 if err := os.WriteFile(filepath.Join(root, "bad..name.txt"), []byte("unsafe"), 0o644); err != nil {
569 t.Fatal(err)
570 }
571
572 refs := []ref{
573 {kind: refFile, raw: "safe.txt"},
574 {kind: refFile, raw: "bad..name.txt"},
575 {kind: refFile, raw: ".."},
576 }
577 resolved := resolveBareNames(refs, root)
578
579 if resolved[0].path != "safe.txt" {
580 t.Fatalf("safe bare name path = %q, want safe.txt", resolved[0].path)
581 }
582 if resolved[1].path != "" {
583 t.Fatalf("unsafe bare name should stay unresolved, got %q", resolved[1].path)
584 }
585 if resolved[2].path != "" {
586 t.Fatalf("parent-dir bare name should stay unresolved, got %q", resolved[2].path)
587 }
588 }
589
590 func TestResolveAbsRef(t *testing.T) {
591 temp := t.TempDir()
592
593 _, _, ok := resolveAbsRef("foo.txt", "")
594 if !ok {
595 t.Errorf("empty base: expected ok=true with CLI fallback")
596 }
597
598 absInBase := filepath.Join(temp, "foo.txt")
599 absPath, absBase, ok := resolveAbsRef(absInBase, temp)
600 if !ok || absPath != absInBase || absBase != temp {
601 t.Errorf("absolute path under base: got (%q, %q, %v), want (%q, %q, true)", absPath, absBase, ok, absInBase, temp)
602 }
603
604 if _, _, ok := resolveAbsRef(filepath.Join(temp, "..", "outside.txt"), temp); ok {
605 t.Errorf("absolute path outside base should be rejected")
606 }
607
608 want := filepath.Join(temp, "sub", "file.txt")
609 absPath, absBase, ok = resolveAbsRef(filepath.Join("sub", "file.txt"), temp)
610 if !ok || absPath != want || absBase != temp {
611 t.Errorf("relative in base: got (%q, %q, %v), want (%q, %q, true)", absPath, absBase, ok, want, temp)
612 }
613
614 if _, _, ok := resolveAbsRef(".."+string(filepath.Separator)+"outside.txt", temp); ok {
615 t.Errorf("path traversal should be rejected")
616 }
617 if _, _, ok := resolveAbsRef("sub/../../escape.txt", temp); ok {
618 t.Errorf("path traversal should be rejected")
619 }
620 }
621
622 func TestReadFileRefBlocksPathTraversal(t *testing.T) {
623 temp := t.TempDir()
624 if err := os.WriteFile(filepath.Join(temp, "safe.txt"), []byte("safe"), 0o644); err != nil {
625 t.Fatal(err)
626 }
627 if err := os.WriteFile(filepath.Join(temp, "..", "outside.txt"), []byte("outside"), 0o644); err != nil {
628 t.Fatal(err)
629 }
630 t.Cleanup(func() { _ = os.Remove(filepath.Join(temp, "..", "outside.txt")) })
631
632 if _, isDir, err := readFileRef(".."+string(filepath.Separator)+"outside.txt", temp); err == nil {
633 t.Errorf("expected traversal to fail, got isDir=%v err=%v", isDir, err)
634 }
635 }
636
637 func TestDetectRefsUsesWorkspaceRootNotProcessCWD(t *testing.T) {
638 cwd := t.TempDir()
639 workspace := t.TempDir()
640 if err := os.WriteFile(filepath.Join(cwd, "cwd-only.txt"), []byte("wrong"), 0o644); err != nil {
641 t.Fatal(err)
642 }
643 if err := os.WriteFile(filepath.Join(workspace, "workspace.txt"), []byte("right"), 0o644); err != nil {
644 t.Fatal(err)
645 }
646
647 oldCwd, err := os.Getwd()
648 if err != nil {
649 t.Fatal(err)
650 }
651 if err := os.Chdir(cwd); err != nil {
652 t.Fatal(err)
653 }
654 t.Cleanup(func() {
655 if err := os.Chdir(oldCwd); err != nil {
656 t.Error(err)
657 }
658 })
659
660 refs := (&Controller{workspaceRoot: workspace}).detectRefs("see @cwd-only.txt and @workspace.txt")
661 if len(refs) != 1 || refs[0].raw != "workspace.txt" {
662 t.Fatalf("detectRefs should only see workspace files, got %+v", refs)
663 }
664
665 block, errs := (&Controller{workspaceRoot: workspace}).ResolveRefs(context.Background(), "see @cwd-only.txt")
666 if block != "" || len(errs) != 0 {
667 t.Fatalf("cwd-only file should not be treated as a ref, block=%q errs=%v", block, errs)
668 }
669 }
670
671 func TestScopedRefsRequireExternalFolderRegistration(t *testing.T) {
672 workspace := t.TempDir()
673 external := t.TempDir()
674 if err := os.WriteFile(filepath.Join(external, "outside.txt"), []byte("outside"), 0o644); err != nil {
675 t.Fatal(err)
676 }
677
678 c := &Controller{workspaceRoot: workspace}
679 block, errs := c.ResolveScopedRefs(context.Background(), "see @"+external)
680 if block != "" || len(errs) != 0 {
681 t.Fatalf("unregistered external dir should not resolve, block=%q errs=%v", block, errs)
682 }
683 }
684
685 func TestRegisterExternalFolderRefResolvesScopedDir(t *testing.T) {
686 workspace := t.TempDir()
687 parent := t.TempDir()
688 external := filepath.Join(parent, "Folder With Spaces")
689 if err := os.MkdirAll(filepath.Join(external, "sub"), 0o755); err != nil {
690 t.Fatal(err)
691 }
692 if err := os.WriteFile(filepath.Join(external, "sub", "outside.txt"), []byte("outside"), 0o644); err != nil {
693 t.Fatal(err)
694 }
695 expectedExternal := external
696 if resolved, err := filepath.EvalSymlinks(external); err == nil {
697 expectedExternal = resolved
698 }
699 expectedDisplayPath := filepath.ToSlash(expectedExternal)
700
701 registrar := &recordingExternalFolderToolRefs{}
702 c := &Controller{workspaceRoot: workspace, externalFolderToolRefs: registrar}
703 token, displayPath, err := c.RegisterExternalFolderRef(external)
704 if err != nil {
705 t.Fatalf("RegisterExternalFolderRef: %v", err)
706 }
707 if registrar.token != token || registrar.root != expectedExternal {
708 t.Fatalf("tool read root registration = (%q, %q), want (%q, %q)", registrar.token, registrar.root, token, expectedExternal)
709 }
710 if strings.ContainsAny(token, " \t\r\n") {
711 t.Fatalf("external folder token must be whitespace-free, got %q", token)
712 }
713 if displayPath != expectedDisplayPath {
714 t.Fatalf("display path = %q, want %q", displayPath, expectedDisplayPath)
715 }
716
717 refs := c.detectRefs("see @" + token + "/")
718 if len(refs) != 1 {
719 t.Fatalf("detectRefs registered external folder = %+v, want 1 ref", refs)
720 }
721 if refs[0].path != "." || refs[0].baseDir != expectedExternal || refs[0].displayPath != expectedDisplayPath {
722 t.Fatalf("external ref = %+v, want path '.' baseDir/displayPath for external folder", refs[0])
723 }
724
725 block, errs := c.ResolveScopedRefs(context.Background(), "see @"+token+"/")
726 if len(errs) != 0 {
727 t.Fatalf("ResolveScopedRefs errors = %v", errs)
728 }
729 if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) ||
730 !strings.Contains(block, "directory listing only") ||
731 !strings.Contains(block, "sub/") ||
732 !strings.Contains(block, "sub/outside.txt") {
733 t.Fatalf("registered external folder should resolve as a dir listing:\n%s", block)
734 }
735
736 block, errs = c.ResolveScopedRefs(context.Background(), "read @"+token+"/sub/outside.txt")
737 if len(errs) != 0 {
738 t.Fatalf("ResolveScopedRefs child errors = %v", errs)
739 }
740 if !strings.Contains(block, `<file path="`+expectedDisplayPath+`/sub/outside.txt">`) ||
741 !strings.Contains(block, "outside") {
742 t.Fatalf("registered external child should resolve as file content:\n%s", block)
743 }
744
745 block, errs = c.ResolveScopedRefs(context.Background(), "escape @"+token+"/../secret.txt")
746 if block != "" || len(errs) != 0 {
747 t.Fatalf("external folder ref must not resolve escaping subpaths, block=%q errs=%v", block, errs)
748 }
749 }
750
751 type recordingExternalFolderToolRefs struct {
752 token string
753 root string
754 }
755
756 func (r *recordingExternalFolderToolRefs) RegisterReadRoot(token, root string) {
757 r.token = token
758 r.root = root
759 }
760
761 func TestExternalFolderRefListAndSearch(t *testing.T) {
762 parent := t.TempDir()
763 external := filepath.Join(parent, "Folder With Spaces")
764 if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil {
765 t.Fatal(err)
766 }
767 if err := os.WriteFile(filepath.Join(external, "src", "outside.txt"), []byte("outside"), 0o644); err != nil {
768 t.Fatal(err)
769 }
770 if err := os.MkdirAll(filepath.Join(external, "node_modules"), 0o755); err != nil {
771 t.Fatal(err)
772 }
773 if err := os.WriteFile(filepath.Join(external, "node_modules", "outside.txt"), []byte("noise"), 0o644); err != nil {
774 t.Fatal(err)
775 }
776 expectedExternal := external
777 if resolved, err := filepath.EvalSymlinks(external); err == nil {
778 expectedExternal = resolved
779 }
780 expectedDisplayPath := filepath.ToSlash(expectedExternal)
781
782 c := &Controller{}
783 token, _, err := c.RegisterExternalFolderRef(external)
784 if err != nil {
785 t.Fatalf("RegisterExternalFolderRef: %v", err)
786 }
787
788 rootEntries, handled := c.ListExternalFolderRefDir(token + "/")
789 if !handled {
790 t.Fatal("ListExternalFolderRefDir should handle the registered root token")
791 }
792 if len(rootEntries) != 1 || rootEntries[0].Name != "src" || !rootEntries[0].IsDir {
793 t.Fatalf("root entries = %+v, want src/ and skipped node_modules", rootEntries)
794 }
795
796 srcEntries, handled := c.ListExternalFolderRefDir(token + "/src/")
797 if !handled {
798 t.Fatal("ListExternalFolderRefDir should handle registered child dirs")
799 }
800 if len(srcEntries) != 1 ||
801 srcEntries[0].Name != "outside.txt" ||
802 srcEntries[0].Path != token+"/src/outside.txt" ||
803 srcEntries[0].DisplayPath != expectedDisplayPath+"/src/outside.txt" {
804 t.Fatalf("src entries = %+v, want outside.txt token/display path", srcEntries)
805 }
806
807 results := c.SearchExternalFolderRefs("outside", 10)
808 if len(results) != 1 ||
809 results[0].Path != token+"/src/outside.txt" ||
810 results[0].DisplayName != "Folder With Spaces/src/outside.txt" ||
811 results[0].DisplayPath != expectedDisplayPath+"/src/outside.txt" {
812 t.Fatalf("search results = %+v, want external outside.txt with token and display paths", results)
813 }
814 }
815
816 func TestResolveRefsWithWorkspaceRootStoresRelativePath(t *testing.T) {
817 workspace := t.TempDir()
818 absPath := filepath.Join(workspace, "docs", "note.txt")
819 if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
820 t.Fatal(err)
821 }
822 if err := os.WriteFile(absPath, []byte("workspace note"), 0o644); err != nil {
823 t.Fatal(err)
824 }
825
826 c := &Controller{workspaceRoot: workspace}
827 refs := c.detectRefs("see @" + absPath)
828 if len(refs) != 1 {
829 t.Fatalf("detectRefs absolute workspace path = %+v, want 1 ref", refs)
830 }
831 if refs[0].path != "docs/note.txt" {
832 t.Fatalf("ref path = %q, want workspace-relative path", refs[0].path)
833 }
834 block, errs := c.ResolveRefs(context.Background(), "see @"+absPath)
835 if len(errs) != 0 {
836 t.Fatalf("ResolveRefs errors = %v", errs)
837 }
838 if !strings.Contains(block, `<file path="docs/note.txt">`) || !strings.Contains(block, "workspace note") {
839 t.Fatalf("ResolveRefs block did not use relative workspace path:\n%s", block)
840 }
841 }
842
843 func TestWorkspaceImageRefsAlsoAttachAsModelImages(t *testing.T) {
844 workspace := t.TempDir()
845 diagram := filepath.Join(workspace, "docs", "diagram.png")
846 if err := os.MkdirAll(filepath.Dir(diagram), 0o755); err != nil {
847 t.Fatal(err)
848 }
849 if err := os.WriteFile(diagram, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil {
850 t.Fatal(err)
851 }
852 attachment := filepath.Join(workspace, ".reasonix", "attachments", "shot.png")
853 if err := os.MkdirAll(filepath.Dir(attachment), 0o755); err != nil {
854 t.Fatal(err)
855 }
856 if err := os.WriteFile(attachment, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil {
857 t.Fatal(err)
858 }
859
860 writeVisionTestConfig(t, workspace)
861 c := &Controller{workspaceRoot: workspace, modelRef: "custom/vision-pro"}
862 refs := c.detectRefs("see @" + diagram + " @" + attachment)
863 if len(refs) != 2 {
864 t.Fatalf("detectRefs = %+v, want two refs", refs)
865 }
866 if refs[0].kind != refFile || refs[0].path != "docs/diagram.png" {
867 t.Fatalf("workspace png ref = %+v, want file ref", refs[0])
868 }
869 if refs[1].kind != refImage || refs[1].path != ".reasonix/attachments/shot.png" {
870 t.Fatalf("attachment png ref = %+v, want image attachment ref", refs[1])
871 }
872
873 block, errs := c.ResolveRefs(context.Background(), "see @"+diagram)
874 if len(errs) != 0 {
875 t.Fatalf("ResolveRefs errors = %v", errs)
876 }
877 if !strings.Contains(block, `<file path="docs/diagram.png">`) || !strings.Contains(block, "sent as direct model image input only when the selected model supports vision") || !strings.Contains(block, "OCR/image/vision tool") {
878 t.Fatalf("workspace png should resolve as direct-vision-or-tool image metadata:\n%s", block)
879 }
880 if urls := c.inputImages("see @" + diagram); len(urls) != 1 || !strings.HasPrefix(urls[0], "data:image/png;base64,") {
881 t.Fatalf("workspace png inputImages = %v, want one png data URL", urls)
882 }
883 }
884
885 func TestResolveRefsWithoutWorkspaceDoesNotClaimImageAttachment(t *testing.T) {
886 dir := t.TempDir()
887 imagePath := filepath.Join(dir, "shot.png")
888 if err := os.WriteFile(imagePath, []byte("\x89PNG\r\n\x1a\n"), 0o644); err != nil {
889 t.Fatal(err)
890 }
891
892 block, errs := New(Options{}).ResolveRefs(context.Background(), "see @"+imagePath)
893 if len(errs) != 0 {
894 t.Fatalf("ResolveRefs errors = %v", errs)
895 }
896 if !strings.Contains(block, "not sent as direct model image input") || !strings.Contains(block, "OCR/image/vision tool") {
897 t.Fatalf("unscoped image ref should not claim model image attachment:\n%s", block)
898 }
899 }
900
901 func TestReadFileRefPDFExtractionWithBaseDirUsesAbsPath(t *testing.T) {
902 base := t.TempDir()
903 pdfPath := filepath.Join(base, "docs", "report.pdf")
904 if err := os.MkdirAll(filepath.Dir(pdfPath), 0o755); err != nil {
905 t.Fatal(err)
906 }
907 if err := os.WriteFile(pdfPath, []byte("%PDF-1.4 fake"), 0o644); err != nil {
908 t.Fatal(err)
909 }
910
911 outside := t.TempDir()
912 oldCwd, err := os.Getwd()
913 if err != nil {
914 t.Fatal(err)
915 }
916 if err := os.Chdir(outside); err != nil {
917 t.Fatal(err)
918 }
919 t.Cleanup(func() {
920 if err := os.Chdir(oldCwd); err != nil {
921 t.Error(err)
922 }
923 })
924
925 oldExtract := extractPDFText
926 t.Cleanup(func() { extractPDFText = oldExtract })
927 extractPDFText = func(path string) (pdfExtractResult, error) {
928 if path != pdfPath {
929 t.Fatalf("extract path = %q, want %q", path, pdfPath)
930 }
931 return pdfExtractResult{text: "workspace pdf", tool: "test-extractor"}, nil
932 }
933
934 got, isDir, err := readFileRef("docs/report.pdf", base)
935 if err != nil || isDir {
936 t.Fatalf("scoped pdf = (isDir=%v, err=%v)", isDir, err)
937 }
938 if !strings.Contains(got, "workspace pdf") {
939 t.Fatalf("scoped pdf extraction missing text: %s", got)
940 }
941 }
942
942 lines GO