Skip to content

Commit 088d3ba

Browse files
committed
tests and docs for global animation coordinator
Signed-off-by: Christopher Petito <chrisjpetito@gmail.com>
1 parent 2244486 commit 088d3ba

5 files changed

Lines changed: 424 additions & 31 deletions

File tree

AGENTS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,27 @@ return events
674674
- `AssistantMessage` - Agent response (text + tool calls)
675675
- `ToolMessage` - Tool execution result
676676

677+
### TUI Animation Coordination
678+
679+
All animated TUI components share a single tick stream via `pkg/tui/animation/`.
680+
681+
```go
682+
// Init: register and maybe start tick
683+
func (m *MyComponent) Init() tea.Cmd {
684+
return animation.StartTickIfFirst()
685+
}
686+
687+
// Update: handle tick
688+
if tick, ok := msg.(animation.TickMsg); ok {
689+
m.frame = tick.Frame
690+
}
691+
692+
// When done: unregister
693+
animation.Unregister()
694+
```
695+
696+
**Rules:** Only call from `Init()`/`Update()`, never from `Cmd` goroutines. Always `Unregister()` when animation stops.
697+
677698
## File Locations and Patterns
678699

679700
### Key Package Structure

docs/CONTRIBUTING.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,6 @@ This agent is an *expert Golang developer specializing in the Docker `cagent` mu
8686
Ask it anything about `cagent`. It can be questions about the current code or about
8787
improvements to the code. It can also fix issues and implement new features!
8888

89-
## Project Architecture
90-
91-
More info about the architecture behind `cagent` can be found [here](/docs/architecture.md)
92-
9389
## Add a new model provider
9490

9591
More details on how to add a new model provider can be found in [PROVIDERS.md](/docs/PROVIDERS.md)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package animation
2+
3+
import (
4+
"sync"
5+
"testing"
6+
"time"
7+
8+
tea "charm.land/bubbletea/v2"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func resetGlobalCoordinator(t *testing.T) {
14+
t.Helper()
15+
globalCoordinator.mu.Lock()
16+
globalCoordinator.active = 0
17+
globalCoordinator.frame = 0
18+
globalCoordinator.mu.Unlock()
19+
}
20+
21+
func getActiveCount() int32 {
22+
globalCoordinator.mu.Lock()
23+
defer globalCoordinator.mu.Unlock()
24+
return globalCoordinator.active
25+
}
26+
27+
func runCmdWithTimeout(t *testing.T, cmd tea.Cmd) tea.Msg {
28+
t.Helper()
29+
require.NotNil(t, cmd)
30+
31+
done := make(chan tea.Msg, 1)
32+
go func() {
33+
done <- cmd()
34+
}()
35+
36+
timeout := time.NewTimer(250 * time.Millisecond)
37+
defer timeout.Stop()
38+
39+
select {
40+
case msg := <-done:
41+
return msg
42+
case <-timeout.C:
43+
t.Fatal("timed out waiting for tick command")
44+
}
45+
46+
return nil
47+
}
48+
49+
func runTickCmd(t *testing.T, cmd tea.Cmd) TickMsg {
50+
t.Helper()
51+
52+
msg := runCmdWithTimeout(t, cmd)
53+
tickMsg, ok := msg.(TickMsg)
54+
require.True(t, ok)
55+
56+
return tickMsg
57+
}
58+
59+
func TestGlobalCoordinatorLifecycle(t *testing.T) {
60+
resetGlobalCoordinator(t)
61+
62+
// No active animations = no tick
63+
require.Nil(t, StartTick())
64+
65+
// First registration starts tick
66+
firstTick := StartTickIfFirst()
67+
tickMsg := runTickCmd(t, firstTick)
68+
assert.Equal(t, 1, tickMsg.Frame)
69+
70+
// Subsequent tick continues
71+
nextTick := StartTick()
72+
tickMsg = runTickCmd(t, nextTick)
73+
assert.Equal(t, 2, tickMsg.Frame)
74+
75+
// Second StartTickIfFirst registers but doesn't return tick (not first)
76+
cmd := StartTickIfFirst()
77+
require.Nil(t, cmd)
78+
assert.Equal(t, int32(2), getActiveCount())
79+
80+
// Unregister one, still active
81+
Unregister()
82+
require.True(t, HasActive())
83+
require.NotNil(t, StartTick())
84+
85+
// Unregister last one
86+
Unregister()
87+
require.False(t, HasActive())
88+
require.Nil(t, StartTick())
89+
}
90+
91+
func TestUnregisterNeverGoesNegative(t *testing.T) {
92+
resetGlobalCoordinator(t)
93+
94+
// Multiple unregisters when already at 0
95+
Unregister()
96+
Unregister()
97+
Unregister()
98+
99+
assert.Equal(t, int32(0), getActiveCount())
100+
require.False(t, HasActive())
101+
}
102+
103+
func TestConcurrentRegisterUnregister(t *testing.T) {
104+
resetGlobalCoordinator(t)
105+
106+
const goroutines = 100
107+
const opsPerGoroutine = 100
108+
109+
var wg sync.WaitGroup
110+
wg.Add(goroutines * 2)
111+
112+
// Half goroutines do register
113+
for range goroutines {
114+
go func() {
115+
defer wg.Done()
116+
for range opsPerGoroutine {
117+
Register()
118+
}
119+
}()
120+
}
121+
122+
// Half goroutines do unregister
123+
for range goroutines {
124+
go func() {
125+
defer wg.Done()
126+
for range opsPerGoroutine {
127+
Unregister()
128+
}
129+
}()
130+
}
131+
132+
wg.Wait()
133+
134+
// Should have exactly goroutines * opsPerGoroutine registers
135+
// minus whatever unregisters succeeded (capped at 0)
136+
// Final count should be >= 0
137+
count := getActiveCount()
138+
assert.GreaterOrEqual(t, count, int32(0), "active count should never be negative")
139+
}
140+
141+
func TestConcurrentStartTickIfFirst(t *testing.T) {
142+
resetGlobalCoordinator(t)
143+
144+
const goroutines = 50
145+
var wg sync.WaitGroup
146+
wg.Add(goroutines)
147+
148+
cmds := make(chan tea.Cmd, goroutines)
149+
150+
// Many goroutines race to be "first"
151+
for range goroutines {
152+
go func() {
153+
defer wg.Done()
154+
cmd := StartTickIfFirst()
155+
cmds <- cmd
156+
}()
157+
}
158+
159+
wg.Wait()
160+
close(cmds)
161+
162+
// Count non-nil commands (ticks started)
163+
ticksStarted := 0
164+
for cmd := range cmds {
165+
if cmd != nil {
166+
ticksStarted++
167+
}
168+
}
169+
170+
// Exactly one should have started the tick
171+
assert.Equal(t, 1, ticksStarted, "exactly one goroutine should start the tick")
172+
// All should have registered
173+
assert.Equal(t, int32(goroutines), getActiveCount())
174+
}

pkg/tui/components/messages/messages_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/docker/cagent/pkg/chat"
1414
"github.com/docker/cagent/pkg/session"
1515
"github.com/docker/cagent/pkg/tools"
16+
"github.com/docker/cagent/pkg/tui/animation"
1617
"github.com/docker/cagent/pkg/tui/components/reasoningblock"
1718
"github.com/docker/cagent/pkg/tui/core/layout"
1819
"github.com/docker/cagent/pkg/tui/service"
@@ -665,3 +666,154 @@ func TestRenderCacheInvalidatesOnChildUpdate(t *testing.T) {
665666
assert.Contains(t, view2, "frame-1")
666667
assert.NotEqual(t, view1, view2, "View should change after Update with non-nil child cmd")
667668
}
669+
670+
func TestRenderCacheInvalidatesOnAnimationTickWithAnimatedContent(t *testing.T) {
671+
t.Parallel()
672+
673+
sessionState := &service.SessionState{}
674+
m := NewScrollableView(80, 24, sessionState).(*model)
675+
m.SetSize(80, 24)
676+
677+
// Add a running tool call which has a spinner (animated content)
678+
toolMsg := types.ToolCallMessage("root", tools.ToolCall{
679+
ID: "call-1",
680+
Function: tools.FunctionCall{Name: "running_tool", Arguments: `{}`},
681+
}, tools.Tool{Name: "running_tool", Description: "A running tool"}, types.ToolStatusRunning)
682+
m.messages = append(m.messages, toolMsg)
683+
m.views = append(m.views, m.createToolCallView(toolMsg))
684+
m.renderDirty = true
685+
686+
// First render
687+
view1 := m.View()
688+
require.Contains(t, view1, "running_tool")
689+
690+
// Clear the dirty flag to simulate cached state
691+
m.renderDirty = false
692+
693+
// Send animation tick - should invalidate cache because we have animated content
694+
m.Update(animation.TickMsg{Frame: 1})
695+
696+
// Cache should be marked dirty
697+
assert.True(t, m.renderDirty, "renderDirty should be true after animation tick with animated content")
698+
}
699+
700+
func TestRenderCacheNotInvalidatedOnAnimationTickWithoutAnimatedContent(t *testing.T) {
701+
t.Parallel()
702+
703+
sessionState := &service.SessionState{}
704+
m := NewScrollableView(80, 24, sessionState).(*model)
705+
m.SetSize(80, 24)
706+
707+
// Add a completed tool call (no spinner - not animated)
708+
toolMsg := types.ToolCallMessage("root", tools.ToolCall{
709+
ID: "call-1",
710+
Function: tools.FunctionCall{Name: "completed_tool", Arguments: `{}`},
711+
}, tools.Tool{Name: "completed_tool", Description: "A completed tool"}, types.ToolStatusCompleted)
712+
m.messages = append(m.messages, toolMsg)
713+
m.views = append(m.views, m.createToolCallView(toolMsg))
714+
m.renderDirty = true
715+
716+
// First render
717+
view1 := m.View()
718+
require.Contains(t, view1, "completed_tool")
719+
720+
// Clear the dirty flag to simulate cached state
721+
m.renderDirty = false
722+
723+
// Send animation tick - should NOT invalidate cache because no animated content
724+
m.Update(animation.TickMsg{Frame: 1})
725+
726+
// Cache should still be clean (not dirty)
727+
assert.False(t, m.renderDirty, "renderDirty should remain false after animation tick without animated content")
728+
}
729+
730+
func TestHasAnimatedContent(t *testing.T) {
731+
t.Parallel()
732+
733+
tests := []struct {
734+
name string
735+
setupFunc func(m *model)
736+
wantAnimated bool
737+
}{
738+
{
739+
name: "empty model",
740+
setupFunc: func(_ *model) {},
741+
wantAnimated: false,
742+
},
743+
{
744+
name: "spinner message",
745+
setupFunc: func(m *model) {
746+
msg := types.Spinner()
747+
m.messages = append(m.messages, msg)
748+
m.views = append(m.views, m.createMessageView(msg))
749+
},
750+
wantAnimated: true,
751+
},
752+
{
753+
name: "loading message",
754+
setupFunc: func(m *model) {
755+
msg := types.Loading("Loading...")
756+
m.messages = append(m.messages, msg)
757+
m.views = append(m.views, m.createMessageView(msg))
758+
},
759+
wantAnimated: true,
760+
},
761+
{
762+
name: "pending tool call",
763+
setupFunc: func(m *model) {
764+
toolMsg := types.ToolCallMessage("root", tools.ToolCall{
765+
ID: "call-1",
766+
Function: tools.FunctionCall{Name: "pending_tool", Arguments: `{}`},
767+
}, tools.Tool{Name: "pending_tool"}, types.ToolStatusPending)
768+
m.messages = append(m.messages, toolMsg)
769+
m.views = append(m.views, m.createToolCallView(toolMsg))
770+
},
771+
wantAnimated: true,
772+
},
773+
{
774+
name: "running tool call",
775+
setupFunc: func(m *model) {
776+
toolMsg := types.ToolCallMessage("root", tools.ToolCall{
777+
ID: "call-1",
778+
Function: tools.FunctionCall{Name: "running_tool", Arguments: `{}`},
779+
}, tools.Tool{Name: "running_tool"}, types.ToolStatusRunning)
780+
m.messages = append(m.messages, toolMsg)
781+
m.views = append(m.views, m.createToolCallView(toolMsg))
782+
},
783+
wantAnimated: true,
784+
},
785+
{
786+
name: "completed tool call",
787+
setupFunc: func(m *model) {
788+
toolMsg := types.ToolCallMessage("root", tools.ToolCall{
789+
ID: "call-1",
790+
Function: tools.FunctionCall{Name: "completed_tool", Arguments: `{}`},
791+
}, tools.Tool{Name: "completed_tool"}, types.ToolStatusCompleted)
792+
m.messages = append(m.messages, toolMsg)
793+
m.views = append(m.views, m.createToolCallView(toolMsg))
794+
},
795+
wantAnimated: false,
796+
},
797+
{
798+
name: "assistant message",
799+
setupFunc: func(m *model) {
800+
msg := types.Agent(types.MessageTypeAssistant, "root", "Hello")
801+
m.messages = append(m.messages, msg)
802+
m.views = append(m.views, m.createMessageView(msg))
803+
},
804+
wantAnimated: false,
805+
},
806+
}
807+
808+
for _, tt := range tests {
809+
t.Run(tt.name, func(t *testing.T) {
810+
t.Parallel()
811+
sessionState := &service.SessionState{}
812+
m := NewScrollableView(80, 24, sessionState).(*model)
813+
m.SetSize(80, 24)
814+
tt.setupFunc(m)
815+
got := m.hasAnimatedContent()
816+
assert.Equal(t, tt.wantAnimated, got)
817+
})
818+
}
819+
}

0 commit comments

Comments
 (0)