返回 DeepSeek-Reasonix
mcp_interaction_test.go
根目录 / internal / acp / mcp_interaction_test.go
1 package acp
2
3 import (
4 "context"
5 "encoding/json"
6 "testing"
7 "time"
8
9 "reasonix/internal/event"
10 )
11
12 func TestMCPInteractionRequiresVersionedOptIn(t *testing.T) {
13 for _, tc := range []struct {
14 raw string
15 want bool
16 }{
17 {`{}`, false},
18 {`{"_meta":{"reasonix.io":{"mcpInteraction":true}}}`, false},
19 {`{"_meta":{"reasonix.io":{"mcpInteraction":{"supported":true}}}}`, false},
20 {`{"_meta":{"reasonix.io":{"mcpInteraction":{"supported":true,"schemaVersion":2}}}}`, false},
21 {`{"_meta":{"reasonix.io":{"mcpInteraction":{"supported":true,"schemaVersion":1}}}}`, true},
22 } {
23 var caps ClientCapabilities
24 if err := json.Unmarshal([]byte(tc.raw), &caps); err != nil {
25 t.Fatal(err)
26 }
27 if got := clientMCPInteractionSupported(caps); got != tc.want {
28 t.Fatalf("%s: got %v want %v", tc.raw, got, tc.want)
29 }
30 svc := &service{clientCaps: caps}
31 var params SessionParams
32 svc.bindClientIO(&params, "session")
33 if params.MCPInteractions != tc.want {
34 t.Fatalf("factory opt-in not propagated: %+v", params)
35 }
36 }
37 }
38
39 func TestMCPInteractionRoundTripAndInvalidReplies(t *testing.T) {
40 for _, tc := range []struct {
41 name, response, action string
42 supported bool
43 }{
44 {"accept", `{"action":"accept","content":{"answer":"yes"}}`, "accept", true},
45 {"decline", `{"action":"decline","content":{"answer":"discard"}}`, "decline", true},
46 {"unknown", `{"action":"allow"}`, "cancel", true},
47 {"malformed", `{"action":`, "cancel", true},
48 {"legacy", `{"action":"accept"}`, "cancel", false},
49 } {
50 t.Run(tc.name, func(t *testing.T) {
51 seen := make(chan MCPInteractionParams, 1)
52 n := &fakeNotifier{onReq: func(method string, params any) (json.RawMessage, error) {
53 if method != mcpInteractionMethod {
54 t.Errorf("method = %s", method)
55 }
56 seen <- params.(MCPInteractionParams)
57 return json.RawMessage(tc.response), nil
58 }}
59 sink := newUpdateSink(n, "session-one")
60 resolved := make(chan MCPInteractionResult, 1)
61 sink.bindMCPInteraction(tc.supported, func(id, action string, content map[string]any) error {
62 if id != "prompt-1" {
63 t.Errorf("prompt = %s", id)
64 }
65 resolved <- MCPInteractionResult{Action: action, Content: content}
66 return nil
67 })
68 sink.Emit(event.Event{Kind: event.MCPInteractionRequest, MCPInteraction: event.MCPInteraction{ID: "prompt-1", TurnID: "turn-1", Server: "browser", Mode: "form", Message: "Confirm", RequestedSchema: json.RawMessage(`{"type":"object"}`)}})
69 select {
70 case got := <-resolved:
71 if got.Action != tc.action {
72 t.Fatalf("action = %s", got.Action)
73 }
74 if got.Action != "accept" && got.Content != nil {
75 t.Fatal("non-accept response retained form values")
76 }
77 case <-time.After(2 * time.Second):
78 t.Fatal("MCP prompt was not resolved")
79 }
80 if tc.supported {
81 got := <-seen
82 if got.SessionID != "session-one" || got.TurnID != "turn-1" || got.PromptID != "prompt-1" {
83 t.Fatalf("routing identity lost: %+v", got)
84 }
85 } else {
86 select {
87 case <-seen:
88 t.Fatal("legacy client received vendor request")
89 default:
90 }
91 }
92 })
93 }
94 }
95
96 func TestMCPInteractionLateReplyCannotResolveReplacementController(t *testing.T) {
97 requested, release := make(chan struct{}), make(chan struct{})
98 n := &fakeNotifier{onReqCtx: func(context.Context, string, any) (json.RawMessage, error) {
99 close(requested)
100 <-release
101 return json.RawMessage(`{"action":"accept"}`), nil
102 }}
103 sink := newUpdateSink(n, "session-one")
104 ctx, cancel := context.WithCancel(t.Context())
105 defer cancel()
106 sink.setTurnContext(ctx)
107 oldResult := make(chan string, 1)
108 replacementResult := make(chan string, 1)
109 sink.bindMCPInteraction(true, func(_ string, action string, _ map[string]any) error { oldResult <- action; return nil })
110 sink.Emit(event.Event{Kind: event.MCPInteractionRequest, MCPInteraction: event.MCPInteraction{ID: "1", Mode: "form"}})
111 <-requested
112 cancel()
113 sink.bindMCPInteraction(true, func(_ string, action string, _ map[string]any) error { replacementResult <- action; return nil })
114 close(release)
115 select {
116 case action := <-oldResult:
117 if action != "cancel" {
118 t.Fatalf("cancelled request accepted: %s", action)
119 }
120 case <-time.After(2 * time.Second):
121 t.Fatal("old request unresolved")
122 }
123 select {
124 case <-replacementResult:
125 t.Fatal("late response reached replacement controller")
126 default:
127 }
128 }
129
130 func TestMCPInteractionRefusesUnsafeURLWithoutClientRequest(t *testing.T) {
131 n := &fakeNotifier{onReq: func(string, any) (json.RawMessage, error) { t.Error("unsafe URL forwarded"); return nil, nil }}
132 sink := newUpdateSink(n, "session-one")
133 resolved := make(chan string, 1)
134 sink.bindMCPInteraction(true, func(_ string, action string, _ map[string]any) error { resolved <- action; return nil })
135 sink.Emit(event.Event{Kind: event.MCPInteractionRequest, MCPInteraction: event.MCPInteraction{ID: "1", Mode: "url", URL: "https://user:secret@example.invalid/"}})
136 select {
137 case action := <-resolved:
138 if action != "cancel" {
139 t.Fatal(action)
140 }
141 case <-time.After(2 * time.Second):
142 t.Fatal("unsafe URL prompt hung")
143 }
144 }
145
145 lines GO