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 sty.Attachments.Skill,
87 )
88 msg := &message.Message{
89 ID: "u-mut",
90 Role: message.User,
91 Parts: []message.ContentPart{
92 message.TextContent{Text: "Hello"},
93 },
94 }
95 item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
96
97 requireBump(t, "SetFocused", item, func() {
98 item.SetFocused(true)
99 })
100 requireBump(t, "SetHighlight", item, func() {
101 item.SetHighlight(0, 0, 0, 3)
102 })
103}
104
105// TestAssistantInfoItem_VersionedAndFinished sanity-checks the
106// AssistantInfoItem wiring. The item carries only immutable data
107// after construction; we still assert Version() is callable and
108// Finished() returns true.
109func TestAssistantInfoItem_VersionedAndFinished(t *testing.T) {
110 t.Parallel()
111
112 sty := styles.CharmtonePantera()
113 cfg := &config.Config{}
114 msg := &message.Message{
115 ID: "info",
116 Role: message.Assistant,
117 Parts: []message.ContentPart{message.Finish{Reason: message.FinishReasonEndTurn, Time: time.Now().Unix()}},
118 }
119 item := NewAssistantInfoItem(&sty, msg, cfg, time.Unix(0, 0)).(*AssistantInfoItem)
120
121 require.True(t, item.Finished(), "AssistantInfoItem must be Finished()")
122 // Version() is callable and starts at zero.
123 require.Equal(t, uint64(0), item.Version())
124}
125
126// TestBaseToolMessageItem_MutatorsBumpVersion enumerates the base
127// tool item mutators. Specific tool types layer on top of this
128// base; the base bumps cover the shared mutator surface.
129func TestBaseToolMessageItem_MutatorsBumpVersion(t *testing.T) {
130 t.Parallel()
131
132 sty := styles.CharmtonePantera()
133 tc := message.ToolCall{ID: "tc1", Name: "bash", Input: "{}", Finished: false}
134 item := NewToolMessageItem(&sty, "msg", tc, nil, false)
135
136 v := item.(versionedItem)
137
138 requireBump(t, "SetFocused", v, func() {
139 if f, ok := item.(list.Focusable); ok {
140 f.SetFocused(true)
141 }
142 })
143 requireBump(t, "SetHighlight", v, func() {
144 if h, ok := item.(list.Highlightable); ok {
145 h.SetHighlight(0, 0, 0, 3)
146 }
147 })
148 requireBump(t, "SetToolCall", v, func() {
149 tc2 := tc
150 tc2.Input = `{"command":"echo"}`
151 item.SetToolCall(tc2)
152 })
153 requireBump(t, "SetResult", v, func() {
154 item.SetResult(&message.ToolResult{ToolCallID: "tc1", Content: "ok"})
155 })
156 requireBump(t, "SetStatus", v, func() {
157 item.SetStatus(ToolStatusSuccess)
158 })
159 requireBump(t, "ToggleExpanded", v, func() {
160 if e, ok := item.(Expandable); ok {
161 e.ToggleExpanded()
162 }
163 })
164 requireBump(t, "SetCompact", v, func() {
165 if c, ok := item.(Compactable); ok {
166 c.SetCompact(true)
167 }
168 })
169}
170
171// TestAssistantMessageItem_AnimateBumpsVersion covers the spinner
172// regression: while the assistant message is spinning, every
173// anim.StepMsg fed through Animate must bump Version() so the
174// list-level cache invalidates and the next draw re-renders the
175// advanced spinner frame. Without this bump the cached entry's
176// version stays put and the spinner appears frozen.
177func TestAssistantMessageItem_AnimateBumpsVersion(t *testing.T) {
178 t.Parallel()
179
180 sty := styles.CharmtonePantera()
181 streaming := &message.Message{
182 ID: "spin",
183 Role: message.Assistant,
184 Parts: []message.ContentPart{
185 message.ReasoningContent{Thinking: "thinking..."},
186 },
187 }
188 item := NewAssistantMessageItem(&sty, streaming).(*AssistantMessageItem)
189
190 requireBump(t, "Animate", item, func() {
191 item.Animate(anim.StepMsg{})
192 })
193
194 // A non-spinning item must not bump on Animate: the bump only
195 // makes sense while the spinner is live, and a stray bump on a
196 // finished item would needlessly invalidate frozen entries.
197 finished := &message.Message{
198 ID: "spin",
199 Role: message.Assistant,
200 Parts: []message.ContentPart{
201 message.TextContent{Text: "done"},
202 message.Finish{Reason: message.FinishReasonEndTurn, Time: testFinishTime},
203 },
204 }
205 item.SetMessage(finished)
206 require.True(t, item.Finished(), "item must report Finished() once the message finishes")
207 before := item.Version()
208 item.Animate(anim.StepMsg{})
209 require.Equal(t, before, item.Version(), "Animate must not bump Version() on a non-spinning item")
210}
211
212// TestAssistantMessageItem_FinishedTransition covers ยง4.5.1: a
213// streaming assistant message reports Finished() == false; once the
214// message reports IsFinished() and stops spinning, Finished() must
215// return true.
216func TestAssistantMessageItem_FinishedTransition(t *testing.T) {
217 t.Parallel()
218
219 sty := styles.CharmtonePantera()
220
221 // Streaming: no finish part, no content yet โ isSpinning == true.
222 streaming := &message.Message{
223 ID: "stream",
224 Role: message.Assistant,
225 Parts: []message.ContentPart{
226 message.ReasoningContent{Thinking: "thinking..."},
227 },
228 }
229 item := NewAssistantMessageItem(&sty, streaming).(*AssistantMessageItem)
230 require.False(t, item.Finished(), "streaming assistant message must not be Finished()")
231
232 // Finished with content.
233 finished := &message.Message{
234 ID: "stream",
235 Role: message.Assistant,
236 Parts: []message.ContentPart{
237 message.ReasoningContent{Thinking: "thinking", StartedAt: testStartedAt, FinishedAt: testFinishedAt},
238 message.TextContent{Text: "the answer"},
239 message.Finish{Reason: message.FinishReasonEndTurn, Time: testFinishTime},
240 },
241 }
242 item.SetMessage(finished)
243 require.True(t, item.Finished(), "finished assistant message must be Finished()")
244}
245
246// TestUserMessageItem_FinishedAlwaysTrue locks in the freezable
247// contract: user messages are never spinning.
248func TestUserMessageItem_FinishedAlwaysTrue(t *testing.T) {
249 t.Parallel()
250
251 sty := styles.CharmtonePantera()
252 r := attachments.NewRenderer(
253 sty.Attachments.Normal,
254 sty.Attachments.Deleting,
255 sty.Attachments.Image,
256 sty.Attachments.Text,
257 sty.Attachments.Skill,
258 )
259 msg := &message.Message{
260 ID: "u-fin",
261 Role: message.User,
262 Parts: []message.ContentPart{message.TextContent{Text: "hi"}},
263 }
264 item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
265 require.True(t, item.Finished())
266}
267
268// TestAgentToolMessageItem_NestedToolMutatorsBumpVersion covers B1:
269// the nested-tool mutators on AgentToolMessageItem must bump
270// Version() so the list cache invalidates frozen entries when a
271// nested tool is added or the slice changes. SetNestedTools always
272// bumps unconditionally โ the live update path in
273// internal/ui/model/ui.go mutates existing children in place and
274// then re-passes the same slice, so a pointer-equality dedupe would
275// hide observable child-render changes. AddNestedTool also always
276// observably mutates state and always bumps.
277func TestAgentToolMessageItem_NestedToolMutatorsBumpVersion(t *testing.T) {
278 t.Parallel()
279
280 sty := styles.CharmtonePantera()
281 parent := message.ToolCall{ID: "agent-parent", Name: "agent", Input: `{}`, Finished: false}
282 item := NewAgentToolMessageItem(&sty, parent, nil, false)
283
284 mkChild := func(id string) ToolMessageItem {
285 tc := message.ToolCall{ID: id, Name: "bash", Input: `{}`, Finished: false}
286 return NewToolMessageItem(&sty, "msg", tc, nil, false)
287 }
288
289 // AddNestedTool always bumps.
290 requireBump(t, "AddNestedTool", item, func() {
291 item.AddNestedTool(mkChild("c1"))
292 })
293
294 // SetNestedTools always bumps, even with a pointer-equal slice.
295 current := append([]ToolMessageItem(nil), item.NestedTools()...)
296 requireBump(t, "SetNestedTools[pointer-equal]", item, func() {
297 item.SetNestedTools(current)
298 })
299
300 // SetNestedTools with a different slice (extra element) bumps.
301 requireBump(t, "SetNestedTools[different]", item, func() {
302 item.SetNestedTools(append(current, mkChild("c2")))
303 })
304
305 // SetNestedTools to an empty slice from a non-empty state bumps.
306 requireBump(t, "SetNestedTools[empty]", item, func() {
307 item.SetNestedTools(nil)
308 })
309}
310
311// TestAgenticFetchToolMessageItem_NestedToolMutatorsBumpVersion is
312// the agentic-fetch counterpart to the agent-tool nested mutator
313// bump test above.
314func TestAgenticFetchToolMessageItem_NestedToolMutatorsBumpVersion(t *testing.T) {
315 t.Parallel()
316
317 sty := styles.CharmtonePantera()
318 parent := message.ToolCall{ID: "fetch-parent", Name: "agentic_fetch", Input: `{}`, Finished: false}
319 item := NewAgenticFetchToolMessageItem(&sty, parent, nil, false)
320
321 mkChild := func(id string) ToolMessageItem {
322 tc := message.ToolCall{ID: id, Name: "fetch", Input: `{}`, Finished: false}
323 return NewToolMessageItem(&sty, "msg", tc, nil, false)
324 }
325
326 requireBump(t, "AddNestedTool", item, func() {
327 item.AddNestedTool(mkChild("c1"))
328 })
329
330 current := append([]ToolMessageItem(nil), item.NestedTools()...)
331 requireBump(t, "SetNestedTools[pointer-equal]", item, func() {
332 item.SetNestedTools(current)
333 })
334
335 requireBump(t, "SetNestedTools[different]", item, func() {
336 item.SetNestedTools(append(current, mkChild("c2")))
337 })
338
339 requireBump(t, "SetNestedTools[empty]", item, func() {
340 item.SetNestedTools(nil)
341 })
342}
343
344// TestAgentToolMessageItem_NestedChildInPlaceMutationBumpsParent is
345// the T5 regression test: it mirrors the live update flow at
346// internal/ui/model/ui.go:1242-1281 where nested tool calls are
347// updated in place (SetToolCall / SetResult on the same child
348// pointers) and then the same slice is handed back to the parent
349// via SetNestedTools. The parent must still bump its version so
350// the list cache invalidates the parent's pre-rendered string and
351// the freshly-rendered child output becomes visible.
352func TestAgentToolMessageItem_NestedChildInPlaceMutationBumpsParent(t *testing.T) {
353 t.Parallel()
354
355 sty := styles.CharmtonePantera()
356 parent := message.ToolCall{ID: "agent-parent", Name: "agent", Input: `{}`, Finished: false}
357 item := NewAgentToolMessageItem(&sty, parent, nil, false)
358
359 childTC := message.ToolCall{ID: "c1", Name: "bash", Input: `{}`, Finished: false}
360 child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
361 item.AddNestedTool(child)
362
363 v0 := item.Version()
364 childVersionBefore := child.(versionedItem).Version()
365
366 // In-place mutate the existing child, exactly like the live
367 // flow in ui.go:1271-1278 does.
368 child.SetResult(&message.ToolResult{ToolCallID: "c1", Content: "ok"})
369 require.Greaterf(t, child.(versionedItem).Version(), childVersionBefore,
370 "child SetResult must bump child version")
371
372 // Hand the same slice back to the parent (pointers unchanged).
373 same := item.NestedTools()
374 item.SetNestedTools(same)
375 require.Greaterf(t, item.Version(), v0,
376 "parent SetNestedTools must bump even when child pointers are unchanged (in-place child mutation invalidates parent's pre-rendered output)")
377}
378
379// TestAgenticFetchToolMessageItem_NestedChildInPlaceMutationBumpsParent
380// is the agentic-fetch counterpart of the T5 regression test.
381func TestAgenticFetchToolMessageItem_NestedChildInPlaceMutationBumpsParent(t *testing.T) {
382 t.Parallel()
383
384 sty := styles.CharmtonePantera()
385 parent := message.ToolCall{ID: "fetch-parent", Name: "agentic_fetch", Input: `{}`, Finished: false}
386 item := NewAgenticFetchToolMessageItem(&sty, parent, nil, false)
387
388 childTC := message.ToolCall{ID: "c1", Name: "fetch", Input: `{}`, Finished: false}
389 child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
390 item.AddNestedTool(child)
391
392 v0 := item.Version()
393 childVersionBefore := child.(versionedItem).Version()
394
395 child.SetResult(&message.ToolResult{ToolCallID: "c1", Content: "ok"})
396 require.Greaterf(t, child.(versionedItem).Version(), childVersionBefore,
397 "child SetResult must bump child version")
398
399 same := item.NestedTools()
400 item.SetNestedTools(same)
401 require.Greaterf(t, item.Version(), v0,
402 "parent SetNestedTools must bump even when child pointers are unchanged")
403}
404
405// requireNoBump asserts the supplied mutator leaves the item's
406// Version() unchanged. The mutator runs once; an unexpected bump
407// would force the F6 list memo to re-render an item whose output
408// did not change, churning the cache.
409func requireNoBump(t *testing.T, name string, item versionedItem, mutate func()) {
410 t.Helper()
411 before := item.Version()
412 mutate()
413 after := item.Version()
414 require.Equalf(t, before, after,
415 "%s must not bump Version() (before=%d, after=%d)", name, before, after)
416}
417
418// TestBaseToolMessageItem_AnimateBumpsVersion is the spinner
419// regression test for non-agent tools: while the tool is spinning,
420// every anim.StepMsg whose ID matches the tool must bump Version()
421// so the list-level cache invalidates and the next draw re-renders
422// the advanced spinner frame. Foreign IDs must not bump (they would
423// churn the cache on every frame), and a finished tool must not
424// bump on any ID (the entry is frozen and stays frozen).
425func TestBaseToolMessageItem_AnimateBumpsVersion(t *testing.T) {
426 t.Parallel()
427
428 sty := styles.CharmtonePantera()
429 tc := message.ToolCall{ID: "tc-spin", Name: "bash", Input: "{}", Finished: false}
430 item := NewToolMessageItem(&sty, "msg", tc, nil, false)
431 v := item.(versionedItem)
432 a, ok := item.(Animatable)
433 require.True(t, ok, "base tool message item must implement Animatable")
434
435 // Spinning + matching ID โ bump.
436 requireBump(t, "Animate[spinning,own ID]", v, func() {
437 a.Animate(anim.StepMsg{ID: tc.ID})
438 })
439
440 // Spinning + foreign ID โ no bump. Routing this StepMsg here at
441 // all would mean a future chat.Animate refactor; the item must
442 // be defensive against it so we don't churn the list cache.
443 requireNoBump(t, "Animate[spinning,foreign ID]", v, func() {
444 a.Animate(anim.StepMsg{ID: "some-other-tool"})
445 })
446
447 // Finished โ no bump on any ID. The entry is frozen; a stray
448 // bump would needlessly invalidate frozen entries.
449 tcFinished := tc
450 tcFinished.Finished = true
451 item.SetToolCall(tcFinished)
452 item.SetResult(&message.ToolResult{ToolCallID: tc.ID, Content: "ok"})
453 require.True(t, item.Finished(), "tool must report Finished() once the result lands")
454
455 requireNoBump(t, "Animate[finished,own ID]", v, func() {
456 a.Animate(anim.StepMsg{ID: tc.ID})
457 })
458 requireNoBump(t, "Animate[finished,foreign ID]", v, func() {
459 a.Animate(anim.StepMsg{ID: "some-other-tool"})
460 })
461}
462
463// TestAgentToolMessageItem_AnimateBumpsVersion is the spinner
464// regression test for agent tools. The parent must bump on both
465// the parent-tick branch (msg.ID == parent.ID()) and the
466// nested-tick branch (msg.ID == nested.ID()) because the list
467// only checks the parent's version โ nested tools are not list
468// entries of their own. Unrelated IDs must not bump, and a parent
469// with a result must not bump on any ID.
470func TestAgentToolMessageItem_AnimateBumpsVersion(t *testing.T) {
471 t.Parallel()
472
473 sty := styles.CharmtonePantera()
474 parentTC := message.ToolCall{ID: "agent-parent", Name: "agent", Input: `{}`, Finished: false}
475 parent := NewAgentToolMessageItem(&sty, parentTC, nil, false)
476
477 childTC := message.ToolCall{ID: "agent-child", Name: "bash", Input: `{}`, Finished: false}
478 child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
479 parent.AddNestedTool(child)
480
481 // Spinning + parent's own ID โ parent bumps.
482 requireBump(t, "Animate[spinning,parent ID]", parent, func() {
483 parent.Animate(anim.StepMsg{ID: parentTC.ID})
484 })
485
486 // Spinning + nested child ID โ parent bumps. The list only
487 // invalidates on the parent; without this the nested
488 // spinner's frame would never reach the screen even though
489 // the nested anim's step has advanced.
490 requireBump(t, "Animate[spinning,nested ID]", parent, func() {
491 parent.Animate(anim.StepMsg{ID: childTC.ID})
492 })
493
494 // Spinning + unrelated ID โ no bump.
495 requireNoBump(t, "Animate[spinning,foreign ID]", parent, func() {
496 parent.Animate(anim.StepMsg{ID: "unrelated"})
497 })
498
499 // Once the parent has a result, neither branch bumps.
500 parent.SetResult(&message.ToolResult{ToolCallID: parentTC.ID, Content: "done"})
501 requireNoBump(t, "Animate[finished,parent ID]", parent, func() {
502 parent.Animate(anim.StepMsg{ID: parentTC.ID})
503 })
504 requireNoBump(t, "Animate[finished,nested ID]", parent, func() {
505 parent.Animate(anim.StepMsg{ID: childTC.ID})
506 })
507}
508
509// TestAgenticFetchToolMessageItem_AnimateBumpsVersion is the
510// agentic-fetch counterpart of the agent-tool Animate bump test.
511// Without an explicit override the embedded base Animate would
512// drop nested-child StepMsgs at anim.Animate's ID check and never
513// bump the parent on its own ticks; this test locks in the
514// override.
515func TestAgenticFetchToolMessageItem_AnimateBumpsVersion(t *testing.T) {
516 t.Parallel()
517
518 sty := styles.CharmtonePantera()
519 parentTC := message.ToolCall{ID: "fetch-parent", Name: "agentic_fetch", Input: `{}`, Finished: false}
520 parent := NewAgenticFetchToolMessageItem(&sty, parentTC, nil, false)
521
522 childTC := message.ToolCall{ID: "fetch-child", Name: "fetch", Input: `{}`, Finished: false}
523 child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
524 parent.AddNestedTool(child)
525
526 requireBump(t, "Animate[spinning,parent ID]", parent, func() {
527 parent.Animate(anim.StepMsg{ID: parentTC.ID})
528 })
529 requireBump(t, "Animate[spinning,nested ID]", parent, func() {
530 parent.Animate(anim.StepMsg{ID: childTC.ID})
531 })
532 requireNoBump(t, "Animate[spinning,foreign ID]", parent, func() {
533 parent.Animate(anim.StepMsg{ID: "unrelated"})
534 })
535
536 parent.SetResult(&message.ToolResult{ToolCallID: parentTC.ID, Content: "done"})
537 requireNoBump(t, "Animate[finished,parent ID]", parent, func() {
538 parent.Animate(anim.StepMsg{ID: parentTC.ID})
539 })
540 requireNoBump(t, "Animate[finished,nested ID]", parent, func() {
541 parent.Animate(anim.StepMsg{ID: childTC.ID})
542 })
543}
544
545// TestBaseToolMessageItem_FinishedTransition covers ยง4.5.1 for
546// tools: a still-running tool reports Finished() == false; once the
547// tool call is marked finished and a result lands, Finished()
548// returns true. Cancelled tools also become Finished.
549func TestBaseToolMessageItem_FinishedTransition(t *testing.T) {
550 t.Parallel()
551
552 sty := styles.CharmtonePantera()
553 tc := message.ToolCall{ID: "tc-fin", Name: "bash", Input: "{}", Finished: false}
554 item := NewToolMessageItem(&sty, "msg", tc, nil, false)
555 require.False(t, item.Finished(), "running tool must not be Finished()")
556
557 tcFinished := tc
558 tcFinished.Finished = true
559 item.SetToolCall(tcFinished)
560 item.SetResult(&message.ToolResult{ToolCallID: "tc-fin", Content: "ok"})
561 require.True(t, item.Finished(), "finished tool with result must be Finished()")
562
563 // Canceled tool with no result is also Finished.
564 tcCanceled := message.ToolCall{ID: "tc-cancel", Name: "bash", Input: "{}", Finished: false}
565 canceled := NewToolMessageItem(&sty, "msg", tcCanceled, nil, true)
566 require.True(t, canceled.Finished(), "canceled tool must be Finished()")
567}