@@ -0,0 +1,425 @@
1+package chat
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/charmbracelet/crush/internal/config"
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/anim"
10+ "github.com/charmbracelet/crush/internal/ui/attachments"
11+ "github.com/charmbracelet/crush/internal/ui/list"
12+ "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// versionedItem is the cross-cutting interface every chat item type
17+// must satisfy under F6: every documented mutator must bump the
18+// shared version counter so the list-level memo invalidates.
19+type versionedItem interface {
20+ list.Item
21+ Version() uint64
22+}
23+
24+// requireBump asserts that the supplied mutator advances the item's
25+// Version(). The mutator runs once; an absent bump is a regression
26+// (a finished item would keep serving stale frozen output to the
27+// list cache).
28+func requireBump(t *testing.T, name string, item versionedItem, mutate func()) {
29+ t.Helper()
30+ before := item.Version()
31+ mutate()
32+ after := item.Version()
33+ require.Greaterf(t, after, before, "%s must bump Version() (before=%d, after=%d)", name, before, after)
34+}
35+
36+// TestAssistantMessageItem_MutatorsBumpVersion enumerates every
37+// documented mutator on AssistantMessageItem and asserts each one
38+// advances Version().
39+func TestAssistantMessageItem_MutatorsBumpVersion(t *testing.T) {
40+ t.Parallel()
41+
42+ sty := styles.CharmtonePantera()
43+ build := func(thinking, content string) *message.Message {
44+ parts := []message.ContentPart{
45+ message.ReasoningContent{
46+ Thinking: thinking,
47+ StartedAt: testStartedAt,
48+ FinishedAt: testFinishedAt,
49+ },
50+ }
51+ if content != "" {
52+ parts = append(parts, message.TextContent{Text: content})
53+ }
54+ return &message.Message{ID: "a-mut", Role: message.Assistant, Parts: parts}
55+ }
56+
57+ item := NewAssistantMessageItem(&sty, build("thinking", "content")).(*AssistantMessageItem)
58+
59+ requireBump(t, "SetMessage", item, func() {
60+ item.SetMessage(build("thinking", "more content"))
61+ })
62+ requireBump(t, "SetFocused", item, func() {
63+ item.SetFocused(true)
64+ })
65+ requireBump(t, "SetHighlight", item, func() {
66+ item.SetHighlight(0, 0, 0, 5)
67+ })
68+ // ToggleExpanded only mutates state when there is non-empty
69+ // thinking text — which the build helper provides.
70+ requireBump(t, "ToggleExpanded", item, func() {
71+ item.ToggleExpanded()
72+ })
73+}
74+
75+// TestUserMessageItem_MutatorsBumpVersion enumerates UserMessageItem
76+// mutators.
77+func TestUserMessageItem_MutatorsBumpVersion(t *testing.T) {
78+ t.Parallel()
79+
80+ sty := styles.CharmtonePantera()
81+ r := attachments.NewRenderer(
82+ sty.Attachments.Normal,
83+ sty.Attachments.Deleting,
84+ sty.Attachments.Image,
85+ sty.Attachments.Text,
86+ )
87+ msg := &message.Message{
88+ ID: "u-mut",
89+ Role: message.User,
90+ Parts: []message.ContentPart{
91+ message.TextContent{Text: "Hello"},
92+ },
93+ }
94+ item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
95+
96+ requireBump(t, "SetFocused", item, func() {
97+ item.SetFocused(true)
98+ })
99+ requireBump(t, "SetHighlight", item, func() {
100+ item.SetHighlight(0, 0, 0, 3)
101+ })
102+}
103+
104+// TestAssistantInfoItem_VersionedAndFinished sanity-checks the
105+// AssistantInfoItem wiring. The item carries only immutable data
106+// after construction; we still assert Version() is callable and
107+// Finished() returns true.
108+func TestAssistantInfoItem_VersionedAndFinished(t *testing.T) {
109+ t.Parallel()
110+
111+ sty := styles.CharmtonePantera()
112+ cfg := &config.Config{}
113+ msg := &message.Message{
114+ ID: "info",
115+ Role: message.Assistant,
116+ Parts: []message.ContentPart{message.Finish{Reason: message.FinishReasonEndTurn, Time: time.Now().Unix()}},
117+ }
118+ item := NewAssistantInfoItem(&sty, msg, cfg, time.Unix(0, 0)).(*AssistantInfoItem)
119+
120+ require.True(t, item.Finished(), "AssistantInfoItem must be Finished()")
121+ // Version() is callable and starts at zero.
122+ require.Equal(t, uint64(0), item.Version())
123+}
124+
125+// TestBaseToolMessageItem_MutatorsBumpVersion enumerates the base
126+// tool item mutators. Specific tool types layer on top of this
127+// base; the base bumps cover the shared mutator surface.
128+func TestBaseToolMessageItem_MutatorsBumpVersion(t *testing.T) {
129+ t.Parallel()
130+
131+ sty := styles.CharmtonePantera()
132+ tc := message.ToolCall{ID: "tc1", Name: "bash", Input: "{}", Finished: false}
133+ item := NewToolMessageItem(&sty, "msg", tc, nil, false)
134+
135+ v := item.(versionedItem)
136+
137+ requireBump(t, "SetFocused", v, func() {
138+ if f, ok := item.(list.Focusable); ok {
139+ f.SetFocused(true)
140+ }
141+ })
142+ requireBump(t, "SetHighlight", v, func() {
143+ if h, ok := item.(list.Highlightable); ok {
144+ h.SetHighlight(0, 0, 0, 3)
145+ }
146+ })
147+ requireBump(t, "SetToolCall", v, func() {
148+ tc2 := tc
149+ tc2.Input = `{"command":"echo"}`
150+ item.SetToolCall(tc2)
151+ })
152+ requireBump(t, "SetResult", v, func() {
153+ item.SetResult(&message.ToolResult{ToolCallID: "tc1", Content: "ok"})
154+ })
155+ requireBump(t, "SetStatus", v, func() {
156+ item.SetStatus(ToolStatusSuccess)
157+ })
158+ requireBump(t, "ToggleExpanded", v, func() {
159+ if e, ok := item.(Expandable); ok {
160+ e.ToggleExpanded()
161+ }
162+ })
163+ requireBump(t, "SetCompact", v, func() {
164+ if c, ok := item.(Compactable); ok {
165+ c.SetCompact(true)
166+ }
167+ })
168+}
169+
170+// TestAssistantMessageItem_AnimateBumpsVersion covers the spinner
171+// regression: while the assistant message is spinning, every
172+// anim.StepMsg fed through Animate must bump Version() so the
173+// list-level cache invalidates and the next draw re-renders the
174+// advanced spinner frame. Without this bump the cached entry's
175+// version stays put and the spinner appears frozen.
176+func TestAssistantMessageItem_AnimateBumpsVersion(t *testing.T) {
177+ t.Parallel()
178+
179+ sty := styles.CharmtonePantera()
180+ streaming := &message.Message{
181+ ID: "spin",
182+ Role: message.Assistant,
183+ Parts: []message.ContentPart{
184+ message.ReasoningContent{Thinking: "thinking..."},
185+ },
186+ }
187+ item := NewAssistantMessageItem(&sty, streaming).(*AssistantMessageItem)
188+
189+ requireBump(t, "Animate", item, func() {
190+ item.Animate(anim.StepMsg{})
191+ })
192+
193+ // A non-spinning item must not bump on Animate: the bump only
194+ // makes sense while the spinner is live, and a stray bump on a
195+ // finished item would needlessly invalidate frozen entries.
196+ finished := &message.Message{
197+ ID: "spin",
198+ Role: message.Assistant,
199+ Parts: []message.ContentPart{
200+ message.TextContent{Text: "done"},
201+ message.Finish{Reason: message.FinishReasonEndTurn, Time: testFinishTime},
202+ },
203+ }
204+ item.SetMessage(finished)
205+ require.True(t, item.Finished(), "item must report Finished() once the message finishes")
206+ before := item.Version()
207+ item.Animate(anim.StepMsg{})
208+ require.Equal(t, before, item.Version(), "Animate must not bump Version() on a non-spinning item")
209+}
210+
211+// TestAssistantMessageItem_FinishedTransition covers §4.5.1: a
212+// streaming assistant message reports Finished() == false; once the
213+// message reports IsFinished() and stops spinning, Finished() must
214+// return true.
215+func TestAssistantMessageItem_FinishedTransition(t *testing.T) {
216+ t.Parallel()
217+
218+ sty := styles.CharmtonePantera()
219+
220+ // Streaming: no finish part, no content yet — isSpinning == true.
221+ streaming := &message.Message{
222+ ID: "stream",
223+ Role: message.Assistant,
224+ Parts: []message.ContentPart{
225+ message.ReasoningContent{Thinking: "thinking..."},
226+ },
227+ }
228+ item := NewAssistantMessageItem(&sty, streaming).(*AssistantMessageItem)
229+ require.False(t, item.Finished(), "streaming assistant message must not be Finished()")
230+
231+ // Finished with content.
232+ finished := &message.Message{
233+ ID: "stream",
234+ Role: message.Assistant,
235+ Parts: []message.ContentPart{
236+ message.ReasoningContent{Thinking: "thinking", StartedAt: testStartedAt, FinishedAt: testFinishedAt},
237+ message.TextContent{Text: "the answer"},
238+ message.Finish{Reason: message.FinishReasonEndTurn, Time: testFinishTime},
239+ },
240+ }
241+ item.SetMessage(finished)
242+ require.True(t, item.Finished(), "finished assistant message must be Finished()")
243+}
244+
245+// TestUserMessageItem_FinishedAlwaysTrue locks in the freezable
246+// contract: user messages are never spinning.
247+func TestUserMessageItem_FinishedAlwaysTrue(t *testing.T) {
248+ t.Parallel()
249+
250+ sty := styles.CharmtonePantera()
251+ r := attachments.NewRenderer(
252+ sty.Attachments.Normal,
253+ sty.Attachments.Deleting,
254+ sty.Attachments.Image,
255+ sty.Attachments.Text,
256+ )
257+ msg := &message.Message{
258+ ID: "u-fin",
259+ Role: message.User,
260+ Parts: []message.ContentPart{message.TextContent{Text: "hi"}},
261+ }
262+ item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
263+ require.True(t, item.Finished())
264+}
265+
266+// TestAgentToolMessageItem_NestedToolMutatorsBumpVersion covers B1:
267+// the nested-tool mutators on AgentToolMessageItem must bump
268+// Version() so the list cache invalidates frozen entries when a
269+// nested tool is added or the slice changes. SetNestedTools always
270+// bumps unconditionally — the live update path in
271+// internal/ui/model/ui.go mutates existing children in place and
272+// then re-passes the same slice, so a pointer-equality dedupe would
273+// hide observable child-render changes. AddNestedTool also always
274+// observably mutates state and always bumps.
275+func TestAgentToolMessageItem_NestedToolMutatorsBumpVersion(t *testing.T) {
276+ t.Parallel()
277+
278+ sty := styles.CharmtonePantera()
279+ parent := message.ToolCall{ID: "agent-parent", Name: "agent", Input: `{}`, Finished: false}
280+ item := NewAgentToolMessageItem(&sty, parent, nil, false)
281+
282+ mkChild := func(id string) ToolMessageItem {
283+ tc := message.ToolCall{ID: id, Name: "bash", Input: `{}`, Finished: false}
284+ return NewToolMessageItem(&sty, "msg", tc, nil, false)
285+ }
286+
287+ // AddNestedTool always bumps.
288+ requireBump(t, "AddNestedTool", item, func() {
289+ item.AddNestedTool(mkChild("c1"))
290+ })
291+
292+ // SetNestedTools always bumps, even with a pointer-equal slice.
293+ current := append([]ToolMessageItem(nil), item.NestedTools()...)
294+ requireBump(t, "SetNestedTools[pointer-equal]", item, func() {
295+ item.SetNestedTools(current)
296+ })
297+
298+ // SetNestedTools with a different slice (extra element) bumps.
299+ requireBump(t, "SetNestedTools[different]", item, func() {
300+ item.SetNestedTools(append(current, mkChild("c2")))
301+ })
302+
303+ // SetNestedTools to an empty slice from a non-empty state bumps.
304+ requireBump(t, "SetNestedTools[empty]", item, func() {
305+ item.SetNestedTools(nil)
306+ })
307+}
308+
309+// TestAgenticFetchToolMessageItem_NestedToolMutatorsBumpVersion is
310+// the agentic-fetch counterpart to the agent-tool nested mutator
311+// bump test above.
312+func TestAgenticFetchToolMessageItem_NestedToolMutatorsBumpVersion(t *testing.T) {
313+ t.Parallel()
314+
315+ sty := styles.CharmtonePantera()
316+ parent := message.ToolCall{ID: "fetch-parent", Name: "agentic_fetch", Input: `{}`, Finished: false}
317+ item := NewAgenticFetchToolMessageItem(&sty, parent, nil, false)
318+
319+ mkChild := func(id string) ToolMessageItem {
320+ tc := message.ToolCall{ID: id, Name: "fetch", Input: `{}`, Finished: false}
321+ return NewToolMessageItem(&sty, "msg", tc, nil, false)
322+ }
323+
324+ requireBump(t, "AddNestedTool", item, func() {
325+ item.AddNestedTool(mkChild("c1"))
326+ })
327+
328+ current := append([]ToolMessageItem(nil), item.NestedTools()...)
329+ requireBump(t, "SetNestedTools[pointer-equal]", item, func() {
330+ item.SetNestedTools(current)
331+ })
332+
333+ requireBump(t, "SetNestedTools[different]", item, func() {
334+ item.SetNestedTools(append(current, mkChild("c2")))
335+ })
336+
337+ requireBump(t, "SetNestedTools[empty]", item, func() {
338+ item.SetNestedTools(nil)
339+ })
340+}
341+
342+// TestAgentToolMessageItem_NestedChildInPlaceMutationBumpsParent is
343+// the T5 regression test: it mirrors the live update flow at
344+// internal/ui/model/ui.go:1242-1281 where nested tool calls are
345+// updated in place (SetToolCall / SetResult on the same child
346+// pointers) and then the same slice is handed back to the parent
347+// via SetNestedTools. The parent must still bump its version so
348+// the list cache invalidates the parent's pre-rendered string and
349+// the freshly-rendered child output becomes visible.
350+func TestAgentToolMessageItem_NestedChildInPlaceMutationBumpsParent(t *testing.T) {
351+ t.Parallel()
352+
353+ sty := styles.CharmtonePantera()
354+ parent := message.ToolCall{ID: "agent-parent", Name: "agent", Input: `{}`, Finished: false}
355+ item := NewAgentToolMessageItem(&sty, parent, nil, false)
356+
357+ childTC := message.ToolCall{ID: "c1", Name: "bash", Input: `{}`, Finished: false}
358+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
359+ item.AddNestedTool(child)
360+
361+ v0 := item.Version()
362+ childVersionBefore := child.(versionedItem).Version()
363+
364+ // In-place mutate the existing child, exactly like the live
365+ // flow in ui.go:1271-1278 does.
366+ child.SetResult(&message.ToolResult{ToolCallID: "c1", Content: "ok"})
367+ require.Greaterf(t, child.(versionedItem).Version(), childVersionBefore,
368+ "child SetResult must bump child version")
369+
370+ // Hand the same slice back to the parent (pointers unchanged).
371+ same := item.NestedTools()
372+ item.SetNestedTools(same)
373+ require.Greaterf(t, item.Version(), v0,
374+ "parent SetNestedTools must bump even when child pointers are unchanged (in-place child mutation invalidates parent's pre-rendered output)")
375+}
376+
377+// TestAgenticFetchToolMessageItem_NestedChildInPlaceMutationBumpsParent
378+// is the agentic-fetch counterpart of the T5 regression test.
379+func TestAgenticFetchToolMessageItem_NestedChildInPlaceMutationBumpsParent(t *testing.T) {
380+ t.Parallel()
381+
382+ sty := styles.CharmtonePantera()
383+ parent := message.ToolCall{ID: "fetch-parent", Name: "agentic_fetch", Input: `{}`, Finished: false}
384+ item := NewAgenticFetchToolMessageItem(&sty, parent, nil, false)
385+
386+ childTC := message.ToolCall{ID: "c1", Name: "fetch", Input: `{}`, Finished: false}
387+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
388+ item.AddNestedTool(child)
389+
390+ v0 := item.Version()
391+ childVersionBefore := child.(versionedItem).Version()
392+
393+ child.SetResult(&message.ToolResult{ToolCallID: "c1", Content: "ok"})
394+ require.Greaterf(t, child.(versionedItem).Version(), childVersionBefore,
395+ "child SetResult must bump child version")
396+
397+ same := item.NestedTools()
398+ item.SetNestedTools(same)
399+ require.Greaterf(t, item.Version(), v0,
400+ "parent SetNestedTools must bump even when child pointers are unchanged")
401+}
402+
403+// TestBaseToolMessageItem_FinishedTransition covers §4.5.1 for
404+// tools: a still-running tool reports Finished() == false; once the
405+// tool call is marked finished and a result lands, Finished()
406+// returns true. Cancelled tools also become Finished.
407+func TestBaseToolMessageItem_FinishedTransition(t *testing.T) {
408+ t.Parallel()
409+
410+ sty := styles.CharmtonePantera()
411+ tc := message.ToolCall{ID: "tc-fin", Name: "bash", Input: "{}", Finished: false}
412+ item := NewToolMessageItem(&sty, "msg", tc, nil, false)
413+ require.False(t, item.Finished(), "running tool must not be Finished()")
414+
415+ tcFinished := tc
416+ tcFinished.Finished = true
417+ item.SetToolCall(tcFinished)
418+ item.SetResult(&message.ToolResult{ToolCallID: "tc-fin", Content: "ok"})
419+ require.True(t, item.Finished(), "finished tool with result must be Finished()")
420+
421+ // Canceled tool with no result is also Finished.
422+ tcCanceled := message.ToolCall{ID: "tc-cancel", Name: "bash", Input: "{}", Finished: false}
423+ canceled := NewToolMessageItem(&sty, "msg", tcCanceled, nil, true)
424+ require.True(t, canceled.Finished(), "canceled tool must be Finished()")
425+}