From f91752feb4436539675210d4001599d0cb4c86fd Mon Sep 17 00:00:00 2001 From: Enrico Piovesan Date: Sat, 22 Aug 2026 20:07:53 -0600 Subject: [PATCH] feat(meeting-notes): 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 --- .../MeetingNotesCore/AppStateViewModel.swift | 9 + .../MeetingNotesCore/EmbeddedHost.swift | 51 ++- .../MeetingNotesCore/MeetingNotesOutput.swift | 17 + .../MeetingNotesCore/PresentationMapper.swift | 224 ++++++++++++ .../PresentationMapperTests.swift | 52 +++ .../meetingnotes/EmbeddedHost.kt | 28 +- .../meetingnotes/PresentationMapper.kt | 209 +++++++++++ .../meetingnotes/PresentationMapperTest.kt | 59 +++ .../cli-rust/src/commands/submit.rs | 3 + apps/meeting-notes/cli-rust/src/output.rs | 10 + .../linux-gtk/src/execution_state.rs | 2 + .../linux-gtk/src/ui/main_window.rs | 15 +- .../meeting-notes-core-rs/src/host.rs | 25 ++ .../meeting-notes-core-rs/src/lib.rs | 5 + .../meeting-notes-core-rs/src/presentation.rs | 341 ++++++++++++++++++ .../meeting-notes-core-rs/tests/host_tests.rs | 5 + apps/meeting-notes/web-react/package.json | 1 + apps/meeting-notes/web-react/src/App.tsx | 3 + .../src/components/HealthIndicator.tsx | 43 ++- .../web-react/src/host/embeddedHost.test.ts | 73 ++++ .../web-react/src/host/embeddedHost.ts | 130 +++++-- .../MeetingNotes.Tests.csproj | 1 + .../PresentationMapperTests.cs | 56 +++ .../MeetingNotes/EmbeddedHost.cs | 41 ++- .../MeetingNotes/PresentationMapper.cs | 322 +++++++++++++++++ package-lock.json | 1 + 26 files changed, 1677 insertions(+), 49 deletions(-) create mode 100644 apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/PresentationMapper.swift create mode 100644 apps/meeting-notes/MeetingNotesCore/Tests/MeetingNotesCoreTests/PresentationMapperTests.swift create mode 100644 apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/PresentationMapper.kt create mode 100644 apps/meeting-notes/android-compose/app/src/test/java/com/traverseframework/meetingnotes/PresentationMapperTest.kt create mode 100644 apps/meeting-notes/meeting-notes-core-rs/src/presentation.rs create mode 100644 apps/meeting-notes/web-react/src/host/embeddedHost.test.ts create mode 100644 apps/meeting-notes/windows-winui/MeetingNotes.Tests/PresentationMapperTests.cs create mode 100644 apps/meeting-notes/windows-winui/MeetingNotes/PresentationMapper.cs diff --git a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/AppStateViewModel.swift b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/AppStateViewModel.swift index f3cccdb..b5b61dc 100644 --- a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/AppStateViewModel.swift +++ b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/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 transcript: 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/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/EmbeddedHost.swift b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/EmbeddedHost.swift index f2d7acc..b992f56 100644 --- a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/EmbeddedHost.swift +++ b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/EmbeddedHost.swift @@ -7,17 +7,46 @@ public struct HostRunResult: Equatable, Sendable { public let output: MeetingNotesOutput? 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: MeetingNotesOutput?, 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/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesOutput.swift b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesOutput.swift index 12f68e4..055200e 100644 --- a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesOutput.swift +++ b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesOutput.swift @@ -139,6 +139,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 MeetingNotesOutputParser { diff --git a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/PresentationMapper.swift b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/PresentationMapper.swift new file mode 100644 index 0000000..64d33c5 --- /dev/null +++ b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/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/meeting-notes/MeetingNotesCore/Tests/MeetingNotesCoreTests/PresentationMapperTests.swift b/apps/meeting-notes/MeetingNotesCore/Tests/MeetingNotesCoreTests/PresentationMapperTests.swift new file mode 100644 index 0000000..4d35a68 --- /dev/null +++ b/apps/meeting-notes/MeetingNotesCore/Tests/MeetingNotesCoreTests/PresentationMapperTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import MeetingNotesCore + +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/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/EmbeddedHost.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/EmbeddedHost.kt index b388067..a244b7d 100644 --- a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/EmbeddedHost.kt +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/EmbeddedHost.kt @@ -23,7 +23,28 @@ data class HostRunResult( val output: MeetingNotesOutput?, 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 InMemoryMeetingNotesHost( @@ -62,7 +83,7 @@ class InMemoryMeetingNotesHost( ) }, error = if (output == null) "embedder emitted no capability_result output" else null, - ) + ).withPresentation(emptyList()) } companion object { @@ -115,9 +136,10 @@ class ProductionMeetingNotesHost private constructor( } else { null }, - ) + ).withPresentation(emptyList()) } catch (e: Exception) { HostRunResult("", null, emptyList(), e.message ?: "submit failed") + .withPresentation(emptyList()) } companion object { diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/PresentationMapper.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/PresentationMapper.kt new file mode 100644 index 0000000..1aa550d --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/PresentationMapper.kt @@ -0,0 +1,209 @@ +package com.traverseframework.meetingnotes + +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/meeting-notes/android-compose/app/src/test/java/com/traverseframework/meetingnotes/PresentationMapperTest.kt b/apps/meeting-notes/android-compose/app/src/test/java/com/traverseframework/meetingnotes/PresentationMapperTest.kt new file mode 100644 index 0000000..8b1dce7 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/test/java/com/traverseframework/meetingnotes/PresentationMapperTest.kt @@ -0,0 +1,59 @@ +package com.traverseframework.meetingnotes + +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/meeting-notes/cli-rust/src/commands/submit.rs b/apps/meeting-notes/cli-rust/src/commands/submit.rs index b07b752..f92fc25 100644 --- a/apps/meeting-notes/cli-rust/src/commands/submit.rs +++ b/apps/meeting-notes/cli-rust/src/commands/submit.rs @@ -32,6 +32,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/meeting-notes/cli-rust/src/output.rs b/apps/meeting-notes/cli-rust/src/output.rs index 8fe2275..78f69fd 100644 --- a/apps/meeting-notes/cli-rust/src/output.rs +++ b/apps/meeting-notes/cli-rust/src/output.rs @@ -9,6 +9,9 @@ pub struct SubmitResultJson { pub execution_id: String, pub output: MeetingNotesOutput, pub trace: Vec, + pub presentation_state: String, + pub presentation_error: Option, + pub active_capability_id: Option, } pub fn print_json(value: &Value) { @@ -39,6 +42,13 @@ pub fn print_submit_result(result: &SubmitResultJson, json: bool) { for follow_up in &output.follow_ups { println!(" - {follow_up}"); } + 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/meeting-notes/linux-gtk/src/execution_state.rs b/apps/meeting-notes/linux-gtk/src/execution_state.rs index 904ce2a..780e89d 100644 --- a/apps/meeting-notes/linux-gtk/src/execution_state.rs +++ b/apps/meeting-notes/linux-gtk/src/execution_state.rs @@ -7,6 +7,8 @@ pub enum ExecutionPhase { Succeeded { output: MeetingNotesOutput, trace: Vec, + presentation_state: String, + active_capability_id: Option, }, Failed { error: String }, } diff --git a/apps/meeting-notes/linux-gtk/src/ui/main_window.rs b/apps/meeting-notes/linux-gtk/src/ui/main_window.rs index 651dc9b..b5b8342 100644 --- a/apps/meeting-notes/linux-gtk/src/ui/main_window.rs +++ b/apps/meeting-notes/linux-gtk/src/ui/main_window.rs @@ -189,8 +189,17 @@ impl MainWindow { output_label.set_text(&format!("Error: {error}")); output_label.remove_css_class("dim-label"); } - ExecutionPhase::Succeeded { output, trace } => { - let mut body = format!("Summary\n{}\n\nAction items\n", output.summary); + ExecutionPhase::Succeeded { + output, + trace, + presentation_state, + active_capability_id, + } => { + let active = active_capability_id.as_deref().unwrap_or("—"); + let mut body = format!( + "Presentation: {presentation_state}\nActive capability: {active}\n\nSummary\n{}\n\nAction items\n", + output.summary + ); for item in &output.action_items { body.push_str(&format!( "- {} (owner: {}, due: {})\n", @@ -289,6 +298,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/meeting-notes/meeting-notes-core-rs/src/host.rs b/apps/meeting-notes/meeting-notes-core-rs/src/host.rs index 95669ff..0a896c8 100644 --- a/apps/meeting-notes/meeting-notes-core-rs/src/host.rs +++ b/apps/meeting-notes/meeting-notes-core-rs/src/host.rs @@ -11,6 +11,9 @@ use traverse_embedder::{ }; use crate::client::{MeetingNotesOutput, TraceEvent}; +use crate::presentation::{ + active_capability_id, map_capability_progress, map_presentation_state, EmbedderEventLike, +}; use crate::state::StateEvent; pub const DEFAULT_WORKFLOW_ID: &str = "meeting-notes.process"; @@ -43,6 +46,10 @@ pub struct HostRunResult { pub session_id: String, pub output: MeetingNotesOutput, pub events: Vec, + pub presentation_state: crate::PresentationState, + pub presentation_error: Option, + pub capability_progress: Vec, + pub active_capability_id: Option, } pub fn resolve_manifest_path(start: Option<&Path>) -> Option { @@ -107,6 +114,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; @@ -129,6 +150,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/meeting-notes/meeting-notes-core-rs/src/lib.rs b/apps/meeting-notes/meeting-notes-core-rs/src/lib.rs index 012e7ea..edb9d0c 100644 --- a/apps/meeting-notes/meeting-notes-core-rs/src/lib.rs +++ b/apps/meeting-notes/meeting-notes-core-rs/src/lib.rs @@ -5,6 +5,7 @@ mod client; mod host; +mod presentation; mod state; pub use client::{ActionItem, Decision, MeetingNotesOutput, TraceEvent}; @@ -12,6 +13,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 = "meeting-notes"; diff --git a/apps/meeting-notes/meeting-notes-core-rs/src/presentation.rs b/apps/meeting-notes/meeting-notes-core-rs/src/presentation.rs new file mode 100644 index 0000000..e0a43e4 --- /dev/null +++ b/apps/meeting-notes/meeting-notes-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/meeting-notes/meeting-notes-core-rs/tests/host_tests.rs b/apps/meeting-notes/meeting-notes-core-rs/tests/host_tests.rs index 6158c47..aef43d2 100644 --- a/apps/meeting-notes/meeting-notes-core-rs/tests/host_tests.rs +++ b/apps/meeting-notes/meeting-notes-core-rs/tests/host_tests.rs @@ -34,4 +34,9 @@ fn test_double_submit_transcript_returns_scripted_output() { assert_eq!(result.output.summary, "We agreed to ship Wave 1."); assert_eq!(result.output.action_items[0].task, "Send notes"); assert_eq!(result.output.decisions[0].text, "Ship Wave 1"); + assert_eq!( + result.presentation_state, + meeting_notes_core_rs::PresentationState::Loaded + ); + assert!(!result.capability_progress.is_empty()); } diff --git a/apps/meeting-notes/web-react/package.json b/apps/meeting-notes/web-react/package.json index ed58bc6..29a7639 100644 --- a/apps/meeting-notes/web-react/package.json +++ b/apps/meeting-notes/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/meeting-notes/web-react/src/App.tsx b/apps/meeting-notes/web-react/src/App.tsx index dae28a6..5b6ea57 100644 --- a/apps/meeting-notes/web-react/src/App.tsx +++ b/apps/meeting-notes/web-react/src/App.tsx @@ -95,6 +95,9 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) { workspace={DEFAULT_WORKSPACE} workflowId={DEFAULT_WORKFLOW_ID} status={runtimeStatus} + presentationState={result?.presentationState ?? null} + activeCapabilityId={result?.activeCapabilityId ?? null} + capabilityProgress={result?.capabilityProgress ?? []} /> Workspace: {workspace} · Workflow: {workflowId} + {presentationState && ( +
+ Session presentation: {presentationState} + {activeCapabilityId ? ( + <> + {' '} + · Active capability: {activeCapabilityId} + + ) : null} + {capabilityProgress.length > 0 ? ( + <> + {' '} + · Capability steps:{' '} + + {capabilityProgress + .map((step) => `${step.capabilityId}:${step.phase}`) + .join(' → ')} + + + ) : null} +
+ )} ) } diff --git a/apps/meeting-notes/web-react/src/host/embeddedHost.test.ts b/apps/meeting-notes/web-react/src/host/embeddedHost.test.ts new file mode 100644 index 0000000..500810a --- /dev/null +++ b/apps/meeting-notes/web-react/src/host/embeddedHost.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { EmbedderTestDouble } from 'traverse-embedder-web' +import { + createTestEmbedder, + submitTranscript, + initProductionEmbedder, + DEFAULT_WORKFLOW_ID, + RUNTIME_MODE_EMBEDDED, +} from './embeddedHost' +import type { MeetingNotesOutput } from '../client/traverseOutput' + +const initMock = vi.fn() + +vi.mock('traverse-embedder-web', async () => { + const actual = await vi.importActual( + 'traverse-embedder-web', + ) + return { + ...actual, + BundleEmbedder: { init: (...args: unknown[]) => initMock(...args) }, + FetchBundleLoader: actual.FetchBundleLoader, + } +}) + +const sampleOutput: MeetingNotesOutput = { + action_items: [{ task: 'Send follow-up email', owner: 'Alex', due: '2026-07-15' }], + decisions: [{ text: 'Ship Phase 1 this week', made_by: 'Jordan' }], + follow_ups: ['Schedule design review'], + summary: 'Team aligned on Phase 1 scope.', +} + +describe('embeddedHost', () => { + beforeEach(() => { + initMock.mockReset() + }) + + it('exposes Embedded runtime constants', () => { + expect(RUNTIME_MODE_EMBEDDED).toBe('Embedded') + expect(DEFAULT_WORKFLOW_ID).toBe('meeting-notes.process') + }) + + it('submitTranscript returns scripted output from test double', () => { + const embedder = createTestEmbedder(sampleOutput) + const result = submitTranscript(embedder, 'Alex will send the follow-up email.') + expect(result.error).toBeNull() + expect(result.output?.summary).toBe('Team aligned on Phase 1 scope.') + expect(result.events.length).toBeGreaterThan(0) + expect(result.presentationState).toBe('loaded') + expect(result.capabilityProgress.length).toBeGreaterThan(0) + }) + + it('submitTranscript surfaces scripted execution errors', () => { + const embedder = new EmbedderTestDouble({ + appId: 'meeting-notes', + platform: 'web', + }).withTargetError(DEFAULT_WORKFLOW_ID, 'execution_failed', 'boom') + const result = submitTranscript(embedder, 'x') + expect(result.error).toContain('boom') + expect(result.output).toBeNull() + expect(result.presentationState).toBe('error') + }) + + it('submitTranscript surfaces rejected unknown targets', () => { + const embedder = new EmbedderTestDouble({ appId: 'meeting-notes', platform: 'web' }) + const result = submitTranscript(embedder, 'x') + expect(result.error).toMatch(/target_not_found|rejected/) + }) + + it('initProductionEmbedder returns null when BundleEmbedder.init fails', async () => { + initMock.mockRejectedValueOnce(new Error('missing bundle')) + await expect(initProductionEmbedder('/missing.json')).resolves.toBeNull() + }) +}) diff --git a/apps/meeting-notes/web-react/src/host/embeddedHost.ts b/apps/meeting-notes/web-react/src/host/embeddedHost.ts index 62a1b9b..23d7739 100644 --- a/apps/meeting-notes/web-react/src/host/embeddedHost.ts +++ b/apps/meeting-notes/web-react/src/host/embeddedHost.ts @@ -1,4 +1,18 @@ -import type { JsonValue, TraverseEmbedderApi } from 'traverse-embedder-web' +import type { + CapabilityProgressStep, + EmbedderEventLike, + PresentationState, +} from 'event-ui-conformance' +import { + activeCapabilityId, + mapCapabilityProgress, + mapPresentationState, +} from 'event-ui-conformance' +import type { + EmbedderEvent, + JsonValue, + TraverseEmbedderApi, +} from 'traverse-embedder-web' import { BundleEmbedder, EmbedderTestDouble, FetchBundleLoader } from 'traverse-embedder-web' import { parseMeetingNotesOutput, type MeetingNotesOutput } from '../client/traverseOutput' @@ -22,9 +36,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 } +export type { TraverseEmbedderApi, EmbedderEvent, PresentationState, CapabilityProgressStep } export function createTestEmbedder(output: MeetingNotesOutput): TraverseEmbedderApi { return new EmbedderTestDouble({ @@ -62,23 +84,56 @@ 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), + } +} + export function submitTranscript(embedder: TraverseEmbedderApi, transcript: string): HostRunResult { - const collected: import('traverse-embedder-web').EmbedderEvent[] = [] + const collected: EmbedderEvent[] = [] embedder.subscribe((event) => { collected.push(event) }) const outcome = embedder.submit(DEFAULT_WORKFLOW_ID, { transcript }) 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' @@ -91,13 +146,16 @@ export function submitTranscript(embedder: TraverseEmbedderApi, transcript: stri 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 = @@ -105,21 +163,27 @@ export function submitTranscript(embedder: TraverseEmbedderApi, transcript: stri ? (event.data as Record) : null const rawOutput = data?.output ?? null - return { - sessionId, - output: parseMeetingNotesOutput(rawOutput), - rawOutput, - events, - error: null, - } + return withPresentation( + { + sessionId, + output: parseMeetingNotesOutput(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/meeting-notes/windows-winui/MeetingNotes.Tests/MeetingNotes.Tests.csproj b/apps/meeting-notes/windows-winui/MeetingNotes.Tests/MeetingNotes.Tests.csproj index 66a85b5..e890aca 100644 --- a/apps/meeting-notes/windows-winui/MeetingNotes.Tests/MeetingNotes.Tests.csproj +++ b/apps/meeting-notes/windows-winui/MeetingNotes.Tests/MeetingNotes.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/apps/meeting-notes/windows-winui/MeetingNotes.Tests/PresentationMapperTests.cs b/apps/meeting-notes/windows-winui/MeetingNotes.Tests/PresentationMapperTests.cs new file mode 100644 index 0000000..a06e298 --- /dev/null +++ b/apps/meeting-notes/windows-winui/MeetingNotes.Tests/PresentationMapperTests.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using Xunit; + +namespace MeetingNotes.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/meeting-notes/windows-winui/MeetingNotes/EmbeddedHost.cs b/apps/meeting-notes/windows-winui/MeetingNotes/EmbeddedHost.cs index d8fae98..673b42e 100644 --- a/apps/meeting-notes/windows-winui/MeetingNotes/EmbeddedHost.cs +++ b/apps/meeting-notes/windows-winui/MeetingNotes/EmbeddedHost.cs @@ -9,7 +9,31 @@ public sealed record HostRunResult( string SessionId, MeetingNotesOutput? 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. @@ -225,7 +249,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) @@ -234,10 +259,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 ?? MeetingNotesOutput.Empty, events, null); + return new HostRunResult(sessionId, output ?? MeetingNotesOutput.Empty, events, null) + .WithPresentation(Array.Empty()); } public void Dispose() @@ -324,14 +351,16 @@ public HostRunResult SubmitTranscript(string transcript) 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 ?? MeetingNotesOutput.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/meeting-notes/windows-winui/MeetingNotes/PresentationMapper.cs b/apps/meeting-notes/windows-winui/MeetingNotes/PresentationMapper.cs new file mode 100644 index 0000000..bba1a82 --- /dev/null +++ b/apps/meeting-notes/windows-winui/MeetingNotes/PresentationMapper.cs @@ -0,0 +1,322 @@ +using System.Text.Json; + +namespace MeetingNotes; + +/// 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 04b583c..e72af2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -340,6 +340,7 @@ "name": "meeting-notes-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"