| 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(¶ms, "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 |