mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-11 19:50:05 +00:00
Two new golden-fixture suites, expectations machine-generated from the web
implementation (same source-of-truth principle as the chat suite):
- shared/fixtures/sse/ (12 cases): session-updated versioned-patch
application via applySessionDetailPatch — strict version gates for
metadata/agentState/todos/teamState, out-of-order arrival, teamState:null
clear, max-monotonic updatedAt, flat last-write-wins fields, sub-minute
activeAt keep-alive drop, scratchlistUpdatedAt trigger, and the pinned
actual behavior that activeTurnStartedAt is NOT applied by the patch path.
Inputs are stored schema-normalized and validated against SessionSchema /
strict SessionPatchSchema at generation time; per-patch applied/unchanged
verdicts are part of the contract.
- shared/fixtures/pagination/ (11 cases): op scripts driving the real
message-window store with a scripted ApiClient — latest page + SSE ingest,
before-cursor older pages, epoch-mismatch reset (window discard + recorded
internal latest request), reset:true replace preserving optimistic rows,
localId echo reconciliation, messages-consumed invokedAt stamping (no
cursor advance), message-cancelled removal, cancel-too-late invoked-row
ingest, hidden-row cursor advance, 400-row trim preserving queued rows,
and queued-state gap recovery. Documents pin the exact requests the store
issued, older-load outcomes, reconcile candidates, and a minimal window
projection (ids/order, queued/optimistic flags, hasMore, epoch, viewMode,
compound cursors).
Generator: web/scripts/fixtures/{sse,pagination}/ extend the K5 framework
(canonical serialization reused; suites pruned of stale files). Web
self-conformance tests replay every fixture against the real implementation
in bun run test:web. README documents schemas, projections, replay
contracts, and native consumption for both suites.
No time injection needed: the store's only Date.now() reads gate
notification throttling, which never reaches persisted or projected state.
253 lines
9.8 KiB
TypeScript
253 lines
9.8 KiB
TypeScript
import type { ApiClient } from '@/api/client'
|
|
import {
|
|
clearMessageWindow,
|
|
fetchOlderMessages,
|
|
getMessageWindowState,
|
|
getQueuedReconcileCandidateLocalIds,
|
|
ingestIncomingMessages,
|
|
appendOptimisticMessage,
|
|
markMessagesConsumed,
|
|
reconcileQueuedLocalIds,
|
|
removeOptimisticMessage,
|
|
setMessageViewMode,
|
|
syncTailMessages,
|
|
updateMessageStatus,
|
|
type MessageWindowState,
|
|
type OlderLoadOutcome
|
|
} from '@/lib/message-window-store'
|
|
import { isQueuedForInvocation } from '@/lib/messages'
|
|
import type { DecryptedMessage, MessagesResponse } from '@/types/api'
|
|
import { toCanonicalJson } from '../serialize'
|
|
import type {
|
|
PageRequest,
|
|
PaginationOp,
|
|
PaginationOpObservation,
|
|
ProjectedOlderOutcome,
|
|
ProjectedWindowMessage,
|
|
ProjectedWindowState
|
|
} from './types'
|
|
|
|
/**
|
|
* The op-script harness: drives the REAL web message-window store with a
|
|
* scripted ApiClient and records what the store did (requests issued, older
|
|
* -load outcomes, reconcile candidates) plus the final normative projection.
|
|
* Shared by the fixture generator and the self-conformance vitest so the
|
|
* stored expectations can never diverge from a fresh replay.
|
|
*
|
|
* Ops run strictly sequentially (each awaited to completion). Determinism:
|
|
* the store's only wall-clock reads (`Date.now`) gate notification
|
|
* throttling, which never touches the state this harness observes.
|
|
*/
|
|
|
|
export function paginationFixtureSessionId(name: string): string {
|
|
return `fixture-pagination-${name}`
|
|
}
|
|
|
|
type ScriptedApi = {
|
|
api: ApiClient
|
|
requests: PageRequest[]
|
|
queue: MessagesResponse[]
|
|
}
|
|
|
|
function createScriptedApi(responses: MessagesResponse[]): ScriptedApi {
|
|
const scripted: ScriptedApi = {
|
|
requests: [],
|
|
queue: [...responses],
|
|
api: undefined as unknown as ApiClient
|
|
}
|
|
const getMessages: ApiClient['getMessages'] = async (_sessionId, options) => {
|
|
scripted.requests.push(JSON.parse(toCanonicalJson(options ?? {})) as PageRequest)
|
|
const next = scripted.queue.shift()
|
|
if (!next) {
|
|
throw new Error('scripted ApiClient exhausted: unexpected getMessages request')
|
|
}
|
|
return next
|
|
}
|
|
scripted.api = { getMessages } as ApiClient
|
|
return scripted
|
|
}
|
|
|
|
function projectOutcome(outcome: OlderLoadOutcome): ProjectedOlderOutcome {
|
|
if (outcome.kind === 'applied') {
|
|
return {
|
|
kind: 'applied',
|
|
hasMore: outcome.hasMore,
|
|
addedRenderableCount: outcome.addedRenderableCount
|
|
}
|
|
}
|
|
if (outcome.kind === 'stopped') {
|
|
return { kind: 'stopped', reason: outcome.reason }
|
|
}
|
|
throw new Error(`older-page load failed: ${outcome.error.message}`)
|
|
}
|
|
|
|
function projectMessage(message: DecryptedMessage): ProjectedWindowMessage {
|
|
return {
|
|
id: message.id,
|
|
localId: message.localId ?? null,
|
|
seq: message.seq ?? null,
|
|
createdAt: message.createdAt,
|
|
...(message.invokedAt !== undefined ? { invokedAt: message.invokedAt } : {}),
|
|
...(message.scheduledAt !== undefined ? { scheduledAt: message.scheduledAt } : {}),
|
|
...(message.status !== undefined ? { status: message.status } : {}),
|
|
queued: isQueuedForInvocation(message),
|
|
optimistic: Boolean(message.localId && message.id === message.localId)
|
|
}
|
|
}
|
|
|
|
/** The store's compound paging cursors live on the internal state object
|
|
* behind `MessageWindowState`. The `in` guards fail loudly if the web store
|
|
* renames them, so the fixtures cannot silently pin nulls. */
|
|
type InternalCursorFields = {
|
|
oldestPositionAt: number | null
|
|
oldestPositionSeq: number | null
|
|
newestPositionAt: number | null
|
|
newestPositionSeq: number | null
|
|
}
|
|
|
|
function projectCursor(at: unknown, seq: unknown): { at: number; seq: number } | null {
|
|
return typeof at === 'number' && typeof seq === 'number' ? { at, seq } : null
|
|
}
|
|
|
|
export function projectWindowState(sessionId: string): ProjectedWindowState {
|
|
const state = getMessageWindowState(sessionId) as MessageWindowState & Partial<InternalCursorFields>
|
|
if (!('oldestPositionAt' in state) || !('newestPositionAt' in state)) {
|
|
throw new Error('message-window-store internals renamed: update the pagination fixture runner')
|
|
}
|
|
return {
|
|
messages: state.messages.map(projectMessage),
|
|
hasMore: state.hasMore,
|
|
epoch: state.epoch,
|
|
viewMode: state.viewMode,
|
|
olderCursor: projectCursor(state.oldestPositionAt, state.oldestPositionSeq),
|
|
newestCursor: projectCursor(state.newestPositionAt, state.newestPositionSeq)
|
|
}
|
|
}
|
|
|
|
function assertSettled(sessionId: string, opIndex: number, scripted: ScriptedApi): void {
|
|
if (scripted.queue.length > 0) {
|
|
throw new Error(`ops[${opIndex}]: ${scripted.queue.length} scripted response(s) left unconsumed`)
|
|
}
|
|
const state = getMessageWindowState(sessionId)
|
|
if (state.warning !== null) {
|
|
throw new Error(`ops[${opIndex}]: store reported a warning: ${state.warning}`)
|
|
}
|
|
if (state.isSyncingTail || state.isLoadingMore) {
|
|
throw new Error(`ops[${opIndex}]: store still busy after the op settled`)
|
|
}
|
|
}
|
|
|
|
async function executeOp(
|
|
sessionId: string,
|
|
op: PaginationOp,
|
|
opIndex: number
|
|
): Promise<PaginationOpObservation> {
|
|
switch (op.op) {
|
|
case 'sync-tail': {
|
|
const scripted = createScriptedApi(op.responses)
|
|
await syncTailMessages(scripted.api, sessionId)
|
|
assertSettled(sessionId, opIndex, scripted)
|
|
return { requests: scripted.requests }
|
|
}
|
|
case 'fetch-older': {
|
|
const scripted = createScriptedApi(op.responses)
|
|
const outcome = await fetchOlderMessages(scripted.api, sessionId)
|
|
assertSettled(sessionId, opIndex, scripted)
|
|
return { requests: scripted.requests, outcome: projectOutcome(outcome) }
|
|
}
|
|
case 'sse-messages':
|
|
ingestIncomingMessages(sessionId, op.messages)
|
|
return {}
|
|
case 'append-optimistic':
|
|
appendOptimisticMessage(sessionId, op.message)
|
|
return {}
|
|
case 'update-status':
|
|
updateMessageStatus(sessionId, op.localId, op.status)
|
|
return {}
|
|
case 'messages-consumed':
|
|
markMessagesConsumed(sessionId, op.localIds, op.invokedAt)
|
|
return {}
|
|
case 'message-cancelled':
|
|
removeOptimisticMessage(sessionId, op.localId)
|
|
return {}
|
|
case 'cancel-invoked':
|
|
// Mirrors useCancelQueuedMessage: optimistic removal on mutate,
|
|
// then the invoked-race response re-ingests the server row as
|
|
// status 'sent' so the chip lands in the thread, not the bar.
|
|
removeOptimisticMessage(sessionId, op.localId)
|
|
appendOptimisticMessage(sessionId, { ...op.message, status: 'sent' })
|
|
return {}
|
|
case 'set-view-mode':
|
|
setMessageViewMode(sessionId, op.mode)
|
|
return {}
|
|
case 'queued-state': {
|
|
// Mirrors reconcileQueuedStateAfterConnect (post tail-sync half):
|
|
// collect candidates, apply invoked verdicts grouped by
|
|
// timestamp, then drop candidates in neither list.
|
|
const candidates = getQueuedReconcileCandidateLocalIds(sessionId)
|
|
const invokedByTimestamp = new Map<number, string[]>()
|
|
for (const entry of op.invoked) {
|
|
const localIds = invokedByTimestamp.get(entry.invokedAt) ?? []
|
|
localIds.push(entry.localId)
|
|
invokedByTimestamp.set(entry.invokedAt, localIds)
|
|
}
|
|
for (const [invokedAt, localIds] of invokedByTimestamp) {
|
|
markMessagesConsumed(sessionId, localIds, invokedAt)
|
|
}
|
|
reconcileQueuedLocalIds(sessionId, candidates, op.queuedLocalIds)
|
|
return { candidates }
|
|
}
|
|
default: {
|
|
const exhaustive: never = op
|
|
throw new Error(`unknown op: ${JSON.stringify(exhaustive)}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function runPaginationScript(
|
|
sessionId: string,
|
|
ops: PaginationOp[]
|
|
): Promise<{ observations: PaginationOpObservation[]; expectedState: ProjectedWindowState }> {
|
|
clearMessageWindow(sessionId)
|
|
const observations: PaginationOpObservation[] = []
|
|
for (const [opIndex, op] of ops.entries()) {
|
|
observations.push(await executeOp(sessionId, op, opIndex))
|
|
}
|
|
return { observations, expectedState: projectWindowState(sessionId) }
|
|
}
|
|
|
|
/** Fill the machine-generated `expected*` fields into the authored ops. */
|
|
export function mergeOpObservations(
|
|
ops: PaginationOp[],
|
|
observations: PaginationOpObservation[]
|
|
): PaginationOp[] {
|
|
return ops.map((op, index) => {
|
|
const observation = observations[index] ?? {}
|
|
return {
|
|
...op,
|
|
...(observation.requests !== undefined ? { expectedRequests: observation.requests } : {}),
|
|
...(observation.outcome !== undefined ? { expectedOutcome: observation.outcome } : {}),
|
|
...(observation.candidates !== undefined ? { expectedCandidates: observation.candidates } : {})
|
|
} as PaginationOp
|
|
})
|
|
}
|
|
|
|
/** Read the stored `expected*` fields back out, shaped like fresh
|
|
* observations, for exact-match comparison in the conformance tests. */
|
|
export function extractOpExpectations(ops: PaginationOp[]): PaginationOpObservation[] {
|
|
return ops.map((op) => {
|
|
const record = op as Record<string, unknown>
|
|
return {
|
|
...(record.expectedRequests !== undefined
|
|
? { requests: record.expectedRequests as PageRequest[] }
|
|
: {}),
|
|
...(record.expectedOutcome !== undefined
|
|
? { outcome: record.expectedOutcome as ProjectedOlderOutcome }
|
|
: {}),
|
|
...(record.expectedCandidates !== undefined
|
|
? { candidates: record.expectedCandidates as string[] }
|
|
: {})
|
|
}
|
|
})
|
|
}
|