mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-14 20:26: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.
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
import type { Session } from '@/types/api'
|
|
import { T0 } from '../cases/support'
|
|
import type { SseFixtureCase } from './types'
|
|
|
|
/**
|
|
* Hand-authored versioned-patch scenarios. Every case pins how
|
|
* `applySessionDetailPatch` treats a `session-updated` SessionPatch against a
|
|
* cached detail Session: flat fields are last-write-wins, `updatedAt` is
|
|
* max-monotonic, and the four versioned wrappers (metadata / agentState /
|
|
* todos / teamState) apply only when strictly newer than the cached
|
|
* watermark. Timestamps derive from the fixed T0 so output is deterministic.
|
|
*/
|
|
|
|
/** Baseline cached session; cases override the fields under test. All
|
|
* schema-defaulted fields are explicit so the stored document equals its own
|
|
* `SessionSchema.parse` (asserted by the generator). */
|
|
function baseSession(overrides: Partial<Session> = {}): Session {
|
|
return {
|
|
id: 'sess-sse-fixture',
|
|
namespace: 'default',
|
|
seq: 12,
|
|
createdAt: T0 - 3_600_000,
|
|
updatedAt: T0,
|
|
active: true,
|
|
activeAt: T0,
|
|
metadata: {
|
|
path: '/home/dev/project',
|
|
host: 'devbox',
|
|
name: 'project',
|
|
flavor: 'claude'
|
|
},
|
|
metadataVersion: 3,
|
|
agentState: null,
|
|
agentStateVersion: 5,
|
|
thinking: false,
|
|
thinkingAt: 0,
|
|
activeTurnStartedAt: null,
|
|
model: 'claude-sonnet-4-6',
|
|
modelReasoningEffort: null,
|
|
effort: null,
|
|
serviceTier: null,
|
|
permissionMode: 'default',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
export const sseFixtureCases: SseFixtureCase[] = [
|
|
{
|
|
name: 'metadata-newer-version-applied',
|
|
description: 'A metadata wrapper with version strictly greater than the cached metadataVersion replaces the value and stores the version; the accompanying newer updatedAt advances.',
|
|
initialSession: baseSession(),
|
|
patches: [
|
|
{
|
|
updatedAt: T0 + 10_000,
|
|
metadata: {
|
|
version: 4,
|
|
value: {
|
|
path: '/home/dev/project',
|
|
host: 'devbox',
|
|
name: 'project',
|
|
flavor: 'claude',
|
|
summary: { text: 'Add retry to upload path', updatedAt: T0 + 9_000 }
|
|
}
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
name: 'metadata-equal-version-dropped',
|
|
description: 'A metadata wrapper whose version equals the cached metadataVersion is dropped even though its value differs — the gate is strictly-greater on version, never a value comparison (dual SSE connections can replay the same version).',
|
|
initialSession: baseSession(),
|
|
patches: [
|
|
{
|
|
metadata: {
|
|
version: 3,
|
|
value: {
|
|
path: '/home/dev/project',
|
|
host: 'devbox',
|
|
name: 'renamed-should-not-apply',
|
|
flavor: 'claude'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
name: 'agent-state-stale-version-dropped',
|
|
description: 'An agentState wrapper older than the cached agentStateVersion is rejected: applying it would resurrect a permission request that was already resolved into completedRequests.',
|
|
initialSession: baseSession({
|
|
agentState: {
|
|
requests: null,
|
|
completedRequests: {
|
|
'req-1': {
|
|
tool: 'Bash',
|
|
arguments: { command: 'rm -rf build' },
|
|
createdAt: T0 - 30_000,
|
|
completedAt: T0 - 20_000,
|
|
status: 'approved',
|
|
decision: 'approved'
|
|
}
|
|
}
|
|
},
|
|
agentStateVersion: 5
|
|
}),
|
|
patches: [
|
|
{
|
|
agentState: {
|
|
version: 4,
|
|
value: {
|
|
requests: {
|
|
'req-1': {
|
|
tool: 'Bash',
|
|
arguments: { command: 'rm -rf build' },
|
|
createdAt: T0 - 30_000
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
name: 'agent-state-out-of-order-versions',
|
|
description: 'Out-of-order arrival across the two SSE connections: version 7 lands first and applies; the older version 6 arrives second and is dropped. Final state is the v7 value.',
|
|
initialSession: baseSession(),
|
|
patches: [
|
|
{
|
|
agentState: {
|
|
version: 7,
|
|
value: {
|
|
requests: {
|
|
'req-2': {
|
|
tool: 'Write',
|
|
arguments: { file_path: '/home/dev/project/README.md' },
|
|
createdAt: T0 + 2_000
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
agentState: {
|
|
version: 6,
|
|
value: {
|
|
requests: {
|
|
'req-1': {
|
|
tool: 'Bash',
|
|
arguments: { command: 'ls' },
|
|
createdAt: T0 + 1_000
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
name: 'todos-version-watermark-from-absent',
|
|
description: 'todos wrappers gate on todosUpdatedAt with an absent watermark treated as 0: the first patch applies, then an older-versioned todos replay is dropped instead of resurrecting the pre-update list.',
|
|
initialSession: baseSession(),
|
|
patches: [
|
|
{
|
|
todos: {
|
|
version: T0 + 300_000,
|
|
value: [
|
|
{ content: 'Wire the SSE reconnect backoff', status: 'completed', priority: 'high', id: 'todo-1' },
|
|
{ content: 'Port the patch gate to native', status: 'in_progress', priority: 'medium', id: 'todo-2', activeForm: 'Porting the patch gate to native' }
|
|
]
|
|
}
|
|
},
|
|
{
|
|
todos: {
|
|
version: T0 + 200_000,
|
|
value: [
|
|
{ content: 'Wire the SSE reconnect backoff', status: 'in_progress', priority: 'high', id: 'todo-1', activeForm: 'Wiring the SSE reconnect backoff' }
|
|
]
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
name: 'team-state-null-clear',
|
|
description: 'teamState with value null means "team deleted": a strictly newer version clears the field entirely (absent in the expected session) and stores the watermark so a lagged pre-delete patch cannot resurrect the team.',
|
|
initialSession: baseSession({
|
|
teamState: {
|
|
teamName: 'hapi-dev',
|
|
members: [
|
|
{ name: 'lead', agentType: 'claude', status: 'active' },
|
|
{ name: 'reviewer', agentType: 'claude', status: 'idle' }
|
|
],
|
|
updatedAt: T0 - 100_000
|
|
},
|
|
teamStateUpdatedAt: T0 - 100_000
|
|
}),
|
|
patches: [
|
|
{
|
|
teamState: {
|
|
version: T0 + 200_000,
|
|
value: null
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
name: 'updated-at-max-monotonic',
|
|
description: 'updatedAt is max-monotonic: a patch carrying an older updatedAt applies its other fields without rewinding the clock; a newer updatedAt advances it; an older updatedAt alone is a no-op (unchanged).',
|
|
initialSession: baseSession(),
|
|
patches: [
|
|
{ updatedAt: T0 - 5_000, thinking: true },
|
|
{ updatedAt: T0 + 5_000 },
|
|
{ updatedAt: T0 - 2_000 }
|
|
]
|
|
},
|
|
{
|
|
name: 'flat-fields-last-write-wins',
|
|
description: 'Flat fields (active, thinking, model, modelReasoningEffort, effort, permissionMode) are last-write-wins assignments with no version gate; serviceTier additionally honors an explicit null (key present) as a clear.',
|
|
initialSession: baseSession({ serviceTier: 'standard' }),
|
|
patches: [
|
|
{
|
|
active: false,
|
|
thinking: true,
|
|
model: 'claude-opus-4-6',
|
|
modelReasoningEffort: 'high',
|
|
effort: 'high',
|
|
permissionMode: 'acceptEdits'
|
|
},
|
|
{ serviceTier: null }
|
|
]
|
|
},
|
|
{
|
|
name: 'active-turn-started-at-not-applied',
|
|
description: 'Pins actual web behavior: applySessionDetailPatch has no branch for activeTurnStartedAt, so a patch carrying only it is a no-op, and in a mixed patch the other fields apply while activeTurnStartedAt keeps its cached value. (Clients relying on this field must take it from full-session payloads.)',
|
|
initialSession: baseSession(),
|
|
patches: [
|
|
{ activeTurnStartedAt: T0 + 5_000 },
|
|
{ activeTurnStartedAt: T0 + 5_000, thinking: true }
|
|
]
|
|
},
|
|
{
|
|
name: 'keepalive-subminute-active-at-dropped',
|
|
description: 'Keep-alive noise gate: a patch whose only effective change is an activeAt delta below 60s is render-irrelevant and returns unchanged (the cached activeAt does NOT move); a delta of at least 60s applies.',
|
|
initialSession: baseSession(),
|
|
patches: [
|
|
{ active: true, thinking: false, activeAt: T0 + 10_000 },
|
|
{ active: true, thinking: false, activeAt: T0 + 60_000 }
|
|
]
|
|
},
|
|
{
|
|
name: 'mixed-flat-and-versioned-patch',
|
|
description: 'One patch carrying flat fields, a newer metadata wrapper, and a stale agentState wrapper: the flat fields and metadata apply, the stale agentState is rejected, and all of it happens in a single applied call.',
|
|
initialSession: baseSession({
|
|
agentState: { controlledByUser: false },
|
|
agentStateVersion: 5
|
|
}),
|
|
patches: [
|
|
{
|
|
thinking: true,
|
|
updatedAt: T0 + 30_000,
|
|
metadata: {
|
|
version: 5,
|
|
value: {
|
|
path: '/home/dev/project',
|
|
host: 'devbox',
|
|
name: 'project-renamed',
|
|
flavor: 'claude'
|
|
}
|
|
},
|
|
agentState: {
|
|
version: 2,
|
|
value: {
|
|
requests: {
|
|
'req-stale': {
|
|
tool: 'Bash',
|
|
arguments: { command: 'echo stale' },
|
|
createdAt: T0 - 60_000
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
name: 'scratchlist-updated-at-trigger-only',
|
|
description: 'scratchlistUpdatedAt is a refetch trigger, not session state: the patch validates and is render-relevant, but applySessionDetailPatch stores nothing and reports unchanged. Clients react by refetching the scratchlist endpoint, never by mutating the session.',
|
|
initialSession: baseSession(),
|
|
patches: [
|
|
{ scratchlistUpdatedAt: T0 + 900_000 }
|
|
]
|
|
}
|
|
]
|