Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR refactors agent backend dispatch, adds ACP session pooling with IDE-side MCP tool bridging, and tightens history/archive filtering in sidebar views with new tests.
Changes:
- Adds shared backend capability/tool abstractions and AgentBackend adapters for Claude, Codex, and ACP.
- Introduces an IDE MCP bridge server and ACP MCP server injection for IDE-side tools.
- Reuses the History sidebar filtering logic in project chat lists and adds archive-filter tests.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| RxCodeTests/HistoryListArchiveFilterTests.swift | Adds unit/ViewInspector coverage for archive filtering. |
| RxCode/Views/Sidebar/ProjectTreeView.swift | Reuses shared history filtering for project chat summaries. |
| RxCode/Views/Sidebar/HistoryListView.swift | Exposes shared filter/sort helpers for history summaries. |
| RxCode/Services/MCPService.swift | Builds ACP mcpServers entries, including the IDE bridge. |
| RxCode/Services/IDEServer/IDEMCPServer.swift | Adds local MCP bridge server for IDE-side tools. |
| RxCode/Services/IDEServer/AppState+IDEToolHandling.swift | Implements IDE tool handling through AppState. |
| RxCode/Services/CodexAppServer.swift | Adds AgentBackend conformance for Codex. |
| RxCode/Services/ClaudeService.swift | Adds AgentBackend conformance for Claude. |
| RxCode/Services/ACPService.swift | Refactors ACP transport around pooled sessions and backend conformance. |
| RxCode/App/AppState.swift | Dispatches through AgentBackend and wires ACP IDE MCP setup. |
| RxCode.xcodeproj/project.pbxproj | Adds the new history archive tests to the test target. |
| Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift | Adds IDE-side MCP tool descriptors and capability gating. |
| Packages/Sources/RxCodeCore/Backend/IDEToolHandling.swift | Adds IDE tool handler protocol and errors. |
| Packages/Sources/RxCodeCore/Backend/BackendCapability.swift | Adds backend capability model and defaults. |
| Packages/Sources/RxCodeCore/Backend/AgentBackend.swift | Adds unified backend request/protocol abstractions. |
Comments suppressed due to low confidence (7)
RxCode/Services/IDEServer/IDEMCPServer.swift:138
- The listener accepts any loopback connection on a predictable port and immediately binds it to the session without an authentication token. Any local process can scan this 100-port range and call IDE MCP tools such as thread history, usage, or future file/job tools for another session. Include an unguessable per-allocation secret in the bridge command and require it during initialization before serving tools.
private func accept(connection: NWConnection, port: UInt16) async {
guard let sessionKey = portIndex[port] else {
connection.cancel()
return
}
guard let capabilities = allocations[sessionKey]?.capabilities else {
connection.cancel()
return
}
Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift:101
ide__get_job_outputis exposed unconditionally as an IDE-only tool, but the AppState handler currently throwsnotSupportedfor this name. Agents will see it intools/list, call it, and receive an error despite the description promising job output; either implement the handler or omit this descriptor until it works.
IDETool(
name: "ide__get_job_output",
description: "Fetch the tail of a running job's output buffer.",
visibility: .alwaysIDEOnly,
inputSchema: .object([
RxCode/Services/IDEServer/AppState+IDEToolHandling.swift:136
ide__get_usageignores thesessionKeyand uses the globally selected provider, so a tool call from an ACP session can report Claude/Codex usage if the UI selection has changed or the session has its own provider override. Use the session'sagentProvider(falling back to the active selection only when absent) so the usage data matches the backend that made the MCP call.
private func handleGetUsage() async -> JSONValue {
let provider = await MainActor.run { selectedAgentProvider }
let usage = await rateLimitUsage(for: provider, forceRefresh: false)
RxCode/Services/IDEServer/AppState+IDEToolHandling.swift:91
- An invalid
project_idstring is converted tonil, which then falls through to the "all projects" path and returns thread metadata across every project. Treat malformed UUIDs as invalid arguments instead of silently widening the scope.
let projectFilter: UUID? = {
if let s = arguments["project_id"]?.stringValue { return UUID(uuidString: s) }
return nil
RxCode/Services/IDEServer/AppState+IDEToolHandling.swift:67
- Malformed todo entries are silently discarded via
compactMap, and the snapshot is still written with the remaining items (or an empty list). A single badstatus/missingcontentfrom the agent can unintentionally erase existing todos while returning success; validate every entry and throwinvalidArgumentsif any item is malformed.
let parsed: [TodoItem] = todosArray.enumerated().compactMap { idx, entry -> TodoItem? in
guard
let dict = entry.objectValue,
let content = dict["content"]?.stringValue,
let statusRaw = dict["status"]?.stringValue,
let status = TodoItem.Status(rawValue: statusRaw)
else { return nil }
let activeForm = dict["activeForm"]?.stringValue ?? content
return TodoItem(id: idx, content: content, activeForm: activeForm, status: status)
RxCode/Services/IDEServer/IDEMCPServer.swift:303
payloadis constructed and then discarded before constructing the identicalbodydictionary. This dead assignment adds noise in a protocol response path and should be removed.
private func reply(id: Any, result: Any, on connection: NWConnection) async {
var payload: [String: Any] = [
"jsonrpc": "2.0",
"id": id,
"result": result
]
_ = payload
let body: [String: Any] = [
RxCode/Services/IDEServer/IDEMCPServer.swift:118
- Releasing a session only cancels the listener; accepted
NWConnections are not stored and therefore cannot be cancelled. Any already-connected MCP bridge can continue to call tools for the released session until it disconnects on its own, which defeats the session-boundary cleanup this method is meant to enforce.
func release(sessionKey: String) async {
guard let alloc = allocations.removeValue(forKey: sessionKey) else { return }
portIndex.removeValue(forKey: alloc.port)
alloc.listener.cancel()
Self.logger.info("[IDE] released port \(alloc.port) for session=\(sessionKey, privacy: .public)")
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let idePort = await ideMCPServer.allocate( | ||
| sessionKey: sessionKey, |
| public func ideAvailableTools(forSession sessionKey: String) async -> [IDETool] { | ||
| let provider = await MainActor.run { sessionStates[sessionKey]?.agentProvider } ?? .acp | ||
| let caps = await backend(for: provider).capabilities(for: sessionKey) | ||
| return IDEToolRegistry.tools(for: caps) | ||
| } | ||
|
|
||
| public func ideHandleToolCall( |
| var alloc = Allocation( | ||
| port: candidate, | ||
| listener: listener, | ||
| sessionKey: sessionKey, | ||
| capabilities: capabilities | ||
| ) | ||
| _ = alloc | ||
| allocations[sessionKey] = Allocation( | ||
| port: candidate, | ||
| listener: listener, | ||
| sessionKey: sessionKey, | ||
| capabilities: capabilities | ||
| ) |
| name: "ide__ask_user", | ||
| description: "Ask the user a question and wait for their selection. Use when you need clarification or a decision the user must make.", | ||
| visibility: .polyfill(.askUserQuestion), | ||
| inputSchema: .object([ | ||
| "type": .string("object"), | ||
| "properties": .object([ | ||
| "question": .object([ | ||
| "type": .string("string"), | ||
| "description": .string("The question to show the user."), | ||
| ]), | ||
| "options": .object([ | ||
| "type": .string("array"), | ||
| "items": .object(["type": .string("string")]), | ||
| "description": .string("2–4 mutually-exclusive options the user can pick from."), | ||
| ]), | ||
| "allow_multiple": .object([ | ||
| "type": .string("boolean"), | ||
| "description": .string("Whether more than one option may be selected."), | ||
| ]), | ||
| ]), | ||
| "required": .array([.string("question"), .string("options")]), | ||
| ]) | ||
| ), | ||
| IDETool( |
| ), | ||
| IDETool( | ||
| name: "ide__get_thread_detail", | ||
| description: "Fetch the message history of a specific thread by id.", |
| /// Release the per-session IDE-MCP listener allocated for ACP turns. | ||
| /// Safe to call for non-ACP providers (no-op). | ||
| private func releaseIDESession(sessionKey: String) async { | ||
| await ideMCPServer.release(sessionKey: sessionKey) |
| /// Releases per-stream bookkeeping for a turn that ran to completion. | ||
| /// The pooled agent process stays alive so the next turn for the same | ||
| /// session inherits its conversation memory. | ||
| func finalize(streamId: UUID) { | ||
| guard var entry = streams.removeValue(forKey: streamId) else { return } | ||
| logger.info("[ACP] finalize streamId=\(streamId.uuidString, privacy: .public) pid=\(entry.process.processIdentifier) pending=\(entry.pending.count) running=\(entry.process.isRunning)") | ||
| try? entry.stdin.close() | ||
| if entry.process.isRunning { | ||
| entry.process.terminate() | ||
| guard let key = streamToKey.removeValue(forKey: streamId) else { return } | ||
| guard var entry = sessions[key] else { return } |
| /// Cancels the in-flight turn for `streamId` via `session/cancel` but | ||
| /// keeps the agent process alive so the user can immediately send a new | ||
| /// prompt without losing conversation state. | ||
| func cancel(streamId: UUID) { | ||
| // Attempt graceful cancel via session/cancel notification, then kill the process. | ||
| if let entry = streams[streamId], let sid = entry.agentSessionId { | ||
| guard let key = streamToKey[streamId], let entry = sessions[key] else { | ||
| return | ||
| } | ||
| if let agentSid = entry.agentSessionId { | ||
| let frame: [String: Any] = [ | ||
| "jsonrpc": "2.0", | ||
| "method": "session/cancel", | ||
| "params": ["sessionId": sid] | ||
| "params": ["sessionId": agentSid] | ||
| ] | ||
| if let data = try? JSONSerialization.data(withJSONObject: frame), | ||
| let line = String(data: data, encoding: .utf8) { | ||
| _ = try? entry.stdin.write(contentsOf: Data((line + "\n").utf8)) | ||
| } | ||
| } | ||
| // Resume any pending requests with .streamClosed so the runTurn await | ||
| // unblocks and we hit the per-turn cleanup path in finalize. | ||
| mutateSession(key) { e in | ||
| for (_, cont) in e.pending { | ||
| cont.resume(throwing: ACPError.streamClosed) | ||
| } | ||
| e.pending.removeAll() | ||
| e.continuation?.finish() | ||
| } | ||
| finalize(streamId: streamId) |
| if let bridge = bridgeCommand { | ||
| entries.append(.object([ | ||
| "name": .string("rxcode-ide"), | ||
| "command": .string(bridge.command), |
| let codex = self.codex | ||
| let ideMCPServer = self.ideMCPServer | ||
| Task { | ||
| await acp.setPermissionServer(permission) | ||
| await codex.setPermissionServer(permission) | ||
| await ideMCPServer.setHandler(self) |
|
🎉 This PR is included in version 1.3.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
No description provided.