diff --git a/docs/event-ui-conformance-harness.md b/docs/event-ui-conformance-harness.md new file mode 100644 index 0000000..8ebddf3 --- /dev/null +++ b/docs/event-ui-conformance-harness.md @@ -0,0 +1,52 @@ +# Event UI conformance harness + +Shared regression contract for mapping public Traverse embedder events to UI +presentation state (Specs +[`001`](specs/001-ui-event-state-conformance/spec.md) / +[`002`](specs/002-capability-event-ui-subscription/spec.md), ADR +[`0001`](adr/0001-event-fixture-harness-first.md)). + +## Fixtures (language-agnostic) + +Path: [`fixtures/event-ui-conformance/`](../fixtures/event-ui-conformance/) + +| Case | Expected state | +|---|---| +| `happy-path.json` | `loaded` | +| `error-path.json` | `error` | +| `blocked-path.json` | `blocked` | +| `ended-path.json` | `ended` | +| `multi-capability.json` | `loaded` (+ ordered capability progress) | +| `replay-late-subscriber.json` | `loaded` (prefix replay ≡ full map) | + +Catalog index: `fixtures/event-ui-conformance/catalog.json`. + +Fixtures are static JSON — no network calls. + +## TypeScript package + +Workspace: `packages/event-ui-conformance` (`event-ui-conformance`). + +```ts +import { + loadFixtureCase, + mapCapabilityProgress, + mapPresentationState, +} 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 progress = mapCapabilityProgress(fixture.events) +``` + +Primary apps should import these helpers (or re-run the same fixtures with a +platform-native mapper) rather than inventing per-app event→state tables. + +## Architecture boundary + +- Subscribe / consume only public embedder envelopes (`state_changed`, + `capability_invoked`, `capability_result`, `error`). +- Do **not** emit synthetic embedder events from production app code. +- Do **not** invent business fields in the UI; render runtime-provided `output`. diff --git a/fixtures/event-ui-conformance/README.md b/fixtures/event-ui-conformance/README.md new file mode 100644 index 0000000..368b7a2 --- /dev/null +++ b/fixtures/event-ui-conformance/README.md @@ -0,0 +1,20 @@ +# Event UI conformance fixtures + +Language-agnostic embedder event streams for Specs +[`001-ui-event-state-conformance`](../../docs/specs/001-ui-event-state-conformance/spec.md) and +[`002-capability-event-ui-subscription`](../../docs/specs/002-capability-event-ui-subscription/spec.md). + +| Case | File | Expected presentation state | +|---|---|---| +| Happy path | `happy-path.json` | `loaded` | +| Error path | `error-path.json` | `error` | +| Blocked path | `blocked-path.json` | `blocked` | +| Ended path | `ended-path.json` | `ended` | +| Multi-capability | `multi-capability.json` | `loaded` | +| Replay / late subscriber | `replay-late-subscriber.json` | `loaded` | + +Index: [`catalog.json`](./catalog.json). + +TypeScript mappers and Vitest consumers live in +[`packages/event-ui-conformance`](../../packages/event-ui-conformance/). +See [`docs/event-ui-conformance-harness.md`](../../docs/event-ui-conformance-harness.md). diff --git a/fixtures/event-ui-conformance/blocked-path.json b/fixtures/event-ui-conformance/blocked-path.json new file mode 100644 index 0000000..e5294c7 --- /dev/null +++ b/fixtures/event-ui-conformance/blocked-path.json @@ -0,0 +1,40 @@ +{ + "id": "blocked-path", + "description": "Runtime state_changed with waiting-for-human → blocked", + "expected_presentation_state": "blocked", + "expected_error_message": null, + "events": [ + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0001", + "sequence": 1, + "event_type": "capability_invoked", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-blocked", + "data": { + "execution_id": "exec_blocked", + "capability_id": "fixture.approve", + "capability_version": "1.0.0" + } + }, + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0002", + "sequence": 2, + "event_type": "state_changed", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-blocked", + "data": { + "state": "waiting_for_human", + "previous_state": "running", + "capability_id": "fixture.approve" + } + } + ] +} diff --git a/fixtures/event-ui-conformance/catalog.json b/fixtures/event-ui-conformance/catalog.json new file mode 100644 index 0000000..f1b1b71 --- /dev/null +++ b/fixtures/event-ui-conformance/catalog.json @@ -0,0 +1,37 @@ +{ + "schema_version": "1.0.0", + "description": "Shared embedder event streams for UI presentation-state + capability-progress conformance (Specs 001/002).", + "cases": [ + { + "id": "happy-path", + "file": "happy-path.json", + "expected_presentation_state": "loaded" + }, + { + "id": "error-path", + "file": "error-path.json", + "expected_presentation_state": "error" + }, + { + "id": "blocked-path", + "file": "blocked-path.json", + "expected_presentation_state": "blocked" + }, + { + "id": "ended-path", + "file": "ended-path.json", + "expected_presentation_state": "ended" + }, + { + "id": "multi-capability", + "file": "multi-capability.json", + "expected_presentation_state": "loaded" + }, + { + "id": "replay-late-subscriber", + "file": "replay-late-subscriber.json", + "expected_presentation_state": "loaded", + "notes": "Same ordered stream is mapped fully and via prefix replay; both must agree." + } + ] +} diff --git a/fixtures/event-ui-conformance/ended-path.json b/fixtures/event-ui-conformance/ended-path.json new file mode 100644 index 0000000..57008f2 --- /dev/null +++ b/fixtures/event-ui-conformance/ended-path.json @@ -0,0 +1,41 @@ +{ + "id": "ended-path", + "description": "Terminal capability_result with no renderable output → ended", + "expected_presentation_state": "ended", + "expected_error_message": null, + "events": [ + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0001", + "sequence": 1, + "event_type": "capability_invoked", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-ended", + "data": { + "execution_id": "exec_ended", + "capability_id": "fixture.cancel", + "capability_version": "1.0.0" + } + }, + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0002", + "sequence": 2, + "event_type": "capability_result", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-ended", + "data": { + "execution_id": "exec_ended", + "capability_id": "fixture.cancel", + "status": "completed", + "output": null + } + } + ] +} diff --git a/fixtures/event-ui-conformance/error-path.json b/fixtures/event-ui-conformance/error-path.json new file mode 100644 index 0000000..38a80d5 --- /dev/null +++ b/fixtures/event-ui-conformance/error-path.json @@ -0,0 +1,45 @@ +{ + "id": "error-path", + "description": "capability_invoked then embedder error → error with payload message", + "expected_presentation_state": "error", + "expected_error_message": "capability failed under runtime governance", + "events": [ + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0001", + "sequence": 1, + "event_type": "capability_invoked", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-error", + "data": { + "execution_id": "exec_error", + "capability_id": "fixture.process", + "capability_version": "1.0.0" + } + }, + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0002", + "sequence": 2, + "event_type": "error", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-error", + "data": { + "execution_id": "exec_error", + "capability_id": "fixture.process", + "status": "error", + "error": { + "code": "execution_failed", + "message": "capability failed under runtime governance", + "details": {} + } + } + } + ] +} diff --git a/fixtures/event-ui-conformance/happy-path.json b/fixtures/event-ui-conformance/happy-path.json new file mode 100644 index 0000000..b4a905a --- /dev/null +++ b/fixtures/event-ui-conformance/happy-path.json @@ -0,0 +1,59 @@ +{ + "id": "happy-path", + "description": "Non-terminal progress then capability_result with renderable output → loaded", + "expected_presentation_state": "loaded", + "expected_error_message": null, + "events": [ + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0001", + "sequence": 1, + "event_type": "state_changed", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-happy", + "data": { + "state": "running", + "previous_state": "idle" + } + }, + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0002", + "sequence": 2, + "event_type": "capability_invoked", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-happy", + "data": { + "execution_id": "exec_happy", + "capability_id": "fixture.process", + "capability_version": "1.0.0" + } + }, + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0003", + "sequence": 3, + "event_type": "capability_result", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-happy", + "data": { + "execution_id": "exec_happy", + "capability_id": "fixture.process", + "status": "completed", + "output": { + "title": "runtime-owned title", + "summary": "fields come only from the fixture output" + } + } + } + ] +} diff --git a/fixtures/event-ui-conformance/multi-capability.json b/fixtures/event-ui-conformance/multi-capability.json new file mode 100644 index 0000000..1ae19cd --- /dev/null +++ b/fixtures/event-ui-conformance/multi-capability.json @@ -0,0 +1,84 @@ +{ + "id": "multi-capability", + "description": "Two capabilities invoke→result in order; terminal output from B only", + "expected_presentation_state": "loaded", + "expected_error_message": null, + "expected_capability_order": [ + { "capability_id": "fixture.analyze", "phase": "invoked" }, + { "capability_id": "fixture.analyze", "phase": "result" }, + { "capability_id": "fixture.recommend", "phase": "invoked" }, + { "capability_id": "fixture.recommend", "phase": "result" } + ], + "events": [ + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0001", + "sequence": 1, + "event_type": "capability_invoked", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-multi", + "data": { + "execution_id": "exec_a", + "capability_id": "fixture.analyze", + "capability_version": "1.0.0" + } + }, + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0002", + "sequence": 2, + "event_type": "capability_result", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-multi", + "data": { + "execution_id": "exec_a", + "capability_id": "fixture.analyze", + "status": "completed", + "output": { + "analysis": "runtime-owned analysis" + } + } + }, + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0003", + "sequence": 3, + "event_type": "capability_invoked", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-multi", + "data": { + "execution_id": "exec_b", + "capability_id": "fixture.recommend", + "capability_version": "1.0.0" + } + }, + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0004", + "sequence": 4, + "event_type": "capability_result", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-multi", + "data": { + "execution_id": "exec_b", + "capability_id": "fixture.recommend", + "status": "completed", + "output": { + "recommendation": "runtime-owned recommendation" + } + } + } + ] +} diff --git a/fixtures/event-ui-conformance/replay-late-subscriber.json b/fixtures/event-ui-conformance/replay-late-subscriber.json new file mode 100644 index 0000000..c0af1b6 --- /dev/null +++ b/fixtures/event-ui-conformance/replay-late-subscriber.json @@ -0,0 +1,58 @@ +{ + "id": "replay-late-subscriber", + "description": "Full recorded stream; late subscriber mapping prefixes must match full map", + "expected_presentation_state": "loaded", + "expected_error_message": null, + "events": [ + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0001", + "sequence": 1, + "event_type": "state_changed", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-replay", + "data": { + "state": "running", + "previous_state": "idle" + } + }, + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0002", + "sequence": 2, + "event_type": "capability_invoked", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-replay", + "data": { + "execution_id": "exec_replay", + "capability_id": "fixture.process", + "capability_version": "1.0.0" + } + }, + { + "kind": "embedder_event", + "schema_version": "1.0.0", + "embedder_api_version": "embedder-api/1.0.0", + "event_id": "evt-0003", + "sequence": 3, + "event_type": "capability_result", + "workspace_id": "local-default", + "app_id": "fixture-app", + "session_id": "sess-replay", + "data": { + "execution_id": "exec_replay", + "capability_id": "fixture.process", + "status": "completed", + "output": { + "title": "replay-safe title" + } + } + } + ] +} diff --git a/package-lock.json b/package-lock.json index 271decf..15b71b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "apps/traverse-starter/web-react", "apps/trace-explorer/web-react", "apps/doc-approval/web-react", - "apps/meeting-notes/web-react" + "apps/meeting-notes/web-react", + "packages/event-ui-conformance" ] }, "apps/doc-approval/web-react": { @@ -2853,6 +2854,10 @@ "node": ">=0.10.0" } }, + "node_modules/event-ui-conformance": { + "resolved": "packages/event-ui-conformance", + "link": true + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -4695,6 +4700,54 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "packages/event-ui-conformance": { + "version": "0.0.0", + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^26.1.0", + "@vitest/coverage-v8": "^4.1.10", + "eslint": "^10.8.1", + "globals": "^17.9.0", + "typescript": "~7.0.2", + "typescript-eslint": "^8.66.0", + "vitest": "^4.1.10" + } + }, + "packages/event-ui-conformance/node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "vendor/traverse-embedder-web": { "version": "0.7.0", "license": "Apache-2.0", diff --git a/package.json b/package.json index 73b17b8..02dedb4 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "apps/traverse-starter/web-react", "apps/trace-explorer/web-react", "apps/doc-approval/web-react", - "apps/meeting-notes/web-react" + "apps/meeting-notes/web-react", + "packages/event-ui-conformance" ], "scripts": { "dev": "npm run dev -w apps/traverse-starter/web-react", diff --git a/packages/event-ui-conformance/README.md b/packages/event-ui-conformance/README.md new file mode 100644 index 0000000..c09c28c --- /dev/null +++ b/packages/event-ui-conformance/README.md @@ -0,0 +1,14 @@ +# event-ui-conformance + +Shared TypeScript helpers for Specs 001/002: + +- Pure `mapPresentationState` → `idle|loading|loaded|blocked|ended|error` +- `mapCapabilityProgress` / `activeCapabilityId` from public embedder events +- Loaders for language-agnostic fixtures under `fixtures/event-ui-conformance/` + +Consumer guide: [`docs/event-ui-conformance-harness.md`](../../docs/event-ui-conformance-harness.md). + +```bash +npm run test -w event-ui-conformance +npm run typecheck -w event-ui-conformance +``` diff --git a/packages/event-ui-conformance/eslint.config.js b/packages/event-ui-conformance/eslint.config.js new file mode 100644 index 0000000..f89bc63 --- /dev/null +++ b/packages/event-ui-conformance/eslint.config.js @@ -0,0 +1,15 @@ +import js from '@eslint/js' +import globals from 'globals' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist', 'coverage']), + { + files: ['**/*.{ts,tsx}'], + extends: [js.configs.recommended, tseslint.configs.recommended], + languageOptions: { + globals: globals.node, + }, + }, +]) diff --git a/packages/event-ui-conformance/package.json b/packages/event-ui-conformance/package.json new file mode 100644 index 0000000..6ad44c2 --- /dev/null +++ b/packages/event-ui-conformance/package.json @@ -0,0 +1,25 @@ +{ + "name": "event-ui-conformance", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "eslint .", + "test": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^26.1.0", + "@vitest/coverage-v8": "^4.1.10", + "eslint": "^10.8.1", + "globals": "^17.9.0", + "typescript": "~7.0.2", + "typescript-eslint": "^8.66.0", + "vitest": "^4.1.10" + } +} diff --git a/packages/event-ui-conformance/src/capabilityProgress.test.ts b/packages/event-ui-conformance/src/capabilityProgress.test.ts new file mode 100644 index 0000000..f67f113 --- /dev/null +++ b/packages/event-ui-conformance/src/capabilityProgress.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { activeCapabilityId, mapCapabilityProgress } from './capabilityProgress.ts' +import { loadFixtureCase } from './loadFixture.ts' +import { mapPresentationState } from './mapPresentationState.ts' +import type { EmbedderEventLike } from './types.ts' + +describe('mapCapabilityProgress', () => { + it('reflects invoke/result order for multi-capability fixtures', () => { + const fixture = loadFixtureCase('multi-capability.json') + const progress = mapCapabilityProgress(fixture.events) + expect(progress.map((step) => ({ capability_id: step.capabilityId, phase: step.phase }))).toEqual( + fixture.expected_capability_order, + ) + + const snapshot = mapPresentationState(fixture.events) + expect(snapshot.state).toBe('loaded') + expect(snapshot.output).toEqual({ recommendation: 'runtime-owned recommendation' }) + expect(activeCapabilityId(fixture.events)).toBeNull() + }) + + it('keeps loading and active capability when invoke has no result yet', () => { + const events: EmbedderEventLike[] = [ + { + event_type: 'capability_invoked', + sequence: 1, + data: { capability_id: 'fixture.process', capability_version: '1.0.0' }, + }, + ] + expect(mapPresentationState(events).state).toBe('loading') + expect(activeCapabilityId(events)).toBe('fixture.process') + expect(mapCapabilityProgress(events)).toEqual([ + { + capabilityId: 'fixture.process', + phase: 'invoked', + sequence: 1, + status: null, + output: null, + }, + ]) + }) +}) diff --git a/packages/event-ui-conformance/src/capabilityProgress.ts b/packages/event-ui-conformance/src/capabilityProgress.ts new file mode 100644 index 0000000..5b5859b --- /dev/null +++ b/packages/event-ui-conformance/src/capabilityProgress.ts @@ -0,0 +1,72 @@ +import { asString, isRecord } from './json.ts' +import type { CapabilityProgressStep, EmbedderEventLike, JsonValue } from './types.ts' + +function capabilityIdFromData(data: JsonValue): string | null { + if (!isRecord(data)) return null + return asString(data.capability_id) +} + +/** + * Ordered capability progress from `capability_invoked` / `capability_result`. + * Identity and status/output come only from event payloads. + */ +export function mapCapabilityProgress( + events: readonly EmbedderEventLike[], +): CapabilityProgressStep[] { + const steps: CapabilityProgressStep[] = [] + + for (const event of events) { + if (event.event_type !== 'capability_invoked' && event.event_type !== 'capability_result') { + continue + } + const capabilityId = capabilityIdFromData(event.data) + if (!capabilityId) continue + + if (event.event_type === 'capability_invoked') { + steps.push({ + capabilityId, + phase: 'invoked', + sequence: event.sequence, + status: null, + output: null, + }) + continue + } + + const status = isRecord(event.data) ? asString(event.data.status) : null + const output = isRecord(event.data) && 'output' in event.data ? event.data.output : null + steps.push({ + capabilityId, + phase: 'result', + sequence: event.sequence, + status, + output: output ?? null, + }) + } + + return steps +} + +/** Active capability for UI chrome: last invoked without a later result for that id. */ +export function activeCapabilityId(events: readonly EmbedderEventLike[]): string | null { + const progress = mapCapabilityProgress(events) + const open = new Map() + + for (const step of progress) { + if (step.phase === 'invoked') { + open.set(step.capabilityId, (open.get(step.capabilityId) ?? 0) + 1) + } else { + const count = open.get(step.capabilityId) ?? 0 + if (count <= 1) open.delete(step.capabilityId) + else open.set(step.capabilityId, count - 1) + } + } + + for (let i = progress.length - 1; i >= 0; i -= 1) { + const step = progress[i] + if (step.phase === 'invoked' && open.has(step.capabilityId)) { + return step.capabilityId + } + } + return null +} diff --git a/packages/event-ui-conformance/src/index.ts b/packages/event-ui-conformance/src/index.ts new file mode 100644 index 0000000..0d07b5f --- /dev/null +++ b/packages/event-ui-conformance/src/index.ts @@ -0,0 +1,15 @@ +export type { + CapabilityPhase, + CapabilityProgressStep, + EmbedderEventLike, + FixtureCase, + FixtureCatalog, + FixtureCatalogEntry, + JsonValue, + PresentationSnapshot, + PresentationState, +} from './types.ts' + +export { mapPresentationState } from './mapPresentationState.ts' +export { activeCapabilityId, mapCapabilityProgress } from './capabilityProgress.ts' +export { FIXTURES_DIR, loadAllFixtureCases, loadCatalog, loadFixtureCase } from './loadFixture.ts' diff --git a/packages/event-ui-conformance/src/json.ts b/packages/event-ui-conformance/src/json.ts new file mode 100644 index 0000000..3b830cc --- /dev/null +++ b/packages/event-ui-conformance/src/json.ts @@ -0,0 +1,9 @@ +import type { JsonValue } from './types.ts' + +export function isRecord(value: JsonValue): value is { [key: string]: JsonValue } { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function asString(value: JsonValue | undefined): string | null { + return typeof value === 'string' ? value : null +} diff --git a/packages/event-ui-conformance/src/loadFixture.ts b/packages/event-ui-conformance/src/loadFixture.ts new file mode 100644 index 0000000..a454b3c --- /dev/null +++ b/packages/event-ui-conformance/src/loadFixture.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { FixtureCase, FixtureCatalog } from './types.ts' + +const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url)) +export const FIXTURES_DIR = join(PACKAGE_DIR, '../../../fixtures/event-ui-conformance') + +export function loadCatalog(): FixtureCatalog { + const raw = readFileSync(join(FIXTURES_DIR, 'catalog.json'), 'utf8') + return JSON.parse(raw) as FixtureCatalog +} + +export function loadFixtureCase(fileName: string): FixtureCase { + const raw = readFileSync(join(FIXTURES_DIR, fileName), 'utf8') + return JSON.parse(raw) as FixtureCase +} + +export function loadAllFixtureCases(): FixtureCase[] { + return loadCatalog().cases.map((entry) => loadFixtureCase(entry.file)) +} diff --git a/packages/event-ui-conformance/src/mapPresentationState.test.ts b/packages/event-ui-conformance/src/mapPresentationState.test.ts new file mode 100644 index 0000000..df90adc --- /dev/null +++ b/packages/event-ui-conformance/src/mapPresentationState.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { mapPresentationState } from './mapPresentationState.ts' +import type { EmbedderEventLike } from './types.ts' +import { loadAllFixtureCases, loadFixtureCase } from './loadFixture.ts' + +function event( + partial: Pick, +): EmbedderEventLike { + return partial +} + +describe('mapPresentationState', () => { + it('returns idle for an empty stream', () => { + expect(mapPresentationState([])).toEqual({ + state: 'idle', + errorMessage: null, + output: null, + }) + }) + + it('maps mid-stream invoke/state to loading', () => { + const events = [ + event({ + event_type: 'state_changed', + sequence: 1, + data: { state: 'running' }, + }), + event({ + event_type: 'capability_invoked', + sequence: 2, + data: { capability_id: 'fixture.process' }, + }), + ] + expect(mapPresentationState(events).state).toBe('loading') + }) + + it('matches every catalog fixture expected presentation state', () => { + for (const fixture of loadAllFixtureCases()) { + const snapshot = mapPresentationState(fixture.events) + expect(snapshot.state, fixture.id).toBe(fixture.expected_presentation_state) + if (fixture.expected_error_message !== undefined) { + expect(snapshot.errorMessage, fixture.id).toBe(fixture.expected_error_message) + } + } + }) + + it('copies output only from capability_result payloads', () => { + const happy = loadFixtureCase('happy-path.json') + const snapshot = mapPresentationState(happy.events) + expect(snapshot.state).toBe('loaded') + expect(snapshot.output).toEqual({ + title: 'runtime-owned title', + summary: 'fields come only from the fixture output', + }) + }) + + it('treats late-subscriber prefix replay as equivalent to full map', () => { + const fixture = loadFixtureCase('replay-late-subscriber.json') + const full = mapPresentationState(fixture.events) + let late = mapPresentationState([]) + for (let i = 1; i <= fixture.events.length; i += 1) { + late = mapPresentationState(fixture.events.slice(0, i)) + } + expect(late).toEqual(full) + expect(full.state).toBe('loaded') + }) +}) diff --git a/packages/event-ui-conformance/src/mapPresentationState.ts b/packages/event-ui-conformance/src/mapPresentationState.ts new file mode 100644 index 0000000..bea6620 --- /dev/null +++ b/packages/event-ui-conformance/src/mapPresentationState.ts @@ -0,0 +1,112 @@ +import { asString, isRecord } from './json.ts' +import type { EmbedderEventLike, JsonValue, PresentationSnapshot, PresentationState } from './types.ts' + +const BLOCKED_STATES = new Set([ + 'blocked', + 'waiting', + 'waiting_for_human', + 'awaiting_human', + 'awaiting_input', +]) + +const ENDED_STATES = new Set(['cancelled', 'canceled', 'closed', 'ended']) + +function errorMessageFromData(data: JsonValue): string | null { + if (!isRecord(data)) return null + const err = data.error + if (typeof err === 'string') return err + if (isRecord(err)) { + const message = err.message + if (typeof message === 'string') return message + } + return null +} + +function runtimeStateToken(data: JsonValue): string | null { + if (!isRecord(data)) return null + return ( + asString(data.state) ?? + asString(data.status) ?? + asString(data.runtime_state) ?? + null + ) +} + +function isBlockedPayload(data: JsonValue): boolean { + if (!isRecord(data)) return false + if (data.blocked === true || data.waiting_for_human === true) return true + const token = runtimeStateToken(data) + return token !== null && BLOCKED_STATES.has(token.toLowerCase()) +} + +function isEndedStatePayload(data: JsonValue): boolean { + const token = runtimeStateToken(data) + return token !== null && ENDED_STATES.has(token.toLowerCase()) +} + +function hasRenderableOutput(data: JsonValue): boolean { + if (!isRecord(data)) return false + if (!('output' in data)) return false + const output = data.output + if (output === null || output === undefined) return false + if (typeof output === 'object' && !Array.isArray(output) && Object.keys(output).length === 0) { + return false + } + return true +} + +/** + * Pure mapper: ordered public embedder events → one presentation snapshot. + * Does not invent business fields; output is copied from capability_result only. + */ +export function mapPresentationState( + events: readonly EmbedderEventLike[], +): PresentationSnapshot { + if (events.length === 0) { + return { state: 'idle', errorMessage: null, output: null } + } + + let state: PresentationState = 'idle' + let errorMessage: string | null = null + let output: JsonValue | null = null + + for (const event of events) { + switch (event.event_type) { + case 'error': { + state = 'error' + errorMessage = errorMessageFromData(event.data) ?? 'execution failed' + break + } + case 'capability_invoked': { + if (state !== 'error') { + state = 'loading' + } + break + } + 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' + } + break + } + case 'capability_result': { + if (state === 'error') break + if (hasRenderableOutput(event.data) && isRecord(event.data)) { + state = 'loaded' + output = event.data.output ?? null + } else { + state = 'ended' + output = null + } + break + } + } + } + + return { state, errorMessage, output } +} diff --git a/packages/event-ui-conformance/src/types.ts b/packages/event-ui-conformance/src/types.ts new file mode 100644 index 0000000..9dc0db3 --- /dev/null +++ b/packages/event-ui-conformance/src/types.ts @@ -0,0 +1,70 @@ +/** Canonical UI presentation states (Spec 001). */ +export type PresentationState = + | 'idle' + | 'loading' + | 'loaded' + | 'blocked' + | 'ended' + | 'error' + +/** JSON wire value (matches public embedder JsonValue). */ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue } + +/** + * Public embedder event envelope fields required by the harness. + * Compatible with `traverse-embedder-web` `EmbedderEvent`. + */ +export interface EmbedderEventLike { + readonly event_type: 'state_changed' | 'capability_invoked' | 'capability_result' | 'error' + readonly sequence: number + readonly session_id?: string | null + readonly data: JsonValue +} + +export interface PresentationSnapshot { + readonly state: PresentationState + readonly errorMessage: string | null + /** Last `capability_result.data.output` when present; never invented. */ + readonly output: JsonValue | null +} + +export type CapabilityPhase = 'invoked' | 'result' + +export interface CapabilityProgressStep { + readonly capabilityId: string + readonly phase: CapabilityPhase + readonly sequence: number + readonly status: string | null + readonly output: JsonValue | null +} + +export interface FixtureCase { + readonly id: string + readonly description: string + readonly expected_presentation_state: PresentationState + readonly expected_error_message?: string | null + readonly expected_capability_order?: readonly { + readonly capability_id: string + readonly phase: CapabilityPhase + }[] + readonly events: readonly EmbedderEventLike[] +} + +export interface FixtureCatalogEntry { + readonly id: string + readonly file: string + readonly expected_presentation_state: PresentationState + readonly notes?: string +} + +export interface FixtureCatalog { + readonly schema_version: string + readonly description: string + readonly cases: readonly FixtureCatalogEntry[] +} diff --git a/packages/event-ui-conformance/tsconfig.json b/packages/event-ui-conformance/tsconfig.json new file mode 100644 index 0000000..c628724 --- /dev/null +++ b/packages/event-ui-conformance/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node", "vitest/globals"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vitest.config.ts"] +} diff --git a/packages/event-ui-conformance/vitest.config.ts b/packages/event-ui-conformance/vitest.config.ts new file mode 100644 index 0000000..ecfe151 --- /dev/null +++ b/packages/event-ui-conformance/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'json-summary', 'html'], + exclude: ['node_modules/', 'vitest.config.ts', 'eslint.config.js'], + }, + }, +})