فهرست منبع

refactor(gui): read the typed projections block off the history response

The wire type now carries projections?: SessionProjectionsBlock (host-base
landed), so the structural projectionsOf narrowing and its TODO(gui) go away —
Session reads result.value.projections directly at all three installWindow
sites. ProjectionsBaseline stays as the cell framework's structural twin
(React-free layer keeps depending on the type table only) with values typed
Partial<SessionProjectionMap>; the erased walk moves inside resetBaseline
where per-key typing is re-established by schema.parse.
imccyu 2 ماه پیش
والد
کامیت
555a6aa7cc

+ 11 - 3
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<string, unknown>
+  values: Partial<SessionProjectionMap>
 }
 
 /** 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<string, unknown>
     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)
     }
   }
 }

+ 3 - 19
packages/client/runtime/src/client/sessions/session.ts

@@ -495,13 +495,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
         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<ConversationSnapshot> {
       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
-}

+ 3 - 1
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']))