返回 DeepSeek-Reasonix
mcp_interaction_test.go
根目录 / internal / control / mcp_interaction_test.go
1 package control
2
3 import (
4 "context"
5 "sync"
6 "testing"
7 "time"
8
9 "reasonix/internal/event"
10 "reasonix/internal/mcpinteraction"
11 )
12
13 type interactionProbeSink struct {
14 mu sync.Mutex
15 interactions []event.MCPInteraction
16 answered []string
17 }
18
19 func (s *interactionProbeSink) Emit(e event.Event) {
20 s.mu.Lock()
21 defer s.mu.Unlock()
22 switch e.Kind {
23 case event.MCPInteractionRequest:
24 s.interactions = append(s.interactions, e.MCPInteraction)
25 case event.PromptAnswered:
26 s.answered = append(s.answered, e.ItemID)
27 }
28 }
29
30 func (s *interactionProbeSink) snapshot() (interactions []event.MCPInteraction, answered []string) {
31 s.mu.Lock()
32 defer s.mu.Unlock()
33 return append([]event.MCPInteraction(nil), s.interactions...), append([]string(nil), s.answered...)
34 }
35
36 func sampleInteractionRequest() mcpinteraction.Request {
37 return mcpinteraction.Request{
38 Server: "github", Mode: mcpinteraction.ModeForm, Message: "approve the OAuth device code",
39 RequestedSchema: []byte(`{"type":"object","properties":{"code":{"type":"string"}},"required":["code"]}`),
40 }
41 }
42
43 func TestInteractEmitsEventAndAnswerResolves(t *testing.T) {
44 sink := &interactionProbeSink{}
45 c := newOwnedTestController(t, Options{Sink: sink, SessionDir: t.TempDir()})
46
47 type reply struct {
48 res mcpinteraction.Result
49 err error
50 }
51 done := make(chan reply, 1)
52 go func() {
53 res, err := c.Interact(t.Context(), sampleInteractionRequest())
54 done <- reply{res, err}
55 }()
56
57 var id string
58 deadline := time.After(2 * time.Second)
59 for {
60 interactions, _ := sink.snapshot()
61 if len(interactions) == 1 {
62 id = interactions[0].ID
63 break
64 }
65 select {
66 case <-deadline:
67 t.Fatal("MCPInteractionRequest never emitted")
68 default:
69 time.Sleep(5 * time.Millisecond)
70 }
71 }
72 interactions, _ := sink.snapshot()
73 if interactions[0].Server != "github" || interactions[0].Mode != "form" {
74 t.Fatalf("event payload = %+v", interactions[0])
75 }
76
77 if err := c.AnswerMCPInteractionChecked(id, mcpinteraction.ActionAccept, map[string]any{"code": "123-456"}); err != nil {
78 t.Fatalf("answer: %v", err)
79 }
80 select {
81 case r := <-done:
82 if r.err != nil {
83 t.Fatalf("Interact error: %v", r.err)
84 }
85 if r.res.Action != mcpinteraction.ActionAccept || r.res.Content["code"] != "123-456" {
86 t.Fatalf("result = %+v", r.res)
87 }
88 case <-time.After(2 * time.Second):
89 t.Fatal("Interact never returned after answer")
90 }
91 // The durable answer transition was persisted before the waiter released.
92 if _, answered := sink.snapshot(); len(answered) == 0 {
93 t.Fatal("PromptAnswered not emitted for the elicitation")
94 }
95 }
96
97 func TestInteractDeclineClearsContent(t *testing.T) {
98 sink := &interactionProbeSink{}
99 c := newOwnedTestController(t, Options{Sink: sink, SessionDir: t.TempDir()})
100
101 done := make(chan mcpinteraction.Result, 1)
102 go func() {
103 res, _ := c.Interact(t.Context(), sampleInteractionRequest())
104 done <- res
105 }()
106 var id string
107 deadline := time.After(2 * time.Second)
108 for {
109 interactions, _ := sink.snapshot()
110 if len(interactions) == 1 {
111 id = interactions[0].ID
112 break
113 }
114 select {
115 case <-deadline:
116 t.Fatal("event never emitted")
117 default:
118 time.Sleep(5 * time.Millisecond)
119 }
120 }
121 if err := c.AnswerMCPInteractionChecked(id, mcpinteraction.ActionDecline, map[string]any{"code": "ignored"}); err != nil {
122 t.Fatalf("decline: %v", err)
123 }
124 select {
125 case res := <-done:
126 if res.Action != mcpinteraction.ActionDecline || res.Content != nil {
127 t.Fatalf("decline result = %+v, want no content", res)
128 }
129 case <-time.After(2 * time.Second):
130 t.Fatal("Interact never returned")
131 }
132 }
133
134 func TestInteractInvalidActionRejected(t *testing.T) {
135 c := newOwnedTestController(t, Options{Sink: &interactionProbeSink{}, SessionDir: t.TempDir()})
136 if err := c.AnswerMCPInteractionChecked("1", "guess", nil); err == nil {
137 t.Fatal("invalid action accepted")
138 }
139 }
140
141 func TestInteractCancelledContextCancels(t *testing.T) {
142 sink := &interactionProbeSink{}
143 c := newOwnedTestController(t, Options{Sink: sink, SessionDir: t.TempDir()})
144 ctx, cancel := context.WithCancel(t.Context())
145 done := make(chan mcpinteraction.Result, 1)
146 go func() {
147 res, _ := c.Interact(ctx, sampleInteractionRequest())
148 done <- res
149 }()
150 deadline := time.After(2 * time.Second)
151 for {
152 if interactions, _ := sink.snapshot(); len(interactions) == 1 {
153 break
154 }
155 select {
156 case <-deadline:
157 t.Fatal("event never emitted")
158 default:
159 time.Sleep(5 * time.Millisecond)
160 }
161 }
162 cancel()
163 select {
164 case res := <-done:
165 if res.Action != mcpinteraction.ActionCancel {
166 t.Fatalf("cancelled action = %q, want cancel", res.Action)
167 }
168 case <-time.After(2 * time.Second):
169 t.Fatal("Interact never returned after cancellation")
170 }
171 }
172
173 func TestInteractReplaysAfterFrontendReconnect(t *testing.T) {
174 sink := &interactionProbeSink{}
175 c := newOwnedTestController(t, Options{Sink: sink, SessionDir: t.TempDir()})
176 done := make(chan mcpinteraction.Result, 1)
177 go func() {
178 res, _ := c.Interact(t.Context(), sampleInteractionRequest())
179 done <- res
180 }()
181 deadline := time.After(2 * time.Second)
182 var id string
183 for {
184 interactions, _ := sink.snapshot()
185 if len(interactions) == 1 {
186 id = interactions[0].ID
187 break
188 }
189 select {
190 case <-deadline:
191 t.Fatal("event never emitted")
192 default:
193 time.Sleep(5 * time.Millisecond)
194 }
195 }
196
197 replay := &interactionProbeSink{}
198 c.ReplayPendingPromptsTo(replay)
199 interactions, _ := replay.snapshot()
200 if len(interactions) != 1 || interactions[0].ID != id {
201 t.Fatalf("replay = %+v, want the pending elicitation", interactions)
202 }
203 _ = c.AnswerMCPInteractionChecked(id, mcpinteraction.ActionCancel, nil)
204 <-done
205 }
206
206 lines GO