返回 DeepSeek-Reasonix
transcript_test.go
根目录 / internal / cli / transcript_test.go
1 package cli
2
3 import (
4 "encoding/json"
5 "errors"
6 "reasonix/internal/i18n"
7 "strings"
8 "testing"
9
10 "github.com/charmbracelet/colorprofile"
11
12 "github.com/charmbracelet/x/ansi"
13
14 "reasonix/internal/provider"
15 )
16
17 func TestAssistantMarkdownHasIdentityAndIndentedBody(t *testing.T) {
18 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
19 activeColorProfile = colorprofile.NoTTY
20 configureCLITheme("dark")
21
22 rendered := renderAssistantMarkdown("A concise answer that wraps across the available width.", 32)
23 lines := strings.Split(ansi.Strip(rendered), "\n")
24 if len(lines) < 4 {
25 t.Fatalf("assistant block should contain a header, gap, and wrapped body:\n%s", rendered)
26 }
27 if lines[0] != " ◆ Reasonix" {
28 t.Fatalf("assistant header = %q, want %q", lines[0], " ◆ Reasonix")
29 }
30 if lines[1] != "" {
31 t.Fatalf("assistant header/body separator = %q, want blank row", lines[1])
32 }
33 for i, line := range lines[2:] {
34 if line != "" && !strings.HasPrefix(line, assistantTranscriptIndent) {
35 t.Fatalf("assistant body row %d lacks the two-cell gutter: %q", i+2, line)
36 }
37 if width := visibleWidth(line); width > 32 {
38 t.Fatalf("assistant row %d width = %d, want <= 32: %q", i+2, width, line)
39 }
40 }
41 }
42
43 func TestReplaySectionsKeepAssistantIdentity(t *testing.T) {
44 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
45 activeColorProfile = colorprofile.NoTTY
46 configureCLITheme("dark")
47
48 sections := replaySectionsFor([]provider.Message{
49 {Role: provider.RoleUser, Origin: provider.MessageOriginHost, Content: "<pinned_context_revision>private pinned body</pinned_context_revision>"},
50 {Role: provider.RoleUser, Content: "Which version?"},
51 {Role: provider.RoleAssistant, Content: "Version 1.2.3"},
52 }, 48)
53 if len(sections) != 2 {
54 t.Fatalf("replay sections = %d, want user and assistant", len(sections))
55 }
56 if plain := ansi.Strip(strings.Join(sections, "")); strings.Contains(plain, "private pinned body") {
57 t.Fatalf("replay exposed a pinned revision: %q", plain)
58 }
59 if plain := ansi.Strip(sections[1]); !strings.HasPrefix(plain, " ◆ Reasonix\n\n Version 1.2.3") {
60 t.Fatalf("replayed assistant answer lost its identity: %q", plain)
61 }
62 }
63
64 func TestReplaySectionsRestoreInterruptedLocalOutput(t *testing.T) {
65 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
66 activeColorProfile = colorprofile.NoTTY
67 configureCLITheme("dark")
68
69 sections := replaySectionsFor([]provider.Message{
70 {Role: provider.RoleUser, Content: "change config"},
71 {
72 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
73 LocalOnly: true, Content: "partial answer", ReasoningContent: "checking config",
74 ToolCalls: []provider.ToolCall{{ID: "p1", Name: "write_file"}},
75 InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true},
76 },
77 }, 64)
78 plain := ansi.Strip(strings.Join(sections, ""))
79 for _, want := range []string{"change config", "checking config", "partial answer", "Write", "bounded recovery summary"} {
80 if !strings.Contains(plain, want) {
81 t.Fatalf("replayed interrupted history missing %q:\n%s", want, plain)
82 }
83 }
84 }
85
86 func TestReplaySectionsRestoreFinalReadinessRecoveryHint(t *testing.T) {
87 sections := replaySectionsFor([]provider.Message{{
88 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName, LocalOnly: true,
89 FinalReadinessRecovery: &provider.FinalReadinessRecovery{Pending: true, Missing: []string{"verification"}},
90 }}, 64)
91 plain := ansi.Strip(strings.Join(sections, ""))
92 if !strings.Contains(plain, "/continue-checks") {
93 t.Fatalf("replayed readiness pause lacks recovery command: %q", plain)
94 }
95 }
96
97 func TestScrollbarThumb(t *testing.T) {
98 if _, size := scrollbarThumb(10, 0, 5); size != 0 {
99 t.Errorf("content within viewport should have no thumb, got size %d", size)
100 }
101 if start, _ := scrollbarThumb(10, 0, 100); start != 0 {
102 t.Errorf("at top the thumb starts at row 0, got %d", start)
103 }
104 const h, total = 10, 100
105 if start, size := scrollbarThumb(h, total-h, total); start+size != h {
106 t.Errorf("at bottom the thumb reaches the last row: start=%d size=%d h=%d", start, size, h)
107 }
108 }
109
110 func TestEdgeScrollDir(t *testing.T) {
111 const h = 10
112 if got := edgeScrollDir(0, h); got != -1 {
113 t.Errorf("top edge dir = %d, want -1", got)
114 }
115 if got := edgeScrollDir(h-1, h); got != 1 {
116 t.Errorf("bottom edge dir = %d, want 1", got)
117 }
118 if got := edgeScrollDir(h/2, h); got != 0 {
119 t.Errorf("middle dir = %d, want 0", got)
120 }
121 }
122
123 func TestSelSpan(t *testing.T) {
124 start, end, cw := selPos{line: 1, col: 3}, selPos{line: 3, col: 5}, 20
125 for _, tc := range []struct {
126 idx int
127 wantOK bool
128 wantLo, wHi int
129 }{
130 {0, false, 0, 0}, // above
131 {1, true, 3, cw}, // first line: anchor col → right edge
132 {2, true, 0, cw}, // middle line: full width
133 {3, true, 0, 5}, // last line: left edge → head col
134 {4, false, 0, 0}, // below
135 } {
136 lo, hi, ok := selSpan(tc.idx, start, end, cw)
137 if ok != tc.wantOK || (ok && (lo != tc.wantLo || hi != tc.wHi)) {
138 t.Errorf("selSpan(%d) = (%d,%d,%v), want (%d,%d,%v)", tc.idx, lo, hi, ok, tc.wantLo, tc.wHi, tc.wantOK)
139 }
140 }
141 }
142
143 func TestSelectedTextMultiLine(t *testing.T) {
144 m := newTestChatTUI()
145 m.wrappedLines = []string{"hello world", "second line", "third row"}
146 m.sel = selection{active: true, anchor: selPos{line: 0, col: 6}, head: selPos{line: 2, col: 5}}
147
148 if got, want := m.selectedText(), "world\nsecond line\nthird"; got != want {
149 t.Errorf("selectedText() = %q, want %q", got, want)
150 }
151
152 // A zero-width selection (plain click) copies nothing.
153 m.sel = selection{active: true, anchor: selPos{line: 0, col: 3}, head: selPos{line: 0, col: 3}}
154 if got := m.selectedText(); got != "" {
155 t.Errorf("empty selection should yield no text, got %q", got)
156 }
157 }
158
159 func TestSelectedTextRestoresMathWithoutReusingRawColumns(t *testing.T) {
160 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
161 activeColorProfile = colorprofile.NoTTY
162 configureCLITheme("dark")
163
164 m := newTestChatTUI()
165 m.width = 80
166 contentWidth := transcriptContentWidth(m.width, m.nativeScrollback)
167 m.viewport.SetWidth(contentWidth)
168 source := transcriptSource{kind: transcriptSourceMarkdown, raw: `before $\alpha$ after`}
169 rendered := m.renderTranscriptSource(source, m.width)
170 m.transcript = []string{rendered}
171 m.transcriptSources = []transcriptSource{source}
172 m.wrappedLines = strings.Split(wrapTranscript(rendered, contentWidth), "\n")
173
174 lineIndex := -1
175 for i, line := range m.wrappedLines {
176 if strings.Contains(ansi.Strip(line), "before α after") {
177 lineIndex = i
178 break
179 }
180 }
181 if lineIndex < 0 {
182 t.Fatalf("rendered transcript did not contain the math line:\n%s", ansi.Strip(rendered))
183 }
184
185 plain := ansi.Strip(m.wrappedLines[lineIndex])
186 before, _, ok := strings.Cut(plain, "α")
187 before0, _, ok0 := strings.Cut(plain, "after")
188 if !ok || !ok0 {
189 t.Fatalf("math line = %q", plain)
190 }
191 formulaCol := ansi.StringWidth(before)
192 afterCol := ansi.StringWidth(before0)
193
194 m.sel = selection{
195 active: true,
196 anchor: selPos{line: lineIndex, col: formulaCol},
197 head: selPos{line: lineIndex, col: formulaCol + ansi.StringWidth("α")},
198 }
199 if got, want := m.selectedText(), `$\alpha$`; got != want {
200 t.Fatalf("formula selection = %q, want %q", got, want)
201 }
202
203 m.sel = selection{
204 active: true,
205 anchor: selPos{line: lineIndex, col: afterCol},
206 head: selPos{line: lineIndex, col: afterCol + ansi.StringWidth("after")},
207 }
208 if got, want := m.selectedText(), "after"; got != want {
209 t.Fatalf("text after formula = %q, want %q", got, want)
210 }
211 }
212
213 func TestSelectedTextRestoresMathFromReplayBundle(t *testing.T) {
214 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
215 activeColorProfile = colorprofile.NoTTY
216 configureCLITheme("dark")
217
218 m := newTestChatTUI()
219 m.width = 80
220 contentWidth := transcriptContentWidth(m.width, m.nativeScrollback)
221 m.viewport.SetWidth(contentWidth)
222 source := transcriptSource{
223 kind: transcriptSourceReplayBundle,
224 history: []provider.Message{
225 {Role: provider.RoleAssistant, Content: `before $\alpha$ after`},
226 {LocalOnly: true, Content: `local $\beta$ recovery`},
227 },
228 }
229 rendered := m.renderTranscriptSource(source, m.width)
230 m.transcript = []string{rendered}
231 m.transcriptSources = []transcriptSource{source}
232 m.wrappedLines = strings.Split(wrapTranscript(rendered, contentWidth), "\n")
233
234 lineIndex := -1
235 formulaCol := -1
236 for i, line := range m.wrappedLines {
237 plain := ansi.Strip(line)
238 before, _, ok := strings.Cut(plain, "α")
239 if !ok {
240 continue
241 }
242 lineIndex = i
243 formulaCol = ansi.StringWidth(before)
244 break
245 }
246 if lineIndex < 0 {
247 t.Fatalf("rendered replay bundle did not contain the formula:\n%s", ansi.Strip(rendered))
248 }
249
250 m.sel = selection{
251 active: true,
252 anchor: selPos{line: lineIndex, col: formulaCol},
253 head: selPos{line: lineIndex, col: formulaCol + ansi.StringWidth("α")},
254 }
255 if got, want := m.selectedText(), `$\alpha$`; got != want {
256 t.Fatalf("replayed formula selection = %q, want %q", got, want)
257 }
258
259 copyLines, ok := m.copyTranscriptLines()
260 if !ok {
261 t.Fatal("copy rendition diverged from the displayed replay bundle")
262 }
263 sourcesByID := make(map[string]string)
264 for _, line := range copyLines {
265 for _, span := range line.math {
266 if source, exists := sourcesByID[span.id]; exists && source != span.source {
267 t.Fatalf("formula marker %q reused for %q and %q", span.id, source, span.source)
268 }
269 sourcesByID[span.id] = span.source
270 }
271 }
272 if len(sourcesByID) != 2 {
273 t.Fatalf("replay formula markers = %v, want two unique formulas", sourcesByID)
274 }
275 foundSources := make(map[string]bool)
276 for _, source := range sourcesByID {
277 foundSources[source] = true
278 }
279 for _, want := range []string{`$\alpha$`, `$\beta$`} {
280 if !foundSources[want] {
281 t.Fatalf("replay formula markers = %v, missing %q", sourcesByID, want)
282 }
283 }
284 }
285
286 func TestSelectedTextPreservesProseAroundMath(t *testing.T) {
287 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
288 activeColorProfile = colorprofile.NoTTY
289 configureCLITheme("dark")
290
291 m := newTestChatTUI()
292 m.width = 80
293 contentWidth := transcriptContentWidth(m.width, m.nativeScrollback)
294 m.viewport.SetWidth(contentWidth)
295 source := transcriptSource{kind: transcriptSourceMarkdown, raw: `before $\frac{1}{2}$ after`}
296 rendered := m.renderTranscriptSource(source, m.width)
297 m.transcript = []string{rendered}
298 m.transcriptSources = []transcriptSource{source}
299 m.wrappedLines = strings.Split(wrapTranscript(rendered, contentWidth), "\n")
300
301 for i, line := range m.wrappedLines {
302 plain := ansi.Strip(line)
303 before, _, ok := strings.Cut(plain, "before")
304 endByte := strings.Index(plain, " after")
305 if !ok || endByte < 0 {
306 continue
307 }
308 startCol := ansi.StringWidth(before)
309 endCol := ansi.StringWidth(plain[:endByte+len(" after")])
310 m.sel = selection{
311 active: true,
312 anchor: selPos{line: i, col: startCol},
313 head: selPos{line: i, col: endCol},
314 }
315 if got, want := m.selectedText(), `before $\frac{1}{2}$ after`; got != want {
316 t.Fatalf("mixed selection = %q, want %q", got, want)
317 }
318 return
319 }
320 t.Fatalf("rendered transcript did not contain the expected mixed line:\n%s", ansi.Strip(rendered))
321 }
322
323 func TestSelectedTextRestoresMathWrappedAcrossDisplayLinesOnce(t *testing.T) {
324 defer restoreThemeForTest(activeColorProfile, activeCLITheme)
325 activeColorProfile = colorprofile.NoTTY
326 configureCLITheme("dark")
327
328 m := newTestChatTUI()
329 m.width = 10
330 contentWidth := transcriptContentWidth(m.width, m.nativeScrollback)
331 m.viewport.SetWidth(contentWidth)
332 const latex = `\alpha+\beta+\gamma+\delta+\epsilon+\zeta`
333 source := transcriptSource{kind: transcriptSourceMarkdown, raw: `$` + latex + `$`}
334 rendered := m.renderTranscriptSource(source, m.width)
335 m.transcript = []string{rendered}
336 m.transcriptSources = []transcriptSource{source}
337 m.wrappedLines = strings.Split(wrapTranscript(rendered, contentWidth), "\n")
338
339 copyLines, ok := m.copyTranscriptLines()
340 if !ok {
341 t.Fatal("copy rendition diverged from the displayed transcript")
342 }
343 firstLine, lastLine := -1, -1
344 firstCol, lastCol := 0, 0
345 for i, line := range copyLines {
346 if len(line.math) == 0 {
347 continue
348 }
349 if firstLine < 0 {
350 firstLine = i
351 firstCol = line.math[0].start
352 }
353 lastLine = i
354 lastCol = line.math[len(line.math)-1].end
355 }
356 if firstLine < 0 || lastLine <= firstLine {
357 t.Fatalf("expected formula to wrap across lines:\n%s", ansi.Strip(rendered))
358 }
359
360 m.sel = selection{
361 active: true,
362 anchor: selPos{line: firstLine, col: firstCol},
363 head: selPos{line: lastLine, col: lastCol},
364 }
365 if got, want := m.selectedText(), `$`+latex+`$`; got != want {
366 t.Fatalf("wrapped formula selection = %q, want %q", got, want)
367 }
368 }
369
370 func TestCopyToClipboard(t *testing.T) {
371 t.Setenv("SSH_CONNECTION", "")
372 t.Setenv("SSH_CLIENT", "")
373 t.Setenv("SSH_TTY", "")
374 previous := writeNativeClipboardText
375 t.Cleanup(func() { writeNativeClipboardText = previous })
376
377 var written string
378 writeNativeClipboardText = func(text string) error {
379 written = text
380 return nil
381 }
382 message := copyToClipboard("hello")()
383 got, ok := message.(clipboardCopyMsg)
384 if !ok {
385 t.Fatalf("copyToClipboard returned %T, want clipboardCopyMsg", message)
386 }
387 if written != "hello" || got.text != "hello" || got.err != nil || got.osc52 {
388 t.Fatalf("native clipboard result = %+v, written %q", got, written)
389 }
390
391 wantErr := errors.New("clipboard unavailable")
392 writeNativeClipboardText = func(string) error { return wantErr }
393 got = copyToClipboard("fallback")().(clipboardCopyMsg)
394 if !errors.Is(got.err, wantErr) || got.osc52 {
395 t.Fatalf("failed native clipboard result = %+v", got)
396 }
397
398 t.Setenv("SSH_CONNECTION", "host 22 client 1234")
399 writeNativeClipboardText = func(string) error {
400 t.Fatal("SSH copy must not write the remote host's native clipboard")
401 return nil
402 }
403 got = copyToClipboard("remote")().(clipboardCopyMsg)
404 if !got.osc52 || got.text != "remote" {
405 t.Fatalf("SSH clipboard result = %+v, want OSC 52", got)
406 }
407 }
408
409 func TestReplaySearchMissingSourcesIsExplicitAndKeepsSummary(t *testing.T) {
410 render := func(s string, _ int) string { return s }
411 for _, msg := range []provider.Message{
412 {Role: provider.RoleAssistant, ServerSearch: []provider.ServerSearchCall{{ID: "s", SourcesStatus: provider.SourcesNotProvided}}},
413 {Role: provider.RoleTool, Name: "web_search", Content: `{"sources_status":"not_provided","summary":"retained search summary"}`},
414 } {
415 got := strings.Join(replaySectionsForWithAssistantRenderer([]provider.Message{msg}, 80, render), "")
416 if !strings.Contains(got, i18n.M.SearchSourcesNotProvided) {
417 t.Fatal("missing source notice")
418 }
419 if msg.Role == provider.RoleTool && !strings.Contains(got, "retained search summary") {
420 t.Fatal("summary lost")
421 }
422 }
423 old := provider.Message{Role: provider.RoleAssistant, ServerSearch: []provider.ServerSearchCall{{ID: "s", Raw: json.RawMessage(`[]`)}}}
424 if got := searchHistorySections(old, 80, render); len(got) != 0 {
425 t.Fatal("inferred unrecorded old status")
426 }
427 }
428
429 // The replay must render the durable display view: host-generated
430 // session-context wrappers are invisible plumbing, and the user bubble shows
431 // the raw submitted text rather than the provider wrapper content.
432 func TestReplayDropsHostSessionContextAndUsesRawUserText(t *testing.T) {
433 history := []provider.Message{
434 {Role: provider.RoleUser, Origin: provider.MessageOriginHost, Content: "<session-context version=\"1\">\nworkspace facts\n</session-context>"},
435 {Role: provider.RoleUser, Content: "<reasoning-language>\nuse zh\n</reasoning-language>\n回复ok就行", RawContent: "回复ok就行"},
436 {Role: provider.RoleAssistant, Content: "ok"},
437 }
438 sections := replaySectionsFor(history, 80)
439 joined := strings.Join(sections, "\n")
440 if strings.Contains(joined, "session-context") {
441 t.Fatalf("host session-context wrapper leaked into the replay: %q", joined)
442 }
443 if strings.Contains(joined, "reasoning-language") {
444 t.Fatalf("provider wrapper text leaked into the user bubble: %q", joined)
445 }
446 if !strings.Contains(joined, "回复ok就行") {
447 t.Fatalf("raw user text missing from the replay: %q", joined)
448 }
449 if !strings.Contains(joined, "ok") {
450 t.Fatalf("assistant reply missing from the replay: %q", joined)
451 }
452 }
453
453 lines GO