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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public final class AppStateViewModel: ObservableObject {
@Published public private(set) var trace: [TraceEvent] = []
@Published public private(set) var runtimeStatus: RuntimeStatus = .starting
@Published public private(set) var submitting: Bool = false
@Published public private(set) var presentationState: PresentationState = .idle
@Published public private(set) var activeCapabilityId: String?
@Published public private(set) var capabilityProgress: [CapabilityProgressStep] = []
@Published public var document: String = ""
@Published public var showTrace: Bool = false

Expand Down Expand Up @@ -84,6 +87,9 @@ public final class AppStateViewModel: ObservableObject {
self.trace = result.events
self.showTrace = !result.events.isEmpty
self.submitting = false
self.presentationState = result.presentationState
self.activeCapabilityId = result.activeCapabilityId
self.capabilityProgress = result.capabilityProgress
if let error = result.error {
self.currentState = "error"
self.errorMessage = error
Expand All @@ -110,6 +116,9 @@ public final class AppStateViewModel: ObservableObject {
trace = []
errorMessage = nil
showTrace = false
presentationState = .idle
activeCapabilityId = nil
capabilityProgress = []
}

/// Compatibility alias for shell call sites.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,23 @@ public enum JSONValue: Equatable, Sendable, Codable {
case .null: try container.encodeNil()
}
}

/// Dictionary mapping (object payloads only).
public var asDictionary: [String: Any] {
guard case .object(let object) = self else { return [:] }
return object.mapValues { $0.asAny }
}

public var asAny: Any {
switch self {
case .string(let value): return value
case .number(let value): return value
case .bool(let value): return value
case .object(let value): return value.mapValues { $0.asAny }
case .array(let value): return value.map { $0.asAny }
case .null: return NSNull()
}
}
}

public enum DocApprovalOutputParser {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,46 @@ public struct HostRunResult: Equatable, Sendable {
public let output: DocApprovalOutput?
public let events: [TraceEvent]
public let error: String?
public let presentationState: PresentationState
public let presentationError: String?
public let capabilityProgress: [CapabilityProgressStep]
public let activeCapabilityId: String?

public init(
sessionId: String,
output: DocApprovalOutput?,
events: [TraceEvent],
error: String?
error: String?,
presentationState: PresentationState = .idle,
presentationError: String? = nil,
capabilityProgress: [CapabilityProgressStep] = [],
activeCapabilityId: String? = nil
) {
self.sessionId = sessionId
self.output = output
self.events = events
self.error = error
self.presentationState = presentationState
self.presentationError = presentationError
self.capabilityProgress = capabilityProgress
self.activeCapabilityId = activeCapabilityId
}

/// Attaches Spec 001/002 presentation fields derived from public embedder events.
public func withPresentation(from likes: [EmbedderEventLike]) -> HostRunResult {
let snap = PresentationMapper.mapPresentationState(likes)
let presentationState: PresentationState =
(error != nil && snap.state == .idle) ? .error : snap.state
return HostRunResult(
sessionId: sessionId,
output: output,
events: events,
error: error,
presentationState: presentationState,
presentationError: snap.errorMessage ?? (error != nil && snap.state == .idle ? error : nil),
capabilityProgress: PresentationMapper.mapCapabilityProgress(likes),
activeCapabilityId: PresentationMapper.activeCapabilityId(likes)
)
}
}

Expand Down Expand Up @@ -137,7 +166,7 @@ private final class ProductionEmbeddedHost: EmbeddedHostProtocol, @unchecked Sen
output: nil,
events: [],
error: "submit \(accepted.status)"
)
).withPresentation(from: [])
}
return try drainEvents(sessionId: accepted.sessionID)
}
Expand Down Expand Up @@ -171,23 +200,25 @@ private final class ProductionEmbeddedHost: EmbeddedHostProtocol, @unchecked Sen
}
}

let likes = embedderEventLikes(from: events)
if let error {
return HostRunResult(sessionId: sessionId, output: nil, events: events, error: error)
.withPresentation(from: likes)
}
if output == nil, events.isEmpty {
return HostRunResult(
sessionId: sessionId,
output: nil,
events: events,
error: "embedder emitted no capability_result"
)
).withPresentation(from: likes)
}
return HostRunResult(
sessionId: sessionId,
output: output ?? .empty,
events: events,
error: nil
)
).withPresentation(from: likes)
}

deinit {
Expand Down Expand Up @@ -235,15 +266,17 @@ private final class TestEmbeddedHost: EmbeddedHostProtocol, @unchecked Sendable
}
}

let likes = embedderEventLikes(from: events)
if let error {
return HostRunResult(sessionId: accepted.sessionID, output: nil, events: events, error: error)
.withPresentation(from: likes)
}
return HostRunResult(
sessionId: accepted.sessionID,
output: output ?? .empty,
events: events,
error: output == nil ? "embedder emitted no capability_result" : nil
)
).withPresentation(from: likes)
}

deinit {
Expand Down Expand Up @@ -277,6 +310,16 @@ private func extractError(_ raw: Any?) -> String? {
return nil
}

private func embedderEventLikes(from events: [TraceEvent]) -> [EmbedderEventLike] {
events.enumerated().map { index, event in
EmbedderEventLike(
eventType: event.event_type,
sequence: UInt64(index + 1),
data: event.data?.asDictionary ?? [:]
)
}
}

private extension JSONValue {
static func fromAny(_ value: Any) -> JSONValue {
switch value {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import Foundation

/// Canonical UI presentation states (Spec 001).
public enum PresentationState: String, Equatable, Sendable {
case idle
case loading
case loaded
case blocked
case ended
case error
}

public struct PresentationSnapshot: Equatable, Sendable {
public let state: PresentationState
public let errorMessage: String?
public let output: Any?

public init(state: PresentationState, errorMessage: String?, output: Any?) {
self.state = state
self.errorMessage = errorMessage
self.output = output
}

public static func == (lhs: PresentationSnapshot, rhs: PresentationSnapshot) -> Bool {
lhs.state == rhs.state && lhs.errorMessage == rhs.errorMessage
}
}

public enum CapabilityPhase: String, Equatable, Sendable {
case invoked
case result
}

public struct CapabilityProgressStep: Equatable, Sendable {
public let capabilityId: String
public let phase: CapabilityPhase
public let sequence: UInt64
public let status: String?
public let output: Any?

public init(
capabilityId: String,
phase: CapabilityPhase,
sequence: UInt64,
status: String?,
output: Any?
) {
self.capabilityId = capabilityId
self.phase = phase
self.sequence = sequence
self.status = status
self.output = output
}

public static func == (lhs: CapabilityProgressStep, rhs: CapabilityProgressStep) -> Bool {
lhs.capabilityId == rhs.capabilityId
&& lhs.phase == rhs.phase
&& lhs.sequence == rhs.sequence
&& lhs.status == rhs.status
}
}

/// Minimal embedder event fields required by the mapper.
public struct EmbedderEventLike: Equatable, Sendable {
public let eventType: String
public let sequence: UInt64
public let data: [String: Any]

public init(eventType: String, sequence: UInt64, data: [String: Any]) {
self.eventType = eventType
self.sequence = sequence
self.data = data
}

public static func == (lhs: EmbedderEventLike, rhs: EmbedderEventLike) -> Bool {
lhs.eventType == rhs.eventType && lhs.sequence == rhs.sequence
}
}

/// Spec 001/002 presentation + capability progress (language-equivalent of
/// `packages/event-ui-conformance`).
public enum PresentationMapper {
private static let blockedStates: Set<String> = [
"blocked", "waiting", "waiting_for_human", "awaiting_human", "awaiting_input",
]
private static let endedStates: Set<String> = [
"cancelled", "canceled", "closed", "ended",
]

public static func mapPresentationState(_ events: [EmbedderEventLike]) -> PresentationSnapshot {
if events.isEmpty {
return PresentationSnapshot(state: .idle, errorMessage: nil, output: nil)
}

var state: PresentationState = .idle
var errorMessage: String?
var output: Any?

for event in events {
switch event.eventType {
case "error":
state = .error
errorMessage = errorMessageFromData(event.data) ?? "execution failed"
case "capability_invoked":
if state != .error {
state = .loading
}
case "state_changed":
if state == .error { break }
if isBlockedPayload(event.data) {
state = .blocked
} else if isEndedStatePayload(event.data) {
state = .ended
} else if state != .loaded && state != .ended {
state = .loading
}
case "capability_result":
if state == .error { break }
if hasRenderableOutput(event.data) {
state = .loaded
output = event.data["output"]
} else {
state = .ended
output = nil
}
default:
break
}
}

return PresentationSnapshot(state: state, errorMessage: errorMessage, output: output)
}

public static func mapCapabilityProgress(_ events: [EmbedderEventLike]) -> [CapabilityProgressStep] {
var steps: [CapabilityProgressStep] = []
for event in events {
guard let capabilityId = event.data["capability_id"] as? String else { continue }
switch event.eventType {
case "capability_invoked":
steps.append(
CapabilityProgressStep(
capabilityId: capabilityId,
phase: .invoked,
sequence: event.sequence,
status: nil,
output: nil
)
)
case "capability_result":
steps.append(
CapabilityProgressStep(
capabilityId: capabilityId,
phase: .result,
sequence: event.sequence,
status: event.data["status"] as? String,
output: event.data["output"]
)
)
default:
break
}
}
return steps
}

public static func activeCapabilityId(_ events: [EmbedderEventLike]) -> String? {
let progress = mapCapabilityProgress(events)
var open: [String: Int] = [:]
for step in progress {
switch step.phase {
case .invoked:
open[step.capabilityId, default: 0] += 1
case .result:
let count = open[step.capabilityId] ?? 0
if count <= 1 {
open.removeValue(forKey: step.capabilityId)
} else {
open[step.capabilityId] = count - 1
}
}
}
for step in progress.reversed() {
if step.phase == .invoked, open[step.capabilityId] != nil {
return step.capabilityId
}
}
return nil
}

private static func errorMessageFromData(_ data: [String: Any]) -> String? {
if let err = data["error"] as? String { return err }
if let err = data["error"] as? [String: Any], let message = err["message"] as? String {
return message
}
return nil
}

private static func runtimeStateToken(_ data: [String: Any]) -> String? {
(data["state"] as? String)
?? (data["status"] as? String)
?? (data["runtime_state"] as? String)
}

private static func isBlockedPayload(_ data: [String: Any]) -> Bool {
if data["blocked"] as? Bool == true || data["waiting_for_human"] as? Bool == true {
return true
}
guard let token = runtimeStateToken(data)?.lowercased() else { return false }
return blockedStates.contains(token)
}

private static func isEndedStatePayload(_ data: [String: Any]) -> Bool {
guard let token = runtimeStateToken(data)?.lowercased() else { return false }
return endedStates.contains(token)
}

private static func hasRenderableOutput(_ data: [String: Any]) -> Bool {
guard data.keys.contains("output") else { return false }
let output = data["output"]
if output == nil || output is NSNull { return false }
if let dict = output as? [String: Any], dict.isEmpty { return false }
return true
}
}
Loading
Loading