Merge nucleic/fuzzy-spruce-dingo-vpaa into dev

This commit is contained in:
2026-07-30 19:36:16 -07:00
parent 56023a9e93
commit ff72a577f3
@@ -0,0 +1,187 @@
import Foundation
import NucleicProtocol
/// Stateful, platform-neutral transcript projection for non-Swift renderers.
///
/// The Windows DLL feeds this the same `HostMsg` JSON it sends to the C# state store. Messages
/// which change a transcript produce a single minimal splice: preserve the common prefix and
/// suffix, delete the changed middle, and insert its replacement. That shape maps directly onto
/// WinUI's `ItemsRepeater` without exposing Swift implementation types across the ABI.
public final class TranscriptProjectionABI {
private final class SessionState {
var events: [AgentEvent] = []
var items: [TranscriptItem] = []
let projector = IncrementalTranscriptProjection()
}
private var sessions: [SessionID: SessionState] = [:]
public init() {}
/// Apply one host message and return canonical, versioned render-diff JSON.
///
/// Messages unrelated to transcripts return `{"splices":[],"version":1}`. Snapshot, live
/// event, full-history chunk, and revert messages update only their named session.
public func apply(_ message: HostMsg) throws -> String {
let update: (SessionID, [AgentEvent])?
switch message {
case .snapshot(let snapshot):
update = (snapshot.summary.sessionID, snapshot.recentEvents)
case .events(let batch):
update = (batch.sessionID, batch.events)
case .transcriptChunk(let chunk):
update = (chunk.sessionID, chunk.events)
case .transcriptReverted(let reverted):
let state = sessions[reverted.sessionID] ?? SessionState()
sessions[reverted.sessionID] = state
if reverted.throughSeq == 0 {
state.events.removeAll()
} else {
state.events.removeAll { $0.seq > reverted.throughSeq }
}
return try renderDiff(sessionID: reverted.sessionID, state: state)
default:
return JSONValue.object([
"version": .number(1),
"splices": .array([]),
]).canonicalString()
}
guard let (sessionID, incoming) = update else {
preconditionFailure("transcript update switch must assign an update")
}
let state = sessions[sessionID] ?? SessionState()
sessions[sessionID] = state
state.events = Self.merged(state.events, incoming)
return try renderDiff(sessionID: sessionID, state: state)
}
private func renderDiff(sessionID: SessionID, state: SessionState) throws -> String {
let previous = state.items
let next = state.projector.items(
for: state.events, showRaw: false, showLockEvents: true)
state.items = next
var prefix = 0
while prefix < previous.count, prefix < next.count, previous[prefix] == next[prefix] {
prefix += 1
}
var suffix = 0
while suffix < previous.count - prefix, suffix < next.count - prefix,
previous[previous.count - suffix - 1] == next[next.count - suffix - 1] {
suffix += 1
}
let splices: [JSONValue]
if prefix == previous.count, prefix == next.count {
splices = []
} else {
let inserted = next[prefix..<(next.count - suffix)].map(Self.json)
splices = [.object([
"start": .number(Double(prefix)),
"deleteCount": .number(Double(previous.count - prefix - suffix)),
"items": .array(inserted),
])]
}
return JSONValue.object([
"version": .number(1),
"sessionID": .string(sessionID.rawValue),
"splices": .array(splices),
]).canonicalString()
}
private static func merged(_ old: [AgentEvent], _ incoming: [AgentEvent]) -> [AgentEvent] {
var bySeq = Dictionary(uniqueKeysWithValues: old.map { ($0.seq, $0) })
for event in incoming { bySeq[event.seq] = event }
return bySeq.values.sorted { $0.seq < $1.seq }
}
// MARK: Render DTO
private static func json(_ item: TranscriptItem) -> JSONValue {
var value: [String: JSONValue] = [
"id": .string(item.id),
// JSON has no UInt64 type; keep the stable scroll/order anchor exact even above
// JavaScript's 53-bit integer ceiling. C# parses this decimal string as UInt64.
"seq": .string(String(item.seq)),
]
switch item.kind {
case .message(let role, let text):
value["type"] = .string("message")
value["role"] = .string(role == .user ? "user" : "assistant")
value["text"] = .string(text)
case .thinking(let text):
value["type"] = .string("thinking")
value["text"] = .string(text)
case .tool(let group):
value["type"] = .string("tool")
value["tool"] = json(group)
case .toolBlock(let groups):
value["type"] = .string("toolBlock")
value["tools"] = .array(groups.map(json))
case .sessionStarted(let model, let cwd):
value["type"] = .string("sessionStarted")
value["model"] = .string(model)
value["cwd"] = .string(cwd)
case .usage(let usage):
value["type"] = .string("usage")
value["usage"] = protocolJSON(usage)
case .rateLimit(let limit):
value["type"] = .string("rateLimit")
value["rateLimit"] = protocolJSON(limit)
case .turnBoundary:
value["type"] = .string("turnBoundary")
case .approval(let toolName):
value["type"] = .string("approval")
value["toolName"] = .string(toolName)
case .runFinished(let outcome):
value["type"] = .string("runFinished")
value["outcome"] = .string(outcome.rawValue)
case .error(let message):
value["type"] = .string("error")
value["message"] = .string(message)
case .note(let text, let icon, let lockEvent, let lock, let processStall):
value["type"] = .string("note")
value["text"] = .string(text)
value["lockEvent"] = .bool(lockEvent)
if let icon { value["icon"] = .string(icon) }
if let lock { value["lock"] = protocolJSON(lock) }
if let processStall { value["processStall"] = protocolJSON(processStall) }
case .raw(let type, let body):
value["type"] = .string("raw")
value["nativeType"] = .string(type)
value["body"] = .string(body)
}
return .object(value)
}
private static func json(_ group: ToolGroup) -> JSONValue {
var value: [String: JSONValue] = [
"toolCallID": .string(group.toolCallID),
"name": .string(group.name),
"input": group.input,
"isError": .bool(group.isError),
"finished": .bool(group.finished),
"isOrchestration": .bool(group.isOrchestration),
"fileChanges": .array(group.fileChanges.map {
.object(["path": .string($0.path), "kind": .string($0.change.rawValue)])
}),
"lockLines": .array(group.lockLines.map(protocolJSON)),
"children": .array(group.children.map(json)),
]
if let result = group.result { value["result"] = result }
if let command = group.bashCommand { value["bashCommand"] = .string(command) }
return .object(value)
}
private static func protocolJSON<T: Encodable>(_ value: T) -> JSONValue {
// These are existing NucleicProtocol Codable values. Encoding cannot fail for their
// concrete cases; retaining a null fallback keeps this renderer-only adapter total if a
// future optional telemetry field gains an unsupported value.
guard let data = try? ProtocolJSON.encode(value),
let json = try? JSONValue(parsing: data) else { return .null }
return json
}
}