import { afterEach, describe, expect, it, vi } from 'vitest'; import { IDBFactory, IDBKeyRange } from 'fake-indexeddb'; import type { RuntimePayload, RuntimeRecord, RuntimeRecordInit, RuntimeSession, } from '@openmaic/dsl'; import { BrowserRuntimeStore, type RuntimeSessionInit, type RuntimeStore } from '@openmaic/storage'; import { applyInstructorEvent } from '@/components/scene-renderers/pbl/v2/apply-instructor-event'; import { recordEvent } from '@/lib/pbl/v2/operations/kernel/engagement'; import { addEvaluation } from '@/lib/pbl/v2/operations/runtime/evaluation'; import { advanceMicrotask, continueAfterHandover, resetProjectProgress, startMicrotask, } from '@/lib/pbl/v2/operations/kernel/progress'; import { transitionProjectUiPhase } from '@/lib/pbl/v2/operations/kernel/runtime-events'; import { addSubmission } from '@/lib/pbl/v2/operations/runtime/submission'; import { clearPendingTaskCompletion, setPendingTaskCompletion, } from '@/lib/pbl/v2/operations/kernel/task-completion'; import { drainProjectRuntime } from '@/lib/pbl/v2/runtime/drain'; import { foldPBLRuntime } from '@/lib/pbl/v2/runtime/fold'; import { appendPBLRuntimeSnapshotIfChanged, documentContainsLearnerState, hydratePBLProjectFromRuntime, hydratePBLScenesFromRuntime, synchronizePBLProjectRuntime, } from '@/lib/pbl/v2/runtime/hydration'; import { extractLearnerState, stripToDesignTemplate } from '@/lib/pbl/v2/runtime/learner-state'; import { pblSnapshotRecordPayload, type PBLRuntimeStorePayload, } from '@/lib/pbl/v2/runtime/record-payloads'; import { makeScene, type Scene } from '@/lib/types/stage'; import type { KVScope, KVStore } from '@openmaic/storage'; import type { PBLProjectV2, PBLRuntimeEvent } from '@/lib/pbl/v2/types'; import { withRuntimeStorageExclusiveLock } from '@/lib/utils/chat-storage-lock'; import { legacyPBLSceneFixture } from '@/tests/fixtures/pbl-v1-scene'; if (!('IDBKeyRange' in globalThis)) { Object.defineProperty(globalThis, 'IDBKeyRange', { value: IDBKeyRange, configurable: true }); } const STAGE_ID = 'stage-1'; const SCENE_ID = 'scene-1'; const LEARNER_KEY = 'anon:test-device'; function readWriteLockManager(onAcquire?: (mode: LockMode) => void): Pick { type Waiter = { mode: LockMode; run(): void; }; const states = new Map(); const stateFor = (name: string) => { let state = states.get(name); if (!state) { state = { readers: 0, writer: false, waiters: [] }; states.set(name, state); } return state; }; const pump = (name: string) => { const state = stateFor(name); if (state.writer || state.waiters.length === 0) return; if (state.waiters[0]!.mode === 'exclusive') { if (state.readers === 0) state.waiters.shift()!.run(); return; } while (state.waiters[0]?.mode === 'shared' && !state.writer) { state.waiters.shift()!.run(); } }; return { request( name: string, optionsOrCallback: LockOptions | (() => Promise | T), maybeCallback?: () => Promise | T, ): Promise { const options = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback; const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback!; const mode = options?.mode ?? 'exclusive'; const state = stateFor(name); return new Promise((resolve, reject) => { state.waiters.push({ mode, run() { if (mode === 'shared') state.readers += 1; else state.writer = true; onAcquire?.(mode); void Promise.resolve() .then(callback) .then(resolve, reject) .finally(() => { if (mode === 'shared') state.readers -= 1; else state.writer = false; pump(name); }); }, }); pump(name); }); }, } as Pick; } afterEach(() => { vi.unstubAllGlobals(); }); class MemoryKVStore implements KVStore { private readonly values = new Map(); async get(key: string, scope: KVScope = 'account'): Promise { return (this.values.get(`${scope}:${key}`) as T | undefined) ?? null; } async set(key: string, value: T, scope: KVScope = 'account'): Promise { this.values.set(`${scope}:${key}`, value); } async remove(key: string, scope: KVScope = 'account'): Promise { this.values.delete(`${scope}:${key}`); } async keys(prefix = '', scope: KVScope = 'account'): Promise { const scopedPrefix = `${scope}:`; return [...this.values.keys()] .filter((key) => key.startsWith(scopedPrefix)) .map((key) => key.slice(scopedPrefix.length)) .filter((key) => key.startsWith(prefix)); } } class MemoryRuntimeStore implements RuntimeStore { readonly sessions: RuntimeSession[] = []; readonly records: RuntimeRecord[] = []; async createSession(init: RuntimeSessionInit): Promise { const existing = this.sessions.find((session) => session.id === init.id); if (existing) throw new Error(`session ${init.id} already exists`); const session: RuntimeSession = { ...init, runtimeDslVersion: 'test' }; this.sessions.push(session); return session; } async getSession(sessionId: string): Promise { return this.sessions.find((session) => session.id === sessionId); } async listSessions(stageId: string, learnerKey: string): Promise { return this.sessions.filter( (session) => session.stageId === stageId && session.learnerKey === learnerKey, ); } async setSessionStatus(): Promise {} async deleteSession(): Promise {} async appendRecord( init: RuntimeRecordInit, ): Promise> { const seq = this.records.filter((record) => record.sessionId === init.sessionId).length; const record: RuntimeRecord = { ...init, seq }; this.records.push(record); return record; } async listRecords(sessionId: string, opts?: { sceneId?: string }): Promise { return this.records.filter( (record) => record.sessionId === sessionId && (opts?.sceneId ? record.sceneId === opts.sceneId : true), ); } async mergeLearner(): Promise { return 0; } async deleteLearnerRuntime(): Promise {} async deleteStageRuntime(): Promise {} async deleteAllRuntime(): Promise {} } class ThrowingRuntimeStore extends MemoryRuntimeStore { async listSessions(): Promise { throw new Error('runtime unavailable'); } async listRecords(): Promise { throw new Error('runtime unavailable'); } async appendRecord( init: RuntimeRecordInit, ): Promise> { void init; throw new Error('runtime unavailable'); } } class SlowFirstAppendRuntimeStore extends MemoryRuntimeStore { private releaseAppend!: () => void; private readonly appendGate = new Promise((resolve) => { this.releaseAppend = resolve; }); private markStarted!: () => void; readonly appendStarted = new Promise((resolve) => { this.markStarted = resolve; }); constructor(private readonly slowRecordId: string) { super(); } override async appendRecord( init: RuntimeRecordInit, ): Promise> { if (init.id === this.slowRecordId) { this.markStarted(); await this.appendGate; } return super.appendRecord(init); } release(): void { this.releaseAppend(); } } class BlockFirstRecordListRuntimeStore extends MemoryRuntimeStore { private recordListCount = 0; private releaseFirstList!: () => void; private readonly firstListGate = new Promise((resolve) => { this.releaseFirstList = resolve; }); private markFirstListStarted!: () => void; readonly firstListStarted = new Promise((resolve) => { this.markFirstListStarted = resolve; }); override async listRecords( sessionId: string, opts?: { sceneId?: string }, ): Promise { this.recordListCount += 1; if (this.recordListCount === 1) { this.markFirstListStarted(); await this.firstListGate; } return super.listRecords(sessionId, opts); } release(): void { this.releaseFirstList(); } } function makeProject(overrides: Partial = {}): PBLProjectV2 { return { uiPhase: 'hero', title: 'Hydration project', description: 'Build something', proficiency: 'intermediate', language: 'en-US', tags: [], status: 'active', roles: [{ id: 'role-i', type: 'instructor', name: 'Instructor' }], milestones: [ { id: 'ms-1', title: 'Milestone 1', status: 'active', order: 0, microtasks: [ { id: 'mt-1', title: 'Task 1', status: 'todo', assignee: 'user', hints: [], order: 0, }, { id: 'mt-2', title: 'Task 2', status: 'todo', assignee: 'user', hints: [], order: 1, }, ], }, ], submissions: [], evaluations: [], threads: [{ agentId: 'role-i', messages: [] }], engagementEvents: [], runtimeEvents: [], createdAt: '2026-05-29T00:00:00.000Z', updatedAt: '2026-05-29T00:00:00.000Z', ...overrides, }; } function makeTwoMilestoneProject(overrides: Partial = {}): PBLProjectV2 { return makeProject({ milestones: [ { id: 'ms-1', title: 'Milestone 1', status: 'active', order: 0, microtasks: [ { id: 'mt-1', title: 'Task 1', status: 'todo', assignee: 'user', hints: [], order: 0, }, ], }, { id: 'ms-2', title: 'Milestone 2', status: 'locked', order: 1, microtasks: [ { id: 'mt-2', title: 'Task 2', status: 'todo', assignee: 'user', hints: [], order: 0, }, ], }, ], ...overrides, }); } function makePBLScene(project: PBLProjectV2): Scene { return makeScene( { id: SCENE_ID, stageId: STAGE_ID, title: 'PBL scene', order: 0, }, { type: 'pbl', projectV2: project, }, ); } async function hydrate(project: PBLProjectV2, store: RuntimeStore = new MemoryRuntimeStore()) { const kv = new MemoryKVStore(); return hydratePBLProjectFromRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); } async function listRecords(store: RuntimeStore): Promise { const sessions = await store.listSessions(STAGE_ID, LEARNER_KEY); if (!sessions[0]) return []; return store.listRecords(sessions[0].id, { sceneId: SCENE_ID }); } async function ensureSession(store: MemoryRuntimeStore): Promise { const existing = store.sessions.find( (session) => session.stageId === STAGE_ID && session.learnerKey === LEARNER_KEY, ); if (existing) return existing; return store.createSession({ id: `pbl-${STAGE_ID}-${LEARNER_KEY}`, kind: 'pbl', stageId: STAGE_ID, learnerKey: LEARNER_KEY, status: 'active', createdAt: '2026-05-29T00:00:00.000Z', updatedAt: '2026-05-29T00:00:00.000Z', }); } describe('PBL runtime hydration', () => { it('skips a damaged hybrid without touching runtime or learner-state hydration', async () => { const damagedHybrid = structuredClone(legacyPBLSceneFixture) as Scene; if (damagedHybrid.content.type !== 'pbl') throw new Error('expected PBL scene'); Reflect.set(damagedHybrid.content, 'projectV2', { title: 'broken' }); const hydrated = await hydratePBLScenesFromRuntime(STAGE_ID, [damagedHybrid], { store: new ThrowingRuntimeStore(), kv: new MemoryKVStore(), learnerKey: LEARNER_KEY, }); expect(hydrated).toEqual([damagedHybrid]); expect(hydrated[0]).toBe(damagedHybrid); expect(documentContainsLearnerState({ title: 'broken' })).toBe(false); }); it('skips a container-valid v2 project with no runnable milestones', async () => { const project = makeProject(); project.milestones = []; const scene = makePBLScene(project); const hydrated = await hydratePBLScenesFromRuntime(STAGE_ID, [scene], { store: new ThrowingRuntimeStore(), kv: new MemoryKVStore(), learnerKey: LEARNER_KEY, }); expect(hydrated).toEqual([scene]); expect(hydrated[0]).toBe(scene); }); it('returns unchanged scenes when runtime hydration throws for a scene', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const project = makeProject({ uiPhase: 'workspace' }); const scenes = [makePBLScene(project)]; const hydrated = await hydratePBLScenesFromRuntime(STAGE_ID, scenes, { store: new ThrowingRuntimeStore(), kv: new MemoryKVStore(), learnerKey: LEARNER_KEY, }); expect(hydrated).toEqual(scenes); expect(hydrated[0]).toBe(scenes[0]); expect(warn).toHaveBeenCalled(); warn.mockRestore(); }); it('does not silently replace runtime-authoritative state with a design-only document', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const scenes = [makePBLScene(stripToDesignTemplate(makeProject()))]; await expect( hydratePBLScenesFromRuntime(STAGE_ID, scenes, { store: new ThrowingRuntimeStore(), kv: new MemoryKVStore(), learnerKey: LEARNER_KEY, }), ).rejects.toThrow('runtime unavailable'); expect(warn).not.toHaveBeenCalledWith( expect.stringContaining('using document state'), expect.anything(), ); warn.mockRestore(); }); it('keeps document state and appends a snapshot for a pre-runtime learner', async () => { const store = new MemoryRuntimeStore(); const project = makeProject({ uiPhase: 'workspace' }); startMicrotask(project, 'mt-1'); project.threads[0]!.messages.push({ id: 'legacy-msg-1', roleType: 'user', content: 'Legacy answer', ts: '2026-05-29T00:00:01.000Z', microtaskId: 'mt-1', }); const hydrated = await hydrate(project, store); expect(hydrated.source).toBe('document'); expect(extractLearnerState(hydrated.project)).toEqual(extractLearnerState(project)); const records = await listRecords(store); expect((records.at(-1)?.payload as PBLRuntimeStorePayload).kind).toBe('pbl_snapshot'); }); it('synchronizes the complete learner state before document persistence', async () => { const store = new MemoryRuntimeStore(); const kv = new MemoryKVStore(); const project = makeProject({ uiPhase: 'workspace', runtimeEvents: [] }); project.threads[0]!.messages.push({ id: 'legacy-msg-1', roleType: 'user', content: 'State that must survive the write cutover', ts: '2026-05-29T00:00:01.000Z', microtaskId: 'mt-1', }); await synchronizePBLProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); const records = await listRecords(store); const folded = foldPBLRuntime({ designTemplate: stripToDesignTemplate(project), records }); expect(folded.diagnostics.gaps).toEqual([]); expect(folded.learnerState).toEqual(extractLearnerState(project)); expect( records.filter( (record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot', ), ).toHaveLength(1); }); it('serializes the full synchronization so an overlapping stale save cannot win last', async () => { const store = new BlockFirstRecordListRuntimeStore(); const kv = new MemoryKVStore(); await ensureSession(store); const staleProject = makeProject({ uiPhase: 'workspace' }); const latestProject = makeProject({ uiPhase: 'completed', status: 'completed' }); const staleSave = synchronizePBLProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project: staleProject, store, kv, learnerKey: LEARNER_KEY, }); await store.firstListStarted; const latestSave = synchronizePBLProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project: latestProject, store, kv, learnerKey: LEARNER_KEY, }); await Promise.resolve(); store.release(); await Promise.all([staleSave, latestSave]); const records = await listRecords(store); const folded = foldPBLRuntime({ designTemplate: stripToDesignTemplate(latestProject), records, }); expect(folded.learnerState).toEqual(extractLearnerState(latestProject)); }); it('keeps RuntimeStore authoritative when the first stripped document write is interrupted', async () => { const store = new MemoryRuntimeStore(); const kv = new MemoryKVStore(); const staleDocument = makeProject({ uiPhase: 'workspace' }); const latestProject = makeProject({ uiPhase: 'completed', status: 'completed' }); await synchronizePBLProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project: latestProject, store, kv, learnerKey: LEARNER_KEY, }); const hydrated = await hydratePBLProjectFromRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project: staleDocument, store, kv, learnerKey: LEARNER_KEY, }); expect(hydrated.source).toBe('fold'); expect(extractLearnerState(hydrated.project)).toEqual(extractLearnerState(latestProject)); expect( (await listRecords(store)).some( (record) => (record.payload as Partial).kind === 'pbl_snapshot' && (record.payload as { reason?: string }).reason === 'write_cutover', ), ).toBe(true); }); it('hydrates a fresh learner from the folded baseline without writing a snapshot', async () => { const store = new MemoryRuntimeStore(); const project = makeProject(); const hydrated = await hydrate(project, store); expect(hydrated.source).toBe('fold'); expect(extractLearnerState(hydrated.project)).toEqual(extractLearnerState(project)); expect(await listRecords(store)).toEqual([]); }); it('uses folded runtime history when it matches the document state', async () => { const store = new MemoryRuntimeStore(); const kv = new MemoryKVStore(); let project = transitionProjectUiPhase(makeProject(), 'workspace'); project = applyInstructorEvent( { type: 'project_patch', patch: { kind: 'message', message: { id: 'msg-1', roleType: 'user', content: 'Runtime-backed answer', ts: '2026-05-29T00:00:01.000Z', microtaskId: 'mt-1', }, }, }, project, () => {}, ); await drainProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); const hydrated = await hydratePBLProjectFromRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); expect(hydrated.source).toBe('fold'); expect(extractLearnerState(hydrated.project)).toEqual(extractLearnerState(project)); expect( (await listRecords(store)).filter( (record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot', ), ).toHaveLength(0); }); it('uses runtime history when the persisted document contains only the design template', async () => { const store = new MemoryRuntimeStore(); const kv = new MemoryKVStore(); const runtimeProject = transitionProjectUiPhase(makeProject(), 'workspace'); await drainProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project: runtimeProject, store, kv, learnerKey: LEARNER_KEY, }); const designDocument = stripToDesignTemplate(runtimeProject); const hydrated = await hydratePBLProjectFromRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project: designDocument, store, kv, learnerKey: LEARNER_KEY, }); expect(hydrated.source).toBe('fold'); expect(extractLearnerState(hydrated.project)).toEqual(extractLearnerState(runtimeProject)); expect( (await listRecords(store)).filter( (record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot', ), ).toHaveLength(0); }); it('waits for the actual drain before reading records or writing a snapshot', async () => { vi.useFakeTimers(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const project = transitionProjectUiPhase(makeProject(), 'workspace'); const eventId = project.runtimeEvents![0]!.id; const store = new SlowFirstAppendRuntimeStore(eventId); try { const hydrating = hydrate(project, store); await store.appendStarted; await vi.advanceTimersByTimeAsync(10_001); await Promise.resolve(); const recordsAfterCallerTimeout = await listRecords(store); store.release(); const hydrated = await hydrating; expect( recordsAfterCallerTimeout.some( (record) => (record.payload as Partial).kind === 'pbl_snapshot', ), ).toBe(false); expect(hydrated.source).toBe('fold'); expect((await listRecords(store)).map((record) => record.id)).toEqual([eventId]); } finally { store.release(); warn.mockRestore(); vi.useRealTimers(); } }); it('enrolls hydration before later maintenance while waiting for an earlier drain', async () => { let sharedAcquisitions = 0; vi.stubGlobal('navigator', { locks: readWriteLockManager((mode) => { if (mode === 'shared') sharedAcquisitions += 1; }), }); const project = transitionProjectUiPhase(makeProject(), 'workspace'); const eventId = project.runtimeEvents![0]!.id; const store = new SlowFirstAppendRuntimeStore(eventId); const kv = new MemoryKVStore(); const priorDrain = drainProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); await store.appendStarted; const hydrating = hydratePBLProjectFromRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); await new Promise((resolve) => setTimeout(resolve, 0)); const sharedAcquisitionsBeforeRelease = sharedAcquisitions; const maintenance = withRuntimeStorageExclusiveLock(async () => { store.records.splice(0); store.sessions.splice(0); }); store.release(); await Promise.all([priorDrain, hydrating, maintenance]); expect(sharedAcquisitionsBeforeRelease).toBe(2); expect(store.records).toEqual([]); expect(store.sessions).toEqual([]); }); it('falls back to the document without a snapshot when the drain barrier times out', async () => { vi.useFakeTimers(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const project = transitionProjectUiPhase(makeProject(), 'workspace'); const eventId = project.runtimeEvents![0]!.id; const store = new SlowFirstAppendRuntimeStore(eventId); const scenes = [makePBLScene(project)]; const kv = new MemoryKVStore(); try { const priorDrain = drainProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); await store.appendStarted; let settled = false; const hydrating = hydratePBLScenesFromRuntime(STAGE_ID, scenes, { store, kv, learnerKey: LEARNER_KEY, }).then((result) => { settled = true; return result; }); await vi.advanceTimersByTimeAsync(20_001); await Promise.resolve(); const settledAtBarrierTimeout = settled; const recordsAtBarrierTimeout = await listRecords(store); store.release(); await priorDrain; const hydrated = await hydrating; expect(settledAtBarrierTimeout).toBe(true); expect(hydrated).toEqual(scenes); expect( recordsAtBarrierTimeout.some( (record) => (record.payload as Partial).kind === 'pbl_snapshot', ), ).toBe(false); } finally { store.release(); warn.mockRestore(); vi.useRealTimers(); } }); it('retains the shared maintenance lock after a timed-out hydration until work settles', async () => { vi.useFakeTimers(); vi.stubGlobal('navigator', { locks: readWriteLockManager() }); const project = transitionProjectUiPhase(makeProject(), 'workspace'); const eventId = project.runtimeEvents![0]!.id; const store = new SlowFirstAppendRuntimeStore(eventId); try { const hydrating = hydrate(project, store); let hydrationSettled = false; const hydrationOutcome = hydrating.then( () => { hydrationSettled = true; }, () => { hydrationSettled = true; }, ); await store.appendStarted; await vi.advanceTimersByTimeAsync(20_001); await Promise.resolve(); expect(hydrationSettled).toBe(true); let maintenanceStarted = false; const maintenance = withRuntimeStorageExclusiveLock(async () => { maintenanceStarted = true; }); await Promise.resolve(); expect(maintenanceStarted).toBe(false); store.release(); await Promise.all([hydrationOutcome, maintenance]); expect(maintenanceStarted).toBe(true); } finally { store.release(); vi.useRealTimers(); } }); it('keeps document state and self-heals when runtime history is partial', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const store = new MemoryRuntimeStore(); const kv = new MemoryKVStore(); const project = makeProject(); startMicrotask(project, 'mt-1'); addSubmission(project, { microtaskId: 'mt-1', milestoneId: 'ms-1', kind: 'text', content: 'Answer missing from runtime history', }); project.runtimeEvents = project.runtimeEvents?.slice(0, 1) ?? []; const hydrated = await hydratePBLProjectFromRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); expect(hydrated.source).toBe('document'); expect(extractLearnerState(hydrated.project)).toEqual(extractLearnerState(project)); expect( (await listRecords(store)).some( (record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot', ), ).toBe(true); expect(warn).toHaveBeenCalled(); warn.mockRestore(); }); it('flips to fold after a legacy raw-record mismatch has a self-heal snapshot', async () => { const store = new MemoryRuntimeStore(); const kv = new MemoryKVStore(); const project = makeProject(); project.threads[0]!.messages.push({ id: 'legacy-msg-1', roleType: 'user', content: 'Legacy raw record content recovered by snapshot', ts: '2026-05-29T00:00:01.000Z', microtaskId: 'mt-1', }); const session = await ensureSession(store); const rawEvent: PBLRuntimeEvent = { id: 'legacy-raw-message', kind: 'message_created', actorType: 'user', messageId: 'legacy-msg-1', threadId: 'role-i', ts: '2026-05-29T00:00:01.000Z', microtaskId: 'mt-1', milestoneId: 'ms-1', }; await store.appendRecord({ id: rawEvent.id, sessionId: session.id, sceneId: SCENE_ID, createdAt: rawEvent.ts, payload: rawEvent as unknown as RuntimePayload, }); await store.appendRecord({ id: 'self-heal-snapshot', sessionId: session.id, sceneId: SCENE_ID, createdAt: '2026-05-29T00:00:02.000Z', payload: pblSnapshotRecordPayload({ epoch: 0, learnerState: extractLearnerState(project), anchor: { lastRuntimeEventId: rawEvent.id }, reason: 'self_heal', }), }); const hydrated = await hydratePBLProjectFromRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); expect(hydrated.source).toBe('fold'); expect(hydrated.diagnostics.gaps).toEqual([]); expect( (await listRecords(store)).filter( (record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot', ), ).toHaveLength(1); }); it('skips a duplicate self-heal snapshot when the latest snapshot already matches', async () => { const store = new MemoryRuntimeStore(); const project = makeProject({ uiPhase: 'workspace' }); const session = await ensureSession(store); const learnerState = extractLearnerState(project); const firstAppend = await appendPBLRuntimeSnapshotIfChanged({ store, stageId: STAGE_ID, sceneId: SCENE_ID, learnerKey: LEARNER_KEY, project, learnerState, records: [], reason: 'self_heal', }); const snapshotRecord = await store.listRecords(session.id, { sceneId: SCENE_ID }); const snapshotPayload = snapshotRecord[0]!.payload as Extract< PBLRuntimeStorePayload, { kind: 'pbl_snapshot' } >; snapshotPayload.learnerState = { status: learnerState.status, uiPhase: learnerState.uiPhase, milestones: learnerState.milestones, submissions: learnerState.submissions, evaluations: learnerState.evaluations, threads: learnerState.threads, engagementEvents: learnerState.engagementEvents, }; const secondAppend = await appendPBLRuntimeSnapshotIfChanged({ store, stageId: STAGE_ID, sceneId: SCENE_ID, learnerKey: LEARNER_KEY, project, learnerState, records: snapshotRecord, reason: 'self_heal', }); expect(firstAppend).toBe(true); expect(secondAppend).toBe(false); expect( (await listRecords(store)).filter( (record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot', ), ).toHaveLength(1); }); it('skips a duplicate snapshot when optional learner-state members were omitted', async () => { const store = new MemoryRuntimeStore(); const project = makeProject({ uiPhase: 'workspace' }); project.submissions.push({ id: 'sub-undefined-filename', microtaskId: 'mt-1', kind: 'text', content: 'Learner answer', filename: undefined, createdAt: '2026-05-29T00:01:00.000Z', }); const learnerState = extractLearnerState(project); const firstAppend = await appendPBLRuntimeSnapshotIfChanged({ store, stageId: STAGE_ID, sceneId: SCENE_ID, learnerKey: LEARNER_KEY, project, learnerState, records: [], reason: 'self_heal', }); const session = await ensureSession(store); const records = await store.listRecords(session.id, { sceneId: SCENE_ID }); const secondAppend = await appendPBLRuntimeSnapshotIfChanged({ store, stageId: STAGE_ID, sceneId: SCENE_ID, learnerKey: LEARNER_KEY, project, learnerState, records, reason: 'self_heal', }); expect(firstAppend).toBe(true); expect(secondAppend).toBe(false); expect(records[0]!.payload).not.toHaveProperty('learnerState.submissions.0.filename'); expect(project.submissions[0]).toHaveProperty('filename', undefined); }); it('preserves document-only transient fields on a fold-match hydration', async () => { const store = new MemoryRuntimeStore(); const kv = new MemoryKVStore(); const project = makeProject({ pendingOpenTaskPriorQuizResults: [ { sceneId: 'quiz-1', sceneTitle: 'Readiness quiz', totalQuestions: 2, correctCount: 1, incorrectCount: 1, unscoredCount: 0, accuracy: 0.5, }, ], }); const hydrated = await hydratePBLProjectFromRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); expect(hydrated.source).toBe('fold'); expect(hydrated.project.pendingOpenTaskPriorQuizResults).toEqual( project.pendingOpenTaskPriorQuizResults, ); }); it('self-heals when a bounded document outbox cannot cover an older runtime gap', async () => { const store = new MemoryRuntimeStore(); const project = makeProject({ uiPhase: 'workspace', runtimeEvents: [] }); project.threads[0]!.messages.push({ id: 'evicted-msg', roleType: 'user', content: 'This message event was evicted from the document outbox', ts: '2026-05-29T00:00:01.000Z', microtaskId: 'mt-1', }); const hydrated = await hydrate(project, store); expect(hydrated.source).toBe('document'); expect( (await listRecords(store)).some( (record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot', ), ).toBe(true); }); it('folds duplicate records from overlapping drains exactly once', async () => { const store = new MemoryRuntimeStore(); const kv = new MemoryKVStore(); let project = transitionProjectUiPhase(makeProject(), 'workspace'); project = applyInstructorEvent( { type: 'project_patch', patch: { kind: 'message', message: { id: 'msg-1', roleType: 'user', content: 'Only once', ts: '2026-05-29T00:00:01.000Z', microtaskId: 'mt-1', }, }, }, project, () => {}, ); await Promise.all([ drainProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }), drainProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }), ]); const records = await listRecords(store); const folded = foldPBLRuntime({ designTemplate: stripToDesignTemplate(project), records }); expect(folded.learnerState.threads[0]?.messages).toHaveLength(1); expect(folded.learnerState).toEqual(extractLearnerState(project)); }); it('folds staged and consumed handover events drained in one batch', async () => { const store = new MemoryRuntimeStore(); const kv = new MemoryKVStore(); const project = makeTwoMilestoneProject(); startMicrotask(project, 'mt-1'); const advanced = advanceMicrotask(project, 'mt-1', 'stage complete', { performance: 'ready for the next milestone', }); expect(advanced.ok).toBe(true); expect(project.pendingHandover?.consumed).toBe(false); const consumed = continueAfterHandover(project); expect(consumed.ok).toBe(true); expect(project.pendingHandover?.consumed).toBe(true); await drainProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); const records = await listRecords(store); const folded = foldPBLRuntime({ designTemplate: stripToDesignTemplate(project), records }); expect(folded.diagnostics.gaps).toEqual([]); expect(folded.learnerState).toEqual(extractLearnerState(project)); }); it('keeps microtask engagement and assessment attached to the completing status event', async () => { const store = new MemoryRuntimeStore(); const kv = new MemoryKVStore(); const project = makeProject(); startMicrotask(project, 'mt-1'); recordEvent(project, 'learner_turn', { microtaskId: 'mt-1', milestoneId: 'ms-1', payload: { chars: 24 }, }); const advanced = advanceMicrotask(project, 'mt-1', 'learner completed task', { performance: 'specific evidence', }); expect(advanced.ok).toBe(true); await drainProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); const records = await listRecords(store); const completedStatus = records .map((record) => record.payload as PBLRuntimeStorePayload) .find( (payload) => payload.kind === 'pbl_runtime_event' && payload.event.kind === 'status_changed' && payload.event.entityType === 'microtask' && payload.event.entityId === 'mt-1' && payload.event.to === 'completed', ); const folded = foldPBLRuntime({ designTemplate: stripToDesignTemplate(project), records }); expect(completedStatus).toMatchObject({ kind: 'pbl_runtime_event', attachment: { kind: 'status', microtask: { completionReason: 'learner completed task', internalAssessment: { performance: 'specific evidence' }, engagement: { learnerTurnCount: 1 }, }, }, }); expect(folded.diagnostics.gaps).toEqual([]); expect(folded.learnerState).toEqual(extractLearnerState(project)); }); it('property-checks randomized reducer sequences against BrowserRuntimeStore folds', async () => { const store = new BrowserRuntimeStore({ indexedDB: new IDBFactory() }); const kv = new MemoryKVStore(); let project = makeProject({ uiPhase: 'workspace' }); let seed = 869; let messageIndex = 0; const nextRandom = () => { seed = (seed * 1103515245 + 12345) % 0x80000000; return seed / 0x80000000; }; for (let step = 0; step < 24; step++) { const active = project.milestones .find((milestone) => milestone.status === 'active') ?.microtasks.find((task) => task.status === 'todo' || task.status === 'in_progress'); const roll = nextRandom(); if (!active || project.status === 'completed') { project = resetProjectProgress(project); } else if (roll < 0.2) { startMicrotask(project, active.id); } else if (roll < 0.4) { messageIndex += 1; project = applyInstructorEvent( { type: 'project_patch', patch: { kind: 'message', message: { id: `msg-${messageIndex}`, roleType: messageIndex % 2 === 0 ? 'instructor' : 'user', agentId: messageIndex % 2 === 0 ? 'role-i' : undefined, content: `Message ${messageIndex}`, ts: `2026-05-29T00:${String(step).padStart(2, '0')}:00.000Z`, microtaskId: active.id, }, }, }, project, () => {}, ); } else if (roll < 0.55) { addSubmission(project, { microtaskId: active.id, milestoneId: 'ms-1', kind: 'text', content: `Submission ${step}`, }); } else if (roll < 0.68) { setPendingTaskCompletion(project, { microtaskId: active.id, milestoneId: 'ms-1', reason: `ready ${step}`, assessment: { performance: 'ready' }, }); } else if (roll < 0.78) { clearPendingTaskCompletion(project, active.id); } else if (roll < 0.9) { const result = advanceMicrotask(project, active.id, `completed ${step}`, { performance: `performance ${step}`, }); expect(result.ok).toBe(true); } else { addEvaluation(project, { kind: 'task', microtaskId: active.id, milestoneId: 'ms-1', feedback: `Feedback ${step}`, score: 80, }); } if (step % 7 === 0) { project = transitionProjectUiPhase( project, project.uiPhase === 'workspace' ? 'hero' : 'workspace', ); } await drainProjectRuntime({ stageId: STAGE_ID, sceneId: SCENE_ID, project, store, kv, learnerKey: LEARNER_KEY, }); const records = await listRecords(store); const folded = foldPBLRuntime({ designTemplate: stripToDesignTemplate(project), records }); expect(folded.diagnostics.gaps).toEqual([]); expect(folded.learnerState).toEqual(extractLearnerState(project)); } }); });