ソースを参照

refactor(client): isolate trajectory history

imccyu 2 ヶ月 前
親
コミット
e0022ff19e
28 ファイル変更、1261 行追加、698 行削除
  1. 35 0
      packages/client/runtime/src/client/contract/session-history.ts
  2. 0 7
      packages/client/runtime/src/client/contract/session.ts
  3. 35 3
      packages/client/runtime/src/client/index.ts
  4. 448 0
      packages/client/runtime/src/client/session-history/history-fold.ts
  5. 66 0
      packages/client/runtime/src/client/session-history/service.ts
  6. 352 0
      packages/client/runtime/src/client/session-history/source.ts
  7. 0 3
      packages/client/runtime/src/client/sessions/conversation.ts
  8. 19 314
      packages/client/runtime/src/client/sessions/fold-adapter.ts
  9. 20 2
      packages/client/runtime/src/client/sessions/history.ts
  10. 34 116
      packages/client/runtime/src/client/sessions/session.ts
  11. 2 3
      packages/client/runtime/tests/fold-adapter.spec.ts
  12. 98 0
      packages/client/runtime/tests/session-history-source.spec.ts
  13. 0 126
      packages/client/runtime/tests/session.spec.ts
  14. 0 7
      packages/client/test-runtime/src/sessions.ts
  15. 0 1
      packages/client/test-runtime/tests/runtime.spec.tsx
  16. 4 4
      packages/client/ui-conversation/src/client/apply.ts
  17. 2 4
      packages/client/ui-conversation/src/client/contract/slots.ts
  18. 6 8
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  19. 7 11
      packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
  20. 0 1
      packages/client/ui-conversation/tests/apply-inject.spec.tsx
  21. 0 1
      packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
  22. 1 3
      packages/client/ui-conversation/tests/skeleton.spec.tsx
  23. 37 2
      packages/client/ui-layout/src/client/AppFrame.module.css
  24. 0 15
      packages/client/ui-primitives/src/markdown/CodeBlock.module.css
  25. 1 5
      packages/client/ui-primitives/src/markdown/CodeBlock.tsx
  26. 20 25
      packages/client/ui-trajectory/src/client/TrajectoryView.tsx
  27. 4 6
      packages/client/ui-trajectory/src/client/index.ts
  28. 70 31
      packages/client/ui-trajectory/tests/views.spec.tsx

+ 35 - 0
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<SessionHistorySnapshot> {
+  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<void>
+}
+
+/** 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
+}

+ 0 - 7
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<void>
-  /**
-   * 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<void>
   /**
    * Execute one slash-command line against this session's agent — pure
    * admission semantics (the host executor durably logs the lifecycle).

+ 35 - 3
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')

+ 448 - 0
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<string, readonly CodeSubCall[]>
+}
+
+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<string, CallIndexEntry>,
+  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<string, RunningToolCall>()
+  const interruptedNodes: ConversationNode[] = []
+  const codeDispatches = new Map<string, readonly CodeSubCall[]>()
+
+  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<string, CallIndexEntry>()
+  const resultViews = new Map<number, ToolResultView>()
+  const assistantSteps = new Map<string, AssistantStepMetadata>()
+  const assistantTimings = new Map<number, AssistantTiming>()
+  const assistantRequestConfigs = new Map<number, AssistantRequestConfig>()
+  const promptsByContext = new Map<number, ConversationPromptSnapshot>()
+  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<number, ConversationNode>()
+  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),
+  }
+}

+ 66 - 0
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<SessionId, SessionHistorySource>()
+
+  /**
+   * @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<MuxFrame>): 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<HostFrame>): 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()
+  }
+}

+ 352 - 0
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<AbortSignal>()
+  private openPromise: Promise<void> | null = null
+  private olderPromise: Promise<void> | 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<void> {
+    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<void> {
+    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<void> {
+    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<void> {
+    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<never>(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<void> {
+    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<void> {
+    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,
+    }
+  }
+}

+ 0 - 3
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

+ 19 - 314
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<string, CallIndexEntry>,
   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<number, ConversationPromptSnapshot>()
-  private assistantSteps = new Map<string, AssistantStepMetadata>()
-  private assistantTimings = new Map<number, AssistantTiming>()
-  private activeRequestConfig: AssistantRequestConfig | undefined
-  private assistantRequestConfigs = new Map<number, AssistantRequestConfig>()
-
-  /**
-   * @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<string, CallIndexEntry> {
@@ -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
-  }
 }

+ 20 - 2
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<string, ToolSchema>
+  interruptedNodes: readonly ConversationNode[]
+  partial: PartialAssistant | null
+  runningCalls: readonly RunningToolCall[]
+  codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
 }
 
 /**
@@ -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
     },

+ 34 - 116
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<string, readonly CodeSubCall[]>()
   private dispatchesRev = 0
   private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | 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<void> | 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<void> {
-    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<void> {
+    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 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
       }
-    })()
-    const settled = operation.finally(() => {
-      if (this.loadOlderPromise !== settled) return
-      this.loadOlderPromise = null
+      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<void> {
-    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
-  }
 }
 
 /**

+ 2 - 3
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<string, unknown>): SessionEvent =>

+ 98 - 0
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<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    const olderStarted = deferred<void>()
+    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)
+  })
+})

+ 0 - 126
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<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
-    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<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
-    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<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
-    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', () => {

+ 0 - 7
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. */

+ 0 - 1
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()
   })
 

+ 4 - 4
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' } },

+ 2 - 4
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: <active id>`. 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).

+ 6 - 8
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx

@@ -118,11 +118,6 @@ export function ConversationRoot({
       {inputBar}
     </div>
   )
-  const composer = renderSlotChain(
-    'conversation.composer',
-    { interactions: pending },
-    { fallback: composerBar, overlay: true },
-  )
 
   return (
     <div className={css.root} data-phase={settling ? 'settling' : hero ? 'hero' : 'active'}>
@@ -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 },
+      )}
     </div>
   )
 }

+ 7 - 11
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 && <header className={css.header}>
+      <header className={css.header}>
         <div className={css.crumbRow}>
           <nav className={css.crumbs} aria-label="Session hierarchy">
             {ancestry.map((summary, index) => {
@@ -87,13 +86,10 @@ export function ConversationSession({
             ))}
           </div>
         )}
-      </header>}
-      {!blankHero && <div className={css.viewArea}>
+      </header>
+      <div className={css.viewArea}>
         {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
-      </div>}
-      {(blankHero || active?.id === 'chat' || hasPending) && (
-        <Fragment key="composer">{composer}</Fragment>
-      )}
+      </div>
     </>
   )
 }

+ 0 - 1
packages/client/ui-conversation/tests/apply-inject.spec.tsx

@@ -34,7 +34,6 @@ function sessionFakeFor() {
   return {
     open: vi.fn(() => Promise.resolve()),
     loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
-    loadAllHistory: vi.fn<ISession['loadAllHistory']>(() => Promise.resolve()),
     prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
     cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
   } satisfies SessionBehaviorOverrides

+ 0 - 1
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<ISession['loadOlder']>(),
-      loadAllHistory: vi.fn<ISession['loadAllHistory']>(),
       prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
     },
   })

+ 1 - 3
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 (
         <ConversationSession
-          composer={sessionOwner.composer}
           sessionId={SID}
           SessionProvider={({ children }) => children(SID)}
           useSession={useSession}

+ 37 - 2
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);
+}

+ 0 - 15
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;

+ 1 - 5
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -59,7 +59,6 @@ async function writeClipboard(text: string): Promise<boolean> {
 
 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<HTMLDivElement>(null)
   const [copied, setCopied] = useState(false)
@@ -88,10 +87,7 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
     )
 
   return (
-    <div
-      ref={rootRef}
-      className={clsx(css.block, !hasLanguage && css.noLanguage, 'md-code-block', className)}
-    >
+    <div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
       <div className={css.bannerWrap}>
         <div className={css.banner}>
           <div className={css.infostring}>{lang ?? ''}</div>

+ 20 - 25
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<void>
 }
 
@@ -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<typeof deriveTrajectoryLayout>,
   query: string,
@@ -137,7 +134,9 @@ function searchMatches(
   return matches
 }
 
-export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & TrajectoryViewInjected) {
+export function TrajectoryView({
+  useHistory, loadAllHistory,
+}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
   const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
   const [collapsedAssistants, setCollapsedAssistants] =
     useState<ReadonlySet<number>>(EMPTY_IDS)
@@ -150,26 +149,22 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
   const [searchQuery, setSearchQuery] = useState('')
   const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
   const ledgerRef = useRef<HTMLDivElement>(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<readonly ConversationContext[]>(
-    () => 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),

+ 4 - 6
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)

+ 70 - 31
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<SessionHistoryInspection> = {},
+): 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<ComponentProps<typeof TrajectoryView>, '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 (
       <View
-        {...injected}
+        {...injectedProps}
         {...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
         key={key}
       />
@@ -151,7 +197,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
   }) as unknown as ConversationSessionProps['renderSlot']
   return render(
     <ConversationSession
-      composer={null}
       sessionId={SID}
       SessionProvider={({ children }) => 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(
       <TrajectoryView
         {...standaloneProps([])}
-        useSession={bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>}
+        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(
       <TrajectoryView
         {...standaloneProps([])}
-        useSession={bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>}
+        useHistory={bindSnapshotSelector(store)}
         loadAllHistory={vi.fn(() => Promise.resolve())}
       />,
     )