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 TestStableChatCacheTracksViewInvalidation(t *testing.T) {
69 i := &Interactive{
70 view: &tui.View{
71 Theme: tui.Dark,
72 Messages: []provider.Message{{
73 Role: provider.RoleUser,
74 Content: []provider.Content{provider.TextBlock{Text: "cache theme"}},
75 }},
76 MessagesRevision: 1,
77 },
78 }
79
80 i.mu.Lock()
81 before := strings.Join(i.stableChatRowsLocked(80), "\n")
82 i.view.Theme = tui.Light
83 i.view.InvalidateRenderCache()
84 after := strings.Join(i.stableChatRowsLocked(80), "\n")
85 i.mu.Unlock()
86
87 if before == after {
88 t.Fatal("stable chat cache reused rows after a theme invalidation")
89 }
90}
91
92func TestStableChatCacheRevealsMessageAfterStreamFlush(t *testing.T) {
93 const finalText = "final message revealed after paced flush"
94
95 agent := &core.Agent{}
96 agent.SetMessages([]provider.Message{
97 {
98 Role: provider.RoleUser,
99 Content: []provider.Content{provider.TextBlock{Text: "prompt"}},
100 },
101 {
102 Role: provider.RoleAssistant,
103 Content: []provider.Content{provider.TextBlock{Text: finalText}},
104 },
105 })
106 i := &Interactive{
107 agent: agent,
108 view: &tui.View{Theme: tui.Dark},
109 renderOutsideLock: true,
110 streamFlushPending: true,
111 }
112
113 revision := agent.Revision()
114 i.mu.Lock()
115 before := strings.Join(i.cachedChatLocked(80), "\n")
116 i.streamFlushPending = false
117 after := strings.Join(i.cachedChatLocked(80), "\n")
118 i.mu.Unlock()
119
120 if got := agent.Revision(); got != revision {
121 t.Fatalf("stream flush changed the transcript revision: got %d, want %d", got, revision)
122 }
123 if strings.Contains(before, finalText) {
124 t.Fatal("stable cache revealed the final message while stream flush was pending")
125 }
126 if !strings.Contains(after, finalText) {
127 t.Fatal("stable cache did not reveal the final message after stream flush")
128 }
129}
130
131func TestInteractiveToolProgressStormDoesNotInvalidate(t *testing.T) {
132 i := &Interactive{
133 dirty: make(chan struct{}, 1),
134 toolCalls: make(map[string]*tui.ToolCallView),
135 toolGate: make(map[string]int),
136 }
137 i.handleEventForPresentation(core.EvToolUseStart{ID: "storm", Name: "bash"})
138 select {
139 case <-i.dirty:
140 default:
141 t.Fatal("tool start did not invalidate the presentation")
142 }
143
144 const progressEvents = 4096
145 for n := 0; n < progressEvents; n++ {
146 i.handleEventForPresentation(core.EvToolProgress{
147 ID: "storm",
148 Text: strings.Repeat("progress payload ", 256),
149 })
150 }
151 select {
152 case <-i.dirty:
153 t.Fatal("invisible progress created presentation invalidation")
154 default:
155 }
156
157 result := strings.Repeat("completed tool output\n", 256)
158 i.handleEventForPresentation(core.EvToolResult{
159 ID: "storm",
160 Result: core.ToolResult{Content: []provider.Content{
161 provider.TextBlock{Text: result},
162 }},
163 })
164 select {
165 case <-i.dirty:
166 default:
167 t.Fatal("completed tool result did not invalidate the presentation")
168 }
169 if got := i.toolCalls["storm"].Result; got != result {
170 t.Fatalf("completed tool result was not retained: got %d bytes", len(got))
171 }
172 if got := i.toolCalls["storm"].Revision; got != 2 {
173 t.Fatalf("completed tool result revision: got %d, want 2", got)
174 }
175
176 view := &tui.View{Theme: tui.Dark, ExpandAll: false, ToolCalls: []tui.ToolCallView{*i.toolCalls["storm"]}}
177 rows := view.BuildLive(80)
178 if len(rows) == 0 {
179 t.Fatal("completed tool result produced no live frame rows")
180 }
181}