Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 30 additions & 55 deletions apps/traverse-starter/web-react/src/host/embeddedHost.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -157,7 +132,7 @@ function withPresentation(
): HostRunResult {
return {
...base,
...mapSessionPresentation(collected, { fallbackError: base.error }),
...mapEmbedderSessionPresentation(collected, { fallbackError: base.error }),
}
}

Expand All @@ -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 })
Expand Down
75 changes: 75 additions & 0 deletions docs/decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions docs/event-ui-conformance-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

Expand Down
3 changes: 3 additions & 0 deletions docs/kit-runner-persona.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/event-ui-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/event-ui-conformance/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
96 changes: 96 additions & 0 deletions packages/event-ui-conformance/src/sessionPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -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, 'event_type' | 'sequence' | 'data'>,
): 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')
})
})
Loading
Loading