refactor(client): isolate trajectory history reads
This commit is contained in:
@@ -28,16 +28,21 @@ export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
|
||||
ConversationNode,
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
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'
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from './sessions/conversation-context.ts'
|
||||
export type {
|
||||
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
|
||||
} from './sessions/request-inspection.ts'
|
||||
export { inspectRequests } from './sessions/request-inspection.ts'
|
||||
export { projectConversationHistory } from './sessions/fold-adapter.ts'
|
||||
export type { ConversationHistoryProjection } from './sessions/fold-adapter.ts'
|
||||
export type { SessionHistory, SessionHistorySnapshot } from './sessions/history.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'
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import type { ConversationPromptSnapshot } from './request-inspection.ts'
|
||||
|
||||
/** Operation that started a new append-only model context. */
|
||||
export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
|
||||
|
||||
/** 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[]
|
||||
}
|
||||
@@ -9,12 +9,26 @@ 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 }
|
||||
|
||||
/** Request configuration recorded for one provider call. */
|
||||
export interface AssistantRequestConfig {
|
||||
provider: string
|
||||
model: string
|
||||
purpose?: string
|
||||
thinking?: string
|
||||
reasoningEffort?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
stop?: readonly string[]
|
||||
}
|
||||
|
||||
/** Stable provider/model identity reported for one completed request. */
|
||||
export interface AssistantProvenanceView {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Assistant content blocks sorted by what the UI cares about
|
||||
* (text body / collapsible reasoning / tool-call card head / other fallback). */
|
||||
export type AssistantBlock =
|
||||
@@ -224,14 +238,6 @@ export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
/** Surface fold product (finalized conversation nodes in surface order). */
|
||||
nodes: readonly ConversationNode[]
|
||||
/** 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?: SessionInspectionSnapshot['compactionRequests']
|
||||
/** Ordinary provider requests, including failed attempts that produced no assistant message. */
|
||||
requestAttempts?: SessionInspectionSnapshot['requestAttempts']
|
||||
/** System-prompt/tool-catalog changes in request order. */
|
||||
promptChanges?: SessionInspectionSnapshot['promptChanges']
|
||||
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
|
||||
foldDegraded: boolean
|
||||
partial: PartialAssistant | null
|
||||
@@ -243,8 +249,6 @@ export interface ConversationSnapshot {
|
||||
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
|
||||
*/
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
/** Model-visible tool schema captured for each recorded call id. */
|
||||
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[]
|
||||
|
||||
@@ -10,13 +10,45 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session/surface'
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { AssistantTiming, ConversationNode } from './conversation.ts'
|
||||
import type {
|
||||
HistoryEntry, ToolCallView, ToolEventView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
AssistantRequestConfig, AssistantTiming, ConversationNode,
|
||||
} from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
import type {
|
||||
AssistantRequestConfig, ConversationContext, ConversationContextOriginKind,
|
||||
ConversationPromptSnapshot,
|
||||
} from './inspection.ts'
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from './conversation-context.ts'
|
||||
import type { ConversationPromptSnapshot } from './request-inspection.ts'
|
||||
|
||||
/** Lazy event-order and context-generation projection for history consumers. */
|
||||
export interface ConversationHistoryProjection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
contexts: readonly ConversationContext[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Project an immutable raw history window into event-order nodes and context
|
||||
* generations. Session's chat snapshot never computes this projection.
|
||||
* @param entries - Contiguous history entries in sequence order.
|
||||
* @returns Inspection-oriented conversation projections.
|
||||
*/
|
||||
export function projectConversationHistory(
|
||||
entries: readonly HistoryEntry[],
|
||||
): ConversationHistoryProjection {
|
||||
const adapter = new FoldAdapter(true)
|
||||
const events = entries.map(entry => entry.event)
|
||||
adapter.reset(
|
||||
events,
|
||||
events[0]?.seq ?? 0,
|
||||
entries.map(entry => entry.view),
|
||||
)
|
||||
return {
|
||||
eventNodes: adapter.eventNodes(),
|
||||
contexts: adapter.contexts(),
|
||||
}
|
||||
}
|
||||
|
||||
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
|
||||
export interface CallIndexEntry {
|
||||
@@ -155,6 +187,7 @@ export class FoldAdapter {
|
||||
* reference-stability contract (§A.9.4) starts here. */
|
||||
private rev = 0
|
||||
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
|
||||
private eventNodesResult: { rev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** Revision of context structure or its request header; unrelated log-only events do not rebuild contexts. */
|
||||
private contextRev = 0
|
||||
private contextsResult: { rev: number; value: readonly ConversationContext[] } | null = null
|
||||
@@ -162,6 +195,12 @@ export class FoldAdapter {
|
||||
private activePrompt: ConversationPromptSnapshot | undefined
|
||||
private promptsByContext = new Map<number, ConversationPromptSnapshot>()
|
||||
|
||||
/**
|
||||
* @param projectContexts - Whether to maintain context-generation indexes
|
||||
* for a later history projection. The live chat fold leaves this disabled.
|
||||
*/
|
||||
constructor(private readonly projectContexts = false) {}
|
||||
|
||||
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
|
||||
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
|
||||
return this.callIdx
|
||||
@@ -176,7 +215,7 @@ export class FoldAdapter {
|
||||
*/
|
||||
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
|
||||
this.rev++
|
||||
this.contextRev++
|
||||
if (this.projectContexts) this.contextRev++
|
||||
this.baseSeq = baseSeq
|
||||
this.padded = []
|
||||
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
|
||||
@@ -194,7 +233,7 @@ export class FoldAdapter {
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (event !== undefined) {
|
||||
this.indexCall(event, views?.[i])
|
||||
this.indexContextPrompt(event)
|
||||
if (this.projectContexts) this.indexContextPrompt(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,10 +246,12 @@ export class FoldAdapter {
|
||||
*/
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.rev++
|
||||
if (isSurfaceEvent(event) || event.type === 'request/header') this.contextRev++
|
||||
if (this.projectContexts && (isSurfaceEvent(event) || event.type === 'request/header')) {
|
||||
this.contextRev++
|
||||
}
|
||||
this.padded.push(event)
|
||||
this.indexCall(event, view)
|
||||
this.indexContextPrompt(event)
|
||||
if (this.projectContexts) this.indexContextPrompt(event)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,11 +284,33 @@ export class FoldAdapter {
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Every in-window message-producing event in original sequence order, without surface replacement folding.
|
||||
* @returns append-only event projection for history inspection.
|
||||
*/
|
||||
eventNodes(): readonly ConversationNode[] {
|
||||
if (this.eventNodesResult !== null && this.eventNodesResult.rev === this.rev) {
|
||||
return this.eventNodesResult.value
|
||||
}
|
||||
const nodes: ConversationNode[] = []
|
||||
for (let seq = this.baseSeq; seq < this.padded.length; seq++) {
|
||||
const event = this.padded[seq]
|
||||
if (event === undefined || !isSurfaceEligibleType(event.type)) continue
|
||||
const node = this.materialize(seq)
|
||||
if (node !== undefined) nodes.push(node)
|
||||
}
|
||||
this.eventNodesResult = { rev: this.rev, value: nodes }
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-only context generations reconstructed from canonical surface replacements.
|
||||
* @returns Frozen historical contexts followed by the current context.
|
||||
*/
|
||||
contexts(): readonly ConversationContext[] {
|
||||
if (!this.projectContexts) {
|
||||
throw new Error('FoldAdapter context projection was not enabled')
|
||||
}
|
||||
if (this.contextsResult !== null && this.contextsResult.rev === this.contextRev) {
|
||||
return this.contextsResult.value
|
||||
}
|
||||
|
||||
33
packages/client/runtime/src/client/sessions/history.ts
Normal file
33
packages/client/runtime/src/client/sessions/history.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
HistoryEntry, RpcError, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import type { OpenState } from './conversation.ts'
|
||||
|
||||
/**
|
||||
* Immutable read window over one session's durable event log.
|
||||
*
|
||||
* The conversation snapshot is a chat projection. Consumers that need event
|
||||
* order or request lifecycle data read this source instead of widening that
|
||||
* projection with inspection-only fields.
|
||||
*/
|
||||
export interface SessionHistorySnapshot {
|
||||
sessionId: SessionId
|
||||
/** Contiguous raw log entries in ascending sequence order. */
|
||||
entries: readonly HistoryEntry[]
|
||||
/** Sequence of the first entry, or zero while the window is empty. */
|
||||
baseSeq: number
|
||||
openState: OpenState
|
||||
openError: RpcError | null
|
||||
hasMore: boolean
|
||||
loadingOlder: boolean
|
||||
}
|
||||
|
||||
/** Read-only observable history plus explicit full-ledger paging. */
|
||||
export interface SessionHistory extends ObservableSnapshot<SessionHistorySnapshot> {
|
||||
/**
|
||||
* Load every earlier page currently available.
|
||||
* @returns When paging is exhausted or cannot advance.
|
||||
*/
|
||||
loadAll(): Promise<void>
|
||||
}
|
||||
@@ -1,493 +0,0 @@
|
||||
// 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'
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// Request-centric inspection read model. Ordinary generation and compaction
|
||||
// calls share one chronological projection; presentation-specific grouping
|
||||
// remains in the trajectory consumer.
|
||||
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
} from './conversation.ts'
|
||||
|
||||
export type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
} from './conversation.ts'
|
||||
|
||||
/** Complete model-visible request header in force for an ordinary generation. */
|
||||
export interface ConversationPromptSnapshot {
|
||||
/** Provider/model and sampling configuration from the 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[]
|
||||
}
|
||||
|
||||
/** System/tool change introduced while preparing one ordinary request. */
|
||||
export interface RequestPromptChange {
|
||||
/** 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 prompt differs from the previous recorded state. */
|
||||
kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
|
||||
/** State immediately before this change; absent for the initial header. */
|
||||
previous?: ConversationPromptSnapshot
|
||||
}
|
||||
|
||||
/** One provider request reconstructed from durable request lifecycle events. */
|
||||
export interface RequestView {
|
||||
/** Request category; compaction is a purpose, not a separate projection. */
|
||||
purpose: 'assistant' | 'compaction'
|
||||
/** Sequence that opened the operation represented by this request. */
|
||||
startSeq: number
|
||||
turn: number
|
||||
/** Agent-loop step, or zero for a direct compaction request. */
|
||||
step: number
|
||||
startedAt: number
|
||||
completedAt: number | null
|
||||
status: 'running' | 'complete' | 'error'
|
||||
error?: string
|
||||
/** Effective ordinary request input, inherited until a later header changes it. */
|
||||
prompt?: ConversationPromptSnapshot
|
||||
/** Prompt change logged while preparing this request. */
|
||||
promptChange?: RequestPromptChange
|
||||
provenance?: AssistantProvenanceView
|
||||
requestConfig?: AssistantRequestConfig
|
||||
usage?: unknown
|
||||
/** Assistant message or compaction summary sequence produced by this request. */
|
||||
resultSeq?: number
|
||||
/** Compaction replacement message sequence, when one was committed. */
|
||||
replacementSeq?: number
|
||||
/** Safe compaction summary projection. */
|
||||
summary?: readonly ContentBlock[]
|
||||
/** Complete compaction provider output before the safe projection. */
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
/** Retry ordinal scheduled after a failed ordinary request. */
|
||||
retry?: number
|
||||
maxRetries?: number
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
/** Immutable request-centric projection derived from one history window. */
|
||||
export interface RequestInspectionSnapshot {
|
||||
requests: readonly RequestView[]
|
||||
callSchemas: ReadonlyMap<string, ToolSchema>
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the request-centric read model from one immutable history window.
|
||||
* Compaction participates as a request purpose rather than a parallel
|
||||
* top-level collection.
|
||||
* @param entries - Contiguous raw session history.
|
||||
* @returns Requests and call-time schemas derived from that history.
|
||||
*/
|
||||
export function inspectRequests(
|
||||
entries: readonly HistoryEntry[],
|
||||
): RequestInspectionSnapshot {
|
||||
const events = entries.map(entry => entry.event)
|
||||
return {
|
||||
requests: deriveRequests(events),
|
||||
callSchemas: deriveCallSchemas(events),
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
function requestKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
function deriveCallSchemas(
|
||||
events: readonly SessionEvent[],
|
||||
): ReadonlyMap<string, ToolSchema> {
|
||||
let active = new Map<string, ToolSchema>()
|
||||
const calls = new Map<string, ToolSchema>()
|
||||
const capture = (callId: string, name: string): void => {
|
||||
if (calls.has(callId)) return
|
||||
const schema = active.get(name)
|
||||
if (schema !== undefined) calls.set(callId, schema)
|
||||
}
|
||||
for (const event of events) {
|
||||
if (event.type === 'request/header') {
|
||||
active = new Map(
|
||||
(event.data.header.tools ?? []).map(schema => [schema.name, schema]),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/call') {
|
||||
capture(String(event.data.callId), event.data.name)
|
||||
continue
|
||||
}
|
||||
const type = event.type as string
|
||||
if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') {
|
||||
const data = event.data as unknown as { subCallId: string; name: string }
|
||||
capture(data.subCallId, data.name)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function promptChange(
|
||||
previous: ConversationPromptSnapshot | undefined,
|
||||
prompt: ConversationPromptSnapshot,
|
||||
event: SessionEvent<'request/header'>,
|
||||
): RequestPromptChange | undefined {
|
||||
const systemChanged = previous !== undefined && previous.system !== prompt.system
|
||||
const toolsChanged = previous !== undefined
|
||||
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
|
||||
if (previous !== undefined && !systemChanged && !toolsChanged) return
|
||||
return {
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
kind: previous === undefined
|
||||
? 'initial'
|
||||
: systemChanged && toolsChanged
|
||||
? 'system-and-tools'
|
||||
: systemChanged
|
||||
? 'system'
|
||||
: 'tools',
|
||||
...(previous === undefined ? {} : { previous }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Project ordinary and compaction provider calls into one chronological request stream. */
|
||||
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
|
||||
const requests: RequestView[] = []
|
||||
const ordinaryByStep = new Map<string, number>()
|
||||
let activeStep: string | undefined
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let activeCompaction: number | undefined
|
||||
|
||||
const update = (index: number | undefined, change: Partial<RequestView>): void => {
|
||||
if (index === undefined) return
|
||||
const request = requests[index]
|
||||
if (request !== undefined) requests[index] = { ...request, ...change }
|
||||
}
|
||||
|
||||
for (const sourceEvent of events) {
|
||||
if (sourceEvent.type === 'step/start') {
|
||||
const { turn, step } = sourceEvent.data
|
||||
const key = requestKey(turn, step)
|
||||
ordinaryByStep.set(key, requests.length)
|
||||
requests.push({
|
||||
purpose: 'assistant',
|
||||
startSeq: sourceEvent.seq,
|
||||
turn,
|
||||
step,
|
||||
startedAt: sourceEvent.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
...(activePrompt === undefined
|
||||
? {}
|
||||
: { prompt: activePrompt, requestConfig: activePrompt.config }),
|
||||
})
|
||||
activeStep = key
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'request/header') {
|
||||
const prompt: ConversationPromptSnapshot = {
|
||||
config: sourceEvent.data.header.config,
|
||||
system: sourceEvent.data.header.system ?? '',
|
||||
tools: sourceEvent.data.header.tools ?? [],
|
||||
}
|
||||
const change = promptChange(activePrompt, prompt, sourceEvent)
|
||||
activePrompt = prompt
|
||||
update(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
|
||||
prompt,
|
||||
requestConfig: prompt.config,
|
||||
...(change === undefined ? {} : { promptChange: change }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'assistant/message') {
|
||||
update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, sourceEvent.data.step)), {
|
||||
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 = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
|
||||
const index = ordinaryByStep.get(key)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
if (request?.status === 'running') {
|
||||
update(index, {
|
||||
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(ordinaryByStep.get(requestKey(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') {
|
||||
const reason = sourceEvent.data.reason
|
||||
update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
|
||||
status: 'error',
|
||||
error: 'failure' in reason ? reason.failure.message : reason.message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const type = sourceEvent.type as string
|
||||
if (type === 'compact/start') {
|
||||
const event = sourceEvent as unknown as CompactionStartEvent
|
||||
activeCompaction = requests.length
|
||||
requests.push({
|
||||
purpose: 'compaction',
|
||||
startSeq: event.seq,
|
||||
turn: event.data.turn,
|
||||
step: 0,
|
||||
startedAt: event.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (type === 'compact/summary' && activeCompaction !== undefined) {
|
||||
const event = sourceEvent as unknown as CompactionSummaryEvent
|
||||
update(activeCompaction, {
|
||||
resultSeq: 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'
|
||||
&& activeCompaction !== undefined
|
||||
&& isCompactionSource(sourceEvent.data.source)
|
||||
) {
|
||||
update(activeCompaction, { replacementSeq: sourceEvent.seq })
|
||||
continue
|
||||
}
|
||||
if (type !== 'compact/end' || activeCompaction === undefined) continue
|
||||
const event = sourceEvent as unknown as CompactionEndEvent
|
||||
update(activeCompaction, {
|
||||
completedAt: event.time,
|
||||
status: event.data.error === undefined ? 'complete' : 'error',
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
})
|
||||
activeCompaction = undefined
|
||||
}
|
||||
|
||||
return requests.sort((left, right) => left.startSeq - right.startSeq)
|
||||
}
|
||||
|
||||
function isCompactionSource(source: unknown): boolean {
|
||||
return typeof source === 'object'
|
||||
&& source !== null
|
||||
&& 'kind' in source
|
||||
&& source.kind === 'plugin'
|
||||
&& 'plugin' in source
|
||||
&& source.plugin === 'compact'
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot,
|
||||
OpenState, PromptError, QueuedMessage, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import { SessionInspection } from './inspection.ts'
|
||||
import type { SessionHistory, SessionHistorySnapshot } from './history.ts'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
import { PendingWait } from './pending.ts'
|
||||
import { FoldAdapter } from './fold-adapter.ts'
|
||||
@@ -59,8 +59,9 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns a session's event window, derived conversation state, and observable
|
||||
* snapshot. React bindings remain outside this data layer.
|
||||
* Owns a session's event window and exposes two observable read surfaces:
|
||||
* the folded chat conversation and the raw history window. React bindings
|
||||
* remain outside this data layer.
|
||||
*/
|
||||
export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
|
||||
@@ -108,8 +109,9 @@ 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
|
||||
/** Diagnostic projections isolated from the ordinary conversation state machine. */
|
||||
private readonly inspection = new SessionInspection()
|
||||
/** Raw history revision; published entries are copied so later live appends never mutate a prior snapshot. */
|
||||
private historyRev = 0
|
||||
private historyEntriesCache: { rev: number; value: readonly HistoryEntry[] } | null = null
|
||||
private running = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
@@ -130,9 +132,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private subscribedLastSeq: number | null = null
|
||||
|
||||
private snapshotCache: ConversationSnapshot
|
||||
private historySnapshotCache: SessionHistorySnapshot | undefined
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
this.historySnapshotCache = this.buildHistorySnapshot()
|
||||
})
|
||||
/** Raw log read surface; trajectory-like consumers project their own model from it. */
|
||||
readonly history: SessionHistory
|
||||
/**
|
||||
* Agent-scoped cordis context, bound once by SessionsService when it
|
||||
* mints the scope (the client mirror of the host Agent's loopCtx). The
|
||||
@@ -153,6 +159,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
this.historySnapshotCache = this.buildHistorySnapshot()
|
||||
this.history = {
|
||||
getSnapshot: () => {
|
||||
this.notifier.ensureFresh()
|
||||
/* v8 ignore next -- constructor initializes the cache before history is published. */
|
||||
if (this.historySnapshotCache === undefined) {
|
||||
throw new Error(`session ${this.sessionId} history cache is uninitialized`)
|
||||
}
|
||||
return this.historySnapshotCache
|
||||
},
|
||||
subscribe: listener => this.notifier.subscribe(listener),
|
||||
loadAll: () => this.loadAllHistory(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,6 +287,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
}
|
||||
this.events = [...older.map(e => e.event), ...this.events]
|
||||
this.views = [...older.map(e => e.view), ...this.views]
|
||||
this.historyRev++
|
||||
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
|
||||
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
|
||||
this.hasMore = result.value.hasMore
|
||||
@@ -281,6 +301,20 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exhaust history paging for inspection surfaces that require a complete
|
||||
* session ledger. Stops after a failed or non-advancing page so a transient
|
||||
* backend failure cannot become an automatic retry loop.
|
||||
* @returns When the available history has been exhausted or paging stops making progress.
|
||||
*/
|
||||
private async loadAllHistory(): Promise<void> {
|
||||
while (this.openState === 'open' && this.hasMore && !this.loadingOlder) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconnect rebuild (manager calls this on onConnected for instances that were opened):
|
||||
* reset the window and rerun open; pending waits for the baseline replay. Invalidates any
|
||||
* in-flight open first — its history request rode the dead connection and must not settle
|
||||
@@ -298,6 +332,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.openError = null
|
||||
this.events = []
|
||||
this.views = []
|
||||
this.historyRev++
|
||||
this.baseSeq = 0
|
||||
// Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim
|
||||
// (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host.
|
||||
@@ -512,6 +547,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void {
|
||||
this.events = entries.map(e => e.event)
|
||||
this.views = entries.map(e => e.view)
|
||||
this.historyRev++
|
||||
this.baseSeq = this.events[0]?.seq ?? 0
|
||||
this.hasMore = hasMore
|
||||
// Session-level projection from the tail page (full-log latest todo/write,
|
||||
@@ -536,6 +572,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
|
||||
this.events.push(event)
|
||||
this.views.push(view)
|
||||
this.historyRev++
|
||||
this.foldAdapter.append(event, view)
|
||||
this.applyEventSideEffects(event, view)
|
||||
}
|
||||
@@ -605,7 +642,6 @@ 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 {
|
||||
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
|
||||
@@ -626,7 +662,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0, step: 0, time: event.time, callView: null,
|
||||
}
|
||||
this.inspection.captureCallSchema(data.subCallId, data.name)
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
this.codeDispatches.set(data.parentCallId, [...siblings, running])
|
||||
this.dispatchesRev++
|
||||
@@ -646,7 +681,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
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 = {
|
||||
@@ -755,7 +789,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.frozenRev++
|
||||
this.codeDispatches = new Map()
|
||||
this.dispatchesRev++
|
||||
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. */
|
||||
@@ -770,7 +803,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
|
||||
private buildSnapshot(): ConversationSnapshot {
|
||||
const { nodes: folded, degraded } = this.foldAdapter.nodes()
|
||||
const contexts = this.foldAdapter.contexts()
|
||||
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
|
||||
// The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
|
||||
// reference across snapshot swaps (§A.9.4).
|
||||
@@ -795,21 +827,15 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
|
||||
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
|
||||
}
|
||||
const inspection = this.inspection.snapshot(this.events)
|
||||
const partial = this.partial?.toPartial() ?? null
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
contexts,
|
||||
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: inspection.callSchemas,
|
||||
queue: this.queueCache.value,
|
||||
running: this.running,
|
||||
composerPhase: derivePhase(
|
||||
@@ -827,6 +853,40 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
todos: this.todos,
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the raw history read surface without leaking the mutable window arrays. */
|
||||
private buildHistorySnapshot(): SessionHistorySnapshot {
|
||||
if (this.historyEntriesCache === null || this.historyEntriesCache.rev !== this.historyRev) {
|
||||
this.historyEntriesCache = {
|
||||
rev: this.historyRev,
|
||||
value: this.events.map((event, index) => {
|
||||
const view = this.views[index]
|
||||
return view === undefined ? { event } : { event, view }
|
||||
}),
|
||||
}
|
||||
}
|
||||
const previous = this.historySnapshotCache
|
||||
if (
|
||||
previous !== undefined
|
||||
&& previous.entries === this.historyEntriesCache.value
|
||||
&& previous.baseSeq === this.baseSeq
|
||||
&& previous.openState === this.openState
|
||||
&& previous.openError === this.openError
|
||||
&& previous.hasMore === this.hasMore
|
||||
&& previous.loadingOlder === this.loadingOlder
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
entries: this.historyEntriesCache.value,
|
||||
baseSeq: this.baseSeq,
|
||||
openState: this.openState,
|
||||
openError: this.openError,
|
||||
hasMore: this.hasMore,
|
||||
loadingOlder: this.loadingOlder,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
|
||||
import {
|
||||
FoldAdapter, projectConversationHistory,
|
||||
} from '../src/client/sessions/fold-adapter.ts'
|
||||
import { ev, plainTurn } from './event-script.ts'
|
||||
|
||||
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
@@ -35,8 +37,7 @@ describe('FoldAdapter', () => {
|
||||
})
|
||||
|
||||
it('projects frozen surface generations without widening the core live surface', () => {
|
||||
const adapter = new FoldAdapter()
|
||||
adapter.reset([
|
||||
const events = [
|
||||
ev.user(0, 'a'),
|
||||
ev.user(1, 'b'),
|
||||
at(2, {
|
||||
@@ -61,9 +62,9 @@ describe('FoldAdapter', () => {
|
||||
provenance: { provider: 'fake', model: 'fake' },
|
||||
},
|
||||
}),
|
||||
], 0)
|
||||
]
|
||||
|
||||
expect(adapter.contexts().map(context => ({
|
||||
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
|
||||
id: context.id,
|
||||
parentId: context.parentId,
|
||||
originSeq: context.originSeq,
|
||||
|
||||
108
packages/client/runtime/tests/request-inspection.spec.ts
Normal file
108
packages/client/runtime/tests/request-inspection.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
|
||||
|
||||
const at = (seq: number, type: string, data: unknown): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
|
||||
|
||||
const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
|
||||
events.map(event => ({ event }))
|
||||
|
||||
describe('inspectRequests', () => {
|
||||
it('projects ordinary and compaction calls into one chronological request stream', () => {
|
||||
const events = [
|
||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||
at(1, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'system',
|
||||
tools: [{
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
},
|
||||
}),
|
||||
at(2, 'tool/call', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: 'call-1',
|
||||
name: 'read',
|
||||
arguments: '{}',
|
||||
}),
|
||||
at(3, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
provenance: { provider: 'fake', model: 'model' },
|
||||
usage: { inputTokens: 5, outputTokens: 2 },
|
||||
}),
|
||||
at(4, 'step/end', { turn: 1, step: 1 }),
|
||||
at(5, 'compact/start', { turn: 1 }),
|
||||
at(6, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
rawOutput: [
|
||||
{ type: 'reasoning', text: 'thought' },
|
||||
{ type: 'text', text: 'summary' },
|
||||
],
|
||||
provider: 'fake',
|
||||
model: 'compact-model',
|
||||
usage: { inputTokens: 8, outputTokens: 3 },
|
||||
}),
|
||||
at(7, 'user/message', {
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}),
|
||||
at(8, 'compact/end', { turn: 1 }),
|
||||
]
|
||||
const snapshot = inspectRequests(entriesOf(events))
|
||||
expect(snapshot.requests).toMatchObject([
|
||||
{
|
||||
purpose: 'assistant',
|
||||
startSeq: 0,
|
||||
resultSeq: 3,
|
||||
status: 'complete',
|
||||
prompt: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
system: 'system',
|
||||
},
|
||||
promptChange: { seq: 1, kind: 'initial' },
|
||||
},
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 5,
|
||||
resultSeq: 6,
|
||||
replacementSeq: 7,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'summary' }],
|
||||
},
|
||||
])
|
||||
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
|
||||
})
|
||||
|
||||
it('captures schemas for nested tool dispatches from the active request header', () => {
|
||||
const snapshot = inspectRequests(entriesOf([
|
||||
at(0, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: {
|
||||
config: { provider: 'fake', model: 'model' },
|
||||
tools: [{
|
||||
name: 'read',
|
||||
description: 'Read a file.',
|
||||
parameters: { type: 'object' },
|
||||
}],
|
||||
},
|
||||
}),
|
||||
at(1, 'tool/code-dispatch-start', {
|
||||
parentCallId: 'parent',
|
||||
subCallId: 'nested',
|
||||
name: 'read',
|
||||
arguments: {},
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
|
||||
})
|
||||
})
|
||||
@@ -256,6 +256,42 @@ describe('paging', () => {
|
||||
expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
|
||||
})
|
||||
|
||||
it('loads every older page for complete-history inspection', async () => {
|
||||
const pages = [
|
||||
plainTurn(0, 0, '最早问', '最早答'),
|
||||
plainTurn(6, 1, '中间问', '中间答'),
|
||||
plainTurn(12, 2, '最新问', '最新答'),
|
||||
]
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = (payload) => {
|
||||
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
|
||||
if (payload.beforeSeq === 12) return histResponse(pages[1]!, true)
|
||||
return histResponse(pages[0]!, false)
|
||||
}
|
||||
|
||||
await session.open()
|
||||
await session.history.loadAll()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(3)
|
||||
expect(session.history.getSnapshot().hasMore).toBe(false)
|
||||
expect(session.history.getSnapshot().entries.map(entry => entry.event.seq))
|
||||
.toEqual([...Array(18).keys()])
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([1, 3, 7, 9, 13, 15])
|
||||
})
|
||||
|
||||
it('stops complete-history loading when a page makes no progress', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = payload => payload.beforeSeq === undefined
|
||||
? histResponse(plainTurn(6, 1, '新问', '新答'), true)
|
||||
: Promise.resolve(err({ code: 'internal', message: 'page unavailable', details: {} }))
|
||||
|
||||
await session.open()
|
||||
await session.history.loadAll()
|
||||
|
||||
expect(api.callsOf('session.history')).toHaveLength(2)
|
||||
expect(session.history.getSnapshot().hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = payload => payload.beforeSeq === undefined
|
||||
@@ -855,16 +891,12 @@ describe('reference stability (the memo contract)', () => {
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.runningCalls).toBe(before.runningCalls)
|
||||
expect(after.pending).toBe(before.pending)
|
||||
expect(after.requestAttempts).toBe(before.requestAttempts)
|
||||
expect(after.compactionRequests).toBe(before.compactionRequests)
|
||||
// And a mutation on the tracked domain swaps that array.
|
||||
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
|
||||
const resolved = session.getSnapshot()
|
||||
expect(resolved.runningCalls).not.toBe(after.runningCalls)
|
||||
expect(resolved.pending).toBe(after.pending)
|
||||
feed(ev.assistant(12, 1, '完成'))
|
||||
const completed = session.getSnapshot()
|
||||
expect(completed.requestAttempts).not.toBe(resolved.requestAttempts)
|
||||
expect(completed.compactionRequests).toBe(resolved.compactionRequests)
|
||||
expect(session.getSnapshot()).not.toBe(resolved)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/** Trajectory view: compact summary over a turn-aware event ledger. */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
AssistantMessageNode, CompactionRequestView, ConversationContext,
|
||||
ConversationPromptChange, ModelRequestView,
|
||||
AssistantMessageNode, ConversationContext, SessionHistory,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
deriveTrajectoryContextBranches, trajectoryBranchContainsSeq,
|
||||
} from './context-branches.ts'
|
||||
inspectRequests, projectConversationHistory,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveTrajectoryContextBranches } from './context-branches.ts'
|
||||
import {
|
||||
TrajectoryTable,
|
||||
type TrajectoryRequestNumber,
|
||||
@@ -19,9 +19,11 @@ import { deriveTrajectoryLayout } from './layout.ts'
|
||||
import css from './views.module.css'
|
||||
|
||||
const EMPTY_IDS: ReadonlySet<number> = new Set()
|
||||
const EMPTY_COMPACTION_REQUESTS: readonly CompactionRequestView[] = []
|
||||
const EMPTY_MODEL_REQUESTS: readonly ModelRequestView[] = []
|
||||
const EMPTY_PROMPT_CHANGES: readonly ConversationPromptChange[] = []
|
||||
|
||||
/** Raw session-history source needed by the event-complete trajectory view. */
|
||||
export interface TrajectoryViewInjected {
|
||||
history: SessionHistory
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
inputTokens?: number
|
||||
@@ -67,30 +69,47 @@ function addUsage(
|
||||
}
|
||||
}
|
||||
|
||||
export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
export function TrajectoryView({ useSession, history }: ConvViewProps & TrajectoryViewInjected) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const projectedContexts = useSession(s => s.contexts)
|
||||
const compactionRequests = useSession(
|
||||
s => s.compactionRequests ?? EMPTY_COMPACTION_REQUESTS,
|
||||
)
|
||||
const requestAttempts = useSession(
|
||||
s => s.requestAttempts ?? EMPTY_MODEL_REQUESTS,
|
||||
)
|
||||
const promptChanges = useSession(
|
||||
s => s.promptChanges ?? EMPTY_PROMPT_CHANGES,
|
||||
)
|
||||
const partial = useSession(s => s.partial)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const callSchemas = useSession(s => s.callSchemas)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const subscribeHistory = useMemo(
|
||||
() => (listener: () => void) => history.subscribe(listener),
|
||||
[history],
|
||||
)
|
||||
const getHistorySnapshot = useMemo(
|
||||
() => () => history.getSnapshot(),
|
||||
[history],
|
||||
)
|
||||
const historySnapshot = useSyncExternalStore(
|
||||
subscribeHistory,
|
||||
getHistorySnapshot,
|
||||
getHistorySnapshot,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (historySnapshot.openState === 'open' && historySnapshot.hasMore) {
|
||||
void history.loadAll()
|
||||
}
|
||||
}, [history, historySnapshot.hasMore, historySnapshot.openState])
|
||||
const projectedHistory = useMemo(
|
||||
() => projectConversationHistory(historySnapshot.entries),
|
||||
[historySnapshot.entries],
|
||||
)
|
||||
const requestInspection = useMemo(
|
||||
() => inspectRequests(historySnapshot.entries),
|
||||
[historySnapshot.entries],
|
||||
)
|
||||
const requests = requestInspection.requests
|
||||
const callSchemas = requestInspection.callSchemas
|
||||
const contexts = useMemo<readonly ConversationContext[]>(
|
||||
() => projectedContexts === undefined || projectedContexts.length === 0
|
||||
() => projectedHistory.contexts.length === 0
|
||||
? [{ id: 0, nodes }]
|
||||
: projectedContexts,
|
||||
[nodes, projectedContexts],
|
||||
: projectedHistory.contexts,
|
||||
[nodes, projectedHistory.contexts],
|
||||
)
|
||||
const branches = useMemo(
|
||||
() => deriveTrajectoryContextBranches(contexts),
|
||||
@@ -98,11 +117,9 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
)
|
||||
const currentBranch = branches.at(-1)
|
||||
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
|
||||
const selectedNodes = useMemo(() => {
|
||||
const bySeq = new Map(currentBranch.nodes.map(node => [node.seq, node]))
|
||||
for (const node of nodes) bySeq.set(node.seq, node)
|
||||
return [...bySeq.values()].sort((left, right) => left.seq - right.seq)
|
||||
}, [currentBranch, nodes])
|
||||
const selectedNodes = projectedHistory.eventNodes.length === 0
|
||||
? nodes
|
||||
: projectedHistory.eventNodes
|
||||
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
const assistantsByStep = new Map<string, AssistantMessageNode>()
|
||||
for (const context of contexts) {
|
||||
@@ -115,46 +132,38 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
if (node.kind !== 'assistant' || node.step <= 0) continue
|
||||
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
|
||||
}
|
||||
const attemptsByStep = new Map(
|
||||
requestAttempts.map(request => [
|
||||
`${request.turn}\u0000${request.step}`,
|
||||
request,
|
||||
]),
|
||||
const requestsByStep = new Map(
|
||||
requests
|
||||
.filter(request => request.purpose === 'assistant')
|
||||
.map(request => [
|
||||
`${request.turn}\u0000${request.step}`,
|
||||
request,
|
||||
]),
|
||||
)
|
||||
const orderedRequests = [
|
||||
...requestAttempts.map(request => ({
|
||||
...requests.map(request => ({
|
||||
seq: request.startSeq,
|
||||
kind: 'ordinary' as const,
|
||||
request,
|
||||
node: assistantsByStep.get(`${request.turn}\u0000${request.step}`),
|
||||
node: request.purpose === 'assistant'
|
||||
? assistantsByStep.get(`${request.turn}\u0000${request.step}`)
|
||||
: undefined,
|
||||
})),
|
||||
...[...assistantsByStep.entries()].flatMap(([key, node]) =>
|
||||
attemptsByStep.has(key)
|
||||
requestsByStep.has(key)
|
||||
? []
|
||||
: [{
|
||||
seq: node.seq,
|
||||
kind: 'ordinary' as const,
|
||||
request: undefined,
|
||||
node,
|
||||
}],
|
||||
),
|
||||
...compactionRequests.map(request => ({
|
||||
seq: request.startSeq,
|
||||
kind: 'compaction' as const,
|
||||
request,
|
||||
node: undefined,
|
||||
})),
|
||||
].sort((left, right) => left.seq - right.seq)
|
||||
const numbered: TrajectoryRequestNumber[] = []
|
||||
let cumulativeUsage: TrajectoryUsage | undefined
|
||||
for (const [index, entry] of orderedRequests.entries()) {
|
||||
const usage = requestUsage(
|
||||
entry.kind === 'compaction'
|
||||
? entry.request.usage
|
||||
: entry.request?.usage ?? entry.node?.usage,
|
||||
)
|
||||
const usage = requestUsage(entry.request?.usage ?? entry.node?.usage)
|
||||
cumulativeUsage = addUsage(cumulativeUsage, usage)
|
||||
if (entry.kind === 'ordinary') {
|
||||
if (entry.request?.purpose !== 'compaction') {
|
||||
const request = entry.request
|
||||
const node = entry.node
|
||||
const turn = request?.turn ?? node?.turn
|
||||
@@ -223,10 +232,10 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
step: partial.step,
|
||||
group: `Step ${partial.step}`,
|
||||
number: orderedRequests.length + 1,
|
||||
...(currentBranch.latest.prompt?.config?.provider === undefined
|
||||
...(currentBranch.latest.prompt?.config.provider === undefined
|
||||
? {}
|
||||
: { provider: currentBranch.latest.prompt.config.provider }),
|
||||
...(currentBranch.latest.prompt?.config?.model === undefined
|
||||
...(currentBranch.latest.prompt?.config.model === undefined
|
||||
? {}
|
||||
: { model: currentBranch.latest.prompt.config.model }),
|
||||
...(currentBranch.latest.prompt?.config === undefined
|
||||
@@ -238,48 +247,20 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
}
|
||||
return numbered
|
||||
}, [
|
||||
compactionRequests, contexts, currentBranch.latest.prompt, nodes, partial,
|
||||
requestAttempts,
|
||||
contexts, currentBranch.latest.prompt, nodes, partial, requests,
|
||||
])
|
||||
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
return globalRequestNumbers.filter(request =>
|
||||
request.seq === undefined
|
||||
|| trajectoryBranchContainsSeq(currentBranch, request.seq),
|
||||
)
|
||||
}, [currentBranch, globalRequestNumbers])
|
||||
const visibleRequestAttempts = useMemo(
|
||||
() => requestAttempts.filter(request =>
|
||||
requestNumbers.some(number =>
|
||||
number.purpose !== 'compaction' && number.seq === request.startSeq,
|
||||
)),
|
||||
[requestAttempts, requestNumbers],
|
||||
)
|
||||
const visibleCompactionRequests = useMemo(
|
||||
() => compactionRequests.filter(request =>
|
||||
requestNumbers.some(number =>
|
||||
number.purpose === 'compaction' && number.seq === request.startSeq,
|
||||
)),
|
||||
[compactionRequests, requestNumbers],
|
||||
)
|
||||
const visiblePromptChanges = useMemo(
|
||||
() => promptChanges.filter(change =>
|
||||
trajectoryBranchContainsSeq(currentBranch, change.seq)),
|
||||
[currentBranch, promptChanges],
|
||||
)
|
||||
const requestNumbers = globalRequestNumbers
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({
|
||||
nodes: selectedNodes,
|
||||
partial,
|
||||
runningCalls,
|
||||
compactionRequests: visibleCompactionRequests,
|
||||
requestAttempts: visibleRequestAttempts,
|
||||
promptChanges: visiblePromptChanges,
|
||||
requests,
|
||||
callSchemas,
|
||||
codeDispatches,
|
||||
}),
|
||||
[
|
||||
selectedNodes, partial, runningCalls, visibleCompactionRequests,
|
||||
visibleRequestAttempts, visiblePromptChanges, callSchemas, codeDispatches,
|
||||
selectedNodes, partial, runningCalls, requests, callSchemas, codeDispatches,
|
||||
],
|
||||
)
|
||||
const collapsibleTurnIds = useMemo(
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* view slot without defining a service.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { TrajectoryView } from './TrajectoryView.tsx'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
import { WaterfallView } from './WaterfallView.tsx'
|
||||
|
||||
/**
|
||||
@@ -16,7 +17,7 @@ import { WaterfallView } from './WaterfallView.tsx'
|
||||
* into an undeclared slot throws — service waiting is what orders this
|
||||
* apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation']
|
||||
export const inject = ['slots', 'conversation', 'sessions']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory and waterfall view tabs. The
|
||||
@@ -25,8 +26,19 @@ export const inject = ['slots', 'conversation']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.slots.register(
|
||||
{ name: 'conversation.view', id: 'trajectory', order: 10, label: 'Trajectory' }, TrajectoryView)
|
||||
ctx.slots.register({
|
||||
name: 'conversation.view',
|
||||
id: 'trajectory',
|
||||
order: 10,
|
||||
label: 'Trajectory',
|
||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||
const session = ctx.sessions.binding(sessionId)?.session
|
||||
if (session === undefined) {
|
||||
throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`)
|
||||
}
|
||||
return { history: session.history }
|
||||
},
|
||||
}, TrajectoryView)
|
||||
ctx.slots.register(
|
||||
{ name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import type {
|
||||
AssistantBlock,
|
||||
AssistantMessageNode,
|
||||
CodeSubCall,
|
||||
CompactionRequestView,
|
||||
ConversationPromptChange,
|
||||
ConversationSnapshot,
|
||||
ModelRequestView,
|
||||
RequestInspectionSnapshot,
|
||||
RequestPromptChange,
|
||||
RequestView,
|
||||
ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
@@ -35,10 +35,8 @@ export interface TrajectoryLayoutInput {
|
||||
nodes: ConversationSnapshot['nodes']
|
||||
partial: ConversationSnapshot['partial']
|
||||
runningCalls: ConversationSnapshot['runningCalls']
|
||||
compactionRequests?: readonly CompactionRequestView[]
|
||||
requestAttempts?: readonly ModelRequestView[]
|
||||
promptChanges?: readonly ConversationPromptChange[]
|
||||
callSchemas?: ConversationSnapshot['callSchemas']
|
||||
requests?: readonly RequestView[]
|
||||
callSchemas?: RequestInspectionSnapshot['callSchemas']
|
||||
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
|
||||
codeDispatches: ConversationSnapshot['codeDispatches']
|
||||
}
|
||||
@@ -83,17 +81,18 @@ type OrderedLayoutEntry =
|
||||
| {
|
||||
kind: 'compaction'
|
||||
seq: number
|
||||
request: CompactionRequestView
|
||||
request: RequestView
|
||||
}
|
||||
| {
|
||||
kind: 'system'
|
||||
seq: number
|
||||
change: ConversationPromptChange
|
||||
request: RequestView
|
||||
change: RequestPromptChange
|
||||
}
|
||||
| {
|
||||
kind: 'request'
|
||||
seq: number
|
||||
request: ModelRequestView
|
||||
request: RequestView
|
||||
}
|
||||
|
||||
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
|
||||
@@ -124,9 +123,7 @@ function inputCellDetail(node: InputNode): Pick<
|
||||
*/
|
||||
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
|
||||
const {
|
||||
nodes, partial, runningCalls, compactionRequests = [], requestAttempts = [],
|
||||
promptChanges = [],
|
||||
callSchemas, codeDispatches,
|
||||
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
|
||||
} = input
|
||||
const resultByCall = indexResults(nodes)
|
||||
const callStartById = new Map<string, number>()
|
||||
@@ -193,17 +190,23 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
node,
|
||||
nodeIndex,
|
||||
})),
|
||||
...compactionRequests.map(request => ({
|
||||
kind: 'compaction' as const,
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
})),
|
||||
...promptChanges.map(change => ({
|
||||
kind: 'system' as const,
|
||||
seq: change.seq,
|
||||
change,
|
||||
})),
|
||||
...requestAttempts
|
||||
...requests
|
||||
.filter(request => request.purpose === 'compaction')
|
||||
.map(request => ({
|
||||
kind: 'compaction' as const,
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
})),
|
||||
...requests.flatMap(request => request.promptChange === undefined || request.prompt === undefined
|
||||
? []
|
||||
: [{
|
||||
kind: 'system' as const,
|
||||
seq: request.promptChange.seq,
|
||||
request,
|
||||
change: request.promptChange,
|
||||
}]),
|
||||
...requests
|
||||
.filter(request => request.purpose === 'assistant')
|
||||
.filter(request =>
|
||||
!representedRequests.has(`${request.turn}\u0000${request.step}`),
|
||||
)
|
||||
@@ -238,7 +241,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'system') {
|
||||
const { change } = entry
|
||||
const { change, request } = entry
|
||||
const turn = change.kind === 'initial'
|
||||
? firstVisibleTurn(nodes, partial)
|
||||
: enclosingPromptTurn(nodes, change.seq, partial)
|
||||
@@ -249,7 +252,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
kind: 'system',
|
||||
text: promptChangeLabel(change),
|
||||
sourceSeq: change.seq,
|
||||
promptDetail: change.prompt,
|
||||
...(request.prompt === undefined ? {} : { promptDetail: request.prompt }),
|
||||
...(change.previous === undefined
|
||||
? {}
|
||||
: { previousPromptDetail: change.previous }),
|
||||
@@ -450,7 +453,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
|
||||
function attachToolSchema(
|
||||
laid: LaidCell,
|
||||
callSchemas: ConversationSnapshot['callSchemas'],
|
||||
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
|
||||
): void {
|
||||
if (laid.callId === undefined || callSchemas === undefined) return
|
||||
const schema = callSchemas.get(laid.callId)
|
||||
@@ -616,7 +619,7 @@ function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function promptChangeLabel(change: ConversationPromptChange): string {
|
||||
function promptChangeLabel(change: RequestPromptChange): string {
|
||||
if (change.kind === 'initial') return 'Initial System Prompt'
|
||||
if (change.kind === 'system') return 'System Prompt Updated'
|
||||
if (change.kind === 'tools') return 'Tools Updated'
|
||||
|
||||
@@ -15,7 +15,10 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionHistory, SessionHistorySnapshot, SessionId,
|
||||
SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
||||
@@ -80,10 +83,42 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
||||
} as unknown as ConvViewProps
|
||||
}
|
||||
|
||||
function emptyHistory(): SessionHistory {
|
||||
const store = createSnapshotStore<SessionHistorySnapshot>({
|
||||
sessionId: SID,
|
||||
entries: [],
|
||||
baseSeq: 0,
|
||||
openState: 'open',
|
||||
openError: null,
|
||||
hasMore: false,
|
||||
loadingOlder: false,
|
||||
})
|
||||
return {
|
||||
getSnapshot: () => store.getSnapshot(),
|
||||
subscribe: listener => store.subscribe(listener),
|
||||
loadAll: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const slots = new SlotsService(ctx)
|
||||
const loadAllHistory = vi.fn(() => Promise.resolve())
|
||||
const historyStore = createSnapshotStore<SessionHistorySnapshot>({
|
||||
sessionId: SID,
|
||||
entries: [],
|
||||
baseSeq: 0,
|
||||
openState: 'open',
|
||||
openError: null,
|
||||
hasMore: true,
|
||||
loadingOlder: false,
|
||||
})
|
||||
const history: SessionHistory = {
|
||||
getSnapshot: () => historyStore.getSnapshot(),
|
||||
subscribe: listener => historyStore.subscribe(listener),
|
||||
loadAll: loadAllHistory,
|
||||
}
|
||||
// The conversation entry's role: declare the ring, then seed the chat entry.
|
||||
slots.register({
|
||||
name: 'root',
|
||||
@@ -95,9 +130,14 @@ async function bench() {
|
||||
// 'conversation' inject is an ordering edge; the bench declares the ring
|
||||
// itself, so a stub satisfies the wait.
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('sessions', {
|
||||
binding: (sessionId: SessionId) => sessionId === SID
|
||||
? { session: { history } }
|
||||
: undefined,
|
||||
})
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber }
|
||||
return { ctx, slots, fiber, loadAllHistory }
|
||||
}
|
||||
|
||||
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
|
||||
@@ -110,6 +150,7 @@ function tabsOf(slots: SlotsService): ViewTab[] {
|
||||
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||
const sessionSnapshot = createSnapshotStore({
|
||||
running: false, removed: false, promptError: null, nodes,
|
||||
openState: 'open' as const, hasMore: true, loadingOlder: false,
|
||||
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
|
||||
})
|
||||
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
||||
@@ -121,8 +162,13 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
|
||||
if (entry === undefined) return null
|
||||
const View = entry.component as FC<ConvViewProps>
|
||||
const injectEntry = entry.inject as ((sessionId: SessionId) => object) | undefined
|
||||
const injected = injectEntry === undefined
|
||||
? {}
|
||||
: injectEntry(SID)
|
||||
return (
|
||||
<View
|
||||
{...injected}
|
||||
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
|
||||
key={key}
|
||||
/>
|
||||
@@ -186,6 +232,9 @@ describe('tab switching in ConversationRoot', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
|
||||
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
|
||||
expect(screen.queryByTestId('chat-body')).toBeNull()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.loadAllHistory).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
|
||||
@@ -244,8 +293,10 @@ describe('span derivation', () => {
|
||||
const { useSession } = fakeSession([])
|
||||
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
|
||||
expect(container.firstChild).toBeNull()
|
||||
render(createElement(TrajectoryView as FC<ConvViewProps>,
|
||||
standaloneProps([])))
|
||||
render(createElement(
|
||||
TrajectoryView,
|
||||
{ ...standaloneProps([]), history: emptyHistory() },
|
||||
))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.queryByRole('row')).toBeNull()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user