assistant_section_cache_test.go

  1package chat
  2
  3import (
  4	"strings"
  5	"testing"
  6
  7	"github.com/charmbracelet/crush/internal/message"
  8	"github.com/charmbracelet/crush/internal/ui/styles"
  9	"github.com/stretchr/testify/require"
 10)
 11
 12// Fixed Unix timestamps for deterministic cache-equality tests. The
 13// thinking section's `extra` hash folds in ThinkingDuration, which
 14// in turn depends on (FinishedAt - StartedAt). Anchoring both
 15// timestamps removes any wall-clock dependency from the cache key
 16// so two builds across a second boundary still hit the cache.
 17const (
 18	testStartedAt  int64 = 1_700_000_000
 19	testFinishedAt int64 = 1_700_000_005
 20	testFinishTime int64 = 1_700_000_006
 21)
 22
 23// thinkingMessage builds an assistant message with a fixed reasoning
 24// content and an optional text content. When text is empty the
 25// message represents a still-thinking turn (matches IsThinking()).
 26// Both reasoning timestamps are anchored to fixed Unix seconds so
 27// ThinkingDuration is deterministic and cache-equality assertions
 28// don't depend on wall-clock time.
 29func thinkingMessage(id, thinking, text string) *message.Message {
 30	parts := []message.ContentPart{
 31		message.ReasoningContent{
 32			Thinking:   thinking,
 33			StartedAt:  testStartedAt,
 34			FinishedAt: testFinishedAt,
 35		},
 36	}
 37	if text != "" {
 38		parts = append(parts, message.TextContent{Text: text})
 39	}
 40	return &message.Message{
 41		ID:    id,
 42		Role:  message.Assistant,
 43		Parts: parts,
 44	}
 45}
 46
 47// errorMessage builds a finished assistant message whose finish part
 48// carries an error reason plus a custom message and details.
 49func errorMessage(id, errMsg, errDetails string) *message.Message {
 50	return &message.Message{
 51		ID:   id,
 52		Role: message.Assistant,
 53		Parts: []message.ContentPart{
 54			message.TextContent{Text: "partial output"},
 55			message.Finish{
 56				Reason:  message.FinishReasonError,
 57				Message: errMsg,
 58				Details: errDetails,
 59				Time:    testFinishTime,
 60			},
 61		},
 62	}
 63}
 64
 65// renderTwoSetMessages drives a SetMessage cycle and returns the
 66// section-cache identity (out string pointers via direct comparison
 67// of the cached fields). The test compares `out` strings; identical
 68// output across cycles is the cache-hit indicator we rely on.
 69type sectionSnapshot struct {
 70	thinking string
 71	content  string
 72	errSec   string
 73}
 74
 75func snapshot(a *AssistantMessageItem) sectionSnapshot {
 76	return sectionSnapshot{
 77		thinking: a.thinkingSec.out,
 78		content:  a.contentSec.out,
 79		errSec:   a.errorSec.out,
 80	}
 81}
 82
 83// TestAssistantSectionCache_ContentChangeDoesNotInvalidateThinking covers
 84// the central F4 invariant: streaming the main content through SetMessage
 85// must keep the cached thinking render intact, provided the inputs to
 86// the thinking section render (text, expanded flag, footer state) are
 87// unchanged. We seed an already-non-empty content so that IsThinking()
 88// is false on both renders — that's the steady streaming state where
 89// the thinking block has finished and content keeps growing.
 90func TestAssistantSectionCache_ContentChangeDoesNotInvalidateThinking(t *testing.T) {
 91	sty := styles.CharmtonePantera()
 92	thinking := "Step 1\nStep 2\nStep 3"
 93	msg := thinkingMessage("a1", thinking, "Initial answer.")
 94	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
 95
 96	const width = 71
 97
 98	_ = item.RawRender(width)
 99	first := snapshot(item)
100	require.NotEmpty(t, first.thinking, "thinking section must be populated after first render")
101
102	// Stream more content into the existing turn. Thinking text and
103	// footer state are byte-identical between the two renders.
104	updated := thinkingMessage("a1", thinking, "Initial answer. More streamed text.")
105	item.SetMessage(updated)
106	_ = item.RawRender(width)
107	second := snapshot(item)
108
109	require.Equal(t, first.thinking, second.thinking,
110		"content streaming must not invalidate the thinking section render")
111	require.NotEqual(t, first.content, second.content,
112		"content section must have been re-rendered")
113}
114
115// TestAssistantSectionCache_ThinkingChangeDoesNotInvalidateContent is the
116// mirror of the previous test: extending thinking text must not force a
117// re-render of the content section.
118func TestAssistantSectionCache_ThinkingChangeDoesNotInvalidateContent(t *testing.T) {
119	sty := styles.CharmtonePantera()
120	content := "Final answer goes here."
121	msg := thinkingMessage("a2", "Step 1", content)
122	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
123
124	const width = 73
125
126	_ = item.RawRender(width)
127	first := snapshot(item)
128	require.NotEmpty(t, first.content)
129
130	updated := thinkingMessage("a2", "Step 1\nStep 2", content)
131	item.SetMessage(updated)
132	_ = item.RawRender(width)
133	second := snapshot(item)
134
135	require.Equal(t, first.content, second.content,
136		"thinking streaming must not invalidate the content section render")
137	require.NotEqual(t, first.thinking, second.thinking,
138		"thinking text changed; thinking section must have re-rendered")
139}
140
141// TestAssistantSectionCache_HashKeyDiscrimination asserts that two
142// messages with different source text hash to different per-section
143// keys, and that messages with identical source text hit the cache.
144func TestAssistantSectionCache_HashKeyDiscrimination(t *testing.T) {
145	sty := styles.CharmtonePantera()
146	msgA := thinkingMessage("a3", "thinking A", "content A")
147	msgB := thinkingMessage("a3", "thinking B", "content B")
148
149	itemA := NewAssistantMessageItem(&sty, msgA).(*AssistantMessageItem)
150	itemB := NewAssistantMessageItem(&sty, msgB).(*AssistantMessageItem)
151
152	thinkSrcA, _ := itemA.thinkingKey()
153	thinkSrcB, _ := itemB.thinkingKey()
154	require.NotEqual(t, thinkSrcA, thinkSrcB,
155		"distinct thinking text must produce distinct FNV-64 source hashes")
156
157	contentSrcA, _ := itemA.contentKey()
158	contentSrcB, _ := itemB.contentKey()
159	require.NotEqual(t, contentSrcA, contentSrcB,
160		"distinct content text must produce distinct FNV-64 source hashes")
161
162	// Identical source text on a fresh item must produce the same
163	// hashes — keying invariant for cache hits.
164	itemAClone := NewAssistantMessageItem(&sty, thinkingMessage("a3", "thinking A", "content A")).(*AssistantMessageItem)
165	thinkSrcAClone, _ := itemAClone.thinkingKey()
166	contentSrcAClone, _ := itemAClone.contentKey()
167	require.Equal(t, thinkSrcA, thinkSrcAClone)
168	require.Equal(t, contentSrcA, contentSrcAClone)
169}
170
171// TestAssistantSectionCache_CloneRoundTrip guards the contract that
172// message.Clone() does not invalidate any section cache: re-keying off
173// the cloned message must produce identical hashes and the section
174// caches must serve byte-identical renders.
175func TestAssistantSectionCache_CloneRoundTrip(t *testing.T) {
176	sty := styles.CharmtonePantera()
177	orig := thinkingMessage("a4", "Reasoning step.", "Answer text.")
178	item := NewAssistantMessageItem(&sty, orig).(*AssistantMessageItem)
179
180	const width = 75
181	_ = item.RawRender(width)
182	first := snapshot(item)
183
184	cloned := orig.Clone()
185	item.SetMessage(&cloned)
186	_ = item.RawRender(width)
187	second := snapshot(item)
188
189	require.Equal(t, first.thinking, second.thinking, "clone must hit the thinking cache")
190	require.Equal(t, first.content, second.content, "clone must hit the content cache")
191}
192
193// TestAssistantSectionCache_ResizeInvalidatesAll asserts that a width
194// change forces a re-render of every section.
195func TestAssistantSectionCache_ResizeInvalidatesAll(t *testing.T) {
196	sty := styles.CharmtonePantera()
197	msg := errorMessage("a5", "boom", strings.Repeat("detail line\n", 5))
198	// errorMessage returns FinishReasonError; combine with thinking
199	// content so all three sections are exercised.
200	msg.Parts = append([]message.ContentPart{
201		message.ReasoningContent{
202			Thinking:   "Considering options.",
203			StartedAt:  testStartedAt,
204			FinishedAt: testFinishedAt,
205		},
206	}, msg.Parts...)
207	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
208
209	_ = item.RawRender(77)
210	first := snapshot(item)
211	require.NotEmpty(t, first.thinking)
212	require.NotEmpty(t, first.content)
213	require.NotEmpty(t, first.errSec)
214
215	_ = item.RawRender(117)
216	second := snapshot(item)
217
218	require.NotEqual(t, first.thinking, second.thinking, "resize must re-render the thinking section")
219	require.NotEqual(t, first.content, second.content, "resize must re-render the content section")
220	require.NotEqual(t, first.errSec, second.errSec, "resize must re-render the error section")
221}
222
223// TestAssistantSectionCache_ErrorIndependentOfThinkingAndContent guards
224// that the error section caches independently. Editing the error
225// message must not invalidate the other two sections, and editing the
226// content must not invalidate the error section.
227func TestAssistantSectionCache_ErrorIndependentOfThinkingAndContent(t *testing.T) {
228	sty := styles.CharmtonePantera()
229	build := func(thinking, content, errMsg, errDetails string) *message.Message {
230		return &message.Message{
231			ID:   "a6",
232			Role: message.Assistant,
233			Parts: []message.ContentPart{
234				message.ReasoningContent{
235					Thinking:   thinking,
236					StartedAt:  testStartedAt,
237					FinishedAt: testFinishedAt,
238				},
239				message.TextContent{Text: content},
240				message.Finish{
241					Reason:  message.FinishReasonError,
242					Message: errMsg,
243					Details: errDetails,
244					Time:    testFinishTime,
245				},
246			},
247		}
248	}
249
250	item := NewAssistantMessageItem(&sty, build("think", "content", "boom", "details")).(*AssistantMessageItem)
251	_ = item.RawRender(79)
252	first := snapshot(item)
253
254	// Change only the error text. Thinking and content caches must
255	// survive; error cache must miss and re-render.
256	item.SetMessage(build("think", "content", "different boom", "different details"))
257	_ = item.RawRender(79)
258	second := snapshot(item)
259
260	require.Equal(t, first.thinking, second.thinking, "error change must not invalidate thinking")
261	require.Equal(t, first.content, second.content, "error change must not invalidate content")
262	require.NotEqual(t, first.errSec, second.errSec, "error change must re-render the error section")
263
264	// Now change only the content; error cache must survive.
265	item.SetMessage(build("think", "different content", "different boom", "different details"))
266	_ = item.RawRender(79)
267	third := snapshot(item)
268
269	require.Equal(t, second.thinking, third.thinking)
270	require.NotEqual(t, second.content, third.content)
271	require.Equal(t, second.errSec, third.errSec, "content change must not invalidate the error section")
272}
273
274// TestAssistantSectionCache_PrefixCacheRespectsSectionChanges guards
275// the F3/F4 boundary: the prefix cache must invalidate when any
276// underlying section changes. We verify by comparing the F3-cached
277// Render output across SetMessage cycles.
278func TestAssistantSectionCache_PrefixCacheRespectsSectionChanges(t *testing.T) {
279	sty := styles.CharmtonePantera()
280	build := func(content string) *message.Message {
281		return &message.Message{
282			ID:   "a7",
283			Role: message.Assistant,
284			Parts: []message.ContentPart{
285				message.TextContent{Text: content},
286				message.Finish{Reason: message.FinishReasonEndTurn, Time: testFinishTime},
287			},
288		}
289	}
290
291	item := NewAssistantMessageItem(&sty, build("first content")).(*AssistantMessageItem)
292	item.SetFocused(true)
293
294	const width = 81
295	first := item.Render(width)
296
297	item.SetMessage(build("second content"))
298	second := item.Render(width)
299	require.NotEqual(t, first, second,
300		"prefix cache must invalidate when the content section changes")
301
302	// Re-set to the original content; the prefix cache should
303	// produce identical output again.
304	item.SetMessage(build("first content"))
305	third := item.Render(width)
306	require.Equal(t, first, third)
307}
308
309// TestAssistantSectionCache_ByteIdenticalToFreshRender asserts that the
310// F4 cached path produces the same bytes as a fresh-instance render of
311// the equivalent message — i.e. caching is invisible from the outside.
312// Drives a sequence of mutations (thinking change, content change,
313// finish) and compares every step against an independent item rendered
314// from scratch.
315func TestAssistantSectionCache_ByteIdenticalToFreshRender(t *testing.T) {
316	sty := styles.CharmtonePantera()
317	const width = 83
318
319	type step struct {
320		name string
321		msg  *message.Message
322	}
323	startedAt := testStartedAt
324	finishedAt := testFinishedAt
325	finishTime := testFinishTime
326	steps := []step{
327		{
328			name: "thinking-only",
329			msg: &message.Message{
330				ID: "iso", Role: message.Assistant,
331				Parts: []message.ContentPart{
332					message.ReasoningContent{Thinking: "first reasoning", StartedAt: startedAt},
333				},
334			},
335		},
336		{
337			name: "thinking-grew",
338			msg: &message.Message{
339				ID: "iso", Role: message.Assistant,
340				Parts: []message.ContentPart{
341					message.ReasoningContent{Thinking: "first reasoning more", StartedAt: startedAt},
342				},
343			},
344		},
345		{
346			name: "content-arrived",
347			msg: &message.Message{
348				ID: "iso", Role: message.Assistant,
349				Parts: []message.ContentPart{
350					message.ReasoningContent{Thinking: "first reasoning more", StartedAt: startedAt, FinishedAt: finishedAt},
351					message.TextContent{Text: "the answer"},
352				},
353			},
354		},
355		{
356			name: "finished-end-turn",
357			msg: &message.Message{
358				ID: "iso", Role: message.Assistant,
359				Parts: []message.ContentPart{
360					message.ReasoningContent{Thinking: "first reasoning more", StartedAt: startedAt, FinishedAt: finishedAt},
361					message.TextContent{Text: "the answer"},
362					message.Finish{Reason: message.FinishReasonEndTurn, Time: finishTime},
363				},
364			},
365		},
366	}
367
368	first := steps[0].msg.Clone()
369	cached := NewAssistantMessageItem(&sty, &first).(*AssistantMessageItem)
370	for _, s := range steps {
371		cached.SetMessage(s.msg)
372		freshMsg := s.msg.Clone()
373		fresh := NewAssistantMessageItem(&sty, &freshMsg).(*AssistantMessageItem)
374		require.Equal(t, fresh.RawRender(width), cached.RawRender(width),
375			"step %q: cached path must match fresh render byte-for-byte", s.name)
376	}
377}
378
379// TestAssistantSectionCache_PrefixCacheInvalidatesOnCompositionOnlyChange
380// guards the F3 prefix cache against composition-only changes:
381// flipping the finish reason from EndTurn to Canceled appends a
382// constant "Canceled" line via renderMessageContent, but no
383// section's own source text changes. The prefix cache must observe
384// the difference (compositionKey is folded into prefixCacheKey) and
385// the resulting bytes must differ. As a second guarantee, a fresh
386// item built with the same final state must produce byte-equal
387// output to the cached item — caching must never produce stale or
388// divergent renders.
389func TestAssistantSectionCache_PrefixCacheInvalidatesOnCompositionOnlyChange(t *testing.T) {
390	sty := styles.CharmtonePantera()
391	const width = 87
392
393	build := func(reason message.FinishReason) *message.Message {
394		return &message.Message{
395			ID:   "comp",
396			Role: message.Assistant,
397			Parts: []message.ContentPart{
398				message.TextContent{Text: "hi"},
399				message.Finish{Reason: reason, Time: testFinishTime},
400			},
401		}
402	}
403
404	item := NewAssistantMessageItem(&sty, build(message.FinishReasonEndTurn)).(*AssistantMessageItem)
405	endTurnOut := item.Render(width)
406
407	// Flip only the finish reason. Thinking is empty and content
408	// text is unchanged, so no section's source hash moves; only
409	// compositionKey shifts. The prefix cache must miss.
410	item.SetMessage(build(message.FinishReasonCanceled))
411	canceledOut := item.Render(width)
412	require.NotEqual(t, endTurnOut, canceledOut,
413		"prefix cache must invalidate on composition-only change (finish reason)")
414
415	// A fresh item built with the same final state must match the
416	// cached item byte-for-byte — caching is invisible from the
417	// outside and never serves stale output.
418	fresh := NewAssistantMessageItem(&sty, build(message.FinishReasonCanceled)).(*AssistantMessageItem)
419	require.Equal(t, fresh.Render(width), canceledOut,
420		"cached output must equal a fresh render of the same final state")
421}
422
423// TestAssistantSectionCache_ThinkingBoxHeightSurvivesCacheHit guards
424// click-detection geometry across thinking-section cache hits. The
425// thinking box height feeds HandleMouseClick; it is recomputed
426// inside renderThinking and must be restored from
427// assistantSection.aux when the thinking cache hits. We render once
428// to capture the original height, trigger a content-only change so
429// thinkingKey stays identical (thinking text, expanded flag, and
430// footer state all unchanged), render again, and assert the
431// thinkingBoxHeight field is preserved.
432func TestAssistantSectionCache_ThinkingBoxHeightSurvivesCacheHit(t *testing.T) {
433	sty := styles.CharmtonePantera()
434	const width = 71
435
436	thinking := strings.Join([]string{
437		"Considering the request.",
438		"Looking at the relevant files.",
439		"Drafting a plan.",
440		"Verifying constraints.",
441	}, "\n")
442	msg := thinkingMessage("hbox", thinking, "initial answer")
443	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
444	item.thinkingViewMode = thinkingFullExpanded
445
446	_ = item.RawRender(width)
447	originalHeight := item.thinkingBoxHeight
448	require.Greater(t, originalHeight, 0,
449		"thinking box height must be populated after first render")
450
451	// Stomp the field so a stale read (cache hit that fails to
452	// restore aux) is detectable. Then trigger a content-only
453	// change: thinkingKey is byte-identical between renders, so
454	// the thinking section cache must hit and restore the
455	// preserved height via assistantSection.aux.
456	item.thinkingBoxHeight = -1
457	updated := thinkingMessage("hbox", thinking, "initial answer with more streamed text")
458	item.SetMessage(updated)
459	_ = item.RawRender(width)
460
461	require.Equal(t, originalHeight, item.thinkingBoxHeight,
462		"thinkingBoxHeight must be preserved across thinking section cache hits "+
463			"so HandleMouseClick keeps targeting the right rows")
464}