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.
This commit is contained in:
Bowen Liang committed 2026-08-26 03:34:24 +08:00
1 parent 5a2b742d50
commit 5cc028ff00
13 files changed
+663 -257

No files matched your search

+59 -46
View File
@@ -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<void>
/**
* 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 <div className="lc-br-note">{props.hint}</div>
}
@@ -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<string | null>(null)
const [openElem, setOpenElem] = React.useState<string | null>(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<Map<number, ConversationNodeLike>>(() => new Map())
const bySeq = React.useMemo(() => {
const m = new Map<number, ConversationNodeLike>()
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'
? (
<button type="button" className="lc-br-retry" onClick={() => { setRetry(r => r + 1) }}>
{t('browser.loadFailed')}
</button>
)
: t('browser.noContent')
const requests = data.requests
const hoverReq = props.previewSeq !== null && props.previewSeq !== undefined
@@ -771,10 +785,9 @@ export function makeContextBrowser(
</span>
),
}}
// 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)
})
+7 -1
View File
@@ -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<string | null>(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}
+9 -1
View File
@@ -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<HTMLDivElement | null>(null)
const scrollerRef = React.useRef<HTMLElement | null>(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}
+171
View File
@@ -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<string, unknown> } | 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<string, unknown> : {}
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<string, unknown> {
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<string, unknown> : { 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<number, ConversationNodeLike> {
const nodes = new Map<number, ConversationNodeLike>()
const calls = new Map<string, { name: string; argsRaw: string }>()
const summaries = new Map<string, string>()
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<string, unknown> : 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<string, unknown> : 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<string, unknown> : null
const source = message?.source !== null && typeof message?.source === 'object' ? message.source as Record<string, unknown> : null
const first = Array.isArray(message?.content) ? (message.content as unknown[])[0] : undefined
const block = first !== null && typeof first === 'object' ? first as Record<string, unknown> : 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<number, ConversationNodeLike>()
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
}
}
+4
View File
@@ -105,6 +105,8 @@ export const DICT_ZH: Record<string, string> = {
'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<string, string> = {
'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',
+1 -20
View File
@@ -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
+41 -31
View File
@@ -89,10 +89,6 @@ export interface ConversationFace {
export type UseSessionLike = <T>(
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<void>
}
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<Name>` 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<void> } }): {
hooks?: Record<string, unknown>
props?: Record<string, unknown>
}
}
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<ConversationNodeLike | null>
+2
View File
@@ -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; }
+108 -87
View File
@@ -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<SurfaceNode> & { 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<void> => { 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<void> => { 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<void>((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<ConversationNodeLike | null> => {
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()
})
})
+33 -15
View File
@@ -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']))
+3 -12
View File
@@ -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) => {
+218
View File
@@ -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: '<envelope>' }] }),
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<string, unknown>): Parameters<typeof makeContentFetcher>[0] {
return { get: (key: string) => services[key] } as Parameters<typeof makeContentFetcher>[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/)
})
})
+7 -44
View File
@@ -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<void>
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)
})
})