1package modes
2
3import (
4 "strings"
5 "sync"
6 "sync/atomic"
7 "testing"
8
9 "github.com/bnema/zut/packages/core"
10 "github.com/bnema/zut/packages/provider"
11 "github.com/bnema/zut/packages/tui"
12)
13
14func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
15 s := newLatestFrameScheduler()
16 started := make(chan struct{})
17 release := make(chan struct{})
18 type renderedFrame struct {
19 req renderRequest
20 version int64
21 }
22 frames := make(chan renderedFrame, 4)
23 done := make(chan struct{})
24 var startedOnce sync.Once
25 var latestVersion atomic.Int64
26 go func() {
27 s.run(func(req renderRequest) {
28 frames <- renderedFrame{req: req, version: latestVersion.Load()}
29 startedOnce.Do(func() { close(started) })
30 <-release
31 })
32 close(done)
33 }()
34
35 latestVersion.Store(1)
36 if !s.request(false, false) {
37 t.Fatal("initial render request was rejected")
38 }
39 first := <-frames
40 <-started
41 if first.req.clear {
42 t.Fatal("ordinary request unexpectedly requested a clear")
43 }
44 for n := 0; n < 1000; n++ {
45 latestVersion.Store(int64(n + 2))
46 if !s.request(false, false) {
47 t.Fatal("request rejected before shutdown")
48 }
49 }
50 close(release)
51 second := <-frames
52 if second.req.clear {
53 t.Fatal("coalesced ordinary request unexpectedly requested a clear")
54 }
55 if second.version != 1001 {
56 t.Fatalf("renderer did not observe newest state: got version %d", second.version)
57 }
58 s.stop()
59 <-done
60
61 select {
62 case <-frames:
63 t.Fatal("scheduler retained more than one pending frame")
64 default:
65 }
66}
67
68func TestToolRenderRevisionsAreGloballyMonotonic(t *testing.T) {
69 i := &Interactive{dirty: make(chan struct{}, 1), toolCalls: make(map[string]*tui.ToolCallView)}
70
71 i.handleEventForPresentation(core.EvToolUseStart{ID: "alpha", Name: "edit"})
72 alphaStart := i.toolCalls["alpha"].Revision
73 i.handleEventForPresentation(core.EvToolUseArgs{ID: "alpha", Delta: `{"path":"alpha.go"}`})
74 alphaArgs := i.toolCalls["alpha"].Revision
75 i.handleEventForPresentation(core.EvToolUseStart{ID: "beta", Name: "edit"})
76 betaStart := i.toolCalls["beta"].Revision
77 i.handleEventForPresentation(core.EvToolUseArgs{ID: "beta", Delta: `{"path":"beta.go"}`})
78 betaArgs := i.toolCalls["beta"].Revision
79
80 if alphaStart >= alphaArgs || alphaArgs >= betaStart || betaStart >= betaArgs {
81 t.Fatalf("tool revisions are not globally monotonic: alpha start=%d args=%d, beta start=%d args=%d",
82 alphaStart, alphaArgs, betaStart, betaArgs)
83 }
84}
85
86func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) {
87 scheduler := newLatestFrameScheduler()
88 i := &Interactive{dirty: make(chan struct{}, 1), toolCalls: make(map[string]*tui.ToolCallView)}
89 i.renderScheduler.Store(scheduler)
90
91 i.handleEventForPresentation(core.EvToolUseStart{ID: "call", Name: "bash"})
92 i.handleEventForPresentation(core.EvToolUseArgs{ID: "call", Delta: `{"command":"pwd"}`})
93 i.handleEventForPresentation(core.EvToolUseEnd{ID: "call"})
94
95 if got := len(scheduler.wake); got != 0 {
96 t.Fatalf("tool events bypassed the main-loop redraw throttle: %d direct request", got)
97 }
98 select {
99 case <-i.dirty:
100 default:
101 t.Fatal("tool events did not wake the throttled redraw path")
102 }
103 select {
104 case <-i.dirty:
105 t.Fatal("tool event burst queued more than one throttled redraw")
106 default:
107 }
108}
109
110func TestStableChatCacheTracksViewInvalidation(t *testing.T) {
111 i := &Interactive{
112 view: &tui.View{
113 Theme: tui.Dark,
114 Messages: []provider.Message{{
115 Role: provider.RoleUser,
116 Content: []provider.Content{provider.TextBlock{Text: "cache theme"}},
117 }},
118 MessagesRevision: 1,
119 },
120 }
121
122 i.mu.Lock()
123 before := strings.Join(i.stableChatRowsLocked(80), "\n")
124 i.view.Theme = tui.Light
125 i.view.InvalidateRenderCache()
126 after := strings.Join(i.stableChatRowsLocked(80), "\n")
127 i.mu.Unlock()
128
129 if before == after {
130 t.Fatal("stable chat cache reused rows after a theme invalidation")
131 }
132}
133
134func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) {
135 const finalText = "final message revealed after paced flush"
136
137 agent := &core.Agent{}
138 agent.SetMessages([]provider.Message{{
139 Role: provider.RoleUser,
140 Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
141 }})
142 i := &Interactive{
143 agent: agent,
144 view: &tui.View{Theme: tui.Dark},
145 renderOutsideLock: true,
146 }
147
148 i.mu.Lock()
149 i.startStreamLocked()
150 i.stream.Push(finalText)
151 i.stream.Finish()
152 i.mu.Unlock()
153 // The agent appends the finished reply while the pacer is still busy.
154 agent.SetMessages(append(agent.Messages(), provider.Message{
155 Role: provider.RoleAssistant,
156 Content: []provider.Content{provider.TextBlock{Text: finalText}},
157 }))
158
159 i.mu.Lock()
160 before := strings.Join(i.cachedChatLocked(80), "\n")
161 for i.stream.Active() {
162 i.stream.Tick()
163 }
164 after := strings.Join(i.cachedChatLocked(80), "\n")
165 i.mu.Unlock()
166
167 if strings.Contains(before, finalText) {
168 t.Fatal("stable cache revealed the final message while stream flush was pending")
169 }
170 if !strings.Contains(after, finalText) {
171 t.Fatal("stable cache did not reveal the final message after stream flush")
172 }
173}
174
175func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
176 i := &Interactive{dirty: make(chan struct{}, 1), toolCalls: make(map[string]*tui.ToolCallView)}
177 i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"})
178 select {
179 case <-i.dirty:
180 default:
181 t.Fatal("tool start did not invalidate the presentation")
182 }
183
184 const progressEvents = 4096
185 for n := 0; n < progressEvents; n++ {
186 i.handleEventForPresentation(core.EvToolProgress{
187 ID: "storm",
188 Text: strings.Repeat("progress payload ", 256),
189 })
190 }
191 select {
192 case <-i.dirty:
193 t.Fatal("invisible progress created presentation invalidation")
194 default:
195 }
196
197 result := strings.Repeat("completed tool output\n", 256)
198 i.handleEventForPresentation(core.EvToolResult{
199 ID: "storm",
200 Result: core.ToolResult{Content: []provider.Content{
201 provider.TextBlock{Text: result},
202 }},
203 })
204 select {
205 case <-i.dirty:
206 default:
207 t.Fatal("completed tool result did not invalidate the presentation")
208 }
209 if got := i.toolCalls["storm"].Result; got != result {
210 t.Fatalf("completed tool result was not retained: got %d bytes", len(got))
211 }
212 if got := i.toolCalls["storm"].Revision; got != 2 {
213 t.Fatalf("completed tool result revision: got %d, want 2", got)
214 }
215
216 view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}}
217 rows := view.BuildLive(80)
218 if len(rows) == 0 {
219 t.Fatal("completed tool result produced no live frame rows")
220 }
221}