// A live tool burst must settle once and invalidate its cache on reset and eviction. package tui import ( "fmt" "strings" "testing" "albedo/cli/internal/daemon" "github.com/charmbracelet/x/ansi" ) func traced(activities []daemon.ToolActivity, changes ...daemon.FileChange) HistoryEntry { return HistoryEntry{Kind: EntryTool, ToolName: "python", ToolResult: `{"status":"ok"}`, ToolTrace: &daemon.ToolTrace{Activities: activities, Changes: changes}} } func burstFixture() []HistoryEntry { ws := "/w/albedo/" return []HistoryEntry{ {Kind: EntryThinking, Text: "hm", ElapsedMs: 34_000}, traced([]daemon.ToolActivity{{Kind: "read", Target: ws + "cli/internal/tui/chat.go"}, {Kind: "read", Target: ws + "cli/internal/tui/transcript.go"}}), traced([]daemon.ToolActivity{{Kind: "run", Target: "cd cli && go test ./..."}}), traced([]daemon.ToolActivity{{Kind: "run", Target: "go test ./internal/tui"}}), traced([]daemon.ToolActivity{{Kind: "read", Target: ws + "priv/python/albedo_trace.py"}}, daemon.FileChange{Path: ws + "priv/python/albedo_trace.py", Kind: "diff", Added: 5, Removed: 2}), {Kind: EntryTool, ToolName: "python", ToolResult: `{"status":"error","error":"boom"}`, ToolArgs: map[string]any{"code": "1/0"}}, } } func TestBurstSettlesOnceProseEndsIt(t *testing.T) { m := newTestChatModel(t, &daemon.Session{ID: "s", Workspace: "/w/albedo"}) m.SetSize(120, 40) m.appendSettledEntry(HistoryEntry{Kind: EntryUser, Text: "go"}) settled := len(m.settledLines) for _, entry := range burstFixture()[:4] { m.appendSettledEntry(entry) } if len(m.settledLines) != settled { t.Fatal("an open burst wrote settled rows") } m.refreshViewportContent() if view := ansi.Strip(m.Viewport.View()); !strings.Contains(view, "ran go test ×2") { t.Fatalf("the open burst is not drawn: %q", view) } m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventMessage, Text: "done"}) joined := ansi.Strip(strings.Join(m.settledLines[settled:], "\n")) if strings.Count(joined, "ran go test") != 1 || !strings.Contains(joined, "done") || strings.Index(joined, "ran go test") > strings.Index(joined, "done") { t.Fatalf("burst did not settle once, before the prose: %q", joined) } m.rebuildSettledLines() if again := ansi.Strip(strings.Join(m.settledLines, "\n")); strings.Count(again, "ran go test") != 1 { t.Fatalf("a rebuild drew the burst %d times", strings.Count(again, "ran go test")) } } // The cached live frame renders exactly what collecting the same entries // renders, and is rebuilt only when one of the inputs in its key changes: // each step below mutates exactly one, and the render counter must move once. func TestCachedBurstMatchesCollectedBurst(t *testing.T) { m := newTestChatModel(t, &daemon.Session{ID: "s", Workspace: "/w/albedo"}) m.SetSize(120, 30) for _, entry := range burstFixture() { m.appendSettledEntry(entry) } m.refreshViewportContent() entries := m.History.Entries() burst := trailingBurst(entries, m.Flags) if len(burst) != len(burstFixture()) { t.Fatalf("trailing burst is %d entries, want %d", len(burst), len(burstFixture())) } want := strings.Join(m.Renderer.BurstBlock(entries[:len(entries)-len(burst)], burst, m.Flags), "\n") if got := strings.Join(m.burstRows, "\n"); got != want { t.Fatalf("cached frame diverged:\n%s\nwant:\n%s", got, want) } renders := m.burstRenders step := func(name string, mutate func(), want int) { mutate() m.refreshViewportContent() if got := m.burstRenders - renders; got != want { t.Fatalf("%s: frame rebuilt %d times, want %d", name, got, want) } renders = m.burstRenders } step("idle refresh", func() {}, 0) step("a settled entry", func() { m.appendSettledEntry(burstFixture()[0]) }, 1) step("width", func() { m.SetSize(100, 30) }, 1) // the frame depends on the flags (diffs render the changes), so each flip // redraws once; flipping back lands on the cached frame again step("flags there and back", func() { m.Flags.Tools = true; m.refreshViewportContent(); m.Flags.Tools = false }, 2) step("workspace", func() { m.Renderer.Workspace = "/w/other" }, 1) step("reset then the same entries", func() { m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventReset}) for _, entry := range burstFixture() { m.appendSettledEntry(entry) } }, 1) // same entry count after a reset, different content: only the epoch tells // the cache these are not the entries it rendered other := burstFixture() other[4].ToolTrace.Changes[0].Path = "/w/albedo/cli/internal/tui/other.go" step("reset then same-sized different entries", func() { m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventReset}) for _, entry := range other { m.appendSettledEntry(entry) } }, 1) m.SetSize(240, 30) // wide enough that the ran list is not counted away m.refreshViewportContent() entries = m.History.Entries() burst = trailingBurst(entries, m.Flags) want = strings.Join(m.Renderer.BurstBlock(entries[:len(entries)-len(burst)], burst, m.Flags), "\n") if got := strings.Join(m.burstRows, "\n"); got != want || !strings.Contains(got, "tui/other.go") { t.Fatalf("same-sized history after a reset kept the old frame:\n%s\nwant:\n%s", got, want) } // the same equivalence under the width and workspace the steps left behind entries = m.History.Entries() burst = trailingBurst(entries, m.Flags) want = strings.Join(m.Renderer.BurstBlock(entries[:len(entries)-len(burst)], burst, m.Flags), "\n") if strings.Join(m.burstRows, "\n") != want { t.Fatalf("after a reset the frame went stale:\n%s\nwant:\n%s", strings.Join(m.burstRows, "\n"), want) } } // Eviction swaps the head of history without changing its length, so the // cached frame must key on evictions too or the live burst freezes. func TestEvictionKeepsTheLiveBurstCurrent(t *testing.T) { m := newTestChatModel(t, &daemon.Session{ID: "s", Workspace: "/w"}) m.SetSize(120, 30) m.History = NewBoundedHistory(4, 1<<20) for i := range 8 { m.appendSettledEntry(traced([]daemon.ToolActivity{{Kind: "read", Target: fmt.Sprintf("/w/f%d.go", i)}})) m.refreshViewportContent() } rows := strings.Join(m.burstRows, "\n") if !strings.Contains(rows, "f7}") || strings.Contains(rows, "f0") || strings.Contains(rows, "f3") { t.Fatalf("live burst froze across eviction: %q", rows) } }