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
30 changes: 26 additions & 4 deletions apps/doc-approval/web-react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,19 @@ import {
submitDocument,
type HostRunResult,
type RuntimeStatus,
type SessionPresentation,
type TraverseEmbedderApi,
} from './host/embeddedHost'

const DOCUMENT_MAX_LENGTH = 10000

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

export interface AppProps {
embedder?: TraverseEmbedderApi | null
}
Expand All @@ -25,6 +33,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 embedder = injected ? injectedEmbedder : prodEmbedder
const runtimeStatus: RuntimeStatus = injected
Expand Down Expand Up @@ -55,8 +64,13 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) {
if (!canSubmit || !embedder) return
setSubmitting(true)
setResult(null)
setLivePresentation(IDLE_PRESENTATION)
try {
setResult(submitDocument(embedder, document))
setResult(
submitDocument(embedder, document, (presentation) => {
setLivePresentation(presentation)
}),
)
} finally {
setSubmitting(false)
}
Expand All @@ -66,9 +80,17 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) {

const handleReset = useCallback(() => {
setResult(null)
setLivePresentation(null)
setDocument('')
}, [])

const presentationState =
livePresentation?.presentationState ?? result?.presentationState ?? null
const activeCapabilityId =
livePresentation?.activeCapabilityId ?? result?.activeCapabilityId ?? null
const capabilityProgress =
livePresentation?.capabilityProgress ?? result?.capabilityProgress ?? []

return (
<div style={{ maxWidth: '800px', margin: '40px auto', padding: '0 20px' }}>
<header style={{ marginBottom: '40px', textAlign: 'center' }}>
Expand All @@ -95,9 +117,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 ?? []}
presentationState={presentationState}
activeCapabilityId={activeCapabilityId}
capabilityProgress={capabilityProgress}
/>

<DocumentInput
Expand Down
11 changes: 11 additions & 0 deletions apps/doc-approval/web-react/src/host/embeddedHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ describe('embeddedHost', () => {
expect(result.capabilityProgress.length).toBeGreaterThan(0)
})

it('submitDocument notifies presentation after each subscribed event', () => {
const embedder = createTestEmbedder(sampleOutput)
const states: string[] = []
const result = submitDocument(embedder, 'Invoice for Acme Corp', (presentation) => {
states.push(presentation.presentationState)
})
expect(states).toContain('loading')
expect(states.at(-1)).toBe('loaded')
expect(result.presentationState).toBe('loaded')
})

it('submitDocument surfaces scripted execution errors', () => {
const embedder = new EmbedderTestDouble({
appId: 'doc-approval',
Expand Down
81 changes: 57 additions & 24 deletions apps/doc-approval/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 @@ -41,12 +36,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 }
export type { TraverseEmbedderApi, EmbedderEvent, PresentationState, SessionPresentation }
export type { CapabilityProgressStep } from 'event-ui-conformance'

export function createTestEmbedder(output: DocApprovalOutput): TraverseEmbedderApi {
return new EmbedderTestDouble({
Expand Down Expand Up @@ -84,13 +80,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,
}))
}
}

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 {
return mapSessionPresentation(toEventLikes(events), options)
}

/**
* 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 {
observeSessionPresentationFromPackage(
{
subscribe(listener) {
host.subscribe((event) => {
listener(toEventLike(event))
})
},
},
onChange,
)
}

function withPresentation(
Expand All @@ -100,29 +128,27 @@ function withPresentation(
>,
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),
...mapEmbedderSessionPresentation(collected, { fallbackError: base.error }),
}
}

export function submitDocument(embedder: TraverseEmbedderApi, document: string): HostRunResult {
/** Submit `{ document }` to `doc-approval.pipeline` and collect terminal output. */
export function submitDocument(
embedder: TraverseEmbedderApi,
document: string,
onPresentation?: (presentation: SessionPresentation) => void,
): HostRunResult {
const collected: EmbedderEvent[] = []
embedder.subscribe((event) => {
collected.push(event)
onPresentation?.(mapEmbedderSessionPresentation(collected))
})

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

const sessionId = outcome.sessionId ?? 'sess-unknown'
Expand Down
36 changes: 31 additions & 5 deletions apps/loop/web-react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@ import {
submitTranscript,
type HostRunResult,
type RuntimeStatus,
type SessionPresentation,
type TraverseEmbedderApi,
} from './host/embeddedHost'

const TRANSCRIPT_MAX_LENGTH = 5000

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

export interface AppProps {
embedder?: TraverseEmbedderApi | null
}
Expand All @@ -26,6 +34,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 embedder = injected ? injectedEmbedder : prodEmbedder
const runtimeStatus: RuntimeStatus = injected
Expand Down Expand Up @@ -56,8 +65,13 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) {
if (!canSubmit || !embedder) return
setSubmitting(true)
setResult(null)
setLivePresentation(IDLE_PRESENTATION)
try {
setResult(submitTranscript(embedder, transcript))
setResult(
submitTranscript(embedder, transcript, (presentation) => {
setLivePresentation(presentation)
}),
)
} finally {
setSubmitting(false)
}
Expand All @@ -67,9 +81,17 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) {

const handleReset = useCallback(() => {
setResult(null)
setLivePresentation(null)
setTranscript('')
}, [])

const presentationState =
livePresentation?.presentationState ?? result?.presentationState ?? null
const activeCapabilityId =
livePresentation?.activeCapabilityId ?? result?.activeCapabilityId ?? null
const capabilityProgress =
livePresentation?.capabilityProgress ?? result?.capabilityProgress ?? []

return (
<div style={{ maxWidth: '800px', margin: '40px auto', padding: '0 20px' }}>
<header style={{ marginBottom: '40px', textAlign: 'center' }}>
Expand All @@ -96,9 +118,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 ?? []}
presentationState={presentationState}
activeCapabilityId={activeCapabilityId}
capabilityProgress={capabilityProgress}
/>

<TranscriptInput
Expand All @@ -119,17 +141,21 @@ function App({ embedder: injectedEmbedder }: AppProps = {}) {
/>

<ReviewPanel
presentationState={result?.presentationState}
presentationState={presentationState}
candidates={result?.rawOutput ?? result?.output ?? null}
onAccept={() => {
// Accept resumes via a follow-up submit of the runtime candidate payload.
if (!embedder || !result?.rawOutput) return
setLivePresentation(IDLE_PRESENTATION)
setResult(
submitTranscript(
embedder,
typeof result.rawOutput === 'object'
? JSON.stringify({ decision: 'accept', candidates: result.rawOutput })
: String(result.rawOutput),
(presentation) => {
setLivePresentation(presentation)
},
),
)
}}
Expand Down
15 changes: 15 additions & 0 deletions apps/loop/web-react/src/host/embeddedHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ describe('embeddedHost', () => {
expect(result.capabilityProgress.length).toBeGreaterThan(0)
})

it('submitTranscript notifies presentation after each subscribed event', () => {
const embedder = createTestEmbedder(sampleOutput)
const states: string[] = []
const result = submitTranscript(
embedder,
'Alex will send the follow-up email.',
(presentation) => {
states.push(presentation.presentationState)
},
)
expect(states).toContain('loading')
expect(states.at(-1)).toBe('loaded')
expect(result.presentationState).toBe('loaded')
})

it('submitTranscript surfaces scripted execution errors', () => {
const embedder = new EmbedderTestDouble({
appId: 'loop',
Expand Down
Loading
Loading