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
2 changes: 2 additions & 0 deletions apps/traverse-starter/web-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ npm run test:coverage
```

Open `http://localhost:5173`. When the embedded host is ready, submit a note and confirm runtime-owned fields render in the output panel.

**Session presentation:** Spec 001/002 fields update from each subscribed embedder event during submit (not a local timer). Because `submit` is synchronous, React may batch paints — a visible `loading` flash is not guaranteed; unit tests assert mid-stream `loading` via the presentation callback.
2 changes: 2 additions & 0 deletions apps/traverse-starter/web-react/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,7 @@ describe('App', () => {
expect(screen.getByText('T')).toBeInTheDocument()
expect(screen.getByText('Summary')).toBeInTheDocument()
expect(screen.getByText('Short')).toBeInTheDocument()
expect(screen.getByText(/Session presentation:/i)).toBeInTheDocument()
expect(screen.getByText('loaded')).toBeInTheDocument()
})
})
27 changes: 22 additions & 5 deletions apps/traverse-starter/web-react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@ import {
submitNote,
type HostRunResult,
type RuntimeStatus,
type SessionPresentation,
type TraceEvent,
type TraverseEmbedderApi,
} from './host/embeddedHost'
import { type TraverseStarterOutput } from './client/traverseOutput'

const NOTE_MAX_LENGTH = 2000

const IDLE_PRESENTATION: SessionPresentation = {
presentationState: 'idle',
presentationError: null,
capabilityProgress: [],
activeCapabilityId: null,
}

export interface AppProps {
/** Injected embedder for tests; when omitted, production BundleEmbedder.init runs. */
embedder?: TraverseEmbedderApi | null
Expand All @@ -27,6 +35,7 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) {
const [prodEmbedder, setProdEmbedder] = useState<TraverseEmbedderApi | null>(null)
const [submitting, setSubmitting] = useState(false)
const [result, setResult] = useState<HostRunResult | null>(null)
const [livePresentation, setLivePresentation] = useState<SessionPresentation | null>(null)
const formRef = useRef<HTMLFormElement>(null)

const embedder = injected ? injectedEmbedder : prodEmbedder
Expand Down Expand Up @@ -58,8 +67,13 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) {
if (!canSubmit || !embedder) return
setSubmitting(true)
setResult(null)
setLivePresentation(IDLE_PRESENTATION)
try {
setResult(submitNote(embedder, note))
setResult(
submitNote(embedder, note, (presentation) => {
setLivePresentation(presentation)
}),
)
} finally {
setSubmitting(false)
}
Expand All @@ -79,11 +93,14 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) {
}

const parsed: TraverseStarterOutput | null = result?.output ?? null
const displayError = result?.error ?? null
const displayError = result?.error ?? livePresentation?.presentationError ?? null
const trace: TraceEvent[] = result?.events ?? []
const presentationState = result?.presentationState ?? null
const activeCapability = result?.activeCapabilityId ?? null
const capabilityProgress = result?.capabilityProgress ?? []
const presentationState =
livePresentation?.presentationState ?? result?.presentationState ?? null
const activeCapability =
livePresentation?.activeCapabilityId ?? result?.activeCapabilityId ?? null
const capabilityProgress =
livePresentation?.capabilityProgress ?? result?.capabilityProgress ?? []
const statusLabel =
runtimeStatus === 'ready' ? 'Ready' : runtimeStatus === 'unavailable' ? 'Unavailable' : 'Starting'

Expand Down
46 changes: 46 additions & 0 deletions apps/traverse-starter/web-react/src/host/embeddedHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createTestEmbedder,
submitNote,
initProductionEmbedder,
observeSessionPresentation,
DEFAULT_WORKFLOW_ID,
RUNTIME_MODE_EMBEDDED,
} from './embeddedHost'
Expand Down Expand Up @@ -52,6 +53,51 @@ describe('embeddedHost', () => {
expect(result.capabilityProgress.length).toBeGreaterThan(0)
})

it('submitNote notifies presentation after each subscribed event', () => {
const output = {
validate: { valid: true, issues: [] as string[] },
process: {
title: 'Hello',
tags: ['a'],
noteType: 'note',
suggestedNextAction: 'next',
status: 'ok',
},
summarize: { summary: 'Sum', wordCount: 1 },
}
const embedder = createTestEmbedder(output)
const states: string[] = []
const result = submitNote(embedder, 'note text', (presentation) => {
states.push(presentation.presentationState)
})
expect(states).toContain('loading')
expect(states.at(-1)).toBe('loaded')
expect(result.presentationState).toBe('loaded')
})

it('observeSessionPresentation starts idle and updates after submit', () => {
const output = {
validate: { valid: true, issues: [] as string[] },
process: {
title: 'Hello',
tags: ['a'],
noteType: 'note',
suggestedNextAction: 'next',
status: 'ok',
},
summarize: { summary: 'Sum', wordCount: 1 },
}
const embedder = createTestEmbedder(output)
const states: string[] = []
observeSessionPresentation(embedder, (presentation) => {
states.push(presentation.presentationState)
})
expect(states.at(-1)).toBe('idle')
submitNote(embedder, 'note text')
expect(states).toContain('loading')
expect(states.at(-1)).toBe('loaded')
})

it('submitNote surfaces scripted execution errors', () => {
const embedder = new EmbedderTestDouble({
appId: 'traverse-starter',
Expand Down
79 changes: 68 additions & 11 deletions apps/traverse-starter/web-react/src/host/embeddedHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ export interface HostRunResult {

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,
}

/** Builds a deterministic test double for Vitest (spec 068 FR-006). */
export function createTestEmbedder(output: TraverseStarterOutput): TraverseEmbedderApi {
return new EmbedderTestDouble({
Expand Down Expand Up @@ -95,37 +110,72 @@ function toEventLikes(events: readonly EmbedderEvent[]): EmbedderEventLike[] {
}))
}

/** Map an ordered public embedder event stream to Spec 001/002 UI fields. */
export function mapSessionPresentation(
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),
}
}

/**
* 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.
*/
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))
}

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),
...mapSessionPresentation(collected, { fallbackError: base.error }),
}
}

/** Submit `{ note }` to `traverse-starter.pipeline` and collect terminal output. */
export function submitNote(embedder: TraverseEmbedderApi, note: string): HostRunResult {
export function submitNote(
embedder: TraverseEmbedderApi,
note: string,
onPresentation?: (presentation: SessionPresentation) => void,
): HostRunResult {
const collected: EmbedderEvent[] = []
embedder.subscribe((event) => {
collected.push(event)
onPresentation?.(mapSessionPresentation(collected))
})

const outcome = embedder.submit(DEFAULT_WORKFLOW_ID, { note })
if (outcome.status === 'rejected') {
return withPresentation(
const rejected = withPresentation(
{
sessionId: outcome.sessionId ?? 'sess-unknown',
output: null,
Expand All @@ -137,6 +187,13 @@ export function submitNote(embedder: TraverseEmbedderApi, note: string): HostRun
},
[],
)
onPresentation?.({
presentationState: rejected.presentationState,
presentationError: rejected.presentationError,
capabilityProgress: rejected.capabilityProgress,
activeCapabilityId: rejected.activeCapabilityId,
})
return rejected
}

const sessionId = outcome.sessionId ?? 'sess-unknown'
Expand Down
74 changes: 74 additions & 0 deletions docs/decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,3 +508,77 @@ Append-only record of design decisions for App-References. Newest sessions at th
- Assigning/staffing Traverse #1240 vs #1241 beyond “#1241 after hygiene”
- Any App-Refs interim workaround that fakes kit MCP or BundleEmbedder success
- Production registry release-train proof for lifecycle (fixture-only by design)


---

## 2026-09-06 — Live Spec 001 subscribe in starter web

**Context:** traverse-starter web derives Spec 001 presentation from subscribed embedder events, but only paints the terminal state after sync `submit`. Question: change runtime, change the web ref app, or proof-only?

### Where to change

**Question:** Where should a change land for proper subscribe-driven UI status?

**Options considered:**
- A — Web ref app only (live observe / per-event setState) — pros: UI-layer; proves Spec 001/002; no ABI change; cons: sync submit may still batch paints
- B — Traverse runtime/embedder (async submit / yields) — pros: all hosts get mid-flight paints; cons: larger; belongs upstream
- C — No product change; tests/docs only — pros: cheapest; cons: demo still jumps to loaded

**Recommendation:** A (+ optional doc note from C).

**Decision:** A — change the web ref app only.

**Why:** Runtime event stream is already sufficient; gap is reference-app subscribe UX/proof.

### Visible loading paint

**Question:** How hard to push for a visible `loading` flash?

**Options considered:**
- A1 — Live subscribe + setState per event — pros: matches Trace Explorer; Spec intent; cons: React may batch sync submit
- A2 — A1 + forced yield — pros: human-demoable flash; cons: artificial timing
- A3 — A1 wiring + tests only for mid-stream loading — pros: honest; cons: weaker live demo

**Recommendation:** A1.

**Decision:** A1.

**Why:** Correct subscribe-driven updates without demo hacks; do not require a guaranteed visible flash under sync submit.

### Scope

**Question:** How wide should A1 ship?

**Options considered:**
- S1 — traverse-starter web only — pros: smallest kit proof; cons: other shells still batch
- S2 — all primary web shells — pros: consistency; cons: large PR
- S3 — shared helper then starter — pros: less copy-paste later; cons: more upfront design

**Recommendation:** S1.

**Decision:** S1.

**Why:** Prove the pattern on the kit flagship first.

### Next action

**Question:** Ticket + implement vs log-only vs implement without ticket?

**Options considered:**
- N1 — Project 2 ticket + implement on cursor branch — pros: governance; cons: setup time
- N2 — Decision log only — pros: locks decision; cons: no code yet
- N3 — Implement without ticket — pros: fast; cons: breaks ticket rule

**Recommendation:** N1.

**Decision:** N1.

**Why:** Meaningful UI behavior change requires Spec + DoD on Project 2.

### What was explicitly deferred

- Traverse async/yield embedder changes (Option B)
- 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)
Loading