返回 DeepSeek-Reasonix
coalesce_test.go
根目录 / internal / event / coalesce_test.go
1 package event
2
3 import (
4 "errors"
5 "strings"
6 "sync"
7 "testing"
8 "time"
9
10 "reasonix/internal/evidence"
11 )
12
13 type checkedRecordSink struct {
14 coalesceRecordSink
15 err error
16 }
17
18 func (s *checkedRecordSink) EmitChecked(e Event) error {
19 if s.err != nil && e.Kind == ToolDispatch {
20 return s.err
21 }
22 s.Emit(e)
23 return nil
24 }
25
26 type coalesceRecordSink struct {
27 mu sync.Mutex
28 events []Event
29 readiness int
30 turns int
31 recovery int
32 workspace int
33 runBudget int
34 }
35
36 type blockingCapabilitySink struct {
37 entered chan struct{}
38 release chan struct{}
39 done chan struct{}
40 mu sync.Mutex
41 order []string
42 }
43
44 func (s *blockingCapabilitySink) Emit(e Event) {
45 if e.Text == "lead" {
46 close(s.entered)
47 <-s.release
48 }
49 s.mu.Lock()
50 s.order = append(s.order, e.Text)
51 s.mu.Unlock()
52 }
53
54 func (s *blockingCapabilitySink) RecordReadinessAudit(evidence.ReadinessAudit) {
55 s.mu.Lock()
56 s.order = append(s.order, "audit")
57 s.mu.Unlock()
58 close(s.done)
59 }
60
61 func (s *coalesceRecordSink) Emit(e Event) {
62 s.mu.Lock()
63 defer s.mu.Unlock()
64 s.events = append(s.events, e)
65 }
66
67 func (s *coalesceRecordSink) RecordReadinessAudit(evidence.ReadinessAudit) {
68 s.mu.Lock()
69 defer s.mu.Unlock()
70 s.readiness++
71 }
72
73 func (s *coalesceRecordSink) RecordTurnCompletion() {
74 s.mu.Lock()
75 defer s.mu.Unlock()
76 s.turns++
77 }
78
79 func (s *coalesceRecordSink) RecordProtocolRecovery(ProtocolRecoveryAudit) {
80 s.mu.Lock()
81 defer s.mu.Unlock()
82 s.recovery++
83 }
84
85 func (s *coalesceRecordSink) RecordWorkspaceMutation(WorkspaceMutation) {
86 s.mu.Lock()
87 defer s.mu.Unlock()
88 s.workspace++
89 }
90
91 func (s *coalesceRecordSink) RecordRunBudget(RunBudgetSample) {
92 s.mu.Lock()
93 defer s.mu.Unlock()
94 s.runBudget++
95 }
96
97 func (s *coalesceRecordSink) snapshot() []Event {
98 s.mu.Lock()
99 defer s.mu.Unlock()
100 return append([]Event(nil), s.events...)
101 }
102
103 func TestCoalesceFirstDeltaForwardsImmediately(t *testing.T) {
104 inner := &coalesceRecordSink{}
105 c := Coalesce(inner, time.Hour)
106 c.Emit(Event{Kind: Text, Text: "hello"})
107 got := inner.snapshot()
108 if len(got) != 1 || got[0].Text != "hello" {
109 t.Fatalf("first delta must forward immediately, got %+v", got)
110 }
111 }
112
113 func TestCoalesceMergesBurstAndFlushesOnBarrier(t *testing.T) {
114 inner := &coalesceRecordSink{}
115 c := Coalesce(inner, time.Hour)
116 c.Emit(Event{Kind: Reasoning, Text: "a"}) // leading edge
117 c.Emit(Event{Kind: Reasoning, Text: "b"})
118 c.Emit(Event{Kind: Reasoning, Text: "c"})
119 c.Emit(Event{Kind: ToolDispatch, Tool: Tool{ID: "t1", Name: "bash"}}) // barrier
120
121 got := inner.snapshot()
122 if len(got) != 3 {
123 t.Fatalf("got %d events, want 3 (leading delta, merged burst, barrier): %+v", len(got), got)
124 }
125 if got[0].Text != "a" || got[1].Kind != Reasoning || got[1].Text != "bc" {
126 t.Fatalf("burst not merged: %+v", got)
127 }
128 if got[2].Kind != ToolDispatch {
129 t.Fatalf("barrier must arrive after the flushed burst, got %+v", got[2])
130 }
131 }
132
133 func TestCoalesceCheckedBarrierFlushesAndReturnsDurabilityError(t *testing.T) {
134 wantErr := errors.New("ledger unavailable")
135 inner := &checkedRecordSink{err: wantErr}
136 c := Coalesce(inner, time.Hour)
137 c.Emit(Event{Kind: Text, Text: "lead"})
138 c.Emit(Event{Kind: Text, Text: "tail"})
139 err := EmitChecked(c, Event{Kind: ToolDispatch, Tool: Tool{ID: "t1", Name: "bash"}})
140 if !errors.Is(err, wantErr) {
141 t.Fatalf("EmitChecked error = %v, want %v", err, wantErr)
142 }
143 got := inner.snapshot()
144 if len(got) != 2 || got[0].Text != "lead" || got[1].Text != "tail" {
145 t.Fatalf("checked barrier did not durably flush stream prefix: %+v", got)
146 }
147 }
148
149 func TestCoalesceKindSwitchFlushes(t *testing.T) {
150 inner := &coalesceRecordSink{}
151 c := Coalesce(inner, time.Hour)
152 c.Emit(Event{Kind: Reasoning, Text: "think"}) // leading edge
153 c.Emit(Event{Kind: Reasoning, Text: "ing"})
154 c.Emit(Event{Kind: Text, Text: "answer"}) // switches kind: flush + buffer
155 c.Emit(Event{Kind: TurnDone})
156
157 got := inner.snapshot()
158 if len(got) != 4 {
159 t.Fatalf("got %d events, want 4: %+v", len(got), got)
160 }
161 if got[1].Kind != Reasoning || got[1].Text != "ing" {
162 t.Fatalf("reasoning tail = %+v", got[1])
163 }
164 if got[2].Kind != Text || got[2].Text != "answer" {
165 t.Fatalf("text after kind switch = %+v", got[2])
166 }
167 }
168
169 func TestCoalescePreservesPlannerSourceAndSeparatesSourceChanges(t *testing.T) {
170 inner := &coalesceRecordSink{}
171 c := Coalesce(inner, time.Hour)
172 c.Emit(Event{Kind: Text, Text: "lead", Source: UsageSourcePlanner})
173 c.Emit(Event{Kind: Text, Text: "planner tail", Source: UsageSourcePlanner})
174 c.Emit(Event{Kind: Text, Text: "executor", Source: UsageSourceExecutor})
175 c.Emit(Event{Kind: TurnDone})
176
177 got := inner.snapshot()
178 if len(got) != 4 || got[1].Text != "planner tail" || got[1].Source != UsageSourcePlanner || got[2].Text != "executor" || got[2].Source != UsageSourceExecutor {
179 t.Fatalf("source-aware stream boundaries changed: %+v", got)
180 }
181 }
182
183 func TestCoalesceWindowFlushesBufferedTail(t *testing.T) {
184 inner := &coalesceRecordSink{}
185 c := Coalesce(inner, 20*time.Millisecond)
186 c.Emit(Event{Kind: Text, Text: "lead"})
187 c.Emit(Event{Kind: Text, Text: "tail"})
188
189 deadline := time.Now().Add(2 * time.Second)
190 for {
191 got := inner.snapshot()
192 if len(got) == 2 {
193 if got[1].Text != "tail" {
194 t.Fatalf("timer flush = %+v", got[1])
195 }
196 return
197 }
198 if time.Now().After(deadline) {
199 t.Fatalf("buffered tail never flushed: %+v", got)
200 }
201 time.Sleep(5 * time.Millisecond)
202 }
203 }
204
205 func TestCoalesceByteCapFlushes(t *testing.T) {
206 inner := &coalesceRecordSink{}
207 c := Coalesce(inner, time.Hour)
208 c.Emit(Event{Kind: Text, Text: "lead"})
209 c.Emit(Event{Kind: Text, Text: strings.Repeat("x", coalesceMaxBytes)})
210 got := inner.snapshot()
211 if len(got) != 2 || len(got[1].Text) != coalesceMaxBytes {
212 t.Fatalf("byte cap must flush synchronously, got %d events", len(got))
213 }
214 }
215
216 func TestCoalesceCapabilitiesFlushFirstAndForward(t *testing.T) {
217 inner := &coalesceRecordSink{}
218 c := Coalesce(inner, time.Hour)
219 c.Emit(Event{Kind: Text, Text: "lead"})
220 c.Emit(Event{Kind: Text, Text: "tail"})
221 c.(ReadinessAuditSink).RecordReadinessAudit(evidence.ReadinessAudit{})
222 c.(TurnCompletionSink).RecordTurnCompletion()
223 c.(ProtocolRecoveryAuditSink).RecordProtocolRecovery(ProtocolRecoveryAudit{})
224 c.(WorkspaceMutationSink).RecordWorkspaceMutation(WorkspaceMutation{Content: true})
225 c.(RunBudgetSink).RecordRunBudget(RunBudgetSample{})
226
227 got := inner.snapshot()
228 if len(got) != 2 || got[1].Text != "tail" {
229 t.Fatalf("capability call must flush the buffered delta first: %+v", got)
230 }
231 if inner.readiness != 1 || inner.turns != 1 || inner.recovery != 1 || inner.workspace != 1 || inner.runBudget != 1 {
232 t.Fatalf("capabilities not forwarded: %d/%d/%d/%d/%d", inner.readiness, inner.turns, inner.recovery, inner.workspace, inner.runBudget)
233 }
234 }
235
236 func TestCoalesceCapabilityCannotOvertakeActiveDrainer(t *testing.T) {
237 inner := &blockingCapabilitySink{entered: make(chan struct{}), release: make(chan struct{}), done: make(chan struct{})}
238 c := Coalesce(inner, time.Hour)
239 go c.Emit(Event{Kind: Text, Text: "lead"})
240 <-inner.entered
241 c.Emit(Event{Kind: Text, Text: "tail"})
242 c.(ReadinessAuditSink).RecordReadinessAudit(evidence.ReadinessAudit{})
243 close(inner.release)
244 select {
245 case <-inner.done:
246 case <-time.After(5 * time.Second):
247 t.Fatal("queued capability did not drain")
248 }
249 inner.mu.Lock()
250 defer inner.mu.Unlock()
251 want := []string{"lead", "tail", "audit"}
252 if len(inner.order) != len(want) {
253 t.Fatalf("order = %v, want %v", inner.order, want)
254 }
255 for i := range want {
256 if inner.order[i] != want[i] {
257 t.Fatalf("order = %v, want %v", inner.order, want)
258 }
259 }
260 }
261
262 func TestCoalesceNonPureDeltaPassesThrough(t *testing.T) {
263 inner := &coalesceRecordSink{}
264 c := Coalesce(inner, time.Hour)
265 c.Emit(Event{Kind: Text, Text: "lead"})
266 c.Emit(Event{Kind: Text, Text: "buffered"})
267 // A Text event carrying any extra field is not a pure delta: it must not
268 // merge, and it must flush the buffer ahead of itself.
269 c.Emit(Event{Kind: Text, Text: "detailed", Detail: "diag"})
270
271 got := inner.snapshot()
272 if len(got) != 3 {
273 t.Fatalf("got %d events, want 3: %+v", len(got), got)
274 }
275 if got[1].Text != "buffered" || got[2].Detail != "diag" {
276 t.Fatalf("non-pure delta ordering broken: %+v", got)
277 }
278 }
279
280 // reentrantSink re-enters the wrapping sink from inside Emit, the way a
281 // frontend callback can synchronously call back into the controller (e.g. a
282 // recovery resolution emitting a decision receipt).
283 type reentrantSink struct {
284 outer Sink
285 events []Event
286 fired bool
287 }
288
289 func (s *reentrantSink) Emit(e Event) {
290 s.events = append(s.events, e)
291 if e.Kind == ApprovalRequest && !s.fired {
292 s.fired = true
293 s.outer.Emit(Event{Kind: Notice, Text: "receipt"})
294 }
295 }
296
297 func TestCoalesceReentrantEmitDoesNotDeadlock(t *testing.T) {
298 inner := &reentrantSink{}
299 c := Coalesce(inner, time.Hour)
300 inner.outer = c
301
302 done := make(chan struct{})
303 go func() {
304 defer close(done)
305 c.Emit(Event{Kind: Text, Text: "lead"})
306 c.Emit(Event{Kind: Text, Text: "buffered"})
307 c.Emit(Event{Kind: ApprovalRequest})
308 }()
309 select {
310 case <-done:
311 case <-time.After(5 * time.Second):
312 t.Fatal("re-entrant Emit deadlocked the coalescer")
313 }
314
315 kinds := make([]Kind, 0, len(inner.events))
316 for _, e := range inner.events {
317 kinds = append(kinds, e.Kind)
318 }
319 want := []Kind{Text, Text, ApprovalRequest, Notice}
320 if len(kinds) != len(want) {
321 t.Fatalf("events = %v, want %v", kinds, want)
322 }
323 for i := range want {
324 if kinds[i] != want[i] {
325 t.Fatalf("order broken: %v, want %v", kinds, want)
326 }
327 }
328 }
329
330 func TestCoalesceDisabledOrNil(t *testing.T) {
331 inner := &coalesceRecordSink{}
332 if s := Coalesce(inner, 0); s != Sink(inner) {
333 t.Fatalf("window<=0 must return inner unchanged")
334 }
335 if _, ok := Coalesce(nil, time.Second).(*coalescer); ok {
336 t.Fatalf("nil inner must not be wrapped")
337 }
338 }
339
339 lines GO