fix(client): keep trajectory history on the session seam

This commit is contained in:
_Kerman
2026-07-28 13:56:00 +08:00
parent 748140da13
commit 1ac0eb9611
9 changed files with 100 additions and 159 deletions
+1 -3
View File
@@ -39,10 +39,8 @@ export type {
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 type { SessionHistoryInspection } 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'
@@ -8,6 +8,7 @@ import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
RpcError, SessionId, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionHistoryInspection } from './history.ts'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
@@ -238,6 +239,8 @@ export interface ConversationSnapshot {
sessionId: SessionId
/** Surface fold product (finalized conversation nodes in surface order). */
nodes: readonly ConversationNode[]
/** Lazy history-only projections consumed by inspection views. */
inspection?: SessionHistoryInspection
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean
partial: PartialAssistant | null
@@ -1,33 +1,46 @@
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'
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationNode } from './conversation.ts'
import type { ConversationContext } from './conversation-context.ts'
import { projectConversationHistory } from './fold-adapter.ts'
import { inspectRequests, type RequestView } from './request-inspection.ts'
/** Lazily derived inspection data for one immutable session-history window. */
export interface SessionHistoryInspection {
eventNodes: readonly ConversationNode[]
contexts: readonly ConversationContext[]
requests: readonly RequestView[]
callSchemas: ReadonlyMap<string, ToolSchema>
}
/**
* 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.
* Create a lazy inspection projection over an immutable history window.
* Conversation consumers retain the cheap wrapper; only Trajectory reads the
* getters that replay event order and request lifecycle state.
* @param entries - Contiguous raw history entries in sequence order.
* @returns Lazy, memoized inspection fields for that exact window.
*/
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>
export function createHistoryInspection(
entries: readonly HistoryEntry[],
): SessionHistoryInspection {
let conversation: ReturnType<typeof projectConversationHistory> | undefined
let requests: ReturnType<typeof inspectRequests> | undefined
const conversationProjection = () =>
conversation ??= projectConversationHistory(entries)
const requestProjection = () =>
requests ??= inspectRequests(entries)
return {
get eventNodes() {
return conversationProjection().eventNodes
},
get contexts() {
return conversationProjection().contexts
},
get requests() {
return requestProjection().requests
},
get callSchemas() {
return requestProjection().callSchemas
},
}
}
@@ -15,7 +15,9 @@ import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot,
OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { SessionHistory, SessionHistorySnapshot } from './history.ts'
import {
createHistoryInspection, type SessionHistoryInspection,
} from './history.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { FoldAdapter } from './fold-adapter.ts'
@@ -112,6 +114,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** 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 historyInspectionCache: {
rev: number
value: SessionHistoryInspection
} | null = null
private running = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
@@ -132,13 +138,9 @@ 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
@@ -159,19 +161,6 @@ 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(),
}
}
/**
@@ -307,7 +296,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* 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> {
async loadAllHistory(): Promise<void> {
while (this.openState === 'open' && this.hasMore && !this.loadingOlder) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
@@ -572,7 +561,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++
if (event.type !== 'assistant/chunk') this.historyRev++
this.foldAdapter.append(event, view)
this.applyEventSideEffects(event, view)
}
@@ -831,6 +820,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return {
sessionId: this.sessionId,
nodes,
inspection: this.buildHistoryInspection(),
foldDegraded: degraded,
partial,
runningCalls: this.callsCache.value,
@@ -854,8 +844,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
}
}
/** Build the raw history read surface without leaking the mutable window arrays. */
private buildHistorySnapshot(): SessionHistorySnapshot {
/** Build the lazy history inspection wrapper without leaking mutable window arrays. */
private buildHistoryInspection(): SessionHistoryInspection {
if (this.historyEntriesCache === null || this.historyEntriesCache.rev !== this.historyRev) {
this.historyEntriesCache = {
rev: this.historyRev,
@@ -865,27 +855,16 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
}),
}
}
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
this.historyInspectionCache === null
|| this.historyInspectionCache.rev !== this.historyRev
) {
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,
this.historyInspectionCache = {
rev: this.historyRev,
value: createHistoryInspection(this.historyEntriesCache.value),
}
}
return this.historyInspectionCache.value
}
}