| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/config" |
| 15 | "reasonix/internal/control" |
| 16 | "reasonix/internal/event" |
| 17 | "reasonix/internal/plugin" |
| 18 | ) |
| 19 | |
| 20 | // gatedMCPServer answers MCP over HTTP but holds initialize until release is |
| 21 | // closed, and reports on started when the handshake reaches it. |
| 22 | func gatedMCPServer(t *testing.T) (url string, started <-chan struct{}, release func()) { |
| 23 | t.Helper() |
| 24 | gate := make(chan struct{}) |
| 25 | reached := make(chan struct{}) |
| 26 | var reachedOnce, releaseOnce sync.Once |
| 27 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 28 | var req struct { |
| 29 | ID json.RawMessage `json:"id"` |
| 30 | Method string `json:"method"` |
| 31 | } |
| 32 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| 33 | http.Error(w, "bad request", http.StatusBadRequest) |
| 34 | return |
| 35 | } |
| 36 | if len(req.ID) == 0 || string(req.ID) == "null" { |
| 37 | w.WriteHeader(http.StatusAccepted) |
| 38 | return |
| 39 | } |
| 40 | result := any(map[string]any{}) |
| 41 | switch req.Method { |
| 42 | case "initialize": |
| 43 | reachedOnce.Do(func() { close(reached) }) |
| 44 | select { |
| 45 | case <-gate: |
| 46 | case <-r.Context().Done(): |
| 47 | return |
| 48 | } |
| 49 | result = map[string]any{"protocolVersion": "2025-03-26", "serverInfo": map[string]any{"name": "docs", "version": "1"}} |
| 50 | case "tools/list": |
| 51 | result = map[string]any{"tools": []map[string]any{{"name": "search", "description": "Search.", "inputSchema": map[string]any{"type": "object"}}}} |
| 52 | } |
| 53 | w.Header().Set("Content-Type", "application/json") |
| 54 | _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result}) |
| 55 | })) |
| 56 | release = func() { releaseOnce.Do(func() { close(gate) }) } |
| 57 | t.Cleanup(srv.Close) |
| 58 | t.Cleanup(release) |
| 59 | return srv.URL, reached, release |
| 60 | } |
| 61 | |
| 62 | func writeMCPWorkspace(t *testing.T, root, url string) { |
| 63 | t.Helper() |
| 64 | raw := minimalTestModelTOML |
| 65 | if url != "" { |
| 66 | raw += ` |
| 67 | [[plugins]] |
| 68 | name = "docs" |
| 69 | type = "http" |
| 70 | url = "` + url + `" |
| 71 | auto_start = false |
| 72 | startup_timeout_seconds = 10 |
| 73 | ` |
| 74 | } |
| 75 | if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(raw), 0o644); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func newMCPConnectModel(t *testing.T, url string) (chatTUI, control.SessionAPI, string) { |
| 81 | t.Helper() |
| 82 | isolateCLIConfigHome(t) |
| 83 | root := t.TempDir() |
| 84 | writeMCPWorkspace(t, root, url) |
| 85 | ctrl, err := setupProfile(context.Background(), "", 0, false, event.Discard, root) |
| 86 | if err != nil { |
| 87 | t.Fatalf("setupProfile: %v", err) |
| 88 | } |
| 89 | t.Cleanup(func() { ctrl.Close() }) |
| 90 | model := newChatTUI(ctrl, "", make(chan event.Event, 1), 80) |
| 91 | model.mcp = &mcpManager{stage: mcpStageDetail, name: "docs"} |
| 92 | return model, ctrl, root |
| 93 | } |
| 94 | |
| 95 | func runConnect(t *testing.T, cmd func() any) <-chan mcpConnectDoneMsg { |
| 96 | t.Helper() |
| 97 | out := make(chan mcpConnectDoneMsg, 1) |
| 98 | go func() { |
| 99 | done, _ := cmd().(mcpConnectDoneMsg) |
| 100 | out <- done |
| 101 | }() |
| 102 | return out |
| 103 | } |
| 104 | |
| 105 | func TestMCPManagerRetryDoesNotBlockTheUILoop(t *testing.T) { |
| 106 | url, started, release := gatedMCPServer(t) |
| 107 | model, ctrl, _ := newMCPConnectModel(t, url) |
| 108 | view := mcpServerView{Name: "docs", Status: "failed"} |
| 109 | |
| 110 | begin := time.Now() |
| 111 | next, cmd := model.applyMCPAction(view, mcpActionConnect) |
| 112 | if elapsed := time.Since(begin); elapsed > time.Second { |
| 113 | t.Fatalf("Retry held the UI loop for %v while the server was unresponsive", elapsed) |
| 114 | } |
| 115 | if cmd == nil { |
| 116 | t.Fatal("Retry returned no command to run the connect off the UI loop") |
| 117 | } |
| 118 | model = next.(chatTUI) |
| 119 | result := runConnect(t, func() any { return cmd() }) |
| 120 | <-started |
| 121 | |
| 122 | model.mcp = nil |
| 123 | model.openMCPManager("docs") |
| 124 | if _, again := model.applyMCPAction(view, mcpActionConnect); again != nil { |
| 125 | t.Fatal("reopening /mcp let a second Retry start another connect while one was in flight") |
| 126 | } |
| 127 | next, _ = model.applyMCPAction(view, mcpActionDisable) |
| 128 | model = next.(chatTUI) |
| 129 | if model.mcpDisabled["docs"] { |
| 130 | t.Fatal("Disable ran while the connect was in flight") |
| 131 | } |
| 132 | next, _ = model.applyMCPAction(view, mcpActionRemove) |
| 133 | model = next.(chatTUI) |
| 134 | if model.mcp.stage == mcpStageConfirmRemove { |
| 135 | t.Fatal("Remove opened its confirmation while the connect was in flight") |
| 136 | } |
| 137 | model.mcp.stage = mcpStageList |
| 138 | |
| 139 | release() |
| 140 | done := <-result |
| 141 | if done.server != "docs" || done.err != nil { |
| 142 | t.Fatalf("connect result = %+v, want success for docs", done) |
| 143 | } |
| 144 | next, _ = model.Update(done) |
| 145 | model = next.(chatTUI) |
| 146 | if model.mcpConnecting["docs"] { |
| 147 | t.Fatal("connect result did not clear the in-flight marker") |
| 148 | } |
| 149 | if model.mcp.stage != mcpStageList { |
| 150 | t.Fatalf("connect result moved the manager to stage %v", model.mcp.stage) |
| 151 | } |
| 152 | if !mcpConnected(ctrl, "docs") { |
| 153 | t.Fatal("server is not connected after a successful Retry") |
| 154 | } |
| 155 | next, _ = model.applyMCPAction(mcpServerView{Name: "docs", Status: "connected"}, mcpActionDisable) |
| 156 | if model = next.(chatTUI); !model.mcpDisabled["docs"] || mcpConnected(ctrl, "docs") { |
| 157 | t.Fatal("Disable after the connect finished did not take effect") |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func TestMCPConnectFinishingAfterRemovalIsDropped(t *testing.T) { |
| 162 | url, started, release := gatedMCPServer(t) |
| 163 | model, ctrl, root := newMCPConnectModel(t, url) |
| 164 | |
| 165 | next, cmd := model.applyMCPAction(mcpServerView{Name: "docs", Status: "failed"}, mcpActionConnect) |
| 166 | model = next.(chatTUI) |
| 167 | result := runConnect(t, func() any { return cmd() }) |
| 168 | <-started |
| 169 | writeMCPWorkspace(t, root, "") |
| 170 | release() |
| 171 | |
| 172 | done := <-result |
| 173 | if done.err != nil { |
| 174 | t.Fatalf("connect: %v", done.err) |
| 175 | } |
| 176 | next, _ = model.Update(done) |
| 177 | model = next.(chatTUI) |
| 178 | if mcpConnected(ctrl, "docs") { |
| 179 | t.Fatal("a server removed from config while connecting stayed live") |
| 180 | } |
| 181 | if model.mcpConnecting["docs"] { |
| 182 | t.Fatal("in-flight marker survived the dropped connect") |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func TestApplyMCPModeRecordsPluginConnectFailure(t *testing.T) { |
| 187 | isolateUserConfig(t) |
| 188 | t.Setenv("PATH", "") |
| 189 | cfg := config.Default() |
| 190 | cfg.Plugins = []config.PluginEntry{{Name: "broken", Command: "definitely-missing-reasonix-mcp", Tier: "background"}} |
| 191 | if err := cfg.SaveTo("reasonix.toml"); err != nil { |
| 192 | t.Fatalf("save config: %v", err) |
| 193 | } |
| 194 | |
| 195 | m := newTestChatTUI() |
| 196 | m.ctrl = newOwnedTestController(t, control.Options{Host: plugin.NewHost()}) |
| 197 | defer m.ctrl.Close() |
| 198 | m.host = m.ctrl.Host() |
| 199 | m.mcp = &mcpManager{ |
| 200 | stage: mcpStageMode, |
| 201 | name: "broken", |
| 202 | snapshot: mcpSnapshot{configPath: "reasonix.toml", servers: []mcpServerView{{ |
| 203 | Name: "broken", Transport: "stdio", Status: "deferred", Configured: true, Tier: "background", |
| 204 | }}}, |
| 205 | } |
| 206 | |
| 207 | next, cmd := m.applyMCPMode("background") |
| 208 | if cmd == nil { |
| 209 | t.Fatal("mode change started no connect") |
| 210 | } |
| 211 | m = next.(chatTUI) |
| 212 | m.handleMCPConnectDone(cmd().(mcpConnectDoneMsg)) |
| 213 | |
| 214 | failures := m.ctrl.Host().Failures() |
| 215 | if len(failures) != 1 || failures[0].Name != "broken" { |
| 216 | t.Fatalf("Host.Failures() = %+v, want broken failure", failures) |
| 217 | } |
| 218 | v, ok := m.mcp.selectedServer() |
| 219 | if !ok { |
| 220 | t.Fatal("selected server missing after refresh") |
| 221 | } |
| 222 | if v.Status != "failed" { |
| 223 | t.Fatalf("server status = %q, want failed; server = %+v", v.Status, v) |
| 224 | } |
| 225 | } |
| 226 |