From 6f119560c8746807ef27a6d618aee8b1f5f78ca0 Mon Sep 17 00:00:00 2001 From: Enrico Piovesan Date: Sun, 6 Sep 2026 17:59:01 -0600 Subject: [PATCH] Extract shared Spec 001 session presentation helpers for web authors. Move map/observe into event-ui-conformance, cut starter over, and document host vs session vocabulary plus sync-submit caveats. Co-authored-by: Cursor --- .../web-react/src/host/embeddedHost.ts | 85 ++++++---------- docs/decision-log.md | 75 +++++++++++++++ docs/event-ui-conformance-harness.md | 9 ++ docs/kit-runner-persona.md | 3 + packages/event-ui-conformance/README.md | 2 + packages/event-ui-conformance/src/index.ts | 6 ++ .../src/sessionPresentation.test.ts | 96 +++++++++++++++++++ .../src/sessionPresentation.ts | 66 +++++++++++++ 8 files changed, 287 insertions(+), 55 deletions(-) create mode 100644 packages/event-ui-conformance/src/sessionPresentation.test.ts create mode 100644 packages/event-ui-conformance/src/sessionPresentation.ts diff --git a/apps/traverse-starter/web-react/src/host/embeddedHost.ts b/apps/traverse-starter/web-react/src/host/embeddedHost.ts index b83bf99..ffcd569 100644 --- a/apps/traverse-starter/web-react/src/host/embeddedHost.ts +++ b/apps/traverse-starter/web-react/src/host/embeddedHost.ts @@ -1,12 +1,7 @@ -import type { - CapabilityProgressStep, - EmbedderEventLike, - PresentationState, -} from 'event-ui-conformance' +import type { EmbedderEventLike, PresentationState, SessionPresentation } from 'event-ui-conformance' import { - activeCapabilityId, - mapCapabilityProgress, - mapPresentationState, + mapSessionPresentation, + observeSessionPresentation as observeSessionPresentationFromPackage, } from 'event-ui-conformance' import type { EmbedderEvent, @@ -42,27 +37,13 @@ export interface HostRunResult { /** Spec 001 error text from event payloads (never invented). */ presentationError: string | null /** Spec 002 ordered capability invoke/result progress. */ - capabilityProgress: CapabilityProgressStep[] + capabilityProgress: SessionPresentation['capabilityProgress'] /** Spec 002 active capability id when an invoke is still open. */ activeCapabilityId: string | null } -export type { TraverseEmbedderApi, EmbedderEvent, PresentationState, CapabilityProgressStep } - -/** Spec 001/002 fields derived from an ordered public embedder event stream. */ -export type SessionPresentation = { - presentationState: PresentationState - presentationError: string | null - capabilityProgress: CapabilityProgressStep[] - activeCapabilityId: string | null -} - -const IDLE_PRESENTATION: SessionPresentation = { - presentationState: 'idle', - presentationError: null, - capabilityProgress: [], - activeCapabilityId: null, -} +export type { TraverseEmbedderApi, EmbedderEvent, PresentationState, SessionPresentation } +export type { CapabilityProgressStep } from 'event-ui-conformance' /** Builds a deterministic test double for Vitest (spec 068 FR-006). */ export function createTestEmbedder(output: TraverseStarterOutput): TraverseEmbedderApi { @@ -101,51 +82,45 @@ function errorMessageFromData(data: JsonValue): string | null { return null } -function toEventLikes(events: readonly EmbedderEvent[]): EmbedderEventLike[] { - return events.map((event) => ({ +function toEventLike(event: EmbedderEvent): EmbedderEventLike { + return { event_type: event.event_type, sequence: event.sequence, session_id: event.session_id, data: event.data, - })) + } } -/** Map an ordered public embedder event stream to Spec 001/002 UI fields. */ -export function mapSessionPresentation( +function toEventLikes(events: readonly EmbedderEvent[]): EmbedderEventLike[] { + return events.map(toEventLike) +} + +/** Map public embedder events to Spec 001/002 UI fields (shared package). */ +export function mapEmbedderSessionPresentation( events: readonly EmbedderEvent[], options?: { fallbackError?: string | null }, ): SessionPresentation { - if (events.length === 0 && !options?.fallbackError) { - return IDLE_PRESENTATION - } - const likes = toEventLikes(events) - const snap = mapPresentationState(likes) - const fallback = options?.fallbackError ?? null - const presentationState: PresentationState = - fallback && snap.state === 'idle' ? 'error' : snap.state - return { - presentationState, - presentationError: snap.errorMessage ?? (fallback && snap.state === 'idle' ? fallback : null), - capabilityProgress: mapCapabilityProgress(likes), - activeCapabilityId: activeCapabilityId(likes), - } + return mapSessionPresentation(toEventLikes(events), options) } /** - * Subscribe to the public embedder event stream and invoke `onChange` after each - * event (including an initial empty → `idle` snapshot). The embedder API has no - * unsubscribe; drop the host when tearing down. + * Subscribe via the public embedder API and map each event with the shared + * Spec 001/002 helpers. Prefer a fresh subscribe per run. */ export function observeSessionPresentation( host: TraverseEmbedderApi, onChange: (presentation: SessionPresentation) => void, ): void { - const collected: EmbedderEvent[] = [] - host.subscribe((event) => { - collected.push(event) - onChange(mapSessionPresentation(collected)) - }) - onChange(mapSessionPresentation(collected)) + observeSessionPresentationFromPackage( + { + subscribe(listener) { + host.subscribe((event) => { + listener(toEventLike(event)) + }) + }, + }, + onChange, + ) } function withPresentation( @@ -157,7 +132,7 @@ function withPresentation( ): HostRunResult { return { ...base, - ...mapSessionPresentation(collected, { fallbackError: base.error }), + ...mapEmbedderSessionPresentation(collected, { fallbackError: base.error }), } } @@ -170,7 +145,7 @@ export function submitNote( const collected: EmbedderEvent[] = [] embedder.subscribe((event) => { collected.push(event) - onPresentation?.(mapSessionPresentation(collected)) + onPresentation?.(mapEmbedderSessionPresentation(collected)) }) const outcome = embedder.submit(DEFAULT_WORKFLOW_ID, { note }) diff --git a/docs/decision-log.md b/docs/decision-log.md index e164ab3..e04ba68 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -582,3 +582,78 @@ Append-only record of design decisions for App-References. Newest sessions at th - Forced yield for visible loading flash (Option A2) - Rolling A1 to doc-approval / meeting-notes / loop web (Option S2) - Extracting a shared observe helper package (Option S3) + + +--- + +## 2026-09-06 — Shared web session-presentation helper + +**Context:** After starter A1 (#298), authors still lack a shared subscribe→Spec 001 map path; other primary webs batch after sync submit. + +### Next move + +**Question:** Highest-leverage next step? + +**Options considered:** +- 1 — Shared helper + roll other primary webs — pros: closes starter-only gap; cons: multi-app work +- 2 — Kit docs only — pros: cheap; cons: shells stay batch-mapping +- 3 — Traverse async/yielding submit — pros: visible mid-flight; cons: upstream +- 4 — Demo blocked/ended on Loop web — pros: non-happy SM; cons: narrow + +**Recommendation:** 1 + thin 2. + +**Decision:** 1+2. + +**Why:** Authors need a copy-paste API and vocabulary/bundle caveats documented. + +### Shared helper home + +**Question:** Where should the helper live? + +**Options considered:** +- W1 — `packages/event-ui-conformance` — pros: all primary webs already depend; cons: careful API +- W2 — new package — pros: isolation; cons: extra package +- W3 — docs-only first — pros: fast; cons: still copy-paste + +**Recommendation:** W1. + +**Decision:** W1. + +**Why:** Natural home next to Spec 001/002 mappers. + +### Ticket shape + +**Question:** How to ticket W1? + +**Options considered:** +- T1 — One ticket extract+docs+all ports — pros: one DoD; cons: large PR +- T2 — (a) extract+docs+starter adopt, (b) port remaining webs — pros: safer; cons: gap until (b) +- T3 — per-app after tiny extract — pros: parallel; cons: board noise + +**Recommendation:** T2. + +**Decision:** T2. + +**Why:** Land author-facing API first without boiling the ocean. + +### Next action + +**Question:** File/claim/implement now? + +**Options considered:** +- N1 — File (a)+(b), claim (a), implement — pros: momentum +- N2 — Decision log only +- N3 — File Ready, don’t claim + +**Recommendation:** N1. + +**Decision:** N1. + +**Why:** Keep momentum while the gap is fresh. + +### What was explicitly deferred + +- Ticket (b) ports for doc-approval / meeting-notes / loop web (and Trace Explorer if not in (a)) +- Traverse async/yielding submit (Option 3) +- Forced yield for visible loading flash +- Loop blocked/ended product demo as the next wave priority diff --git a/docs/event-ui-conformance-harness.md b/docs/event-ui-conformance-harness.md index 8ebddf3..0e18c86 100644 --- a/docs/event-ui-conformance-harness.md +++ b/docs/event-ui-conformance-harness.md @@ -32,12 +32,21 @@ import { loadFixtureCase, mapCapabilityProgress, mapPresentationState, + mapSessionPresentation, + observeSessionPresentation, } from 'event-ui-conformance' const fixture = loadFixtureCase('happy-path.json') const ui = mapPresentationState(fixture.events) // ui.state === 'loaded'; ui.output from capability_result only +const session = mapSessionPresentation(fixture.events) +// session.presentationState === 'loaded' (+ capabilityProgress / activeCapabilityId) + +// Live subscribe (fresh per run; host has no unsubscribe): +// observeSessionPresentation(host, (presentation) => { ... }) +``` + const progress = mapCapabilityProgress(fixture.events) ``` diff --git a/docs/kit-runner-persona.md b/docs/kit-runner-persona.md index 5452a38..c153c87 100644 --- a/docs/kit-runner-persona.md +++ b/docs/kit-runner-persona.md @@ -17,6 +17,8 @@ For **each** primary OS target, a persona can: | **Configuration** | `config_schema` / `default_config` / `workspace_defaults`; OS settings where present | Health/settings show `workspace_id` (default `local-default`) | | **Workflow** | `workflows[]` (starter: `traverse-starter.pipeline`) | One submit runs the chained capabilities; UI does not call each cap itself | +**Web authors (Specs 001/002):** use `mapSessionPresentation` / `observeSessionPresentation` from [`event-ui-conformance`](../packages/event-ui-conformance/) (starter web wraps them in `embeddedHost`). Do not confuse **host** status (`starting` / `ready` / `unavailable` from embedder init) with **session** presentation (`idle` / `loading` / `loaded` / `blocked` / `ended` / `error`). Prefer a **fresh subscribe per run** (no unsubscribe API). Sync `submit` may batch React paints — mid-stream `loading` is asserted in unit tests; a visible flash is not guaranteed. For local digest-pinned E2E, use `bash scripts/ci/prepare_embedded_smoke_bundle.sh` (plain sync alone can leave stub WASM / digest mismatch → Unavailable). + Primary apps on all seven OS: `traverse-starter`, `doc-approval`, `meeting-notes`, `loop`. Trace Explorer is web-only (debugger). LLM MCP is a **separate** façade. ## CI vs human @@ -69,6 +71,7 @@ Local npm gates always. Manifest / `registry_ref` / runbook probes always. `TRAV | Registry MCP is not an OS-shell path | `llm-mcp-mode-a-spec119-scaffold` Done (fail-closed); live kit execute `llm-mcp-traverse-starter-catalog` Blocked | | Creating a **new** app id from CLI + this kit | [`new-app-author.md`](new-app-author.md) (`new-app-author-e2e`) | | `onboarding_check.sh` is not a merge-blocking CI gate | By design (slow `npm install`); `embedded_smoke` is the PR gate | +| Live Spec 001 subscribe on non-starter primary webs | `web-session-presentation-port-primary` (Blocked on shared helper) | ## File bugs diff --git a/packages/event-ui-conformance/README.md b/packages/event-ui-conformance/README.md index c09c28c..7567d76 100644 --- a/packages/event-ui-conformance/README.md +++ b/packages/event-ui-conformance/README.md @@ -4,9 +4,11 @@ Shared TypeScript helpers for Specs 001/002: - Pure `mapPresentationState` → `idle|loading|loaded|blocked|ended|error` - `mapCapabilityProgress` / `activeCapabilityId` from public embedder events +- `mapSessionPresentation` / `observeSessionPresentation` for live subscribe → UI fields - Loaders for language-agnostic fixtures under `fixtures/event-ui-conformance/` Consumer guide: [`docs/event-ui-conformance-harness.md`](../../docs/event-ui-conformance-harness.md). +Kit-runner state-machine notes: [`docs/kit-runner-persona.md`](../../docs/kit-runner-persona.md). ```bash npm run test -w event-ui-conformance diff --git a/packages/event-ui-conformance/src/index.ts b/packages/event-ui-conformance/src/index.ts index 59ffe02..b2ff528 100644 --- a/packages/event-ui-conformance/src/index.ts +++ b/packages/event-ui-conformance/src/index.ts @@ -7,5 +7,11 @@ export type { PresentationState, } from './types.ts' +export type { SessionPresentation, SessionPresentationHost } from './sessionPresentation.ts' + export { mapPresentationState } from './mapPresentationState.ts' export { activeCapabilityId, mapCapabilityProgress } from './capabilityProgress.ts' +export { + mapSessionPresentation, + observeSessionPresentation, +} from './sessionPresentation.ts' diff --git a/packages/event-ui-conformance/src/sessionPresentation.test.ts b/packages/event-ui-conformance/src/sessionPresentation.test.ts new file mode 100644 index 0000000..53cb80e --- /dev/null +++ b/packages/event-ui-conformance/src/sessionPresentation.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + mapSessionPresentation, + observeSessionPresentation, + type SessionPresentationHost, +} from './sessionPresentation.ts' +import type { EmbedderEventLike } from './types.ts' + +function event( + partial: Pick, +): EmbedderEventLike { + return partial +} + +describe('mapSessionPresentation', () => { + it('returns idle for an empty stream', () => { + expect(mapSessionPresentation([])).toEqual({ + presentationState: 'idle', + presentationError: null, + capabilityProgress: [], + activeCapabilityId: null, + }) + }) + + it('maps mid-stream invoke to loading then result to loaded', () => { + const mid = [ + event({ + event_type: 'capability_invoked', + sequence: 1, + data: { capability_id: 'fixture.process' }, + }), + ] + expect(mapSessionPresentation(mid).presentationState).toBe('loading') + expect(mapSessionPresentation(mid).activeCapabilityId).toBe('fixture.process') + + const done = [ + ...mid, + event({ + event_type: 'capability_result', + sequence: 2, + data: { + capability_id: 'fixture.process', + status: 'completed', + output: { title: 'runtime-owned' }, + }, + }), + ] + expect(mapSessionPresentation(done).presentationState).toBe('loaded') + }) + + it('maps fallbackError on empty stream to error', () => { + const snap = mapSessionPresentation([], { fallbackError: 'submit rejected' }) + expect(snap.presentationState).toBe('error') + expect(snap.presentationError).toBe('submit rejected') + }) +}) + +describe('observeSessionPresentation', () => { + it('starts idle and updates after each subscribed event', () => { + const listeners: Array<(event: EmbedderEventLike) => void> = [] + const host: SessionPresentationHost = { + subscribe(listener) { + listeners.push(listener) + }, + } + const states: string[] = [] + observeSessionPresentation(host, (presentation) => { + states.push(presentation.presentationState) + }) + expect(states).toEqual(['idle']) + + for (const listener of listeners) { + listener( + event({ + event_type: 'capability_invoked', + sequence: 1, + data: { capability_id: 'fixture.process' }, + }), + ) + listener( + event({ + event_type: 'capability_result', + sequence: 2, + data: { + capability_id: 'fixture.process', + status: 'completed', + output: { ok: true }, + }, + }), + ) + } + + expect(states).toContain('loading') + expect(states.at(-1)).toBe('loaded') + }) +}) diff --git a/packages/event-ui-conformance/src/sessionPresentation.ts b/packages/event-ui-conformance/src/sessionPresentation.ts new file mode 100644 index 0000000..62e581a --- /dev/null +++ b/packages/event-ui-conformance/src/sessionPresentation.ts @@ -0,0 +1,66 @@ +import { activeCapabilityId, mapCapabilityProgress } from './capabilityProgress.ts' +import { mapPresentationState } from './mapPresentationState.ts' +import type { + CapabilityProgressStep, + EmbedderEventLike, + PresentationState, +} from './types.ts' + +/** Spec 001/002 fields derived from an ordered public embedder event stream. */ +export type SessionPresentation = { + presentationState: PresentationState + presentationError: string | null + capabilityProgress: CapabilityProgressStep[] + activeCapabilityId: string | null +} + +/** Minimal subscribe surface (compatible with public embedder hosts). */ +export type SessionPresentationHost = { + subscribe(listener: (event: EmbedderEventLike) => void): void +} + +const IDLE_PRESENTATION: SessionPresentation = { + presentationState: 'idle', + presentationError: null, + capabilityProgress: [], + activeCapabilityId: null, +} + +/** Map an ordered public embedder event stream to Spec 001/002 UI fields. */ +export function mapSessionPresentation( + events: readonly EmbedderEventLike[], + options?: { fallbackError?: string | null }, +): SessionPresentation { + if (events.length === 0 && !options?.fallbackError) { + return IDLE_PRESENTATION + } + const snap = mapPresentationState(events) + const fallback = options?.fallbackError ?? null + const presentationState: PresentationState = + fallback && snap.state === 'idle' ? 'error' : snap.state + return { + presentationState, + presentationError: + snap.errorMessage ?? (fallback && snap.state === 'idle' ? fallback : null), + capabilityProgress: mapCapabilityProgress(events), + activeCapabilityId: activeCapabilityId(events), + } +} + +/** + * Subscribe to a public embedder event stream and invoke `onChange` after each + * event (including an initial empty → `idle` snapshot). Hosts typically have no + * unsubscribe; drop the host when tearing down. Prefer a fresh subscribe per run + * so prior sessions are not mixed into the mapped snapshot. + */ +export function observeSessionPresentation( + host: SessionPresentationHost, + onChange: (presentation: SessionPresentation) => void, +): void { + const collected: EmbedderEventLike[] = [] + host.subscribe((event) => { + collected.push(event) + onChange(mapSessionPresentation(collected)) + }) + onChange(mapSessionPresentation(collected)) +}