@@ -400,6 +400,146 @@ func TestAgenticFetchToolMessageItem_NestedChildInPlaceMutationBumpsParent(t *te
400400 "parent SetNestedTools must bump even when child pointers are unchanged")
401401}
402402
403+// requireNoBump asserts the supplied mutator leaves the item's
404+// Version() unchanged. The mutator runs once; an unexpected bump
405+// would force the F6 list memo to re-render an item whose output
406+// did not change, churning the cache.
407+func requireNoBump(t *testing.T, name string, item versionedItem, mutate func()) {
408+ t.Helper()
409+ before := item.Version()
410+ mutate()
411+ after := item.Version()
412+ require.Equalf(t, before, after,
413+ "%s must not bump Version() (before=%d, after=%d)", name, before, after)
414+}
415+
416+// TestBaseToolMessageItem_AnimateBumpsVersion is the spinner
417+// regression test for non-agent tools: while the tool is spinning,
418+// every anim.StepMsg whose ID matches the tool must bump Version()
419+// so the list-level cache invalidates and the next draw re-renders
420+// the advanced spinner frame. Foreign IDs must not bump (they would
421+// churn the cache on every frame), and a finished tool must not
422+// bump on any ID (the entry is frozen and stays frozen).
423+func TestBaseToolMessageItem_AnimateBumpsVersion(t *testing.T) {
424+ t.Parallel()
425+
426+ sty := styles.CharmtonePantera()
427+ tc := message.ToolCall{ID: "tc-spin", Name: "bash", Input: "{}", Finished: false}
428+ item := NewToolMessageItem(&sty, "msg", tc, nil, false)
429+ v := item.(versionedItem)
430+ a, ok := item.(Animatable)
431+ require.True(t, ok, "base tool message item must implement Animatable")
432+
433+ // Spinning + matching ID → bump.
434+ requireBump(t, "Animate[spinning,own ID]", v, func() {
435+ a.Animate(anim.StepMsg{ID: tc.ID})
436+ })
437+
438+ // Spinning + foreign ID → no bump. Routing this StepMsg here at
439+ // all would mean a future chat.Animate refactor; the item must
440+ // be defensive against it so we don't churn the list cache.
441+ requireNoBump(t, "Animate[spinning,foreign ID]", v, func() {
442+ a.Animate(anim.StepMsg{ID: "some-other-tool"})
443+ })
444+
445+ // Finished → no bump on any ID. The entry is frozen; a stray
446+ // bump would needlessly invalidate frozen entries.
447+ tcFinished := tc
448+ tcFinished.Finished = true
449+ item.SetToolCall(tcFinished)
450+ item.SetResult(&message.ToolResult{ToolCallID: tc.ID, Content: "ok"})
451+ require.True(t, item.Finished(), "tool must report Finished() once the result lands")
452+
453+ requireNoBump(t, "Animate[finished,own ID]", v, func() {
454+ a.Animate(anim.StepMsg{ID: tc.ID})
455+ })
456+ requireNoBump(t, "Animate[finished,foreign ID]", v, func() {
457+ a.Animate(anim.StepMsg{ID: "some-other-tool"})
458+ })
459+}
460+
461+// TestAgentToolMessageItem_AnimateBumpsVersion is the spinner
462+// regression test for agent tools. The parent must bump on both
463+// the parent-tick branch (msg.ID == parent.ID()) and the
464+// nested-tick branch (msg.ID == nested.ID()) because the list
465+// only checks the parent's version — nested tools are not list
466+// entries of their own. Unrelated IDs must not bump, and a parent
467+// with a result must not bump on any ID.
468+func TestAgentToolMessageItem_AnimateBumpsVersion(t *testing.T) {
469+ t.Parallel()
470+
471+ sty := styles.CharmtonePantera()
472+ parentTC := message.ToolCall{ID: "agent-parent", Name: "agent", Input: `{}`, Finished: false}
473+ parent := NewAgentToolMessageItem(&sty, parentTC, nil, false)
474+
475+ childTC := message.ToolCall{ID: "agent-child", Name: "bash", Input: `{}`, Finished: false}
476+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
477+ parent.AddNestedTool(child)
478+
479+ // Spinning + parent's own ID → parent bumps.
480+ requireBump(t, "Animate[spinning,parent ID]", parent, func() {
481+ parent.Animate(anim.StepMsg{ID: parentTC.ID})
482+ })
483+
484+ // Spinning + nested child ID → parent bumps. The list only
485+ // invalidates on the parent; without this the nested
486+ // spinner's frame would never reach the screen even though
487+ // the nested anim's step has advanced.
488+ requireBump(t, "Animate[spinning,nested ID]", parent, func() {
489+ parent.Animate(anim.StepMsg{ID: childTC.ID})
490+ })
491+
492+ // Spinning + unrelated ID → no bump.
493+ requireNoBump(t, "Animate[spinning,foreign ID]", parent, func() {
494+ parent.Animate(anim.StepMsg{ID: "unrelated"})
495+ })
496+
497+ // Once the parent has a result, neither branch bumps.
498+ parent.SetResult(&message.ToolResult{ToolCallID: parentTC.ID, Content: "done"})
499+ requireNoBump(t, "Animate[finished,parent ID]", parent, func() {
500+ parent.Animate(anim.StepMsg{ID: parentTC.ID})
501+ })
502+ requireNoBump(t, "Animate[finished,nested ID]", parent, func() {
503+ parent.Animate(anim.StepMsg{ID: childTC.ID})
504+ })
505+}
506+
507+// TestAgenticFetchToolMessageItem_AnimateBumpsVersion is the
508+// agentic-fetch counterpart of the agent-tool Animate bump test.
509+// Without an explicit override the embedded base Animate would
510+// drop nested-child StepMsgs at anim.Animate's ID check and never
511+// bump the parent on its own ticks; this test locks in the
512+// override.
513+func TestAgenticFetchToolMessageItem_AnimateBumpsVersion(t *testing.T) {
514+ t.Parallel()
515+
516+ sty := styles.CharmtonePantera()
517+ parentTC := message.ToolCall{ID: "fetch-parent", Name: "agentic_fetch", Input: `{}`, Finished: false}
518+ parent := NewAgenticFetchToolMessageItem(&sty, parentTC, nil, false)
519+
520+ childTC := message.ToolCall{ID: "fetch-child", Name: "fetch", Input: `{}`, Finished: false}
521+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
522+ parent.AddNestedTool(child)
523+
524+ requireBump(t, "Animate[spinning,parent ID]", parent, func() {
525+ parent.Animate(anim.StepMsg{ID: parentTC.ID})
526+ })
527+ requireBump(t, "Animate[spinning,nested ID]", parent, func() {
528+ parent.Animate(anim.StepMsg{ID: childTC.ID})
529+ })
530+ requireNoBump(t, "Animate[spinning,foreign ID]", parent, func() {
531+ parent.Animate(anim.StepMsg{ID: "unrelated"})
532+ })
533+
534+ parent.SetResult(&message.ToolResult{ToolCallID: parentTC.ID, Content: "done"})
535+ requireNoBump(t, "Animate[finished,parent ID]", parent, func() {
536+ parent.Animate(anim.StepMsg{ID: parentTC.ID})
537+ })
538+ requireNoBump(t, "Animate[finished,nested ID]", parent, func() {
539+ parent.Animate(anim.StepMsg{ID: childTC.ID})
540+ })
541+}
542+
403543// TestBaseToolMessageItem_FinishedTransition covers §4.5.1 for
404544// tools: a still-running tool reports Finished() == false; once the
405545// tool call is marked finished and a result lands, Finished()