返回 DeepSeek-Reasonix
chat_attachment_test.go
根目录 / internal / cli / chat_attachment_test.go
1 package cli
2
3 import (
4 "encoding/base64"
5 "math"
6 "os"
7 "path/filepath"
8 "reflect"
9 "runtime"
10 "strings"
11 "testing"
12
13 tea "charm.land/bubbletea/v2"
14
15 "reasonix/internal/agent"
16 "reasonix/internal/control"
17 "reasonix/internal/event"
18 "reasonix/internal/provider"
19 )
20
21 func TestExpandPastedBlocksImage(t *testing.T) {
22 m := &chatTUI{pastedBlocks: []pastedBlock{
23 {label: "[image #1]", text: "@.reasonix/attachments/clipboard-20260601-010203.000001.png", image: true},
24 {label: "[Pasted text #2 · 3 lines]", text: "a\nb\nc"},
25 }}
26 got := m.expandPastedBlocks("look at [image #1] and [Pasted text #2 · 3 lines]")
27 want := "look at @.reasonix/attachments/clipboard-20260601-010203.000001.png and " +
28 renderFoldedPasteBlock(m.pastedBlocks[1])
29 if got != want {
30 t.Fatalf("expandPastedBlocks = %q, want %q", got, want)
31 }
32 if displayLineForImageRefs(got) != "look at [image1] and "+renderFoldedPasteBlock(m.pastedBlocks[1]) {
33 t.Fatalf("image ref should collapse to a label in the bubble: %q", displayLineForImageRefs(got))
34 }
35 }
36
37 func TestRecoverOrphanedPasteLabelFromHistory(t *testing.T) {
38 block := pastedBlock{
39 label: "[Pasted text #4 · 2 lines]",
40 text: "old\nbody",
41 }
42 history := []provider.Message{{
43 Role: provider.RoleUser,
44 Content: renderFoldedPasteBlock(block),
45 }}
46
47 got := recoverOrphanedPasteLabelsFromHistory("repeat "+block.label, nil, history)
48 want := "repeat " + renderFoldedPasteBlock(block)
49 if got != want {
50 t.Fatalf("recovered paste = %q, want %q", got, want)
51 }
52 }
53
54 func TestRecoverOrphanedPasteLabelPreservesOriginalWhitespace(t *testing.T) {
55 block := pastedBlock{
56 label: "[Pasted text #5 · 5 lines]",
57 text: "\n first line\nsecond line \n\n",
58 }
59 history := []provider.Message{{
60 Role: provider.RoleUser,
61 Content: renderFoldedPasteBlock(block),
62 }}
63
64 got := recoverOrphanedPasteLabelsFromHistory(block.label, nil, history)
65 want := renderFoldedPasteBlock(block)
66 if got != want {
67 t.Fatalf("recovered paste = %q, want exact original %q", got, want)
68 }
69 }
70
71 func TestRecoverOrphanedPasteLabelPreservesEmbeddedEndMarker(t *testing.T) {
72 label := "[Pasted text #4 · 3 lines]"
73 block := pastedBlock{
74 label: label,
75 text: "first\n--- End " + label + " ---\nlast",
76 }
77 history := []provider.Message{{
78 Role: provider.RoleUser,
79 Content: renderFoldedPasteBlock(block),
80 }}
81
82 got := recoverOrphanedPasteLabelsFromHistory(label, nil, history)
83 want := renderFoldedPasteBlock(block)
84 if got != want {
85 t.Fatalf("recovered paste = %q, want exact original %q", got, want)
86 }
87 }
88
89 func TestRecoverOrphanedPasteLabelLeavesConflictingExpansionsUnchanged(t *testing.T) {
90 label := "[Pasted text #4 · 2 lines]"
91 history := []provider.Message{
92 {Role: provider.RoleUser, Content: renderFoldedPasteBlock(pastedBlock{label: label, text: "old\nbody"})},
93 {Role: provider.RoleAssistant, Content: renderFoldedPasteBlock(pastedBlock{label: label, text: "untrusted\nbody"})},
94 {Role: provider.RoleUser, Content: renderFoldedPasteBlock(pastedBlock{label: label, text: "new\nbody"})},
95 }
96
97 if got := recoverOrphanedPasteLabelsFromHistory(label, nil, history); got != label {
98 t.Fatalf("ambiguous paste = %q, want unchanged label %q", got, label)
99 }
100 }
101
102 func TestRecoverOrphanedPasteLabelAcceptsRepeatedIdenticalExpansion(t *testing.T) {
103 label := "[Pasted text #4 · 2 lines]"
104 block := pastedBlock{label: label, text: "same\nbody"}
105 history := []provider.Message{
106 {Role: provider.RoleUser, Content: renderFoldedPasteBlock(block)},
107 {Role: provider.RoleAssistant, Content: renderFoldedPasteBlock(pastedBlock{label: label, text: "untrusted\nbody"})},
108 {Role: provider.RoleUser, Content: renderFoldedPasteBlock(block)},
109 }
110
111 got := recoverOrphanedPasteLabelsFromHistory(label, nil, history)
112 want := renderFoldedPasteBlock(block)
113 if got != want {
114 t.Fatalf("recovered paste = %q, want identical user expansion %q", got, want)
115 }
116 }
117
118 func TestRecoverOrphanedPasteLabelLeavesUnverifiedTextUnchanged(t *testing.T) {
119 sent := "explain [Pasted text #9 · 10 lines] syntax"
120 history := []provider.Message{{
121 Role: provider.RoleAssistant,
122 Content: renderFoldedPasteBlock(pastedBlock{label: "[Pasted text #9 · 10 lines]", text: "assistant\ncontent"}),
123 }}
124
125 if got := recoverOrphanedPasteLabelsFromHistory(sent, nil, history); got != sent {
126 t.Fatalf("unverified label = %q, want unchanged %q", got, sent)
127 }
128 }
129
130 func TestRecoverOrphanedPasteLabelDoesNotReexpandRenderedBlock(t *testing.T) {
131 block := pastedBlock{label: "[Pasted text #4 · 2 lines]", text: "old\nbody"}
132 rendered := renderFoldedPasteBlock(block)
133 history := []provider.Message{{Role: provider.RoleUser, Content: rendered}}
134
135 if got := recoverOrphanedPasteLabelsFromHistory(rendered, nil, history); got != rendered {
136 t.Fatalf("rendered block = %q, want unchanged %q", got, rendered)
137 }
138 }
139
140 func TestNextPasteIDForHistoryContinuesAcrossReload(t *testing.T) {
141 history := []provider.Message{
142 {Role: provider.RoleUser, Content: "[Pasted text #2 · 4 lines]"},
143 {Role: provider.RoleAssistant, Content: "--- Begin [Pasted text #7 · 3 lines] ---"},
144 }
145 if got := nextPasteIDForHistory(history); got != 8 {
146 t.Fatalf("nextPasteIDForHistory = %d, want 8", got)
147 }
148 if got := nextPasteIDForHistory(nil); got != 1 {
149 t.Fatalf("nextPasteIDForHistory(nil) = %d, want 1", got)
150 }
151 }
152
153 func TestNextPasteIDForHistoryDoesNotOverflow(t *testing.T) {
154 history := []provider.Message{{
155 Role: provider.RoleAssistant,
156 Content: foldedPasteLabel(math.MaxInt, 1),
157 }}
158 if got := nextPasteIDForHistory(history); got != 1 {
159 t.Fatalf("nextPasteIDForHistory = %d, want 1", got)
160 }
161 }
162
163 func TestTakeNextPasteIDSkipsUsedIDsAcrossWrap(t *testing.T) {
164 history := []provider.Message{
165 {Role: provider.RoleUser, Content: foldedPasteLabel(1, 1)},
166 {Role: provider.RoleAssistant, Content: foldedPasteLabel(math.MaxInt-1, 1)},
167 {Role: provider.RoleAssistant, Content: foldedPasteLabel(math.MaxInt, 1)},
168 }
169 next, used := pasteIDStateForHistory(history)
170 m := &chatTUI{nextPasteID: next, usedPasteIDs: used}
171
172 if got := m.takeNextPasteID(); got != 2 {
173 t.Fatalf("takeNextPasteID = %d, want first unused ID 2", got)
174 }
175 if m.nextPasteID != 3 {
176 t.Fatalf("nextPasteID = %d, want 3", m.nextPasteID)
177 }
178 }
179
180 func TestTakeNextPasteIDWrapsAfterMaxInt(t *testing.T) {
181 m := &chatTUI{nextPasteID: math.MaxInt}
182 if got := m.takeNextPasteID(); got != math.MaxInt {
183 t.Fatalf("first takeNextPasteID = %d, want %d", got, math.MaxInt)
184 }
185 if got := m.takeNextPasteID(); got != 1 {
186 t.Fatalf("wrapped takeNextPasteID = %d, want 1", got)
187 }
188 }
189
190 func TestTakeNextPasteIDSynchronizesAdoptedControllerHistory(t *testing.T) {
191 first := pastedBlock{label: "[Pasted text #1 · 1 lines]", text: "first"}
192 session := agent.NewSession("system")
193 session.Add(provider.Message{Role: provider.RoleUser, Content: renderFoldedPasteBlock(first)})
194 executor := agent.New(nil, nil, session, agent.Options{}, event.Discard)
195 ctrl := control.New(control.Options{Executor: executor, Label: "review"})
196 t.Cleanup(ctrl.Close)
197
198 m := newChatTUI(ctrl, "", make(chan event.Event), 80)
199
200 second := pastedBlock{label: "[Pasted text #2 · 1 lines]", text: "second"}
201 adopted := agent.NewSession("system")
202 adopted.Add(provider.Message{Role: provider.RoleUser, Content: renderFoldedPasteBlock(first)})
203 adopted.Add(provider.Message{Role: provider.RoleUser, Content: renderFoldedPasteBlock(second)})
204 executor.SetSession(adopted)
205
206 if got := m.takeNextPasteID(); got != 3 {
207 t.Fatalf("takeNextPasteID after adopted history = %d, want 3", got)
208 }
209 }
210
211 func TestDisplayLineForImageRefs(t *testing.T) {
212 got := displayLineForImageRefs("describe @.reasonix/attachments/clipboard-20260601-010203.000001.png @.reasonix/attachments/clipboard-20260601-010204.000002-000002.jpg")
213 want := "describe [image1] [image2]"
214 if got != want {
215 t.Fatalf("displayLineForImageRefs = %q, want %q", got, want)
216 }
217 }
218
219 func TestPastedFileRef(t *testing.T) {
220 dir := t.TempDir()
221 pdf := filepath.Join(dir, "report.pdf")
222 if err := os.WriteFile(pdf, []byte("%PDF-1.4 fake"), 0o644); err != nil {
223 t.Fatal(err)
224 }
225
226 if got, ok := pastedFileRef(pdf); !ok || got != "@"+filepath.Clean(pdf) {
227 t.Fatalf("pastedFileRef(existing pdf) = %q, %v", got, ok)
228 }
229 if got, ok := pastedFileRef(`"` + pdf + `"`); !ok || got != "@"+filepath.Clean(pdf) {
230 t.Fatalf("pastedFileRef(quoted pdf) = %q, %v", got, ok)
231 }
232 if _, ok := pastedFileRef("just-a-word"); ok {
233 t.Fatal("a bare word with no separator must not be a file ref")
234 }
235 if _, ok := pastedFileRef(filepath.Join(dir, "missing.pdf")); ok {
236 t.Fatal("a non-existent path must not be a file ref")
237 }
238 if _, ok := pastedFileRef(dir); ok {
239 t.Fatal("a directory must not be a file ref")
240 }
241 }
242
243 func TestPastedFileRefShellEscapedSpaces(t *testing.T) {
244 if runtime.GOOS == "windows" {
245 t.Skip("POSIX shell-escaped paths are not decoded on Windows")
246 }
247 dir := t.TempDir()
248 path := filepath.Join(dir, "Application Support", "report 2026.pdf")
249 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
250 t.Fatal(err)
251 }
252 if err := os.WriteFile(path, []byte("%PDF-1.4 fake"), 0o644); err != nil {
253 t.Fatal(err)
254 }
255 escaped := strings.ReplaceAll(path, " ", `\ `)
256
257 // The returned ref keeps whitespace escaped so it survives @-token parsing
258 // on submit (control.parseRefTokens unescapes it back to the real path).
259 want := "@" + control.EscapeRefPath(filepath.Clean(path))
260 if got, ok := pastedFileRef(escaped); !ok || got != want {
261 t.Fatalf("pastedFileRef(shell escaped pdf) = %q, %v; want %s", got, ok, want)
262 }
263 }
264
265 func TestPastedImageSources(t *testing.T) {
266 cases := []struct {
267 name string
268 text string
269 want []string
270 ok bool
271 posixOnly bool
272 }{
273 {
274 name: "data URL",
275 text: "data:image/png;base64,aaa",
276 want: []string{"data:image/png;base64,aaa"},
277 ok: true,
278 },
279 {
280 name: "markdown images",
281 text: "![a](/tmp/a.png)\n![b](file:///tmp/b.jpg)",
282 want: []string{"/tmp/a.png", "file:///tmp/b.jpg"},
283 ok: true,
284 },
285 {
286 name: "shell escaped path with spaces",
287 text: `/Users/jawa/Library/Application\ Support/CleanShot/media/CleanShot\ 2026-07-06\ at\ 11.33.14@2x.png`,
288 want: []string{`/Users/jawa/Library/Application\ Support/CleanShot/media/CleanShot\ 2026-07-06\ at\ 11.33.14@2x.png`},
289 ok: true,
290 posixOnly: true,
291 },
292 {
293 name: "shell escaped path without whitespace",
294 text: `/tmp/capture\(1\).png`,
295 want: []string{`/tmp/capture\(1\).png`},
296 ok: true,
297 },
298 {
299 name: "multiple shell escaped paths on one line",
300 text: `/tmp/first\ image.png /tmp/second\ image.jpg`,
301 want: []string{`/tmp/first\ image.png`, `/tmp/second\ image.jpg`},
302 ok: true,
303 posixOnly: true,
304 },
305 {
306 name: "multiple quoted paths on one line",
307 text: `'/tmp/first image.png' "/tmp/second image.jpg"`,
308 want: []string{`'/tmp/first image.png'`, `"/tmp/second image.jpg"`},
309 ok: true,
310 },
311 {
312 name: "sentence with image path remains text",
313 text: `see /tmp/CleanShot\ 2026.png`,
314 ok: false,
315 },
316 {
317 name: "plain text",
318 text: "hello /tmp/a.png",
319 ok: false,
320 },
321 }
322 for _, c := range cases {
323 t.Run(c.name, func(t *testing.T) {
324 if c.posixOnly && runtime.GOOS == "windows" {
325 t.Skip("POSIX shell-escaped paths are not decoded on Windows")
326 }
327 sources, ok := pastedImageSources(c.text)
328 if ok != c.ok {
329 t.Fatalf("ok = %v, want %v", ok, c.ok)
330 }
331 var got []string
332 if sources != nil {
333 got = make([]string, 0, len(sources))
334 for _, source := range sources {
335 got = append(got, source.value)
336 }
337 }
338 if !reflect.DeepEqual(got, c.want) {
339 t.Fatalf("sources = %v, want %v", got, c.want)
340 }
341 })
342 }
343 }
344
345 func TestPasteShellEscapedImagePathInsertsImageToken(t *testing.T) {
346 if runtime.GOOS == "windows" {
347 t.Skip("POSIX shell-escaped paths are not decoded on Windows")
348 }
349 root := t.TempDir()
350 t.Chdir(root)
351 path := filepath.Join(root, "Library", "Application Support", "CleanShot", "CleanShot 2026-07-06 at 11.33.14@2x.png")
352 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
353 t.Fatal(err)
354 }
355 raw, err := base64.StdEncoding.DecodeString(tinyPNGBase64)
356 if err != nil {
357 t.Fatal(err)
358 }
359 if err := os.WriteFile(path, raw, 0o644); err != nil {
360 t.Fatal(err)
361 }
362
363 m := newTestChatTUI()
364 next, _ := m.Update(tea.PasteMsg{Content: strings.ReplaceAll(path, " ", `\ `)})
365 updated := next.(chatTUI)
366
367 if got := updated.input.Value(); got != "[image #1] " {
368 t.Fatalf("input after paste = %q, want image token", got)
369 }
370 if len(updated.pastedBlocks) != 1 || !updated.pastedBlocks[0].image {
371 t.Fatalf("pastedBlocks = %+v, want one image block", updated.pastedBlocks)
372 }
373 if text := updated.pastedBlocks[0].text; !strings.HasPrefix(text, "@.reasonix/attachments/clipboard-") || !strings.HasSuffix(text, ".png") {
374 t.Fatalf("image block text = %q, want saved attachment ref", text)
375 }
376 }
377
378 func TestPasteShellEscapedImagePathWithoutWhitespaceInsertsImageToken(t *testing.T) {
379 if runtime.GOOS == "windows" {
380 t.Skip("POSIX shell-escaped paths are not decoded on Windows")
381 }
382 root := t.TempDir()
383 t.Chdir(root)
384 path := filepath.Join(root, "capture^(1),x.png")
385 raw, err := base64.StdEncoding.DecodeString(tinyPNGBase64)
386 if err != nil {
387 t.Fatal(err)
388 }
389 if err := os.WriteFile(path, raw, 0o644); err != nil {
390 t.Fatal(err)
391 }
392 escaped := strings.NewReplacer("(", `\(`, ")", `\)`, "^", `\^`, ",", `\,`).Replace(path)
393
394 m := newTestChatTUI()
395 next, _ := m.Update(tea.PasteMsg{Content: escaped})
396 updated := next.(chatTUI)
397
398 if got := updated.input.Value(); got != "[image #1] " {
399 t.Fatalf("input after paste = %q, want image token", got)
400 }
401 if len(updated.pastedBlocks) != 1 || !updated.pastedBlocks[0].image {
402 t.Fatalf("pastedBlocks = %+v, want one image block", updated.pastedBlocks)
403 }
404 }
405
406 func TestPasteMultipleShellEscapedImagePathsInsertsImageTokens(t *testing.T) {
407 if runtime.GOOS == "windows" {
408 t.Skip("POSIX shell-escaped paths are not decoded on Windows")
409 }
410 root := t.TempDir()
411 t.Chdir(root)
412 raw, err := base64.StdEncoding.DecodeString(tinyPNGBase64)
413 if err != nil {
414 t.Fatal(err)
415 }
416 first := filepath.Join(root, "first image.png")
417 second := filepath.Join(root, "second image.png")
418 for _, p := range []string{first, second} {
419 if err := os.WriteFile(p, raw, 0o644); err != nil {
420 t.Fatal(err)
421 }
422 }
423 content := strings.ReplaceAll(first, " ", `\ `) + " " + strings.ReplaceAll(second, " ", `\ `)
424
425 m := newTestChatTUI()
426 next, _ := m.Update(tea.PasteMsg{Content: content})
427 updated := next.(chatTUI)
428
429 if got := updated.input.Value(); got != "[image #1] [image #2] " {
430 t.Fatalf("input after paste = %q, want two image tokens", got)
431 }
432 if len(updated.pastedBlocks) != 2 || !updated.pastedBlocks[0].image || !updated.pastedBlocks[1].image {
433 t.Fatalf("pastedBlocks = %+v, want two image blocks", updated.pastedBlocks)
434 }
435 }
436
437 func TestMissingPastedImagePathRemainsText(t *testing.T) {
438 content := `/definitely-missing/reasonix-image.png`
439 if runtime.GOOS == "windows" {
440 content = `C:/definitely-missing/reasonix-image.png`
441 }
442
443 m := newTestChatTUI()
444 next, _ := m.Update(tea.PasteMsg{Content: content})
445 updated := next.(chatTUI)
446 if got := updated.input.Value(); got != content {
447 t.Fatalf("input after missing image paste = %q, want original %q", got, content)
448 }
449 if len(updated.pastedBlocks) != 0 {
450 t.Fatalf("pastedBlocks = %+v, want no image attachment", updated.pastedBlocks)
451 }
452 }
453
454 func TestPastedImagePathShellUnescape(t *testing.T) {
455 cases := []struct {
456 name string
457 src string
458 goos string
459 want string
460 ok bool
461 }{
462 {
463 name: "posix escaped parens without whitespace",
464 src: `/tmp/capture\(1\).png`,
465 goos: "linux",
466 want: "/tmp/capture(1).png",
467 ok: true,
468 },
469 {
470 name: "posix escaped spaces",
471 src: `/tmp/first\ image.png`,
472 goos: "linux",
473 want: "/tmp/first image.png",
474 ok: true,
475 },
476 {
477 name: "posix escaped caret and comma",
478 src: `/tmp/capture\^1\,a.png`,
479 goos: "linux",
480 want: "/tmp/capture^1,a.png",
481 ok: true,
482 },
483 {
484 name: "posix escaped literal backslash",
485 src: `/tmp/a\\b.png`,
486 goos: "linux",
487 want: `/tmp/a\b.png`,
488 ok: true,
489 },
490 {
491 name: "posix unescaped space rejected",
492 src: "/tmp/first image.png",
493 goos: "linux",
494 ok: false,
495 },
496 {
497 name: "windows backslash separators preserved",
498 src: `C:\Users\me\shot(1).png`,
499 goos: "windows",
500 want: `C:\Users\me\shot(1).png`,
501 ok: true,
502 },
503 {
504 name: "windows dollar directory preserved",
505 src: `C:\$Recycle.Bin\shot.png`,
506 goos: "windows",
507 want: `C:\$Recycle.Bin\shot.png`,
508 ok: true,
509 },
510 {
511 name: "windows unquoted space rejected",
512 src: `C:\Program Files\shot.png`,
513 goos: "windows",
514 ok: false,
515 },
516 {
517 name: "windows quoted path with space preserved",
518 src: `"C:\my dir\shot.png"`,
519 goos: "windows",
520 want: `C:\my dir\shot.png`,
521 ok: true,
522 },
523 }
524 for _, c := range cases {
525 t.Run(c.name, func(t *testing.T) {
526 got, ok := pastedImagePathForOS(c.src, c.goos)
527 if ok != c.ok {
528 t.Fatalf("ok = %v, want %v", ok, c.ok)
529 }
530 if c.ok && got != c.want {
531 t.Fatalf("path = %q, want %q", got, c.want)
532 }
533 })
534 }
535 }
536
536 lines GO