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.
This commit is contained in:
imccyu
2026-07-27 16:20:26 +08:00
parent 95a3794e68
commit 555a6aa7cc
3 changed files with 17 additions and 23 deletions

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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']))