From 5cc028ff00856fc0bd7c04b196decb3bd4de0f56 Mon Sep 17 00:00:00 2001 From: Bowen Liang Date: Wed, 26 Aug 2026 03:34:24 +0800 Subject: [PATCH] feat(client): replace history paging with seq-targeted content fetch The Context browser joined full content against the chat conversation window; when a surface node's seq sat outside it, the fallback paged blindly backwards through session.loadOlder() (up to 20 pages of 50 messages prepended into the shared chat window) and finally dead-ended on a static note. sessions.history already supports seq-anchored pages cut on whole append-origin message boundaries, so ONE call with beforeSeq: seq + 1 returns exactly the page holding the event whenever the durable log still has it. The browser now: - fetches on miss with that single anchored read and maps the raw page into the node shapes it already renders (new client/historyPage.ts: user messages, assistant blocks, tool results paired with their in-page call head, compaction checkpoints with their summary); - merges fetched nodes under the zero-cost window join (join wins); - reports honest states instead of one dead end: loading, absent from the session log (compacted or cleared), failed with a retry button; - drops the sessions.provide contribution and the MAX_AUTO_PAGES machinery entirely - the chat scrollback is never mutated again. Hosts without the connection api keep the static preview-plus-hint degradation. --- src/client/components/browser.tsx | 105 +++++----- src/client/components/contextModal.tsx | 8 +- src/client/components/contextView.tsx | 10 +- src/client/historyPage.ts | 171 +++++++++++++++ src/client/i18n.ts | 4 + src/client/index.ts | 21 +- src/client/services.ts | 72 ++++--- src/client/styles.css | 2 + tests/client/components/browser.spec.ts | 195 +++++++++-------- tests/client/components/contextView.spec.ts | 48 +++-- tests/client/helpers/harness.ts | 15 +- tests/client/historyPage.spec.ts | 218 ++++++++++++++++++++ tests/client/index.spec.ts | 51 +---- 13 files changed, 663 insertions(+), 257 deletions(-) create mode 100644 src/client/historyPage.ts create mode 100644 tests/client/historyPage.spec.ts diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index 3acb687..8ebcc4d 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -4,7 +4,7 @@ import { assemble } from '../assemble' import type { Assembled } from '../assemble' import { CATS, partsOf } from '../categories' import { React } from '../react' -import type { ConversationNodeLike, UseSessionLike } from '../services' +import type { ContentFetcher, ConversationNodeLike, UseSessionLike } from '../services' import type { ViewKit } from '../viewkit' import { blockSummaryOf, callSummaryOf, parseCallArgs } from '../callSummary' import { makeNodeText } from './nodes' @@ -19,8 +19,12 @@ export interface ContextBrowserProps { data: ContextTimeline headers: ContextHeaders | null useSession?: UseSessionLike - /** History-pagination verb contributed via `sessions.provide` (absent on older hosts). */ - loadOlderHistory?: () => Promise + /** + * Targeted full-content fetch for nodes outside the conversation window: + * one seq-anchored history read per expanded row (absent on older hosts — + * those keep the preview-plus-hint degradation). + */ + fetchContent?: ContentFetcher /** Preview-seq: hover transiently previews that step; the picker's own selection resumes when the pointer leaves the chart. */ previewSeq?: number | null /** Pin-seq: a pin selects that step; pinSeq null returns the browser to the live surface. */ @@ -428,7 +432,7 @@ function CallArgRow(props: { name: string; value: unknown }): ReactNS.ReactEleme function NodeContent(props: { node: SurfaceNode conv: ConversationNodeLike | undefined - hint: string + hint: ReactNS.ReactNode rich: RichKit img: ImageKit labels: DetailLabels @@ -436,7 +440,7 @@ function NodeContent(props: { const { node, conv, rich, img, labels } = props if (conv === undefined) { // The join missed (node outside the loaded window): the 80-char preview - // still shows as a plain content section, with the window note below. + // still shows as a plain content section, with the fetch-state note below. if (node.text === undefined || node.text === '') { return
{props.hint}
} @@ -508,10 +512,6 @@ export function makeContextBrowser( // descriptions, system text, and message bodies stay in sync. const lineLabel = (n: number): string => t(n === 1 ? 'block.line' : 'block.lines', { n }) - // Auto-load ceiling: a guard against seqs that never land in the conversation snapshot (pages pull until the seq joins, history runs out, - // or the cap is hit). - const MAX_AUTO_PAGES = 20 - return function ContextBrowser(props: ContextBrowserProps): ReactNS.ReactElement { const { data, headers } = props // 'live' = the current surface (the NEXT request's context); number = a retained step's seq. @@ -519,52 +519,52 @@ export function makeContextBrowser( const [openCat, setOpenCat] = React.useState(null) const [openElem, setOpenElem] = React.useState(null) - // Full message content joined from the conversation snapshot by surface seq (`s.nodes` is a stable reference per snapshot; the bySeq - // map memoizes over it). + // Full message content: the conversation-window join first (zero cost), + // plus nodes fetched on demand for seqs outside the window (`s.nodes` is + // a stable reference per snapshot; the map memoizes over it). const convNodes = typeof props.useSession === 'function' ? props.useSession(s => s.nodes) : undefined + const [fetched, setFetched] = React.useState>(() => new Map()) const bySeq = React.useMemo(() => { const m = new Map() for (const n of convNodes ?? []) m.set(n.seq, n) + for (const [seq, n] of fetched) if (!m.has(seq)) m.set(seq, n) return m - }, [convNodes]) - - // On-demand pagination flags as primitive selectors, so the component re-renders only when a page actually lands or runs out. - const hasMore = typeof props.useSession === 'function' - ? props.useSession(s => s.hasMore === true) - : false - const loadingOlder = typeof props.useSession === 'function' - ? props.useSession(s => s.loadingOlder === true) - : false + }, [convNodes, fetched]) // The open element's surface-node seq ('sys'/'tool:*' keys never join). const openSeq = openElem !== null && openElem.startsWith('n') ? Number(openElem.slice(1)) : null + // Fetch-on-miss state machine: one targeted history read per expanded row + // whose seq the join missed. `failed` arms the retry button; `absent` + // means the page came back without the seq — it is not in the durable log. const missingSeq = openSeq !== null && !bySeq.has(openSeq) ? openSeq : null - // Auto-load pages older history until the open seq joins (one page in flight, sequenced by loadingOlder); `exhausted` latches - // cap/history-end so the hint falls back to the static note instead of 'loading' forever. - const [exhausted, setExhausted] = React.useState(false) - const pagesRef = React.useRef(0) + const fetchContent = props.fetchContent + const [missState, setMissState] = React.useState<'idle' | 'loading' | 'absent' | 'failed'>('idle') + const [retry, setRetry] = React.useState(0) React.useEffect(() => { - pagesRef.current = 0 - setExhausted(false) - }, [openElem]) - const loadOlderHistory = props.loadOlderHistory - React.useEffect(() => { - if (missingSeq === null || !hasMore || loadingOlder || exhausted) return - if (loadOlderHistory === undefined) return - if (pagesRef.current >= MAX_AUTO_PAGES) { - setExhausted(true) - return - } - pagesRef.current += 1 - void loadOlderHistory() - }, [missingSeq, hasMore, loadingOlder, exhausted, bySeq, loadOlderHistory]) - React.useEffect(() => { - if (!hasMore && missingSeq !== null && !exhausted) setExhausted(true) - }, [hasMore, missingSeq, exhausted]) + if (missingSeq === null || fetchContent === undefined) return + let live = true + setMissState('loading') + fetchContent(missingSeq).then((node) => { + if (!live) return + if (node === null) { + setMissState('absent') + return + } + setFetched((prev) => { + const next = new Map(prev) + next.set(missingSeq, node) + return next + }) + setMissState('idle') + }, () => { + if (live) setMissState('failed') + }) + return () => { live = false } + }, [missingSeq, fetchContent, retry]) // Pin linkage: a pinned bar selects its step (same accordion reset as a manual pick); unpin returns to live — a manual pick here is // overridden only when a NEW pin lands. const pinSeq = props.pinSeq @@ -601,7 +601,21 @@ export function makeContextBrowser( focusScrollRef.current = false rootRef.current?.querySelector('.lc-br-elem-on')?.scrollIntoView({ block: 'nearest' }) }) - const awaiting = missingSeq !== null && !exhausted && loadOlderHistory !== undefined && hasMore + // The note an un-joined open row shows, per fetch state: legacy static hint + // (no fetcher), in-flight loading, log-absent, or a failed read with retry. + const missNote: ReactNS.ReactNode = fetchContent === undefined + ? t('browser.noContent') + : missState === 'loading' + ? t('browser.loading') + : missState === 'absent' + ? t('browser.notInLog') + : missState === 'failed' + ? ( + + ) + : t('browser.noContent') const requests = data.requests const hoverReq = props.previewSeq !== null && props.previewSeq !== undefined @@ -771,10 +785,9 @@ export function makeContextBrowser( ), }} - // Only the open row's body renders, so `awaiting` (open seq missing, pagination armed) is exactly THIS join being pulled for. - hint={conv === undefined && awaiting - ? t('browser.loading') - : t('browser.noContent')} + // Only the open row's body renders, so the miss note is exactly THIS join's fetch state; a joined-but-empty row keeps the + // static hint. + hint={conv === undefined ? missNote : t('browser.noContent')} />, rowErr) }) diff --git a/src/client/components/contextModal.tsx b/src/client/components/contextModal.tsx index 299ab9d..34363c2 100644 --- a/src/client/components/contextModal.tsx +++ b/src/client/components/contextModal.tsx @@ -9,6 +9,7 @@ import { headlineOf } from '../headline' import { modalStoreOf, takePendingConsume } from '../modalStore' import type { ClientCtx, SessionStandardProps, SessionsFace } from '../services' import { contextBreakdownOf, contextPressureOf, headersOf, timelineOf } from '../services' +import { makeContentFetcher } from '../historyPage' import type { ViewKit } from '../viewkit' import { makeContextBrowser } from './browser' import { makeCurrentComposition } from './currentComposition' @@ -48,6 +49,11 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM : null const [hoverCat, setHoverCat] = React.useState(null) const [toolFocus, setToolFocus] = React.useState<{ tool?: string } | null>(null) + // Same targeted content fetch the Context tab wires (one seq-anchored history read per expanded row). + const fetchContent = React.useMemo( + () => (sessionId !== '' ? makeContentFetcher(ctx, sessionId) : undefined), + [ctx, sessionId], + ) const close = React.useCallback(() => { if (sessionId === '') return @@ -105,7 +111,7 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM data={data} headers={headers} useSession={props.useSession} - loadOlderHistory={props.loadOlderHistory} + fetchContent={fetchContent} hoverKey={hoverCat} onHoverKey={setHoverCat} toolFocus={toolFocus} diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index 3f74933..15b2469 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -10,6 +10,7 @@ import { headlineOf } from '../headline' import type { SessionStandardProps } from '../services' import { contextBreakdownOf, contextPressureOf, headersOf, timelineOf, tokenUsageOf } from '../services' import type { ClientCtx, ConversationFace, ConversationNodeLike, ImageRefLike } from '../services' +import { makeContentFetcher } from '../historyPage' import type { ContextSettings } from '../settings' import type { ViewKit } from '../viewkit' import { makeContextBrowser } from './browser' @@ -113,6 +114,13 @@ export function makeContextView( return (attachment: ImageRefLike) => conversation.resolveImage(sessionId, attachment) }, [sessionId]) + // Targeted full-content fetch for browser nodes outside the conversation window (one seq-anchored history read per expanded row); + // absent face/session degrades to the static hint — never an error. + const fetchContent = React.useMemo( + () => (typeof sessionId === 'string' && sessionId !== '' ? makeContentFetcher(ctx, sessionId) : undefined), + [ctx, sessionId], + ) + const rootRef = React.useRef(null) const scrollerRef = React.useRef(null) // The session whose position was already applied this mount — re-applying on re-renders would yank the reader's scroll. @@ -329,7 +337,7 @@ export function makeContextView( data={data} headers={headers} useSession={props.useSession} - loadOlderHistory={props.loadOlderHistory} + fetchContent={fetchContent} previewSeq={hoveredSeq} pinSeq={pinnedReq !== null ? pinnedReq.seq : null} hoverKey={hoverCat} diff --git a/src/client/historyPage.ts b/src/client/historyPage.ts new file mode 100644 index 0000000..7ff9a22 --- /dev/null +++ b/src/client/historyPage.ts @@ -0,0 +1,171 @@ +/** + * Targeted full-content fetch for the Context browser — the fallback that + * replaces blind tail paging. When a surface node's seq is outside the + * conversation window, ONE seq-anchored history RPC + * (`sessions.history({ beforeSeq: seq + 1 })`) returns the page containing + * that event: the host cuts pages on whole append-origin message boundaries, + * so the newest group on the page covers `seq` whenever the durable log + * still holds it. The raw events map into the same conversation-node shapes + * the window join delivers (a thin display subset of dsh's own fold), and + * fetched nodes cache per session — history is immutable, so a seq never + * needs fetching twice. + */ + +import type { ClientCtx, ConnectionFace, ContentFetcher, ConversationNodeLike, HistoryEntryLike } from './services' + +/** Narrow one served row to a validated durable event envelope, or null. */ +function eventOf(entry: unknown): { type: string; seq: number; data: Record } | null { + if (entry === null || typeof entry !== 'object') return null + const row = entry as HistoryEntryLike + const ev = row.event + if (ev === null || typeof ev !== 'object') return null + const e = ev as { type?: unknown; seq?: unknown; data?: unknown } + if (typeof e.type !== 'string' || typeof e.seq !== 'number' || !Number.isFinite(e.seq)) return null + const data = e.data !== null && typeof e.data === 'object' ? e.data as Record : {} + return { type: e.type, seq: e.seq, data } +} + +/** All string texts of an event's message-content shape joined (compaction summaries). */ +function textOf(blocks: unknown): string | null { + if (!Array.isArray(blocks)) return null + let out = '' + for (const b of blocks) { + const text = b !== null && typeof b === 'object' ? (b as { type?: unknown; text?: unknown }).text : undefined + if (typeof text === 'string') out += text + } + return out.trim() === '' ? null : out +} + +/** + * One assistant content block → the snapshot block vocabulary the browser + * already renders (`kind`: text/reasoning/image/tool-call); unmappable + * blocks pass through raw and degrade to the generic JSON section. + */ +function assistantBlockOf(block: unknown): Record { + const b = block !== null && typeof block === 'object' ? block as { type?: unknown; text?: unknown; attachment?: unknown; name?: unknown; arguments?: unknown } : null + switch (b?.type) { + case 'text': case 'reasoning': + return { kind: b.type, ...(typeof b.text === 'string' ? { text: b.text } : {}) } + case 'image': + return { kind: 'image', ...(b.attachment !== undefined ? { attachment: b.attachment } : {}) } + case 'tool-call': + return { + kind: 'tool-call', + name: typeof b.name === 'string' ? b.name : '?', + argsRaw: b.arguments, + } + default: + return block !== null && typeof block === 'object' ? block as Record : { value: block } + } +} + +/** + * Map one history page into joined conversation nodes keyed by their event + * seq — the display subset of the browser's join: user messages, assistant + * blocks, tool results paired with their in-page call head, and compaction + * checkpoints paired with their summary event. Everything else (headers, + * boundaries, chunks, bare calls) projects to nothing. + */ +export function pageNodesOf(entries: readonly unknown[]): Map { + const nodes = new Map() + const calls = new Map() + const summaries = new Map() + for (const entry of entries) { + const ev = eventOf(entry) + if (ev === null) continue + const { type, seq, data } = ev + if (type === 'tool/call') { + if (typeof data.callId === 'string') { + calls.set(data.callId, { + name: typeof data.name === 'string' ? data.name : '?', + // The durable envelope stores arguments as a raw JSON string. + argsRaw: typeof data.arguments === 'string' ? data.arguments : '', + }) + } + continue + } + if (type === 'compaction/summary') { + if (typeof data.compactionId === 'string') { + const summary = textOf(data.summary) + if (summary !== null) summaries.set(data.compactionId, summary) + } + continue + } + if (type === 'user/message') { + const source = data.source !== null && typeof data.source === 'object' ? data.source as Record : null + const compactionId = source !== null + && source.kind === 'plugin' && typeof source.compactionId === 'string' + ? source.compactionId + : null + if (compactionId !== null) { + // A compaction checkpoint: the model-visible envelope never renders — + // the marker shows its summary instead (null when the page cut left + // the summary event outside). + nodes.set(seq, { kind: 'compaction', seq, summary: summaries.get(compactionId) ?? null }) + continue + } + nodes.set(seq, { + kind: 'user', + seq, + content: Array.isArray(data.content) ? data.content as readonly unknown[] : [], + }) + continue + } + if (type === 'assistant/message') { + const message = data.message !== null && typeof data.message === 'object' ? data.message as Record : null + const content = message !== null && Array.isArray(message.content) ? message.content : [] + nodes.set(seq, { + kind: 'assistant', + seq, + blocks: (content as unknown[]).map(assistantBlockOf), + }) + continue + } + if (type === 'tool/result') { + const message = data.message !== null && typeof data.message === 'object' ? data.message as Record : null + const source = message?.source !== null && typeof message?.source === 'object' ? message.source as Record : null + const first = Array.isArray(message?.content) ? (message.content as unknown[])[0] : undefined + const block = first !== null && typeof first === 'object' ? first as Record : null + const callId = typeof source?.callId === 'string' ? source.callId + : typeof block?.toolCallId === 'string' ? block.toolCallId + : null + const call = callId !== null ? calls.get(callId) ?? null : null + nodes.set(seq, { + kind: 'tool-result', + seq, + call, + content: block !== null && Array.isArray(block.content) ? block.content as readonly unknown[] : [], + isError: block?.isError === true || data.error === true, + }) + continue + } + } + return nodes +} + +/** + * Build the browser's per-session fetcher over the shared api client. + * Undefined when the connection face or the history verb is absent (older + * hosts) — the caller keeps its static preview-plus-hint degradation. Found + * nodes cache in the closure: one mount re-reading a row never re-fetches. + */ +export function makeContentFetcher(ctx: ClientCtx, sessionId: string): ContentFetcher | undefined { + const sessions = (ctx.get('connection') as ConnectionFace | undefined)?.api?.sessions + if (typeof sessions?.history !== 'function') return undefined + const cache = new Map() + return async (seq) => { + const hit = cache.get(seq) + if (hit !== undefined) return hit + const response = await sessions.history({ sessionId, beforeSeq: seq + 1 }) + const result = response.result + // The whole envelope is re-proven (no-white-screen guarantee): a failed or + // malformed read rejects so the browser offers a retry instead of guessing. + if (result === null || typeof result !== 'object' || result.ok !== true || result.value === null || typeof result.value !== 'object') { + throw new Error('history rpc failed') + } + const events = result.value.events + const node = pageNodesOf(Array.isArray(events) ? events : []).get(seq) ?? null + if (node !== null) cache.set(seq, node) + return node + } +} diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 24566e2..79822b3 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -105,6 +105,8 @@ export const DICT_ZH: Record = { 'browser.noEpoch': '该步骤的头部内容(系统提示词 / 工具定义)不在保留范围内', 'browser.noContent': '完整内容不在当前加载的消息窗口内(在聊天页加载更早历史后可查看)', 'browser.loading': '正在从更早的会话历史加载完整内容…', + 'browser.notInLog': '会话日志中未找到该条目的完整内容(可能已被压缩或清理)', + 'browser.loadFailed': '内容加载失败,点击重试', 'browser.preview': '预览', 'tool.desc': '描述', 'tool.params': '参数', @@ -237,6 +239,8 @@ export const DICT_EN: Record = { 'browser.noEpoch': 'The header content (System Prompt / Tool Schemas) of this step is outside retention', 'browser.noContent': 'Full content is outside the loaded message window (load older history in Chat to view)', 'browser.loading': 'Loading full content from older session history…', + 'browser.notInLog': 'This item is not in the session log anymore (it may have been compacted away or cleared)', + 'browser.loadFailed': 'Load failed — click to retry', 'browser.preview': 'Preview', 'tool.desc': 'Description', 'tool.params': 'Parameters', diff --git a/src/client/index.ts b/src/client/index.ts index 1d43976..a5df40f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -26,7 +26,7 @@ import { registerContextCommand } from './command' import { makeContextModal } from './components/contextModal' import { makeSettingsCard } from './components/settingsCard' import { modalStoreOf } from './modalStore' -import type { ClientCtx, SessionsFace } from './services' +import type { ClientCtx } from './services' import { createContextSettings, type SettingsField, type SettingsScopeBinderFace } from './settings' import { makeContextView } from './components/contextView' import { makeViewKit } from './viewkit' @@ -54,25 +54,6 @@ function apply(ctx: ClientCtx): void { const settings = createContextSettings() const ContextView = makeContextView(ctx, kit, settings) - // Contribute the history-pagination verb as a standard prop, so the - // Context browser can pull older conversation pages on demand when the - // reader expands an element outside the loaded window. Fail-soft: hosts - // without the provide channel (or a duplicate-name refusal) keep the - // preview-plus-hint degradation. - ctx.effect(() => { - const noop = (): void => {} - const sessions = ctx.get('sessions') as SessionsFace | undefined - if (sessions === undefined || typeof sessions.provide !== 'function') return noop - try { - return sessions.provide({ - props: ['loadOlderHistory'], - resolve: binding => ({ props: { loadOlderHistory: () => binding.session.loadOlder() } }), - }) - } catch { - return noop - } - }, 'dsh-context: loadOlderHistory prop') - ctx.slots.inject('conversation.view', () => { return ctx.slots.register( // order 20 renders right of Chat (0) and Trajectory (10); the locale diff --git a/src/client/services.ts b/src/client/services.ts index 8825e5b..1734c9a 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -89,10 +89,6 @@ export interface ConversationFace { export type UseSessionLike = ( selector: (snapshot: { nodes?: readonly ConversationNodeLike[] - /** whether older history remains outside the loaded window */ - hasMore?: boolean - /** whether an older-history page is currently being pulled */ - loadingOlder?: boolean }) => T, ) => T @@ -106,13 +102,6 @@ export interface SessionStandardProps { sessionId?: string useProjection?: (key: string) => unknown useSession?: UseSessionLike - /** - * History-pagination verb this plugin contributes through the harness's - * `sessions.provide` channel (one call prepends one older page to the - * conversation window). Absent on older hosts without the channel — the - * Context browser then keeps its preview-plus-hint degradation. - */ - loadOlderHistory?: () => Promise } export type ClientCtx = Context & { @@ -322,26 +311,47 @@ export interface SessionScopeFace { bail(subject: unknown, event: string, payload: unknown): unknown } -/** - * One standard-props contribution to the harness's `sessions.provide` - * channel: declared members are resolved per session and delivered to every - * session-scope slot component (hooks become `use` selector hooks, - * props spread verbatim). Minimally typed against the runtime contract. - */ -export interface SessionProvideDescriptorLike { - hooks?: readonly string[] - props?: readonly string[] - resolve(binding: { session: { loadOlder(): Promise } }): { - hooks?: Record - props?: Record - } -} - export interface SessionsFace { scope(id: string): SessionScopeFace | undefined - /** - * The standard-props provide channel (absent on older hosts). Throws on a - * misdeclared or duplicate contribution — callers fail soft. - */ - provide?(descriptor: SessionProvideDescriptorLike): () => void } + +/** + * One raw durable-log event as the history RPC serves it (the wire envelope + * the Host fold consumes, minimally re-typed): every field re-proved by the + * mapper before use (the no-white-screen guarantee). + */ +export interface HistoryEventLike { + type?: unknown + seq?: unknown + data?: unknown +} + +/** One history page row: the raw event plus the optional host-computed view. */ +export interface HistoryEntryLike { + event?: unknown +} + +/** + * The sessions domain of the shared api client (`connection.api.sessions`), + * narrowed to the one verb the targeted content fetch rides on: a + * seq-anchored history page whose boundaries align to whole append-origin + * messages, so `beforeSeq: seq + 1` always covers that seq when the log + * still holds it. Every response field is re-proven at runtime. + */ +export interface SessionsHistoryFace { + history(request: { sessionId: string; beforeSeq: number }): Promise<{ + result?: { ok?: unknown; value?: { events?: unknown } | null } | null + }> +} + +/** The connection service face, as far as this plugin consumes it. */ +export interface ConnectionFace { + api?: { sessions?: SessionsHistoryFace } +} + +/** + * On-demand full content for one surface-node seq: resolves the joined + * conversation node, `null` when the durable log does not hold the seq, + * rejects on transport/RPC failure (the caller distinguishes the three). + */ +export type ContentFetcher = (seq: number) => Promise diff --git a/src/client/styles.css b/src/client/styles.css index 859396d..12dab41 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -254,6 +254,8 @@ button.lc-brief-row-link { cursor: pointer; } .lc-br-meta b { color: var(--dsw-alias-label-primary); } .lc-br-meta .lc-actual { color: var(--dsw-alias-state-success-primary); } .lc-br-note { margin-top: 8px; color: var(--dsw-alias-label-secondary); font-size: 12px; } +.lc-br-retry { border: 0; background: transparent; padding: 0; color: var(--dsw-alias-brand-primary); font: inherit; font-size: 12px; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } +.lc-br-retry:hover { filter: brightness(1.15); } .lc-br-cats { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; } .lc-br-cat { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-layer-2); overflow: hidden; } .lc-br-cat-empty { opacity: 0.55; } diff --git a/tests/client/components/browser.spec.ts b/tests/client/components/browser.spec.ts index 73ec327..06ad92a 100644 --- a/tests/client/components/browser.spec.ts +++ b/tests/client/components/browser.spec.ts @@ -1,7 +1,7 @@ // ContextBrowser (src/client/components/browser.tsx) rendered with real // React in jsdom: picker, category accordion, header content (schema // narrowing matrix), conversation join (block cascade, tail-status matrix), -// history auto-pagination, hover linkage, and the focus bridges. +// targeted content fetch, hover linkage, and the focus bridges. import assert from 'node:assert/strict' import { act } from 'react' @@ -41,13 +41,13 @@ function node(over: Partial & { seq: number }): SurfaceNode { } /** A REAL tiny selector over an in-memory snapshot — the documented UseSessionLike contract. */ -function sess(snap: { nodes?: readonly ConversationNodeLike[]; hasMore?: boolean; loadingOlder?: boolean }): UseSessionLike { +function sess(snap: { nodes?: readonly ConversationNodeLike[] }): UseSessionLike { return (sel) => sel(snap) } -interface Snap { nodes?: readonly ConversationNodeLike[]; hasMore?: boolean; loadingOlder?: boolean } +interface Snap { nodes?: readonly ConversationNodeLike[] } -/** Same contract, but re-reads the snapshot on every render (pagination tests mutate it). */ +/** Same contract, but re-reads the snapshot on every render (join tests mutate it). */ function liveSess(get: () => Snap): UseSessionLike { return (sel) => sel(get()) } @@ -891,7 +891,7 @@ describe('ContextBrowser tool results', () => { }) }) -describe('ContextBrowser history auto-load', () => { +describe('ContextBrowser targeted content fetch', () => { const data = tl({ current: { system: 0, tools: 0, user: 10, inject: 0, assistant: 0, tool: 0, total: 10 }, nodes: [node({ seq: 5, tokens: 5, text: 'pageable' }), node({ seq: 6, tokens: 5, text: 'also pageable' })], @@ -902,91 +902,112 @@ describe('ContextBrowser history auto-load', () => { await click(elemRows(m)[1]) // seq 5 (newest first: 6, 5) } - test('missing join pulls one older page, then the joined content renders', async () => { - let snap: Snap = { nodes: [], hasMore: true, loadingOlder: false } + test('a missed join fetches the seq once, and the fetched body renders', async () => { let calls = 0 - const loadOlderHistory = async (): Promise => { calls += 1 } - const el = () => h(Browser, props({ - data, useSession: liveSess(() => snap), loadOlderHistory, - })) - const m = await mount(el()) - await openFirstRow(m) - assert.equal(calls, 1, 'one page requested') - assert.ok(text(query(m.container, '.lc-br-content')).includes('Loading full content from older session history')) - // The page lands: the seq joins the window. - snap = { nodes: [{ kind: 'user', seq: 5, content: [{ type: 'text', text: 'FULL BODY' }] }], hasMore: false, loadingOlder: false } - await m.update(el()) - assert.ok(text(query(m.container, '.lc-br-content')).includes('FULL BODY')) - assert.equal(calls, 1, 'no further pages once the join lands') - await m.unmount() - }) - - test('a page already in flight does not double-fire', async () => { - const snap: Snap = { nodes: [], hasMore: true, loadingOlder: true } - let calls = 0 - const m = await mount(h(Browser, props({ - data, - useSession: liveSess(() => snap), - loadOlderHistory: async () => { calls += 1 }, - }))) - await openFirstRow(m) - assert.equal(calls, 0) - assert.ok(text(query(m.container, '.lc-br-content')).includes('Loading full content')) - await m.unmount() - }) - - test('history end latches the static note; absent loader degrades the same way', async () => { - const snap: Snap = { nodes: [], hasMore: false, loadingOlder: false } - let calls = 0 - const m = await mount(h(Browser, props({ - data, - useSession: liveSess(() => snap), - loadOlderHistory: async () => { calls += 1 }, - }))) - await openFirstRow(m) - assert.equal(calls, 0, 'no pages left to pull') - assert.ok(text(query(m.container, '.lc-br-content')).includes('outside the loaded message window')) - await m.unmount() - - // Older host without the pagination verb. - const snap2: Snap = { nodes: [], hasMore: true, loadingOlder: false } - const m2 = await mount(h(Browser, props({ - data, - useSession: liveSess(() => snap2), - }))) - await openFirstRow(m2) - assert.ok(text(query(m2.container, '.lc-br-content')).includes('outside the loaded message window')) - await m2.unmount() - }) - - test('the auto-load ceiling stops paging and opening another element re-arms', async () => { - const snap: Snap = { nodes: [], hasMore: true, loadingOlder: false } - let calls = 0 - const loadOlderHistory = async (): Promise => { calls += 1 } - const el = () => h(Browser, props({ - data, useSession: liveSess(() => snap), loadOlderHistory, - })) - const m = await mount(el()) - await openFirstRow(m) - assert.equal(calls, 1) - // Each landed page (loadingOlder true→false) without the seq re-arms the pull. - for (let i = 0; i < 19; i++) { - snap.loadingOlder = true - await m.update(el()) - snap.loadingOlder = false - await m.update(el()) + let release: (() => void) | null = null + const fetchContent = async (seq: number) => { + calls += 1 + return new Promise((resolve) => { release = resolve }) + .then(() => ({ kind: 'user', seq, content: [{ type: 'text', text: 'FULL BODY' }] }) as ConversationNodeLike) } - assert.equal(calls, 20, 'twenty pages pulled') - // The next pull hits the cap: exhausted latches, the note falls back. - snap.loadingOlder = true - await m.update(el()) - snap.loadingOlder = false - await m.update(el()) - assert.equal(calls, 20, 'the ceiling stops auto-paging') + const m = await mount(h(Browser, props({ data, useSession: sess({ nodes: [] }), fetchContent }))) + await openFirstRow(m) + assert.equal(calls, 1, 'one targeted read') + assert.ok(text(query(m.container, '.lc-br-content')).includes('Loading full content from older session history'), 'in-flight note') + await act(async () => { release?.() }) + await flush() + assert.ok(text(query(m.container, '.lc-br-content')).includes('FULL BODY')) + // Close and reopen the same row: the fetched node is merged state — no re-read. + await click(elemRows(m)[1]) + await click(elemRows(m)[1]) + assert.ok(text(query(m.container, '.lc-br-content')).includes('FULL BODY')) + assert.equal(calls, 1, 'no second fetch for a merged seq') + await m.unmount() + }) + + test('a page without the seq reports it as absent from the session log', async () => { + let calls = 0 + const fetchContent = async () => { + calls += 1 + return null + } + const m = await mount(h(Browser, props({ data, useSession: sess({ nodes: [] }), fetchContent }))) + await openFirstRow(m) + await flush() + assert.equal(calls, 1) + assert.ok(text(query(m.container, '.lc-br-content')).includes('not in the session log anymore')) + await m.unmount() + }) + + test('a failed read arms the retry button; retrying succeeds', async () => { + let calls = 0 + const fetchContent = async (seq: number) => { + calls += 1 + if (calls === 1) throw new Error('transport down') + return { kind: 'user', seq, content: [{ type: 'text', text: 'RETRY BODY' }] } as ConversationNodeLike + } + const m = await mount(h(Browser, props({ data, useSession: sess({ nodes: [] }), fetchContent }))) + await openFirstRow(m) + await flush() + const failed = query(m.container, '.lc-br-content') + assert.ok(text(failed).includes('Load failed')) + await click(query(failed, '.lc-br-retry')) + await flush() + assert.ok(text(query(m.container, '.lc-br-content')).includes('RETRY BODY')) + assert.equal(calls, 2) + await m.unmount() + }) + + test('without a fetcher (older host) an un-joined row keeps the static note', async () => { + const m = await mount(h(Browser, props({ data, useSession: sess({ nodes: [] }) }))) + await openFirstRow(m) + await flush() assert.ok(text(query(m.container, '.lc-br-content')).includes('outside the loaded message window')) - // Opening another element resets the budget. - await click(elemRows(m)[0]) // seq 6 - assert.equal(calls, 21, 'a new element re-arms the auto-load') + await m.unmount() + }) + + test('a late fetch for an abandoned row never lands; the next row fetches its own seq', async () => { + const deferreds: { resolve: (node: ConversationNodeLike | null) => void; reject: (reason: Error) => void }[] = [] + const fetchedFor: number[] = [] + const fetchContent = (seq: number): Promise => { + fetchedFor.push(seq) + if (seq === 5) return new Promise((resolve, reject) => { deferreds.push({ resolve, reject }) }) + return Promise.resolve({ kind: 'user', seq, content: [{ type: 'text', text: 'BODY ' + String(seq) }] } as ConversationNodeLike) + } + let snap: Snap = { nodes: [] } + const el = () => h(Browser, props({ data, useSession: liveSess(() => snap), fetchContent })) + const m = await mount(el()) + await openFirstRow(m) // seq 5 hangs in flight + assert.equal(deferreds.length, 1) + await click(elemRows(m)[0]) // switch to seq 6 while seq 5 is pending + await flush() + assert.ok(text(query(m.container, '.lc-br-content')).includes('BODY 6')) + // The abandoned promise settles after the row was left — both outcomes are ignored. + await act(async () => { + deferreds[0]?.resolve({ kind: 'user', seq: 5, content: [{ type: 'text', text: 'STALE BODY' }] }) + }) + assert.ok(!text(m.container).includes('STALE BODY'), 'stale result ignored') + await click(elemRows(m)[1]) // seq 5 again: another deferred hangs + await click(elemRows(m)[0]) + await flush() + await act(async () => { + deferreds[1]?.reject(new Error('gone')) + }) + await flush() + assert.ok(text(query(m.container, '.lc-br-content')).includes('BODY 6'), 'stale rejection ignored') + // Reopening seq 5 renders once its own fetch settles. + await click(elemRows(m)[1]) + await act(async () => { + deferreds[2]?.resolve({ kind: 'user', seq: 5, content: [{ type: 'text', text: 'BODY 5' }] }) + }) + await flush() + assert.ok(text(query(m.container, '.lc-br-content')).includes('BODY 5')) + assert.deepEqual(fetchedFor, [5, 6, 5, 5], 'one call per miss (seq 6 stays cached)') + // The conversation window catching up later wins over fetched state. + snap = { nodes: [{ kind: 'user', seq: 5, content: [{ type: 'text', text: 'WINDOW BODY' }] }] } + await m.update(el()) + assert.ok(text(query(m.container, '.lc-br-content')).includes('WINDOW BODY')) + assert.ok(!text(m.container).includes('BODY 5'), 'the join takes precedence over the fetch cache') await m.unmount() }) }) diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts index d29b52a..3532fb2 100644 --- a/tests/client/components/contextView.spec.ts +++ b/tests/client/components/contextView.spec.ts @@ -157,7 +157,7 @@ describe('ContextView — interactions', () => { useProjection: projectionsFor(richTimeline(), { contextHeaders: { headers: [{ seq: 1, time: T0, system: 'SYS', tools: [{ name: 'bash', tokens: 12, description: 'run' }] }] }, }), - useSession: (sel => sel({ nodes: [], hasMore: false, loadingOlder: false })) as UseSessionLike, + useSession: (sel => sel({ nodes: [] })) as UseSessionLike, })) return m } @@ -330,34 +330,54 @@ describe('ContextView — interactions', () => { }) }) -describe('ContextView — history paging and image loading', () => { - test('opening an uncached node pulls older history through loadOlderHistory', async () => { - const View = makeView(new TestClientCtx()) - const loads: number[] = [] +describe('ContextView — targeted content fetch and image loading', () => { + test('opening an un-joined node reads one seq-anchored history page through the connection api', async () => { + const calls: { sessionId: string; beforeSeq: number }[] = [] + const ctx = new TestClientCtx({ + services: { + connection: { + api: { + sessions: { + history: (request: { sessionId: string; beforeSeq: number }) => { + calls.push(request) + return Promise.resolve({ + result: { + ok: true, + value: { + events: [ + { event: { type: 'user/message', seq: request.beforeSeq - 1, data: { content: [{ type: 'text', text: 'OLD FULL BODY' }] } } }, + ], + }, + }, + }) + }, + }, + }, + }, + }, + }) + const View = makeView(ctx) const m = await mount(h(View, { sessionId: 'sv-page', useProjection: projectionsFor(timeline({ nodes: [{ seq: 1, cat: 'user', tokens: 5, text: 'old msg' }] })), - useSession: (sel => sel({ nodes: [], hasMore: true, loadingOlder: false })) as UseSessionLike, - loadOlderHistory: async () => { - loads.push(1) - }, + useSession: (sel => sel({ nodes: [] })) as UseSessionLike, })) const catRow = queryAll(m.container, '.lc-br-cat-row').find(r => text(r).includes(DICT_EN['cat.user'])) assert.ok(catRow !== undefined) await click(catRow) await click(query(m.container, '.lc-br-elem-row')) await flush() - assert.ok(loads.length >= 1) - assert.ok(text(m.container).includes(DICT_EN['browser.loading'])) + assert.deepEqual(calls, [{ sessionId: 'sv-page', beforeSeq: 2 }], 'one read anchored just past the seq') + assert.ok(text(m.container).includes('OLD FULL BODY'), 'mapped page content renders') await m.unmount() }) - test('without loadOlderHistory an uncached node shows the static note', async () => { + test('without a connection face an uncached node shows the static note', async () => { const View = makeView(new TestClientCtx()) const m = await mount(h(View, { sessionId: 'sv-nopage', useProjection: projectionsFor(timeline({ nodes: [{ seq: 1, cat: 'user', tokens: 5, text: 'old msg' }] })), - useSession: (sel => sel({ nodes: [], hasMore: false })) as UseSessionLike, + useSession: (sel => sel({ nodes: [] })) as UseSessionLike, })) const catRow = queryAll(m.container, '.lc-br-cat-row').find(r => text(r).includes(DICT_EN['cat.user'])) assert.ok(catRow !== undefined) @@ -393,8 +413,6 @@ describe('ContextView — history paging and image loading', () => { seq: 1, content: [{ type: 'image', attachment: { attachmentId: 'att-1', name: 'pic.png', bytes: 2048, width: 640, height: 480 } }], }], - hasMore: false, - loadingOlder: false, })) as UseSessionLike, })) const catRow = queryAll(m.container, '.lc-br-cat-row').find(r => text(r).includes(DICT_EN['cat.user'])) diff --git a/tests/client/helpers/harness.ts b/tests/client/helpers/harness.ts index 6492c83..74289be 100644 --- a/tests/client/helpers/harness.ts +++ b/tests/client/helpers/harness.ts @@ -1,11 +1,11 @@ // A faithful in-memory implementation of the harness client context the // plugin's `apply` consumes. Every member implements the DOCUMENTED contract // (cordis effect/inject semantics, locale fallback chain, slot registry, -// sessions provide channel) rather than a test-specific stub — registrations +// sessions scope) rather than a test-specific stub — registrations // and disposals behave the way the real harness's do, so specs assert through // the same seams the production runtime uses. -import type { ClientCtx, LocaleService, SessionProvideDescriptorLike, SlotRegistration, SlotsService } from '../../../src/client/services' +import type { ClientCtx, LocaleService, SlotRegistration, SlotsService } from '../../../src/client/services' import { makeTranslate } from './kit' export interface SlotEntry { @@ -123,19 +123,10 @@ export function asClientCtx(ctx: TestClientCtx): ClientCtx { return ctx as unknown as ClientCtx } -/** A real sessions face with the documented provide/scope contract. */ +/** A real sessions face with the documented scope contract. */ export class TestSessions { - readonly provided: SessionProvideDescriptorLike[] = [] readonly bails: { scope: unknown; event: string; payload: unknown }[] = [] - provide(descriptor: SessionProvideDescriptorLike): () => void { - this.provided.push(descriptor) - return () => { - const i = this.provided.indexOf(descriptor) - if (i >= 0) this.provided.splice(i, 1) - } - } - scope(_id: string): { bail(subject: unknown, event: string, payload: unknown): boolean } | undefined { return { bail: (subject, event, payload) => { diff --git a/tests/client/historyPage.spec.ts b/tests/client/historyPage.spec.ts new file mode 100644 index 0000000..9ae0264 --- /dev/null +++ b/tests/client/historyPage.spec.ts @@ -0,0 +1,218 @@ +// historyPage (src/client/historyPage.ts): the targeted content fetch — raw +// durable-log pages mapped into the browser's conversation-node shapes, plus +// the per-session fetcher built over the shared api client. + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { makeContentFetcher, pageNodesOf } from '../../src/client/historyPage' + +/** One well-formed history row. */ +function row(type: string, seq: number, data: unknown): unknown { + return { event: { type, seq, time: seq, data } } +} + +describe('pageNodesOf — the durable-page mapper', () => { + test('garbage rows and non-message events project to nothing', () => { + const nodes = pageNodesOf([ + null, + 42, + {}, + { event: null }, + { event: { type: 'user/message' } }, // no seq + { event: { type: 'user/message', seq: Number.NaN } }, + row('request/header', 1, {}), + row('turn/end', 2, {}), + row('tool/call', 4, {}), // no callId: nothing recorded + row('compaction/summary', 5, {}), // no compactionId + row('compaction/summary', 6, { compactionId: 'k0', summary: [{ type: 'text', text: ' ' }] }), // blank summary + row('compaction/summary', 7, { compactionId: 'k0b', summary: 'not-an-array' }), // malformed summary + ]) + assert.equal(nodes.size, 0) + // An envelope with no data at all still maps to an empty user node. + assert.deepEqual(pageNodesOf([{ event: { type: 'user/message', seq: 3 } }]).get(3), { + kind: 'user', seq: 3, content: [], + }) + }) + + test('call heads degrade missing names and raw arguments safely', () => { + const page = [ + row('tool/call', 10, { callId: 'c1' }), + row('tool/result', 11, { message: { source: { callId: 'c1' }, content: [] } }), + ] + const nodes = pageNodesOf(page) + assert.deepEqual(nodes.get(11)?.call, { name: '?', argsRaw: '' }) + }) + + test('a tool result without any message shape still joins empty', () => { + const nodes = pageNodesOf([row('tool/result', 12, {})]) + assert.deepEqual(nodes.get(12), { kind: 'tool-result', seq: 12, call: null, content: [], isError: false }) + }) + + test('the call head falls back to the result block\u2019s toolCallId when the source omits it', () => { + const page = [ + row('tool/call', 20, { callId: 'cb', name: 'read', arguments: '{"path":"a.ts"}' }), + row('tool/result', 21, { message: { content: [{ toolCallId: 'cb', content: [] }] } }), + ] + assert.deepEqual(pageNodesOf(page).get(21)?.call, { name: 'read', argsRaw: '{"path":"a.ts"}' }) + }) + + test('user messages map with their durable content blocks', () => { + const nodes = pageNodesOf([row('user/message', 10, { content: [{ type: 'text', text: 'hello' }], source: { form: 'context' } })]) + const n = nodes.get(10) + assert.ok(n !== undefined) + assert.equal(n.kind, 'user') + assert.deepEqual(n.content, [{ type: 'text', text: 'hello' }]) + // Missing/invalid content degrades to an empty array, never undefined. + assert.deepEqual(pageNodesOf([row('user/message', 11, {}) ]).get(11)?.content, []) + }) + + test('assistant messages map to the snapshot block vocabulary', () => { + const nodes = pageNodesOf([row('assistant/message', 20, { + message: { + content: [ + { type: 'text', text: 'reply' }, + { type: 'reasoning', text: 'hmm' }, + { type: 'tool-call', id: 'call-1', name: 'bash', arguments: '{"command":"ls"}' }, + { type: 'image', attachment: { attachmentId: 'a1' } }, + { type: 'mystery', foo: 1 }, + 'junk', + // Degraded shapes: missing facts fall back per field. + { type: 'text' }, + { type: 'image' }, + { type: 'tool-call', arguments: '{}' }, + ], + }, + })]) + const n = nodes.get(20) + assert.equal(n?.kind, 'assistant') + assert.deepEqual(n?.blocks, [ + { kind: 'text', text: 'reply' }, + { kind: 'reasoning', text: 'hmm' }, + { kind: 'tool-call', name: 'bash', argsRaw: '{"command":"ls"}' }, + { kind: 'image', attachment: { attachmentId: 'a1' } }, + { type: 'mystery', foo: 1 }, + { value: 'junk' }, + { kind: 'text' }, + { kind: 'image' }, + { kind: 'tool-call', name: '?', argsRaw: '{}' }, + ]) + }) + + test('an assistant event without a message still joins (empty blocks)', () => { + assert.deepEqual(pageNodesOf([row('assistant/message', 21, {})]).get(21), { kind: 'assistant', seq: 21, blocks: [] }) + }) + + test('tool results pair with their in-page call head; isError rides block or envelope', () => { + const page = [ + row('tool/call', 30, { callId: 'c9', name: 'bash', arguments: '{"command":"pwd"}' }), + row('tool/result', 31, { + callId: 'c9', + error: true, + message: { source: { callId: 'c9' }, content: [{ toolCallId: 'c9', content: [{ type: 'text', text: 'out' }] }] }, + }), + row('tool/result', 32, { + message: { source: { callId: 'missing' }, content: [{ isError: true, content: [] }] }, + }), + row('tool/result', 33, { message: {} }), + ] + const nodes = pageNodesOf(page) + const paired = nodes.get(31) + assert.deepEqual(paired?.call, { name: 'bash', argsRaw: '{"command":"pwd"}' }) + assert.deepEqual(paired?.content, [{ type: 'text', text: 'out' }]) + assert.equal(paired?.isError, true, 'envelope error flag') + assert.equal(nodes.get(32)?.isError, true, 'block error flag without a pairable call') + assert.equal(nodes.get(32)?.call, null) + assert.deepEqual(nodes.get(33)?.content, []) + assert.equal(nodes.get(33)?.isError, false) + }) + + test('a compaction checkpoint renders as a compaction marker with its summary text', () => { + const page = [ + row('compaction/summary', 40, { + compactionId: 'k1', + // Mixed block shapes: only string texts join the summary. + summary: [{ type: 'other' }, null, { type: 'text', text: 'the' }, { type: 'text', text: ' gist' }, 42], + }), + row('user/message', 41, { source: { kind: 'plugin', plugin: 'dsh-compaction', compactionId: 'k1' }, content: [{ type: 'text', text: '' }] }), + row('user/message', 42, { source: { kind: 'plugin', plugin: 'dsh-compaction', compactionId: 'gone' }, content: [] }), + ] + const nodes = pageNodesOf(page) + assert.deepEqual(nodes.get(41), { kind: 'compaction', seq: 41, summary: 'the gist' }) + assert.deepEqual(nodes.get(42), { kind: 'compaction', seq: 42, summary: null }, 'summary outside the page degrades to null') + // An ordinary plugin injection is NOT a checkpoint — it stays a user node. + const plain = pageNodesOf([row('user/message', 43, { source: { kind: 'plugin', plugin: 'other' }, content: [] })]) + assert.equal(plain.get(43)?.kind, 'user') + // A scalar data envelope degrades to an empty record (no source to read). + assert.deepEqual( + pageNodesOf([{ event: { type: 'user/message', seq: 44, data: 'scalar' } }]).get(44), + { kind: 'user', seq: 44, content: [] }, + ) + }) +}) + +describe('makeContentFetcher — the per-session targeted read', () => { + /** A minimal cordis-like ctx over one service map. */ + function ctxWith(services: Record): Parameters[0] { + return { get: (key: string) => services[key] } as Parameters[0] + } + + const apiFace = (events: unknown[], calls: { beforeSeq: number }[] = []) => ({ + sessions: { + history: (request: { sessionId: string; beforeSeq: number }) => { + calls.push({ beforeSeq: request.beforeSeq }) + return Promise.resolve({ result: { ok: true, value: { events } } }) + }, + }, + }) + + test('undefined without the connection face or the history verb (older hosts)', () => { + assert.equal(makeContentFetcher(ctxWith({}), 's'), undefined) + assert.equal(makeContentFetcher(ctxWith({ connection: {} }), 's'), undefined) + assert.equal(makeContentFetcher(ctxWith({ connection: { api: { sessions: {} } } }), 's'), undefined) + }) + + test('fetches the page anchored past the seq, maps it, and caches the hit', async () => { + const calls: { beforeSeq: number }[] = [] + const fetch = makeContentFetcher(ctxWith({ + connection: { api: apiFace([ + row('user/message', 7, { content: [{ type: 'text', text: 'CACHED BODY' }] }), + ], calls) }, + }), 'sess-1')! + assert.deepEqual(calls, []) + const node = await fetch(7) + assert.deepEqual(calls, [{ beforeSeq: 8 }]) + assert.equal((node?.content as { text: string }[])[0]?.text, 'CACHED BODY') + // Second read hits the closure cache — no second RPC. + const again = await fetch(7) + assert.equal(again, node) + assert.deepEqual(calls.length, 1) + }) + + test('a page without the seq resolves null; garbage events arrays too', async () => { + const fetch = makeContentFetcher(ctxWith({ connection: { api: apiFace([row('user/message', 9, {})]) } }), 's')! + assert.equal(await fetch(500), null) + const empty = makeContentFetcher(ctxWith({ + connection: { api: { sessions: { history: async () => ({ result: { ok: true, value: {} } }) } } }, + }), 's')! + assert.equal(await empty(1), null) + }) + + test('malformed rpc envelopes reject so the caller can offer a retry', async () => { + const fetch = makeContentFetcher(ctxWith({ + connection: { api: { sessions: { history: async () => ({ result: { ok: false, value: undefined } }) } } }, + }), 's')! + await assert.rejects(fetch(1)) + const noResult = makeContentFetcher(ctxWith({ + connection: { api: { sessions: { history: async () => ({}) } } }, + }), 's')! + await assert.rejects(noResult(1)) + const scalarValue = makeContentFetcher(ctxWith({ + connection: { api: { sessions: { history: async () => ({ result: { ok: true, value: 'nope' } }) } } }, + }), 's')! + await assert.rejects(scalarValue(1)) + const broken = makeContentFetcher(ctxWith({ + connection: { api: { sessions: { history: async () => { throw new Error('transport down') } } } }, + }), 's')! + await assert.rejects(broken(1), /transport down/) + }) +}) diff --git a/tests/client/index.spec.ts b/tests/client/index.spec.ts index 8e8a026..43ce25d 100644 --- a/tests/client/index.spec.ts +++ b/tests/client/index.spec.ts @@ -1,7 +1,7 @@ // Client entry (src/client/index.ts): the plugin's apply() wiring asserted // through the faithful harness-context seams — dictionaries, slots, the -// sessions provide channel, the /context trigger source, and the deferred -// settingsScope inject — plus real renders of the registered components. +// /context trigger source, and the deferred settingsScope inject — plus real +// renders of the registered components. import assert from 'node:assert/strict' import type { ReactElement } from 'react' @@ -9,7 +9,6 @@ import { describe, test } from 'vitest' import { DICT_EN, DICT_ZH } from '../../src/client/i18n' import { modalStoreOf, type ModalStore } from '../../src/client/modalStore' import { h } from '../../src/client/react' -import type { SessionProvideDescriptorLike } from '../../src/client/services' import type { SettingsField, SettingsScopeLike, SettingsState } from '../../src/client/settings' import { TestClientCtx, TestSessions, asClientCtx } from './helpers/harness' import { click, mount, query, queryAll } from './helpers/kit' @@ -104,50 +103,17 @@ describe('client entry: conversation.view slot', () => { }) }) -describe('client entry: sessions.provide effect', () => { - test('absent sessions service: apply is a noop for the provide channel', () => { +describe('client entry: sessions scope seam', () => { + test('absent sessions service: apply is a noop for the sessions seams', () => { const ctx = new TestClientCtx() applyTo(ctx) ctx.dispose() }) - test('sessions without the provide channel: noop, nothing thrown', () => { - const ctx = new TestClientCtx({ services: { sessions: { scope: () => undefined } } }) - applyTo(ctx) - ctx.dispose() - }) - - test('sessions present: the loadOlderHistory descriptor resolves to session.loadOlder', async () => { + test('sessions present: apply wires through and disposes cleanly', () => { const sessions = new TestSessions() const ctx = new TestClientCtx({ services: { sessions } }) applyTo(ctx) - assert.equal(sessions.provided.length, 1) - const descriptor = sessions.provided[0] - assert.deepEqual(descriptor.props, ['loadOlderHistory']) - let calls = 0 - const resolved = descriptor.resolve({ - session: { - loadOlder: async () => { - calls += 1 - }, - }, - }) - const loadOlderHistory = resolved.props?.loadOlderHistory as () => Promise - await loadOlderHistory() - assert.equal(calls, 1) - ctx.dispose() - assert.equal(sessions.provided.length, 0) - }) - - test('a throwing provide (duplicate refusal) fails soft', () => { - const sessions = { - scope: () => undefined, - provide: (_descriptor: SessionProvideDescriptorLike): never => { - throw new Error('duplicate') - }, - } - const ctx = new TestClientCtx({ services: { sessions } }) - applyTo(ctx) ctx.dispose() }) }) @@ -302,8 +268,7 @@ describe('client entry: settings card slot', () => { }) describe('client entry: dispose', () => { - test('a full apply unwinds dictionaries, the provide descriptor, and the command source', () => { - const sessions = new TestSessions() + test('a full apply unwinds dictionaries and the command source', () => { const sources: unknown[] = [] const inputTriggers = { registerSource(src: unknown): () => void { @@ -314,14 +279,12 @@ describe('client entry: dispose', () => { } }, } - const ctx = new TestClientCtx({ services: { sessions, inputTriggers } }) + const ctx = new TestClientCtx({ services: { inputTriggers } }) applyTo(ctx) assert.ok(ctx.locale.namespaces.has('dsh-context')) - assert.equal(sessions.provided.length, 1) assert.equal(sources.length, 1) ctx.dispose() assert.equal(ctx.locale.namespaces.has('dsh-context'), false) - assert.equal(sessions.provided.length, 0) assert.equal(sources.length, 0) }) })