返回 DeepSeek-Reasonix
prompt_identity_test.go
根目录 / internal / control / prompt_identity_test.go
1 package control
2
3 import (
4 "context"
5 "errors"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "reasonix/internal/event"
12 "reasonix/internal/session"
13 )
14
15 func TestResolvePromptExactRejectsStaleTurnBeforeDispatch(t *testing.T) {
16 c := newOwnedTestController(t, Options{})
17 t.Cleanup(c.Close)
18 err := c.ResolvePromptExact(PromptIdentity{
19 PromptID: "prompt-1", TurnID: "turn-stale", Kind: PromptAsk,
20 }, PromptAnswer{})
21 if !errors.Is(err, ErrPromptStaleTurn) {
22 t.Fatalf("ResolvePromptExact error = %v, want ErrPromptStaleTurn", err)
23 }
24 }
25
26 func TestResolvePromptExactRejectsIncompleteIdentity(t *testing.T) {
27 c := newOwnedTestController(t, Options{})
28 t.Cleanup(c.Close)
29 err := c.ResolvePromptExact(PromptIdentity{PromptID: "prompt-1", Kind: PromptAsk}, PromptAnswer{})
30 if !errors.Is(err, ErrPromptNotPending) {
31 t.Fatalf("ResolvePromptExact error = %v, want ErrPromptNotPending", err)
32 }
33 }
34
35 func TestResolvePromptExactRejectsStaleRuntime(t *testing.T) {
36 c := newOwnedTestController(t, Options{})
37 t.Cleanup(c.Close)
38 c.SetTurnEventRoutingMetadata("runtime-current", "")
39 err := c.ResolvePromptExact(PromptIdentity{
40 PromptID: "prompt-1", TurnID: "turn-any", RuntimeEpoch: "runtime-old", Kind: PromptAsk,
41 }, PromptAnswer{})
42 if !errors.Is(err, ErrPromptStaleRuntime) {
43 t.Fatalf("ResolvePromptExact error = %v, want ErrPromptStaleRuntime", err)
44 }
45 }
46
47 func TestResolvePromptExactRejectsLegacyIdentityAfterRuntimeEpochIsSet(t *testing.T) {
48 c := newOwnedTestController(t, Options{})
49 t.Cleanup(c.Close)
50 c.SetTurnEventRoutingMetadata("runtime-current", "")
51 err := c.ResolvePromptExact(PromptIdentity{PromptID: "legacy", TurnID: "turn-any", Kind: PromptAsk}, PromptAnswer{})
52 if !errors.Is(err, ErrPromptStaleRuntime) {
53 t.Fatalf("legacy exact resolve error = %v, want ErrPromptStaleRuntime", err)
54 }
55 }
56
57 func TestResolvePromptExactRejectsClosedController(t *testing.T) {
58 c := newOwnedTestController(t, Options{})
59 c.Close()
60 err := c.ResolvePromptExact(PromptIdentity{PromptID: "p", TurnID: "t", Kind: PromptAsk}, PromptAnswer{})
61 if !errors.Is(err, ErrPromptNotPending) {
62 t.Fatalf("closed resolver error = %v", err)
63 }
64 }
65
66 func TestPendingPromptOwnerTracksResolvedIdentity(t *testing.T) {
67 var owner PendingPromptOwner
68 id := PromptIdentity{PromptID: "p", TurnID: "t", Kind: PromptAsk}
69 if err := owner.Register(id); err != nil {
70 t.Fatal(err)
71 }
72 if got, ok := owner.Identity("p"); !ok || got != id {
73 t.Fatalf("registered identity = %+v, %v", got, ok)
74 }
75 owner.MarkResolved(id)
76 if _, ok := owner.Identity("p"); ok {
77 t.Fatal("resolved prompt remains pending")
78 }
79 if !owner.WasResolved("p") {
80 t.Fatal("resolved prompt was not recorded")
81 }
82 }
83
84 func TestPendingPromptOwnerRejectsConcurrentResolveReservation(t *testing.T) {
85 var owner PendingPromptOwner
86 id := PromptIdentity{PromptID: "p", TurnID: "t", Kind: PromptMCP}
87 if err := owner.Register(id); err != nil {
88 t.Fatal(err)
89 }
90 start := make(chan struct{})
91 results := make(chan error, 2)
92 var wg sync.WaitGroup
93 for range 2 {
94 wg.Go(func() { <-start; results <- owner.BeginResolve(id) })
95 }
96 close(start)
97 wg.Wait()
98 close(results)
99 var success, already int
100 for err := range results {
101 if err == nil {
102 success++
103 }
104 if errors.Is(err, ErrPromptAlreadyResolved) {
105 already++
106 }
107 }
108 if success != 1 || already != 1 {
109 t.Fatalf("resolve reservations = success %d already %d", success, already)
110 }
111 }
112
113 func TestPendingPromptOwnerResolveFailureBecomesUnavailable(t *testing.T) {
114 var owner PendingPromptOwner
115 id := PromptIdentity{PromptID: "p-fail", TurnID: "t", Kind: PromptAsk}
116 if err := owner.RegisterPrompt(PendingPrompt{Identity: id, Resolve: func(PromptAnswer) error { return errors.New("persist failed") }}); err != nil {
117 t.Fatal(err)
118 }
119 if err := owner.Resolve(id, PromptAnswer{}); !errors.Is(err, ErrPromptUnavailable) || !strings.Contains(err.Error(), "persist failed") {
120 t.Fatalf("resolve error = %v", err)
121 }
122 if _, ok := owner.Identity(id.PromptID); ok {
123 t.Fatal("failed answerer remained pending")
124 }
125 resolution, ok := owner.Resolution(id.PromptID)
126 if !ok || resolution.State != PromptUnavailable {
127 t.Fatalf("failed answerer resolution = %+v %v", resolution, ok)
128 }
129 }
130
131 func TestPendingPromptOwnerTerminatesUnavailableAnswerer(t *testing.T) {
132 var owner PendingPromptOwner
133 id := PromptIdentity{PromptID: "p-unavailable", TurnID: "t", Kind: PromptAsk}
134 if err := owner.Register(id); err != nil {
135 t.Fatal(err)
136 }
137 if err := owner.Resolve(id, PromptAnswer{}); !errors.Is(err, ErrPromptUnavailable) {
138 t.Fatalf("resolve error = %v, want ErrPromptUnavailable", err)
139 }
140 if _, ok := owner.Identity(id.PromptID); ok {
141 t.Fatal("unavailable prompt remains pending")
142 }
143 resolution, ok := owner.Resolution(id.PromptID)
144 if !ok || resolution.State != PromptUnavailable {
145 t.Fatalf("resolution = %+v, %v", resolution, ok)
146 }
147 }
148
149 func TestPendingPromptOwnerCancellationDoesNotWaitForAnswerer(t *testing.T) {
150 var owner PendingPromptOwner
151 id := PromptIdentity{PromptID: "p-blocked", TurnID: "t", Kind: PromptAsk}
152 answerStarted := make(chan struct{})
153 releaseAnswer := make(chan struct{})
154 if err := owner.RegisterPrompt(PendingPrompt{Identity: id, Resolve: func(PromptAnswer) error {
155 close(answerStarted)
156 <-releaseAnswer
157 return nil
158 }}); err != nil {
159 t.Fatal(err)
160 }
161 resolved := make(chan error, 1)
162 go func() { resolved <- owner.Resolve(id, PromptAnswer{}) }()
163 <-answerStarted
164 cancelled := make(chan struct{})
165 go func() {
166 owner.CancelAll()
167 close(cancelled)
168 }()
169 select {
170 case <-cancelled:
171 case <-time.After(time.Second):
172 t.Fatal("cancellation waited for the blocked answerer")
173 }
174 close(releaseAnswer)
175 <-resolved
176 resolution, ok := owner.Resolution(id.PromptID)
177 if !ok || resolution.State != PromptCancelled {
178 t.Fatalf("resolution = %+v, %v", resolution, ok)
179 }
180 }
181
182 func TestPendingPromptOwnerCancellationDoesNotWaitForCancelCallback(t *testing.T) {
183 var owner PendingPromptOwner
184 id := PromptIdentity{PromptID: "p-blocked-cancel", TurnID: "t", Kind: PromptMCP}
185 cancelStarted := make(chan struct{})
186 releaseCancel := make(chan struct{})
187 cancelDone := make(chan struct{})
188 if err := owner.RegisterPrompt(PendingPrompt{Identity: id, Cancel: func() error {
189 close(cancelStarted)
190 <-releaseCancel
191 close(cancelDone)
192 return nil
193 }}); err != nil {
194 t.Fatal(err)
195 }
196 returned := make(chan struct{})
197 go func() {
198 owner.CancelAll()
199 close(returned)
200 }()
201 select {
202 case <-returned:
203 case <-time.After(time.Second):
204 t.Fatal("registry cancellation waited for a blocked cancellation callback")
205 }
206 <-cancelStarted
207 close(releaseCancel)
208 <-cancelDone
209 resolution, ok := owner.Resolution(id.PromptID)
210 if !ok || resolution.State != PromptCancelled {
211 t.Fatalf("resolution = %+v, %v", resolution, ok)
212 }
213 }
214
215 func TestControllerCancelSignalsTurnWhilePromptAnswererIsBlocked(t *testing.T) {
216 c := newOwnedTestController(t, Options{})
217 t.Cleanup(c.Close)
218
219 turnCtx, cancelTurn := context.WithCancel(context.Background())
220 c.mu.Lock()
221 c.turns.cancel = cancelTurn
222 c.turns.phase = session.RuntimeRunning
223 c.mu.Unlock()
224
225 id := PromptIdentity{PromptID: "p-controller-blocked", TurnID: "turn-1", Kind: PromptApproval}
226 answerStarted := make(chan struct{})
227 releaseAnswer := make(chan struct{})
228 if err := c.promptOwner.RegisterPrompt(PendingPrompt{Identity: id, Resolve: func(PromptAnswer) error {
229 close(answerStarted)
230 <-releaseAnswer
231 return nil
232 }}); err != nil {
233 t.Fatal(err)
234 }
235 resolved := make(chan error, 1)
236 go func() { resolved <- c.promptOwner.Resolve(id, PromptAnswer{Allow: true}) }()
237 <-answerStarted
238
239 cancelReturned := make(chan struct{})
240 go func() {
241 c.Cancel()
242 close(cancelReturned)
243 }()
244 select {
245 case <-turnCtx.Done():
246 case <-time.After(time.Second):
247 t.Fatal("Stop did not signal the active turn while its answerer was blocked")
248 }
249 select {
250 case <-cancelReturned:
251 case <-time.After(time.Second):
252 t.Fatal("Stop waited for the blocked answerer")
253 }
254
255 close(releaseAnswer)
256 <-resolved
257 c.mu.Lock()
258 c.turns.phase = session.RuntimeIdle
259 c.turns.cancel = nil
260 c.mu.Unlock()
261 }
262
263 func TestPendingPromptOwnerBindsMissingRoutingOnce(t *testing.T) {
264 var owner PendingPromptOwner
265 id := PromptIdentity{PromptID: "p-bind", Kind: PromptAsk}
266 if err := owner.Register(id); err != nil {
267 t.Fatal(err)
268 }
269 bound, ok := owner.BindRouting(id.PromptID, "turn-1", "runtime-1")
270 if !ok || bound.TurnID != "turn-1" || bound.RuntimeEpoch != "runtime-1" {
271 t.Fatalf("bound identity = %+v, %v", bound, ok)
272 }
273 again, ok := owner.BindRouting(id.PromptID, "turn-2", "runtime-2")
274 if !ok || again != bound {
275 t.Fatalf("routing identity was rewritten: first=%+v second=%+v ok=%v", bound, again, ok)
276 }
277 }
278
279 func TestPromptAnsweredEventInheritsOwnerTurnID(t *testing.T) {
280 var got event.Event
281 c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) { got = e })})
282 t.Cleanup(c.Close)
283 c.promptOwner.Register(PromptIdentity{PromptID: "p-event", TurnID: "turn-event", Kind: PromptAsk})
284 if err := c.emitTurnEventChecked(event.Event{Kind: event.PromptAnswered, ItemID: "p-event"}); err != nil {
285 t.Fatal(err)
286 }
287 if got.TurnID != "turn-event" {
288 t.Fatalf("PromptAnswered turn id = %q", got.TurnID)
289 }
290 }
291
291 lines GO