Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions Packages/Sources/RxCodeCore/Backend/AgentBackend.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import Foundation

/// Unified send envelope passed to any `AgentBackend`. Carries the superset
/// of parameters the three current backends need; each adapter consumes only
/// the fields it cares about.
public struct BackendSendRequest: Sendable {
public let streamId: UUID
public let prompt: String
public let cwd: String
/// CLI session id (Claude) / thread id (Codex) / ACP session id, depending
/// on the backend. `nil` means "start a new session".
public let sessionId: String?
public let model: String?
public let effort: String?
public let permissionMode: PermissionMode
public let planMode: Bool
/// Path to the Claude hook settings JSON written for this turn. Claude-only.
public let hookSettingsPath: String?
/// Path to the Claude MCP config JSON written for this turn. Claude-only.
public let mcpClaudeConfigPath: String?
/// `-c` overrides handed to the Codex app-server child. Codex-only.
public let mcpCodexOverrides: [String]
/// JSON-RPC payload for ACP's `session/new` `mcpServers` parameter.
/// ACP-only. Each element is an object with `name`/`command`/`args`/`env`.
public let acpMCPServers: [JSONValue]
/// Resolved client spec when `provider == .acp`. Ignored otherwise.
public let acpSpec: ACPClientSpec?
/// AppState's internal session key for the pooled ACP entry.
public let clientSessionKey: String

public init(
streamId: UUID,
prompt: String,
cwd: String,
sessionId: String?,
model: String?,
effort: String? = nil,
permissionMode: PermissionMode,
planMode: Bool = false,
hookSettingsPath: String? = nil,
mcpClaudeConfigPath: String? = nil,
mcpCodexOverrides: [String] = [],
acpMCPServers: [JSONValue] = [],
acpSpec: ACPClientSpec? = nil,
clientSessionKey: String
) {
self.streamId = streamId
self.prompt = prompt
self.cwd = cwd
self.sessionId = sessionId
self.model = model
self.effort = effort
self.permissionMode = permissionMode
self.planMode = planMode
self.hookSettingsPath = hookSettingsPath
self.mcpClaudeConfigPath = mcpClaudeConfigPath
self.mcpCodexOverrides = mcpCodexOverrides
self.acpMCPServers = acpMCPServers
self.acpSpec = acpSpec
self.clientSessionKey = clientSessionKey
}
}

/// Common surface for every agent transport (Claude CLI, Codex app-server,
/// ACP). AppState dispatches via this protocol instead of switching on
/// `AgentProvider` directly.
///
/// Lifecycle per turn:
/// 1. AppState builds a `BackendSendRequest` and calls `send(_:)`.
/// 2. The returned `AsyncStream<StreamEvent>` carries unified events.
/// 3. On natural completion AppState calls `finalize(streamId:)`.
/// 4. On user-initiated stop AppState calls `cancel(streamId:)`.
public protocol AgentBackend: Actor {
nonisolated var provider: AgentProvider { get }
nonisolated var staticCapabilities: CapabilitySet { get }

/// Runtime override hook. Phase 1 returns `staticCapabilities`; Phase 2+
/// can refine after the agent's handshake reports its real toolset.
func capabilities(for sessionKey: String) async -> CapabilitySet

func send(_ request: BackendSendRequest) -> AsyncStream<StreamEvent>
func cancel(streamId: UUID)
func finalize(streamId: UUID)
}

public extension AgentBackend {
func capabilities(for sessionKey: String) async -> CapabilitySet {
staticCapabilities
}
}
42 changes: 42 additions & 0 deletions Packages/Sources/RxCodeCore/Backend/BackendCapability.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation

/// Editor-facing features an agent backend may natively support. A backend
/// that lacks a capability gets it polyfilled via the IDE-side MCP server
/// (Phase 2+) so the user-visible behavior is uniform across providers.
public enum BackendCapability: String, Sendable, Hashable, CaseIterable, Codable {
case askUserQuestion
case todos
case planMode
case fileEdit
case getUsage
case customSlashCommands
case attachments
case hooks
case mcpServers
}

public typealias CapabilitySet = Set<BackendCapability>

public extension AgentProvider {
/// Compile-time defaults. Phase 1 ships with these; backends may later
/// override at runtime via `AgentBackend.capabilities(for:)` once handshake
/// negotiation lands (Phase 2+).
var staticCapabilities: CapabilitySet {
switch self {
case .claudeCode:
return [
.askUserQuestion, .todos, .planMode, .fileEdit, .hooks,
.mcpServers, .attachments, .customSlashCommands, .getUsage,
]
case .codex:
return [
.askUserQuestion, .todos, .planMode, .fileEdit,
.mcpServers, .attachments, .getUsage,
]
case .acp:
return [
.planMode, .fileEdit, .mcpServers, .attachments, .getUsage,
]
}
}
}
30 changes: 30 additions & 0 deletions Packages/Sources/RxCodeCore/Backend/IDEToolHandling.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation

/// Anything that can serve IDE-side MCP tool calls. AppState conforms in
/// the host target so the bridge listener stays decoupled from UI state.
///
/// All entry points are isolated to the conforming actor; the bridge hops
/// to the actor before invoking. Handlers may freely touch UI state.
public protocol IDEToolHandling: AnyObject, Sendable {
/// Tools exposed to the agent for `sessionKey`. The bridge calls this
/// at `tools/list` time. Implementations typically derive the list from
/// `IDEToolRegistry.tools(for:)` minus any per-session opt-outs.
func ideAvailableTools(forSession sessionKey: String) async -> [IDETool]

/// Dispatch a `tools/call`. Returns the tool result as a `JSONValue`
/// (typically `.object(["content": .array([...]) ])` per the MCP spec)
/// or throws an `IDEToolError`. The bridge translates throws into MCP
/// error responses.
func ideHandleToolCall(
name: String,
arguments: JSONValue,
sessionKey: String
) async throws -> JSONValue
}

public enum IDEToolError: Error, Sendable {
case unknownTool(String)
case invalidArguments(String)
case notSupported(String)
case handlerFailed(String)
}
161 changes: 161 additions & 0 deletions Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import Foundation

/// Catalog of IDE-side MCP tools the editor can expose to agent backends.
/// Tools are gated by the *complement* of the backend's capability set:
/// if a backend natively supports a feature, the corresponding polyfill
/// tool is NOT registered for that session. Tools tagged `.alwaysIDEOnly`
/// are exposed to every backend regardless of capabilities — they expose
/// IDE-only state (running jobs, thread history, usage) that has no
/// equivalent in any agent's native toolset.
public struct IDETool: Sendable {
public enum Visibility: Sendable {
/// Exposed only when the backend lacks the matched capability.
case polyfill(BackendCapability)
/// Exposed unconditionally — agent-native equivalents don't exist.
case alwaysIDEOnly
}

public let name: String
public let description: String
public let visibility: Visibility
/// JSON Schema for the tool's input parameters, serialized as a
/// `JSONValue` object. Consumed by the MCP server's `tools/list` handler.
public let inputSchema: JSONValue

public init(name: String, description: String, visibility: Visibility, inputSchema: JSONValue) {
self.name = name
self.description = description
self.visibility = visibility
self.inputSchema = inputSchema
}
}

public enum IDEToolRegistry {
public static let allTools: [IDETool] = [
IDETool(
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(
Comment on lines +36 to +59
name: "ide__set_todos",
description: "Replace the current todo list shown in the IDE sidebar. Use to track multi-step work.",
visibility: .polyfill(.todos),
inputSchema: .object([
"type": .string("object"),
"properties": .object([
"todos": .object([
"type": .string("array"),
"items": .object([
"type": .string("object"),
"properties": .object([
"content": .object(["type": .string("string")]),
"status": .object([
"type": .string("string"),
"enum": .array([
.string("pending"),
.string("in_progress"),
.string("completed"),
]),
]),
]),
"required": .array([.string("content"), .string("status")]),
]),
]),
]),
"required": .array([.string("todos")]),
])
),
IDETool(
name: "ide__get_running_jobs",
description: "List run-profile jobs (dev servers, scripts) currently executing in the IDE.",
visibility: .alwaysIDEOnly,
inputSchema: .object([
"type": .string("object"),
"properties": .object([:]),
])
),
IDETool(
name: "ide__get_job_output",
description: "Fetch the tail of a running job's output buffer.",
visibility: .alwaysIDEOnly,
inputSchema: .object([
"type": .string("object"),
"properties": .object([
"job_id": .object(["type": .string("string")]),
"limit": .object([
"type": .string("integer"),
"description": .string("Maximum lines to return from the tail. Defaults to 200."),
]),
]),
"required": .array([.string("job_id")]),
])
),
IDETool(
name: "ide__get_threads",
description: "List chat threads in the current project (or all projects if none specified).",
visibility: .alwaysIDEOnly,
inputSchema: .object([
"type": .string("object"),
"properties": .object([
"project_id": .object(["type": .string("string")]),
]),
])
),
IDETool(
name: "ide__get_thread_detail",
description: "Fetch the message history of a specific thread by id.",
visibility: .alwaysIDEOnly,
inputSchema: .object([
"type": .string("object"),
"properties": .object([
"thread_id": .object(["type": .string("string")]),
]),
"required": .array([.string("thread_id")]),
])
),
IDETool(
name: "ide__get_usage",
description: "Get current rate-limit / token usage stats reported by the active provider.",
visibility: .alwaysIDEOnly,
inputSchema: .object([
"type": .string("object"),
"properties": .object([:]),
])
),
]

/// Returns the tools that should be exposed to an agent whose declared
/// capabilities are `capabilities`. Polyfill tools whose capability is
/// already covered natively are filtered out; `alwaysIDEOnly` tools
/// always pass through.
public static func tools(for capabilities: CapabilitySet) -> [IDETool] {
allTools.filter { tool in
switch tool.visibility {
case .polyfill(let cap):
return !capabilities.contains(cap)
case .alwaysIDEOnly:
return true
}
}
}
}
4 changes: 4 additions & 0 deletions RxCode.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; };
DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; };
DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; };
DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */; };
E6821AC12F7CEE7200829FC9 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = E6A001012F8A000100000001 /* SwiftTerm */; };
E6C001022F9B000100000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E6C001012F9B000100000001 /* Sparkle */; };
E6D001032FA0000100000001 /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001012FA0000100000001 /* RxCodeCore */; };
Expand All @@ -36,6 +37,7 @@
A9993BB72A5307039A88B729 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; };
DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanDecisionTests.swift; sourceTree = "<group>"; };
DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanCardViewTests.swift; sourceTree = "<group>"; };
DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryListArchiveFilterTests.swift; sourceTree = "<group>"; };
E67335382F7356F600FD26C7 /* RxCode.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RxCode.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -79,6 +81,7 @@
4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */,
DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */,
DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */,
DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */,
);
path = RxCodeTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -237,6 +240,7 @@
33993F0F87CF4DB09F2813A8 /* AppStateProjectSwitchTests.swift in Sources */,
DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */,
DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */,
DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
Loading