返回 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 TestRecoverOrphanedPasteLabelIgnoresPinnedRevision(t *testing.T) {
131 block := pastedBlock{label: "[Pasted text #9 · 2 lines]", text: "private\nbody"}
132 history := []provider.Message{{
133 Role: provider.RoleUser, Origin: provider.MessageOriginHost,
134 Content: "<pinned_context_revision>" + renderFoldedPasteBlock(block) + "</pinned_context_revision>",
135 }}
136 if got := recoverOrphanedPasteLabelsFromHistory(block.label, nil, history); got != block.label {
137 t.Fatalf("pinned revision recovered paste body: %q", got)
138 }
139 }
140
141 func TestRecoverOrphanedPasteLabelDoesNotReexpandRenderedBlock(t *testing.T) {
142 block := pastedBlock{label: "[Pasted text #4 · 2 lines]", text: "old\nbody"}
143 rendered := renderFoldedPasteBlock(block)
144 history := []provider.Message{{Role: provider.RoleUser, Content: rendered}}
145
146 if got := recoverOrphanedPasteLabelsFromHistory(rendered, nil, history); got != rendered {
147 t.Fatalf("rendered block = %q, want unchanged %q", got, rendered)
148 }
149 }
150
151 func TestNextPasteIDForHistoryContinuesAcrossReload(t *testing.T) {
152 history := []provider.Message{
153 {Role: provider.RoleUser, Content: "[Pasted text #2 · 4 lines]"},
154 {Role: provider.RoleAssistant, Content: "--- Begin [Pasted text #7 · 3 lines] ---"},
155 }
156 if got := nextPasteIDForHistory(history); got != 8 {
157 t.Fatalf("nextPasteIDForHistory = %d, want 8", got)
158 }
159 if got := nextPasteIDForHistory(nil); got != 1 {
160 t.Fatalf("nextPasteIDForHistory(nil) = %d, want 1", got)
161 }
162 }
163
164 func TestNextPasteIDForHistoryDoesNotOverflow(t *testing.T) {
165 history := []provider.Message{{
166 Role: provider.RoleAssistant,
167 Content: foldedPasteLabel(math.MaxInt, 1),
168 }}
169 if got := nextPasteIDForHistory(history); got != 1 {
170 t.Fatalf("nextPasteIDForHistory = %d, want 1", got)
171 }
172 }
173
174 func TestTakeNextPasteIDSkipsUsedIDsAcrossWrap(t *testing.T) {
175 history := []provider.Message{
176 {Role: provider.RoleUser, Content: foldedPasteLabel(1, 1)},
177 {Role: provider.RoleAssistant, Content: foldedPasteLabel(math.MaxInt-1, 1)},
178 {Role: provider.RoleAssistant, Content: foldedPasteLabel(math.MaxInt, 1)},
179 }
180 next, used := pasteIDStateForHistory(history)
181 m := &chatTUI{nextPasteID: next, usedPasteIDs: used}
182
183 if got := m.takeNextPasteID(); got != 2 {
184 t.Fatalf("takeNextPasteID = %d, want first unused ID 2", got)
185 }
186 if m.nextPasteID != 3 {
187 t.Fatalf("nextPasteID = %d, want 3", m.nextPasteID)
188 }
189 }
190
191 func TestTakeNextPasteIDWrapsAfterMaxInt(t *testing.T) {
192 m := &chatTUI{nextPasteID: math.MaxInt}
193 if got := m.takeNextPasteID(); got != math.MaxInt {
194 t.Fatalf("first takeNextPasteID = %d, want %d", got, math.MaxInt)
195 }
196 if got := m.takeNextPasteID(); got != 1 {
197 t.Fatalf("wrapped takeNextPasteID = %d, want 1", got)
198 }
199 }
200
201 func TestTakeNextPasteIDSynchronizesAdoptedControllerHistory(t *testing.T) {
202 first := pastedBlock{label: "[Pasted text #1 · 1 lines]", text: "first"}
203 session := agent.NewSession("system")
204 session.Add(provider.Message{Role: provider.RoleUser, Content: renderFoldedPasteBlock(first)})
205 executor := agent.New(nil, nil, session, agent.Options{}, event.Discard)
206 ctrl := newOwnedTestController(t, control.Options{Executor: executor, Label: "review"})
207 t.Cleanup(ctrl.Close)
208
209 m := newChatTUI(ctrl, "", make(chan event.Event), 80)
210
211 second := pastedBlock{label: "[Pasted text #2 · 1 lines]", text: "second"}
212 adopted := agent.NewSession("system")
213 adopted.Add(provider.Message{Role: provider.RoleUser, Content: renderFoldedPasteBlock(first)})
214 adopted.Add(provider.Message{Role: provider.RoleUser, Content: renderFoldedPasteBlock(second)})
215 executor.SetSession(adopted)
216
217 if got := m.takeNextPasteID(); got != 3 {
218 t.Fatalf("takeNextPasteID after adopted history = %d, want 3", got)
219 }
220 }
221
222 func TestDisplayLineForImageRefs(t *testing.T) {
223 got := displayLineForImageRefs("describe @.reasonix/attachments/clipboard-20260601-010203.000001.png @.reasonix/attachments/clipboard-20260601-010204.000002-000002.jpg")
224 want := "describe [image1] [image2]"
225 if got != want {
226 t.Fatalf("displayLineForImageRefs = %q, want %q", got, want)
227 }
228 }
229
230 func TestPastedFileRef(t *testing.T) {
231 dir := t.TempDir()
232 pdf := filepath.Join(dir, "report.pdf")
233 if err := os.WriteFile(pdf, []byte("%PDF-1.4 fake"), 0o644); err != nil {
234 t.Fatal(err)
235 }
236
237 if got, ok := pastedFileRef(pdf); !ok || got != "@"+filepath.Clean(pdf) {
238 t.Fatalf("pastedFileRef(existing pdf) = %q, %v", got, ok)
239 }
240 if got, ok := pastedFileRef(`"` + pdf + `"`); !ok || got != "@"+filepath.Clean(pdf) {
241 t.Fatalf("pastedFileRef(quoted pdf) = %q, %v", got, ok)
242 }
243 if _, ok := pastedFileRef("just-a-word"); ok {
244 t.Fatal("a bare word with no separator must not be a file ref")
245 }
246 if _, ok := pastedFileRef(filepath.Join(dir, "missing.pdf")); ok {
247 t.Fatal("a non-existent path must not be a file ref")
248 }
249 if _, ok := pastedFileRef(dir); ok {
250 t.Fatal("a directory must not be a file ref")
251 }
252 }
253
254 func TestPastedFileRefShellEscapedSpaces(t *testing.T) {
255 if runtime.GOOS == "windows" {
256 t.Skip("POSIX shell-escaped paths are not decoded on Windows")
257 }
258 dir := t.TempDir()
259 path := filepath.Join(dir, "Application Support", "report 2026.pdf")
260 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
261 t.Fatal(err)
262 }
263 if err := os.WriteFile(path, []byte("%PDF-1.4 fake"), 0o644); err != nil {
264 t.Fatal(err)
265 }
266 escaped := strings.ReplaceAll(path, " ", `\ `)
267
268 // The returned ref keeps whitespace escaped so it survives @-token parsing
269 // on submit (control.parseRefTokens unescapes it back to the real path).
270 want := "@" + control.EscapeRefPath(filepath.Clean(path))
271 if got, ok := pastedFileRef(escaped); !ok || got != want {
272 t.Fatalf("pastedFileRef(shell escaped pdf) = %q, %v; want %s", got, ok, want)
273 }
274 }
275
276 func TestPastedImageSources(t *testing.T) {
277 cases := []struct {
278 name string
279 text string
280 want []string
281 ok bool
282 posixOnly bool
283 }{
284 {
285 name: "data URL",
286 text: "data:image/png;base64,aaa",
287 want: []string{"data:image/png;base64,aaa"},
288 ok: true,
289 },
290 {
291 name: "markdown images",
292 text: "![a](/tmp/a.png)\n![b](file:///tmp/b.jpg)",
293 want: []string{"/tmp/a.png", "file:///tmp/b.jpg"},
294 ok: true,
295 },
296 {
297 name: "shell escaped path with spaces",
298 text: `/Users/jawa/Library/Application\ Support/CleanShot/media/CleanShot\ 2026-07-06\ at\ 11.33.14@2x.png`,
299 want: []string{`/Users/jawa/Library/Application\ Support/CleanShot/media/CleanShot\ 2026-07-06\ at\ 11.33.14@2x.png`},
300 ok: true,
301 posixOnly: true,
302 },
303 {
304 name: "shell escaped path without whitespace",
305 text: `/tmp/capture\(1\).png`,
306 want: []string{`/tmp/capture\(1\).png`},
307 ok: true,
308 },
309 {
310 name: "multiple shell escaped paths on one line",
311 text: `/tmp/first\ image.png /tmp/second\ image.jpg`,
312 want: []string{`/tmp/first\ image.png`, `/tmp/second\ image.jpg`},
313 ok: true,
314 posixOnly: true,
315 },
316 {
317 name: "multiple quoted paths on one line",
318 text: `'/tmp/first image.png' "/tmp/second image.jpg"`,
319 want: []string{`'/tmp/first image.png'`, `"/tmp/second image.jpg"`},
320 ok: true,
321 },
322 {
323 name: "sentence with image path remains text",
324 text: `see /tmp/CleanShot\ 2026.png`,
325 ok: false,
326 },
327 {
328 name: "plain text",
329 text: "hello /tmp/a.png",
330 ok: false,
331 },
332 }
333 for _, c := range cases {
334 t.Run(c.name, func(t *testing.T) {
335 if c.posixOnly && runtime.GOOS == "windows" {
336 t.Skip("POSIX shell-escaped paths are not decoded on Windows")
337 }
338 sources, ok := pastedImageSources(c.text)
339 if ok != c.ok {
340 t.Fatalf("ok = %v, want %v", ok, c.ok)
341 }
342 var got []string
343 if sources != nil {
344 got = make([]string, 0, len(sources))
345 for _, source := range sources {
346 got = append(got, source.value)
347 }
348 }
349 if !reflect.DeepEqual(got, c.want) {
350 t.Fatalf("sources = %v, want %v", got, c.want)
351 }
352 })
353 }
354 }
355
356 func TestPasteShellEscapedImagePathInsertsImageToken(t *testing.T) {
357 if runtime.GOOS == "windows" {
358 t.Skip("POSIX shell-escaped paths are not decoded on Windows")
359 }
360 root := t.TempDir()
361 t.Chdir(root)
362 path := filepath.Join(root, "Library", "Application Support", "CleanShot", "CleanShot 2026-07-06 at 11.33.14@2x.png")
363 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
364 t.Fatal(err)
365 }
366 raw, err := base64.StdEncoding.DecodeString(tinyPNGBase64)
367 if err != nil {
368 t.Fatal(err)
369 }
370 if err := os.WriteFile(path, raw, 0o644); err != nil {
371 t.Fatal(err)
372 }
373
374 m := newTestChatTUI()
375 next, _ := m.Update(tea.PasteMsg{Content: strings.ReplaceAll(path, " ", `\ `)})
376 updated := next.(chatTUI)
377
378 if got := updated.input.Value(); got != "[image #1] " {
379 t.Fatalf("input after paste = %q, want image token", got)
380 }
381 if len(updated.pastedBlocks) != 1 || !updated.pastedBlocks[0].image {
382 t.Fatalf("pastedBlocks = %+v, want one image block", updated.pastedBlocks)
383 }
384 if text := updated.pastedBlocks[0].text; !strings.HasPrefix(text, "@.reasonix/attachments/clipboard-") || !strings.HasSuffix(text, ".png") {
385 t.Fatalf("image block text = %q, want saved attachment ref", text)
386 }
387 }
388
389 func TestPasteShellEscapedImagePathWithoutWhitespaceInsertsImageToken(t *testing.T) {
390 if runtime.GOOS == "windows" {
391 t.Skip("POSIX shell-escaped paths are not decoded on Windows")
392 }
393 root := t.TempDir()
394 t.Chdir(root)
395 path := filepath.Join(root, "capture^(1),x.png")
396 raw, err := base64.StdEncoding.DecodeString(tinyPNGBase64)
397 if err != nil {
398 t.Fatal(err)
399 }
400 if err := os.WriteFile(path, raw, 0o644); err != nil {
401 t.Fatal(err)
402 }
403 escaped := strings.NewReplacer("(", `\(`, ")", `\)`, "^", `\^`, ",", `\,`).Replace(path)
404
405 m := newTestChatTUI()
406 next, _ := m.Update(tea.PasteMsg{Content: escaped})
407 updated := next.(chatTUI)
408
409 if got := updated.input.Value(); got != "[image #1] " {
410 t.Fatalf("input after paste = %q, want image token", got)
411 }
412 if len(updated.pastedBlocks) != 1 || !updated.pastedBlocks[0].image {
413 t.Fatalf("pastedBlocks = %+v, want one image block", updated.pastedBlocks)
414 }
415 }
416
417 func TestPasteMultipleShellEscapedImagePathsInsertsImageTokens(t *testing.T) {
418 if runtime.GOOS == "windows" {
419 t.Skip("POSIX shell-escaped paths are not decoded on Windows")
420 }
421 root := t.TempDir()
422 t.Chdir(root)
423 raw, err := base64.StdEncoding.DecodeString(tinyPNGBase64)
424 if err != nil {
425 t.Fatal(err)
426 }
427 first := filepath.Join(root, "first image.png")
428 second := filepath.Join(root, "second image.png")
429 for _, p := range []string{first, second} {
430 if err := os.WriteFile(p, raw, 0o644); err != nil {
431 t.Fatal(err)
432 }
433 }
434 content := strings.ReplaceAll(first, " ", `\ `) + " " + strings.ReplaceAll(second, " ", `\ `)
435
436 m := newTestChatTUI()
437 next, _ := m.Update(tea.PasteMsg{Content: content})
438 updated := next.(chatTUI)
439
440 if got := updated.input.Value(); got != "[image #1] [image #2] " {
441 t.Fatalf("input after paste = %q, want two image tokens", got)
442 }
443 if len(updated.pastedBlocks) != 2 || !updated.pastedBlocks[0].image || !updated.pastedBlocks[1].image {
444 t.Fatalf("pastedBlocks = %+v, want two image blocks", updated.pastedBlocks)
445 }
446 }
447
448 func TestMissingPastedImagePathRemainsText(t *testing.T) {
449 content := `/definitely-missing/reasonix-image.png`
450 if runtime.GOOS == "windows" {
451 content = `C:/definitely-missing/reasonix-image.png`
452 }
453
454 m := newTestChatTUI()
455 next, _ := m.Update(tea.PasteMsg{Content: content})
456 updated := next.(chatTUI)
457 if got := updated.input.Value(); got != content {
458 t.Fatalf("input after missing image paste = %q, want original %q", got, content)
459 }
460 if len(updated.pastedBlocks) != 0 {
461 t.Fatalf("pastedBlocks = %+v, want no image attachment", updated.pastedBlocks)
462 }
463 }
464
465 func TestPastedImagePathShellUnescape(t *testing.T) {
466 cases := []struct {
467 name string
468 src string
469 goos string
470 want string
471 ok bool
472 }{
473 {
474 name: "posix escaped parens without whitespace",
475 src: `/tmp/capture\(1\).png`,
476 goos: "linux",
477 want: "/tmp/capture(1).png",
478 ok: true,
479 },
480 {
481 name: "posix escaped spaces",
482 src: `/tmp/first\ image.png`,
483 goos: "linux",
484 want: "/tmp/first image.png",
485 ok: true,
486 },
487 {
488 name: "posix escaped caret and comma",
489 src: `/tmp/capture\^1\,a.png`,
490 goos: "linux",
491 want: "/tmp/capture^1,a.png",
492 ok: true,
493 },
494 {
495 name: "posix escaped literal backslash",
496 src: `/tmp/a\\b.png`,
497 goos: "linux",
498 want: `/tmp/a\b.png`,
499 ok: true,
500 },
501 {
502 name: "posix unescaped space rejected",
503 src: "/tmp/first image.png",
504 goos: "linux",
505 ok: false,
506 },
507 {
508 name: "windows backslash separators preserved",
509 src: `C:\Users\me\shot(1).png`,
510 goos: "windows",
511 want: `C:\Users\me\shot(1).png`,
512 ok: true,
513 },
514 {
515 name: "windows dollar directory preserved",
516 src: `C:\$Recycle.Bin\shot.png`,
517 goos: "windows",
518 want: `C:\$Recycle.Bin\shot.png`,
519 ok: true,
520 },
521 {
522 name: "windows unquoted space rejected",
523 src: `C:\Program Files\shot.png`,
524 goos: "windows",
525 ok: false,
526 },
527 {
528 name: "windows quoted path with space preserved",
529 src: `"C:\my dir\shot.png"`,
530 goos: "windows",
531 want: `C:\my dir\shot.png`,
532 ok: true,
533 },
534 }
535 for _, c := range cases {
536 t.Run(c.name, func(t *testing.T) {
537 got, ok := pastedImagePathForOS(c.src, c.goos)
538 if ok != c.ok {
539 t.Fatalf("ok = %v, want %v", ok, c.ok)
540 }
541 if c.ok && got != c.want {
542 t.Fatalf("path = %q, want %q", got, c.want)
543 }
544 })
545 }
546 }
547
547 lines GO