返回 DeepSeek-Reasonix
turn_submission_correlation_test.go
根目录 / desktop / turn_submission_correlation_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "path/filepath"
7 "sync"
8 "testing"
9 "time"
10
11 "reasonix/internal/control"
12 "reasonix/internal/event"
13 )
14
15 type turnFanoutGate struct {
16 kind event.Kind
17 once sync.Once
18 entered chan struct{}
19 release chan struct{}
20 }
21
22 type admissionResult struct {
23 admission *tabTurnAdmission
24 err error
25 }
26
27 type activeTurnStatusController struct {
28 control.SessionAPI
29 }
30
31 type expiredTurnFinishingController struct {
32 control.SessionAPI
33 mu sync.Mutex
34 boundaryChecked bool
35 }
36
37 func (c *activeTurnStatusController) RuntimeStatus() control.RuntimeStatus {
38 return control.RuntimeStatus{Running: true, Cancellable: true}
39 }
40
41 func (c *activeTurnStatusController) TurnFinishingDone() (<-chan struct{}, bool) {
42 return nil, false
43 }
44
45 func (c *expiredTurnFinishingController) RuntimeStatus() control.RuntimeStatus {
46 c.mu.Lock()
47 defer c.mu.Unlock()
48 return control.RuntimeStatus{Running: !c.boundaryChecked}
49 }
50
51 func (c *expiredTurnFinishingController) TurnFinishingDone() (<-chan struct{}, bool) {
52 c.mu.Lock()
53 defer c.mu.Unlock()
54 // Model fan-out ending after RuntimeStatus observed finishing=true but
55 // before the controller can return the boundary channel.
56 c.boundaryChecked = true
57 return nil, false
58 }
59
60 func (s *turnFanoutGate) Emit(e event.Event) {
61 if e.Kind != s.kind {
62 return
63 }
64 s.once.Do(func() { close(s.entered) })
65 <-s.release
66 }
67
68 func correlatedSubmissionID(t *testing.T, payload any) (string, *int) {
69 t.Helper()
70 wire, ok := payload.(correlatedWireEventTab)
71 if !ok {
72 t.Fatalf("payload type = %T, want correlatedWireEventTab", payload)
73 }
74 return wire.SubmissionID, wire.CheckpointTurn
75 }
76
77 func TestTabEventSinkCorrelatesDelayedTurnDoneBySubmission(t *testing.T) {
78 entered := make(chan struct{})
79 release := make(chan struct{})
80 delivered := make(chan any, 2)
81 sink := &tabEventSink{tabID: "tab", ctx: context.Background()}
82 sink.runtimeEvents.emit = func(_ context.Context, _ string, payload ...any) {
83 delivered <- payload[0]
84 if len(delivered) == 1 {
85 close(entered)
86 <-release
87 }
88 }
89
90 firstTurn := 0
91 if !sink.tryBeginTurn("u-first") {
92 t.Fatal("failed to reserve first turn")
93 }
94 sink.Emit(event.Event{Kind: event.TurnDone, CheckpointTurn: &firstTurn})
95 select {
96 case <-entered:
97 case <-time.After(500 * time.Millisecond):
98 t.Fatal("first runtime delivery did not start")
99 }
100
101 secondTurn := 1
102 if !sink.tryBeginTurn("u-second") {
103 t.Fatal("delayed frontend delivery blocked the next raw turn")
104 }
105 sink.Emit(event.Event{Kind: event.TurnDone, CheckpointTurn: &secondTurn})
106 close(release)
107
108 first := <-delivered
109 second := <-delivered
110 if id, turn := correlatedSubmissionID(t, first); id != "u-first" || turn == nil || *turn != 0 {
111 t.Fatalf("first correlation = (%q, %v), want (u-first, 0)", id, turn)
112 }
113 if id, turn := correlatedSubmissionID(t, second); id != "u-second" || turn == nil || *turn != 1 {
114 t.Fatalf("second correlation = (%q, %v), want (u-second, 1)", id, turn)
115 }
116 }
117
118 func TestTabEventSinkClearsRejectedSubmissionCorrelation(t *testing.T) {
119 delivered := make(chan any, 2)
120 sink := &tabEventSink{tabID: "tab", ctx: context.Background()}
121 sink.runtimeEvents.emit = func(_ context.Context, _ string, payload ...any) {
122 delivered <- payload[0]
123 }
124
125 if !sink.tryBeginTurn("u-local-command") {
126 t.Fatal("failed to reserve local command")
127 }
128 sink.Emit(event.Event{Kind: event.Notice, Text: "local result"})
129 sink.cancelTurnStart()
130 if id, gotTurn := correlatedSubmissionID(t, <-delivered); id != "u-local-command" || gotTurn != nil {
131 t.Fatalf("local command correlation = (%q, %v), want (u-local-command, nil)", id, gotTurn)
132 }
133 sink.Emit(event.Event{Kind: event.Notice, Text: "late local notice"})
134 if _, ok := (<-delivered).(wireEventTab); !ok {
135 t.Fatal("event after rejected submission retained its correlation")
136 }
137
138 turn := 4
139 if !sink.tryBeginTurn("u-model-turn") {
140 t.Fatal("rejected local command left the sink reserved")
141 }
142 sink.Emit(event.Event{Kind: event.TurnDone, CheckpointTurn: &turn})
143 if id, gotTurn := correlatedSubmissionID(t, <-delivered); id != "u-model-turn" || gotTurn == nil || *gotTurn != turn {
144 t.Fatalf("model correlation = (%q, %v), want (u-model-turn, %d)", id, gotTurn, turn)
145 }
146 }
147
148 func TestSubmitToTabWithIDCorrelatesOnlyAdmittedGuardedTurn(t *testing.T) {
149 delivered := make(chan any, 64)
150 sink := &tabEventSink{tabID: "tab", ctx: context.Background()}
151 sink.runtimeEvents.emit = func(_ context.Context, _ string, payload ...any) {
152 delivered <- payload[0]
153 }
154 dir := t.TempDir()
155 ctrl := control.New(control.Options{Sink: sink, SessionDir: dir, SessionPath: filepath.Join(dir, "session.jsonl")})
156 defer ctrl.Close()
157 tab := &WorkspaceTab{ID: "tab", Scope: "global", Ready: true, Ctrl: ctrl, sink: sink}
158 app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
159
160 if err := app.SubmitToTabWithID(tab.ID, "/tree", "u-local"); err != nil {
161 t.Fatalf("local command: %v", err)
162 }
163 local := <-delivered
164 if id, turn := correlatedSubmissionID(t, local); id != "u-local" || turn != nil {
165 t.Fatalf("local correlation = (%q, %v), want (u-local, nil)", id, turn)
166 }
167
168 if err := app.SubmitToTabWithID(tab.ID, "/mcp__definitely_missing", "u-guarded"); err != nil {
169 t.Fatalf("guarded command: %v", err)
170 }
171 deadline := time.After(time.Second)
172 for {
173 select {
174 case payload := <-delivered:
175 wire, ok := payload.(correlatedWireEventTab)
176 if !ok || wire.Kind != "turn_done" {
177 continue
178 }
179 if wire.SubmissionID != "u-guarded" {
180 t.Fatalf("guarded TurnDone submission = %q, want u-guarded", wire.SubmissionID)
181 }
182 return
183 case <-deadline:
184 t.Fatal("timed out waiting for guarded TurnDone")
185 }
186 }
187 }
188
189 func TestBeginTabTurnWaitsForTurnDoneFanoutBeforeRetry(t *testing.T) {
190 sink := &tabEventSink{tabID: "tab", ctx: context.Background()}
191 gate := &turnFanoutGate{kind: event.TurnDone, entered: make(chan struct{}), release: make(chan struct{})}
192 sink.SetBotSink(gate)
193 dir := t.TempDir()
194 ctrl := control.New(control.Options{Sink: sink, SessionDir: dir, SessionPath: filepath.Join(dir, "session.jsonl")})
195 t.Cleanup(ctrl.Close)
196 tab := &WorkspaceTab{ID: "tab", Scope: "global", Ready: true, Ctrl: ctrl, sink: sink}
197 app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
198 sink.app = app
199
200 if err := app.SubmitToTabWithID(tab.ID, "/mcp__definitely_missing", "u-first"); err != nil {
201 t.Fatalf("first submit: %v", err)
202 }
203 select {
204 case <-gate.entered:
205 case <-time.After(time.Second):
206 t.Fatal("first TurnDone did not enter the held fan-out")
207 }
208
209 result := make(chan admissionResult, 1)
210 go func() {
211 admission, _, err := app.beginTabTurn(tab.ID, false, "u-second")
212 result <- admissionResult{admission: admission, err: err}
213 }()
214 select {
215 case got := <-result:
216 if got.admission != nil {
217 got.admission.abort()
218 }
219 close(gate.release)
220 t.Fatalf("next submit returned inside TurnDone fan-out: %v", got.err)
221 default:
222 }
223
224 close(gate.release)
225 select {
226 case got := <-result:
227 if got.err != nil {
228 t.Fatalf("next submit after TurnDone fan-out: %v", got.err)
229 }
230 if got.admission == nil {
231 t.Fatal("next submit returned without an admission token")
232 }
233 got.admission.abort()
234 case <-time.After(time.Second):
235 t.Fatal("next submit did not retry after TurnDone fan-out")
236 }
237 }
238
239 func TestBeginTabTurnStillRejectsGenuinelyRunningTurn(t *testing.T) {
240 sink := &tabEventSink{tabID: "tab", ctx: context.Background()}
241 base := control.New(control.Options{Sink: sink})
242 t.Cleanup(base.Close)
243 ctrl := &activeTurnStatusController{SessionAPI: base}
244 tab := &WorkspaceTab{ID: "tab", Scope: "global", Ready: true, Ctrl: ctrl, sink: sink}
245 app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
246 sink.app = app
247
248 result := make(chan admissionResult, 1)
249 go func() {
250 admission, _, err := app.beginTabTurn(tab.ID, false, "u-second")
251 result <- admissionResult{admission: admission, err: err}
252 }()
253 select {
254 case got := <-result:
255 if got.admission != nil {
256 got.admission.abort()
257 }
258 if !errors.Is(got.err, control.ErrTurnRunning) {
259 t.Fatalf("active-turn admission error = %v, want ErrTurnRunning", got.err)
260 }
261 case <-time.After(time.Second):
262 t.Fatal("active-turn admission waited instead of returning ErrTurnRunning")
263 }
264 }
265
266 func TestBeginTabTurnRetriesWhenFinishingBoundaryExpiresBetweenChecks(t *testing.T) {
267 sink := &tabEventSink{tabID: "tab", ctx: context.Background()}
268 base := control.New(control.Options{Sink: sink})
269 t.Cleanup(base.Close)
270 ctrl := &expiredTurnFinishingController{SessionAPI: base}
271 tab := &WorkspaceTab{ID: "tab", Scope: "global", Ready: true, Ctrl: ctrl, sink: sink}
272 app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
273 sink.app = app
274
275 admission, _, err := app.beginTabTurn(tab.ID, false, "u-second")
276 if err != nil {
277 t.Fatalf("admission after expired finishing boundary: %v", err)
278 }
279 if admission == nil {
280 t.Fatal("admission after expired finishing boundary returned no token")
281 }
282 admission.abort()
283 }
284
285 func TestTabEventSinkDropsCorrelationWhenFrontendBindingChanges(t *testing.T) {
286 for _, tc := range []struct {
287 name string
288 change func(*tabEventSink)
289 }{
290 {name: "tab", change: func(s *tabEventSink) { s.setBinding("replacement", nil) }},
291 {name: "runtime epoch", change: func(s *tabEventSink) { s.setRuntimeEpoch("runtime-2") }},
292 } {
293 t.Run(tc.name, func(t *testing.T) {
294 delivered := make(chan any, 1)
295 sink := &tabEventSink{tabID: "original", ctx: context.Background(), runtimeEpoch: "runtime-1"}
296 sink.runtimeEvents.emit = func(_ context.Context, _ string, payload ...any) {
297 delivered <- payload[0]
298 }
299 if !sink.tryBeginTurn("u-original") {
300 t.Fatal("failed to reserve original turn")
301 }
302 tc.change(sink)
303 turn := 7
304 sink.Emit(event.Event{Kind: event.TurnDone, CheckpointTurn: &turn})
305 payload := <-delivered
306 if _, ok := payload.(correlatedWireEventTab); ok {
307 t.Fatal("changed frontend binding received the old submission correlation")
308 }
309 wire, ok := payload.(wireEventTab)
310 if !ok || wire.CheckpointTurn == nil || *wire.CheckpointTurn != turn {
311 t.Fatalf("uncorrelated payload = %#v, want checkpoint turn %d", payload, turn)
312 }
313 })
314 }
315 }
316
316 lines GO