Преглед изворни кода

refactor(client-runtime): remove legacy history fold

imccyu пре 2 месеци
родитељ
комит
c828d38f51

+ 0 - 43
packages/client/runtime/src/client/contract/session-history.ts

@@ -1,43 +0,0 @@
-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
-  /** Absolute sequence of the first loaded raw event, or zero for an empty window. */
-  baseSeq: number
-  inspection: SessionHistoryInspection
-}
-
-/** Read-only history source addressed by session id. */
-export interface SessionHistoryFace
-  extends ObservableSnapshot<SessionHistorySnapshot> {
-  readonly sessionId: SessionId
-  /**
-   * Load the current tail without reading older pages.
-   * @param signal - Consumer lifetime.
-   * @returns When the tail is ready or loading fails.
-   */
-  loadTail(signal?: AbortSignal): Promise<void>
-  /**
-   * Prepend one older page when the current window has a predecessor.
-   * @param signal - Consumer lifetime.
-   * @returns Whether the loaded window advanced.
-   */
-  loadOlder(signal?: AbortSignal): Promise<boolean>
-}
-
-/** 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 - 428
packages/client/runtime/src/client/session-history/history-fold.ts

@@ -1,428 +0,0 @@
-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, ConversationNode,
-  PartialAssistant, RunningToolCall,
-} from '../sessions/conversation.ts'
-import { toAssistantBlocks } from '../sessions/conversation.ts'
-import { contextForm, contextProvenance } from '../sessions/context-provenance.ts'
-import { SteeringHistory } from '../sessions/steering-history.ts'
-import type {
-  ConversationContext, ConversationContextOriginKind,
-} from '../sessions/conversation-context.ts'
-import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
-import { PartialAccumulator } from '../sessions/partial.ts'
-import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
-import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
-import { ToolCallTree } from '../sessions/tool-call-tree.ts'
-
-interface CallIndexEntry {
-  name: string
-  argsRaw: string
-  time: number
-  callView: ToolCallView | null
-}
-
-interface FoldedContext {
-  generation: number
-  nodes: readonly number[]
-  originSeq?: number
-}
-
-/** 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[]
-}
-
-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 foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
-  const replay: SessionEvent[] = []
-  const originalSeqs: number[] = []
-  const rebasedSeqByOriginal = new Map<number, number>()
-  const surface = new SurfaceManager(replay)
-  const contexts: FoldedContext[] = []
-  let generation = 0
-  let originSeq: number | undefined
-  const originalNodes = () => surface.nodes.map((seq) => {
-    const original = originalSeqs[seq]
-    if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`)
-    return original
-  })
-  for (const event of events) {
-    if (!isSurfaceEvent(event)) continue
-    if (event.surfaceOp !== 'append') {
-      contexts.push({
-        generation,
-        nodes: originalNodes(),
-        ...(originSeq === undefined ? {} : { originSeq }),
-      })
-      generation++
-      originSeq = event.seq
-    }
-    const rebasedSeq = replay.length
-    const {
-      sourceEventSeqs: rawSources,
-      ...eventWithoutSources
-    } = event as SessionEvent & { sourceEventSeqs?: readonly number[] }
-    const mappedSourceEventSeqs = rawSources?.flatMap((seq) => {
-      const rebased = rebasedSeqByOriginal.get(seq)
-      return rebased === undefined ? [] : [rebased]
-    })
-    const sourceEventSeqs = mappedSourceEventSeqs?.length === 0
-      ? undefined
-      : mappedSourceEventSeqs
-    const surfaceOp = event.surfaceOp === 'append'
-      ? event.surfaceOp
-      : {
-        ...event.surfaceOp,
-        start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start,
-        end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end,
-      }
-    originalSeqs.push(event.seq)
-    rebasedSeqByOriginal.set(event.seq, rebasedSeq)
-    replay.push({
-      ...eventWithoutSources,
-      seq: rebasedSeq,
-      surfaceOp,
-      ...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
-    } as SessionEvent)
-  }
-  contexts.push({
-    generation,
-    nodes: originalNodes(),
-    ...(originSeq === undefined ? {} : { originSeq }),
-  })
-  return contexts
-}
-
-// History projection owns its node mapping so Chat's live adapter remains free
-// of inspection metadata and lifecycle coupling.
-/* jscpd:ignore-start */
-function materializeNode(
-  event: SessionEvent,
-  callIndex: ReadonlyMap<string, CallIndexEntry>,
-  resultView: ToolResultView | null,
-  assistantTiming: AssistantTiming | undefined,
-  requestConfig: AssistantRequestConfig | undefined,
-  steering: boolean,
-): 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,
-          provenance: contextProvenance(event.data.source),
-          form: contextForm(event.data.source),
-        }
-      }
-      if (steering) {
-        return {
-          kind: 'steering', messageId: event.data.id,
-          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 '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,
-        subCalls: [],
-      }
-    }
-    default:
-      return {
-        kind: 'unknown', seq: event.seq, time: event.time,
-        type: event.type, data: (event as { data?: unknown }).data,
-      }
-  }
-}
-/* jscpd:ignore-end */
-
-interface TransientProjection extends Pick<
-  ConversationHistoryProjection,
-  'interruptedNodes' | 'partial' | 'runningCalls'
-> {
-  toolCallTree: ToolCallTree
-}
-
-function projectTransient(entries: readonly HistoryEntry[]): TransientProjection {
-  let partial: PartialAccumulator | null = null
-  const openCalls = new Map<string, RunningToolCall>()
-  const interruptedNodes: ConversationNode[] = []
-  const toolCallTree = new ToolCallTree()
-
-  for (const entry of entries) {
-    const { event } = entry
-    if (toolCallTree.apply(event)) 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':
-        // History reconstructs its own in-flight index; this intentionally
-        // mirrors the published Chat node shape, not Chat's mutable state.
-        /* jscpd:ignore-start */
-        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,
-          subCalls: [],
-        })
-        /* jscpd:ignore-end */
-        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)
-          // Interrupted terminal nodes are reconstructed independently so a
-          // Trajectory replay cannot observe Session's frozen-node lifecycle.
-          /* jscpd:ignore-start */
-          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,
-            subCalls: [],
-          })
-          /* jscpd:ignore-end */
-        }
-        break
-      }
-      default:
-        break
-    }
-  }
-
-  return {
-    interruptedNodes,
-    partial: partial?.toPartial() ?? null,
-    runningCalls: [...openCalls.values()],
-    toolCallTree,
-  }
-}
-
-/**
- * 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 steeringHistory = new SteeringHistory()
-  const steeringSeqs = new Set<number>()
-  for (const event of events) {
-    if (steeringHistory.apply(event)) steeringSeqs.add(event.seq)
-  }
-  const baseSeq = events[0]?.seq ?? 0
-  const eventsBySeq = new Map(events.map(event => [event.seq, event]))
-  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)
-    }
-    indexAssistantStepTiming(assistantSteps, event)
-    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 === 'assistant/message') {
-      assistantTimings.set(
-        event.seq,
-        settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, 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 = eventsBySeq.get(seq)
-    if (event === undefined || !isSurfaceEligibleType(event.type)) return
-    const node = materializeNode(
-      event,
-      callIndex,
-      resultViews.get(seq) ?? null,
-      assistantTimings.get(seq),
-      assistantRequestConfigs.get(seq),
-      steeringSeqs.has(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(events).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 = eventsBySeq.get(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,
-      }]
-    }
-  }
-
-  const transient = projectTransient(entries)
-  const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes)
-  const projectedContexts = contexts.map((context): ConversationContext => {
-    const nodes = transient.toolCallTree.projectNodes(context.nodes)
-    return nodes === context.nodes ? context : { ...context, nodes }
-  })
-  return {
-    eventNodes: projectedEventNodes,
-    contexts: projectedContexts,
-    interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes),
-    partial: transient.partial,
-    runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls),
-  }
-}

+ 0 - 66
packages/client/runtime/src/client/session-history/service.ts

@@ -1,66 +0,0 @@
-import type { Context } from '@deepseek-ai/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()
-  }
-}

+ 0 - 432
packages/client/runtime/src/client/session-history/source.ts

@@ -1,432 +0,0 @@
-import type {
-  HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
-} from '@deepseek-ai/dsh-client-connection/client'
-import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
-import type {
-  SessionHistoryFace, SessionHistorySnapshot,
-} from '../contract/session-history.ts'
-import {
-  compactHistoryInspectionEntries, createHistoryInspection,
-} from '../sessions/history.ts'
-import { Notifier } from '../sessions/notifier.ts'
-import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.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: HistoryEntry[] = []
-  private inspectionEntries: 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 streamPublishToken: object | null = null
-  private streamPartial: PartialAccumulator | 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 current tail without reading older pages.
-   * @param signal - Consumer lifetime.
-   * @returns When the tail is ready or loading fails.
-   */
-  async loadTail(signal?: AbortSignal): Promise<void> {
-    if (isAborted(signal)) return
-    this.trackConsumer(signal)
-    await this.open()
-  }
-
-  /**
-   * Prepend one older page when the current window has a predecessor.
-   * @param signal - Consumer lifetime.
-   * @returns Whether the loaded window advanced.
-   */
-  async loadOlder(signal?: AbortSignal): Promise<boolean> {
-    if (isAborted(signal)) return false
-    this.trackConsumer(signal)
-    await this.open()
-    if (isAborted(signal)) return false
-    const previousBaseSeq = this.baseSeq
-    await this.loadOlderPage()
-    return this.baseSeq !== previousBaseSeq
-  }
-
-  /**
-   * 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.publishDirtyNow()
-    }
-  }
-
-  /** 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.inspectionEntries = []
-    this.baseSeq = 0
-    this.hasMore = false
-    this.state = 'cold'
-    this.error = null
-    this.publishDirtyNow()
-    void this.open()
-  }
-
-  /** 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 = []
-    this.streamPublishToken = null
-    this.streamPartial = null
-  }
-
-  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.publishDirtyNow()
-    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.publishDirtyNow()
-    }
-  }
-
-  private loadOlderPage(): 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.inspectionEntries = compactHistoryInspectionEntries([...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.publishDirtyNow()
-    })
-    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
-    this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
-    const buffered = this.liveBuffer
-    this.liveBuffer = []
-    for (const entry of buffered) this.appendLive(entry)
-    this.publishDirtyNow()
-  }
-
-  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
-    }
-    if (
-      entry.event.type === 'assistant/chunk'
-      && entry.event.data.chunk.type !== 'usage'
-    ) {
-      if (!this.appendIncrementalChunk(entry, entry.event)) return
-      this.publishStreamDirty()
-      return
-    }
-    this.appendLive(entry)
-    this.publishDirtyNow()
-  }
-
-  private appendLive(entry: HistoryEntry): void {
-    const tailSeq = this.tailSeq()
-    if (tailSeq !== null && entry.event.seq <= tailSeq) return
-    this.entries.push(entry)
-    this.inspectionEntries = [...this.inspectionEntries, entry]
-    if (entry.event.type === 'assistant/message') {
-      this.inspectionEntries = compactHistoryInspectionEntries(this.inspectionEntries)
-    }
-  }
-
-  /** Append a chunk against the cached finalized projection; false means no visible publish. */
-  private appendIncrementalChunk(
-    entry: HistoryEntry,
-    event: SessionEvent<'assistant/chunk'>,
-  ): boolean {
-    const { turn, step, chunk } = event.data
-    if (!isVisibleAssistantChunk(chunk.type)) {
-      const inspection = this.currentInspection()
-      this.appendLive(entry)
-      this.inspectionCache = { entries: this.inspectionEntries, value: inspection }
-      return false
-    }
-    const base = this.currentInspection()
-    if (
-      this.streamPartial === null
-      || this.streamPartial.turn !== turn
-      || this.streamPartial.step !== step
-    ) {
-      const current = base.partial
-      this.streamPartial = new PartialAccumulator(
-        turn,
-        step,
-        current?.turn === turn && current.step === step ? current.blocks : [],
-      )
-    }
-    this.streamPartial.push(chunk)
-    this.appendLive(entry)
-    this.inspectionCache = {
-      entries: this.inspectionEntries,
-      value: { ...base, partial: this.streamPartial.toPartial() },
-    }
-    return true
-  }
-
-  /** Coalesce token-stream projection and rendering work to one publish per browser frame. */
-  private publishStreamDirty(): void {
-    if (this.streamPublishToken !== null) return
-    const token = {}
-    this.streamPublishToken = token
-    const publish = () => {
-      if (this.streamPublishToken !== token) return
-      this.streamPublishToken = null
-      this.notifier.markDirty()
-    }
-    if (typeof globalThis.requestAnimationFrame === 'function') {
-      globalThis.requestAnimationFrame(publish)
-    } else {
-      queueMicrotask(publish)
-    }
-  }
-
-  /** Publish structural changes immediately and invalidate an older scheduled stream publish. */
-  private publishDirtyNow(): void {
-    this.streamPublishToken = null
-    this.streamPartial = null
-    this.notifier.markDirty()
-  }
-
-  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 {
-    return {
-      state: this.state,
-      error: this.error,
-      hasMore: this.hasMore,
-      baseSeq: this.baseSeq,
-      inspection: this.currentInspection(),
-    }
-  }
-
-  /** Inspection pinned to the source's current immutable entry array. */
-  private currentInspection(): SessionHistorySnapshot['inspection'] {
-    if (this.inspectionCache?.entries !== this.inspectionEntries) {
-      const entries = this.inspectionEntries
-      this.inspectionCache = {
-        entries,
-        value: createHistoryInspection(() => entries),
-      }
-    }
-    return this.inspectionCache.value
-  }
-}

+ 0 - 121
packages/client/runtime/src/client/sessions/history.ts

@@ -1,121 +0,0 @@
-import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
-import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
-import type {
-  ConversationNode, PartialAssistant, RunningToolCall,
-} from './conversation.ts'
-import type { ConversationContext } from './conversation-context.ts'
-import { projectConversationHistory } from '../session-history/history-fold.ts'
-import { inspectRequests, type RequestView } from './request-inspection.ts'
-
-function assistantStepKey(turn: number, step: number): string {
-  return `${turn}\u0000${step}`
-}
-
-function isFirstTokenCandidate(entry: HistoryEntry): boolean {
-  const event = entry.event
-  if (event.type !== 'assistant/chunk') return false
-  switch (event.data.chunk.type) {
-    case 'text-delta':
-    case 'reasoning-delta':
-      return event.data.chunk.text !== ''
-    case 'tool-call-delta':
-      return event.data.chunk.argumentsDelta !== '' || event.data.chunk.name !== undefined
-    default:
-      return false
-  }
-}
-
-/** Lazily derived inspection data for one immutable session-history window. */
-export interface SessionHistoryInspection {
-  eventNodes: readonly ConversationNode[]
-  contexts: readonly ConversationContext[]
-  requests: readonly RequestView[]
-  callSchemas: ReadonlyMap<string, ToolSchema>
-  interruptedNodes: readonly ConversationNode[]
-  partial: PartialAssistant | null
-  runningCalls: readonly RunningToolCall[]
-}
-
-/**
- * Remove completed-step token payloads that no inspection projection reads.
- * The first visible token preserves timing, usage chunks preserve accounting,
- * and unfinished steps retain every chunk for live or interrupted content.
- * @param entries - Contiguous raw history entries in sequence order.
- * @returns A projection-equivalent, usually much smaller entry ledger.
- */
-export function compactHistoryInspectionEntries(
-  entries: readonly HistoryEntry[],
-): readonly HistoryEntry[] {
-  const completedSteps = new Set<string>()
-  for (const { event } of entries) {
-    if (event.type === 'assistant/message') {
-      completedSteps.add(assistantStepKey(event.data.turn, event.data.step))
-    }
-  }
-
-  const firstTokenSteps = new Set<string>()
-  const compacted: HistoryEntry[] = []
-  let changed = false
-  for (const entry of entries) {
-    const event = entry.event
-    if (event.type !== 'assistant/chunk') {
-      compacted.push(entry)
-      continue
-    }
-    const key = assistantStepKey(event.data.turn, event.data.step)
-    if (!completedSteps.has(key) || event.data.chunk.type === 'usage') {
-      compacted.push(entry)
-      continue
-    }
-    if (isFirstTokenCandidate(entry) && !firstTokenSteps.has(key)) {
-      firstTokenSteps.add(key)
-      compacted.push(entry)
-    } else {
-      changed = true
-    }
-  }
-  return changed ? compacted : entries
-}
-
-/**
- * Create a lazy inspection projection over an immutable history window.
- * Conversation consumers retain the cheap wrapper; only Trajectory snapshots
- * the entries and replays event order and request lifecycle state.
- * @param loadEntries - Lazily snapshots contiguous raw entries in sequence order.
- * @returns Lazy, memoized inspection fields for that exact window.
- */
-export function createHistoryInspection(
-  loadEntries: () => readonly HistoryEntry[],
-): SessionHistoryInspection {
-  let entries: readonly HistoryEntry[] | undefined
-  let conversation: ReturnType<typeof projectConversationHistory> | undefined
-  let requests: ReturnType<typeof inspectRequests> | undefined
-  const historyEntries = () => entries ??= loadEntries()
-  const conversationProjection = () =>
-    conversation ??= projectConversationHistory(historyEntries())
-  const requestProjection = () =>
-    requests ??= inspectRequests(historyEntries())
-  return {
-    get eventNodes() {
-      return conversationProjection().eventNodes
-    },
-    get contexts() {
-      return conversationProjection().contexts
-    },
-    get interruptedNodes() {
-      return conversationProjection().interruptedNodes
-    },
-    get partial() {
-      return conversationProjection().partial
-    },
-    get runningCalls() {
-      return conversationProjection().runningCalls
-    },
-    get requests() {
-      return requestProjection().requests
-    },
-    get callSchemas() {
-      return requestProjection().callSchemas
-    },
-  }
-}

+ 4 - 324
packages/client/runtime/src/client/sessions/request-inspection.ts

@@ -1,17 +1,7 @@
-// Request-centric inspection read model. Ordinary generation and compaction
-// calls share one chronological projection; presentation-specific grouping
-// remains in the trajectory consumer.
-
-import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types'
-import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
-import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type {} from '@deepseek-ai/dsh-compact/types'
-import type {} from '@deepseek-ai/dsh-llm-retry/types'
-import type {} from '@deepseek-ai/dsh-tools/types'
+import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
 import type {
   AssistantProvenanceView, AssistantRequestConfig,
 } from './conversation.ts'
-import { displayFailureMessage } from './failure-display.ts'
 
 export type {
   AssistantProvenanceView, AssistantRequestConfig,
@@ -54,7 +44,7 @@ interface RequestViewBase {
   resultSeq?: number
 }
 
-/** One ordinary assistant generation reconstructed from durable request events. */
+/** One ordinary assistant generation assembled from durable request events. */
 interface AssistantRequestView extends RequestViewBase {
   purpose: 'assistant'
   turn: number
@@ -85,321 +75,11 @@ interface CompactionRequestView extends RequestViewBase {
   rawOutput?: readonly ContentBlock[]
 }
 
-/** One provider request reconstructed from durable request lifecycle events. */
+/** One provider request assembled from durable request lifecycle events. */
 export type RequestView = AssistantRequestView | CompactionRequestView
 
-/** Immutable request-centric projection derived from one history window. */
+/** Request data consumed by the stage-oriented Trajectory layout. */
 export interface RequestInspectionSnapshot {
   requests: readonly RequestView[]
   callSchemas: ReadonlyMap<string, ToolSchema>
 }
-
-/**
- * Derive the request-centric read model from one immutable history window.
- * Compaction participates as a request purpose rather than a parallel
- * top-level collection. A leading resume/change header exposes its prompt but
- * cannot project a change until the preceding header enters the window.
- * @param entries - Contiguous raw session history.
- * @returns Requests and call-time schemas derived from that history.
- */
-export function inspectRequests(
-  entries: readonly HistoryEntry[],
-): RequestInspectionSnapshot {
-  const events = entries.map(entry => entry.event)
-  return {
-    requests: deriveRequests(events),
-    callSchemas: deriveCallSchemas(events),
-  }
-}
-
-function requestKey(turn: number, step: number): string {
-  return `${turn}\u0000${step}`
-}
-
-function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage {
-  const previous = current as TokenUsage | undefined
-  return {
-    inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens,
-    outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens,
-    ...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined
-      ? {}
-      : {
-        cacheReadTokens:
-          (previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0),
-      }),
-    ...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined
-      ? {}
-      : {
-        cacheWriteTokens:
-          (previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0),
-      }),
-    ...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined
-      ? {}
-      : {
-        reasoningTokens:
-          (previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0),
-      }),
-  }
-}
-
-function deriveCallSchemas(
-  events: readonly SessionEvent[],
-): ReadonlyMap<string, ToolSchema> {
-  let active = new Map<string, ToolSchema>()
-  const calls = new Map<string, ToolSchema>()
-  const capture = (callId: string, name: string): void => {
-    if (calls.has(callId)) return
-    const schema = active.get(name)
-    if (schema !== undefined) calls.set(callId, schema)
-  }
-  for (const event of events) {
-    if (event.type === 'request/header') {
-      const tools: unknown = event.data.header.tools
-      active = new Map(
-        Array.isArray(tools)
-          ? (tools as ToolSchema[]).map(schema => [schema.name, schema])
-          : [],
-      )
-      continue
-    }
-    if (event.type === 'tool/call') {
-      capture(String(event.data.callId), event.data.name)
-      continue
-    }
-    if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
-      capture(String(event.data.subCallId), event.data.name)
-    }
-  }
-  return calls
-}
-
-function promptChange(
-  previous: ConversationPromptSnapshot | undefined,
-  prompt: ConversationPromptSnapshot,
-  event: SessionEvent<'request/header'>,
-): RequestPromptChange | undefined {
-  if (previous === undefined && event.data.reason !== 'initial') return
-  const systemChanged = previous !== undefined && previous.system !== prompt.system
-  const toolsChanged = previous !== undefined
-    && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
-  if (previous !== undefined && !systemChanged && !toolsChanged) return
-  return {
-    seq: event.seq,
-    time: event.time,
-    kind: previous === undefined
-      ? 'initial'
-      : systemChanged && toolsChanged
-        ? 'system-and-tools'
-        : systemChanged
-          ? 'system'
-          : 'tools',
-    ...(previous === undefined ? {} : { previous }),
-  }
-}
-
-/** Project ordinary and compaction provider calls into one chronological request stream. */
-function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
-  const requests: RequestView[] = []
-  const ordinaryByStep = new Map<string, number>()
-  const lastStepByTurn = new Map<number, string>()
-  let activeStep: string | undefined
-  let activePrompt: ConversationPromptSnapshot | undefined
-  let activeCompaction: number | undefined
-
-  const updateAssistant = (
-    index: number | undefined,
-    change: Partial<Omit<AssistantRequestView, 'purpose'>>,
-  ): void => {
-    if (index === undefined) return
-    const request = requests[index]
-    if (request?.purpose === 'assistant') requests[index] = { ...request, ...change }
-  }
-  const updateCompaction = (
-    index: number | undefined,
-    change: Partial<Omit<CompactionRequestView, 'purpose'>>,
-  ): void => {
-    if (index === undefined) return
-    const request = requests[index]
-    if (request?.purpose === 'compaction') requests[index] = { ...request, ...change }
-  }
-
-  for (const sourceEvent of events) {
-    if (sourceEvent.type === 'step/start') {
-      const { turn, step } = sourceEvent.data
-      const key = requestKey(turn, step)
-      ordinaryByStep.set(key, requests.length)
-      lastStepByTurn.set(turn, key)
-      requests.push({
-        purpose: 'assistant',
-        startSeq: sourceEvent.seq,
-        turn,
-        step,
-        startedAt: sourceEvent.time,
-        completedAt: null,
-        status: 'running',
-        ...(activePrompt === undefined
-          ? {}
-          : { prompt: activePrompt, requestConfig: activePrompt.config }),
-      })
-      activeStep = key
-      continue
-    }
-    if (sourceEvent.type === 'request/header') {
-      const tools: unknown = sourceEvent.data.header.tools
-      const prompt: ConversationPromptSnapshot = {
-        config: sourceEvent.data.header.config,
-        system: sourceEvent.data.header.system ?? '',
-        tools: Array.isArray(tools) ? tools as ToolSchema[] : [],
-      }
-      const change = promptChange(activePrompt, prompt, sourceEvent)
-      activePrompt = prompt
-      updateAssistant(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
-        prompt,
-        requestConfig: prompt.config,
-        ...(change === undefined ? {} : { promptChange: change }),
-      })
-      continue
-    }
-    if (
-      sourceEvent.type === 'assistant/chunk'
-      && sourceEvent.data.chunk.type === 'usage'
-    ) {
-      const index = ordinaryByStep.get(
-        requestKey(sourceEvent.data.turn, sourceEvent.data.step),
-      )
-      const request = index === undefined ? undefined : requests[index]
-      updateAssistant(index, {
-        usage: addTokenUsage(
-          request?.purpose === 'assistant' ? request.usage : undefined,
-          sourceEvent.data.chunk.usage,
-        ),
-      })
-      continue
-    }
-    if (sourceEvent.type === 'assistant/message') {
-      const index = ordinaryByStep.get(
-        requestKey(sourceEvent.data.turn, sourceEvent.data.step),
-      )
-      const request = index === undefined ? undefined : requests[index]
-      updateAssistant(index, {
-        completedAt: sourceEvent.time,
-        status: 'complete',
-        resultSeq: sourceEvent.seq,
-        provenance: {
-          provider: sourceEvent.data.message.source.provider,
-          model: sourceEvent.data.message.source.model,
-        },
-        ...(request?.purpose === 'assistant'
-          && request.usage !== undefined
-          || sourceEvent.data.usage === undefined
-          ? {}
-          : { usage: sourceEvent.data.usage }),
-      })
-      continue
-    }
-    if (sourceEvent.type === 'step/end') {
-      const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
-      const index = ordinaryByStep.get(key)
-      const request = index === undefined ? undefined : requests[index]
-      if (request?.purpose === 'assistant' && request.status === 'running') {
-        updateAssistant(index, {
-          completedAt: sourceEvent.time,
-          status: 'error',
-        })
-      }
-      if (activeStep === key) activeStep = undefined
-      continue
-    }
-    if (sourceEvent.type === 'llm/retry') {
-      const data = sourceEvent.data
-      updateAssistant(ordinaryByStep.get(requestKey(data.turn, data.step)), {
-        status: 'error',
-        error: displayFailureMessage(data.failure),
-        retry: data.retry,
-        ...data.mode === 'normal' ? { maxRetries: data.maxRetries } : {},
-        retryDelayMs: data.delayMs,
-      })
-      continue
-    }
-    if (sourceEvent.type === 'turn/end') {
-      const lastStep = lastStepByTurn.get(sourceEvent.data.turn)
-      if (sourceEvent.data.reason.kind === 'error') {
-        updateAssistant(lastStep === undefined ? undefined : ordinaryByStep.get(lastStep), {
-          status: 'error',
-          error: displayFailureMessage(sourceEvent.data.reason.error),
-        })
-      }
-      lastStepByTurn.delete(sourceEvent.data.turn)
-      continue
-    }
-
-    if (sourceEvent.type === 'session/end-seed' && activeCompaction !== undefined) {
-      updateCompaction(activeCompaction, {
-        completedAt: sourceEvent.time,
-        status: 'error',
-        error: 'Compaction was interrupted before completion.',
-      })
-      activeCompaction = undefined
-      continue
-    }
-    if (sourceEvent.type === 'compact/start') {
-      activeCompaction = requests.length
-      requests.push({
-        purpose: 'compaction',
-        startSeq: sourceEvent.seq,
-        turn: sourceEvent.data.turn,
-        step: 0,
-        startedAt: sourceEvent.time,
-        completedAt: null,
-        status: 'running',
-      })
-      continue
-    }
-    if (sourceEvent.type === 'compact/summary' && activeCompaction !== undefined) {
-      const data = sourceEvent.data
-      updateCompaction(activeCompaction, {
-        resultSeq: sourceEvent.seq,
-        summary: data.summary,
-        ...(data.rawOutput === undefined ? {} : { rawOutput: data.rawOutput }),
-        provenance: {
-          provider: data.provider,
-          model: data.model,
-        },
-        requestConfig: {
-          provider: data.provider,
-          model: data.model,
-          purpose: 'compaction',
-          ...(data.maxTokens === undefined ? {} : { maxTokens: data.maxTokens }),
-        },
-        ...(data.usage === undefined ? {} : { usage: data.usage }),
-      })
-      continue
-    }
-    if (
-      sourceEvent.type === 'user/message'
-      && activeCompaction !== undefined
-      && isCompactionSource(sourceEvent.data.source)
-    ) {
-      updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq })
-      continue
-    }
-    if (sourceEvent.type !== 'compact/end' || activeCompaction === undefined) continue
-    updateCompaction(activeCompaction, {
-      completedAt: sourceEvent.time,
-      status: sourceEvent.data.error === undefined ? 'complete' : 'error',
-      ...(sourceEvent.data.error === undefined ? {} : { error: sourceEvent.data.error }),
-    })
-    activeCompaction = undefined
-  }
-
-  return requests.sort((left, right) => left.startSeq - right.startSeq)
-}
-
-function isCompactionSource(source: unknown): boolean {
-  return typeof source === 'object'
-    && source !== null
-    && 'kind' in source
-    && source.kind === 'plugin'
-    && 'plugin' in source
-    && source.plugin === 'compact'
-}

+ 0 - 232
packages/client/runtime/tests/history-fold.spec.ts

@@ -1,232 +0,0 @@
-import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
-import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import { describe, expect, it } from 'vitest'
-import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
-import { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts'
-import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
-import { ev } from './event-script.ts'
-
-const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
-  ({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
-
-describe('projectConversationHistory', () => {
-  it('names an injected context node from its durable source, like the live adapter', () => {
-    // The fold declares its own node mapping (jscpd:ignore in the source), so
-    // the source projection is pinned on both sides independently.
-    const injected = at(0, {
-      type: 'user/message',
-      surfaceOp: 'append',
-      data: createUserMessage({
-        content: [{ type: 'text', text: '<available_skills>…</available_skills>' }],
-        // A plugin source, because the client program does not see the host
-        // packages that merge richer source kinds; those arms are pinned in
-        // context-provenance.spec.ts.
-        source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
-      }),
-    })
-    const { contexts } = projectConversationHistory([{ event: injected }])
-    expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
-      kind: 'context',
-      seq: 0,
-      provenance: { role: 'inject', label: 'dsh-tool-skill' },
-      form: 'catalog',
-    }])
-  })
-
-  it('projects next-step human input as durable steering', () => {
-    const steering = createUserMessage({
-      content: [{ type: 'text', text: 'change course' }],
-      source: { kind: 'user' },
-    })
-    const events = [
-      at(0, { type: 'agent/inbox/spliced', data: {
-        target: 'next-step', start: 0, inserted: [steering],
-      } }),
-      at(1, { type: 'agent/inbox/spliced', data: {
-        target: 'next-step', start: 0, removedCount: 1, inserted: [],
-      } }),
-      at(2, { type: 'user/message', surfaceOp: 'append', data: steering }),
-    ]
-    const projection = projectConversationHistory(events.map(event => ({ event })))
-    expect(projection.eventNodes).toMatchObject([{
-      kind: 'steering', messageId: steering.id, seq: 2,
-    }])
-  })
-
-  it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
-    const baseSeq = 400_000
-    const events = [
-      ev.user(baseSeq, 'loaded tail'),
-      at(baseSeq + 1, {
-        type: 'assistant/message',
-        surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
-        sourceEventSeqs: [baseSeq],
-        data: {
-          turn: 80,
-          step: 1,
-          message: createMessage({
-            role: 'assistant',
-            content: [{ type: 'text', text: 'tail summary' }],
-            source: { kind: 'model', provider: 'fake', model: 'fake' },
-          }),
-        },
-      }),
-    ]
-
-    const projection = projectConversationHistory(events.map(event => ({ event })))
-    expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1])
-    expect(projection.contexts.map(context => ({
-      originSeq: context.originSeq,
-      nodes: context.nodes.map(node => node.seq),
-    }))).toEqual([
-      { originSeq: undefined, nodes: [baseSeq] },
-      { originSeq: baseSeq + 1, nodes: [baseSeq + 1] },
-    ])
-  })
-
-  it('projects frozen surface generations without widening the core live surface', () => {
-    const events = [
-      ev.user(0, 'a'),
-      ev.user(1, 'b'),
-      at(2, {
-        type: 'assistant/message',
-        surfaceOp: { op: 'replace', start: 0, end: 0 },
-        sourceEventSeqs: [0],
-        data: {
-          turn: 1,
-          step: 1,
-          message: createMessage({
-            role: 'assistant',
-            content: [{ type: 'text', text: 'summary' }],
-            source: { kind: 'model', provider: 'fake', model: 'fake' },
-          }),
-        },
-      }),
-      at(3, {
-        type: 'assistant/message',
-        surfaceOp: { op: 'replace', start: 2, end: 1 },
-        sourceEventSeqs: [2, 1],
-        data: {
-          turn: 1,
-          step: 2,
-          message: createMessage({
-            role: 'assistant',
-            content: [{ type: 'text', text: 'summary 2' }],
-            source: { kind: 'model', provider: 'fake', model: 'fake' },
-          }),
-        },
-      }),
-    ]
-
-    expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
-      id: context.id,
-      parentId: context.parentId,
-      originSeq: context.originSeq,
-      nodes: context.nodes.map(node => node.seq),
-    }))).toEqual([
-      { id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
-      { id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
-      { id: 2, parentId: 1, originSeq: 3, nodes: [3] },
-    ])
-  })
-
-  it('projects assistant timing and the active request header from history', () => {
-    const projection = projectConversationHistory([
-      ev.stepStart(0, 1, 2),
-      at(1, { type: 'request/header', data: {
-        reason: 'initial',
-        header: {
-          config: { provider: 'fake', model: 'first' },
-          tools: [],
-        },
-      } }),
-      ev.chunkStart(2, 1, 2),
-      ev.chunkText(3, 1, 'token', 2),
-      ev.assistant(4, 1, 'done', 2),
-      ev.stepStart(5, 2, 1),
-      ev.chunkText(6, 2, 'next', 1),
-      ev.assistant(7, 2, 'next done', 1),
-    ].map(event => ({ event })))
-
-    expect(projection.eventNodes[0]).toMatchObject({
-      kind: 'assistant',
-      timing: {
-        stepStartTime: 1_700_000_000_000,
-        firstTokenTime: 1_700_000_000_003,
-        completedTime: 1_700_000_000_004,
-      },
-      requestConfig: { provider: 'fake', model: 'first' },
-    })
-
-    expect(projection.eventNodes.at(-1)).toMatchObject({
-      timing: {
-        stepStartTime: 1_700_000_000_005,
-        firstTokenTime: 1_700_000_000_006,
-        completedTime: 1_700_000_000_007,
-      },
-      requestConfig: { provider: 'fake', model: 'first' },
-    })
-  })
-
-  it('projects nested dispatches onto settled and interrupted history calls', () => {
-    const projection = projectConversationHistory([
-      ev.turnStart(0, 1),
-      ev.toolCall(1, 1, 'settled', 'run_code', '{}'),
-      ev.codeDispatchStart(2, 'settled', 1, 'run_code', { code: 'nested' }),
-      ev.codeDispatchStart(3, 'settled:code:1', 1, 'read', { path: 'a.txt' }),
-      ev.codeDispatch(4, 'settled:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
-      ev.codeDispatch(5, 'settled', 1, 'run_code', { code: 'nested' }, 'alpha'),
-      ev.toolResult(6, 1, 'settled', 'done'),
-      ev.turnEnd(7, 1),
-      ev.turnStart(8, 2),
-      ev.toolCall(9, 2, 'interrupted', 'run_code', '{}'),
-      ev.codeDispatchStart(10, 'interrupted', 1, 'bash', { command: 'sleep 1' }),
-      ev.turnEnd(11, 2, 'aborted'),
-    ].map(event => ({ event })))
-
-    const settled = {
-      callId: 'settled',
-      subCalls: [{
-        callId: 'settled:code:1',
-        subCalls: [{ callId: 'settled:code:1:code:1', call: { name: 'read' } }],
-      }],
-    }
-    expect(projection.eventNodes).toMatchObject([settled])
-    expect(projection.contexts[0]?.nodes).toMatchObject([settled])
-    expect(projection.interruptedNodes).toMatchObject([{
-      callId: 'interrupted',
-      subCalls: [{ callId: 'interrupted:code:1', name: 'bash' }],
-    }])
-  })
-
-  it('drops completed token payloads without changing inspection projections', () => {
-    const events = [
-      ev.user(0, 'before'),
-      ev.stepStart(1, 1, 0),
-      ev.chunkStart(2, 1),
-      ev.chunkText(3, 1, ''),
-      ev.chunkText(4, 1, 'first'),
-      ev.chunkText(5, 1, ' discarded'),
-      at(6, { type: 'assistant/chunk', data: {
-        turn: 1,
-        step: 0,
-        chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } },
-      } }),
-      ev.assistant(7, 1, 'first discarded'),
-      ev.compactSummary(8, 'summary', 0, 7),
-      ev.compactCheckpoint(9, 8, 0, 7),
-      ev.stepStart(10, 2, 0),
-      ev.chunkStart(11, 2),
-      ev.chunkText(12, 2, 'interrupted'),
-      ev.turnEnd(13, 2, 'aborted'),
-    ]
-    const raw = events.map(event => ({ event }))
-    const compacted = compactHistoryInspectionEntries(raw)
-
-    expect(compacted.map(entry => entry.event.seq)).toEqual([
-      0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13,
-    ])
-    expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw))
-    expect(inspectRequests(compacted)).toEqual(inspectRequests(raw))
-  })
-})

+ 0 - 319
packages/client/runtime/tests/request-inspection.spec.ts

@@ -1,319 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
-import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
-import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
-
-const at = (seq: number, type: string, data: unknown): SessionEvent =>
-  ({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
-
-const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
-  events.map(event => ({ event }))
-
-describe('inspectRequests', () => {
-  it('projects ordinary and compaction calls into one chronological request stream', () => {
-    const events = [
-      at(0, 'step/start', { turn: 1, step: 1 }),
-      at(1, 'request/header', {
-        reason: 'initial',
-        header: {
-          config: { provider: 'fake', model: 'model' },
-          system: 'system',
-          tools: [{
-            name: 'read',
-            description: 'Read a file.',
-            parameters: { type: 'object' },
-          }],
-        },
-      }),
-      at(2, 'tool/call', {
-        turn: 1,
-        step: 1,
-        callId: 'call-1',
-        name: 'read',
-        arguments: '{}',
-      }),
-      at(3, 'assistant/message', {
-        turn: 1,
-        step: 1,
-        message: createAssistantMessage({
-          content: [{ type: 'text', text: 'done' }],
-          source: { provider: 'fake', model: 'model' },
-        }),
-        usage: { inputTokens: 5, outputTokens: 2 },
-      }),
-      at(4, 'step/end', { turn: 1, step: 1 }),
-      at(5, 'compact/start', { turn: 1 }),
-      at(6, 'compact/summary', {
-        summary: [{ type: 'text', text: 'summary' }],
-        rawOutput: [
-          { type: 'reasoning', text: 'thought' },
-          { type: 'text', text: 'summary' },
-        ],
-        provider: 'fake',
-        model: 'compact-model',
-        usage: { inputTokens: 8, outputTokens: 3 },
-      }),
-      at(7, 'user/message', createUserMessage({
-        content: [{ type: 'text', text: 'checkpoint' }],
-        source: { kind: 'plugin', plugin: 'compact' },
-      })),
-      at(8, 'compact/end', { turn: 1 }),
-    ]
-    const snapshot = inspectRequests(entriesOf(events))
-    expect(snapshot.requests).toMatchObject([
-      {
-        purpose: 'assistant',
-        startSeq: 0,
-        resultSeq: 3,
-        status: 'complete',
-        prompt: {
-          config: { provider: 'fake', model: 'model' },
-          system: 'system',
-        },
-        promptChange: { seq: 1, kind: 'initial' },
-      },
-      {
-        purpose: 'compaction',
-        startSeq: 5,
-        resultSeq: 6,
-        replacementSeq: 7,
-        status: 'complete',
-        summary: [{ type: 'text', text: 'summary' }],
-      },
-    ])
-    expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
-  })
-
-  it('does not promote a truncated resume or change header to the initial prompt', () => {
-    for (const reason of ['resume', 'change'] as const) {
-      const snapshot = inspectRequests(entriesOf([
-        at(10, 'step/start', { turn: 3, step: 1 }),
-        at(11, 'request/header', {
-          reason,
-          header: {
-            config: { provider: 'fake', model: 'model' },
-            system: 'tail-window prompt',
-          },
-        }),
-      ]))
-
-      expect(snapshot.requests[0]).toMatchObject({
-        purpose: 'assistant',
-        prompt: { system: 'tail-window prompt' },
-      })
-      expect(snapshot.requests[0]).not.toHaveProperty('promptChange')
-    }
-  })
-
-  it('classifies a prompt change once the preceding header is loaded', () => {
-    const snapshot = inspectRequests(entriesOf([
-      at(0, 'step/start', { turn: 1, step: 1 }),
-      at(1, 'request/header', {
-        reason: 'initial',
-        header: {
-          config: { provider: 'fake', model: 'model' },
-          system: 'before',
-        },
-      }),
-      at(2, 'step/start', { turn: 1, step: 2 }),
-      at(3, 'request/header', {
-        reason: 'change',
-        header: {
-          config: { provider: 'fake', model: 'model' },
-          system: 'after',
-        },
-      }),
-    ]))
-
-    expect(snapshot.requests[1]).toMatchObject({
-      promptChange: {
-        seq: 3,
-        kind: 'system',
-        previous: { system: 'before' },
-      },
-    })
-  })
-
-  it('preserves a standalone compaction owner without widening assistant turns', () => {
-    const snapshot = inspectRequests(entriesOf([
-      at(0, 'compact/start', { turn: null }),
-      at(1, 'compact/summary', {
-        summary: [{ type: 'text', text: 'standalone summary' }],
-        provider: 'fake',
-        model: 'compact-model',
-      }),
-      at(2, 'compact/end', { turn: null }),
-      at(3, 'step/start', { turn: 2, step: 1 }),
-    ]))
-
-    const [compaction, assistant] = snapshot.requests
-    expect(compaction).toMatchObject({
-      purpose: 'compaction',
-      turn: null,
-      step: 0,
-      status: 'complete',
-    })
-    expect(assistant).toMatchObject({
-      purpose: 'assistant',
-      turn: 2,
-      step: 1,
-      status: 'running',
-    })
-    if (assistant?.purpose === 'assistant') {
-      const turn: number = assistant.turn
-      expect(turn).toBe(2)
-    }
-  })
-
-  it('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => {
-    const snapshot = inspectRequests(entriesOf([
-      at(0, 'compact/start', { turn: null }),
-      at(1, 'session/end-seed', {}),
-      at(2, 'compact/start', { turn: null }),
-      at(3, 'compact/summary', {
-        summary: [{ type: 'text', text: 'replacement summary' }],
-        provider: 'fake',
-        model: 'compact-model',
-      }),
-      at(4, 'compact/end', { turn: null }),
-    ]))
-
-    expect(snapshot.requests).toMatchObject([
-      {
-        purpose: 'compaction',
-        startSeq: 0,
-        status: 'error',
-        completedAt: 1_700_000_000_001,
-        error: 'Compaction was interrupted before completion.',
-      },
-      {
-        purpose: 'compaction',
-        startSeq: 2,
-        status: 'complete',
-        completedAt: 1_700_000_000_004,
-        summary: [{ type: 'text', text: 'replacement summary' }],
-      },
-    ])
-  })
-
-  it('captures schemas for nested tool dispatches from the active request header', () => {
-    const snapshot = inspectRequests(entriesOf([
-      at(0, 'request/header', {
-        reason: 'initial',
-        header: {
-          config: { provider: 'fake', model: 'model' },
-          tools: [{
-            name: 'read',
-            description: 'Read a file.',
-            parameters: { type: 'object' },
-          }],
-        },
-      }),
-      at(1, 'tool/code-dispatch-start', {
-        parentCallId: 'parent',
-        subCallId: 'nested',
-        name: 'read',
-        arguments: {},
-      }),
-    ]))
-
-    expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
-  })
-
-  it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
-    const chunkUsage = { inputTokens: 21, outputTokens: 3 }
-    const retryUsage = {
-      inputTokens: 5,
-      outputTokens: 2,
-      cacheReadTokens: 8,
-      reasoningTokens: 1,
-    }
-    const snapshot = inspectRequests(entriesOf([
-      at(0, 'step/start', { turn: 1, step: 1 }),
-      at(1, 'assistant/chunk', {
-        turn: 1,
-        step: 1,
-        chunk: { type: 'usage', usage: chunkUsage },
-      }),
-      at(2, 'llm/retry', {
-        turn: 1,
-        step: 1,
-        retry: 1,
-        maxRetries: 2,
-        delayMs: 100,
-        failure: { message: 'rate limited' },
-      }),
-      at(3, 'assistant/chunk', {
-        turn: 1,
-        step: 1,
-        chunk: { type: 'usage', usage: retryUsage },
-      }),
-      at(4, 'assistant/message', {
-        turn: 1,
-        step: 1,
-        message: createAssistantMessage({
-          content: [{ type: 'text', text: 'recovered' }],
-          source: { provider: 'fake', model: 'model' },
-        }),
-        usage: { inputTokens: 1, outputTokens: 1 },
-      }),
-    ]))
-
-    expect(snapshot.requests[0]).toMatchObject({
-      status: 'complete',
-      usage: {
-        inputTokens: 26,
-        outputTokens: 5,
-        cacheReadTokens: 8,
-        reasoningTokens: 1,
-      },
-    })
-  })
-
-  it('keeps provider credential fragments out of projected request errors', () => {
-    const snapshot = inspectRequests(entriesOf([
-      at(0, 'step/start', { turn: 1, step: 1 }),
-      at(1, 'turn/end', {
-        turn: 1, reason: { kind: 'error', error: {
-          code: 'AUTH',
-          message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
-        },
-        },
-      }),
-      at(2, 'step/start', { turn: 2, step: 1 }),
-      at(3, 'turn/end', {
-        turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
-      }),
-    ]))
-
-    expect(snapshot.requests).toMatchObject([
-      { status: 'error', error: 'API key is invalid' },
-      { status: 'error', error: 'plugin exploded' },
-    ])
-  })
-
-  it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
-    const snapshot = inspectRequests(entriesOf([
-      at(0, 'step/start', { turn: 1, step: 1 }),
-      at(1, 'request/header', {
-        reason: 'initial',
-        header: {
-          config: { provider: 'fake', model: 'model' },
-          tools: '{{tools}}',
-        },
-      }),
-      at(2, 'tool/call', {
-        turn: 1,
-        step: 1,
-        callId: 'call-1',
-        name: 'read',
-        arguments: '{}',
-      }),
-    ]))
-
-    expect(snapshot.callSchemas).toEqual(new Map())
-    const [request] = snapshot.requests
-    expect(request?.purpose === 'assistant' ? request.prompt?.tools : undefined).toEqual([])
-  })
-})

+ 0 - 180
packages/client/runtime/tests/session-history-source.spec.ts

@@ -1,180 +0,0 @@
-import { afterEach, describe, expect, it, vi } 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
-
-afterEach(() => {
-  vi.unstubAllGlobals()
-})
-
-function histResponse(events: SessionEvent[], hasMore = false) {
-  return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
-}
-
-describe('SessionHistorySource', () => {
-  it('loads the tail first and prepends older pages on demand', 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.loadTail()
-
-    expect(api.callsOf('session.history')).toHaveLength(1)
-    expect(source.getSnapshot().hasMore).toBe(true)
-    expect(source.getSnapshot().baseSeq).toBe(12)
-    expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
-      .toEqual([13, 15])
-
-    expect(await source.loadOlder()).toBe(true)
-    expect(await source.loadOlder()).toBe(true)
-    expect(await source.loadOlder()).toBe(false)
-
-    expect(api.callsOf('session.history')).toHaveLength(3)
-    expect(source.getSnapshot().hasMore).toBe(false)
-    expect(source.getSnapshot().baseSeq).toBe(0)
-    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.loadTail()
-    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('publishes multiple assistant chunks once per browser frame', async () => {
-    const api = new FakeApiClient()
-    api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
-    const source = new SessionHistorySource(SID, api)
-    await source.loadTail()
-    const frames: FrameRequestCallback[] = []
-    vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
-      frames.push(callback)
-      return frames.length
-    })
-    let notifications = 0
-    const unsubscribe = source.subscribe(() => { notifications++ })
-    const before = source.getSnapshot().inspection
-    const finalizedNodes = before.eventNodes
-    const requests = before.requests
-    const contexts = before.contexts
-
-    for (const event of [
-      ev.chunkStart(6, 1),
-      ev.chunkText(7, 1, 'stream '),
-      ev.chunkText(8, 1, 'content'),
-    ]) {
-      source.handleMuxFrame({
-        type: 'session/event',
-        sessionId: SID,
-        event,
-      })
-    }
-
-    expect(frames).toHaveLength(1)
-    expect(notifications).toBe(0)
-    frames[0]?.(0)
-    await Promise.resolve()
-
-    expect(notifications).toBe(1)
-    const streamed = source.getSnapshot().inspection
-    expect(streamed.eventNodes).toBe(finalizedNodes)
-    expect(streamed.requests).toBe(requests)
-    expect(streamed.contexts).toBe(contexts)
-    expect(streamed.partial?.blocks).toEqual([
-      { kind: 'text', text: 'stream content' },
-    ])
-
-    source.handleMuxFrame({
-      type: 'session/event',
-      sessionId: SID,
-      event: ev.chunkText(9, 1, ' then final'),
-    })
-    source.handleMuxFrame({
-      type: 'session/event',
-      sessionId: SID,
-      event: ev.assistant(10, 1, 'stream content then final'),
-    })
-    await Promise.resolve()
-
-    expect(notifications).toBe(2)
-    const finalized = source.getSnapshot().inspection
-    expect(finalized.eventNodes).not.toBe(finalizedNodes)
-    expect(finalized.partial).toBeNull()
-    frames[1]?.(0)
-    await Promise.resolve()
-    expect(notifications).toBe(2)
-    unsubscribe()
-  })
-
-  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.loadTail()
-    expect(await source.loadOlder()).toBe(false)
-
-    expect(api.callsOf('session.history')).toHaveLength(2)
-    expect(source.getSnapshot().hasMore).toBe(true)
-  })
-
-  it('finishes an already started older page after consumer cancellation', async () => {
-    const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
-    const olderStarted = deferred<undefined>()
-    const api = new FakeApiClient()
-    api.onHistory = (payload) => {
-      if (payload.beforeSeq === undefined) {
-        return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
-      }
-      olderStarted.resolve(undefined)
-      return middle.promise
-    }
-    const source = new SessionHistorySource(SID, api)
-    const controller = new AbortController()
-    await source.loadTail(controller.signal)
-    const complete = source.loadOlder(controller.signal)
-    await olderStarted.promise
-    controller.abort()
-    middle.resolve(ok({
-      events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
-      hasMore: true,
-    }))
-
-    expect(await complete).toBe(true)
-
-    expect(api.callsOf('session.history')).toHaveLength(2)
-    expect(source.getSnapshot().hasMore).toBe(true)
-  })
-})

+ 0 - 1
scripts/gen-cordis-catalog.ts

@@ -132,7 +132,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
   models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface',
   modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the surface',
   remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the surface',
-  sessionHistory: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
   slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the surface',
   slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
   theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the surface',