diff --git a/docs/compatibility.md b/docs/compatibility.md index cfbe041..fa97e33 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -63,6 +63,7 @@ The plugin lives off data it does not own: the durable session log (event shapes - **One bad record never takes down a view.** A malformed node, event, tool entry, or file op degrades to zero rows for that item — the card, the tab, and the session keep working. - **Host-side projection folds are TOTAL.** The harness projection registry drives `apply` straight off the session/event bus with no error boundary of its own: one throwing fold stalls that unit's cells and its `session/projection` push feed, and the browser waits on "loading" forever. Unknown event types return the state unchanged; per-event processing is isolated so a malformed event is dropped whole (all-or-nothing, no partial state); and no `undefined`-valued property is ever materialized into persisted state — the plain-JSON precondition makes one such property fail EVERY projection-cache write for the session, breaking sessions in unrelated, far-away places. - **Client-side parsing degrades visibly.** Delivered projection payloads are sanitized at the boundary (the `timelineOf` pattern: collections re-proved, scalars zeroed, whole-value absence stays `null` → loading screen); per-item work in any fold over join/log data is isolated (per-item guards, or a bounded catch where a hostile object may throw on property access); every async fetch resolves to data or a visible retryable state, never an unhandled rejection that leaves a spinner. +- **A session with no pushed `contextTimeline` value never stalls the view.** The projection store's `seed()` clears a key its baseline omits, and a checkpoint row can be missing or version-stale on a cold-observed session — so the Context tab or `/context` modal can sit with no value at all and no push on the way. The source opens the detail channel itself in that case (rev 0): the plugin's own `/api/dsh-context/detail` route folds the durable log on demand and serves the slim head beside the collections, the next pushed head takes the channel back per its own revision, and a settled-without-data read surfaces the retryable failure note (the same `DetailNote` the detail cards use) instead of an eternal spinner. - **Every field is re-proved at runtime.** Structural narrowing over blind casts; optional chaining over non-null assertions; elements that fail the shape are skipped instead of throwing. - **Every parser carries hostile fixtures next to its happy path**: wrong types, null/missing fields, null or primitive elements inside arrays, unpaired references, and objects that throw on property access. The repository's 100% per-file coverage bar applies to every guard branch — an untested guard is an unverified promise. diff --git a/tests/client/components/contextModal.spec.ts b/tests/client/components/contextModal.spec.ts index 8efdcbf..d50796c 100644 --- a/tests/client/components/contextModal.spec.ts +++ b/tests/client/components/contextModal.spec.ts @@ -7,6 +7,7 @@ import { act, createElement as h, useSyncExternalStore } from 'react' import assert from 'node:assert/strict' import { afterEach, describe, test, vi } from 'vitest' import { makeContextModal } from '../../../src/client/components/contextModal' +import { resetTimelineDetailStores } from '../../../src/client/timelineSource' import { modalStoreOf, setPendingConsume, takePendingConsume } from '../../../src/client/modalStore' import { watchHistoryFaces } from '../../../src/client/historyPage' import { createContextSettings } from '../../../src/client/settings' @@ -44,6 +45,10 @@ const OPEN = (): boolean => true afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() + // The cold-start share (timelineSource.ts) is page-lifetime per session: + // drop it so one test's cold read never leaks its state into a later test + // that reuses the same session id. + resetTimelineDetailStores() }) describe('ContextModal', () => { @@ -82,6 +87,25 @@ describe('ContextModal', () => { await m2.unmount() }) + test('a cold read that settles without data surfaces the retryable failure, not a stall', async () => { + vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 500, json: async () => null } as Response)) + const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } }) + const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings) + const m = await mount(h(ContextModal, { + sessionId: 'sm-cold-fail', + useContextModal: OPEN, + useProjection: () => undefined, + })) + await until(() => text(m.container).includes(DICT_EN['detail.loadFailed']), 'the failure note never surfaced') + assert.ok(!text(m.container).includes(DICT_EN.loading), 'no spinner remains once failed') + await act(async () => { + const hit = queryAll(m.container, 'button').find(b => text(b) === DICT_EN['detail.loadFailed']) + if (hit === undefined) throw new Error('retry button not found') + hit.click() + }) + await m.unmount() + }) + test('a corrupt timeline projection is sanitized and still renders the composition', async () => { const ctx = new TestClientCtx({ services: { sessions: new TestSessions() } }) const ContextModal = makeContextModal(asClientCtx(ctx), kit, settings) diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts index 564b315..5f57a5b 100644 --- a/tests/client/components/contextView.spec.ts +++ b/tests/client/components/contextView.spec.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, test, vi } from 'vitest' import { makeContextView } from '../../../src/client/components/contextView' import { watchHistoryFaces } from '../../../src/client/historyPage' import { requestContextFocus, takeContextFocus } from '../../../src/client/viewFocus' +import { resetTimelineDetailStores } from '../../../src/client/timelineSource' import { createContextSettings } from '../../../src/client/settings' import type { SettingsScopeLike } from '../../../src/client/settings' import { resetModelPrices, setModelPricesLoader } from '../../../src/client/modelPrices' @@ -30,6 +31,10 @@ const kit = makeKit() afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() + // The cold-start share (timelineSource.ts) is page-lifetime per session: + // drop it so one test's cold read never leaks its failed/ready state into + // a later test that reuses the same session id. + resetTimelineDetailStores() }) @@ -135,6 +140,18 @@ describe('ContextView — projection guards', () => { await m3.unmount() }) + test('a cold read that settles without data surfaces the retryable failure, not a stall', async () => { + vi.stubGlobal('fetch', () => Promise.resolve({ ok: false, status: 500, json: async () => null } as Response)) + const View = makeView(new TestClientCtx()) + const m = await mount(h(View, { sessionId: 'sv-cold-fail', useProjection: () => undefined })) + await until(() => text(m.container).includes(DICT_EN['detail.loadFailed']), 'the failure note never surfaced') + assert.ok(!text(m.container).includes(DICT_EN.loading), 'no spinner remains once failed') + await act(async () => { + buttonByText(m.container, DICT_EN['detail.loadFailed']).click() + }) + await m.unmount() + }) + test('renders the full tab without a session id (the agent card anchors nothing)', async () => { const View = makeView(new TestClientCtx()) const m = await mount(h(View, { useProjection: projectionsFor(timeline()) })) diff --git a/tests/client/timelineSource.spec.ts b/tests/client/timelineSource.spec.ts index 8e695f3..0569346 100644 --- a/tests/client/timelineSource.spec.ts +++ b/tests/client/timelineSource.spec.ts @@ -400,10 +400,11 @@ describe('detailStoreOf', () => { }) /** A probe component rendering the source's observable surface as text. */ -function SourceProbe(props: { ctx: ClientCtx; sessionId: string; value: unknown }): ReactElement { +function SourceProbe(props: { ctx: ClientCtx; sessionId: string; value?: unknown; useProjection?: (key: string) => unknown }): ReactElement { + const useProjection = props.useProjection ?? ((key: string) => (key === 'contextTimeline' ? props.value : undefined)) const source = useTimelineSource(props.ctx, { sessionId: props.sessionId, - useProjection: (key: string) => (key === 'contextTimeline' ? props.value : undefined), + useProjection, }) const data = source.data return h('div', null, @@ -451,10 +452,89 @@ describe('useTimelineSource', () => { await m.unmount() }) - test('no projection value keeps the loading surface', async () => { - const m = await mount(h(SourceProbe, { ctx: ctxWithCall(() => ({ body: null })), sessionId: 's1', value: undefined })) + test('the cold start: no pushed value opens the channel itself and renders from the read', async () => { + let calls = 0 + const ctx = ctxWithCall(() => { + calls++ + return { body: { ok: true, value: detail(2, { head: slimHead(2) }) } } + }) + const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: undefined })) + // The first paint waits on the cold read — then the read's own slim head + // stands in for the never-pushed projection value. assert.equal(probeRead(m.container, 'state'), 'loading') + await until(() => probeRead(m.container, 'state') === 'ready', 'the cold read never landed') + assert.equal(probeRead(m.container, 'model'), 'm', 'the read head renders') + assert.equal(probeRead(m.container, 'rev'), '2') + assert.equal(probeRead(m.container, 'steps'), '1', 'the read collections merge') + assert.ok(calls >= 1) + await m.unmount() + }) + + test('a pushed head arriving after the cold read takes the channel back', async () => { + // The stub folds the served rev per the latest requested revision — the + // production route folds the requested session at its current watermark. + let servedRev = 1 + vi.stubGlobal('fetch', async () => ({ ok: true, status: 200, json: async () => ({ ok: true, value: detail(servedRev, { head: slimHead(servedRev, { model: servedRev === 1 ? 'cold-m' : 'pushed-m' }) }) }) })) + const ctx = asClientCtx(new TestClientCtx()) + // The probe reads `useProjection` through a mutable capture so an update + // rerender sees the freshly pushed head (the real seat reads per render). + let pushed: unknown = undefined + function PushProbe(): ReactElement { + return h(SourceProbe, { + ctx, + sessionId: 's1', + useProjection: (key: string) => (key === 'contextTimeline' ? pushed : undefined), + }) + } + const m = await mount(h(PushProbe, null)) + await until(() => probeRead(m.container, 'state') === 'ready', 'the cold read never landed') + assert.equal(probeRead(m.container, 'model'), 'cold-m') + // The pushed head outruns the cold read's revision: same per-session + // store, one trailing refetch, the pushed value renders. + servedRev = 2 + pushed = slimHead(2, { model: 'pushed-m' }) + await m.update(h(PushProbe, null)) + assert.equal(probeRead(m.container, 'model'), 'pushed-m', 'the pushed head wins at once') + assert.equal(probeRead(m.container, 'rev'), '2', 'the pushed revision renders') + await until(() => detailStoreOf(ctx, 's1').getSnapshot().detail?.rev === 2, 'the pushed head never trailed the channel') + await m.unmount() + }) + + test('the cold read failing arms the retry affordance instead of stalling', async () => { + let online = false + const ctx = ctxWithCall(() => { + if (!online) throw new Error('offline') + return { body: { ok: true, value: detail(1, { head: slimHead(1) }) } } + }) + const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: undefined })) + await until(() => probeRead(m.container, 'state') === 'failed', 'the cold failure never surfaced') assert.equal(probeRead(m.container, 'steps'), 'null') + online = true + await act(async () => { + ;(m.container.querySelector('[data-k="retry"]') as HTMLElement).click() + }) + await until(() => probeRead(m.container, 'state') === 'ready', 'the retry never recovered') + await m.unmount() + }) + + test('a cold payload without its head types the failure (nothing renderable, no stall)', async () => { + const ctx = ctxWithCall(() => ({ body: { ok: true, value: detail(1) } })) + const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: undefined })) + await until(() => probeRead(m.container, 'state') === 'failed', 'the headless payload never surfaced') + await m.unmount() + }) + + test('an absent cold answer (the session left the live set) surfaces the failed note', async () => { + const ctx = ctxWithCall(() => ({ body: { ok: true, value: null } })) + const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: undefined })) + await until(() => probeRead(m.container, 'state') === 'failed', 'absence never surfaced') + await m.unmount() + }) + + test('no session id to read for: the cold start types the failure, not an eternal spinner', async () => { + const ctx = ctxWithCall(() => ({ body: { ok: true, value: detail(1, { head: slimHead(1) }) } })) + const m = await mount(h(SourceProbe, { ctx, sessionId: '', value: undefined })) + await until(() => probeRead(m.container, 'state') === 'failed', 'the empty-id cold start never surfaced') await m.unmount() })