version_bump_test.go

  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}