Files
1jehuang--jcode/ios/Sources/JCodeKit/Wire.swift
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

436 lines
16 KiB
Swift

import Foundation
/// Client request to a jcode server.
///
/// Wire format mirrors `crates/jcode-protocol/src/wire.rs` (`#[serde(tag = "type")]`,
/// snake_case tags). Only the requests the iOS app uses are modeled; the server
/// ignores fields it does not expect.
public enum Request: Equatable, Sendable {
case subscribe(id: UInt64, targetSessionID: String?)
case message(id: UInt64, content: String)
case cancel(id: UInt64)
case softInterrupt(id: UInt64, content: String, urgent: Bool)
case cancelSoftInterrupts(id: UInt64)
case ping(id: UInt64)
case getHistory(id: UInt64)
case resumeSession(id: UInt64, sessionID: String)
case setModel(id: UInt64, model: String)
case setReasoningEffort(id: UInt64, effort: String)
case compact(id: UInt64)
case renameSession(id: UInt64, title: String?)
case clear(id: UInt64)
public var id: UInt64 {
switch self {
case let .subscribe(id, _), let .message(id, _), let .cancel(id),
let .softInterrupt(id, _, _), let .cancelSoftInterrupts(id),
let .ping(id), let .getHistory(id), let .resumeSession(id, _),
let .setModel(id, _), let .setReasoningEffort(id, _), let .compact(id),
let .renameSession(id, _), let .clear(id):
return id
}
}
/// Encodes the request as a single JSON line (no trailing newline).
public func encodedLine() throws -> String {
var object: [String: Any] = ["id": id]
switch self {
case let .subscribe(_, targetSessionID):
object["type"] = "subscribe"
if let targetSessionID {
object["target_session_id"] = targetSessionID
}
case let .message(_, content):
object["type"] = "message"
object["content"] = content
case .cancel:
object["type"] = "cancel"
case let .softInterrupt(_, content, urgent):
object["type"] = "soft_interrupt"
object["content"] = content
object["urgent"] = urgent
case .cancelSoftInterrupts:
object["type"] = "cancel_soft_interrupts"
case .ping:
object["type"] = "ping"
case .getHistory:
object["type"] = "get_history"
case let .resumeSession(_, sessionID):
object["type"] = "resume_session"
object["session_id"] = sessionID
case let .setModel(_, model):
object["type"] = "set_model"
object["model"] = model
case let .setReasoningEffort(_, effort):
object["type"] = "set_reasoning_effort"
object["effort"] = effort
case .compact:
object["type"] = "compact"
case let .renameSession(_, title):
object["type"] = "rename_session"
if let title {
object["title"] = title
}
case .clear:
object["type"] = "clear"
}
let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
guard let line = String(data: data, encoding: .utf8) else {
throw WireError.encodingFailed
}
return line
}
}
/// A tool call as represented in history payloads.
public struct ToolCallRecord: Equatable, Sendable {
public var id: String
public var name: String
public var input: String
public var output: String?
public var error: String?
public init(id: String, name: String, input: String, output: String?, error: String?) {
self.id = id
self.name = name
self.input = input
self.output = output
self.error = error
}
}
/// A message in conversation history (response to `get_history`).
public struct HistoryMessage: Equatable, Sendable {
public var role: String
public var content: String
public var toolCalls: [String]
public var toolData: ToolCallRecord?
public init(
role: String, content: String, toolCalls: [String] = [], toolData: ToolCallRecord? = nil
) {
self.role = role
self.content = content
self.toolCalls = toolCalls
self.toolData = toolData
}
}
/// Server event from a jcode server. Unknown event types decode as `.unknown`
/// so newer servers never break older apps.
public enum ServerEvent: Equatable, Sendable {
case ack(id: UInt64)
case textDelta(text: String)
case reasoningDelta(text: String)
case reasoningDone(durationSecs: Double?)
case textReplace(text: String)
case toolStart(id: String, name: String)
case toolInput(delta: String)
case toolExec(id: String, name: String)
case toolDone(id: String, name: String, output: String, error: String?)
case tokenUsage(input: UInt64, output: UInt64)
case statusDetail(detail: String)
case connectionPhase(phase: String)
case softInterruptInjected(content: String, displayRole: String?, point: String, toolsSkipped: Int?)
case retryRollback(attempt: Int, max: Int)
case messageEnd
case interrupted
case done(id: UInt64)
case error(id: UInt64, message: String, retryAfterSecs: UInt64?)
case pong(id: UInt64)
case state(id: UInt64, sessionID: String, messageCount: Int, isProcessing: Bool)
case sessionID(sessionID: String)
case sessionRenamed(sessionID: String, displayTitle: String)
case history(HistoryPayload)
case modelChanged(id: UInt64, model: String, error: String?)
case reasoningEffortChanged(id: UInt64, effort: String?, error: String?)
case compactResult(id: UInt64, message: String, success: Bool)
case availableModelsUpdated(models: [String], providerModel: String?)
case compaction(trigger: String, tokensSaved: UInt64?)
case notification(fromName: String?, message: String)
case reloading(newSocket: String?)
case sessionCloseRequested(reason: String)
case unknown(type: String)
public struct HistoryPayload: Equatable, Sendable {
public var id: UInt64
public var sessionID: String
public var messages: [HistoryMessage]
public var providerName: String?
public var providerModel: String?
public var availableModels: [String]
public var totalTokens: TokenTotals?
public var allSessions: [String]
public var serverVersion: String?
public var displayTitle: String?
public var reasoningEffort: String?
public struct TokenTotals: Equatable, Sendable {
public var input: UInt64
public var output: UInt64
public init(input: UInt64, output: UInt64) {
self.input = input
self.output = output
}
}
public init(
id: UInt64,
sessionID: String,
messages: [HistoryMessage],
providerName: String? = nil,
providerModel: String? = nil,
availableModels: [String] = [],
totalTokens: TokenTotals? = nil,
allSessions: [String] = [],
serverVersion: String? = nil,
displayTitle: String? = nil,
reasoningEffort: String? = nil
) {
self.id = id
self.sessionID = sessionID
self.messages = messages
self.providerName = providerName
self.providerModel = providerModel
self.availableModels = availableModels
self.totalTokens = totalTokens
self.allSessions = allSessions
self.serverVersion = serverVersion
self.displayTitle = displayTitle
self.reasoningEffort = reasoningEffort
}
}
/// Decodes one newline-delimited JSON event line.
public static func decode(line: String) throws -> ServerEvent {
guard let data = line.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data),
let object = parsed as? [String: Any]
else {
throw WireError.invalidJSON(line: line)
}
guard let type = object["type"] as? String else {
throw WireError.missingType(line: line)
}
let json = JSONObject(object)
switch type {
case "ack":
return .ack(id: json.uint64("id"))
case "text_delta":
return .textDelta(text: json.string("text"))
case "reasoning_delta":
return .reasoningDelta(text: json.string("text"))
case "reasoning_done":
return .reasoningDone(durationSecs: json.optionalDouble("duration_secs"))
case "text_replace":
return .textReplace(text: json.string("text"))
case "tool_start":
return .toolStart(id: json.string("id"), name: json.string("name"))
case "tool_input":
return .toolInput(delta: json.string("delta"))
case "tool_exec":
return .toolExec(id: json.string("id"), name: json.string("name"))
case "tool_done":
return .toolDone(
id: json.string("id"),
name: json.string("name"),
output: json.string("output"),
error: json.optionalString("error")
)
case "tokens":
return .tokenUsage(input: json.uint64("input"), output: json.uint64("output"))
case "status_detail":
return .statusDetail(detail: json.string("detail"))
case "connection_phase":
return .connectionPhase(phase: json.string("phase"))
case "soft_interrupt_injected":
return .softInterruptInjected(
content: json.string("content"),
displayRole: json.optionalString("display_role"),
point: json.string("point"),
toolsSkipped: json.optionalInt("tools_skipped")
)
case "retry_rollback":
return .retryRollback(attempt: json.int("attempt"), max: json.int("max"))
case "message_end":
return .messageEnd
case "interrupted":
return .interrupted
case "done":
return .done(id: json.uint64("id"))
case "error":
return .error(
id: json.uint64("id"),
message: json.string("message"),
retryAfterSecs: json.optionalUInt64("retry_after_secs")
)
case "pong":
return .pong(id: json.uint64("id"))
case "state":
return .state(
id: json.uint64("id"),
sessionID: json.string("session_id"),
messageCount: json.int("message_count"),
isProcessing: json.bool("is_processing")
)
case "session":
return .sessionID(sessionID: json.string("session_id"))
case "session_renamed":
return .sessionRenamed(
sessionID: json.string("session_id"),
displayTitle: json.string("display_title")
)
case "history":
return .history(decodeHistory(json))
case "model_changed":
return .modelChanged(
id: json.uint64("id"),
model: json.string("model"),
error: json.optionalString("error")
)
case "reasoning_effort_changed":
return .reasoningEffortChanged(
id: json.uint64("id"),
effort: json.optionalString("effort"),
error: json.optionalString("error")
)
case "compact_result":
return .compactResult(
id: json.uint64("id"),
message: json.string("message"),
success: json.bool("success")
)
case "available_models_updated":
return .availableModelsUpdated(
models: json.stringArray("available_models"),
providerModel: json.optionalString("provider_model")
)
case "compaction":
return .compaction(
trigger: json.string("trigger"),
tokensSaved: json.optionalUInt64("tokens_saved")
)
case "notification":
return .notification(
fromName: json.optionalString("from_name"),
message: json.string("message")
)
case "reloading":
return .reloading(newSocket: json.optionalString("new_socket"))
case "session_close_requested":
return .sessionCloseRequested(reason: json.string("reason"))
default:
return .unknown(type: type)
}
}
private static func decodeHistory(_ json: JSONObject) -> HistoryPayload {
let messages = json.objectArray("messages").map { msg -> HistoryMessage in
var toolData: ToolCallRecord?
if let td = msg.optionalObject("tool_data") {
toolData = ToolCallRecord(
id: td.string("id"),
name: td.string("name"),
input: td.string("input"),
output: td.optionalString("output"),
error: td.optionalString("error")
)
}
return HistoryMessage(
role: msg.string("role"),
content: msg.string("content"),
toolCalls: msg.stringArray("tool_calls"),
toolData: toolData
)
}
var totals: HistoryPayload.TokenTotals?
if let pair = json.raw["total_tokens"] as? [Any], pair.count == 2,
let input = JSONObject.coerceUInt64(pair[0]),
let output = JSONObject.coerceUInt64(pair[1])
{
totals = HistoryPayload.TokenTotals(input: input, output: output)
}
return HistoryPayload(
id: json.uint64("id"),
sessionID: json.string("session_id"),
messages: messages,
providerName: json.optionalString("provider_name"),
providerModel: json.optionalString("provider_model"),
availableModels: json.stringArray("available_models"),
totalTokens: totals,
allSessions: json.stringArray("all_sessions"),
serverVersion: json.optionalString("server_version"),
displayTitle: json.optionalString("display_title"),
reasoningEffort: json.optionalString("reasoning_effort")
)
}
}
public enum WireError: Error, Equatable {
case encodingFailed
case invalidJSON(line: String)
case missingType(line: String)
}
/// Lenient JSON accessor. The wire protocol omits absent optionals and the
/// app must never crash on a server that is newer or older than itself.
struct JSONObject {
let raw: [String: Any]
init(_ raw: [String: Any]) {
self.raw = raw
}
func string(_ key: String) -> String {
raw[key] as? String ?? ""
}
func optionalString(_ key: String) -> String? {
raw[key] as? String
}
func bool(_ key: String) -> Bool {
raw[key] as? Bool ?? false
}
func int(_ key: String) -> Int {
(raw[key] as? NSNumber)?.intValue ?? 0
}
func optionalInt(_ key: String) -> Int? {
(raw[key] as? NSNumber)?.intValue
}
func uint64(_ key: String) -> UInt64 {
Self.coerceUInt64(raw[key] ?? 0) ?? 0
}
func optionalUInt64(_ key: String) -> UInt64? {
raw[key].flatMap(Self.coerceUInt64)
}
func optionalDouble(_ key: String) -> Double? {
(raw[key] as? NSNumber)?.doubleValue
}
func stringArray(_ key: String) -> [String] {
raw[key] as? [String] ?? []
}
func objectArray(_ key: String) -> [JSONObject] {
(raw[key] as? [[String: Any]])?.map(JSONObject.init) ?? []
}
func optionalObject(_ key: String) -> JSONObject? {
(raw[key] as? [String: Any]).map(JSONObject.init)
}
static func coerceUInt64(_ value: Any) -> UInt64? {
if let number = value as? NSNumber {
return number.uint64Value
}
return nil
}
}