Selaa lähdekoodia

cleanup(session): isolate trajectory inspection

_Kerman 2 kuukautta sitten
vanhempi
sitoutus
3f7760717d

+ 2 - 2
docs/core-data-structures/compaction.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md
-compaction.md: d3db21fb84cb82a4ba3bb8c16731b0e9f04c160a
-compaction.zh.md: e96fff729b7a2d6375e6b471ee343a59f0f75a07
+compaction.md: 911b71d00fa4b42e9cdfa67f67d4e9b29e354a4a
+compaction.zh.md: 643a116ff2edbbb53d300b4f5ff0ad36d401130b

+ 1 - 3
docs/core-data-structures/compaction.md

@@ -22,7 +22,7 @@ These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` b
 
 ## `CompactionResult`
 
-What a successful compaction returns to its caller: the bookkeeping-event seqs, safe summary projection, optional complete provider output, shadowed range and seqs, and estimated token count.
+What a successful compaction returns to its caller: the bookkeeping-event seqs, safe summary projection, shadowed range and seqs, and estimated token count.
 
 ```ts type-equiv
 /** Result of a successful compaction operation. */
@@ -35,8 +35,6 @@ interface CompactionResult {
   endSeq: number
   /** The summary content blocks produced by the backend. */
   summary: ContentBlock[]
-  /** Complete provider output before the backend's safe summary projection. */
-  rawOutput?: ContentBlock[]
   /**
    * The surface-boundary pair that was shadowed: the seqs of the first
    * (`start`) and last (`end`) surface nodes of the replaced range. A

+ 1 - 3
docs/core-data-structures/compaction.zh.md

@@ -22,7 +22,7 @@
 
 ## `CompactionResult`
 
-成功压缩向调用方返回:记账事件 seq、安全摘要投影、可选的完整 provider 输出、被遮蔽的范围与 seq,以及估算 token 数。
+成功压缩向调用方返回:记账事件 seq、安全摘要投影、被遮蔽的范围与 seq,以及估算 token 数。
 
 ```ts type-equiv
 /** Result of a successful compaction operation. */
@@ -35,8 +35,6 @@ interface CompactionResult {
   endSeq: number
   /** The summary content blocks produced by the backend. */
   summary: ContentBlock[]
-  /** Complete provider output before the backend's safe summary projection. */
-  rawOutput?: ContentBlock[]
   /**
    * The surface-boundary pair that was shadowed: the seqs of the first
    * (`start`) and last (`end`) surface nodes of the replaced range. A

+ 7 - 4
packages/client/runtime/src/client/index.ts

@@ -28,13 +28,16 @@ export type {
   EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
 } from './contract/store.ts'
 export type {
-  AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
-  AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
-  CompactionRequestView, ConversationContext, ConversationContextOriginKind,
-  ConversationNode, ConversationPromptChange, ConversationPromptSnapshot, ModelRequestView,
+  AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
+  ConversationNode,
   ConversationSnapshot, QueuedMessage, RunningToolCall,
   SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
 } from './sessions/conversation.ts'
+export type {
+  AssistantProvenanceView, AssistantRequestConfig, CompactionRequestView,
+  ConversationContext, ConversationContextOriginKind, ConversationPromptChange,
+  ConversationPromptSnapshot, ModelRequestView,
+} from './sessions/inspection.ts'
 export { PendingWait } from './sessions/pending.ts'
 export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
 export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'

+ 8 - 110
packages/client/runtime/src/client/sessions/conversation.ts

@@ -3,12 +3,15 @@
 // substructures keep their references (the React.memo premise). callId/approvalId stay plain
 // string here (narrow to real brands when convenient).
 
-import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { TodoItem } from '@deepseek-ai/dsh-session/types'
 import type {
   RpcError, SessionId, ToolCallView, ToolResultView,
 } from '@deepseek-ai/dsh-client-connection/client'
 import type { PendingInteraction } from './pending.ts'
+import type {
+  AssistantProvenanceView, AssistantRequestConfig, ConversationContext, SessionInspectionSnapshot,
+} from './inspection.ts'
 
 export type { TodoItem }
 
@@ -51,7 +54,6 @@ export interface UserMessageNode {
   time: number
   content: readonly ContentBlock[]
   source: unknown
-  meta?: unknown
 }
 
 /** Recorded boundaries used to derive assistant latency and throughput. */
@@ -64,24 +66,6 @@ export interface AssistantTiming {
   completedTime: number
 }
 
-/** Request configuration recorded in the effective header for one assistant response. */
-export interface AssistantRequestConfig {
-  provider: string
-  model: string
-  purpose?: string
-  thinking?: string
-  reasoningEffort?: string
-  temperature?: number
-  maxTokens?: number
-  stop?: readonly string[]
-}
-
-/** Stable provider/model identity attached to one assistant response. */
-export interface AssistantProvenanceView {
-  provider: string
-  model: string
-}
-
 /** A finalized (or interruption-frozen) assistant message. */
 export interface AssistantMessageNode {
   kind: 'assistant'
@@ -110,7 +94,6 @@ export interface SteeringMessageNode {
   turn: number
   content: readonly ContentBlock[]
   source: unknown
-  meta?: unknown
 }
 
 /** A context/system injection surfaced in the flow. */
@@ -230,91 +213,6 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
  */
 export type ComposerPhase = 'blank' | 'engaging' | 'active'
 
-/** Operation that started a new append-only model context. */
-export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
-
-/** Latest complete model request header in force within one context generation. */
-export interface ConversationPromptSnapshot {
-  /** Provider/model and sampling configuration from the latest effective request header. */
-  config?: AssistantRequestConfig
-  /** Rendered system prompt text; empty when the request had no system prompt. */
-  system: string
-  /** Complete tool catalog sent with the request, including tools that were never called. */
-  tools: readonly ToolSchema[]
-}
-
-/** One system-prompt/tool-catalog state that became effective in the request timeline. */
-export interface ConversationPromptChange {
-  /** Sequence of the request/header event that introduced this state. */
-  seq: number
-  /** Unix epoch ms from the request/header event. */
-  time: number
-  /** How the model-visible system configuration differs from the prior recorded state. */
-  kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
-  /** Complete state effective from this event onward. */
-  prompt: ConversationPromptSnapshot
-  /** State immediately before this change; absent for the initial header. */
-  previous?: ConversationPromptSnapshot
-}
-
-/** One immutable model-context generation reconstructed from surface replacements. */
-export interface ConversationContext {
-  /** Zero-based generation within the session; stable across later appends. */
-  id: number
-  /** Previous generation in this session; absent for the initial context. */
-  parentId?: number
-  /** Why this generation exists; absent for the initial context. */
-  origin?: ConversationContextOriginKind
-  /** Event seq of the replacement that created this generation. */
-  originSeq?: number
-  /** Unix epoch ms of the replacement that created this generation. */
-  createdAt?: number
-  /** Latest request header observed in this generation, inherited until a later header replaces it. */
-  prompt?: ConversationPromptSnapshot
-  /** Final frozen nodes for historical generations, or current folded nodes for the tail. */
-  nodes: readonly ConversationNode[]
-}
-
-/** One auxiliary compaction model request reconstructed from its durable lifecycle events. */
-export interface CompactionRequestView {
-  startSeq: number
-  turn: number
-  startedAt: number
-  completedAt: number | null
-  status: 'running' | 'complete' | 'error'
-  error?: string
-  summarySeq?: number
-  replacementSeq?: number
-  summary?: readonly ContentBlock[]
-  rawOutput?: readonly ContentBlock[]
-  provenance?: AssistantProvenanceView
-  requestConfig?: AssistantRequestConfig
-  usage?: unknown
-}
-
-/** One ordinary provider-request attempt reconstructed from a durable step boundary. */
-export interface ModelRequestView {
-  /** Sequence of the step/start event that opened this attempt. */
-  startSeq: number
-  turn: number
-  step: number
-  /** Unix epoch ms from step/start. */
-  startedAt: number
-  /** Assistant completion time, or the failed step/end time when no response completed. */
-  completedAt: number | null
-  status: 'running' | 'complete' | 'error'
-  error?: string
-  /** Assistant/message sequence when this attempt completed successfully. */
-  resultSeq?: number
-  provenance?: AssistantProvenanceView
-  requestConfig?: AssistantRequestConfig
-  usage?: unknown
-  /** Retry ordinal scheduled after this failed attempt. */
-  retry?: number
-  maxRetries?: number
-  retryDelayMs?: number
-}
-
 /** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
 export interface PromptError {
   op: 'send' | 'stop'
@@ -329,11 +227,11 @@ export interface ConversationSnapshot {
   /** Append-only context generations split at every model-surface replacement. */
   contexts?: readonly ConversationContext[]
   /** Auxiliary compaction requests, including those without an assistant/message surface node. */
-  compactionRequests?: readonly CompactionRequestView[]
+  compactionRequests?: SessionInspectionSnapshot['compactionRequests']
   /** Ordinary provider requests, including failed attempts that produced no assistant message. */
-  requestAttempts?: readonly ModelRequestView[]
+  requestAttempts?: SessionInspectionSnapshot['requestAttempts']
   /** System-prompt/tool-catalog changes in request order. */
-  promptChanges?: readonly ConversationPromptChange[]
+  promptChanges?: SessionInspectionSnapshot['promptChanges']
   /** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
   foldDegraded: boolean
   partial: PartialAssistant | null
@@ -346,7 +244,7 @@ export interface ConversationSnapshot {
    */
   codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
   /** Model-visible tool schema captured for each recorded call id. */
-  callSchemas?: ReadonlyMap<string, ToolSchema>
+  callSchemas?: SessionInspectionSnapshot['callSchemas']
   pending: readonly PendingInteraction[]
   /** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */
   queue: readonly QueuedMessage[]

+ 45 - 7
packages/client/runtime/src/client/sessions/fold-adapter.ts

@@ -11,11 +11,12 @@ import {
   SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
 } from '@deepseek-ai/dsh-session/surface'
 import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
+import type { AssistantTiming, ConversationNode } from './conversation.ts'
+import { toAssistantBlocks } from './conversation.ts'
 import type {
-  AssistantRequestConfig, AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode,
+  AssistantRequestConfig, ConversationContext, ConversationContextOriginKind,
   ConversationPromptSnapshot,
-} from './conversation.ts'
-import { toAssistantBlocks } from './conversation.ts'
+} from './inspection.ts'
 
 /** In-window tool/call index entry (result-card backfill + runningCalls material). */
 export interface CallIndexEntry {
@@ -37,6 +38,43 @@ function paddingEvent(seq: number): SessionEvent {
   return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
 }
 
+/** Minimal generation projection owned by the inspection adapter, not the core live surface. */
+interface FoldedContext {
+  generation: number
+  nodes: readonly number[]
+  originSeq?: number
+}
+
+/**
+ * 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,
@@ -223,26 +261,26 @@ export class FoldAdapter {
       this.contextsResult = { rev: this.contextRev, value }
       return value
     }
-    const value = this.surface.contexts.map((context): ConversationContext => {
+    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.origin === undefined) {
+      if (context.originSeq === undefined) {
         return {
           id: context.generation,
           ...(prompt === undefined ? {} : { prompt }),
           nodes,
         }
       }
-      const originEvent = this.padded[context.origin.seq]
+      const originEvent = this.padded[context.originSeq]
       return {
         id: context.generation,
         parentId: context.generation - 1,
         origin: contextOriginKind(originEvent),
-        originSeq: context.origin.seq,
+        originSeq: context.originSeq,
         ...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
         ...(prompt === undefined ? {} : { prompt }),
         nodes,

+ 493 - 0
packages/client/runtime/src/client/sessions/inspection.ts

@@ -0,0 +1,493 @@
+// Session inspection read models. These projections preserve durable request
+// and prompt semantics for diagnostic UIs without making the conversation fold
+// or the core SessionSurface own inspection-only history.
+
+import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import type { ConversationNode } from './conversation.ts'
+
+/** Request configuration recorded in the effective header for one assistant response. */
+export interface AssistantRequestConfig {
+  provider: string
+  model: string
+  purpose?: string
+  thinking?: string
+  reasoningEffort?: string
+  temperature?: number
+  maxTokens?: number
+  stop?: readonly string[]
+}
+
+/** Stable provider/model identity attached to one assistant response. */
+export interface AssistantProvenanceView {
+  provider: string
+  model: string
+}
+
+/** Operation that started a new append-only model context. */
+export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
+
+/** Latest complete model request header in force within one context generation. */
+export interface ConversationPromptSnapshot {
+  /** Provider/model and sampling configuration from the latest effective request header. */
+  config?: AssistantRequestConfig
+  /** Rendered system prompt text; empty when the request had no system prompt. */
+  system: string
+  /** Complete tool catalog sent with the request, including tools that were never called. */
+  tools: readonly ToolSchema[]
+}
+
+/** One system-prompt/tool-catalog state that became effective in the request timeline. */
+export interface ConversationPromptChange {
+  /** Sequence of the request/header event that introduced this state. */
+  seq: number
+  /** Unix epoch ms from the request/header event. */
+  time: number
+  /** How the model-visible system configuration differs from the prior recorded state. */
+  kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
+  /** Complete state effective from this event onward. */
+  prompt: ConversationPromptSnapshot
+  /** State immediately before this change; absent for the initial header. */
+  previous?: ConversationPromptSnapshot
+}
+
+/** One immutable model-context generation reconstructed from surface replacements. */
+export interface ConversationContext {
+  /** Zero-based generation within the session; stable across later appends. */
+  id: number
+  /** Previous generation in this session; absent for the initial context. */
+  parentId?: number
+  /** Why this generation exists; absent for the initial context. */
+  origin?: ConversationContextOriginKind
+  /** Event seq of the replacement that created this generation. */
+  originSeq?: number
+  /** Unix epoch ms of the replacement that created this generation. */
+  createdAt?: number
+  /** Latest request header observed in this generation, inherited until a later header replaces it. */
+  prompt?: ConversationPromptSnapshot
+  /** Final frozen nodes for historical generations, or current folded nodes for the tail. */
+  nodes: readonly ConversationNode[]
+}
+
+/** One auxiliary compaction model request reconstructed from its durable lifecycle events. */
+export interface CompactionRequestView {
+  startSeq: number
+  turn: number
+  startedAt: number
+  completedAt: number | null
+  status: 'running' | 'complete' | 'error'
+  error?: string
+  summarySeq?: number
+  replacementSeq?: number
+  summary?: readonly ContentBlock[]
+  rawOutput?: readonly ContentBlock[]
+  provenance?: AssistantProvenanceView
+  requestConfig?: AssistantRequestConfig
+  usage?: unknown
+}
+
+/** One ordinary provider-request attempt reconstructed from a durable step boundary. */
+export interface ModelRequestView {
+  /** Sequence of the step/start event that opened this attempt. */
+  startSeq: number
+  turn: number
+  step: number
+  /** Unix epoch ms from step/start. */
+  startedAt: number
+  /** Assistant completion time, or the failed step/end time when no response completed. */
+  completedAt: number | null
+  status: 'running' | 'complete' | 'error'
+  error?: string
+  /** Assistant/message sequence when this attempt completed successfully. */
+  resultSeq?: number
+  provenance?: AssistantProvenanceView
+  requestConfig?: AssistantRequestConfig
+  usage?: unknown
+  /** Retry ordinal scheduled after this failed attempt. */
+  retry?: number
+  maxRetries?: number
+  retryDelayMs?: number
+}
+
+/** Stable inspection substructures attached to a conversation snapshot. */
+export interface SessionInspectionSnapshot {
+  compactionRequests: readonly CompactionRequestView[]
+  requestAttempts: readonly ModelRequestView[]
+  promptChanges: readonly ConversationPromptChange[]
+  callSchemas: ReadonlyMap<string, ToolSchema>
+}
+
+interface RetryEvent {
+  type: 'llm/retry'
+  seq: number
+  time: number
+  data: {
+    turn: number
+    step: number
+    retry: number
+    maxRetries: number
+    delayMs: number
+    failure: { message: string }
+  }
+}
+
+interface CompactionStartEvent {
+  type: 'compact/start'
+  seq: number
+  time: number
+  data: { turn: number }
+}
+
+interface CompactionSummaryEvent {
+  type: 'compact/summary'
+  seq: number
+  time: number
+  data: {
+    summary: readonly ContentBlock[]
+    rawOutput?: readonly ContentBlock[]
+    provider: string
+    model: string
+    maxTokens?: number
+    usage?: unknown
+  }
+}
+
+interface CompactionEndEvent {
+  type: 'compact/end'
+  seq: number
+  time: number
+  data: { turn: number; error?: string }
+}
+
+/**
+ * Own incremental invalidation and call-time schema capture for inspection
+ * read models. Conversation state delegates events here but owns no request
+ * reconstruction details.
+ */
+export class SessionInspection {
+  private activeToolSchemas = new Map<string, ToolSchema>()
+  private callSchemas = new Map<string, ToolSchema>()
+  private callSchemasRev = 0
+  private callSchemasCache: { rev: number; value: ReadonlyMap<string, ToolSchema> } | null = null
+  private modelRequestsRev = 0
+  private modelRequestsCache: {
+    rev: number
+    value: readonly ModelRequestView[]
+  } | null = null
+  private compactionRequestsRev = 0
+  private compactionRequestsCache: {
+    rev: number
+    value: readonly CompactionRequestView[]
+  } | null = null
+  private promptChangesRev = 0
+  private promptChangesCache: {
+    rev: number
+    value: readonly ConversationPromptChange[]
+  } | null = null
+
+  /**
+   * Invalidate projections before replaying a rebuilt history window.
+   * @returns Nothing.
+   */
+  reset(): void {
+    this.activeToolSchemas = new Map()
+    this.callSchemas = new Map()
+    this.callSchemasRev++
+    this.modelRequestsRev++
+    this.compactionRequestsRev++
+    this.promptChangesRev++
+  }
+
+  /**
+   * Apply inspection-specific incremental state for one durable event.
+   * @param event - Event entering the current history window.
+   * @returns Nothing.
+   */
+  applyEvent(event: SessionEvent): void {
+    if (affectsModelRequests(event)) this.modelRequestsRev++
+    if (affectsCompactionRequests(event)) this.compactionRequestsRev++
+    if (event.type === 'request/header') {
+      this.promptChangesRev++
+      this.activeToolSchemas = new Map(
+        (event.data.header.tools ?? []).map(schema => [schema.name, schema]),
+      )
+      return
+    }
+    if (event.type === 'tool/call') {
+      this.captureCallSchema(String(event.data.callId), event.data.name)
+    }
+  }
+
+  /**
+   * Preserve the schema active when one native or nested call starts.
+   * @param callId - Durable or synthetic call identifier.
+   * @param name - Tool name used to resolve the active catalog entry.
+   * @returns Nothing.
+   */
+  captureCallSchema(callId: string, name: string): void {
+    if (this.callSchemas.has(callId)) return
+    const schema = this.activeToolSchemas.get(name)
+    if (schema === undefined) return
+    this.callSchemas.set(callId, schema)
+    this.callSchemasRev++
+  }
+
+  /**
+   * Materialize reference-stable inspection projections for the current log.
+   * @param events - Current contiguous client history window.
+   * @returns Inspection projections with stable unchanged substructure references.
+   */
+  snapshot(events: readonly SessionEvent[]): SessionInspectionSnapshot {
+    if (this.callSchemasCache === null || this.callSchemasCache.rev !== this.callSchemasRev) {
+      this.callSchemasCache = { rev: this.callSchemasRev, value: new Map(this.callSchemas) }
+    }
+    if (
+      this.modelRequestsCache === null
+      || this.modelRequestsCache.rev !== this.modelRequestsRev
+    ) {
+      this.modelRequestsCache = {
+        rev: this.modelRequestsRev,
+        value: deriveModelRequests(events),
+      }
+    }
+    if (
+      this.compactionRequestsCache === null
+      || this.compactionRequestsCache.rev !== this.compactionRequestsRev
+    ) {
+      this.compactionRequestsCache = {
+        rev: this.compactionRequestsRev,
+        value: deriveCompactionRequests(events),
+      }
+    }
+    if (
+      this.promptChangesCache === null
+      || this.promptChangesCache.rev !== this.promptChangesRev
+    ) {
+      this.promptChangesCache = {
+        rev: this.promptChangesRev,
+        value: derivePromptChanges(events),
+      }
+    }
+    return {
+      callSchemas: this.callSchemasCache.value,
+      requestAttempts: this.modelRequestsCache.value,
+      compactionRequests: this.compactionRequestsCache.value,
+      promptChanges: this.promptChangesCache.value,
+    }
+  }
+}
+
+function modelRequestKey(turn: number, step: number): string {
+  return `${turn}\u0000${step}`
+}
+
+function affectsModelRequests(event: SessionEvent): boolean {
+  switch (event.type) {
+    case 'request/header':
+    case 'step/start':
+    case 'assistant/message':
+    case 'step/end':
+      return true
+    case 'turn/end':
+      return event.data.reason.kind === 'error'
+    default:
+      return (event.type as string) === 'llm/retry'
+  }
+}
+
+function affectsCompactionRequests(event: SessionEvent): boolean {
+  const type = event.type as string
+  return type === 'compact/start'
+    || type === 'compact/summary'
+    || type === 'compact/end'
+    || (event.type === 'user/message' && isCompactionSource(event.data.source))
+}
+
+/** Project every durable step into one provider request, retaining failed retry attempts. */
+function deriveModelRequests(events: readonly SessionEvent[]): readonly ModelRequestView[] {
+  const requests: ModelRequestView[] = []
+  const byStep = new Map<string, number>()
+  let activeStep: string | undefined
+  let activeConfig: ConversationPromptSnapshot['config']
+
+  const update = (key: string, change: Partial<ModelRequestView>): void => {
+    const index = byStep.get(key)
+    if (index === undefined) return
+    const request = requests[index]
+    if (request !== undefined) requests[index] = { ...request, ...change }
+  }
+
+  for (const sourceEvent of events) {
+    if (sourceEvent.type === 'request/header') {
+      activeConfig = sourceEvent.data.header.config
+      if (activeStep !== undefined) update(activeStep, { requestConfig: activeConfig })
+      continue
+    }
+    if (sourceEvent.type === 'step/start') {
+      const { turn, step } = sourceEvent.data
+      const key = modelRequestKey(turn, step)
+      byStep.set(key, requests.length)
+      requests.push({
+        startSeq: sourceEvent.seq,
+        turn,
+        step,
+        startedAt: sourceEvent.time,
+        completedAt: null,
+        status: 'running',
+        ...(activeConfig === undefined ? {} : { requestConfig: activeConfig }),
+      })
+      activeStep = key
+      continue
+    }
+    if (sourceEvent.type === 'assistant/message') {
+      const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step)
+      update(key, {
+        completedAt: sourceEvent.time,
+        status: 'complete',
+        resultSeq: sourceEvent.seq,
+        provenance: {
+          provider: sourceEvent.data.provenance.provider,
+          model: sourceEvent.data.provenance.model,
+        },
+        ...(sourceEvent.data.usage === undefined ? {} : { usage: sourceEvent.data.usage }),
+      })
+      continue
+    }
+    if (sourceEvent.type === 'step/end') {
+      const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step)
+      const index = byStep.get(key)
+      const request = index === undefined ? undefined : requests[index]
+      if (index !== undefined && request !== undefined && request.status === 'running') {
+        requests[index] = {
+          ...request,
+          completedAt: sourceEvent.time,
+          status: 'error',
+        }
+      }
+      if (activeStep === key) activeStep = undefined
+      continue
+    }
+    if ((sourceEvent.type as string) === 'llm/retry') {
+      const event = sourceEvent as unknown as RetryEvent
+      update(modelRequestKey(event.data.turn, event.data.step), {
+        status: 'error',
+        error: event.data.failure.message,
+        retry: event.data.retry,
+        maxRetries: event.data.maxRetries,
+        retryDelayMs: event.data.delayMs,
+      })
+      continue
+    }
+    if (sourceEvent.type !== 'turn/end' || sourceEvent.data.reason.kind !== 'error') continue
+    const reason = sourceEvent.data.reason
+    update(modelRequestKey(sourceEvent.data.turn, reason.step), {
+      status: 'error',
+      error: 'failure' in reason ? reason.failure.message : reason.message,
+    })
+  }
+  return requests
+}
+
+/** Project log-only compaction request brackets without coupling the client runtime to one backend package. */
+function deriveCompactionRequests(events: readonly SessionEvent[]): readonly CompactionRequestView[] {
+  const requests: CompactionRequestView[] = []
+  let active: CompactionRequestView | undefined
+  for (const sourceEvent of events) {
+    const type = sourceEvent.type as string
+    if (type === 'compact/start') {
+      const event = sourceEvent as unknown as CompactionStartEvent
+      active = {
+        startSeq: event.seq,
+        turn: event.data.turn,
+        startedAt: event.time,
+        completedAt: null,
+        status: 'running',
+      }
+      continue
+    }
+    if (type === 'compact/summary' && active !== undefined) {
+      const event = sourceEvent as unknown as CompactionSummaryEvent
+      active = {
+        ...active,
+        summarySeq: event.seq,
+        summary: event.data.summary,
+        ...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
+        provenance: {
+          provider: event.data.provider,
+          model: event.data.model,
+        },
+        requestConfig: {
+          provider: event.data.provider,
+          model: event.data.model,
+          purpose: 'compaction',
+          ...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
+        },
+        ...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
+      }
+      continue
+    }
+    if (
+      sourceEvent.type === 'user/message'
+      && active?.summarySeq !== undefined
+      && isCompactionSource(sourceEvent.data.source)
+    ) {
+      active = { ...active, replacementSeq: sourceEvent.seq }
+      continue
+    }
+    if (type !== 'compact/end' || active === undefined) continue
+    const event = sourceEvent as unknown as CompactionEndEvent
+    active = {
+      ...active,
+      completedAt: event.time,
+      status: event.data.error === undefined ? 'complete' : 'error',
+      ...(event.data.error === undefined ? {} : { error: event.data.error }),
+    }
+    requests.push(active)
+    active = undefined
+  }
+  if (active !== undefined) requests.push(active)
+  return requests
+}
+
+/** Project request headers into model-visible system/tool changes only. */
+function derivePromptChanges(events: readonly SessionEvent[]): readonly ConversationPromptChange[] {
+  const changes: ConversationPromptChange[] = []
+  let previous: ConversationPromptSnapshot | undefined
+  for (const event of events) {
+    if (event.type !== 'request/header') continue
+    const prompt: ConversationPromptSnapshot = {
+      config: event.data.header.config,
+      system: event.data.header.system ?? '',
+      tools: event.data.header.tools ?? [],
+    }
+    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) {
+      changes.push({
+        seq: event.seq,
+        time: event.time,
+        kind: previous === undefined
+          ? 'initial'
+          : systemChanged && toolsChanged
+            ? 'system-and-tools'
+            : systemChanged
+              ? 'system'
+              : 'tools',
+        prompt,
+        ...(previous === undefined ? {} : { previous }),
+      })
+    }
+    previous = prompt
+  }
+  return changes
+}
+
+function isCompactionSource(source: unknown): boolean {
+  return typeof source === 'object'
+    && source !== null
+    && 'kind' in source
+    && source.kind === 'plugin'
+    && 'plugin' in source
+    && source.plugin === 'compact'
+}

+ 15 - 343
packages/client/runtime/src/client/sessions/session.ts

@@ -1,7 +1,7 @@
 // Sessions remain resident after creation so they continue consuming mux frames off-screen.
 
 import type { Context } from 'cordis'
-import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types'
 import type {
   HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
@@ -12,10 +12,10 @@ import type {
 import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type { ObservableSnapshot } from '../contract/store.ts'
 import type {
-  CodeSubCall, CompactionRequestView, ComposerPhase, ConversationNode,
-  ConversationPromptChange, ConversationPromptSnapshot, ConversationSnapshot,
-  ModelRequestView, OpenState, PromptError, QueuedMessage, RunningToolCall,
+  CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot,
+  OpenState, PromptError, QueuedMessage, RunningToolCall,
 } from './conversation.ts'
+import { SessionInspection } from './inspection.ts'
 import type { PendingInteraction } from './pending.ts'
 import { PendingWait } from './pending.ts'
 import { FoldAdapter } from './fold-adapter.ts'
@@ -108,27 +108,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
   private codeDispatches = new Map<string, readonly CodeSubCall[]>()
   private dispatchesRev = 0
   private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
-  /** Schemas in force for the next tool/call, updated by request/header. */
-  private activeToolSchemas = new Map<string, ToolSchema>()
-  /** Call-time schema snapshots keyed by native or code-dispatch call id. */
-  private callSchemas = new Map<string, ToolSchema>()
-  private callSchemasRev = 0
-  private callSchemasCache: { rev: number; value: ReadonlyMap<string, ToolSchema> } | null = null
-  private modelRequestsRev = 0
-  private modelRequestsCache: {
-    rev: number
-    value: readonly ModelRequestView[]
-  } | null = null
-  private compactionRequestsRev = 0
-  private compactionRequestsCache: {
-    rev: number
-    value: readonly CompactionRequestView[]
-  } | null = null
-  private promptChangesRev = 0
-  private promptChangesCache: {
-    rev: number
-    value: readonly ConversationPromptChange[]
-  } | null = null
+  /** Diagnostic projections isolated from the ordinary conversation state machine. */
+  private readonly inspection = new SessionInspection()
   private running = false
   /**
    * Sticky send marker, private input of the composerPhase derivation: set
@@ -624,8 +605,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
   /** Per-event side effects (right column of the §A.9 dispatch table):
    *  chunk accumulation / partial clear on finalize / openCalls add-remove. */
   private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
-    if (affectsModelRequests(event)) this.modelRequestsRev++
-    if (affectsCompactionRequests(event)) this.compactionRequestsRev++
+    this.inspection.applyEvent(event)
     // The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
     // the host-side dsh-tools plugin whose types cannot enter the client
     // program (its host Context merges collide with the client's), so this
@@ -646,7 +626,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
         argsRaw: JSON.stringify(data.arguments),
         turn: 0, step: 0, time: event.time, callView: null,
       }
-      this.captureCallSchema(data.subCallId, data.name)
+      this.inspection.captureCallSchema(data.subCallId, data.name)
       const siblings = this.codeDispatches.get(data.parentCallId) ?? []
       this.codeDispatches.set(data.parentCallId, [...siblings, running])
       this.dispatchesRev++
@@ -666,7 +646,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
         content: ContentBlock[]
       }
       const siblings = this.codeDispatches.get(data.parentCallId) ?? []
-      this.captureCallSchema(data.subCallId, data.name)
+      this.inspection.captureCallSchema(data.subCallId, data.name)
       const at = siblings.findIndex(sub => sub.callId === data.subCallId)
       const started = at === -1 ? undefined : siblings[at]
       const settled: CodeSubCall = {
@@ -688,13 +668,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
       return
     }
     switch (event.type) {
-      case 'request/header': {
-        this.promptChangesRev++
-        this.activeToolSchemas = new Map(
-          (event.data.header.tools ?? []).map(schema => [schema.name, schema]),
-        )
-        return
-      }
       case 'assistant/chunk': {
         const { turn, step, chunk } = event.data
         if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
@@ -710,7 +683,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
         return
       }
       case 'tool/call': {
-        this.captureCallSchema(String(event.data.callId), event.data.name)
         this.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,
@@ -769,15 +741,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     }
   }
 
-  /** Preserve the schema active when one call starts. */
-  private captureCallSchema(callId: string, name: string): void {
-    if (this.callSchemas.has(callId)) return
-    const schema = this.activeToolSchemas.get(name)
-    if (schema === undefined) return
-    this.callSchemas.set(callId, schema)
-    this.callSchemasRev++
-  }
-
   /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
    *  paging/stitching consistent, and makes the live freeze and the history replay converge on the
    *  same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text).
@@ -792,12 +755,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     this.frozenRev++
     this.codeDispatches = new Map()
     this.dispatchesRev++
-    this.activeToolSchemas = new Map()
-    this.callSchemas = new Map()
-    this.callSchemasRev++
-    this.modelRequestsRev++
-    this.compactionRequestsRev++
-    this.promptChangesRev++
+    this.inspection.reset()
     for (let i = 0; i < this.events.length; i++) {
       const event = this.events[i]
       /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
@@ -834,53 +792,24 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
     if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
       this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
     }
-    if (this.callSchemasCache === null || this.callSchemasCache.rev !== this.callSchemasRev) {
-      this.callSchemasCache = { rev: this.callSchemasRev, value: new Map(this.callSchemas) }
-    }
-    if (
-      this.modelRequestsCache === null
-      || this.modelRequestsCache.rev !== this.modelRequestsRev
-    ) {
-      this.modelRequestsCache = {
-        rev: this.modelRequestsRev,
-        value: deriveModelRequests(this.events),
-      }
-    }
-    if (
-      this.compactionRequestsCache === null
-      || this.compactionRequestsCache.rev !== this.compactionRequestsRev
-    ) {
-      this.compactionRequestsCache = {
-        rev: this.compactionRequestsRev,
-        value: deriveCompactionRequests(this.events),
-      }
-    }
     if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
       this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
     }
-    if (
-      this.promptChangesCache === null
-      || this.promptChangesCache.rev !== this.promptChangesRev
-    ) {
-      this.promptChangesCache = {
-        rev: this.promptChangesRev,
-        value: derivePromptChanges(this.events),
-      }
-    }
+    const inspection = this.inspection.snapshot(this.events)
     const partial = this.partial?.toPartial() ?? null
     return {
       sessionId: this.sessionId,
       nodes,
       contexts,
-      compactionRequests: this.compactionRequestsCache.value,
-      requestAttempts: this.modelRequestsCache.value,
-      promptChanges: this.promptChangesCache.value,
+      compactionRequests: inspection.compactionRequests,
+      requestAttempts: inspection.requestAttempts,
+      promptChanges: inspection.promptChanges,
       foldDegraded: degraded,
       partial,
       runningCalls: this.callsCache.value,
       pending: this.pendingCache.value,
       codeDispatches: this.dispatchesCache.value,
-      callSchemas: this.callSchemasCache.value,
+      callSchemas: inspection.callSchemas,
       queue: this.queueCache.value,
       running: this.running,
       composerPhase: derivePhase(
@@ -914,260 +843,3 @@ function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPha
   if (hasContent) return 'active'
   return promptAttempted ? 'engaging' : 'blank'
 }
-
-interface RetryEvent {
-  type: 'llm/retry'
-  seq: number
-  time: number
-  data: {
-    turn: number
-    step: number
-    retry: number
-    maxRetries: number
-    delayMs: number
-    failure: { message: string }
-  }
-}
-
-function modelRequestKey(turn: number, step: number): string {
-  return `${turn}\u0000${step}`
-}
-
-function affectsModelRequests(event: SessionEvent): boolean {
-  switch (event.type) {
-    case 'request/header':
-    case 'step/start':
-    case 'assistant/message':
-    case 'step/end':
-      return true
-    case 'turn/end':
-      return event.data.reason.kind === 'error'
-    default:
-      return (event.type as string) === 'llm/retry'
-  }
-}
-
-function affectsCompactionRequests(event: SessionEvent): boolean {
-  const type = event.type as string
-  return type === 'compact/start'
-    || type === 'compact/summary'
-    || type === 'compact/end'
-    || (event.type === 'user/message' && isCompactionSource(event.data.source))
-}
-
-/** Project every durable step into one provider request, retaining failed retry attempts. */
-function deriveModelRequests(events: readonly SessionEvent[]): readonly ModelRequestView[] {
-  const requests: ModelRequestView[] = []
-  const byStep = new Map<string, number>()
-  let activeStep: string | undefined
-  let activeConfig: ConversationPromptSnapshot['config']
-
-  const update = (key: string, change: Partial<ModelRequestView>): void => {
-    const index = byStep.get(key)
-    if (index === undefined) return
-    const request = requests[index]
-    if (request !== undefined) requests[index] = { ...request, ...change }
-  }
-
-  for (const sourceEvent of events) {
-    if (sourceEvent.type === 'request/header') {
-      activeConfig = sourceEvent.data.header.config
-      if (activeStep !== undefined) update(activeStep, { requestConfig: activeConfig })
-      continue
-    }
-    if (sourceEvent.type === 'step/start') {
-      const { turn, step } = sourceEvent.data
-      const key = modelRequestKey(turn, step)
-      byStep.set(key, requests.length)
-      requests.push({
-        startSeq: sourceEvent.seq,
-        turn,
-        step,
-        startedAt: sourceEvent.time,
-        completedAt: null,
-        status: 'running',
-        ...(activeConfig === undefined ? {} : { requestConfig: activeConfig }),
-      })
-      activeStep = key
-      continue
-    }
-    if (sourceEvent.type === 'assistant/message') {
-      const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step)
-      update(key, {
-        completedAt: sourceEvent.time,
-        status: 'complete',
-        resultSeq: sourceEvent.seq,
-        provenance: {
-          provider: sourceEvent.data.provenance.provider,
-          model: sourceEvent.data.provenance.model,
-        },
-        ...(sourceEvent.data.usage === undefined ? {} : { usage: sourceEvent.data.usage }),
-      })
-      continue
-    }
-    if (sourceEvent.type === 'step/end') {
-      const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step)
-      const index = byStep.get(key)
-      const request = index === undefined ? undefined : requests[index]
-      if (index !== undefined && request !== undefined && request.status === 'running') {
-        requests[index] = {
-          ...request,
-          completedAt: sourceEvent.time,
-          status: 'error',
-        }
-      }
-      if (activeStep === key) activeStep = undefined
-      continue
-    }
-    if ((sourceEvent.type as string) === 'llm/retry') {
-      const event = sourceEvent as unknown as RetryEvent
-      update(modelRequestKey(event.data.turn, event.data.step), {
-        status: 'error',
-        error: event.data.failure.message,
-        retry: event.data.retry,
-        maxRetries: event.data.maxRetries,
-        retryDelayMs: event.data.delayMs,
-      })
-      continue
-    }
-    if (sourceEvent.type !== 'turn/end' || sourceEvent.data.reason.kind !== 'error') continue
-    const reason = sourceEvent.data.reason
-    update(modelRequestKey(sourceEvent.data.turn, reason.step), {
-      status: 'error',
-      error: 'failure' in reason ? reason.failure.message : reason.message,
-    })
-  }
-  return requests
-}
-
-interface CompactionStartEvent {
-  type: 'compact/start'
-  seq: number
-  time: number
-  data: { turn: number }
-}
-
-interface CompactionSummaryEvent {
-  type: 'compact/summary'
-  seq: number
-  time: number
-  data: {
-    summary: readonly ContentBlock[]
-    rawOutput?: readonly ContentBlock[]
-    provider: string
-    model: string
-    maxTokens?: number
-    usage?: unknown
-  }
-}
-
-interface CompactionEndEvent {
-  type: 'compact/end'
-  seq: number
-  time: number
-  data: { turn: number; error?: string }
-}
-
-/** Project log-only compaction request brackets without coupling the client runtime to one backend package. */
-function deriveCompactionRequests(events: readonly SessionEvent[]): readonly CompactionRequestView[] {
-  const requests: CompactionRequestView[] = []
-  let active: CompactionRequestView | undefined
-  for (const sourceEvent of events) {
-    const type = sourceEvent.type as string
-    if (type === 'compact/start') {
-      const event = sourceEvent as unknown as CompactionStartEvent
-      active = {
-        startSeq: event.seq,
-        turn: event.data.turn,
-        startedAt: event.time,
-        completedAt: null,
-        status: 'running',
-      }
-      continue
-    }
-    if (type === 'compact/summary' && active !== undefined) {
-      const event = sourceEvent as unknown as CompactionSummaryEvent
-      active = {
-        ...active,
-        summarySeq: event.seq,
-        summary: event.data.summary,
-        ...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
-        provenance: {
-          provider: event.data.provider,
-          model: event.data.model,
-        },
-        requestConfig: {
-          provider: event.data.provider,
-          model: event.data.model,
-          purpose: 'compaction',
-          ...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
-        },
-        ...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
-      }
-      continue
-    }
-    if (
-      sourceEvent.type === 'user/message'
-      && active?.summarySeq !== undefined
-      && isCompactionSource(sourceEvent.data.source)
-    ) {
-      active = { ...active, replacementSeq: sourceEvent.seq }
-      continue
-    }
-    if (type !== 'compact/end' || active === undefined) continue
-    const event = sourceEvent as unknown as CompactionEndEvent
-    active = {
-      ...active,
-      completedAt: event.time,
-      status: event.data.error === undefined ? 'complete' : 'error',
-      ...(event.data.error === undefined ? {} : { error: event.data.error }),
-    }
-    requests.push(active)
-    active = undefined
-  }
-  if (active !== undefined) requests.push(active)
-  return requests
-}
-
-/** Project request headers into model-visible system/tool changes only. */
-function derivePromptChanges(events: readonly SessionEvent[]): readonly ConversationPromptChange[] {
-  const changes: ConversationPromptChange[] = []
-  let previous: ConversationPromptSnapshot | undefined
-  for (const event of events) {
-    if (event.type !== 'request/header') continue
-    const prompt: ConversationPromptSnapshot = {
-      config: event.data.header.config,
-      system: event.data.header.system ?? '',
-      tools: event.data.header.tools ?? [],
-    }
-    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) {
-      changes.push({
-        seq: event.seq,
-        time: event.time,
-        kind: previous === undefined
-          ? 'initial'
-          : systemChanged && toolsChanged
-            ? 'system-and-tools'
-            : systemChanged
-              ? 'system'
-              : 'tools',
-        prompt,
-        ...(previous === undefined ? {} : { previous }),
-      })
-    }
-    previous = prompt
-  }
-  return changes
-}
-
-function isCompactionSource(source: unknown): boolean {
-  return typeof source === 'object'
-    && source !== null
-    && 'kind' in source
-    && source.kind === 'plugin'
-    && 'plugin' in source
-    && source.plugin === 'compact'
-}

+ 41 - 0
packages/client/runtime/tests/fold-adapter.spec.ts

@@ -34,6 +34,47 @@ describe('FoldAdapter', () => {
     expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
   })
 
+  it('projects frozen surface generations without widening the core live surface', () => {
+    const adapter = new FoldAdapter()
+    adapter.reset([
+      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,
+          content: [{ type: 'text', text: 'summary' }],
+          provenance: { provider: 'fake', model: 'fake' },
+        },
+      }),
+      at(3, {
+        type: 'assistant/message',
+        surfaceOp: { op: 'replace', start: 2, end: 1 },
+        sourceEventSeqs: [2, 1],
+        data: {
+          turn: 1,
+          step: 2,
+          content: [{ type: 'text', text: 'summary 2' }],
+          provenance: { provider: 'fake', model: 'fake' },
+        },
+      }),
+    ], 0)
+
+    expect(adapter.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('materializes all six node variants with field mapping', () => {
     const adapter = new FoldAdapter()
     const events = [

+ 1 - 4
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -591,12 +591,9 @@ function messageOriginLabel(source: unknown): string {
 function MessageOrigin({ record }: { record: TableRecord }) {
   const source = record.cell.messageSource
   if (source === undefined) return <p className={css.noPayload}>Origin not recorded</p>
-  const sourceRoot = typeof source === 'object' && source !== null
+  const data = typeof source === 'object' && source !== null
     ? source
     : { value: source }
-  const data = record.cell.messageMeta === undefined
-    ? sourceRoot
-    : { source, meta: record.cell.messageMeta }
   return (
     <JsonTree
       data={data}

+ 0 - 1
packages/client/ui-trajectory/src/client/layout.ts

@@ -317,7 +317,6 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
           index: ++index,
           kind: 'user',
           ...inputCellDetail(node),
-          ...(node.meta === undefined ? {} : { messageMeta: node.meta }),
           opensTurn: node.kind === 'user',
         },
       })

+ 0 - 1
packages/client/ui-trajectory/src/client/trajectory-record.ts

@@ -47,7 +47,6 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
   /** Producer provenance from a user-role message or context injection. */
   messageSource?: unknown
   /** Producer-owned model-hidden metadata carried beside the message source. */
-  messageMeta?: unknown
   /** A separator-only anchor for an auxiliary request with no visible record. */
   requestOnly?: boolean
   /** Full request/message content for the details panel. */

+ 0 - 1
packages/compact/compact-basic/src/region.ts

@@ -168,7 +168,6 @@ export async function compactSurfaceRegion(
       summarySeq: summaryEvent.seq,
       endSeq: endEvent.seq,
       summary,
-      ...rawOutput === undefined ? {} : { rawOutput },
       shadowedRange: { start, end },
       shadowedSeqs,
       shadowedTokenCount,

+ 0 - 1
packages/compact/compact-basic/tests/compact-basic.spec.ts

@@ -816,7 +816,6 @@ describe('compaction region transaction', () => {
 
     expect(result.shadowedSeqs).toEqual(before.slice(0, 4))
     expect(result.shadowedTokenCount).toBeGreaterThan(0)
-    expect(result.rawOutput).toEqual(compact.rawOutput)
     expect(compact.calls[0]).toMatchObject({ signal: SIGNAL })
     expect(summarizedText(compact.calls[0]!.input)).toContain('fixture user 1')
     const summary = session.events.findLast(event => event.type === 'compact/summary')

+ 0 - 2
packages/compact/compact/src/types.ts

@@ -55,8 +55,6 @@ export interface CompactionResult {
   endSeq: number
   /** The summary content blocks produced by the backend. */
   summary: ContentBlock[]
-  /** Complete provider output before the backend's safe summary projection. */
-  rawOutput?: ContentBlock[]
   /**
    * The surface-boundary pair that was shadowed: the seqs of the first
    * (`start`) and last (`end`) surface nodes of the replaced range. A

+ 1 - 1
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -1539,7 +1539,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'CompactionResult',
-    declaration: 'export interface CompactionResult {\n    startSeq: number;\n    summarySeq: number;\n    endSeq: number;\n    summary: ContentBlock[];\n    rawOutput?: ContentBlock[];\n    shadowedRange: {\n        start: number;\n        end: number;\n    };\n    shadowedSeqs: number[];\n    shadowedTokenCount: number;\n}',
+    declaration: 'export interface CompactionResult {\n    startSeq: number;\n    summarySeq: number;\n    endSeq: number;\n    summary: ContentBlock[];\n    shadowedRange: {\n        start: number;\n        end: number;\n    };\n    shadowedSeqs: number[];\n    shadowedTokenCount: number;\n}',
   },
   {
     name: 'CompactionTrigger',

+ 8 - 57
packages/core/session/src/surface.ts

@@ -57,16 +57,6 @@ export interface SurfaceFoldResult {
   replacements: SurfaceFoldReplacement[]
 }
 
-/** One append-only surface generation separated from its successor by a replacement. */
-export interface SurfaceFoldContext {
-  /** Zero-based generation within this session log. */
-  generation: number
-  /** Surface sequences present when this generation froze, or at the current tail. */
-  nodes: number[]
-  /** Replacement operation that created this generation; absent for the initial context. */
-  origin?: SurfaceFoldReplacement
-}
-
 /** Readonly live projection of the message-producing session events. */
 export interface SessionSurface {
   /** Current surface event sequences in model-visible order. */
@@ -270,11 +260,14 @@ function planSurfaceEvent(
   }
 }
 
-/** Commit one validated transition and return replacement metadata when one occurred. */
-function applySurfacePlan(
+/** Apply one event and return replacement metadata only when one occurred. */
+function applySurfaceEvent(
   state: SurfaceFoldState,
-  plan: SurfacePlan | undefined,
+  event: SessionEvent,
+  expectedSeq: number,
+  events: readonly SessionEvent[],
 ): SurfaceFoldReplacement | undefined {
+  const plan = planSurfaceEvent(state, event, expectedSeq, events)
   if (plan?.kind === 'append') {
     state.nodes.push(plan.seq)
   } else if (plan?.kind === 'replace') {
@@ -290,16 +283,6 @@ function applySurfacePlan(
   }
 }
 
-/** Apply one event and return replacement metadata only when one occurred. */
-function applySurfaceEvent(
-  state: SurfaceFoldState,
-  event: SessionEvent,
-  expectedSeq: number,
-  events: readonly SessionEvent[],
-): SurfaceFoldReplacement | undefined {
-  return applySurfacePlan(state, planSurfaceEvent(state, event, expectedSeq, events))
-}
-
 /**
  * Replay a complete session log through the canonical surface fold.
  * @param events - session events in contiguous seq order.
@@ -316,34 +299,9 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
   return { nodes: [...state.nodes], replacements }
 }
 
-/** Reconstruct every surface generation only for consumers that request history. */
-function foldSurfaceContexts(events: readonly SessionEvent[]): SurfaceFoldContext[] {
-  const state = createFoldState()
-  const contexts: SurfaceFoldContext[] = []
-  let origin: SurfaceFoldReplacement | undefined
-  for (const [index, event] of events.entries()) {
-    const plan = planSurfaceEvent(state, event, index, events)
-    const priorNodes = plan?.kind === 'replace' ? [...state.nodes] : undefined
-    const replacement = applySurfacePlan(state, plan)
-    if (replacement === undefined || priorNodes === undefined) continue
-    contexts.push({
-      generation: state.replaceGeneration - 1,
-      nodes: priorNodes,
-      ...(origin === undefined ? {} : { origin }),
-    })
-    origin = replacement
-  }
-  contexts.push({
-    generation: state.replaceGeneration,
-    nodes: [...state.nodes],
-    ...(origin === undefined ? {} : { origin }),
-  })
-  return contexts
-}
-
 /** Incremental ordered surface view and append-boundary validator. */
 export class SurfaceManager implements SessionSurface {
-  /** Shared transition state for the live surface. */
+  /** Shared transition state; replacement history is not retained. */
   private _state = createFoldState()
   /** Last processed seq; -1 folds a seeded log on first access. */
   private _lastProcessedSeq = -1
@@ -371,18 +329,11 @@ export class SurfaceManager implements SessionSurface {
     return this._state.nodes
   }
 
-  /** Surface generations reconstructed on demand without burdening ordinary live sessions. */
-  get contexts(): readonly SurfaceFoldContext[] {
-    if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
-    return foldSurfaceContexts(this.log)
-  }
-
   /** Fold events appended since the previous access. */
   private _processDelta(): void {
     for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
-      const event = this.log[i]!
-      applySurfaceEvent(this._state, event, i, this.log)
+      applySurfaceEvent(this._state, this.log[i]!, i, this.log)
       this._lastProcessedSeq = i
     }
   }

+ 0 - 15
packages/core/session/tests/surface.spec.ts

@@ -8,7 +8,6 @@ import {
   isSurfaceEvent,
 } from '@deepseek-ai/dsh-session'
 import { CallId } from '@deepseek-ai/dsh-llm'
-import { SurfaceManager } from '../src/surface.ts'
 
 /** Build a minimal session with turn boundaries and a single user message. */
 function surfaceSession(): Session {
@@ -190,19 +189,6 @@ describe('SurfaceManager', () => {
       { seq: 2, start: 0, end: 0, shadowedSeqs: [0] },
       { seq: 3, start: 2, end: 1, shadowedSeqs: [2, 1] },
     ])
-    expect(new SurfaceManager(s.events).contexts).toEqual([
-      { generation: 0, nodes: [0, 1] },
-      {
-        generation: 1,
-        nodes: [2, 1],
-        origin: { seq: 2, start: 0, end: 0, shadowedSeqs: [0] },
-      },
-      {
-        generation: 2,
-        nodes: [3],
-        origin: { seq: 3, start: 2, end: 1, shadowedSeqs: [2, 1] },
-      },
-    ])
     folded.nodes[0] = 99
     folded.replacements[0]!.shadowedSeqs.push(99)
     expect(s.surface.nodes).toEqual([3])
@@ -218,7 +204,6 @@ describe('SurfaceManager', () => {
     expect(s.surface.nodes).toEqual([1])
     const manager = s.surface as unknown as { _state: object }
     expect(Object.hasOwn(manager._state, 'replacements')).toBe(false)
-    expect(Object.hasOwn(manager, '_contexts')).toBe(false)
     expect(foldSurface(s.events).replacements).toEqual([
       { seq: 1, start: 0, end: 0, shadowedSeqs: [0] },
     ])