1package chat
2
3import (
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.
19type 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).
28func 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().
39func 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.
77func 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.
108func 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.
128func 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.
176func 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.
215func 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.
247func 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.
275func 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.
312func 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.
350func 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.
379func 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.
407func 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}