Merge nucleic/fuzzy-spruce-dingo-vpaa into dev
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user