cleanup(session): isolate trajectory inspection

This commit is contained in:
_Kerman
2026-07-28 11:39:53 +08:00
parent 67fcd8ea6d
commit 3f7760717d
18 changed files with 624 additions and 556 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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[]

View File

@@ -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 {
AssistantRequestConfig, AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode,
ConversationPromptSnapshot,
} from './conversation.ts'
import type { AssistantTiming, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
import type {
AssistantRequestConfig, ConversationContext, ConversationContextOriginKind,
ConversationPromptSnapshot,
} 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,

View File

@@ -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'
}

View File

@@ -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'
}

View File

@@ -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 = [

View File

@@ -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}

View File

@@ -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',
},
})

View File

@@ -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. */

View File

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

View File

@@ -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')

View File

@@ -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

View File

@@ -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',

View File

@@ -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
}
}

View File

@@ -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] },
])