Files
nucleic/Sources/NucleicCore/JSONRPCConnection.swift
T
NucleicandClaude Opus 4.8 609ce70539 Migrate Grok backend to ACP (grok agent stdio)
Replace the streaming-json CLI + generated PreToolUse-hook Grok adapter with grok's native ACP mode (JSON-RPC 2.0 over stdio). Adds GrokACPBackend/GrokACPDecoder/GrokACPDecisionMapping (a near-twin of the Codex app-server adapter); generalizes CodexJSONRPC into a shared JSONRPCConnection (jsonrpc-header flag, default off so Codex is byte-identical, plus endNotifications); deletes GrokBuildBackend, GrokStreamDecoder, GrokHookConfig and strips the /grok-hook HTTP bridge from MCPApprovalServer. Claude and Codex are left native. Preserves conflict-lock parity, native resume (session/load), and the hermetic fake-grok contract test. Full suite: 525 tests green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 02:52:24 -07:00

205 lines
9.0 KiB
Swift

import Foundation
/// A small JSON-RPC 2.0 client layered over a `ProcessHandle`'s stdio (ADAPTERS §0.2), shared by
/// the two duplex-stdio backends:
///
/// - **`codex app-server`** ([`CodexAppServerBackend`](Codex/CodexAppServerBackend.swift)) —
/// `includeVersionHeader: false`: codex omits the `"jsonrpc":"2.0"` header on the wire (its
/// source-confirmed dialect — it neither emits nor requires it), so we match that exactly.
/// - **`grok agent stdio`** ([`GrokACPBackend`](Grok/GrokACPBackend.swift)) —
/// `includeVersionHeader: true`: the Agent Client Protocol is *standards* JSON-RPC 2.0, so the
/// header is present on every message.
///
/// Three message classes flow over one newline-delimited (JSONL) channel:
///
/// - outbound **requests** (`id` + `method`) → correlated to an `await`ed continuation by `id`;
/// - inbound **notifications** (`method`, no `id`) → published on `notifications`;
/// - inbound **server→client requests** (`id` + `method`) → dispatched to the handler registered
/// in `start`, which must reply with a `result` keyed by the same `id`. **Approvals arrive this
/// way** — codex's `…/requestApproval` and ACP's `session/request_permission` alike (ADAPTERS §2.4).
public actor JSONRPCConnection {
public struct Notification: Sendable, Equatable {
public let method: String
public let params: JSONValue
public init(method: String, params: JSONValue) {
self.method = method
self.params = params
}
}
/// A server→client request we must answer (e.g. an approval). `id` is echoed
/// verbatim into the reply — it may be a number or a string on the wire.
public struct InboundRequest: Sendable, Equatable {
public let id: JSONValue
public let method: String
public let params: JSONValue
public init(id: JSONValue, method: String, params: JSONValue) {
self.id = id
self.method = method
self.params = params
}
}
public enum RPCError: Error, Sendable, Equatable {
/// The peer returned an `error` object for one of our requests.
case server(code: Int, message: String)
/// The connection closed before the response arrived.
case connectionClosed
}
private let handle: any ProcessHandle
/// Emit the `"jsonrpc":"2.0"` header on outbound messages (standards JSON-RPC, e.g. ACP).
/// Off for codex app-server, whose confirmed dialect omits it.
private let includeVersionHeader: Bool
private var nextID = 0
private var pending: [Int: CheckedContinuation<JSONValue, Error>] = [:]
private var notificationContinuation: AsyncStream<Notification>.Continuation?
/// Buffered (unbounded) so notifications that arrive during `initialize`/`session/new`
/// — before the backend begins draining — are never dropped.
public nonisolated let notifications: AsyncStream<Notification>
private var requestHandler: (@Sendable (InboundRequest) async -> Void)?
private var readTask: Task<Void, Never>?
private var closed = false
public init(handle: any ProcessHandle, includeVersionHeader: Bool = false) {
self.handle = handle
self.includeVersionHeader = includeVersionHeader
var captured: AsyncStream<Notification>.Continuation!
self.notifications = AsyncStream(bufferingPolicy: .unbounded) { captured = $0 }
self.notificationContinuation = captured
}
/// Begin reading the peer's stdout. `onRequest` handles inbound server→client
/// requests; it runs detached so a suspended approval handler can't block the
/// read loop (and thus block our own request responses or further notifications).
public func start(onRequest: @escaping @Sendable (InboundRequest) async -> Void) {
requestHandler = onRequest
guard readTask == nil else { return }
let handle = self.handle
readTask = Task { [weak self] in
do {
for try await line in handle.stdoutLines {
await self?.ingest(line)
}
} catch {
await self?.terminate(reason: error)
return
}
await self?.terminate(reason: RPCError.connectionClosed)
}
}
// MARK: - Outbound
/// Send a request and suspend until the correlated response arrives.
public func request(_ method: String, params: JSONValue? = nil) async throws -> JSONValue {
if closed { throw RPCError.connectionClosed }
nextID += 1
let id = nextID
var fields: [String: JSONValue] = ["id": .number(Double(id)), "method": .string(method)]
if includeVersionHeader { fields["jsonrpc"] = .string("2.0") }
if let params { fields["params"] = params }
let payload = JSONValue.object(fields)
return try await withCheckedThrowingContinuation { continuation in
pending[id] = continuation
do {
try handle.writeLine(payload.encodedData())
} catch {
pending.removeValue(forKey: id)
continuation.resume(throwing: error)
}
}
}
/// Fire a notification (no response expected).
public func notify(_ method: String, params: JSONValue? = nil) throws {
var fields: [String: JSONValue] = ["method": .string(method)]
if includeVersionHeader { fields["jsonrpc"] = .string("2.0") }
if let params { fields["params"] = params }
try handle.writeLine(JSONValue.object(fields).encodedData())
}
/// Answer an inbound server→client request with a `result`.
public func reply(to id: JSONValue, result: JSONValue) throws {
var fields: [String: JSONValue] = ["id": id, "result": result]
if includeVersionHeader { fields["jsonrpc"] = .string("2.0") }
try handle.writeLine(JSONValue.object(fields).encodedData())
}
/// Answer an inbound server→client request with an `error`.
public func replyError(to id: JSONValue, code: Int, message: String) throws {
let error = JSONValue.object(["code": .number(Double(code)), "message": .string(message)])
var fields: [String: JSONValue] = ["id": id, "error": error]
if includeVersionHeader { fields["jsonrpc"] = .string("2.0") }
try handle.writeLine(JSONValue.object(fields).encodedData())
}
// MARK: - Inbound
private func ingest(_ line: Data) {
guard !line.isEmpty, let root = try? JSONValue(parsing: line),
let object = root.objectValue
else { return } // non-JSON noise on stdout is ignored, never fatal
let idValue = object["id"]
let hasID = (idValue != nil && idValue != .null)
if let method = object["method"]?.stringValue {
let params = object["params"] ?? .object([:])
if hasID, let idValue {
// Server→client request — dispatch detached so a suspended handler
// never stalls the read loop.
let request = InboundRequest(id: idValue, method: method, params: params)
if let requestHandler {
Task { await requestHandler(request) }
}
} else {
notificationContinuation?.yield(Notification(method: method, params: params))
}
return
}
// No method → a response to one of our requests.
guard hasID, let id = idValue?.intValue,
let continuation = pending.removeValue(forKey: id)
else { return }
if let error = object["error"] {
continuation.resume(
throwing: RPCError.server(
code: error["code"]?.intValue ?? -1,
message: error["message"]?.stringValue ?? "RPC error"))
} else {
continuation.resume(returning: object["result"] ?? .null)
}
}
/// Finish the notification stream *without* failing in-flight requests or stopping the read
/// loop. The ACP turn terminal is the `session/prompt` *response*, not a notification, so the
/// backend awaits that response and then calls this — the unbounded buffer guarantees every
/// already-yielded `session/update` is still delivered to the drain loop before it ends, so no
/// trailing chunk is dropped. Idempotent.
public func endNotifications() {
notificationContinuation?.finish()
notificationContinuation = nil
}
/// Stream closed (peer exited or read error): fail every in-flight request and
/// end the notification stream so the backend's drain loop terminates.
private func terminate(reason: Error) {
guard !closed else { return }
closed = true
for (_, continuation) in pending {
continuation.resume(throwing: reason)
}
pending.removeAll()
notificationContinuation?.finish()
notificationContinuation = nil
}
/// Stop reading and release continuations (called on backend shutdown).
public func close() {
readTask?.cancel()
terminate(reason: RPCError.connectionClosed)
}
}