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.thinkingExpanded = true
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}