mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
cleanup(session): isolate trajectory inspection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
493
packages/client/runtime/src/client/sessions/inspection.ts
Normal file
493
packages/client/runtime/src/client/sessions/inspection.ts
Normal 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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -168,7 +168,6 @@ export async function compactSurfaceRegion(
|
||||
summarySeq: summaryEvent.seq,
|
||||
endSeq: endEvent.seq,
|
||||
summary,
|
||||
...rawOutput === undefined ? {} : { rawOutput },
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] },
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user