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:
1 parent
5a2b742d50
commit
5cc028ff00
13 files changed
+663
-257
No files matched your search
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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']))
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user