返回 DeepSeek-Reasonix
submission_identity_test.go
根目录 / internal / control / submission_identity_test.go
1 package control
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "sync"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 "reasonix/internal/session"
17 "reasonix/internal/tool"
18 )
19
20 func TestLegacySubmissionReportsSynchronousAdmission(t *testing.T) {
21 for name, req := range map[string]SubmissionRequest{
22 "http": {HTTP: true, Input: "hello"},
23 "retired action": {Action: FinalReadinessRecoveryAction, Input: "continue"},
24 } {
25 t.Run(name, func(t *testing.T) {
26 c := newOwnedTestController(t, Options{Runner: noOpTurnRunner{}, Sink: event.Discard})
27 if _, err := c.SubmitIdentified(req); err != nil {
28 t.Fatal(err)
29 }
30 c.Close()
31 if _, err := c.SubmitIdentified(req); !errors.Is(err, ErrSubmissionNotAccepted) {
32 t.Fatalf("submission after close error = %v, want ErrSubmissionNotAccepted", err)
33 }
34 })
35 }
36 }
37
38 func TestShellSubmissionIsDurableBeforeCommandDispatchAndDeduplicated(t *testing.T) {
39 var ctrl *Controller
40 var dispatches atomic.Int32
41 var admitted atomic.Bool
42 done := make(chan struct{}, 1)
43 verified := make(chan bool, 1)
44 req := SubmissionRequest{ID: "shell-once", Action: "shell", Input: "echo fixture", Display: "echo fixture"}
45 sink := event.FuncSink(func(ev event.Event) {
46 if ev.Kind == event.TurnStarted {
47 receipt, found := ctrl.sessionEventStore().Submission(req.ID)
48 snapshot := ctrl.sessionEventStore().Snapshot()
49 admitted.Store(found && MatchesSubmissionReceipt(req, receipt) && snapshot.DurableSequence >= snapshot.EventSequence)
50 }
51 if ev.Kind == event.ToolDispatch {
52 dispatches.Add(1)
53 verified <- admitted.Load()
54 }
55 if ev.Kind == event.TurnDone {
56 select {
57 case done <- struct{}{}:
58 default:
59 }
60 }
61 })
62 ctrl = newOwnedTestController(t, Options{SessionPath: filepath.Join(t.TempDir(), "shell.jsonl"), Sink: sink})
63 defer ctrl.Close()
64 first, err := ctrl.SubmitIdentified(req)
65 if err != nil {
66 t.Fatal(err)
67 }
68 if !<-verified {
69 t.Fatal("shell command dispatched before durable receipt")
70 }
71 <-done
72 second, err := ctrl.SubmitIdentified(req)
73 if err != nil || second != first || dispatches.Load() != 1 {
74 t.Fatalf("duplicate shell: %v %+v dispatches=%d", err, second, dispatches.Load())
75 }
76 }
77
78 func TestSubmissionIdentityDurableAndConflicting(t *testing.T) {
79 path := filepath.Join(t.TempDir(), "session.jsonl")
80 c := newOwnedTestController(t, Options{SessionPath: path, Sink: event.Discard})
81 request := SubmissionRequest{ID: "request-1", Input: "hello", Display: "hello"}
82 runs := 0
83 receipt, err := c.submitIdentified(request, func() {
84 runs++
85 if err := c.prepareTurnAdmission(func(context.Context) error { return nil })(context.Background()); err != nil {
86 t.Fatal(err)
87 }
88 })
89 if err != nil {
90 t.Fatal(err)
91 }
92 if receipt.TurnID == "" || receipt.MessageID == "" {
93 t.Fatalf("incomplete receipt: %+v", receipt)
94 }
95 retry, err := c.submitIdentified(request, func() { runs++ })
96 if err != nil || retry != receipt || runs != 1 {
97 t.Fatalf("retry=%+v runs=%d err=%v", retry, runs, err)
98 }
99 request.Input = "different"
100 if _, err := c.submitIdentified(request, func() { runs++ }); err == nil {
101 t.Fatal("conflicting request accepted")
102 }
103 if err := c.emitTurnEventChecked(event.Event{Kind: event.TurnDone, Status: event.TurnCompleted}); err != nil {
104 t.Fatal(err)
105 }
106 c.Close()
107 reopened := newOwnedTestController(t, Options{SessionPath: path, Sink: event.Discard})
108 defer reopened.Close()
109 request.Input = "hello"
110 recovered, found, err := reopened.LookupSubmission(request)
111 if err != nil || !found || recovered != receipt {
112 t.Fatalf("recovery: %+v %v %v", recovered, found, err)
113 }
114 }
115
116 func TestSubmissionIdentityConcurrentPublicAdmission(t *testing.T) {
117 c := newOwnedTestController(t, Options{SessionPath: filepath.Join(t.TempDir(), "session.jsonl"), Sink: event.Discard})
118 defer c.Close()
119 req := SubmissionRequest{ID: "concurrent", Input: "/mcp__definitely_missing", Display: "request"}
120 const callers = 12
121 start := make(chan struct{})
122 receipts := make(chan session.SubmissionReceipt, callers)
123 errs := make(chan error, callers)
124 var group sync.WaitGroup
125 for range callers {
126 group.Go(func() {
127 <-start
128 receipt, err := c.SubmitIdentified(req)
129 receipts <- receipt
130 errs <- err
131 })
132 }
133 close(start)
134 group.Wait()
135 close(receipts)
136 close(errs)
137 for err := range errs {
138 if err != nil {
139 t.Fatal(err)
140 }
141 }
142 var first session.SubmissionReceipt
143 for receipt := range receipts {
144 if first.SubmissionID == "" {
145 first = receipt
146 }
147 if receipt != first || receipt.TurnID == "" {
148 t.Fatalf("different admission: %+v / %+v", first, receipt)
149 }
150 }
151 if receipt, found, err := c.LookupSubmission(req); err != nil || !found || receipt != first {
152 t.Fatalf("lookup: %+v %v %v", receipt, found, err)
153 }
154 }
155
156 func TestSubmissionIdentityInterruptedAdmissionDoesNotReplay(t *testing.T) {
157 path := filepath.Join(t.TempDir(), "session.jsonl")
158 c := newOwnedTestController(t, Options{SessionPath: path, Sink: event.Discard})
159 req := SubmissionRequest{ID: "accepted-before-body", Input: "a side effect"}
160 first, err := c.submitIdentified(req, func() {
161 // Persist admission without ever invoking the returned execution body.
162 _ = c.prepareTurnAdmission(func(context.Context) error { t.Fatal("body ran"); return nil })
163 })
164 if err != nil {
165 t.Fatal(err)
166 }
167 c.Close()
168 reopened := newOwnedTestController(t, Options{SessionPath: path, Sink: event.Discard})
169 defer reopened.Close()
170 got, err := reopened.submitIdentified(req, func() { t.Fatal("uncertain accepted input was replayed") })
171 if err != nil || got != first {
172 t.Fatalf("retry: %+v %v", got, err)
173 }
174 for _, changed := range []SubmissionRequest{
175 {ID: req.ID, Input: req.Input, Original: "different edit"},
176 {ID: req.ID, Input: req.Input, Invocations: []InvocationRequest{{Name: "different"}}},
177 {ID: req.ID, Input: req.Input, ToolApprovalMode: "yolo"},
178 } {
179 if _, _, err := reopened.LookupSubmission(changed); err == nil {
180 t.Fatal("execution options omitted from fingerprint")
181 }
182 }
183 }
184
185 func TestSubmissionAdmissionCancellationReleasesGateAndRetryReusesReceipt(t *testing.T) {
186 flushStarted := make(chan struct{})
187 releaseWrite := make(chan struct{})
188 var once, releaseOnce sync.Once
189 releasePhysicalWrite := func() { releaseOnce.Do(func() { close(releaseWrite) }) }
190 store, err := session.CreateWithOptions(filepath.Join(t.TempDir(), "session"), "cancelled-admission", session.OpenOptions{
191 Sync: func(*os.File) error {
192 once.Do(func() { close(flushStarted) })
193 <-releaseWrite
194 return nil
195 },
196 })
197 if err != nil {
198 t.Fatal(err)
199 }
200 service, err := session.NewService("desktop", failingFlushPersistence{session: store})
201 if err != nil {
202 t.Fatal(err)
203 }
204 t.Cleanup(func() {
205 releasePhysicalWrite()
206 _ = service.CloseAll(context.Background())
207 })
208 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "cancelled-admission"})
209 if err != nil {
210 t.Fatal(err)
211 }
212
213 requests := make(chan provider.Request, 2)
214 p := &reviewImageProvider{requests: requests}
215 turnDone := make(chan struct{}, 1)
216 sink := event.FuncSink(func(e event.Event) {
217 if e.Kind == event.TurnDone {
218 select {
219 case turnDone <- struct{}{}:
220 default:
221 }
222 }
223 })
224 ag := agent.New(p, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, sink)
225 c := newOwnedTestController(t, Options{
226 Runner: ag, Executor: ag, Sink: sink,
227 SessionService: service, SessionRuntime: runtime, ExclusiveSession: true,
228 })
229 defer c.Close()
230
231 req := SubmissionRequest{ID: "cancel-and-retry", Input: "inspect once", Display: "inspect once"}
232 ctx, cancel := context.WithCancel(t.Context())
233 firstDone := make(chan error, 1)
234 go func() {
235 _, err := c.SubmitIdentifiedContext(ctx, req)
236 firstDone <- err
237 }()
238 select {
239 case <-flushStarted:
240 case <-time.After(5 * time.Second):
241 t.Fatal("submission did not reach the durable flush")
242 }
243 cancel()
244 select {
245 case err := <-firstDone:
246 if !errors.Is(err, context.Canceled) || errors.Is(err, ErrSubmissionNotAccepted) {
247 t.Fatalf("cancelled admission = %v", err)
248 }
249 case <-time.After(2 * time.Second):
250 t.Fatal("cancelled caller remained blocked on the physical write")
251 }
252
253 release := c.trySubmissionAdmissionLock()
254 if release == nil {
255 t.Fatal("cancelled admission retained the submission gate")
256 }
257 release()
258
259 retryDone := make(chan struct {
260 receipt session.SubmissionReceipt
261 err error
262 }, 1)
263 go func() {
264 receipt, err := c.SubmitIdentifiedContext(t.Context(), req)
265 retryDone <- struct {
266 receipt session.SubmissionReceipt
267 err error
268 }{receipt: receipt, err: err}
269 }()
270 releasePhysicalWrite()
271 var receipt session.SubmissionReceipt
272 select {
273 case result := <-retryDone:
274 if result.err != nil {
275 t.Fatal(result.err)
276 }
277 receipt = result.receipt
278 case <-time.After(5 * time.Second):
279 t.Fatal("retry did not observe the durable receipt")
280 }
281 if receipt.SubmissionID != req.ID || receipt.TurnID == "" {
282 t.Fatalf("retry receipt = %+v", receipt)
283 }
284
285 select {
286 case <-turnDone:
287 case <-time.After(5 * time.Second):
288 t.Fatal("cancelled admission did not reach a durable terminal state")
289 }
290 select {
291 case request := <-requests:
292 t.Fatalf("cancelled admission reached the provider: %+v", request)
293 case <-time.After(100 * time.Millisecond):
294 }
295 snapshot := store.ExecutionSnapshot()
296 if len(snapshot.Projection.Turns) != 1 {
297 t.Fatalf("durable turns = %d, want 1", len(snapshot.Projection.Turns))
298 }
299 }
300
300 lines GO