The client POSTed every plugin route as a root-absolute /api/dsh-context/* path, which stripped a reverse proxy's /app/dsh prefix at the gateway — every poll 404ed at it, the panel retried on an aggressive backoff, and the deployment fell into the issue #159 reconnect loop. The six route constants now live in one module as their browser-relative spellings: fetch resolves them against the page's document base, the same idiom dsh's own stream/socket clients ride, so a prefixed proxy keeps its prefix and a direct deployment resolves to the exact same URLs before and after. A host/client pairing spec pins each spelling to its route registration, and docs/compatibility.md gains the §5.8 client-seam rule. Fixes #159
878 lines
38 KiB
TypeScript
878 lines
38 KiB
TypeScript
// The timeline source (src/client/timelineSource.ts): the detail store machine and the
|
|
// useTimelineSource hook merging both wire generations into the value the cards render.
|
|
|
|
import { act, createElement as h, type ReactElement } from 'react'
|
|
import assert from 'node:assert/strict'
|
|
import { describe, test, vi, afterEach } from 'vitest'
|
|
import {
|
|
DetailStore,
|
|
detailOf,
|
|
detailStoreOf,
|
|
makeDetailFetcher,
|
|
resetTimelineDetailStores,
|
|
useTimelineSource,
|
|
} from '../../src/client/timelineSource'
|
|
import type { ContextTimeline, ContextTimelineDetail } from '../../src/shared/types'
|
|
import { mount, text, until } from './helpers/kit'
|
|
|
|
afterEach(() => {
|
|
resetTimelineDetailStores()
|
|
vi.restoreAllMocks()
|
|
vi.unstubAllGlobals()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
/** A render-safe detail payload (the shape the host endpoint serves). */
|
|
function detail(rev: number, over: Record<string, unknown> = {}): ContextTimelineDetail {
|
|
return {
|
|
rev,
|
|
requests: [{ seq: rev, time: 0, system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 5, total: 15 }],
|
|
events: [{ seq: rev, time: 0, kind: 'inject', form: 'context' }],
|
|
nodes: [{ seq: rev, cat: 'user', tokens: 3 }],
|
|
droppedNodes: 0,
|
|
archive: [],
|
|
...over,
|
|
}
|
|
}
|
|
|
|
/** A slim wire head (the split generation's pushed value). */
|
|
function slimHead(rev: number, over: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
ok: true,
|
|
model: 'm',
|
|
current: { system: 1, tools: 2, user: 3, inject: 0, skill: 0, assistant: 4, tool: 5, total: 15 },
|
|
counts: { turns: 1, steps: 1, injects: 1, compactions: 0, prunes: 0 },
|
|
last: { seq: 9, total: 15, prompt: 14 },
|
|
detailRev: rev,
|
|
...over,
|
|
}
|
|
}
|
|
|
|
/** Stub the global fetch with a programmable detail reply (the transport the detail reader POSTs). */
|
|
function stubDetailFetch(behavior: () => { status?: number; body?: unknown }): void {
|
|
vi.stubGlobal('fetch', async () => {
|
|
const outcome = await behavior()
|
|
const status = outcome.status ?? 200
|
|
return { ok: status === 200, status, json: async () => outcome.body }
|
|
})
|
|
}
|
|
|
|
describe('detailOf', () => {
|
|
test('rejects non-records and revision-less/negative/NaN payloads', () => {
|
|
assert.equal(detailOf(null), null)
|
|
assert.equal(detailOf(42), null)
|
|
assert.equal(detailOf({}), null)
|
|
assert.equal(detailOf({ rev: '1' }), null)
|
|
assert.equal(detailOf({ rev: -1 }), null)
|
|
assert.equal(detailOf({ rev: NaN }), null)
|
|
})
|
|
|
|
test('narrows a served payload: collections re-proved per item, floors conditional', () => {
|
|
const d = detail(3, {
|
|
requests: [{ seq: 3 }, null, 'junk'],
|
|
nodes: [null, { seq: 2 }],
|
|
droppedNodes: 'many',
|
|
surfaceFloor: 7,
|
|
archiveFloor: 'no',
|
|
})
|
|
const narrowed = detailOf(d)
|
|
assert.ok(narrowed !== null)
|
|
assert.equal(narrowed.rev, 3)
|
|
assert.deepEqual(narrowed.requests, [{ seq: 3 }])
|
|
assert.deepEqual(narrowed.nodes, [{ seq: 2 }])
|
|
assert.equal(narrowed.droppedNodes, 0, 'a wrong-typed scalar zeroes')
|
|
assert.equal(narrowed.surfaceFloor, 7)
|
|
assert.equal(narrowed.archiveFloor, undefined, 'a wrong-typed floor stays absent')
|
|
assert.equal(detailOf(detail(1, { archiveFloor: 9 }))?.archiveFloor, 9)
|
|
// The op log and its floor ride through, per-item guarded.
|
|
const withOps = detailOf(detail(1, {
|
|
fileOps: [{ seq: 2, path: 'a.ts', kind: 'read', tool: 'read', err: false, added: 0, removed: 0 }, null],
|
|
fileOpsFloor: 4,
|
|
}))
|
|
assert.equal(withOps?.fileOps?.length, 1, 'junk entries drop')
|
|
assert.equal(withOps?.fileOpsFloor, 4)
|
|
// The timing strip's painted spans ride through, per-item guarded too.
|
|
const withSpans = detailOf(detail(1, {
|
|
spans: [{ kind: 'ttft', start: 0, end: 100 }, null],
|
|
}))
|
|
assert.equal(withSpans?.spans?.length, 1, 'junk span entries drop')
|
|
// Absent stays absent (the legacy inline generation's marker).
|
|
assert.equal(detailOf(detail(1))?.fileOps, undefined)
|
|
assert.equal(detailOf(detail(1))?.spans, undefined)
|
|
})
|
|
|
|
test('the slim head rides through sanitized; a malformed head drops alone', () => {
|
|
// A well-formed head passes through (the Agent network's cold-ring fetch).
|
|
const served = detailOf(detail(2, { head: slimHead(2) }))
|
|
assert.ok(served?.head !== undefined)
|
|
assert.equal(served.head.current.total, 15)
|
|
assert.equal(served.head.model, 'm')
|
|
// A non-record head drops without rejecting the payload the cards consume.
|
|
const junkHead = detailOf(detail(2, { head: 'nope' }))
|
|
assert.ok(junkHead !== null)
|
|
assert.equal(junkHead.head, undefined)
|
|
assert.ok(junkHead.requests.length > 0)
|
|
// Absent stays absent.
|
|
assert.equal(detailOf(detail(2))?.head, undefined)
|
|
})
|
|
})
|
|
|
|
describe('makeDetailFetcher', () => {
|
|
test('no session id yields no fetcher', () => {
|
|
assert.equal(makeDetailFetcher(''), undefined)
|
|
})
|
|
|
|
test('the envelope contract: not-ok rejects, null value means absent, malformed value rejects', async () => {
|
|
stubDetailFetch(() => ({ body: { ok: false, error: { code: 'x', message: 'm', details: {} } } }))
|
|
const failed = makeDetailFetcher('s1')!
|
|
await assert.rejects(() => failed(), /detail read failed/)
|
|
|
|
stubDetailFetch(() => ({ body: { ok: true, value: null } }))
|
|
const absent = makeDetailFetcher('s1')!
|
|
assert.equal(await absent(), null)
|
|
|
|
stubDetailFetch(() => ({ body: { ok: true, value: { no: 'rev' } } }))
|
|
const malformed = makeDetailFetcher('s1')!
|
|
await assert.rejects(() => malformed(), /detail read malformed/)
|
|
|
|
stubDetailFetch(() => {
|
|
throw new Error('offline')
|
|
})
|
|
const transport = makeDetailFetcher('s1')!
|
|
await assert.rejects(() => transport(), /offline/)
|
|
|
|
// A non-200 answer (the route absent on an inline-generation host) rejects.
|
|
stubDetailFetch(() => ({ status: 404, body: 'not found' }))
|
|
const missing = makeDetailFetcher('s1')!
|
|
await assert.rejects(() => missing(), /HTTP 404/)
|
|
})
|
|
|
|
test('a good read resolves the narrowed detail and POSTs the declared route', async () => {
|
|
const seen: { url?: unknown; body?: unknown } = {}
|
|
vi.stubGlobal('fetch', async (url: unknown, init: { body: string }) => {
|
|
seen.url = url
|
|
seen.body = JSON.parse(String(init.body))
|
|
return { ok: true, status: 200, json: async () => ({ ok: true, value: detail(4) }) }
|
|
})
|
|
const d = await makeDetailFetcher('s1')!()
|
|
assert.equal(d?.rev, 4)
|
|
assert.equal(seen.url, 'api/dsh-context/detail')
|
|
assert.deepEqual(seen.body, { sessionId: 's1' })
|
|
})
|
|
})
|
|
|
|
describe('DetailStore', () => {
|
|
function scriptedFetcher(outcomes: (ContextTimelineDetail | null | Error)[]): { fetcher: () => Promise<ContextTimelineDetail | null>; calls: number[] } {
|
|
const calls: number[] = []
|
|
return {
|
|
calls,
|
|
fetcher: async () => {
|
|
calls.push(Date.now())
|
|
const next = outcomes.length > 0 ? outcomes.shift() : null
|
|
if (next instanceof Error) throw next
|
|
return next ?? null
|
|
},
|
|
}
|
|
}
|
|
|
|
async function settle(): Promise<void> {
|
|
await new Promise(resolve => setTimeout(resolve, 5))
|
|
}
|
|
|
|
test('request schedules the trailing-edge read; the snapshot transitions pending → landed', async () => {
|
|
const { fetcher, calls } = scriptedFetcher([detail(1)])
|
|
const store = new DetailStore(fetcher, 0)
|
|
const seen: string[] = []
|
|
store.subscribe(() => seen.push('change'))
|
|
assert.equal(store.getSnapshot().pending, false)
|
|
store.request(1)
|
|
assert.equal(store.getSnapshot().pending, true, 'the debounce window is armed')
|
|
await settle()
|
|
assert.equal(store.getSnapshot().detail?.rev, 1)
|
|
assert.equal(store.getSnapshot().pending, false)
|
|
assert.equal(store.getSnapshot().failed, false)
|
|
assert.equal(calls.length, 1)
|
|
assert.ok(seen.length > 0, 'listeners fired')
|
|
store.request(1)
|
|
await settle()
|
|
assert.equal(calls.length, 1, 'a covered revision does not refetch')
|
|
})
|
|
|
|
test('unsubscribe stops the notifications', async () => {
|
|
const { fetcher } = scriptedFetcher([detail(1)])
|
|
const store = new DetailStore(fetcher, 0)
|
|
const seen: string[] = []
|
|
const off = store.subscribe(() => seen.push('change'))
|
|
off()
|
|
store.request(1)
|
|
await settle()
|
|
assert.equal(seen.length, 0)
|
|
})
|
|
|
|
test('a parked store arms no read (the last unsubscribe drops the pending window)', async () => {
|
|
vi.useFakeTimers()
|
|
const { fetcher, calls } = scriptedFetcher([detail(1)])
|
|
const store = new DetailStore(fetcher, 10)
|
|
const off = store.subscribe(() => {})
|
|
store.request(1)
|
|
assert.equal(store.getSnapshot().pending, true, 'the debounce window is armed')
|
|
off()
|
|
assert.equal(store.getSnapshot().pending, false, 'parking drops the pending window')
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
assert.equal(calls.length, 0, 'a parked store never reaches the fetcher')
|
|
// A further rev while still parked arms nothing either.
|
|
store.request(2)
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
assert.equal(calls.length, 0)
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
test('a read settling after the last unsubscribe does not re-arm', async () => {
|
|
vi.useFakeTimers()
|
|
let reject!: (error: Error) => void
|
|
let calls = 0
|
|
const store = new DetailStore(() => {
|
|
calls++
|
|
return new Promise<ContextTimelineDetail | null>((_resolve, rej) => { reject = rej })
|
|
}, 10)
|
|
const off = store.subscribe(() => {})
|
|
store.request(1)
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
assert.equal(calls, 1, 'the read is in flight')
|
|
off()
|
|
reject(new Error('offline'))
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
assert.equal(calls, 1, 'no retry is armed once nobody is subscribed')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
test('a store parked without a payload re-reads on the next subscribe', async () => {
|
|
vi.useFakeTimers()
|
|
const { fetcher, calls } = scriptedFetcher([new Error('offline'), detail(1)])
|
|
const store = new DetailStore(fetcher, 10)
|
|
const off = store.subscribe(() => {})
|
|
store.request(1)
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
assert.equal(calls.length, 1)
|
|
assert.equal(store.getSnapshot().failed, true)
|
|
off()
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
assert.equal(calls.length, 1, 'the parked retry is dropped')
|
|
store.subscribe(() => {})
|
|
// The SAME head rev must read again: the failed read already marked it
|
|
// "wanted", which would otherwise swallow the resumed store's request.
|
|
store.request(1)
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
assert.equal(calls.length, 2, 'the resumed store reads instead of sticking on the note')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
test('a parked store with a cached payload resumes without a read', async () => {
|
|
const { fetcher, calls } = scriptedFetcher([detail(1)])
|
|
const store = new DetailStore(fetcher, 0)
|
|
const off = store.subscribe(() => {})
|
|
store.request(1)
|
|
await until(() => store.getSnapshot().detail?.rev === 1, 'the first read never landed')
|
|
off()
|
|
store.subscribe(() => {})
|
|
store.request(1)
|
|
await settle()
|
|
assert.equal(calls.length, 1, 'the cached payload still covers the rev')
|
|
})
|
|
|
|
test('a shared store parks only when its last subscriber leaves', async () => {
|
|
vi.useFakeTimers()
|
|
const { fetcher, calls } = scriptedFetcher([detail(1)])
|
|
const store = new DetailStore(fetcher, 10)
|
|
// The tab and the /context modal share one store per session.
|
|
const offTab = store.subscribe(() => {})
|
|
const offModal = store.subscribe(() => {})
|
|
store.request(1)
|
|
offTab()
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
assert.equal(calls.length, 1, 'the remaining subscriber still gets its read')
|
|
offModal()
|
|
store.request(2)
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
assert.equal(calls.length, 1, 'the parked store arms nothing')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
test('release drops the cached payload and any armed window', async () => {
|
|
vi.useFakeTimers()
|
|
const { fetcher, calls } = scriptedFetcher([detail(1), detail(2)])
|
|
const store = new DetailStore(fetcher, 10)
|
|
store.subscribe(() => {})
|
|
store.request(1)
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
assert.equal(store.hasPayload, true)
|
|
store.request(2)
|
|
assert.equal(store.getSnapshot().pending, true, 'a second window is armed')
|
|
store.release()
|
|
assert.equal(store.hasPayload, false)
|
|
assert.equal(store.getSnapshot().pending, false)
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
assert.equal(calls.length, 1, 'the released store arms nothing')
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
test('a rev bump during the in-flight read re-reads after settle (trailing edge)', async () => {
|
|
let gate!: () => void
|
|
const first = new Promise<ContextTimelineDetail | null>(resolve => { gate = () => resolve(detail(1)) })
|
|
let call = 0
|
|
const store = new DetailStore(() => {
|
|
call++
|
|
return call === 1 ? first : Promise.resolve(detail(2))
|
|
}, 0)
|
|
store.request(1)
|
|
await vi.waitFor(() => { assert.equal(call, 1) })
|
|
store.request(2)
|
|
gate()
|
|
await until(() => store.getSnapshot().detail?.rev === 2, 'the trailing read never landed')
|
|
assert.equal(call, 2)
|
|
})
|
|
|
|
test('latest-wins: a stale response (behind the served rev, off-target) drops; the trail re-reads', async () => {
|
|
const { fetcher, calls } = scriptedFetcher([detail(3), detail(2), detail(4)])
|
|
const store = new DetailStore(fetcher, 0)
|
|
store.request(3)
|
|
await settle()
|
|
assert.equal(store.getSnapshot().detail?.rev, 3)
|
|
store.request(4)
|
|
await until(() => store.getSnapshot().detail?.rev === 4, 'the trail never converged')
|
|
assert.equal(calls.length, 3, 'the stale rev-2 answer burned one read before the rev-4 landing')
|
|
})
|
|
|
|
test('a rev DECREASE means the host refolded: the ledger resets and refetches', async () => {
|
|
const { fetcher } = scriptedFetcher([detail(5), detail(2)])
|
|
const store = new DetailStore(fetcher, 0)
|
|
store.request(5)
|
|
await settle()
|
|
assert.equal(store.getSnapshot().detail?.rev, 5)
|
|
store.request(2)
|
|
await until(() => store.getSnapshot().detail?.rev === 2, 'the refold reset never refetched')
|
|
})
|
|
|
|
for (const outcome of ['detail', 'absent', 'failure'] as const) {
|
|
for (const timerFired of [false, true]) {
|
|
test(`a refold ignores an old ${outcome} before a new read (timer fired: ${String(timerFired)})`, async () => {
|
|
vi.useFakeTimers()
|
|
let resolve!: (value: ContextTimelineDetail | null) => void
|
|
let reject!: (reason: Error) => void
|
|
let calls = 0
|
|
const first = new Promise<ContextTimelineDetail | null>((res, rej) => {
|
|
resolve = res
|
|
reject = rej
|
|
})
|
|
const store = new DetailStore(() => {
|
|
calls++
|
|
return calls === 1 ? first : Promise.resolve(detail(2))
|
|
}, 10)
|
|
store.request(5)
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
store.request(2)
|
|
if (timerFired) await vi.advanceTimersByTimeAsync(10)
|
|
assert.equal(calls, 1, 'the reset never stacks a concurrent read')
|
|
|
|
if (outcome === 'failure') reject(new Error('offline'))
|
|
else resolve(outcome === 'detail' ? detail(5) : null)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
assert.equal(store.getSnapshot().detail, null, 'old collections must not land under the new head')
|
|
assert.equal(store.getSnapshot().failed, false, 'old absence or failure must not fail the new generation')
|
|
assert.equal(store.getSnapshot().pending, true, 'the new generation still needs its read')
|
|
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
assert.equal(calls, 2)
|
|
assert.equal(store.getSnapshot().detail?.rev, 2)
|
|
assert.equal(store.getSnapshot().failed, false)
|
|
assert.equal(store.getSnapshot().pending, false)
|
|
})
|
|
}
|
|
}
|
|
|
|
test('several refolds during one read converge to the latest generation', async () => {
|
|
vi.useFakeTimers()
|
|
let resolve!: (value: ContextTimelineDetail | null) => void
|
|
let calls = 0
|
|
const first = new Promise<ContextTimelineDetail | null>(res => { resolve = res })
|
|
const store = new DetailStore(() => {
|
|
calls++
|
|
return calls === 1 ? first : Promise.resolve(detail(1))
|
|
}, 10)
|
|
store.request(5)
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
store.request(3)
|
|
store.request(1)
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
resolve(detail(5))
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
assert.equal(store.getSnapshot().detail, null)
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
assert.equal(calls, 2)
|
|
assert.equal(store.getSnapshot().detail?.rev, 1)
|
|
})
|
|
|
|
test('a transport failure with no detail arms the failed state; the backoff trailing recovers on its own', async () => {
|
|
const { fetcher, calls } = scriptedFetcher([new Error('offline'), detail(1)])
|
|
const store = new DetailStore(fetcher, 20)
|
|
store.request(1)
|
|
await until(() => store.getSnapshot().failed === true, 'the failure never surfaced')
|
|
assert.equal(store.getSnapshot().detail, null)
|
|
// No manual retry: the trailing edge re-reads after the backoff window.
|
|
await until(() => store.getSnapshot().detail?.rev === 1, 'the trailing recovery never landed')
|
|
assert.equal(store.getSnapshot().failed, false)
|
|
assert.equal(calls.length, 2)
|
|
})
|
|
|
|
test('a transport failure WITH detail keeps the served value (no flicker to failed), then recovers', async () => {
|
|
const { fetcher, calls } = scriptedFetcher([detail(1), new Error('offline'), detail(2)])
|
|
const store = new DetailStore(fetcher, 0)
|
|
store.request(1)
|
|
await until(() => store.getSnapshot().detail?.rev === 1, 'the first read never landed')
|
|
store.request(2)
|
|
// Mid-way: the failed read settles with the last good detail still serving
|
|
// (>=: the zero-debounce trail can land the recovery between two polls).
|
|
await until(() => calls.length >= 2, 'the second read never fired')
|
|
await until(() => store.getSnapshot().detail?.rev === 2, 'the trailing recovery never landed')
|
|
assert.equal(store.getSnapshot().failed, false, 'the failed read never flipped the cards to failed')
|
|
})
|
|
|
|
test('an absent answer stops the trailing; a fresh rev asks again', async () => {
|
|
const { fetcher, calls } = scriptedFetcher([null, detail(2)])
|
|
const store = new DetailStore(fetcher, 0)
|
|
store.request(1)
|
|
await settle()
|
|
assert.equal(store.getSnapshot().failed, true, 'absent with nothing to show arms the note')
|
|
await settle()
|
|
assert.equal(calls.length, 1, 'absence stops the self-trailing')
|
|
store.request(2)
|
|
await until(() => store.getSnapshot().detail?.rev === 2, 'the fresh rev never refetched')
|
|
assert.equal(store.getSnapshot().failed, false)
|
|
})
|
|
|
|
test('retry with no armed timer refires directly (the stopped-trailing shape)', async () => {
|
|
// Absence stops the trailing timer; the manual retry must not wait on one.
|
|
const { fetcher, calls } = scriptedFetcher([null, detail(1)])
|
|
const store = new DetailStore(fetcher, 0)
|
|
store.request(1)
|
|
await until(() => store.getSnapshot().failed === true, 'the absent read never settled')
|
|
assert.equal(calls.length, 1)
|
|
store.retry()
|
|
await until(() => store.getSnapshot().detail?.rev === 1, 'the timer-less retry never fired')
|
|
assert.equal(calls.length, 2)
|
|
})
|
|
|
|
test('retry during an in-flight read does not stack a second call', async () => {
|
|
let release!: (d: ContextTimelineDetail | null) => void
|
|
let started = false
|
|
const store = new DetailStore(() => {
|
|
started = true
|
|
return new Promise<ContextTimelineDetail | null>(resolve => { release = resolve })
|
|
}, 0)
|
|
store.request(1)
|
|
await until(() => started, 'the read never started')
|
|
store.retry()
|
|
release(detail(1))
|
|
await until(() => store.getSnapshot().detail?.rev === 1, 'the in-flight read never landed')
|
|
assert.equal(store.getSnapshot().failed, false)
|
|
})
|
|
|
|
test('absence with a served detail keeps it and stays silent', async () => {
|
|
const { fetcher } = scriptedFetcher([detail(1), null])
|
|
const store = new DetailStore(fetcher, 0)
|
|
store.request(1)
|
|
await settle()
|
|
store.request(2)
|
|
await until(() => store.getSnapshot().pending === false, 'the absent read never settled')
|
|
assert.equal(store.getSnapshot().detail?.rev, 1)
|
|
assert.equal(store.getSnapshot().failed, false)
|
|
})
|
|
|
|
test('without a fetcher the store types the failure (the empty-session-id path)', async () => {
|
|
const store = new DetailStore(undefined, 0)
|
|
store.request(1)
|
|
await settle()
|
|
assert.equal(store.getSnapshot().failed, true)
|
|
assert.equal(store.getSnapshot().detail, null)
|
|
})
|
|
|
|
test('retry re-arms immediately and resets the backoff', async () => {
|
|
const { fetcher, calls } = scriptedFetcher([new Error('offline'), detail(1)])
|
|
const store = new DetailStore(fetcher, 1000)
|
|
store.request(1)
|
|
await vi.waitFor(() => { assert.equal(calls.length, 1) }, { timeout: 3000 })
|
|
await until(() => store.getSnapshot().failed === true, 'the failure never settled')
|
|
store.retry()
|
|
await until(() => store.getSnapshot().detail?.rev === 1, 'the retry never refetched')
|
|
assert.equal(calls.length, 2)
|
|
// A retry with a served detail is a no-op.
|
|
store.retry()
|
|
await settle()
|
|
assert.equal(calls.length, 2)
|
|
})
|
|
|
|
test('consecutive failures back the debounce off exponentially', async () => {
|
|
vi.useFakeTimers()
|
|
const { fetcher, calls } = scriptedFetcher([new Error('a'), new Error('b'), detail(1)])
|
|
const store = new DetailStore(fetcher, 10)
|
|
store.request(1)
|
|
await vi.advanceTimersByTimeAsync(10)
|
|
assert.equal(calls.length, 1, 'the first read fires after the base window')
|
|
assert.equal(store.getSnapshot().failed, true)
|
|
await vi.advanceTimersByTimeAsync(19)
|
|
assert.equal(calls.length, 1, 'the trailing read waits out the doubled window')
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
assert.equal(calls.length, 2)
|
|
// Second failure: the window doubles again (40).
|
|
await vi.advanceTimersByTimeAsync(39)
|
|
assert.equal(calls.length, 2)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
assert.equal(calls.length, 3, 'the recovered read lands')
|
|
})
|
|
|
|
test('a rev bump arriving while a read is in flight waits out the flight, then trails', async () => {
|
|
// The in-flight guard: the bumped rev's timer fires DURING the first read
|
|
// and must not stack a second call; the settle re-arms it.
|
|
let release!: (d: ContextTimelineDetail | null) => void
|
|
let call = 0
|
|
const store = new DetailStore(() => {
|
|
call++
|
|
if (call === 1) return new Promise<ContextTimelineDetail | null>(resolve => { release = resolve })
|
|
return Promise.resolve(detail(2))
|
|
}, 0)
|
|
store.request(1)
|
|
await vi.waitFor(() => { assert.equal(call, 1) })
|
|
store.request(2)
|
|
await settle()
|
|
assert.equal(call, 1, 'no stacked read while the first is in flight')
|
|
release(detail(1))
|
|
await until(() => store.getSnapshot().detail?.rev === 2, 'the trailing read never landed')
|
|
assert.equal(call, 2)
|
|
})
|
|
})
|
|
|
|
describe('detailStoreOf', () => {
|
|
test('one store per session, shared by every consumer; reset drops the cache', () => {
|
|
stubDetailFetch(() => ({ body: { ok: true, value: detail(1) } }))
|
|
const a = detailStoreOf('s1')
|
|
assert.equal(a, detailStoreOf('s1'), 'the session key wins, the first fetcher keeps serving')
|
|
assert.notEqual(a, detailStoreOf('s2'))
|
|
resetTimelineDetailStores()
|
|
assert.notEqual(detailStoreOf('s1'), a, 'a reset store is a fresh instance')
|
|
})
|
|
|
|
function countingFetch(): Map<string, number> {
|
|
const calls = new Map<string, number>()
|
|
vi.stubGlobal('fetch', async (_url: unknown, init?: { body?: string }) => {
|
|
if (init?.body === undefined) return { ok: false, status: 404, json: async () => null }
|
|
const id = (JSON.parse(init.body) as { sessionId: string }).sessionId
|
|
calls.set(id, (calls.get(id) ?? 0) + 1)
|
|
return { ok: true, status: 200, json: async () => ({ ok: true, value: detail(1) }) }
|
|
})
|
|
return calls
|
|
}
|
|
|
|
/** The app's mount path for one session: subscribe, request, land, leave. */
|
|
async function visitStore(id: string): Promise<DetailStore> {
|
|
const store = detailStoreOf(id)
|
|
const off = store.subscribe(() => {})
|
|
store.request(1)
|
|
await until(() => store.hasPayload, `store ${id} never landed its read`)
|
|
off()
|
|
return store
|
|
}
|
|
|
|
test('the page cache bounds retained payloads; a demoted session reads again', async () => {
|
|
const calls = countingFetch()
|
|
const ids = Array.from({ length: 12 }, (_, i) => 'cap-' + i)
|
|
for (const id of ids) await visitStore(id)
|
|
assert.equal(detailStoreOf(ids[11]).hasPayload, true, 'the newest visit stays cached')
|
|
assert.equal(detailStoreOf(ids[0]).hasPayload, false, 'the oldest visit was demoted')
|
|
// The cached session re-opens without a read; the demoted one reads again.
|
|
await visitStore(ids[11])
|
|
assert.equal(calls.get(ids[11]), 1, 'a cached re-open reads nothing')
|
|
await visitStore(ids[0])
|
|
assert.equal(calls.get(ids[0]), 2, 'a demoted re-open reads once more')
|
|
})
|
|
|
|
test('a mounted store is never demoted', async () => {
|
|
countingFetch()
|
|
const ids = Array.from({ length: 12 }, (_, i) => 'live-' + i)
|
|
const held = detailStoreOf(ids[0])
|
|
const off = held.subscribe(() => {})
|
|
held.request(1)
|
|
await until(() => held.hasPayload, 'the mounted store never landed')
|
|
for (const id of ids.slice(1)) await visitStore(id)
|
|
assert.equal(held.active, true)
|
|
assert.equal(held.hasPayload, true, 'a mounted store keeps its payload')
|
|
off()
|
|
})
|
|
|
|
test('a burst of opens is trimmed back to the bound on the next open', async () => {
|
|
countingFetch()
|
|
const ids = Array.from({ length: 12 }, (_, i) => 'burst-' + i)
|
|
// Every store subscribes and requests BEFORE any read lands.
|
|
const offs: (() => void)[] = []
|
|
const created = ids.map((id) => {
|
|
const store = detailStoreOf(id)
|
|
offs.push(store.subscribe(() => {}))
|
|
store.request(1)
|
|
return store
|
|
})
|
|
for (const [i, store] of created.entries()) {
|
|
await until(() => store.hasPayload, 'burst store ' + String(i) + ' never landed')
|
|
}
|
|
for (const off of offs) off()
|
|
// The next open trims the excess, oldest first.
|
|
await visitStore('burst-extra')
|
|
assert.equal(created[0].hasPayload, false, 'the oldest burst payload is released')
|
|
assert.equal(created[11].hasPayload, true, 'the newest burst payload survives')
|
|
// A later open walks past the already-demoted entries to the next victim.
|
|
await visitStore('burst-extra-2')
|
|
assert.equal(created[5].hasPayload, false)
|
|
assert.equal(created[11].hasPayload, true)
|
|
})
|
|
})
|
|
|
|
/** A probe component rendering the source's observable surface as text. */
|
|
function SourceProbe(props: { sessionId: string; value?: unknown; useProjection?: (key: string) => unknown }): ReactElement {
|
|
const useProjection = props.useProjection ?? ((key: string) => (key === 'contextTimeline' ? props.value : undefined))
|
|
const source = useTimelineSource({
|
|
sessionId: props.sessionId,
|
|
useProjection,
|
|
})
|
|
const data = source.data
|
|
return h('div', null,
|
|
h('span', { 'data-k': 'state' }, source.detailState),
|
|
h('span', { 'data-k': 'steps' }, data === null ? 'null' : String(data.requests.length)),
|
|
h('span', { 'data-k': 'turns' }, String(data?.counts?.turns ?? 'x')),
|
|
h('span', { 'data-k': 'rev' }, String(data?.detailRev ?? 'x')),
|
|
h('span', { 'data-k': 'floors' }, `${String(data?.surfaceFloor ?? 'x')}/${String(data?.archiveFloor ?? 'x')}`),
|
|
h('span', { 'data-k': 'ops' }, String(data?.fileOps?.length ?? 'x')),
|
|
h('span', { 'data-k': 'spans' }, String(data?.spans?.length ?? 'x')),
|
|
h('span', { 'data-k': 'model' }, data?.model ?? 'x'),
|
|
h('button', { 'data-k': 'retry', onClick: source.retryDetail }, 'retry'))
|
|
}
|
|
|
|
function probeRead(container: HTMLElement, key: string): string {
|
|
return text(container.querySelector(`[data-k="${key}"]`) as HTMLElement)
|
|
}
|
|
|
|
describe('useTimelineSource', () => {
|
|
test('a refold never merges the previous generation into the pushed head', async () => {
|
|
vi.useFakeTimers()
|
|
const response = (value: ContextTimelineDetail) => ({ ok: true, json: async () => ({ ok: true, value }) })
|
|
let resolve!: (value: ReturnType<typeof response>) => void
|
|
let calls = 0
|
|
const first = new Promise<ReturnType<typeof response>>(res => { resolve = res })
|
|
vi.stubGlobal('fetch', () => {
|
|
calls++
|
|
return calls === 1 ? first : Promise.resolve(response(detail(2)))
|
|
})
|
|
const m = await mount(h(SourceProbe, { sessionId: 's1', value: slimHead(5) }))
|
|
try {
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(300) })
|
|
await m.update(h(SourceProbe, { sessionId: 's1', value: slimHead(2) }))
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(300) })
|
|
await act(async () => {
|
|
resolve(response(detail(5)))
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
})
|
|
assert.equal(probeRead(m.container, 'rev'), '2')
|
|
assert.equal(probeRead(m.container, 'state'), 'loading')
|
|
assert.equal(probeRead(m.container, 'steps'), '0', 'no rows from the discarded generation')
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(300) })
|
|
assert.equal(calls, 2)
|
|
assert.equal(probeRead(m.container, 'state'), 'ready')
|
|
assert.equal(detailStoreOf('s1').getSnapshot().detail?.rev, 2)
|
|
} finally {
|
|
await m.unmount()
|
|
}
|
|
})
|
|
|
|
test('the inline generation passes through untouched and never fetches', async () => {
|
|
let calls = 0
|
|
stubDetailFetch(() => {
|
|
calls++
|
|
return { body: { ok: true, value: detail(1) } }
|
|
})
|
|
const inline: ContextTimeline = {
|
|
ok: true,
|
|
model: 'inline-m',
|
|
current: { system: 1, tools: 2, user: 3, inject: 0, skill: 0, assistant: 4, tool: 5, total: 15 },
|
|
requests: [{ seq: 1, time: 0, system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 5, total: 15 }],
|
|
events: [],
|
|
nodes: [],
|
|
droppedNodes: 0,
|
|
archive: [],
|
|
}
|
|
const m = await mount(h(SourceProbe, { sessionId: 's1', value: inline }))
|
|
await new Promise(resolve => setTimeout(resolve, 350))
|
|
assert.equal(probeRead(m.container, 'state'), 'legacy')
|
|
assert.equal(probeRead(m.container, 'steps'), '1')
|
|
assert.equal(probeRead(m.container, 'model'), 'inline-m')
|
|
assert.equal(calls, 0, 'the inline generation never opens the detail channel')
|
|
// The legacy retry is the inert no-op (the cards show no note).
|
|
await act(async () => {
|
|
;(m.container.querySelector('[data-k="retry"]') as HTMLElement).click()
|
|
})
|
|
await m.unmount()
|
|
})
|
|
|
|
test('the cold start: no pushed value opens the channel itself and renders from the read', async () => {
|
|
let calls = 0
|
|
stubDetailFetch(() => {
|
|
calls++
|
|
return { body: { ok: true, value: detail(2, { head: slimHead(2) }) } }
|
|
})
|
|
const m = await mount(h(SourceProbe, { 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' }) }) }) }))
|
|
// 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, {
|
|
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('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
|
|
stubDetailFetch(() => {
|
|
if (!online) throw new Error('offline')
|
|
return { body: { ok: true, value: detail(1, { head: slimHead(1) }) } }
|
|
})
|
|
const m = await mount(h(SourceProbe, { 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 () => {
|
|
stubDetailFetch(() => ({ body: { ok: true, value: detail(1) } }))
|
|
const m = await mount(h(SourceProbe, { 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 () => {
|
|
stubDetailFetch(() => ({ body: { ok: true, value: null } }))
|
|
const m = await mount(h(SourceProbe, { 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 () => {
|
|
stubDetailFetch(() => ({ body: { ok: true, value: detail(1, { head: slimHead(1) }) } }))
|
|
const m = await mount(h(SourceProbe, { sessionId: '', value: undefined }))
|
|
await until(() => probeRead(m.container, 'state') === 'failed', 'the empty-id cold start never surfaced')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('the split generation: counters render off the head immediately, the detail lands through the channel', async () => {
|
|
let calls = 0
|
|
stubDetailFetch(() => {
|
|
calls++
|
|
return {
|
|
body: {
|
|
ok: true,
|
|
value: detail(3, {
|
|
surfaceFloor: 2,
|
|
archiveFloor: 5,
|
|
fileOps: [{ seq: 2, path: 'a.ts', kind: 'read', tool: 'read', err: false, added: 0, removed: 0 }],
|
|
fileOpsFloor: 4,
|
|
spans: [{ kind: 'ttft', start: 0, end: 100 }, { kind: 'other', start: 100, end: 1_400 }],
|
|
}),
|
|
},
|
|
}
|
|
})
|
|
const m = await mount(h(SourceProbe, { sessionId: 's1', value: slimHead(3) }))
|
|
// The first paint: the head's counters with the detail still pending.
|
|
assert.equal(probeRead(m.container, 'state'), 'loading')
|
|
assert.equal(probeRead(m.container, 'turns'), '1', 'the head counters render before the detail lands')
|
|
assert.equal(probeRead(m.container, 'steps'), '0', 'the collections wait for the channel')
|
|
assert.equal(probeRead(m.container, 'floors'), 'x/x', 'no floors before the detail')
|
|
await until(() => probeRead(m.container, 'state') === 'ready', 'the detail never landed')
|
|
assert.equal(probeRead(m.container, 'steps'), '1', 'the merged requests serve')
|
|
assert.equal(probeRead(m.container, 'rev'), '3')
|
|
assert.equal(probeRead(m.container, 'floors'), '2/5', 'the detail floors merge')
|
|
assert.equal(probeRead(m.container, 'ops'), '1', 'the op log merges')
|
|
assert.equal(probeRead(m.container, 'spans'), '2', 'the strip spans merge')
|
|
assert.ok(calls >= 1)
|
|
await m.unmount()
|
|
})
|
|
|
|
test('a rev bump on the pushed head refetches the detail', async () => {
|
|
let served = 3
|
|
stubDetailFetch(() => ({ body: { ok: true, value: detail(served) } }))
|
|
const m = await mount(h(SourceProbe, { sessionId: 's1', value: slimHead(3) }))
|
|
await until(() => probeRead(m.container, 'state') === 'ready', 'the first read never landed')
|
|
assert.equal(detailStoreOf('s1').getSnapshot().detail?.rev, 3)
|
|
served = 4
|
|
await m.update(h(SourceProbe, { sessionId: 's1', value: slimHead(4) }))
|
|
// The probe's rev cell reads the HEAD's marker (4 as pushed); the store's served detail proves the refetch.
|
|
assert.equal(probeRead(m.container, 'rev'), '4')
|
|
await until(() => detailStoreOf('s1').getSnapshot().detail?.rev === 4, 'the refetch never landed')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('the failed read arms the retry affordance, which refires the read', async () => {
|
|
let online = false
|
|
let calls = 0
|
|
stubDetailFetch(() => {
|
|
calls++
|
|
if (!online) throw new Error('offline')
|
|
return { body: { ok: true, value: detail(1) } }
|
|
})
|
|
const m = await mount(h(SourceProbe, { sessionId: 's1', value: slimHead(1) }))
|
|
await until(() => probeRead(m.container, 'state') === 'failed', 'the failure never surfaced')
|
|
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')
|
|
assert.ok(calls >= 2)
|
|
await m.unmount()
|
|
})
|
|
|
|
test('an absent answer (the session left the live set) surfaces the failed note', async () => {
|
|
stubDetailFetch(() => ({ body: { ok: true, value: null } }))
|
|
const m = await mount(h(SourceProbe, { sessionId: 's1', value: slimHead(1) }))
|
|
await until(() => probeRead(m.container, 'state') === 'failed', 'absence never surfaced')
|
|
await m.unmount()
|
|
})
|
|
})
|