diff --git a/packages/client/runtime/src/client/sessions/projection-cell.ts b/packages/client/runtime/src/client/sessions/projection-cell.ts index de7cb5ac12..b4b5204416 100644 --- a/packages/client/runtime/src/client/sessions/projection-cell.ts +++ b/packages/client/runtime/src/client/sessions/projection-cell.ts @@ -68,12 +68,17 @@ export type UseProjection = { ): S } -/** Tail-page projections baseline (structural wire mirror; the zod schema lands with the host-base PR). */ +/** + * Tail-page projections baseline — structurally identical to the wire's + * `SessionProjectionsBlock` (apiproxy api layer), restated here so the + * React-free cell framework depends only on the type table, not the wire + * package's response vocabulary. + */ export interface ProjectionsBaseline { /** The consistent-cut seq (equals the window tail seq by construction). */ asOfSeq: number /** Whole current values by key; a registered key absent here means the capability is absent. */ - values: Record + values: Partial } /** Type-erased spec view the framework machinery works with (the register seam already proved the typed contract). */ @@ -215,8 +220,11 @@ export class ProjectionCellSet { * @param baseline - the response's projections block. */ resetBaseline(baseline: ProjectionsBaseline): void { + // Erased view: the framework walks the open key space; per-key typing + // lives at the cell spec seam (schema.parse re-establishes it). + const values = baseline.values as Record for (const [key, cell] of this.cells) { - cell.resetBaseline(Object.hasOwn(baseline.values, key), baseline.values[key], baseline.asOfSeq) + cell.resetBaseline(Object.hasOwn(values, key), values[key], baseline.asOfSeq) } } } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index a77197e7e3..ec5c9c6023 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -495,13 +495,13 @@ export class Session implements ObservableSnapshot { this.openError = result.error return } - this.installWindow(result.value.events, result.value.hasMore, result.value.todos, projectionsOf(result.value)) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos, result.value.projections) // Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more. const tailSeq = this.windowTailSeq() if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) { result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result if (generation !== this.openGeneration) return - if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos, projectionsOf(result.value)) + if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos, result.value.projections) } this.openState = 'open' } catch (error) { @@ -590,7 +590,7 @@ export class Session implements ObservableSnapshot { const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES }) // Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself. if (result.ok && generation === this.openGeneration && this.openState === 'open') { - this.installWindow(result.value.events, result.value.hasMore, result.value.todos, projectionsOf(result.value)) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos, result.value.projections) } } catch (error) { console.error('[web-runtime] gap repair failed:', error) @@ -850,19 +850,3 @@ function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPha if (hasContent) return 'active' return promptAttempted ? 'engaging' : 'blank' } - -/** - * Structural read of the optional projections block on a history response. - * TODO(gui): drop this narrowing once the host-base PR (dsh-session-projection - * + apiproxy block) lands and the wire type carries `projections` — parallel - * construction posture, same as the code-dispatch event narrowing above. - * @param value - the history response value. - * @returns the block, or undefined (loadOlder pages and blockless deployments). - */ -function projectionsOf(value: object): ProjectionsBaseline | undefined { - const block = (value as { projections?: ProjectionsBaseline }).projections - if (block === undefined) return undefined - return typeof block.asOfSeq === 'number' && typeof block.values === 'object' && block.values !== null - ? block - : undefined -} diff --git a/packages/client/runtime/tests/projection-cell.spec.ts b/packages/client/runtime/tests/projection-cell.spec.ts index 8ab22c4347..85609d20a7 100644 --- a/packages/client/runtime/tests/projection-cell.spec.ts +++ b/packages/client/runtime/tests/projection-cell.spec.ts @@ -95,7 +95,9 @@ describe('ProjectionCellSet semantics', () => { it('degrades a baseline payload failing schema validation to absent instead of poisoning the cell', () => { const { set, cell } = bench() - set.resetBaseline({ asOfSeq: 10, values: { 'test/marks': 'not-an-object' } }) + // Deliberately malformed wire payload: the typed block cannot express it, + // which is exactly why the boundary schema exists. + set.resetBaseline({ asOfSeq: 10, values: { 'test/marks': 'not-an-object' as never } }) expect(cell.getSnapshot()).toBeUndefined() // The watermark still advanced to the cut: pre-cut events stay dropped. set.offerEvent(markEvent(8, ['pre-cut']))