From e0022ff19e03ea249948f118793c271fba8dc577 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:21:37 +0800 Subject: [PATCH] refactor(client): isolate trajectory history --- .../src/client/contract/session-history.ts | 35 ++ .../runtime/src/client/contract/session.ts | 7 - packages/client/runtime/src/client/index.ts | 38 +- .../client/session-history/history-fold.ts | 448 ++++++++++++++++++ .../src/client/session-history/service.ts | 66 +++ .../src/client/session-history/source.ts | 352 ++++++++++++++ .../src/client/sessions/conversation.ts | 3 - .../src/client/sessions/fold-adapter.ts | 333 +------------ .../runtime/src/client/sessions/history.ts | 22 +- .../runtime/src/client/sessions/session.ts | 150 ++---- .../client/runtime/tests/fold-adapter.spec.ts | 5 +- .../tests/session-history-source.spec.ts | 98 ++++ packages/client/runtime/tests/session.spec.ts | 126 ----- packages/client/test-runtime/src/sessions.ts | 7 - .../test-runtime/tests/runtime.spec.tsx | 1 - .../ui-conversation/src/client/apply.ts | 8 +- .../src/client/contract/slots.ts | 6 +- .../src/client/skeleton/ConversationRoot.tsx | 14 +- .../client/skeleton/ConversationSession.tsx | 18 +- .../tests/apply-inject.spec.tsx | 1 - .../tests/chat-toolview-slot.spec.tsx | 1 - .../ui-conversation/tests/skeleton.spec.tsx | 4 +- .../ui-layout/src/client/AppFrame.module.css | 39 +- .../src/markdown/CodeBlock.module.css | 15 - .../ui-primitives/src/markdown/CodeBlock.tsx | 6 +- .../src/client/TrajectoryView.tsx | 45 +- .../client/ui-trajectory/src/client/index.ts | 10 +- .../client/ui-trajectory/tests/views.spec.tsx | 101 ++-- 28 files changed, 1261 insertions(+), 698 deletions(-) create mode 100644 packages/client/runtime/src/client/contract/session-history.ts create mode 100644 packages/client/runtime/src/client/session-history/history-fold.ts create mode 100644 packages/client/runtime/src/client/session-history/service.ts create mode 100644 packages/client/runtime/src/client/session-history/source.ts create mode 100644 packages/client/runtime/tests/session-history-source.spec.ts diff --git a/packages/client/runtime/src/client/contract/session-history.ts b/packages/client/runtime/src/client/contract/session-history.ts new file mode 100644 index 0000000000..2b6679fd05 --- /dev/null +++ b/packages/client/runtime/src/client/contract/session-history.ts @@ -0,0 +1,35 @@ +import type { + RpcError, SessionId, +} from '@deepseek-ai/dsh-client-connection/client' +import type { SessionHistoryInspection } from '../sessions/history.ts' +import type { ObservableSnapshot } from './store.ts' + +/** Observable state of one independently loaded session history ledger. */ +export interface SessionHistorySnapshot { + state: 'cold' | 'loading' | 'ready' | 'error' + error: RpcError | null + hasMore: boolean + inspection: SessionHistoryInspection +} + +/** Read-only history source addressed by session id. */ +export interface SessionHistoryFace + extends ObservableSnapshot { + readonly sessionId: SessionId + /** + * Load the tail and exhaust every available older page. + * @param signal - Consumer lifetime; abort is observed between page requests. + * @returns When the available ledger is complete or stops advancing. + */ + loadAll(signal?: AbortSignal): Promise +} + +/** Runtime service resolving independent history sources. */ +export interface ISessionHistory { + /** + * Resolve the identity-stable source for a session. + * @param sessionId - Host session identity. + * @returns The source owned outside Session and SessionManager. + */ + source(sessionId: SessionId): SessionHistoryFace +} diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index a38835bc2e..31335c388c 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -46,13 +46,6 @@ export interface ISession { * @returns completion; failures land in snapshot.openState/loadingOlder. */ loadOlder(): Promise - /** - * Exhaust the available history for inspection features. - * An abort stops before the next page without abandoning an active request. - * @param signal - Consumer lifetime; abort is observed between pages. - * @returns completion when history is exhausted or paging stops making progress. - */ - loadAllHistory(signal?: AbortSignal): Promise /** * Execute one slash-command line against this session's agent — pure * admission semantics (the host executor durably logs the lifecycle). diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index e68d55497f..f359eb1eac 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -5,6 +5,7 @@ import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek- import { SlotsService } from './slots.ts' import { SessionsService } from './sessions/service.ts' import type { SessionListState } from './sessions/service.ts' +import { SessionHistoryService } from './session-history/service.ts' import { WorkspacesService } from './workspaces/service.ts' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts' import type { UseProjection } from './sessions/projection-store.ts' @@ -12,6 +13,7 @@ import type { UseProjection } from './sessions/projection-store.ts' export { SlotsService } from './slots.ts' export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' +export { SessionHistoryService } from './session-history/service.ts' // The provide channel is shared with the client test runtime (one // materialization/projection implementation; no test-side mirror to drift). export { SessionProvideChannel } from './sessions/provide.ts' @@ -21,6 +23,9 @@ export type { AgentScopeHandle } from './agents/scope.ts' export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' export type { Session } from './sessions/session.ts' export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' +export type { + ISessionHistory, SessionHistoryFace, SessionHistorySnapshot, +} from './contract/session-history.ts' export type { ISessions } from './contract/sessions.ts' export type { IWorkspaces } from './contract/workspaces.ts' export type { @@ -49,7 +54,7 @@ export type { export type { ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView, } from './sessions/request-inspection.ts' -export type { ConversationHistoryProjection } from './sessions/fold-adapter.ts' +export type { ConversationHistoryProjection } from './session-history/history-fold.ts' export type { SessionHistoryInspection } from './sessions/history.ts' export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' @@ -129,6 +134,8 @@ declare module 'cordis' { slots: import('./slots.ts').SlotsService /** The outward face only; the concrete service stays inside the runtime. */ sessions: import('./contract/sessions.ts').ISessions + /** Read-only history sources isolated from Chat sessions and workspace state. */ + sessionHistory: import('./contract/session-history.ts').ISessionHistory /** The outward face only; the concrete service stays inside the runtime. */ workspaces: import('./contract/workspaces.ts').IWorkspaces } @@ -144,30 +151,55 @@ export function apply(ctx: Context): void { ctx.plugin(SlotsService) const connection = ctx.get('connection') as ConnectionHandle const sessions = new SessionsService(ctx, connection.api) + const sessionHistory = new SessionHistoryService(ctx, connection.api) const workspaces = new WorkspacesService(ctx, connection.api, sessions) ctx.effect( () => workspaces.startInitialSelection(), 'runtime: initial Workspace selection', ) const loop = connection.start({ - onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) }, + onMuxEnvelope: (envelope) => { + sessions.handleMuxEnvelope(envelope) + try { + sessionHistory.handleMuxEnvelope(envelope) + } catch (error) { + console.error('[web-runtime] history frame routing failed:', error) + } + }, onHostEnvelope: (envelope) => { sessions.handleHostEnvelope(envelope) workspaces.handleHostEnvelope(envelope) // Typed-event bridge: the session layer ignores registry frames (no // session routing); consumers (command directory caches) subscribe on ctx. if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed') + try { + sessionHistory.handleHostEnvelope(envelope) + } catch (error) { + console.error('[web-runtime] history host-frame routing failed:', error) + } }, onConnected: () => { sessions.handleConnected() workspaces.handleConnected() ctx.emit('connection/reset') + try { + sessionHistory.handleConnected() + } catch (error) { + console.error('[web-runtime] history reconnect failed:', error) + } }, onStateChange: (state) => { // Generation death fires before any next-generation frame can arrive // (reconnect replays flow from stream open, ahead of onConnected): // the only safe moment to drop generation-scoped interaction state. - if (state === 'reconnecting') sessions.handleDisconnected() + if (state === 'reconnecting') { + sessions.handleDisconnected() + try { + sessionHistory.handleDisconnected() + } catch (error) { + console.error('[web-runtime] history disconnect failed:', error) + } + } }, }) ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop') diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts new file mode 100644 index 0000000000..a1a7a8fe39 --- /dev/null +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -0,0 +1,448 @@ +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { + SurfaceManager, isSurfaceEligibleType, isSurfaceEvent, +} from '@deepseek-ai/dsh-session/surface' +import type { + HistoryEntry, ToolCallView, ToolResultView, +} from '@deepseek-ai/dsh-client-connection/client' +import type { + AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode, + PartialAssistant, RunningToolCall, +} from '../sessions/conversation.ts' +import { toAssistantBlocks } from '../sessions/conversation.ts' +import type { + ConversationContext, ConversationContextOriginKind, +} from '../sessions/conversation-context.ts' +import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts' +import { PartialAccumulator } from '../sessions/partial.ts' + +interface CallIndexEntry { + name: string + argsRaw: string + time: number + callView: ToolCallView | null +} + +interface FoldedContext { + generation: number + nodes: readonly number[] + originSeq?: number +} + +interface AssistantStepMetadata { + stepStartTime: number | null + firstTokenTime: number | null +} + +/** Immutable conversation projections derived only from the history source. */ +export interface ConversationHistoryProjection { + eventNodes: readonly ConversationNode[] + contexts: readonly ConversationContext[] + interruptedNodes: readonly ConversationNode[] + partial: PartialAssistant | null + runningCalls: readonly RunningToolCall[] + codeDispatches: ReadonlyMap +} + +function assistantStepKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + +function paddingEvent(seq: number): SessionEvent { + return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent +} + +function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean { + if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false + return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq +} + +function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind { + if (event?.type !== 'user/message') return 'rewrite' + const source = event.data.source + if (typeof source === 'object' && 'kind' in source && 'plugin' in source) { + if (source.plugin === 'compact') return 'compaction' + if (source.plugin === 'rewind') return 'rewind' + } + return 'rewrite' +} + +function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} + +function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] { + const replay: SessionEvent[] = [] + const surface = new SurfaceManager(replay) + const contexts: FoldedContext[] = [] + let generation = 0 + let originSeq: number | undefined + for (const event of events) { + if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { + contexts.push({ + generation, + nodes: [...surface.nodes], + ...(originSeq === undefined ? {} : { originSeq }), + }) + generation++ + originSeq = event.seq + } + replay.push(event) + } + contexts.push({ + generation, + nodes: [...surface.nodes], + ...(originSeq === undefined ? {} : { originSeq }), + }) + return contexts +} + +function materializeNode( + event: SessionEvent, + callIndex: ReadonlyMap, + resultView: ToolResultView | null, + assistantTiming: AssistantTiming | undefined, + requestConfig: AssistantRequestConfig | undefined, +): ConversationNode { + switch (event.type) { + case 'user/message': + if (event.data.source.kind !== 'user') { + return { + kind: 'context', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, + } + } + return { + kind: 'user', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, + } + case 'assistant/message': + return { + kind: 'assistant', seq: event.seq, time: event.time, + turn: event.data.turn, step: event.data.step, + blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage, + provenance: { + provider: event.data.message.source.provider, + model: event.data.message.source.model, + }, + ...(requestConfig === undefined ? {} : { requestConfig }), + ...(assistantTiming === undefined ? {} : { timing: assistantTiming }), + } + case 'steering/message': + return { + kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, + content: event.data.message.content, source: event.data.message.source, + } + case 'tool/result': { + const result = event.data.message.content[0] + const callId = String(event.data.message.source.callId) + const call = callIndex.get(callId) + return { + kind: 'tool-result', seq: event.seq, time: event.time, + callId, + call: call === undefined ? null : { name: call.name, argsRaw: call.argsRaw }, + callTime: call?.time ?? null, + content: result.content, isError: result.isError === true, + ...(event.data.error === undefined ? {} : { error: event.data.error }), + meta: event.data.meta, + callView: call?.callView ?? null, + resultView, + } + } + default: + return { + kind: 'unknown', seq: event.seq, time: event.time, + type: event.type, data: (event as { data?: unknown }).data, + } + } +} + +function projectTransient(entries: readonly HistoryEntry[]): Pick< + ConversationHistoryProjection, + 'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches' +> { + let partial: PartialAccumulator | null = null + const openCalls = new Map() + const interruptedNodes: ConversationNode[] = [] + const codeDispatches = new Map() + + for (const entry of entries) { + const { event } = entry + if ((event.type as string) === 'tool/code-dispatch-start') { + const data = event.data as unknown as { + parentCallId: string + subCallId: string + name: string + arguments: unknown + } + const siblings = codeDispatches.get(data.parentCallId) ?? [] + codeDispatches.set(data.parentCallId, [...siblings, { + callId: data.subCallId, + name: data.name, + argsRaw: JSON.stringify(data.arguments), + turn: 0, + step: 0, + time: event.time, + callView: null, + }]) + continue + } + if ((event.type as string) === 'tool/code-dispatch') { + const data = event.data as unknown as { + parentCallId: string + subCallId: string + name: string + arguments: unknown + isError: boolean + content: ContentBlock[] + } + const siblings = codeDispatches.get(data.parentCallId) ?? [] + const at = siblings.findIndex(sub => sub.callId === data.subCallId) + const started = at === -1 ? undefined : siblings[at] + const settled: CodeSubCall = { + kind: 'tool-result', seq: event.seq, time: event.time, + callId: data.subCallId, + call: { name: data.name, argsRaw: JSON.stringify(data.arguments) }, + callTime: started?.time ?? null, + content: data.content, + isError: data.isError, + callView: null, + resultView: null, + } + codeDispatches.set( + data.parentCallId, + at === -1 + ? [...siblings, settled] + : siblings.map((sub, index) => index === at ? settled : sub), + ) + continue + } + switch (event.type) { + case 'assistant/chunk': { + const { turn, step, chunk } = event.data + if (partial === null || partial.turn !== turn || partial.step !== step) { + partial = new PartialAccumulator(turn, step) + } + partial.push(chunk) + break + } + case 'assistant/message': + if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null + break + case 'tool/call': + openCalls.set(String(event.data.callId), { + callId: String(event.data.callId), + name: event.data.name, + argsRaw: event.data.arguments, + turn: event.data.turn, + step: event.data.step, + time: event.time, + callView: entry.view?.for === 'call' ? entry.view.view : null, + }) + break + case 'tool/result': + openCalls.delete(String(event.data.message.source.callId)) + break + case 'turn/end': { + if (partial !== null && partial.turn === event.data.turn) { + const { blocks } = partial.toPartial() + const visible = blocks.some(block => + block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true) + if (visible) { + interruptedNodes.push({ + kind: 'assistant', seq: event.seq - 0.9, time: event.time, + turn: partial.turn, step: partial.step, blocks, interrupted: true, + }) + } + partial = null + } + let callOffset = 0 + for (const [callId, call] of openCalls) { + if (call.turn !== event.data.turn) continue + openCalls.delete(callId) + interruptedNodes.push({ + kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, + time: event.time, + callId, + call: { name: call.name, argsRaw: call.argsRaw }, + callTime: call.time, + content: [], + isError: true, + error: { name: 'Interrupted', code: 'interrupted' }, + callView: call.callView, + resultView: null, + }) + } + break + } + default: + break + } + } + + return { + interruptedNodes, + partial: partial?.toPartial() ?? null, + runningCalls: [...openCalls.values()], + codeDispatches, + } +} + +/** + * Project one immutable history ledger without reading or mutating Chat state. + * @param entries - Contiguous history entries in sequence order. + * @returns Event order, context lineage, and transient tail state. + */ +export function projectConversationHistory( + entries: readonly HistoryEntry[], +): ConversationHistoryProjection { + const events = entries.map(entry => entry.event) + const baseSeq = events[0]?.seq ?? 0 + const padded = [ + ...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)), + ...events, + ] + const callIndex = new Map() + const resultViews = new Map() + const assistantSteps = new Map() + const assistantTimings = new Map() + const assistantRequestConfigs = new Map() + const promptsByContext = new Map() + let activeRequestConfig: AssistantRequestConfig | undefined + let activePrompt: ConversationPromptSnapshot | undefined + let contextGeneration = 0 + + for (const [index, event] of events.entries()) { + const view = entries[index]?.view + if (event.type === 'tool/call') { + callIndex.set(String(event.data.callId), { + name: event.data.name, + argsRaw: event.data.arguments, + time: event.time, + callView: view?.for === 'call' ? view.view : null, + }) + } else if (event.type === 'tool/result' && view?.for === 'result') { + resultViews.set(event.seq, view.view) + } + if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { + contextGeneration++ + if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt) + } + if (event.type === 'request/header') { + activeRequestConfig = event.data.header.config + activePrompt = { + config: event.data.header.config, + system: event.data.header.system ?? '', + tools: event.data.header.tools ?? [], + } + promptsByContext.set(contextGeneration, activePrompt) + } else if (event.type === 'step/start') { + assistantSteps.set( + assistantStepKey(event.data.turn, event.data.step), + { stepStartTime: event.time, firstTokenTime: null }, + ) + } else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) { + const key = assistantStepKey(event.data.turn, event.data.step) + const current = assistantSteps.get(key) ?? { + stepStartTime: null, + firstTokenTime: null, + } + if (current.firstTokenTime === null) { + assistantSteps.set(key, { ...current, firstTokenTime: event.time }) + } + } else if (event.type === 'assistant/message') { + assistantTimings.set( + event.seq, + { + ...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? { + stepStartTime: null, + firstTokenTime: null, + }), + completedTime: event.time, + }, + ) + if (activeRequestConfig !== undefined) { + assistantRequestConfigs.set(event.seq, activeRequestConfig) + } + } + } + + const nodeCache = new Map() + const materialize = (seq: number): ConversationNode | undefined => { + const cached = nodeCache.get(seq) + if (cached !== undefined) return cached + const event = padded[seq] + if (event === undefined || !isSurfaceEligibleType(event.type)) return + const node = materializeNode( + event, + callIndex, + resultViews.get(seq) ?? null, + assistantTimings.get(seq), + assistantRequestConfigs.get(seq), + ) + nodeCache.set(seq, node) + return node + } + const eventNodes = events.flatMap((event) => { + const node = materialize(event.seq) + return node === undefined ? [] : [node] + }) + + let contexts: readonly ConversationContext[] + if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) { + contexts = [{ + id: 0, + ...(activePrompt === undefined ? {} : { prompt: activePrompt }), + nodes: eventNodes, + }] + } else { + try { + contexts = foldContexts(padded).map((context): ConversationContext => { + const nodes = context.nodes.flatMap((seq) => { + const node = materialize(seq) + return node === undefined ? [] : [node] + }) + const prompt = promptsByContext.get(context.generation) + if (context.originSeq === undefined) { + return { + id: context.generation, + ...(prompt === undefined ? {} : { prompt }), + nodes, + } + } + const originEvent = padded[context.originSeq] + return { + id: context.generation, + parentId: context.generation - 1, + origin: contextOriginKind(originEvent), + originSeq: context.originSeq, + ...(originEvent === undefined ? {} : { createdAt: originEvent.time }), + ...(prompt === undefined ? {} : { prompt }), + nodes, + } + }) + } catch (error) { + console.error('[web-runtime] history surface fold failed, using event order:', error) + contexts = [{ + id: 0, + ...(activePrompt === undefined ? {} : { prompt: activePrompt }), + nodes: eventNodes, + }] + } + } + + return { + eventNodes, + contexts, + ...projectTransient(entries), + } +} diff --git a/packages/client/runtime/src/client/session-history/service.ts b/packages/client/runtime/src/client/session-history/service.ts new file mode 100644 index 0000000000..b5aba32bce --- /dev/null +++ b/packages/client/runtime/src/client/session-history/service.ts @@ -0,0 +1,66 @@ +import type { Context } from 'cordis' +import type { + HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId, +} from '@deepseek-ai/dsh-client-connection/client' +import type { + ISessionHistory, SessionHistoryFace, +} from '../contract/session-history.ts' +import { SessionHistorySource } from './source.ts' + +/** Root registry and frame router for independent inspection histories. */ +export class SessionHistoryService implements ISessionHistory { + private readonly sources = new Map() + + /** + * @param ctx - Client root context. + * @param api - Shared wire client. + */ + constructor(ctx: Context, private readonly api: IApiClient) { + ctx.reflect.provide('sessionHistory', this, undefined) + } + + /** + * Resolve one identity-stable history source. + * @param sessionId - Host session identity. + * @returns Source independent from SessionManager. + */ + source(sessionId: SessionId): SessionHistoryFace { + let source = this.sources.get(sessionId) + if (source === undefined) { + source = new SessionHistorySource(sessionId, this.api) + this.sources.set(sessionId, source) + } + return source + } + + /** + * Route history-relevant mux frames only to an existing source. + * @param envelope - Validated mux envelope. + */ + handleMuxEnvelope(envelope: RpcRequest): void { + const frame = envelope.payload + if (frame.type === 'stream/error') return + this.sources.get(frame.sessionId)?.handleMuxFrame(frame) + } + + /** + * Drop a removed session's independent history source. + * @param envelope - Validated host envelope. + */ + handleHostEnvelope(envelope: RpcRequest): void { + const frame = envelope.payload + if (frame.type !== 'host/session-removed') return + this.sources.get(frame.sessionId)?.dispose() + this.sources.delete(frame.sessionId) + } + + /** Invalidate requests from the dead connection generation. */ + handleDisconnected(): void { + for (const source of this.sources.values()) source.handleDisconnected() + } + + /** Rebuild every previously activated source from the new generation. */ + handleConnected(): void { + for (const source of this.sources.values()) source.resync() + } +} diff --git a/packages/client/runtime/src/client/session-history/source.ts b/packages/client/runtime/src/client/session-history/source.ts new file mode 100644 index 0000000000..8de11b9da9 --- /dev/null +++ b/packages/client/runtime/src/client/session-history/source.ts @@ -0,0 +1,352 @@ +import type { + HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId, +} from '@deepseek-ai/dsh-client-connection/client' +import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { + SessionHistoryFace, SessionHistorySnapshot, +} from '../contract/session-history.ts' +import { createHistoryInspection } from '../sessions/history.ts' +import { Notifier } from '../sessions/notifier.ts' + +const HISTORY_PAGE_MESSAGES = 50 + +function isAborted(signal: AbortSignal | undefined): boolean { + return signal?.aborted === true +} + +/** Independent raw-history owner used only by inspection consumers. */ +export class SessionHistorySource implements SessionHistoryFace { + private entries: readonly HistoryEntry[] = [] + private baseSeq = 0 + private hasMore = false + private state: SessionHistorySnapshot['state'] = 'cold' + private error: RpcError | null = null + private generation = 0 + private persistentConsumer = false + private readonly consumerSignals = new Set() + private openPromise: Promise | null = null + private olderPromise: Promise | null = null + private stitching = false + private liveBuffer: HistoryEntry[] = [] + private subscribedLastSeq: number | null = null + private inspectionCache: { + entries: readonly HistoryEntry[] + value: SessionHistorySnapshot['inspection'] + } | null = null + private snapshotCache: SessionHistorySnapshot + private readonly notifier = new Notifier(() => { + this.snapshotCache = this.buildSnapshot() + }) + + /** + * @param sessionId - Host session identity. + * @param api - Shared wire client. + */ + constructor( + readonly sessionId: SessionId, + private readonly api: IApiClient, + ) { + this.snapshotCache = this.buildSnapshot() + } + + /** + * Subscribe to ledger changes. + * @param listener - Change callback. + * @returns Unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener) + } + + /** + * Read the cached ledger snapshot. + * @returns Stable snapshot until the source changes. + */ + getSnapshot(): SessionHistorySnapshot { + this.notifier.ensureFresh() + return this.snapshotCache + } + + /** + * Load the tail and exhaust all available older pages. + * @param signal - Consumer lifetime. + * @returns When paging completes, fails to advance, or is aborted. + */ + async loadAll(signal?: AbortSignal): Promise { + if (signal?.aborted === true) return + this.trackConsumer(signal) + await this.open() + while ( + !isAborted(signal) + && this.state === 'ready' + && this.hasMore + ) { + const previousBaseSeq = this.baseSeq + await this.loadOlder() + if (isAborted(signal) || this.baseSeq === previousBaseSeq) return + } + } + + /** Rebuild and page for whichever mounted consumers survive a reconnect. */ + private async loadForConsumers(): Promise { + await this.open() + while ( + this.hasConsumer() + && this.state === 'ready' + && this.hasMore + ) { + const previousBaseSeq = this.baseSeq + await this.loadOlder() + if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return + } + } + + /** + * Route a relevant mux frame without involving the Chat session. + * @param frame - Session-addressed frame. + */ + handleMuxFrame(frame: MuxFrame): void { + if (frame.type === 'session/subscribed') { + this.subscribedLastSeq = frame.lastSeq + return + } + if (frame.type !== 'session/event') return + this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) }) + } + + /** Invalidate dead-generation requests while retaining the last readable snapshot. */ + handleDisconnected(): void { + this.generation++ + this.openPromise = null + this.olderPromise = null + this.stitching = false + this.liveBuffer = [] + this.subscribedLastSeq = null + if (this.state !== 'cold') { + this.state = 'cold' + this.error = null + this.notifier.markDirty() + } + } + + /** Rebuild an activated ledger from the new connection generation. */ + resync(): void { + if (!this.hasConsumer()) return + this.generation++ + this.openPromise = null + this.olderPromise = null + this.stitching = false + this.liveBuffer = [] + this.subscribedLastSeq = null + this.entries = [] + this.baseSeq = 0 + this.hasMore = false + this.state = 'cold' + this.error = null + this.notifier.markDirty() + void this.loadForConsumers() + } + + /** Stop future refresh work after the host removes the session. */ + dispose(): void { + this.persistentConsumer = false + this.consumerSignals.clear() + this.generation++ + this.openPromise = null + this.olderPromise = null + this.liveBuffer = [] + } + + private open(): Promise { + if (this.state === 'ready') return Promise.resolve() + if (this.openPromise !== null) return this.openPromise + const generation = this.generation + const operation = this.doOpen(generation) + const settled = operation.finally(() => { + if (this.openPromise === settled) this.openPromise = null + }) + this.openPromise = settled + return settled + } + + private trackConsumer(signal: AbortSignal | undefined): void { + if (signal === undefined) { + this.persistentConsumer = true + return + } + if (this.consumerSignals.has(signal)) return + this.consumerSignals.add(signal) + signal.addEventListener('abort', () => { + this.consumerSignals.delete(signal) + }, { once: true }) + } + + private hasConsumer(): boolean { + return this.persistentConsumer || this.consumerSignals.size > 0 + } + + private async doOpen(generation: number): Promise { + this.state = 'loading' + this.error = null + this.notifier.markDirty() + try { + let { result } = await this.api.sessions.history({ + sessionId: this.sessionId, + maxMessages: HISTORY_PAGE_MESSAGES, + }) + if (generation !== this.generation) return + if (!result.ok) { + this.state = 'error' + this.error = result.error + return + } + this.installTail(result.value.events, result.value.hasMore, true) + const tailSeq = this.tailSeq() + if ( + this.subscribedLastSeq !== null + && tailSeq !== null + && this.subscribedLastSeq > tailSeq + ) { + result = (await this.api.sessions.history({ + sessionId: this.sessionId, + maxMessages: HISTORY_PAGE_MESSAGES, + })).result + if (generation !== this.generation) return + if (result.ok) this.installTail(result.value.events, result.value.hasMore, true) + } + this.state = 'ready' + } catch (error) { + if (generation !== this.generation) return + this.state = 'error' + const folded = transportError(error) + /* v8 ignore next -- transportError always returns the error branch. */ + this.error = folded.ok ? null : folded.error + } finally { + if (generation === this.generation) this.notifier.markDirty() + } + } + + private loadOlder(): Promise { + if (this.olderPromise !== null) return this.olderPromise + if (this.state !== 'ready' || !this.hasMore) return Promise.resolve() + const generation = this.generation + const operation = (async () => { + try { + const { result } = await this.api.sessions.history({ + sessionId: this.sessionId, + beforeSeq: this.baseSeq, + maxMessages: HISTORY_PAGE_MESSAGES, + }) + if (generation !== this.generation || this.state !== 'ready' || !result.ok) return + const older = result.value.events + if (older.length === 0) { + this.hasMore = result.value.hasMore + return + } + const tail = older.at(-1) + if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) { + console.error( + `[web-runtime] inspection history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`, + ) + this.hasMore = false + return + } + this.entries = [...older, ...this.entries] + this.baseSeq = older[0]?.event.seq ?? this.baseSeq + this.hasMore = result.value.hasMore + } catch (error) { + console.error('[web-runtime] inspection history paging failed:', error) + } + })() + const settled = operation.finally(() => { + if (this.olderPromise !== settled) return + this.olderPromise = null + this.notifier.markDirty() + }) + this.olderPromise = settled + return settled + } + + private installTail( + tail: readonly HistoryEntry[], + hasMore: boolean, + replace: boolean, + ): void { + if (replace) { + this.entries = [...tail] + this.hasMore = hasMore + } else { + const firstSeq = tail[0]?.event.seq + const prefix = firstSeq === undefined + ? this.entries + : this.entries.filter(entry => entry.event.seq < firstSeq) + this.entries = [...prefix, ...tail] + } + this.baseSeq = this.entries[0]?.event.seq ?? 0 + const buffered = this.liveBuffer + this.liveBuffer = [] + for (const entry of buffered) this.appendLive(entry) + this.notifier.markDirty() + } + + private acceptLive(entry: HistoryEntry): void { + if (this.state === 'loading' || this.stitching) { + this.liveBuffer.push(entry) + return + } + if (this.state !== 'ready') return + const tailSeq = this.tailSeq() + if (tailSeq !== null && entry.event.seq > tailSeq + 1) { + this.liveBuffer.push(entry) + void this.repairGap() + return + } + this.appendLive(entry) + this.notifier.markDirty() + } + + private appendLive(entry: HistoryEntry): void { + const tailSeq = this.tailSeq() + if (tailSeq !== null && entry.event.seq <= tailSeq) return + this.entries = [...this.entries, entry] + } + + private async repairGap(): Promise { + if (this.stitching) return + this.stitching = true + const generation = this.generation + try { + const { result } = await this.api.sessions.history({ + sessionId: this.sessionId, + maxMessages: HISTORY_PAGE_MESSAGES, + }) + if (result.ok && generation === this.generation && this.state === 'ready') { + this.installTail(result.value.events, result.value.hasMore, false) + } + } catch (error) { + console.error('[web-runtime] inspection history gap repair failed:', error) + } finally { + if (generation === this.generation) this.stitching = false + } + } + + private tailSeq(): number | null { + return this.entries.at(-1)?.event.seq ?? null + } + + private buildSnapshot(): SessionHistorySnapshot { + if (this.inspectionCache?.entries !== this.entries) { + const entries = this.entries + this.inspectionCache = { + entries, + value: createHistoryInspection(() => entries), + } + } + return { + state: this.state, + error: this.error, + hasMore: this.hasMore, + inspection: this.inspectionCache.value, + } + } +} diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 1fbdf4efb8..cd03c92485 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -9,7 +9,6 @@ import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' -import type { SessionHistoryInspection } from './history.ts' import type { PendingInteraction } from './pending.ts' export type { TodoItem } @@ -266,8 +265,6 @@ export interface ConversationSnapshot { sessionId: SessionId /** Surface fold product (finalized conversation nodes in surface order). */ nodes: readonly ConversationNode[] - /** Lazy history-only projections consumed by inspection views. */ - inspection?: SessionHistoryInspection /** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */ foldDegraded: boolean partial: PartialAssistant | null diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index d83d46ffe6..039c36056b 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -1,55 +1,17 @@ // FoldAdapter: core SurfaceManager wiring + node materialization cache. -// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index). -// A replace that crosses the loaded window head uses a lenient linear scan until -// paging reaches its range; unexpected fold failures report and use the same fallback. +// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index); +// a cross-window replace throw degrades to a lenient linear scan (foldDegraded — +// the degradation lives in one branch function in this file, zero scattered removal points). import type { SessionEvent } from '@deepseek-ai/dsh-session/types' // Subpath export (package.json exports "./surface", alias added for this): all value imports // go through it — the package root points at lib/index.js (needs a build) which the vite // browser bundle cannot resolve; surface.ts has no Node dependencies. -import { - SurfaceManager, isSurfaceEligibleType, isSurfaceEvent, -} from '@deepseek-ai/dsh-session/surface' +import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -import type { - HistoryEntry, ToolCallView, ToolEventView, ToolResultView, -} from '@deepseek-ai/dsh-client-connection/client' -import type { - AssistantRequestConfig, AssistantTiming, CommandNode, ConversationNode, -} from './conversation.ts' +import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { CommandNode, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' -import type { - ConversationContext, ConversationContextOriginKind, -} from './conversation-context.ts' -import type { ConversationPromptSnapshot } from './request-inspection.ts' - -/** Lazy event-order and context-generation projection for history consumers. */ -export interface ConversationHistoryProjection { - eventNodes: readonly ConversationNode[] - contexts: readonly ConversationContext[] -} - -/** - * Project an immutable raw history window into event-order nodes and context - * generations. Session's chat snapshot never computes this projection. - * @param entries - Contiguous history entries in sequence order. - * @returns Inspection-oriented conversation projections. - */ -export function projectConversationHistory( - entries: readonly HistoryEntry[], -): ConversationHistoryProjection { - const adapter = new FoldAdapter(true) - const events = entries.map(entry => entry.event) - adapter.reset( - events, - events[0]?.seq ?? 0, - entries.map(entry => entry.view), - ) - return { - eventNodes: adapter.eventNodes(), - contexts: adapter.contexts(), - } -} /** In-window tool/call index entry (result-card backfill + runningCalls material). */ export interface CallIndexEntry { @@ -71,70 +33,11 @@ function paddingEvent(seq: number): SessionEvent { return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent } -/** - * Whether a valid replacement range begins before the loaded history window. - * @param event - Candidate surface event in the current replay window. - * @param baseSeq - Sequence at the loaded window head. - * @returns True when strict folding requires an earlier page. - */ -function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean { - if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false - return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq -} - -/** Minimal generation projection owned by the inspection adapter, not the core live surface. */ -interface FoldedContext { - generation: number - nodes: readonly number[] - originSeq?: number -} - -interface AssistantStepMetadata { - stepStartTime: number | null - firstTokenTime: number | null -} - -function assistantStepKey(turn: number, step: number): string { - return `${turn}\u0000${step}` -} - -/** - * Replay surface replacements into frozen generations while keeping replacement - * validation and mutation in the canonical core manager. - */ -function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] { - const replay: SessionEvent[] = [] - const surface = new SurfaceManager(replay) - const contexts: FoldedContext[] = [] - let generation = 0 - let originSeq: number | undefined - for (const event of events) { - if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { - contexts.push({ - generation, - nodes: [...surface.nodes], - ...(originSeq === undefined ? {} : { originSeq }), - }) - generation++ - originSeq = event.seq - } - replay.push(event) - } - contexts.push({ - generation, - nodes: [...surface.nodes], - ...(originSeq === undefined ? {} : { originSeq }), - }) - return contexts -} - /** One event -> UI node (pure function; the six-variant ConversationNode union). */ function materializeNode( event: SessionEvent, callIndex: ReadonlyMap, resultView: ToolResultView | null, - assistantTiming?: AssistantTiming, - requestConfig?: AssistantRequestConfig, ): ConversationNode { switch (event.type) { case 'user/message': @@ -155,12 +58,6 @@ function materializeNode( kind: 'assistant', seq: event.seq, time: event.time, turn: event.data.turn, step: event.data.step, blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage, - provenance: { - provider: event.data.message.source.provider, - model: event.data.message.source.model, - }, - ...(requestConfig === undefined ? {} : { requestConfig }), - ...(assistantTiming !== undefined ? { timing: assistantTiming } : {}), } case 'steering/message': return { @@ -194,7 +91,7 @@ function materializeNode( } } -/** Window fold over the core SurfaceManager with a lenient partial-history fallback. */ +/** Window fold over the core SurfaceManager (sentinel padding for the seq offset; degrades to a linear scan on cross-window replace). */ export class FoldAdapter { /** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */ private padded: SessionEvent[] = [] @@ -219,23 +116,6 @@ export class FoldAdapter { * reference-stability contract (§A.9.4) starts here. */ private rev = 0 private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null - private eventNodesResult: { rev: number; value: readonly ConversationNode[] } | null = null - /** Revision of context structure or its request header; unrelated log-only events do not rebuild contexts. */ - private contextRev = 0 - private contextsResult: { rev: number; value: readonly ConversationContext[] } | null = null - private contextGeneration = 0 - private activePrompt: ConversationPromptSnapshot | undefined - private promptsByContext = new Map() - private assistantSteps = new Map() - private assistantTimings = new Map() - private activeRequestConfig: AssistantRequestConfig | undefined - private assistantRequestConfigs = new Map() - - /** - * @param projectContexts - Whether to maintain context-generation indexes - * for a later history projection. The live chat fold leaves this disabled. - */ - constructor(private readonly projectContexts = false) {} /** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */ get callIndex(): ReadonlyMap { @@ -251,31 +131,21 @@ export class FoldAdapter { */ reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void { this.rev++ - if (this.projectContexts) this.contextRev++ this.baseSeq = baseSeq this.padded = [] for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i)) for (const event of events) this.padded.push(event) this.surface = new SurfaceManager(this.padded) this.nodeCache.clear() - this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq)) + this.degraded = false this.callIdx = new Map() this.resultViews.clear() - this.contextGeneration = 0 - this.activePrompt = undefined - this.promptsByContext = new Map() - this.assistantSteps = new Map() - this.assistantTimings = new Map() - this.activeRequestConfig = undefined - this.assistantRequestConfigs = new Map() this.commandIdx = new Map() for (let i = 0; i < events.length; i++) { const event = events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ if (event !== undefined) { this.indexCall(event, views?.[i]) - if (this.projectContexts) this.indexContextPrompt(event) - this.indexAssistantMetadata(event) this.indexCommand(event) } } @@ -289,14 +159,8 @@ export class FoldAdapter { */ append(event: SessionEvent, view?: ToolEventView): void { this.rev++ - if (this.projectContexts && (isSurfaceEvent(event) || event.type === 'request/header')) { - this.contextRev++ - } - if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true this.padded.push(event) this.indexCall(event, view) - if (this.projectContexts) this.indexContextPrompt(event) - this.indexAssistantMetadata(event) this.indexCommand(event) } @@ -321,9 +185,17 @@ export class FoldAdapter { } const out: ConversationNode[] = [] for (const seq of seqs) { - const node = this.materialize(seq) - /* v8 ignore next -- both seq sources only emit indexes present in padded. */ - if (node !== undefined) out.push(node) + const cached = this.nodeCache.get(seq) + if (cached !== undefined) { + out.push(cached) + continue + } + const event = this.padded[seq] + /* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */ + if (event === undefined) continue + const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null) + this.nodeCache.set(seq, node) + out.push(node) } // Command nodes fold outside the surface (log-only events); merge by seq. // Both inputs are seq-ascending (surface order and run-index insertion @@ -346,75 +218,6 @@ export class FoldAdapter { return value } - /** - * Every in-window message-producing event in original sequence order, without surface replacement folding. - * @returns append-only event projection for history inspection. - */ - eventNodes(): readonly ConversationNode[] { - if (this.eventNodesResult !== null && this.eventNodesResult.rev === this.rev) { - return this.eventNodesResult.value - } - const nodes: ConversationNode[] = [] - for (let seq = this.baseSeq; seq < this.padded.length; seq++) { - const event = this.padded[seq] - if (event === undefined || !isSurfaceEligibleType(event.type)) continue - const node = this.materialize(seq) - if (node !== undefined) nodes.push(node) - } - this.eventNodesResult = { rev: this.rev, value: nodes } - return nodes - } - - /** - * Append-only context generations reconstructed from canonical surface replacements. - * @returns Frozen historical contexts followed by the current context. - */ - contexts(): readonly ConversationContext[] { - if (!this.projectContexts) { - throw new Error('FoldAdapter context projection was not enabled') - } - if (this.contextsResult !== null && this.contextsResult.rev === this.contextRev) { - return this.contextsResult.value - } - const current = this.nodes() - if (current.degraded) { - const value: readonly ConversationContext[] = [{ - id: 0, - ...(this.activePrompt === undefined ? {} : { prompt: this.activePrompt }), - nodes: current.nodes, - }] - this.contextsResult = { rev: this.contextRev, value } - return value - } - const value = foldContexts(this.padded).map((context): ConversationContext => { - const nodes: ConversationNode[] = [] - for (const seq of context.nodes) { - const node = this.materialize(seq) - if (node !== undefined) nodes.push(node) - } - const prompt = this.promptsByContext.get(context.generation) - if (context.originSeq === undefined) { - return { - id: context.generation, - ...(prompt === undefined ? {} : { prompt }), - nodes, - } - } - const originEvent = this.padded[context.originSeq] - return { - id: context.generation, - parentId: context.generation - 1, - origin: contextOriginKind(originEvent), - originSeq: context.originSeq, - ...(originEvent === undefined ? {} : { createdAt: originEvent.time }), - ...(prompt === undefined ? {} : { prompt }), - nodes, - } - }) - this.contextsResult = { rev: this.contextRev, value } - return value - } - /** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */ private degradedSeqs(): number[] { const seqs: number[] = [] @@ -425,22 +228,6 @@ export class FoldAdapter { return seqs } - private materialize(seq: number): ConversationNode | undefined { - const cached = this.nodeCache.get(seq) - if (cached !== undefined) return cached - const event = this.padded[seq] - if (event === undefined) return - const node = materializeNode( - event, - this.callIdx, - this.resultViews.get(seq) ?? null, - this.assistantTimings.get(seq), - this.assistantRequestConfigs.get(seq), - ) - this.nodeCache.set(seq, node) - return node - } - /** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */ private indexCommand(event: SessionEvent): void { // Log-only plugin events: the host-side dsh-commands declaration cannot @@ -485,86 +272,4 @@ export class FoldAdapter { // No backfill into already-materialized tool-result nodes for this callId // (window order puts the call before its result; cannot happen on the normal path). } - - private indexAssistantMetadata(event: SessionEvent): void { - if (event.type === 'request/header') { - this.activeRequestConfig = event.data.header.config - return - } - if (event.type === 'step/start') { - this.assistantSteps.set( - assistantStepKey(event.data.turn, event.data.step), - { stepStartTime: event.time, firstTokenTime: null }, - ) - return - } - if (event.type === 'assistant/chunk') { - if (!isTokenDelta(event.data.chunk)) return - const key = assistantStepKey(event.data.turn, event.data.step) - const current = this.assistantSteps.get(key) ?? { - stepStartTime: null, - firstTokenTime: null, - } - if (current.firstTokenTime === null) { - this.assistantSteps.set(key, { - ...current, - firstTokenTime: event.time, - }) - } - return - } - if (event.type !== 'assistant/message') return - const timing = this.assistantSteps.get( - assistantStepKey(event.data.turn, event.data.step), - ) ?? { stepStartTime: null, firstTokenTime: null } - this.assistantTimings.set(event.seq, { - ...timing, - completedTime: event.time, - }) - if (this.activeRequestConfig !== undefined) { - this.assistantRequestConfigs.set(event.seq, this.activeRequestConfig) - } - } - - private indexContextPrompt(event: SessionEvent): void { - if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { - this.contextGeneration++ - if (this.activePrompt !== undefined) { - this.promptsByContext.set(this.contextGeneration, this.activePrompt) - } - } - if (event.type !== 'request/header') return - this.activePrompt = { - config: event.data.header.config, - system: event.data.header.system ?? '', - tools: event.data.header.tools ?? [], - } - this.promptsByContext.set(this.contextGeneration, this.activePrompt) - } -} - -function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind { - if (event?.type !== 'user/message') return 'rewrite' - const source = event.data.source - if ( - typeof source === 'object' - && 'kind' in source - && 'plugin' in source - ) { - if (source.plugin === 'compact') return 'compaction' - if (source.plugin === 'rewind') return 'rewind' - } - return 'rewrite' -} - -function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean { - switch (chunk.type) { - case 'text-delta': - case 'reasoning-delta': - return chunk.text !== '' - case 'tool-call-delta': - return chunk.argumentsDelta !== '' || chunk.name !== undefined - default: - return false - } } diff --git a/packages/client/runtime/src/client/sessions/history.ts b/packages/client/runtime/src/client/sessions/history.ts index 369c7ce43a..520503a561 100644 --- a/packages/client/runtime/src/client/sessions/history.ts +++ b/packages/client/runtime/src/client/sessions/history.ts @@ -1,8 +1,10 @@ import type { ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' -import type { ConversationNode } from './conversation.ts' +import type { + CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall, +} from './conversation.ts' import type { ConversationContext } from './conversation-context.ts' -import { projectConversationHistory } from './fold-adapter.ts' +import { projectConversationHistory } from '../session-history/history-fold.ts' import { inspectRequests, type RequestView } from './request-inspection.ts' /** Lazily derived inspection data for one immutable session-history window. */ @@ -11,6 +13,10 @@ export interface SessionHistoryInspection { contexts: readonly ConversationContext[] requests: readonly RequestView[] callSchemas: ReadonlyMap + interruptedNodes: readonly ConversationNode[] + partial: PartialAssistant | null + runningCalls: readonly RunningToolCall[] + codeDispatches: ReadonlyMap } /** @@ -38,6 +44,18 @@ export function createHistoryInspection( get contexts() { return conversationProjection().contexts }, + get interruptedNodes() { + return conversationProjection().interruptedNodes + }, + get partial() { + return conversationProjection().partial + }, + get runningCalls() { + return conversationProjection().runningCalls + }, + get codeDispatches() { + return conversationProjection().codeDispatches + }, get requests() { return requestProjection().requests }, diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 601fd7cfbd..0f5d39ac8e 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -12,12 +12,9 @@ import type { import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SessionFace } from '../contract/session.ts' import type { - CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, - OpenState, PromptError, QueuedMessage, RunningToolCall, + CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, + PromptError, QueuedMessage, RunningToolCall, } from './conversation.ts' -import { - createHistoryInspection, type SessionHistoryInspection, -} from './history.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' import { FoldAdapter } from './fold-adapter.ts' @@ -51,10 +48,6 @@ export interface SessionOptions { /** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */ const QUEUE_PREVIEW_CHARS = 200 -function isAborted(signal: AbortSignal | undefined): boolean { - return signal?.aborted === true -} - /** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */ interface QueuedEntry { row: QueuedMessage @@ -73,11 +66,10 @@ function queuePreviewOf(content: readonly ContentBlock[]): string { } /** - * Owns a session's event window, folded conversation, raw history inspection, - * and observable snapshot. React bindings remain outside this data layer. - * Features see only the {@link SessionFace} slice (ISession verbs + the - * snapshot source); the remaining public members are manager/runtime entry - * points. + * Owns a session's event window, derived conversation state, and observable + * snapshot. React bindings remain outside this data layer. Features see only + * the {@link SessionFace} slice (ISession verbs + the snapshot source); the + * remaining public members are manager/runtime entry points. */ export class Session implements SessionFace { // ---- Window and derived state (all private; the snapshot is the only read surface) ---- @@ -122,13 +114,6 @@ export class Session implements SessionFace { private codeDispatches = new Map() private dispatchesRev = 0 private dispatchesCache: { rev: number; value: ReadonlyMap } | null = null - /** Raw history revision; inspection wrappers capture the exact array window and length. */ - private historyRev = 0 - private historyInspectionCache: { - rev: number - value: SessionHistoryInspection - } | null = null - private loadOlderPromise: Promise | null = null private running = false /** * Sticky send marker, private input of the composerPhase derivation: set @@ -294,75 +279,40 @@ export class Session implements SessionFace { return promise } - /** - * Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). - * Concurrent callers share the active page so complete-history readers can continue afterward. - * @returns When the active or newly started page request settles. - */ - loadOlder(): Promise { - if (this.loadOlderPromise !== null) return this.loadOlderPromise - if (this.openState !== 'open' || !this.hasMore) return Promise.resolve() + /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */ + async loadOlder(): Promise { + if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return this.loadingOlder = true this.notifier.markDirty() - const generation = this.openGeneration - const operation = (async () => { - try { - const { result } = await this.api.sessions.history({ - sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES, - }) - if (generation !== this.openGeneration || this.openState !== 'open') return - if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded) - const older = result.value.events - if (older.length === 0) { - this.hasMore = result.value.hasMore - return - } - const tail = older[older.length - 1] - if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) { - // §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream. - console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`) - this.hasMore = false - return - } - this.events = [...older.map(e => e.event), ...this.events] - this.views = [...older.map(e => e.view), ...this.views] - this.historyRev++ - /* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */ - this.baseSeq = older[0]?.event.seq ?? this.baseSeq + try { + const { result } = await this.api.sessions.history({ + sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES, + }) + if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded) + const older = result.value.events + if (older.length === 0) { this.hasMore = result.value.hasMore - this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed) - this.rebuildDerivedFromWindow() - } catch (error) { - console.error('[web-runtime] loadOlder failed:', error) + return } - })() - const settled = operation.finally(() => { - if (this.loadOlderPromise !== settled) return - this.loadOlderPromise = null + const tail = older[older.length - 1] + if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) { + // §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream. + console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`) + this.hasMore = false + return + } + this.events = [...older.map(e => e.event), ...this.events] + this.views = [...older.map(e => e.view), ...this.views] + /* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */ + this.baseSeq = older[0]?.event.seq ?? this.baseSeq + this.hasMore = result.value.hasMore + this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed) + this.rebuildDerivedFromWindow() + } catch (error) { + console.error('[web-runtime] loadOlder failed:', error) + } finally { this.loadingOlder = false this.notifier.markDirty() - }) - this.loadOlderPromise = settled - return settled - } - - /** - * Exhaust history paging for inspection surfaces that require a complete - * session ledger. Stops after a failed or non-advancing page so a transient - * backend failure cannot become an automatic retry loop, and observes - * cancellation between pages without abandoning an active unary request. - * @param signal - Mounted consumer lifetime; abort stops before the next page. - * @returns When the available history has been exhausted or paging stops making progress. - */ - async loadAllHistory(signal?: AbortSignal): Promise { - while ( - !isAborted(signal) - && this.openState === 'open' - && this.hasMore - ) { - const previousBaseSeq = this.baseSeq - await this.loadOlder() - if (isAborted(signal) || this.baseSeq === previousBaseSeq) return } } @@ -379,13 +329,10 @@ export class Session implements SessionFace { if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) this.openGeneration++ this.openPromise = null - this.loadOlderPromise = null - this.loadingOlder = false this.openState = 'cold' this.openError = null this.events = [] this.views = [] - this.historyRev++ this.baseSeq = 0 // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host. @@ -604,7 +551,6 @@ export class Session implements SessionFace { private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { this.events = entries.map(e => e.event) this.views = entries.map(e => e.view) - this.historyRev++ this.baseSeq = this.events[0]?.seq ?? 0 this.hasMore = hasMore this.foldAdapter.reset(this.events, this.baseSeq, this.views) @@ -622,7 +568,6 @@ export class Session implements SessionFace { if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop this.events.push(event) this.views.push(view) - if (event.type !== 'assistant/chunk') this.historyRev++ this.foldAdapter.append(event, view) this.applyEventSideEffects(event, view) } @@ -874,7 +819,6 @@ export class Session implements SessionFace { return { sessionId: this.sessionId, nodes, - inspection: this.buildHistoryInspection(), foldDegraded: degraded, partial, runningCalls: this.callsCache.value, @@ -899,32 +843,6 @@ export class Session implements SessionFace { lastAgentError: this.lastAgentError, } } - - /** Build a lazy inspection wrapper for the exact current history window. */ - private buildHistoryInspection(): SessionHistoryInspection { - if ( - this.historyInspectionCache === null - || this.historyInspectionCache.rev !== this.historyRev - ) { - const events = this.events - const views = this.views - const length = events.length - this.historyInspectionCache = { - rev: this.historyRev, - value: createHistoryInspection(() => - Array.from({ length }, (_, index) => { - const event = events[index] - if (event === undefined) { - throw new Error('captured history window changed before inspection') - } - const view = views[index] - return view === undefined ? { event } : { event, view } - }), - ), - } - } - return this.historyInspectionCache.value - } } /** diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index 5f0f417601..6c44aa04f0 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -7,9 +7,8 @@ import { createUserMessage, CallId, createMessage, createToolResultMessage } fro import { describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { - FoldAdapter, projectConversationHistory, -} from '../src/client/sessions/fold-adapter.ts' +import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts' +import { projectConversationHistory } from '../src/client/session-history/history-fold.ts' import { ev, plainTurn } from './event-script.ts' const at = (seq: number, e: Record): SessionEvent => diff --git a/packages/client/runtime/tests/session-history-source.spec.ts b/packages/client/runtime/tests/session-history-source.spec.ts new file mode 100644 index 0000000000..d4e8fc9b41 --- /dev/null +++ b/packages/client/runtime/tests/session-history-source.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import { SessionHistorySource } from '../src/client/session-history/source.ts' +import { FakeApiClient, deferred, err, ok } from './fake-api.ts' +import { entries, ev, plainTurn } from './event-script.ts' + +const SID = 'history-s1' as SessionId + +function histResponse(events: SessionEvent[], hasMore = false) { + return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) +} + +describe('SessionHistorySource', () => { + it('loads every older page without changing a Chat session', async () => { + const pages = [ + plainTurn(0, 0, '最早问', '最早答'), + plainTurn(6, 1, '中间问', '中间答'), + plainTurn(12, 2, '最新问', '最新答'), + ] + const api = new FakeApiClient() + api.onHistory = (payload) => { + if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true) + if (payload.beforeSeq === 12) return histResponse(pages[1]!, true) + return histResponse(pages[0]!, false) + } + const source = new SessionHistorySource(SID, api) + + await source.loadAll() + + expect(api.callsOf('session.history')).toHaveLength(3) + expect(source.getSnapshot().hasMore).toBe(false) + expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) + .toEqual([1, 3, 7, 9, 13, 15]) + }) + + it('pins a lazy inspection to the entries in its source snapshot', async () => { + const api = new FakeApiClient() + api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) + const source = new SessionHistorySource(SID, api) + await source.loadAll() + const before = source.getSnapshot() + + source.handleMuxFrame({ + type: 'session/event', + sessionId: SID, + event: ev.user(6, 'later'), + }) + + expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3]) + expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) + .toEqual([1, 3, 6]) + }) + + it('stops loading when an older page fails to advance', async () => { + const api = new FakeApiClient() + api.onHistory = payload => payload.beforeSeq === undefined + ? histResponse(plainTurn(6, 1, '新问', '新答'), true) + : Promise.resolve(err({ + code: 'internal', + message: 'page unavailable', + details: {}, + })) + const source = new SessionHistorySource(SID, api) + + await source.loadAll() + + expect(api.callsOf('session.history')).toHaveLength(2) + expect(source.getSnapshot().hasMore).toBe(true) + }) + + it('observes consumer cancellation between older pages', async () => { + const middle = deferred>>() + const olderStarted = deferred() + const api = new FakeApiClient() + api.onHistory = (payload) => { + if (payload.beforeSeq === undefined) { + return histResponse(plainTurn(12, 2, '最新问', '最新答'), true) + } + olderStarted.resolve() + return middle.promise + } + const source = new SessionHistorySource(SID, api) + const controller = new AbortController() + const complete = source.loadAll(controller.signal) + await olderStarted.promise + controller.abort() + middle.resolve(ok({ + events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[], + hasMore: true, + })) + + await complete + + expect(api.callsOf('session.history')).toHaveLength(2) + expect(source.getSnapshot().hasMore).toBe(true) + }) +}) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index a705357a9c..3f6388b9eb 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -161,21 +161,6 @@ describe('live event path', () => { expect((last as { interrupted?: true }).interrupted).toBeUndefined() }) - it('keeps a lazily inspected snapshot pinned to its original history window', async () => { - const { session } = await opened() - const before = session.getSnapshot() - - session.handleMuxEnvelope('r' as never, { - type: 'session/event', - sessionId: SID, - event: ev.user(6, 'later'), - }) - - expect(before.inspection?.eventNodes.map(node => node.seq)).toEqual([1, 3]) - expect(session.getSnapshot().inspection?.eventNodes.map(node => node.seq)) - .toEqual([1, 3, 6]) - }) - it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } @@ -241,92 +226,6 @@ describe('paging', () => { expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9]) }) - it('loads every older page for complete-history inspection', async () => { - const pages = [ - plainTurn(0, 0, '最早问', '最早答'), - plainTurn(6, 1, '中间问', '中间答'), - plainTurn(12, 2, '最新问', '最新答'), - ] - const { api, session } = makeSession() - api.onHistory = (payload) => { - if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true) - if (payload.beforeSeq === 12) return histResponse(pages[1]!, true) - return histResponse(pages[0]!, false) - } - - await session.open() - await session.loadAllHistory() - - expect(api.callsOf('session.history')).toHaveLength(3) - expect(session.getSnapshot().hasMore).toBe(false) - expect(session.getSnapshot().inspection?.eventNodes.map(node => node.seq)) - .toEqual([1, 3, 7, 9, 13, 15]) - expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([1, 3, 7, 9, 13, 15]) - }) - - it('stops complete-history loading when a page makes no progress', async () => { - const { api, session } = makeSession() - api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(plainTurn(6, 1, '新问', '新答'), true) - : Promise.resolve(err({ code: 'internal', message: 'page unavailable', details: {} })) - - await session.open() - await session.loadAllHistory() - - expect(api.callsOf('session.history')).toHaveLength(2) - expect(session.getSnapshot().hasMore).toBe(true) - }) - - it('continues complete-history loading after an already active page', async () => { - const pages = [ - plainTurn(0, 0, '最早问', '最早答'), - plainTurn(6, 1, '中间问', '中间答'), - plainTurn(12, 2, '最新问', '最新答'), - ] - const middle = deferred>>() - const { api, session } = makeSession() - api.onHistory = (payload) => { - if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true) - if (payload.beforeSeq === 12) return middle.promise - return histResponse(pages[0]!, false) - } - - await session.open() - const activePage = session.loadOlder() - const completeHistory = session.loadAllHistory() - middle.resolve(ok({ - events: entries(pages[1]!) as never[], - hasMore: true, - modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - })) - await Promise.all([activePage, completeHistory]) - - expect(api.callsOf('session.history')).toHaveLength(3) - expect(session.getSnapshot().hasMore).toBe(false) - expect(session.getSnapshot().nodes.map(node => node.seq)) - .toEqual([1, 3, 7, 9, 13, 15]) - }) - - it('stops complete-history loading between pages after its consumer aborts', async () => { - const middle = deferred>>() - const { api, session } = makeSession() - api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(plainTurn(12, 2, '最新问', '最新答'), true) - : middle.promise - await session.open() - const controller = new AbortController() - const completeHistory = session.loadAllHistory(controller.signal) - controller.abort() - middle.resolve(ok({ - events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[], - hasMore: true, - })) - await completeHistory - - expect(api.callsOf('session.history')).toHaveLength(2) - expect(session.getSnapshot().hasMore).toBe(true) - }) - it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => { const { api, session } = makeSession() api.onHistory = payload => payload.beforeSeq === undefined @@ -803,31 +702,6 @@ describe('resync', () => { expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9]) }) - it('starts fresh paging while an older generation page is still pending', async () => { - const stalePage = deferred>>() - const { api, session } = makeSession() - let call = 0 - api.onHistory = () => { - call++ - if (call === 1) return histResponse(plainTurn(6, 1, '新', '页'), true) - if (call === 2) return stalePage.promise - if (call === 3) return histResponse(plainTurn(6, 1, '新', '代'), true) - return histResponse(plainTurn(0, 0, '旧', '页'), false) - } - await session.open() - const stale = session.loadOlder() - await session.resync() - const fresh = session.loadAllHistory() - await vi.waitFor(() => { expect(call).toBe(4) }) - stalePage.resolve(ok({ - events: entries(plainTurn(0, 0, '废', '弃')) as never[], - hasMore: false, - })) - await Promise.all([stale, fresh]) - - expect(session.getSnapshot().hasMore).toBe(false) - expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([1, 3, 7, 9]) - }) }) describe('run_code sub-dispatch indexing', () => { diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 9142cdc730..408badeb42 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -108,13 +108,6 @@ export class FixtureSession implements SessionFace { throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`) } - /** - * Fail-loud stub; supply `loadAllHistory` on the fixture's session face to exercise it. - * @returns never — always throws. - */ - loadAllHistory(): never { - throw new Error(`test session "${this.sessionId}": loadAllHistory is not stubbed — supply it on the fixture's session face`) - } } /** One live test session: fixture-derived stores plus its minted scope state. */ diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 6e6ea32741..22d3bb5cfc 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -470,7 +470,6 @@ describe('fixture session face', () => { expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) - expect(() => bare.loadAllHistory()).toThrow(/loadAllHistory is not stubbed/) await runtime.dispose() }) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index db6f8bf79a..2b845e6c83 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -83,8 +83,8 @@ export function apply(ctx: Context): void { }, }), 'ui-conversation: input standard-kit provider') - // Resident current-session-optional shell. It constructs the stable - // composer frame; the strict session child decides whether Chat mounts it. + // Resident current-session-optional shell. It owns the stable Hero/composer + // frame while strict session slots fill only their session-bound regions. slots.register({ name: 'conversation', children: { @@ -114,8 +114,8 @@ export function apply(ctx: Context): void { }), }, ConversationRoot) - // The strict session subtree owns the per-session store and view content; - // the resident parent supplies the composer node through owner props. + // The strict session subtree owns only per-session store and view content; + // the resident parent keeps Hero and composer layout identity stable. slots.register({ name: 'conversation.session', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 737e2da920..9f3f3edf02 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -19,7 +19,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps } /** * The conversation view ring: one list entry per view tab (chat here; - * Trajectory from ui-trajectory), rendered one-at-a-time by + * trajectory/waterfall from ui-trajectory), rendered one-at-a-time by * ConversationRoot via `only: `. Declared by this package's * 'conversation' entry (declaring is claiming). Session scope: views read * the conversation snapshot through the standard kit. @@ -117,8 +117,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Owner share of the strict session content seat. */ export interface ConversationSessionOwnerProps { - /** Composer chain assembled by the resident parent; only Chat mounts it. */ - composer: ReactNode } /** @@ -143,7 +141,7 @@ export interface ConvViewOwnerProps {} /** * Owner share of a per-view toolview slot: the call material the rendering - * view supplies per row. Uniform across views — the Trajectory + * view supplies per row. Uniform across views — the trajectory/waterfall * toolview slots (same kind/scope/owner, names fixed by the slot-naming * discipline) land with their own row render sites; today only the chat slot * is declared (RendersCheck rejects a declaration nobody renders). diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 84e62d0ef6..b110860685 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -118,11 +118,6 @@ export function ConversationRoot({ {inputBar} ) - const composer = renderSlotChain( - 'conversation.composer', - { interactions: pending }, - { fallback: composerBar, overlay: true }, - ) return (
@@ -130,9 +125,12 @@ export function ConversationRoot({ renders no chrome while blank but owns the draft-persistence mirror bind — unmounting it in the hero would lose pre-first-send text on a refresh or scope rebuild. */} - {sessionId !== undefined - ? renderSlot('conversation.session', { composer }) - : composer} + {sessionId !== undefined && renderSlot('conversation.session', {})} + {renderSlotChain( + 'conversation.composer', + { interactions: pending }, + { fallback: composerBar, overlay: true }, + )}
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 46e08fae62..0c1addcba7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,6 +1,6 @@ /** Strict per-session conversation content: header, view ring, and chat store bindings. */ -import { Fragment, useEffect, useSyncExternalStore } from 'react' +import { useEffect, useSyncExternalStore } from 'react' import clsx from 'clsx' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationSession({ sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, - renderSlot, views, bindDraftMirror, open, composer, + renderSlot, views, bindDraftMirror, open, }: ConversationSessionProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -32,7 +32,6 @@ export function ConversationSession({ const active = tabs.find(view => view.id === activeId) ?? tabs[0] const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) const composerPhase = useSession(s => s.composerPhase) - const hasPending = useSession(s => s.pending.length > 0) const blank = useSession(s => s.blank) const inputState = useInput(s => s) const storedDraft = useStore(s => s.draft) @@ -45,11 +44,11 @@ export function ConversationSession({ // the machine mirror, not this seed effect. }, [inputActions]) - const blankHero = blank && composerPhase === 'blank' + if (blank && composerPhase === 'blank') return null return ( <> - {!blankHero &&
+
)} -
} - {!blankHero &&
+
+
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} -
} - {(blankHero || active?.id === 'chat' || hasPending) && ( - {composer} - )} + ) } diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index d9ae93aa9f..416f3fa4ef 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -34,7 +34,6 @@ function sessionFakeFor() { return { open: vi.fn(() => Promise.resolve()), loadOlder: vi.fn(() => Promise.resolve()), - loadAllHistory: vi.fn(() => Promise.resolve()), prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })), } satisfies SessionBehaviorOverrides diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 9fe4d5d02c..3d2e9ea0e6 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -59,7 +59,6 @@ async function bench(nodes: ToolResultNode[]) { snapshot: { nodes }, session: { loadOlder: vi.fn(), - loadAllHistory: vi.fn(), prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), }, }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 8c7dcc2725..12123d5e6d 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -18,7 +18,7 @@ import { ConversationSession } from '../src/client/skeleton/ConversationSession. import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import type { - ComposerBarOwnerProps, ConversationSessionOwnerProps, + ComposerBarOwnerProps, } from '../src/client/contract/slots.ts' /** Machine-backed wiring over a sink spy. */ @@ -88,10 +88,8 @@ function mount( slotCalls.push(key) if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null } if (key === 'conversation.session') { - const sessionOwner = owner as ConversationSessionOwnerProps return ( children(SID)} useSession={useSession} diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index 53d51eaf0c..b805bb178a 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -49,15 +49,17 @@ } /* Drag handles are frame children (columns clip overflow): an 8px hit strip - centered on the column border via inline left, above column content. */ + centered on the column border via inline left, above column content. The + visible pill (12x32 r10, riding the border at vertical center) is the figma + Handle component; the hit strip stays wider than the pill. */ .handle { position: absolute; - z-index: 6; top: 0; bottom: 0; width: 8px; margin-left: -4px; cursor: col-resize; + z-index: 2; touch-action: none; /* Rides the same curve as the tracks so the pill stays on the moving border during collapse/expand; paused while dragging (frame rule). */ @@ -73,3 +75,36 @@ transition: none; } } + +.handle::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 12px; + height: 32px; + border-radius: 10px; + box-sizing: border-box; + background: var(--dsw-alias-button-floating-fill); + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + /* Hover affordance: the pill hides until the pointer is over the owning + column (data-side pairs handle and column), the strip itself, or a drag. */ + opacity: 0; + transition: + opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out), + background var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.sidebarCol:hover ~ .handle[data-side='sidebar']::after, +.detailsCol:hover ~ .handle[data-side='details']::after, +.handle:hover::after, +.handle[data-dragging='true']::after { + opacity: 1; +} + +.handle:hover::after, +.handle[data-dragging='true']::after { + background: var(--dsw-alias-button-floating-hover); + border-color: var(--dsw-alias-border-l3); +} diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index 27e18fbdfc..7222c3df44 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -66,17 +66,6 @@ font: inherit; } -.noLanguage .bannerWrap { - position: absolute; - right: 0; - background: transparent; -} - -.noLanguage .banner { - padding-left: 8px; - background: transparent; -} - .block :where(pre) { font: var(--dsl-code-block-content-font); padding: 16px; @@ -87,10 +76,6 @@ background: var(--dsw-alias-markdown-code-block); } -.noLanguage :where(pre) { - padding-right: 72px; -} - /* Shiki inlines its theme background var; route it to the repo token. */ .block :where(pre.shiki) { background: var(--dsw-alias-markdown-code-block) !important; diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 5265006ab7..de6a478af4 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -59,7 +59,6 @@ async function writeClipboard(text: string): Promise { export function CodeBlock({ code, lang, className }: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code - const hasLanguage = lang !== undefined && lang !== '' const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang]) const rootRef = useRef(null) const [copied, setCopied] = useState(false) @@ -88,10 +87,7 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) { ) return ( -
+
{lang ?? ''}
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 1d50dd5560..67d4b929d9 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { - AssistantMessageNode, ConversationContext, ConversationNode, RequestView, + AssistantMessageNode, ConversationContext, RequestView, + SessionHistoryFace, } from '@deepseek-ai/dsh-client-runtime/client' import { deriveTrajectoryContextBranches, trajectoryBranchContainsRequest, @@ -28,6 +30,7 @@ const EMPTY_REQUESTS: readonly RequestView[] = [] /** Session-history paging needed by the event-complete trajectory view. */ export interface TrajectoryViewInjected { + hooks: { history: SessionHistoryFace } loadAllHistory: (signal: AbortSignal) => Promise } @@ -84,12 +87,6 @@ function searchableJson(value: unknown): string { } } -function isInterruptedNode(node: ConversationNode): boolean { - return node.kind === 'assistant' - ? node.interrupted === true - : node.kind === 'tool-result' && node.error?.code === 'interrupted' -} - function searchMatches( turns: ReturnType, query: string, @@ -137,7 +134,9 @@ function searchMatches( return matches } -export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & TrajectoryViewInjected) { +export function TrajectoryView({ + useHistory, loadAllHistory, +}: ConvViewProps & InjectFace) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_IDS) const [collapsedAssistants, setCollapsedAssistants] = useState>(EMPTY_IDS) @@ -150,26 +149,22 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T const [searchQuery, setSearchQuery] = useState('') const [selectedTimelineIndex, setSelectedTimelineIndex] = useState(null) const ledgerRef = useRef(null) - const nodes = useSession(s => s.nodes) - const inspection = useSession(s => s.inspection) - const hasMore = useSession(s => s.hasMore) - const openState = useSession(s => s.openState) - const partial = useSession(s => s.partial) - const runningCalls = useSession(s => s.runningCalls) - const codeDispatches = useSession(s => s.codeDispatches) + const inspection = useHistory(snapshot => snapshot.inspection) + const nodes = inspection.eventNodes + const partial = inspection.partial + const runningCalls = inspection.runningCalls + const codeDispatches = inspection.codeDispatches const loadAllHistoryRef = useRef(loadAllHistory) loadAllHistoryRef.current = loadAllHistory useEffect(() => { const controller = new AbortController() - if (openState === 'open' && hasMore) { - void loadAllHistoryRef.current(controller.signal) - } + void loadAllHistoryRef.current(controller.signal) return () => { controller.abort() } - }, [hasMore, openState]) - const requests = inspection?.requests ?? EMPTY_REQUESTS - const callSchemas = inspection?.callSchemas + }, []) + const requests = inspection.requests ?? EMPTY_REQUESTS + const callSchemas = inspection.callSchemas const contexts = useMemo( - () => inspection === undefined || inspection.contexts.length === 0 + () => inspection.contexts.length === 0 ? [{ id: 0, nodes }] : inspection.contexts, [inspection, nodes], @@ -182,11 +177,11 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty') const selectedNodes = useMemo(() => { const selected = new Map(currentBranch.nodes.map(node => [node.seq, node])) - for (const node of nodes) { - if (isInterruptedNode(node)) selected.set(node.seq, node) + for (const node of inspection.interruptedNodes) { + selected.set(node.seq, node) } return [...selected.values()].sort((left, right) => left.seq - right.seq) - }, [currentBranch, nodes]) + }, [currentBranch, inspection]) const selectedRequests = useMemo( () => requests.filter(request => trajectoryBranchContainsRequest(currentBranch, request), diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index de0ea3ed31..347b5c9cec 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -16,7 +16,7 @@ import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.ts * into an undeclared slot throws — service waiting is what orders this * apply after the declaring one. */ -export const inject = ['slots', 'conversation', 'sessions'] +export const inject = ['slots', 'conversation', 'sessionHistory'] /** * Client plugin body: register the trajectory view tab. The registration @@ -30,12 +30,10 @@ export function apply(ctx: Context): void { order: 10, label: 'Trajectory', inject: (sessionId: SessionId): TrajectoryViewInjected => { - const session = ctx.sessions.binding(sessionId)?.session - if (session === undefined) { - throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`) - } + const history = ctx.sessionHistory.source(sessionId) return { - loadAllHistory: signal => session.loadAllHistory(signal), + hooks: { history }, + loadAllHistory: signal => history.loadAll(signal), } }, }, TrajectoryView) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 5f39acecb1..5d391bc8cb 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -10,13 +10,14 @@ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { createElement, type FC, type ReactNode } from 'react' +import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { - ConversationSnapshot, RequestView, SessionId, SessionListState, WorkspaceListState, + ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection, + SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' @@ -24,7 +25,9 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' -import { TrajectoryView } from '../src/client/TrajectoryView.tsx' +import { + TrajectoryView, type TrajectoryViewInjected, +} from '../src/client/TrajectoryView.tsx' import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' const SID = 's1' as SessionId @@ -54,6 +57,38 @@ const NODES = [ }, ] as unknown as ConversationSnapshot['nodes'] +function historySnapshot( + nodes: ConversationSnapshot['nodes'], + inspection: Partial = {}, +): SessionHistorySnapshot { + return { + state: 'ready', + error: null, + hasMore: false, + inspection: { + eventNodes: nodes, + contexts: [{ id: 0, nodes }], + requests: [], + callSchemas: new Map(), + interruptedNodes: [], + partial: null, + runningCalls: [], + codeDispatches: new Map(), + ...inspection, + }, + } +} + +function standaloneHistory( + snapshot: SessionHistorySnapshot, +): Pick, 'useHistory' | 'loadAllHistory'> { + const store = createSnapshotStore(snapshot) + return { + useHistory: bindSnapshotSelector(store), + loadAllHistory: () => Promise.resolve(), + } +} + function fakeSession(nodes: ConversationSnapshot['nodes']) { const store = createSnapshotStore({ nodes, pending: [], partial: null, @@ -93,6 +128,13 @@ async function bench() { const ctx = new Context() const slots = new SlotsService(ctx) const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve()) + const historyStore = createSnapshotStore(historySnapshot(NODES)) + const history: SessionHistoryFace = { + sessionId: SID, + getSnapshot: historyStore.getSnapshot, + subscribe: historyStore.subscribe, + loadAll: loadAllHistory, + } // The conversation entry's role: declare the ring, then seed the chat entry. slots.register({ name: 'root', @@ -104,11 +146,7 @@ async function bench() { // 'conversation' inject is an ordering edge; the bench declares the ring // itself, so a stub satisfies the wait. ctx.provide('conversation', {}) - ctx.provide('sessions', { - binding: (sessionId: SessionId) => sessionId === SID - ? { session: { loadAllHistory } } - : undefined, - }) + ctx.provide('sessionHistory', { source: () => history }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { ctx, slots, fiber, loadAllHistory } @@ -141,9 +179,17 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES const injected = injectEntry === undefined ? {} : injectEntry(SID) + const injectedProps = 'hooks' in injected + ? { + loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory, + useHistory: bindSnapshotSelector( + (injected as TrajectoryViewInjected).hooks.history, + ), + } + : injected return ( @@ -151,7 +197,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES }) as unknown as ConversationSessionProps['renderSlot'] return render( children(SID)} useSession={useSession} @@ -345,7 +390,10 @@ describe('timeline projection', () => { expect(deriveTrajectoryTimeline([])).toBeNull() render(createElement( TrajectoryView, - { ...standaloneProps([]), loadAllHistory: () => Promise.resolve() }, + { + ...standaloneProps([]), + ...standaloneHistory(historySnapshot([])), + }, )) expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.queryByRole('row')).toBeNull() @@ -386,9 +434,9 @@ describe('TrajectoryView branches', () => { completedAt: startSeq * 1_000 + 100, status: 'complete', }) - const store = createSnapshotStore({ - nodes: [retained, current], - inspection: { + const store = createSnapshotStore(historySnapshot( + [retained, abandoned, current], + { eventNodes: [retained, abandoned, current], contexts: [ { id: 0, nodes: [retained, abandoned] }, @@ -403,17 +451,12 @@ describe('TrajectoryView branches', () => { requests: [request(2, 1), request(4, 2)], callSchemas: new Map(), }, - openState: 'open' as const, - hasMore: false, - partial: null, - runningCalls: [] as ConversationSnapshot['runningCalls'], - codeDispatches: new Map(), - }) + )) const view = render( } + useHistory={bindSnapshotSelector(store)} loadAllHistory={vi.fn(() => Promise.resolve())} />, ) @@ -441,25 +484,21 @@ describe('TrajectoryView branches', () => { error: { name: 'Interrupted', code: 'interrupted' }, callView: null, resultView: null, } as unknown as ConversationSnapshot['nodes'][number] - const store = createSnapshotStore({ - nodes: [retained, interruptedAssistant, interruptedTool], - inspection: { + const store = createSnapshotStore(historySnapshot( + [retained], + { eventNodes: [retained], contexts: [{ id: 0, nodes: [retained] }], requests: [], callSchemas: new Map(), + interruptedNodes: [interruptedAssistant, interruptedTool], }, - openState: 'open' as const, - hasMore: false, - partial: null, - runningCalls: [] as ConversationSnapshot['runningCalls'], - codeDispatches: new Map(), - }) + )) render( } + useHistory={bindSnapshotSelector(store)} loadAllHistory={vi.fn(() => Promise.resolve())} />, )