From da4b8307644ea4d6a98dadfc4d4aaf83ea7a70ed Mon Sep 17 00:00:00 2001 From: Enrico Piovesan Date: Sat, 22 Aug 2026 19:57:59 -0600 Subject: [PATCH] feat(traverse-starter): apply Spec 001/002 event-UI parity across shells Wire shared presentation/capability mappers into web, Rust core (CLI/GTK), Swift, Android, and Windows hosts so UI chrome renders runtime-owned state without inventing business fields. Co-authored-by: Cursor --- .../TraverseCore/AppStateViewModel.swift | 9 + .../Sources/TraverseCore/EmbeddedHost.swift | 51 ++- .../TraverseCore/PresentationMapper.swift | 224 ++++++++++++ .../Sources/TraverseCore/TraverseOutput.swift | 17 + .../PresentationMapperTests.swift | 52 +++ .../traverseframework/starter/EmbeddedHost.kt | 28 +- .../starter/PresentationMapper.kt | 209 +++++++++++ .../starter/PresentationMapperTest.kt | 59 +++ .../cli-rust/src/commands/run.rs | 3 + apps/traverse-starter/cli-rust/src/output.rs | 10 + .../linux-gtk/src/execution_state.rs | 2 + .../linux-gtk/src/ui/main_window.rs | 14 +- .../traverse-core-rs/src/host.rs | 25 ++ .../traverse-core-rs/src/lib.rs | 5 + .../traverse-core-rs/src/presentation.rs | 341 ++++++++++++++++++ apps/traverse-starter/web-react/package.json | 1 + apps/traverse-starter/web-react/src/App.tsx | 32 ++ .../web-react/src/host/embeddedHost.test.ts | 3 + .../web-react/src/host/embeddedHost.ts | 122 +++++-- .../PresentationMapperTests.cs | 56 +++ .../TraverseStarter/EmbeddedHost.cs | 41 ++- .../TraverseStarter/PresentationMapper.cs | 322 +++++++++++++++++ package-lock.json | 1 + packages/event-ui-conformance/src/index.ts | 4 - 24 files changed, 1581 insertions(+), 50 deletions(-) create mode 100644 apps/traverse-starter/TraverseCore/Sources/TraverseCore/PresentationMapper.swift create mode 100644 apps/traverse-starter/TraverseCore/Tests/TraverseCoreTests/PresentationMapperTests.swift create mode 100644 apps/traverse-starter/android-compose/app/src/main/java/com/traverseframework/starter/PresentationMapper.kt create mode 100644 apps/traverse-starter/android-compose/app/src/test/java/com/traverseframework/starter/PresentationMapperTest.kt create mode 100644 apps/traverse-starter/traverse-core-rs/src/presentation.rs create mode 100644 apps/traverse-starter/windows-winui/TraverseStarter.Tests/PresentationMapperTests.cs create mode 100644 apps/traverse-starter/windows-winui/TraverseStarter/PresentationMapper.cs diff --git a/apps/traverse-starter/TraverseCore/Sources/TraverseCore/AppStateViewModel.swift b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/AppStateViewModel.swift index 7a7868e..013c8f2 100644 --- a/apps/traverse-starter/TraverseCore/Sources/TraverseCore/AppStateViewModel.swift +++ b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/AppStateViewModel.swift @@ -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 note: String = "" @Published public var showTrace: Bool = false @@ -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 @@ -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. diff --git a/apps/traverse-starter/TraverseCore/Sources/TraverseCore/EmbeddedHost.swift b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/EmbeddedHost.swift index 843e795..fe71c7e 100644 --- a/apps/traverse-starter/TraverseCore/Sources/TraverseCore/EmbeddedHost.swift +++ b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/EmbeddedHost.swift @@ -7,17 +7,46 @@ public struct HostRunResult: Equatable, Sendable { public let output: TraverseStarterOutput? 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: TraverseStarterOutput?, 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) + ) } } @@ -171,8 +200,10 @@ 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( @@ -180,14 +211,14 @@ private final class ProductionEmbeddedHost: EmbeddedHostProtocol, @unchecked Sen 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 { @@ -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 { @@ -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 { diff --git a/apps/traverse-starter/TraverseCore/Sources/TraverseCore/PresentationMapper.swift b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/PresentationMapper.swift new file mode 100644 index 0000000..64d33c5 --- /dev/null +++ b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/PresentationMapper.swift @@ -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 = [ + "blocked", "waiting", "waiting_for_human", "awaiting_human", "awaiting_input", + ] + private static let endedStates: Set = [ + "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 + } +} diff --git a/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseOutput.swift b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseOutput.swift index 9b0c7dc..d123430 100644 --- a/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseOutput.swift +++ b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseOutput.swift @@ -176,6 +176,23 @@ public enum JSONValue: Equatable, Sendable, Codable { case .null: try container.encodeNil() } } + + /// Dictionary view for presentation 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 TraverseOutputParser { diff --git a/apps/traverse-starter/TraverseCore/Tests/TraverseCoreTests/PresentationMapperTests.swift b/apps/traverse-starter/TraverseCore/Tests/TraverseCoreTests/PresentationMapperTests.swift new file mode 100644 index 0000000..8451fae --- /dev/null +++ b/apps/traverse-starter/TraverseCore/Tests/TraverseCoreTests/PresentationMapperTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import TraverseCore + +final class PresentationMapperTests: XCTestCase { + func testEmptyStreamIsIdle() { + let snap = PresentationMapper.mapPresentationState([]) + XCTAssertEqual(snap.state, .idle) + XCTAssertNil(snap.errorMessage) + } + + func testHappyPathLoads() { + let events: [EmbedderEventLike] = [ + .init(eventType: "capability_invoked", sequence: 1, data: [ + "capability_id": "fixture.process", + ]), + .init(eventType: "capability_result", sequence: 2, data: [ + "capability_id": "fixture.process", + "output": ["ok": true], + ]), + ] + let snap = PresentationMapper.mapPresentationState(events) + XCTAssertEqual(snap.state, .loaded) + let progress = PresentationMapper.mapCapabilityProgress(events) + XCTAssertEqual(progress.map(\.capabilityId), ["fixture.process", "fixture.process"]) + XCTAssertEqual(progress.map(\.phase), [.invoked, .result]) + XCTAssertNil(PresentationMapper.activeCapabilityId(events)) + } + + func testBlockedWaitingForHuman() { + let events: [EmbedderEventLike] = [ + .init(eventType: "capability_invoked", sequence: 1, data: [ + "capability_id": "fixture.approve", + ]), + .init(eventType: "state_changed", sequence: 2, data: [ + "state": "waiting_for_human", + ]), + ] + XCTAssertEqual(PresentationMapper.mapPresentationState(events).state, .blocked) + XCTAssertEqual(PresentationMapper.activeCapabilityId(events), "fixture.approve") + } + + func testErrorPath() { + let events: [EmbedderEventLike] = [ + .init(eventType: "error", sequence: 1, data: [ + "error": ["message": "boom"], + ]), + ] + let snap = PresentationMapper.mapPresentationState(events) + XCTAssertEqual(snap.state, .error) + XCTAssertEqual(snap.errorMessage, "boom") + } +} diff --git a/apps/traverse-starter/android-compose/app/src/main/java/com/traverseframework/starter/EmbeddedHost.kt b/apps/traverse-starter/android-compose/app/src/main/java/com/traverseframework/starter/EmbeddedHost.kt index 97c6958..b8d8973 100644 --- a/apps/traverse-starter/android-compose/app/src/main/java/com/traverseframework/starter/EmbeddedHost.kt +++ b/apps/traverse-starter/android-compose/app/src/main/java/com/traverseframework/starter/EmbeddedHost.kt @@ -23,7 +23,28 @@ data class HostRunResult( val output: TraverseStarterOutput?, val events: List, val error: String?, -) + val presentationState: PresentationState = PresentationState.Idle, + val presentationError: String? = null, + val capabilityProgress: List = emptyList(), + val activeCapabilityId: String? = null, +) { + fun withPresentation(likes: List): HostRunResult { + val snap = PresentationMapper.mapPresentationState(likes) + val state = + if (error != null && snap.state == PresentationState.Idle) { + PresentationState.Error + } else { + snap.state + } + return copy( + presentationState = state, + presentationError = snap.errorMessage + ?: if (error != null && snap.state == PresentationState.Idle) error else null, + capabilityProgress = PresentationMapper.mapCapabilityProgress(likes), + activeCapabilityId = PresentationMapper.activeCapabilityId(likes), + ) + } +} /** Deterministic test double wrapping [dev.traverse.embedder.InMemoryTraverseEmbedder]. */ class InMemoryStarterHost( @@ -62,7 +83,7 @@ class InMemoryStarterHost( ) }, error = if (output == null) "embedder emitted no capability_result output" else null, - ) + ).withPresentation(emptyList()) } companion object { @@ -115,9 +136,10 @@ class ProductionStarterHost private constructor( } else { null }, - ) + ).withPresentation(emptyList()) } catch (e: Exception) { HostRunResult("", null, emptyList(), e.message ?: "submit failed") + .withPresentation(emptyList()) } companion object { diff --git a/apps/traverse-starter/android-compose/app/src/main/java/com/traverseframework/starter/PresentationMapper.kt b/apps/traverse-starter/android-compose/app/src/main/java/com/traverseframework/starter/PresentationMapper.kt new file mode 100644 index 0000000..2833372 --- /dev/null +++ b/apps/traverse-starter/android-compose/app/src/main/java/com/traverseframework/starter/PresentationMapper.kt @@ -0,0 +1,209 @@ +package com.traverseframework.starter + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** Canonical UI presentation states (Spec 001). */ +enum class PresentationState { + Idle, + Loading, + Loaded, + Blocked, + Ended, + Error, + ; + + fun asWire(): String = when (this) { + Idle -> "idle" + Loading -> "loading" + Loaded -> "loaded" + Blocked -> "blocked" + Ended -> "ended" + Error -> "error" + } +} + +data class PresentationSnapshot( + val state: PresentationState, + val errorMessage: String?, + val output: JsonElement?, +) + +enum class CapabilityPhase { + Invoked, + Result, + ; + + fun asWire(): String = when (this) { + Invoked -> "invoked" + Result -> "result" + } +} + +data class CapabilityProgressStep( + val capabilityId: String, + val phase: CapabilityPhase, + val sequence: Long, + val status: String?, + val output: JsonElement?, +) + +/** Minimal embedder event fields required by the mapper. */ +data class EmbedderEventLike( + val eventType: String, + val sequence: Long, + val data: JsonObject, +) + +/** + * Spec 001/002 presentation + capability progress (language-equivalent of + * `packages/event-ui-conformance`). + */ +object PresentationMapper { + private val blockedStates = setOf( + "blocked", + "waiting", + "waiting_for_human", + "awaiting_human", + "awaiting_input", + ) + private val endedStates = setOf("cancelled", "canceled", "closed", "ended") + + fun mapPresentationState(events: List): PresentationSnapshot { + if (events.isEmpty()) { + return PresentationSnapshot(PresentationState.Idle, null, null) + } + + var state = PresentationState.Idle + var errorMessage: String? = null + var output: JsonElement? = null + + for (event in events) { + when (event.eventType) { + "error" -> { + state = PresentationState.Error + errorMessage = errorMessageFromData(event.data) ?: "execution failed" + } + "capability_invoked" -> { + if (state != PresentationState.Error) { + state = PresentationState.Loading + } + } + "state_changed" -> { + if (state == PresentationState.Error) continue + state = when { + isBlockedPayload(event.data) -> PresentationState.Blocked + isEndedStatePayload(event.data) -> PresentationState.Ended + state != PresentationState.Loaded && state != PresentationState.Ended -> + PresentationState.Loading + else -> state + } + } + "capability_result" -> { + if (state == PresentationState.Error) continue + if (hasRenderableOutput(event.data)) { + state = PresentationState.Loaded + output = event.data["output"] + } else { + state = PresentationState.Ended + output = null + } + } + } + } + + return PresentationSnapshot(state, errorMessage, output) + } + + fun mapCapabilityProgress(events: List): List { + val steps = mutableListOf() + for (event in events) { + val capabilityId = stringField(event.data, "capability_id") ?: continue + when (event.eventType) { + "capability_invoked" -> steps.add( + CapabilityProgressStep( + capabilityId = capabilityId, + phase = CapabilityPhase.Invoked, + sequence = event.sequence, + status = null, + output = null, + ), + ) + "capability_result" -> steps.add( + CapabilityProgressStep( + capabilityId = capabilityId, + phase = CapabilityPhase.Result, + sequence = event.sequence, + status = stringField(event.data, "status"), + output = event.data["output"], + ), + ) + } + } + return steps + } + + fun activeCapabilityId(events: List): String? { + val progress = mapCapabilityProgress(events) + val open = mutableMapOf() + for (step in progress) { + when (step.phase) { + CapabilityPhase.Invoked -> open[step.capabilityId] = (open[step.capabilityId] ?: 0) + 1 + CapabilityPhase.Result -> { + val count = open[step.capabilityId] ?: 0 + if (count <= 1) open.remove(step.capabilityId) + else open[step.capabilityId] = count - 1 + } + } + } + for (step in progress.asReversed()) { + if (step.phase == CapabilityPhase.Invoked && open.containsKey(step.capabilityId)) { + return step.capabilityId + } + } + return null + } + + private fun stringField(data: JsonObject, key: String): String? = + data[key]?.jsonPrimitive?.contentOrNull + + private fun errorMessageFromData(data: JsonObject): String? { + val err = data["error"] ?: return null + err.jsonPrimitive.contentOrNull?.let { return it } + return try { + err.jsonObject["message"]?.jsonPrimitive?.contentOrNull + } catch (_: Exception) { + null + } + } + + private fun runtimeStateToken(data: JsonObject): String? = + stringField(data, "state") + ?: stringField(data, "status") + ?: stringField(data, "runtime_state") + + private fun isBlockedPayload(data: JsonObject): Boolean { + if (data["blocked"]?.jsonPrimitive?.booleanOrNull == true) return true + if (data["waiting_for_human"]?.jsonPrimitive?.booleanOrNull == true) return true + val token = runtimeStateToken(data)?.lowercase() ?: return false + return token in blockedStates + } + + private fun isEndedStatePayload(data: JsonObject): Boolean { + val token = runtimeStateToken(data)?.lowercase() ?: return false + return token in endedStates + } + + private fun hasRenderableOutput(data: JsonObject): Boolean { + if (!data.containsKey("output")) return false + val output = data["output"] + if (output == null || output is JsonNull) return false + if (output is JsonObject && output.isEmpty()) return false + return true + } +} diff --git a/apps/traverse-starter/android-compose/app/src/test/java/com/traverseframework/starter/PresentationMapperTest.kt b/apps/traverse-starter/android-compose/app/src/test/java/com/traverseframework/starter/PresentationMapperTest.kt new file mode 100644 index 0000000..14031aa --- /dev/null +++ b/apps/traverse-starter/android-compose/app/src/test/java/com/traverseframework/starter/PresentationMapperTest.kt @@ -0,0 +1,59 @@ +package com.traverseframework.starter + +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PresentationMapperTest { + @Test + fun emptyStreamIsIdle() { + val snap = PresentationMapper.mapPresentationState(emptyList()) + assertEquals(PresentationState.Idle, snap.state) + assertNull(snap.errorMessage) + } + + @Test + fun happyPathLoads() { + val events = listOf( + EmbedderEventLike( + eventType = "capability_invoked", + sequence = 1, + data = buildJsonObject { put("capability_id", "fixture.process") }, + ), + EmbedderEventLike( + eventType = "capability_result", + sequence = 2, + data = buildJsonObject { + put("capability_id", "fixture.process") + put("output", buildJsonObject { put("ok", true) }) + }, + ), + ) + assertEquals(PresentationState.Loaded, PresentationMapper.mapPresentationState(events).state) + assertNull(PresentationMapper.activeCapabilityId(events)) + assertEquals( + listOf("fixture.process", "fixture.process"), + PresentationMapper.mapCapabilityProgress(events).map { it.capabilityId }, + ) + } + + @Test + fun blockedWaitingForHuman() { + val events = listOf( + EmbedderEventLike( + eventType = "capability_invoked", + sequence = 1, + data = buildJsonObject { put("capability_id", "fixture.approve") }, + ), + EmbedderEventLike( + eventType = "state_changed", + sequence = 2, + data = buildJsonObject { put("state", "waiting_for_human") }, + ), + ) + assertEquals(PresentationState.Blocked, PresentationMapper.mapPresentationState(events).state) + assertEquals("fixture.approve", PresentationMapper.activeCapabilityId(events)) + } +} diff --git a/apps/traverse-starter/cli-rust/src/commands/run.rs b/apps/traverse-starter/cli-rust/src/commands/run.rs index 4665492..5424566 100644 --- a/apps/traverse-starter/cli-rust/src/commands/run.rs +++ b/apps/traverse-starter/cli-rust/src/commands/run.rs @@ -34,6 +34,9 @@ fn finish(result: Result, json: bool) -> i32 { execution_id: result.session_id, output: result.output, trace: result.events, + presentation_state: result.presentation_state.as_str().to_string(), + presentation_error: result.presentation_error, + active_capability_id: result.active_capability_id, }, json, ); diff --git a/apps/traverse-starter/cli-rust/src/output.rs b/apps/traverse-starter/cli-rust/src/output.rs index 9606873..207a93f 100644 --- a/apps/traverse-starter/cli-rust/src/output.rs +++ b/apps/traverse-starter/cli-rust/src/output.rs @@ -9,6 +9,9 @@ pub struct RunResultJson { pub execution_id: String, pub output: TraverseStarterOutput, pub trace: Vec, + pub presentation_state: String, + pub presentation_error: Option, + pub active_capability_id: Option, } pub fn print_json(value: &Value) { @@ -41,6 +44,13 @@ pub fn print_run_result(result: &RunResultJson, json: bool) { println!("Tags: {}", output.process.tags.join(", ")); println!("Summary: {}", output.summarize.summary); println!("Word count: {}", output.summarize.word_count); + println!("Presentation: {}", result.presentation_state.bold()); + if let Some(active) = &result.active_capability_id { + println!("Active capability: {active}"); + } + if let Some(err) = &result.presentation_error { + println!("Presentation error: {err}"); + } if !result.trace.is_empty() { println!("Trace ({} events):", result.trace.len()); for event in &result.trace { diff --git a/apps/traverse-starter/linux-gtk/src/execution_state.rs b/apps/traverse-starter/linux-gtk/src/execution_state.rs index 9b150e2..26461fe 100644 --- a/apps/traverse-starter/linux-gtk/src/execution_state.rs +++ b/apps/traverse-starter/linux-gtk/src/execution_state.rs @@ -7,6 +7,8 @@ pub enum ExecutionPhase { Succeeded { output: TraverseStarterOutput, trace: Vec, + presentation_state: String, + active_capability_id: Option, }, Failed { error: String }, } diff --git a/apps/traverse-starter/linux-gtk/src/ui/main_window.rs b/apps/traverse-starter/linux-gtk/src/ui/main_window.rs index 768b2ce..17da0e7 100644 --- a/apps/traverse-starter/linux-gtk/src/ui/main_window.rs +++ b/apps/traverse-starter/linux-gtk/src/ui/main_window.rs @@ -191,9 +191,17 @@ impl MainWindow { output_label.set_text(&format!("Error: {error}")); output_label.remove_css_class("dim-label"); } - ExecutionPhase::Succeeded { output, trace } => { + ExecutionPhase::Succeeded { + output, + trace, + presentation_state, + active_capability_id, + } => { + let active = active_capability_id + .as_deref() + .unwrap_or("—"); output_label.set_text(&format!( - "Valid: {}\nIssues: {}\nTitle: {}\nNote type: {}\nStatus: {}\nNext action: {}\nTags: {}\nSummary: {}\nWord count: {}", + "Presentation: {presentation_state}\nActive capability: {active}\nValid: {}\nIssues: {}\nTitle: {}\nNote type: {}\nStatus: {}\nNext action: {}\nTags: {}\nSummary: {}\nWord count: {}", if output.validate.valid { "yes" } else { "no" }, if output.validate.issues.is_empty() { "None".to_string() @@ -285,6 +293,8 @@ impl MainWindow { state.lock().unwrap().phase = ExecutionPhase::Succeeded { output: run.output, trace: run.events, + presentation_state: run.presentation_state.as_str().to_string(), + active_capability_id: run.active_capability_id, }; } Err(err) => { diff --git a/apps/traverse-starter/traverse-core-rs/src/host.rs b/apps/traverse-starter/traverse-core-rs/src/host.rs index 785ec96..15b4606 100644 --- a/apps/traverse-starter/traverse-core-rs/src/host.rs +++ b/apps/traverse-starter/traverse-core-rs/src/host.rs @@ -13,6 +13,9 @@ use traverse_embedder::{ }; use crate::client::{TraceEvent, TraverseStarterOutput}; +use crate::presentation::{ + active_capability_id, map_capability_progress, map_presentation_state, EmbedderEventLike, +}; use crate::state::StateEvent; /// Workflow id invoked by traverse-starter shells. @@ -51,6 +54,10 @@ pub struct HostRunResult { pub session_id: String, pub output: TraverseStarterOutput, pub events: Vec, + pub presentation_state: crate::PresentationState, + pub presentation_error: Option, + pub capability_progress: Vec, + pub active_capability_id: Option, } /// Resolves the application bundle manifest path. @@ -119,6 +126,20 @@ fn collect_submit( }) .collect(); + let likes: Vec = raw_events + .iter() + .filter_map(|event| { + Some(EmbedderEventLike { + event_type: event.get("event_type")?.as_str()?.to_string(), + sequence: event.get("sequence")?.as_u64().unwrap_or(0), + data: event.get("data").cloned().unwrap_or(Value::Null), + }) + }) + .collect(); + let snap = map_presentation_state(&likes); + let progress = map_capability_progress(&likes); + let active = active_capability_id(&likes); + for event in &raw_events { let Some(parsed) = StateEvent::from_embedder_event(event) else { continue; @@ -151,6 +172,10 @@ fn collect_submit( session_id, output, events: trace, + presentation_state: snap.state, + presentation_error: snap.error_message, + capability_progress: progress, + active_capability_id: active, }); } } diff --git a/apps/traverse-starter/traverse-core-rs/src/lib.rs b/apps/traverse-starter/traverse-core-rs/src/lib.rs index a8d85b2..5de78ed 100644 --- a/apps/traverse-starter/traverse-core-rs/src/lib.rs +++ b/apps/traverse-starter/traverse-core-rs/src/lib.rs @@ -7,6 +7,7 @@ mod client; mod host; +mod presentation; mod state; pub use client::{ @@ -16,6 +17,10 @@ pub use host::{ resolve_manifest_path, EmbeddedRuntime, HostError, HostRunResult, TestEmbeddedRuntime, DEFAULT_WORKFLOW_ID, MANIFEST_ENV, RUNTIME_MODE_EMBEDDED, }; +pub use presentation::{ + active_capability_id, map_capability_progress, map_presentation_state, CapabilityPhase, + CapabilityProgressStep, EmbedderEventLike, PresentationSnapshot, PresentationState, +}; pub use state::{AppState, StateEvent}; pub const DEFAULT_APP_ID: &str = "traverse-starter"; diff --git a/apps/traverse-starter/traverse-core-rs/src/presentation.rs b/apps/traverse-starter/traverse-core-rs/src/presentation.rs new file mode 100644 index 0000000..e0a43e4 --- /dev/null +++ b/apps/traverse-starter/traverse-core-rs/src/presentation.rs @@ -0,0 +1,341 @@ +//! Spec 001/002 presentation + capability progress (language-equivalent of +//! `packages/event-ui-conformance`). + +use serde_json::Value; + +/// Canonical UI presentation states (Spec 001). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PresentationState { + Idle, + Loading, + Loaded, + Blocked, + Ended, + Error, +} + +impl PresentationState { + pub fn as_str(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Loading => "loading", + Self::Loaded => "loaded", + Self::Blocked => "blocked", + Self::Ended => "ended", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PresentationSnapshot { + pub state: PresentationState, + pub error_message: Option, + pub output: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityPhase { + Invoked, + Result, +} + +impl CapabilityPhase { + pub fn as_str(self) -> &'static str { + match self { + Self::Invoked => "invoked", + Self::Result => "result", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CapabilityProgressStep { + pub capability_id: String, + pub phase: CapabilityPhase, + pub sequence: u64, + pub status: Option, + pub output: Option, +} + +/// Minimal embedder event fields required by the mapper. +#[derive(Debug, Clone)] +pub struct EmbedderEventLike { + pub event_type: String, + pub sequence: u64, + pub data: Value, +} + +fn as_str_field(data: &Value, key: &str) -> Option { + data.get(key)?.as_str().map(str::to_string) +} + +fn error_message_from_data(data: &Value) -> Option { + match data.get("error") { + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Object(map)) => map + .get("message") + .and_then(|m| m.as_str()) + .map(str::to_string), + _ => None, + } +} + +fn runtime_state_token(data: &Value) -> Option { + as_str_field(data, "state") + .or_else(|| as_str_field(data, "status")) + .or_else(|| as_str_field(data, "runtime_state")) +} + +fn is_blocked_payload(data: &Value) -> bool { + if data.get("blocked").and_then(|v| v.as_bool()) == Some(true) + || data.get("waiting_for_human").and_then(|v| v.as_bool()) == Some(true) + { + return true; + } + runtime_state_token(data) + .map(|token| { + matches!( + token.to_lowercase().as_str(), + "blocked" | "waiting" | "waiting_for_human" | "awaiting_human" | "awaiting_input" + ) + }) + .unwrap_or(false) +} + +fn is_ended_state_payload(data: &Value) -> bool { + runtime_state_token(data) + .map(|token| { + matches!( + token.to_lowercase().as_str(), + "cancelled" | "canceled" | "closed" | "ended" + ) + }) + .unwrap_or(false) +} + +fn has_renderable_output(data: &Value) -> bool { + match data.get("output") { + None => false, + Some(Value::Null) => false, + Some(Value::Object(map)) if map.is_empty() => false, + Some(_) => true, + } +} + +/// Pure mapper: ordered public embedder events → one presentation snapshot. +pub fn map_presentation_state(events: &[EmbedderEventLike]) -> PresentationSnapshot { + if events.is_empty() { + return PresentationSnapshot { + state: PresentationState::Idle, + error_message: None, + output: None, + }; + } + + let mut state = PresentationState::Idle; + let mut error_message = None; + let mut output = None; + + for event in events { + match event.event_type.as_str() { + "error" => { + state = PresentationState::Error; + error_message = + Some(error_message_from_data(&event.data).unwrap_or_else(|| { + "execution failed".to_string() + })); + } + "capability_invoked" => { + if state != PresentationState::Error { + state = PresentationState::Loading; + } + } + "state_changed" => { + if state == PresentationState::Error { + continue; + } + if is_blocked_payload(&event.data) { + state = PresentationState::Blocked; + } else if is_ended_state_payload(&event.data) { + state = PresentationState::Ended; + } else if state != PresentationState::Loaded && state != PresentationState::Ended { + state = PresentationState::Loading; + } + } + "capability_result" => { + if state == PresentationState::Error { + continue; + } + if has_renderable_output(&event.data) { + state = PresentationState::Loaded; + output = event.data.get("output").cloned(); + } else { + state = PresentationState::Ended; + output = None; + } + } + _ => {} + } + } + + PresentationSnapshot { + state, + error_message, + output, + } +} + +/// Ordered capability progress from `capability_invoked` / `capability_result`. +pub fn map_capability_progress(events: &[EmbedderEventLike]) -> Vec { + let mut steps = Vec::new(); + for event in events { + let Some(capability_id) = as_str_field(&event.data, "capability_id") else { + continue; + }; + match event.event_type.as_str() { + "capability_invoked" => steps.push(CapabilityProgressStep { + capability_id, + phase: CapabilityPhase::Invoked, + sequence: event.sequence, + status: None, + output: None, + }), + "capability_result" => steps.push(CapabilityProgressStep { + capability_id, + phase: CapabilityPhase::Result, + sequence: event.sequence, + status: as_str_field(&event.data, "status"), + output: event.data.get("output").cloned(), + }), + _ => {} + } + } + steps +} + +/// Active capability: last invoked without a later result for that id. +pub fn active_capability_id(events: &[EmbedderEventLike]) -> Option { + let progress = map_capability_progress(events); + let mut open: std::collections::HashMap = std::collections::HashMap::new(); + for step in &progress { + match step.phase { + CapabilityPhase::Invoked => { + *open.entry(step.capability_id.clone()).or_insert(0) += 1; + } + CapabilityPhase::Result => { + if let Some(count) = open.get_mut(&step.capability_id) { + if *count <= 1 { + open.remove(&step.capability_id); + } else { + *count -= 1; + } + } + } + } + } + for step in progress.iter().rev() { + if step.phase == CapabilityPhase::Invoked && open.contains_key(&step.capability_id) { + return Some(step.capability_id.clone()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::fs; + use std::path::PathBuf; + + fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../fixtures/event-ui-conformance") + } + + fn load_case(name: &str) -> (PresentationState, Vec) { + let raw = fs::read_to_string(fixtures_dir().join(name)).expect("fixture"); + let value: Value = serde_json::from_str(&raw).expect("json"); + let expected = match value["expected_presentation_state"].as_str().unwrap() { + "idle" => PresentationState::Idle, + "loading" => PresentationState::Loading, + "loaded" => PresentationState::Loaded, + "blocked" => PresentationState::Blocked, + "ended" => PresentationState::Ended, + "error" => PresentationState::Error, + other => panic!("unknown state {other}"), + }; + let events = value["events"] + .as_array() + .unwrap() + .iter() + .map(|event| EmbedderEventLike { + event_type: event["event_type"].as_str().unwrap().to_string(), + sequence: event["sequence"].as_u64().unwrap(), + data: event["data"].clone(), + }) + .collect(); + (expected, events) + } + + #[test] + fn maps_catalog_fixtures() { + for name in [ + "happy-path.json", + "error-path.json", + "blocked-path.json", + "ended-path.json", + "multi-capability.json", + "replay-late-subscriber.json", + ] { + let (expected, events) = load_case(name); + let snap = map_presentation_state(&events); + assert_eq!(snap.state, expected, "fixture {name}"); + } + } + + #[test] + fn multi_capability_progress_order() { + let (_, events) = load_case("multi-capability.json"); + let progress = map_capability_progress(&events); + let order: Vec<_> = progress + .iter() + .map(|s| (s.capability_id.as_str(), s.phase.as_str())) + .collect(); + assert_eq!( + order, + vec![ + ("fixture.analyze", "invoked"), + ("fixture.analyze", "result"), + ("fixture.recommend", "invoked"), + ("fixture.recommend", "result"), + ] + ); + assert!(active_capability_id(&events).is_none()); + } + + #[test] + fn empty_stream_is_idle() { + let snap = map_presentation_state(&[]); + assert_eq!(snap.state, PresentationState::Idle); + assert_eq!(snap.output, None); + assert_eq!(snap.error_message, None); + } + + #[test] + fn loading_while_invoked() { + let events = vec![EmbedderEventLike { + event_type: "capability_invoked".into(), + sequence: 1, + data: json!({"capability_id": "fixture.process"}), + }]; + assert_eq!( + map_presentation_state(&events).state, + PresentationState::Loading + ); + assert_eq!( + active_capability_id(&events).as_deref(), + Some("fixture.process") + ); + } +} diff --git a/apps/traverse-starter/web-react/package.json b/apps/traverse-starter/web-react/package.json index 66fb8dd..5669ef9 100644 --- a/apps/traverse-starter/web-react/package.json +++ b/apps/traverse-starter/web-react/package.json @@ -13,6 +13,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "event-ui-conformance": "*", "react": "^19.2.8", "react-dom": "^19.2.8", "traverse-embedder-web": "file:../../../vendor/traverse-embedder-web" diff --git a/apps/traverse-starter/web-react/src/App.tsx b/apps/traverse-starter/web-react/src/App.tsx index 67b6415..e221bee 100644 --- a/apps/traverse-starter/web-react/src/App.tsx +++ b/apps/traverse-starter/web-react/src/App.tsx @@ -81,6 +81,9 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) { const parsed: TraverseStarterOutput | null = result?.output ?? null const displayError = result?.error ?? null const trace: TraceEvent[] = result?.events ?? [] + const presentationState = result?.presentationState ?? null + const activeCapability = result?.activeCapabilityId ?? null + const capabilityProgress = result?.capabilityProgress ?? [] const statusLabel = runtimeStatus === 'ready' ? 'Ready' : runtimeStatus === 'unavailable' ? 'Unavailable' : 'Starting' @@ -171,6 +174,35 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) { Workspace: {DEFAULT_WORKSPACE} · Workflow:{' '} {DEFAULT_WORKFLOW_ID} · App: {DEFAULT_APP_ID} + {presentationState && ( +
+ Session presentation: {presentationState} + {activeCapability ? ( + <> + {' '} + · Active capability: {activeCapability} + + ) : null} + {capabilityProgress.length > 0 ? ( + <> + {' '} + · Capability steps:{' '} + + {capabilityProgress + .map((step) => `${step.capabilityId}:${step.phase}`) + .join(' → ')} + + + ) : null} +
+ )}
diff --git a/apps/traverse-starter/web-react/src/host/embeddedHost.test.ts b/apps/traverse-starter/web-react/src/host/embeddedHost.test.ts index 2e3aa8f..bcb7be7 100644 --- a/apps/traverse-starter/web-react/src/host/embeddedHost.test.ts +++ b/apps/traverse-starter/web-react/src/host/embeddedHost.test.ts @@ -48,6 +48,8 @@ describe('embeddedHost', () => { expect(result.error).toBeNull() expect(result.output?.process.title).toBe('Hello') expect(result.events.length).toBeGreaterThan(0) + expect(result.presentationState).toBe('loaded') + expect(result.capabilityProgress.length).toBeGreaterThan(0) }) it('submitNote surfaces scripted execution errors', () => { @@ -58,6 +60,7 @@ describe('embeddedHost', () => { const result = submitNote(embedder, 'x') expect(result.error).toContain('boom') expect(result.output).toBeNull() + expect(result.presentationState).toBe('error') }) it('submitNote surfaces rejected unknown targets', () => { diff --git a/apps/traverse-starter/web-react/src/host/embeddedHost.ts b/apps/traverse-starter/web-react/src/host/embeddedHost.ts index 59b7822..59ef410 100644 --- a/apps/traverse-starter/web-react/src/host/embeddedHost.ts +++ b/apps/traverse-starter/web-react/src/host/embeddedHost.ts @@ -1,3 +1,13 @@ +import type { + CapabilityProgressStep, + EmbedderEventLike, + PresentationState, +} from 'event-ui-conformance' +import { + activeCapabilityId, + mapCapabilityProgress, + mapPresentationState, +} from 'event-ui-conformance' import type { EmbedderEvent, JsonValue, @@ -27,9 +37,17 @@ export interface HostRunResult { rawOutput: unknown events: TraceEvent[] error: string | null + /** Spec 001 presentation state from the public embedder event stream. */ + presentationState: PresentationState + /** Spec 001 error text from event payloads (never invented). */ + presentationError: string | null + /** Spec 002 ordered capability invoke/result progress. */ + capabilityProgress: CapabilityProgressStep[] + /** Spec 002 active capability id when an invoke is still open. */ + activeCapabilityId: string | null } -export type { TraverseEmbedderApi, EmbedderEvent } +export type { TraverseEmbedderApi, EmbedderEvent, PresentationState, CapabilityProgressStep } /** Builds a deterministic test double for Vitest (spec 068 FR-006). */ export function createTestEmbedder(output: TraverseStarterOutput): TraverseEmbedderApi { @@ -68,6 +86,36 @@ function errorMessageFromData(data: JsonValue): string | null { return null } +function toEventLikes(events: readonly EmbedderEvent[]): EmbedderEventLike[] { + return events.map((event) => ({ + event_type: event.event_type, + sequence: event.sequence, + session_id: event.session_id, + data: event.data, + })) +} + +function withPresentation( + base: Omit< + HostRunResult, + 'presentationState' | 'presentationError' | 'capabilityProgress' | 'activeCapabilityId' + >, + collected: readonly EmbedderEvent[], +): HostRunResult { + const likes = toEventLikes(collected) + const snap = mapPresentationState(likes) + const presentationState: PresentationState = + base.error && snap.state === 'idle' ? 'error' : snap.state + return { + ...base, + presentationState, + presentationError: + snap.errorMessage ?? (base.error && snap.state === 'idle' ? base.error : null), + capabilityProgress: mapCapabilityProgress(likes), + activeCapabilityId: activeCapabilityId(likes), + } +} + /** Submit `{ note }` to `traverse-starter.pipeline` and collect terminal output. */ export function submitNote(embedder: TraverseEmbedderApi, note: string): HostRunResult { const collected: EmbedderEvent[] = [] @@ -77,15 +125,18 @@ export function submitNote(embedder: TraverseEmbedderApi, note: string): HostRun const outcome = embedder.submit(DEFAULT_WORKFLOW_ID, { note }) if (outcome.status === 'rejected') { - return { - sessionId: outcome.sessionId ?? 'sess-unknown', - output: null, - rawOutput: null, - events: [], - error: outcome.error - ? `${outcome.error.code}: ${outcome.error.message}` - : 'submit rejected', - } + return withPresentation( + { + sessionId: outcome.sessionId ?? 'sess-unknown', + output: null, + rawOutput: null, + events: [], + error: outcome.error + ? `${outcome.error.code}: ${outcome.error.message}` + : 'submit rejected', + }, + [], + ) } const sessionId = outcome.sessionId ?? 'sess-unknown' @@ -98,13 +149,16 @@ export function submitNote(embedder: TraverseEmbedderApi, note: string): HostRun for (const event of collected) { if (event.session_id && event.session_id !== sessionId) continue if (event.event_type === 'error') { - return { - sessionId, - output: null, - rawOutput: null, - events, - error: errorMessageFromData(event.data) ?? 'execution failed', - } + return withPresentation( + { + sessionId, + output: null, + rawOutput: null, + events, + error: errorMessageFromData(event.data) ?? 'execution failed', + }, + collected, + ) } if (event.event_type === 'capability_result') { const data = @@ -112,21 +166,27 @@ export function submitNote(embedder: TraverseEmbedderApi, note: string): HostRun ? (event.data as Record) : null const rawOutput = data?.output ?? null - return { - sessionId, - output: parseOutput(rawOutput), - rawOutput, - events, - error: null, - } + return withPresentation( + { + sessionId, + output: parseOutput(rawOutput), + rawOutput, + events, + error: null, + }, + collected, + ) } } - return { - sessionId, - output: null, - rawOutput: null, - events, - error: 'embedder emitted no capability_result', - } + return withPresentation( + { + sessionId, + output: null, + rawOutput: null, + events, + error: 'embedder emitted no capability_result', + }, + collected, + ) } diff --git a/apps/traverse-starter/windows-winui/TraverseStarter.Tests/PresentationMapperTests.cs b/apps/traverse-starter/windows-winui/TraverseStarter.Tests/PresentationMapperTests.cs new file mode 100644 index 0000000..0ecc454 --- /dev/null +++ b/apps/traverse-starter/windows-winui/TraverseStarter.Tests/PresentationMapperTests.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using Xunit; + +namespace TraverseStarter.Tests; + +public class PresentationMapperTests +{ + [Fact] + public void EmptyStreamIsIdle() + { + var snap = PresentationMapper.MapPresentationState(Array.Empty()); + Assert.Equal(PresentationState.Idle, snap.State); + Assert.Null(snap.ErrorMessage); + } + + [Fact] + public void HappyPathLoads() + { + var events = new[] + { + new EmbedderEventLike( + "capability_invoked", + 1, + JsonDocument.Parse("""{"capability_id":"fixture.process"}""").RootElement), + new EmbedderEventLike( + "capability_result", + 2, + JsonDocument.Parse("""{"capability_id":"fixture.process","output":{"ok":true}}""").RootElement), + }; + + Assert.Equal(PresentationState.Loaded, PresentationMapper.MapPresentationState(events).State); + Assert.Null(PresentationMapper.ActiveCapabilityId(events)); + Assert.Equal( + new[] { "fixture.process", "fixture.process" }, + PresentationMapper.MapCapabilityProgress(events).Select(s => s.CapabilityId).ToArray()); + } + + [Fact] + public void BlockedWaitingForHuman() + { + var events = new[] + { + new EmbedderEventLike( + "capability_invoked", + 1, + JsonDocument.Parse("""{"capability_id":"fixture.approve"}""").RootElement), + new EmbedderEventLike( + "state_changed", + 2, + JsonDocument.Parse("""{"state":"waiting_for_human"}""").RootElement), + }; + + Assert.Equal(PresentationState.Blocked, PresentationMapper.MapPresentationState(events).State); + Assert.Equal("fixture.approve", PresentationMapper.ActiveCapabilityId(events)); + } +} diff --git a/apps/traverse-starter/windows-winui/TraverseStarter/EmbeddedHost.cs b/apps/traverse-starter/windows-winui/TraverseStarter/EmbeddedHost.cs index 7f477c3..1bfe7bc 100644 --- a/apps/traverse-starter/windows-winui/TraverseStarter/EmbeddedHost.cs +++ b/apps/traverse-starter/windows-winui/TraverseStarter/EmbeddedHost.cs @@ -9,7 +9,31 @@ public sealed record HostRunResult( string SessionId, TraverseStarterOutput? Output, IReadOnlyList Events, - string? Error); + string? Error, + PresentationState PresentationState = PresentationState.Idle, + string? PresentationError = null, + IReadOnlyList? CapabilityProgress = null, + string? ActiveCapabilityId = null) +{ + public IReadOnlyList CapabilityProgressSteps => + CapabilityProgress ?? Array.Empty(); + + public HostRunResult WithPresentation(IReadOnlyList likes) + { + var snap = PresentationMapper.MapPresentationState(likes); + var state = Error is not null && snap.State == PresentationState.Idle + ? PresentationState.Error + : snap.State; + return this with + { + PresentationState = state, + PresentationError = snap.ErrorMessage + ?? (Error is not null && snap.State == PresentationState.Idle ? Error : null), + CapabilityProgress = PresentationMapper.MapCapabilityProgress(likes), + ActiveCapabilityId = PresentationMapper.ActiveCapabilityId(likes), + }; + } +} /// /// Embedded Traverse host boundary for WinUI shells. @@ -233,7 +257,8 @@ private HostRunResult DrainEvents(string sessionId) if (error is not null) { - return new HostRunResult(sessionId, null, events, error); + return new HostRunResult(sessionId, null, events, error) + .WithPresentation(Array.Empty()); } if (output is null && events.Count == 0) @@ -242,10 +267,12 @@ private HostRunResult DrainEvents(string sessionId) sessionId, null, events, - "embedder emitted no capability_result"); + "embedder emitted no capability_result") + .WithPresentation(Array.Empty()); } - return new HostRunResult(sessionId, output ?? TraverseStarterOutput.Empty, events, null); + return new HostRunResult(sessionId, output ?? TraverseStarterOutput.Empty, events, null) + .WithPresentation(Array.Empty()); } public void Dispose() @@ -332,14 +359,16 @@ public HostRunResult SubmitNote(string note) if (error is not null) { - return new HostRunResult(accepted.SessionId, null, events, error); + return new HostRunResult(accepted.SessionId, null, events, error) + .WithPresentation(Array.Empty()); } return new HostRunResult( accepted.SessionId, output ?? TraverseStarterOutput.Empty, events, - output is null ? "embedder emitted no capability_result" : null); + output is null ? "embedder emitted no capability_result" : null) + .WithPresentation(Array.Empty()); } public void Dispose() => _harness.Shutdown(); diff --git a/apps/traverse-starter/windows-winui/TraverseStarter/PresentationMapper.cs b/apps/traverse-starter/windows-winui/TraverseStarter/PresentationMapper.cs new file mode 100644 index 0000000..67323d7 --- /dev/null +++ b/apps/traverse-starter/windows-winui/TraverseStarter/PresentationMapper.cs @@ -0,0 +1,322 @@ +using System.Text.Json; + +namespace TraverseStarter; + +/// Canonical UI presentation states (Spec 001). +public enum PresentationState +{ + Idle, + Loading, + Loaded, + Blocked, + Ended, + Error, +} + +public static class PresentationStateExtensions +{ + public static string AsWire(this PresentationState state) => state switch + { + PresentationState.Idle => "idle", + PresentationState.Loading => "loading", + PresentationState.Loaded => "loaded", + PresentationState.Blocked => "blocked", + PresentationState.Ended => "ended", + PresentationState.Error => "error", + _ => "idle", + }; +} + +public sealed record PresentationSnapshot( + PresentationState State, + string? ErrorMessage, + JsonElement? Output); + +public enum CapabilityPhase +{ + Invoked, + Result, +} + +public static class CapabilityPhaseExtensions +{ + public static string AsWire(this CapabilityPhase phase) => phase switch + { + CapabilityPhase.Invoked => "invoked", + CapabilityPhase.Result => "result", + _ => "invoked", + }; +} + +public sealed record CapabilityProgressStep( + string CapabilityId, + CapabilityPhase Phase, + ulong Sequence, + string? Status, + JsonElement? Output); + +/// Minimal embedder event fields required by the mapper. +public sealed record EmbedderEventLike( + string EventType, + ulong Sequence, + JsonElement Data); + +/// +/// Spec 001/002 presentation + capability progress (language-equivalent of +/// packages/event-ui-conformance). +/// +public static class PresentationMapper +{ + private static readonly HashSet BlockedStates = new(StringComparer.OrdinalIgnoreCase) + { + "blocked", "waiting", "waiting_for_human", "awaiting_human", "awaiting_input", + }; + + private static readonly HashSet EndedStates = new(StringComparer.OrdinalIgnoreCase) + { + "cancelled", "canceled", "closed", "ended", + }; + + public static PresentationSnapshot MapPresentationState(IReadOnlyList events) + { + if (events.Count == 0) + { + return new PresentationSnapshot(PresentationState.Idle, null, null); + } + + var state = PresentationState.Idle; + string? errorMessage = null; + JsonElement? output = null; + + foreach (var eventItem in events) + { + switch (eventItem.EventType) + { + case "error": + state = PresentationState.Error; + errorMessage = ErrorMessageFromData(eventItem.Data) ?? "execution failed"; + break; + case "capability_invoked": + if (state != PresentationState.Error) + { + state = PresentationState.Loading; + } + + break; + case "state_changed": + if (state == PresentationState.Error) + { + break; + } + + if (IsBlockedPayload(eventItem.Data)) + { + state = PresentationState.Blocked; + } + else if (IsEndedStatePayload(eventItem.Data)) + { + state = PresentationState.Ended; + } + else if (state is not (PresentationState.Loaded or PresentationState.Ended)) + { + state = PresentationState.Loading; + } + + break; + case "capability_result": + if (state == PresentationState.Error) + { + break; + } + + if (HasRenderableOutput(eventItem.Data)) + { + state = PresentationState.Loaded; + output = GetProperty(eventItem.Data, "output"); + } + else + { + state = PresentationState.Ended; + output = null; + } + + break; + } + } + + return new PresentationSnapshot(state, errorMessage, output); + } + + public static IReadOnlyList MapCapabilityProgress( + IReadOnlyList events) + { + var steps = new List(); + foreach (var eventItem in events) + { + var capabilityId = StringField(eventItem.Data, "capability_id"); + if (capabilityId is null) + { + continue; + } + + switch (eventItem.EventType) + { + case "capability_invoked": + steps.Add(new CapabilityProgressStep( + capabilityId, + CapabilityPhase.Invoked, + eventItem.Sequence, + null, + null)); + break; + case "capability_result": + steps.Add(new CapabilityProgressStep( + capabilityId, + CapabilityPhase.Result, + eventItem.Sequence, + StringField(eventItem.Data, "status"), + GetProperty(eventItem.Data, "output"))); + break; + } + } + + return steps; + } + + public static string? ActiveCapabilityId(IReadOnlyList events) + { + var progress = MapCapabilityProgress(events); + var open = new Dictionary(StringComparer.Ordinal); + foreach (var step in progress) + { + if (step.Phase == CapabilityPhase.Invoked) + { + open[step.CapabilityId] = open.GetValueOrDefault(step.CapabilityId) + 1; + } + else + { + var count = open.GetValueOrDefault(step.CapabilityId); + if (count <= 1) + { + open.Remove(step.CapabilityId); + } + else + { + open[step.CapabilityId] = count - 1; + } + } + } + + for (var i = progress.Count - 1; i >= 0; i--) + { + var step = progress[i]; + if (step.Phase == CapabilityPhase.Invoked && open.ContainsKey(step.CapabilityId)) + { + return step.CapabilityId; + } + } + + return null; + } + + private static string? ErrorMessageFromData(JsonElement data) + { + if (data.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (!data.TryGetProperty("error", out var err)) + { + return null; + } + + if (err.ValueKind == JsonValueKind.String) + { + return err.GetString(); + } + + if (err.ValueKind == JsonValueKind.Object && + err.TryGetProperty("message", out var message) && + message.ValueKind == JsonValueKind.String) + { + return message.GetString(); + } + + return null; + } + + private static string? StringField(JsonElement data, string key) + { + if (data.ValueKind != JsonValueKind.Object) + { + return null; + } + + return data.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + private static JsonElement? GetProperty(JsonElement data, string key) + { + if (data.ValueKind != JsonValueKind.Object) + { + return null; + } + + return data.TryGetProperty(key, out var value) ? value.Clone() : null; + } + + private static string? RuntimeStateToken(JsonElement data) => + StringField(data, "state") + ?? StringField(data, "status") + ?? StringField(data, "runtime_state"); + + private static bool IsBlockedPayload(JsonElement data) + { + if (data.ValueKind == JsonValueKind.Object) + { + if (data.TryGetProperty("blocked", out var blocked) && + blocked.ValueKind is JsonValueKind.True) + { + return true; + } + + if (data.TryGetProperty("waiting_for_human", out var waiting) && + waiting.ValueKind is JsonValueKind.True) + { + return true; + } + } + + var token = RuntimeStateToken(data); + return token is not null && BlockedStates.Contains(token); + } + + private static bool IsEndedStatePayload(JsonElement data) + { + var token = RuntimeStateToken(data); + return token is not null && EndedStates.Contains(token); + } + + private static bool HasRenderableOutput(JsonElement data) + { + if (data.ValueKind != JsonValueKind.Object || !data.TryGetProperty("output", out var output)) + { + return false; + } + + if (output.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return false; + } + + if (output.ValueKind == JsonValueKind.Object && !output.EnumerateObject().Any()) + { + return false; + } + + return true; + } +} diff --git a/package-lock.json b/package-lock.json index a5a1cff..04b583c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -985,6 +985,7 @@ "apps/traverse-starter/web-react": { "version": "0.0.0", "dependencies": { + "event-ui-conformance": "*", "react": "^19.2.8", "react-dom": "^19.2.8", "traverse-embedder-web": "file:../../../vendor/traverse-embedder-web" diff --git a/packages/event-ui-conformance/src/index.ts b/packages/event-ui-conformance/src/index.ts index 0d07b5f..59ffe02 100644 --- a/packages/event-ui-conformance/src/index.ts +++ b/packages/event-ui-conformance/src/index.ts @@ -2,9 +2,6 @@ export type { CapabilityPhase, CapabilityProgressStep, EmbedderEventLike, - FixtureCase, - FixtureCatalog, - FixtureCatalogEntry, JsonValue, PresentationSnapshot, PresentationState, @@ -12,4 +9,3 @@ export type { export { mapPresentationState } from './mapPresentationState.ts' export { activeCapabilityId, mapCapabilityProgress } from './capabilityProgress.ts' -export { FIXTURES_DIR, loadAllFixtureCases, loadCatalog, loadFixtureCase } from './loadFixture.ts'