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{
70 dirty: make(chan struct{}, 1),
71 toolCalls: make(map[string]*tui.ToolCallView),
72 toolGate: make(map[string]int),
73 }
74
75 i.handleEventForPresentation(core.EvToolUseStart{ID: "alpha", Name: "edit"})
76 alphaStart := i.toolCalls["alpha"].Revision
77 i.handleEventForPresentation(core.EvToolUseArgs{ID: "alpha", Delta: `{"path":"alpha.go"}`})
78 alphaArgs := i.toolCalls["alpha"].Revision
79 i.handleEventForPresentation(core.EvToolUseStart{ID: "beta", Name: "edit"})
80 betaStart := i.toolCalls["beta"].Revision
81 i.handleEventForPresentation(core.EvToolUseArgs{ID: "beta", Delta: `{"path":"beta.go"}`})
82 betaArgs := i.toolCalls["beta"].Revision
83
84 if alphaStart >= alphaArgs || alphaArgs >= betaStart || betaStart >= betaArgs {
85 t.Fatalf("tool revisions are not globally monotonic: alpha start=%d args=%d, beta start=%d args=%d",
86 alphaStart, alphaArgs, betaStart, betaArgs)
87 }
88}
89
90func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) {
91 scheduler := newLatestFrameScheduler()
92 i := &Interactive{
93 dirty: make(chan struct{}, 1),
94 toolCalls: make(map[string]*tui.ToolCallView),
95 toolGate: make(map[string]int),
96 }
97 i.renderScheduler.Store(scheduler)
98
99 i.handleEventForPresentation(core.EvToolUseStart{ID: "call", Name: "bash"})
100 i.handleEventForPresentation(core.EvToolUseArgs{ID: "call", Delta: `{"command":"pwd"}`})
101 i.handleEventForPresentation(core.EvToolUseEnd{ID: "call"})
102
103 if got := len(scheduler.wake); got != 0 {
104 t.Fatalf("tool events bypassed the main-loop redraw throttle: %d direct request", got)
105 }
106 select {
107 case <-i.dirty:
108 default:
109 t.Fatal("tool events did not wake the throttled redraw path")
110 }
111 select {
112 case <-i.dirty:
113 t.Fatal("tool event burst queued more than one throttled redraw")
114 default:
115 }
116}
117
118func TestStableChatCacheTracksViewInvalidation(t *testing.T) {
119 i := &Interactive{
120 view: &tui.View{
121 Theme: tui.Dark,
122 Messages: []provider.Message{{
123 Role: provider.RoleUser,
124 Content: []provider.Content{provider.TextBlock{Text: "cache theme"}},
125 }},
126 MessagesRevision: 1,
127 },
128 }
129
130 i.mu.Lock()
131 before := strings.Join(i.stableChatRowsLocked(80), "\n")
132 i.view.Theme = tui.Light
133 i.view.InvalidateRenderCache()
134 after := strings.Join(i.stableChatRowsLocked(80), "\n")
135 i.mu.Unlock()
136
137 if before == after {
138 t.Fatal("stable chat cache reused rows after a theme invalidation")
139 }
140}
141
142func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) {
143 const finalText = "final message revealed after paced flush"
144
145 agent := &core.Agent{}
146 agent.SetMessages([]provider.Message{
147 {
148 Role: provider.RoleUser,
149 Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
150 },
151 {
152 Role: provider.RoleAssistant,
153 Content: []provider.Content{provider.TextBlock{Text: finalText}},
154 },
155 })
156 i := &Interactive{
157 agent: agent,
158 view: &tui.View{Theme: tui.Dark},
159 renderOutsideLock: true,
160 streamFlushPending: true,
161 }
162
163 revision := agent.Revision()
164 i.mu.Lock()
165 before := strings.Join(i.cachedChatLocked(80), "\n")
166 i.streamFlushPending = false
167 after := strings.Join(i.cachedChatLocked(80), "\n")
168 i.mu.Unlock()
169
170 if got := agent.Revision(); got != revision {
171 t.Fatalf("stream flush changed the transcript revision: got %d, want %d", got, revision)
172 }
173 if strings.Contains(before, finalText) {
174 t.Fatal("stable cache revealed the final message while stream flush was pending")
175 }
176 if !strings.Contains(after, finalText) {
177 t.Fatal("stable cache did not reveal the final message after stream flush")
178 }
179}
180
181func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
182 i := &Interactive{
183 dirty: make(chan struct{}, 1),
184 toolCalls: make(map[string]*tui.ToolCallView),
185 toolGate: make(map[string]int),
186 }
187 i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"})
188 select {
189 case <-i.dirty:
190 default:
191 t.Fatal("tool start did not invalidate the presentation")
192 }
193
194 const progressEvents = 4096
195 for n := 0; n < progressEvents; n++ {
196 i.handleEventForPresentation(core.EvToolProgress{
197 ID: "storm",
198 Text: strings.Repeat("progress payload ", 256),
199 })
200 }
201 select {
202 case <-i.dirty:
203 t.Fatal("invisible progress created presentation invalidation")
204 default:
205 }
206
207 result := strings.Repeat("completed tool output\n", 256)
208 i.handleEventForPresentation(core.EvToolResult{
209 ID: "storm",
210 Result: core.ToolResult{Content: []provider.Content{
211 provider.TextBlock{Text: result},
212 }},
213 })
214 select {
215 case <-i.dirty:
216 default:
217 t.Fatal("completed tool result did not invalidate the presentation")
218 }
219 if got := i.toolCalls["storm"].Result; got != result {
220 t.Fatalf("completed tool result was not retained: got %d bytes", len(got))
221 }
222 if got := i.toolCalls["storm"].Revision; got != 2 {
223 t.Fatalf("completed tool result revision: got %d, want 2", got)
224 }
225
226 view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}}
227 rows := view.BuildLive(80)
228 if len(rows) == 0 {
229 t.Fatal("completed tool result produced no live frame rows")
230 }
231}