1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/mattn/go-runewidth"
14
15 "github.com/bnema/zut/packages/core"
16 "github.com/bnema/zut/packages/provider"
17 "github.com/bnema/zut/packages/tui"
18)
19
20func timelineTestData() timelineData {
21 started := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
22 finished := started.Add(1250 * time.Millisecond)
23 return timelineData{
24 System: "You are zot.",
25 Tools: []provider.Tool{{
26 Name: "bash", Schema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}}}`),
27 }},
28 Messages: []provider.Message{
29 {Role: provider.RoleUser, Time: started.Add(-time.Second), Content: []provider.Content{provider.TextBlock{Text: "list files"}}},
30 {Role: provider.RoleAssistant, Time: started, Content: []provider.Content{
31 provider.TextBlock{Text: "I will inspect them."},
32 provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"ls -la"}`)},
33 }},
34 {Role: provider.RoleTool, Time: finished, Content: []provider.Content{
35 provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "README.md"}}},
36 }},
37 },
38 ContextUsed: 1200,
39 ContextMax: 200000,
40 }
41}
42
43func TestSessionTimelineOpensAlternateView(t *testing.T) {
44 interactive := &Interactive{timeline: newTimelineView()}
45 interactive.runSlash(context.Background(), "/session timeline")
46 if !interactive.timeline.Active() {
47 t.Fatal("/session timeline did not open the timeline view")
48 }
49}
50
51func TestSessionPickerIncludesTimeline(t *testing.T) {
52 interactive := &Interactive{sessionOpsDialog: newSessionOpsDialog()}
53 interactive.openSessionOpsDialog()
54 if len(interactive.sessionOpsDialog.items) == 0 || interactive.sessionOpsDialog.items[0].action != "timeline" {
55 t.Fatalf("session actions = %+v, want timeline first", interactive.sessionOpsDialog.items)
56 }
57}
58
59func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
60 entries := buildTimelineEntries(timelineTestData())
61 if len(entries) != 4 {
62 t.Fatalf("entries = %d, want system, user, assistant, and tool", len(entries))
63 }
64 tool := entries[3]
65 if tool.Kind != "tool" || tool.ToolCall != "call-1" {
66 t.Fatalf("tool entry = %+v", tool)
67 }
68 if !strings.Contains(tool.Payload, "ls -la") || !strings.Contains(tool.Result, "README.md") {
69 t.Fatalf("tool details missing: %+v", tool)
70 }
71 if !strings.Contains(tool.Schema, "command") {
72 t.Fatalf("tool schema missing: %q", tool.Schema)
73 }
74 if tool.Duration != "1.25s" {
75 t.Fatalf("duration = %q, want 1.25s", tool.Duration)
76 }
77}
78
79func TestTimelineReasoningIsReadableAndScrollable(t *testing.T) {
80 data := timelineTestData()
81 data.Messages[1].Content = append([]provider.Content{provider.ReasoningBlock{
82 Summary: "**First** step\n" + strings.Repeat("A long line of thought. ", 25) + "\nFinal insight",
83 }}, data.Messages[1].Content...)
84 entries := buildTimelineEntries(data)
85 if len(entries) != 5 || entries[2].Kind != "reasoning" || entries[3].Kind != "assistant" {
86 t.Fatalf("reasoning entries = %+v", entries)
87 }
88 if strings.Contains(entries[3].Summary, "First") {
89 t.Fatalf("reasoning duplicated in assistant: %+v", entries[3])
90 }
91 view := newTimelineView()
92 view.Open(data)
93 view.cursor = 2
94 rows := timelineDetailRows(tui.Dark, 40, entries[2], 0)
95 if len(rows) < 4 || !strings.Contains(stripANSIBytes(strings.Join(rows, "\n")), "Final insight") {
96 t.Fatalf("reasoning detail not complete: %q", rows)
97 }
98 for _, row := range rows {
99 if runewidth.StringWidth(stripANSIBytes(row)) > 40 {
100 t.Fatalf("row exceeds width: %q", row)
101 }
102 }
103 first := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n")
104 view.HandleKey(tui.Key{Kind: tui.KeyEnd}, data)
105 last := strings.Join(view.Render(tui.Dark, 40, 17, data), "\n")
106 if view.detailTop == 0 || first == last || !strings.Contains(stripANSIBytes(last), "Final insight") {
107 t.Fatalf("detail did not scroll to end: top=%d, output=%q", view.detailTop, last)
108 }
109 view.HandleKey(tui.Key{Kind: tui.KeyLeft}, data)
110 if view.detailTop == 0 {
111 t.Fatal("left did not scroll detail up")
112 }
113 view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
114 if view.detailTop != 0 {
115 t.Fatal("changing tab did not reset detail scroll")
116 }
117}
118
119func TestTimelineNavigationKeysMoveSelection(t *testing.T) {
120 data := timelineTestData()
121 view := newTimelineView()
122 view.Open(data)
123 last := len(buildTimelineEntries(data)) - 1
124 if view.cursor != last {
125 t.Fatalf("initial cursor = %d, want %d", view.cursor, last)
126 }
127 view.HandleKey(tui.Key{Kind: tui.KeyUp}, data)
128 if view.cursor != last-1 {
129 t.Fatalf("cursor after up = %d, want %d", view.cursor, last-1)
130 }
131 view.HandleKey(tui.Key{Kind: tui.KeyDown}, data)
132 if view.cursor != last {
133 t.Fatalf("cursor after down = %d, want %d", view.cursor, last)
134 }
135 view.HandleKey(tui.Key{Kind: tui.KeyPageUp}, data)
136 if view.cursor != 0 {
137 t.Fatalf("cursor after page up = %d, want 0", view.cursor)
138 }
139 view.HandleKey(tui.Key{Kind: tui.KeyPageDown}, data)
140 if view.cursor != last {
141 t.Fatalf("cursor after page down = %d, want %d", view.cursor, last)
142 }
143}
144
145func TestTimelineSearchAndTabs(t *testing.T) {
146 data := timelineTestData()
147 view := newTimelineView()
148 view.Open(data)
149 view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}, data)
150 for _, r := range "readme" {
151 view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}, data)
152 }
153 matches := filterTimelineEntries(buildTimelineEntries(data), view.filter)
154 if len(matches) != 1 || matches[0].Kind != "tool" {
155 t.Fatalf("matches = %+v, want tool result", matches)
156 }
157 view.HandleKey(tui.Key{Kind: tui.KeyTab}, data)
158 if view.tab != 1 {
159 t.Fatalf("tab = %d, want payload tab", view.tab)
160 }
161 view.HandleKey(tui.Key{Kind: tui.KeyEsc}, data)
162 if view.filter != "" || !view.Active() {
163 t.Fatal("first esc should clear search without closing timeline")
164 }
165}
166
167func TestTimelineTabsKeepSelectedTabVisible(t *testing.T) {
168 const width = 30
169 for selected, name := range timelineTabNames {
170 rendered := renderTimelineTabs(tui.Theme{}, width, selected)
171 plain := stripANSIBytes(rendered)
172 if !strings.Contains(plain, "["+name+"]") {
173 t.Errorf("selected tab %q not visible in %q", name, plain)
174 }
175 if got := runewidth.StringWidth(plain); got > width {
176 t.Errorf("tabs width = %d, want <= %d: %q", got, width, plain)
177 }
178 }
179}
180
181func TestTimelineRenderShowsContextAndControls(t *testing.T) {
182 data := timelineTestData()
183 if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 {
184 t.Fatalf("context rows = %d, want usage and composition only", len(contextLines))
185 }
186 view := newTimelineView()
187 view.Open(data)
188 rendered := strings.Join(view.Render(tui.Theme{}, 100, 40, data), "\n")
189 for _, want := range []string{"timeline", "context 1.2k / 200.0k", "estimated composition", "ctrl+e export", "TOOL bash"} {
190 if !strings.Contains(rendered, want) {
191 t.Fatalf("render missing %q:\n%s", want, rendered)
192 }
193 }
194}
195
196func TestTimelineExportOmitsImageDataAndUsesPrivatePermissions(t *testing.T) {
197 data := timelineTestData()
198 data.Messages = append(data.Messages, provider.Message{
199 Role: provider.RoleUser,
200 Content: []provider.Content{provider.ImageBlock{MimeType: "image/png", Data: []byte("secret-image-bytes")}},
201 })
202 view := newTimelineView()
203 path, err := view.Export(t.TempDir(), data)
204 if err != nil {
205 t.Fatal(err)
206 }
207 contents, err := os.ReadFile(path)
208 if err != nil {
209 t.Fatal(err)
210 }
211 if strings.Contains(string(contents), "secret-image-bytes") {
212 t.Fatal("export embedded image data")
213 }
214 if !strings.Contains(string(contents), "image/png") {
215 t.Fatal("export omitted image metadata")
216 }
217 info, err := os.Stat(path)
218 if err != nil {
219 t.Fatal(err)
220 }
221 if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
222 t.Fatalf("permissions = %o, want 600", info.Mode().Perm())
223 }
224}
225
226func TestTimelinePairsReusedToolCallIDs(t *testing.T) {
227 t0 := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
228 call := func(cmd string) provider.Content {
229 return provider.ToolCallBlock{ID: "reused", Name: "bash", Arguments: json.RawMessage(`{"command":"` + cmd + `"}`)}
230 }
231 result := func(text string, isErr bool) provider.Message {
232 return provider.Message{Role: provider.RoleTool, Time: t0.Add(time.Second), Content: []provider.Content{
233 provider.ToolResultBlock{CallID: "reused", IsError: isErr, Content: []provider.Content{provider.TextBlock{Text: text}}},
234 }}
235 }
236 data := timelineData{Messages: []provider.Message{
237 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "go"}}},
238 {Role: provider.RoleAssistant, Time: t0, Content: []provider.Content{call("first")}},
239 result("first-output", false),
240 {Role: provider.RoleAssistant, Time: t0.Add(2 * time.Second), Content: []provider.Content{call("second")}},
241 result("second-output", true),
242 {Role: provider.RoleAssistant, Time: t0.Add(4 * time.Second), Content: []provider.Content{call("third")}},
243 }}
244 var tools []timelineEntry
245 for _, e := range buildTimelineEntries(data) {
246 if e.Kind == "tool" {
247 tools = append(tools, e)
248 }
249 }
250 if len(tools) != 3 {
251 t.Fatalf("tool entries = %d, want 3: %+v", len(tools), tools)
252 }
253 if !strings.Contains(tools[0].Result, "first-output") || tools[0].IsError {
254 t.Fatalf("first call paired wrongly: %+v", tools[0])
255 }
256 if !strings.Contains(tools[1].Result, "second-output") || !tools[1].IsError {
257 t.Fatalf("second call paired wrongly: %+v", tools[1])
258 }
259 if tools[2].Result != "" || timelineStatus(tools[2]) != "result unavailable" {
260 t.Fatalf("third call must stay unpaired: %+v", tools[2])
261 }
262}
263
264func TestTimelineKeepsOrphanResultVisible(t *testing.T) {
265 data := timelineData{Messages: []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{
266 provider.ToolResultBlock{CallID: "gone", Content: []provider.Content{provider.TextBlock{Text: "late"}}},
267 }}}}
268 entries := buildTimelineEntries(data)
269 if len(entries) != 1 || !strings.Contains(entries[0].Result, "late") || !strings.Contains(entries[0].Summary, "unpaired") {
270 t.Fatalf("orphan result = %+v", entries)
271 }
272}
273
274func TestTimelineNeverShowsOrExportsEncryptedReasoningOrSignatures(t *testing.T) {
275 data := timelineTestData()
276 data.Messages[1].Content = append([]provider.Content{
277 provider.ReasoningBlock{ID: "r1", Summary: "visible summary", Encrypted: "ENCRYPTED-SECRET-BLOB"},
278 provider.TextBlock{Text: "answer", ThoughtSignature: "SIGNATURE-SECRET"},
279 }, data.Messages[1].Content...)
280 view := newTimelineView()
281 view.Open(data)
282 var shown strings.Builder
283 for tab := 0; tab < timelineTabCount; tab++ {
284 view.tab = tab
285 for cursor := range buildTimelineEntries(data) {
286 view.cursor = cursor
287 shown.WriteString(strings.Join(view.Render(tui.Dark, 100, 40, data), "\n"))
288 }
289 }
290 path, err := view.Export(t.TempDir(), data)
291 if err != nil {
292 t.Fatal(err)
293 }
294 exported, err := os.ReadFile(path)
295 if err != nil {
296 t.Fatal(err)
297 }
298 for _, secret := range []string{"ENCRYPTED-SECRET-BLOB", "SIGNATURE-SECRET"} {
299 if strings.Contains(shown.String(), secret) || strings.Contains(string(exported), secret) {
300 t.Fatalf("%s leaked into the timeline", secret)
301 }
302 }
303 if !strings.Contains(string(exported), "visible summary") {
304 t.Fatal("reasoning summary missing from export")
305 }
306}
307
308func TestTimelineExportIsExclusiveAndPrivate(t *testing.T) {
309 if runtime.GOOS == "windows" {
310 t.Skip("POSIX permissions")
311 }
312 dir := filepath.Join(t.TempDir(), "exports")
313 view := newTimelineView()
314 path, err := view.Export(dir, timelineTestData())
315 if err != nil {
316 t.Fatal(err)
317 }
318 if info, err := os.Stat(dir); err != nil || info.Mode().Perm() != 0o700 {
319 t.Fatalf("export dir mode = %v (%v), want 700", info.Mode().Perm(), err)
320 }
321 // A pre-planted symlink at the target name must not be followed.
322 victim := filepath.Join(t.TempDir(), "victim")
323 if err := os.WriteFile(victim, []byte("keep"), 0o600); err != nil {
324 t.Fatal(err)
325 }
326 if err := os.Remove(path); err != nil {
327 t.Fatal(err)
328 }
329 if err := os.Symlink(victim, path); err != nil {
330 t.Skip("symlinks unavailable")
331 }
332 if _, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600); err == nil {
333 t.Fatal("exclusive open unexpectedly succeeded on an existing symlink")
334 }
335 if got, _ := os.ReadFile(victim); string(got) != "keep" {
336 t.Fatalf("victim overwritten: %q", got)
337 }
338}
339
340func TestTimelineSnapshotHidesHostInternalMessages(t *testing.T) {
341 ag := core.NewAgent(nil, "m", "system prompt", nil)
342 ag.SetMessages([]provider.Message{
343 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "real prompt"}}},
344 {Role: provider.RoleUser, Meta: map[string]string{goalContinueMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "hidden goal continuation"}}},
345 {Role: provider.RoleUser, Meta: map[string]string{autoCompactContinueMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "hidden compact continuation"}}},
346 {Role: provider.RoleUser, Meta: map[string]string{core.RepetitionGuardMetaKey: "true"}, Content: []provider.Content{provider.TextBlock{Text: "hidden repetition guard"}}},
347 })
348 i := &Interactive{agent: ag, timeline: newTimelineView()}
349 i.mu.Lock()
350 data := i.timelineDataLocked()
351 i.mu.Unlock()
352 if data.System != "system prompt" || len(data.Messages) != 1 {
353 t.Fatalf("snapshot = system %q, %d messages", data.System, len(data.Messages))
354 }
355 for _, e := range buildTimelineEntries(data) {
356 if strings.Contains(e.Summary, "hidden") {
357 t.Fatalf("internal message leaked: %+v", e)
358 }
359 }
360}
361
362func TestTimelineCacheRebuildsOnlyWhenTranscriptChanges(t *testing.T) {
363 ag := core.NewAgent(nil, "m", "sys", nil)
364 ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}})
365 i := &Interactive{agent: ag, timeline: newTimelineView()}
366 i.mu.Lock()
367 data := i.timelineDataLocked()
368 i.timeline.Open(data)
369 first := i.timeline.entries(data)
370 again := i.timeline.entries(i.timelineDataLocked())
371 i.mu.Unlock()
372 if &first[0] != &again[0] {
373 t.Fatal("unchanged transcript rebuilt the timeline")
374 }
375 ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "two"}}}})
376 i.mu.Lock()
377 updated := i.timeline.entries(i.timelineDataLocked())
378 i.mu.Unlock()
379 if !strings.Contains(updated[len(updated)-1].Summary, "two") {
380 t.Fatalf("stale timeline after transcript change: %+v", updated)
381 }
382}
383
384func TestTimelineRendersInsideFloatingPane(t *testing.T) {
385 data := timelineTestData()
386 i := &Interactive{timeline: newTimelineView()}
387 i.timeline.Open(data)
388 pane := tui.FloatingPaneMaxRect(100, 30)
389 lines := padDialogFrame(i.timeline.Render(tui.Dark, pane.ContentWidth(), pane.ContentHeight(), data))
390 title, body, _ := floatingDialogBody(lines)
391 if title != "timeline" {
392 t.Fatalf("pane title = %q, want timeline", title)
393 }
394 if len(body) > pane.ContentHeight() {
395 t.Fatalf("body has %d rows, pane content height is %d", len(body), pane.ContentHeight())
396 }
397 for _, row := range body {
398 if w := runewidth.StringWidth(stripANSIBytes(row)); w > pane.ContentWidth() {
399 t.Fatalf("row width %d exceeds pane %d: %q", w, pane.ContentWidth(), row)
400 }
401 }
402 // A narrow drawer must still fit.
403 narrow := tui.FloatingPaneMaxRect(40, 12)
404 for _, row := range i.timeline.Render(tui.Dark, narrow.ContentWidth(), narrow.ContentHeight(), data) {
405 if w := runewidth.StringWidth(stripANSIBytes(row)); w > narrow.ContentWidth() {
406 t.Fatalf("narrow row width %d exceeds %d: %q", w, narrow.ContentWidth(), row)
407 }
408 }
409}
410
411func TestTimelineKeysAreOwnedByTimelineAndCtrlCCloses(t *testing.T) {
412 i := NewInteractive(InteractiveConfig{Agent: core.NewAgent(nil, "m", "", nil)})
413 i.openTimeline()
414 if !i.timeline.Active() || !i.confirmChildActive() {
415 t.Fatal("timeline is not registered as an active overlay")
416 }
417 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'x'})
418 if i.ed.Value() != "" {
419 t.Fatalf("key leaked to the main editor: %q", i.ed.Value())
420 }
421 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlC})
422 if i.timeline.Active() {
423 t.Fatal("ctrl+c did not close the timeline")
424 }
425}
426
427func hostileTimelineData() timelineData {
428 const hostile = "a\x1b]0;evil-title\x07b\x1b[31mred\x1b[0m\tc\rd\x1b"
429 data := timelineData{
430 System: "sys " + hostile + "\nsecond\r\nthird\rfourth",
431 Tools: []provider.Tool{{Name: "bash", Schema: json.RawMessage(`{"type":"object"}`)}},
432 Messages: []provider.Message{
433 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: hostile + "\nmore"}}},
434 {Role: provider.RoleAssistant, Content: []provider.Content{
435 provider.ReasoningBlock{Summary: "think " + hostile},
436 provider.ToolCallBlock{ID: "c1", Name: "bash", Arguments: json.RawMessage(`{"command":"echo \u001b]0;evil-title\u0007 \t x\r y"}`)},
437 }},
438 {Role: provider.RoleTool, Content: []provider.Content{
439 provider.ToolResultBlock{CallID: "c1", Content: []provider.Content{provider.TextBlock{Text: hostile}}},
440 }},
441 },
442 }
443 return data
444}
445
446func TestTimelineSanitizesUntrustedText(t *testing.T) {
447 data := hostileTimelineData()
448 view := newTimelineView()
449 view.Open(data)
450 view.searching = true
451 view.filter = "q\x1b]0;evil-title\x07\tz"
452 entries := buildTimelineEntries(data)
453 view.filter = ""
454 for _, width := range []int{20, 60, 120} {
455 for tab := 0; tab < timelineTabCount; tab++ {
456 view.tab = tab
457 for cursor := range entries {
458 view.cursor = cursor
459 out := strings.Join(view.Render(tui.Dark, width, 30, data), "\n")
460 plain := stripANSIBytes(out)
461 // JSON payloads show control bytes as inert \uXXXX text, so
462 // only raw controls are checked there; the summary tab and the
463 // list rows must have the OSC payload removed entirely.
464 bad := []string{"\r", "\t", "\a", "\x1b"}
465 if tab == 0 {
466 bad = append(bad, "evil-title")
467 }
468 for _, b := range bad {
469 if strings.Contains(out, b) && b != "\x1b" || b == "\x1b" && strings.Contains(plain, b) {
470 t.Fatalf("width %d tab %d entry %d leaked %q:\n%q", width, tab, cursor, b, plain)
471 }
472 }
473 for _, row := range strings.Split(plain, "\n") {
474 if w := runewidth.StringWidth(row); w > width {
475 t.Fatalf("width %d tab %d entry %d: row width %d: %q", width, tab, cursor, w, row)
476 }
477 }
478 }
479 }
480 }
481 // A typed or pasted filter cannot inject control sequences either.
482 view.searching = true
483 view.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "x\x1b]0;evil-title\x07\ty"}, data)
484 view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\t'}, data)
485 view.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\r'}, data)
486 shown := stripANSIBytes(strings.Join(view.Render(tui.Dark, 80, 30, data), "\n"))
487 if strings.ContainsAny(view.filter, "\x1b\a\t\r") || strings.Contains(shown, "evil-title\x07") || strings.ContainsAny(shown, "\a\t\r") {
488 t.Fatalf("filter leaked control text: filter=%q", view.filter)
489 }
490}
491
492func TestTimelineBodyFitsPaneAtEveryHeight(t *testing.T) {
493 data := timelineTestData()
494 data.Messages = append(data.Messages, provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{
495 provider.ReasoningBlock{Summary: strings.Repeat("long reasoning line\n", 40)},
496 }})
497 view := newTimelineView()
498 view.Open(data)
499 view.notice = "exported to ~/x.json"
500 for _, searching := range []bool{false, true} {
501 view.searching = searching
502 for height := 1; height <= 40; height++ {
503 for _, width := range []int{12, 30, 78, 120} {
504 for tab := 0; tab < timelineTabCount; tab++ {
505 view.tab = tab
506 lines := padDialogFrame(view.Render(tui.Dark, width, height, data))
507 _, body, _ := floatingDialogBody(lines)
508 if len(body) > height {
509 t.Fatalf("height %d width %d tab %d: body has %d rows", height, width, tab, len(body))
510 }
511 for _, row := range body {
512 if w := runewidth.StringWidth(stripANSIBytes(row)); w > width {
513 t.Fatalf("height %d width %d: row width %d: %q", height, width, w, row)
514 }
515 }
516 }
517 }
518 }
519 }
520 // Tiny panes still show the selected event before optional chrome.
521 view.searching = false
522 view.tab = 0
523 lines := padDialogFrame(view.Render(tui.Dark, 80, 3, data))
524 _, body, _ := floatingDialogBody(lines)
525 if !strings.Contains(stripANSIBytes(strings.Join(body, "\n")), "REASONING") {
526 t.Fatalf("selected event missing from a 3-row pane: %q", body)
527 }
528}
529
530func TestTimelineSnapshotCacheTracksPromptContentAndSkipsCopies(t *testing.T) {
531 ag := core.NewAgent(nil, "m", "prompt-AAAA", core.Registry{})
532 ag.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}})
533 i := &Interactive{agent: ag, timeline: newTimelineView()}
534 snapshot := func() timelineData {
535 i.mu.Lock()
536 defer i.mu.Unlock()
537 return i.timelineDataLocked()
538 }
539
540 first := snapshot()
541 again := snapshot()
542 if &first.Messages[0] != &again.Messages[0] || !again.Est.ok {
543 t.Fatal("unchanged transcript and prompt were re-copied")
544 }
545 // A same-length system prompt edit changes neither the transcript
546 // revision nor the prompt length, yet must refresh the snapshot.
547 ag.SetSystemPrompt("prompt-BBBB")
548 if edited := snapshot(); edited.System != "prompt-BBBB" {
549 t.Fatalf("stale system prompt after same-length edit: %q", edited.System)
550 }
551 // Same for a tool whose schema changes at equal length.
552 ag.SetTools(core.Registry{"t": schemaTool{name: "t", schema: `{"a":1}`}})
553 if s := snapshot(); len(s.Tools) != 1 || string(s.Tools[0].Schema) != `{"a":1}` {
554 t.Fatalf("tools = %+v", s.Tools)
555 }
556 ag.SetTools(core.Registry{"t": schemaTool{name: "t", schema: `{"a":2}`}})
557 if s := snapshot(); string(s.Tools[0].Schema) != `{"a":2}` {
558 t.Fatalf("stale tool schema after same-length edit: %s", s.Tools[0].Schema)
559 }
560 // The context gauge is live and never cached.
561 i.mu.Lock()
562 i.lastCtxInput = 42
563 i.mu.Unlock()
564 if snapshot().ContextUsed != 42 {
565 t.Fatal("context usage was served from the cache")
566 }
567}
568
569type schemaTool struct{ name, schema string }
570
571func (t schemaTool) Name() string { return t.name }
572func (t schemaTool) Description() string { return "d" }
573func (t schemaTool) Schema() json.RawMessage { return json.RawMessage(t.schema) }
574func (t schemaTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
575 return core.ToolResult{}, nil
576}