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

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'

View File

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

View File

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

View File

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

View File

@@ -270,12 +270,12 @@ describe('paging', () => {
}
await session.open()
await session.history.loadAll()
await session.loadAllHistory()
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().hasMore).toBe(false)
expect(session.getSnapshot().inspection?.eventNodes.map(node => node.seq))
.toEqual([1, 3, 7, 9, 13, 15])
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([1, 3, 7, 9, 13, 15])
})
@@ -286,10 +286,10 @@ describe('paging', () => {
: Promise.resolve(err({ code: 'internal', message: 'page unavailable', details: {} }))
await session.open()
await session.history.loadAll()
await session.loadAllHistory()
expect(api.callsOf('session.history')).toHaveLength(2)
expect(session.history.getSnapshot().hasMore).toBe(true)
expect(session.getSnapshot().hasMore).toBe(true)
})
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {

View File

@@ -1,12 +1,9 @@
/** Trajectory view: compact summary over a turn-aware event ledger. */
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
AssistantMessageNode, ConversationContext, SessionHistory,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
inspectRequests, projectConversationHistory,
AssistantMessageNode, ConversationContext, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveTrajectoryContextBranches } from './context-branches.ts'
import {
@@ -19,10 +16,11 @@ import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_REQUESTS: readonly RequestView[] = []
/** Raw session-history source needed by the event-complete trajectory view. */
/** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
history: SessionHistory
loadAllHistory: () => Promise<void>
}
interface UsageLike {
@@ -69,47 +67,29 @@ function addUsage(
}
}
export function TrajectoryView({ useSession, history }: ConvViewProps & TrajectoryViewInjected) {
export function TrajectoryView({ useSession, loadAllHistory }: 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 inspection = useSession(s => s.inspection)
const hasMore = useSession(s => s.hasMore)
const openState = useSession(s => s.openState)
const partial = useSession(s => s.partial)
const runningCalls = useSession(s => s.runningCalls)
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,
)
const loadAllHistoryRef = useRef(loadAllHistory)
loadAllHistoryRef.current = loadAllHistory
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
if (openState === 'open' && hasMore) void loadAllHistoryRef.current()
}, [hasMore, openState])
const requests = inspection?.requests ?? EMPTY_REQUESTS
const callSchemas = inspection?.callSchemas
const contexts = useMemo<readonly ConversationContext[]>(
() => projectedHistory.contexts.length === 0
() => inspection === undefined || inspection.contexts.length === 0
? [{ id: 0, nodes }]
: projectedHistory.contexts,
[nodes, projectedHistory.contexts],
: inspection.contexts,
[inspection, nodes],
)
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
@@ -117,9 +97,9 @@ export function TrajectoryView({ useSession, history }: ConvViewProps & Trajecto
)
const currentBranch = branches.at(-1)
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = projectedHistory.eventNodes.length === 0
const selectedNodes = inspection === undefined || inspection.eventNodes.length === 0
? nodes
: projectedHistory.eventNodes
: inspection.eventNodes
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
@@ -256,7 +236,7 @@ export function TrajectoryView({ useSession, history }: ConvViewProps & Trajecto
partial,
runningCalls,
requests,
callSchemas,
...(callSchemas === undefined ? {} : { callSchemas }),
codeDispatches,
}),
[

View File

@@ -36,7 +36,7 @@ export function apply(ctx: Context): void {
if (session === undefined) {
throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`)
}
return { history: session.history }
return { loadAllHistory: () => session.loadAllHistory() }
},
}, TrajectoryView)
ctx.slots.register(

View File

@@ -60,7 +60,7 @@ describe('tsdown client artifact', () => {
const { handoff, surface } = await loadArtifact()
expect(handoff.id).toBe(PLUGIN_ID)
expect(surface.apply).toBeTypeOf('function')
expect(surface.inject).toEqual(['slots', 'conversation'])
expect(surface.inject).toEqual(['slots', 'conversation', 'sessions'])
})
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both view tabs on the real ring', async () => {
@@ -72,10 +72,10 @@ describe('tsdown client artifact', () => {
name: 'root',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
}, (_p: { renderSlot?: unknown }) => null)
// The plugin injects 'conversation' as an ordering edge (the declaring
// plugin provides it after declaring the ring); the bench declares the
// ring itself, so a stub satisfies the wait.
// The plugin injects 'conversation' as an ordering edge and 'sessions'
// for its per-session history callback; this bench supplies both.
ctx.provide('conversation', {})
ctx.provide('sessions', {})
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
await fiber.await()
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory', 'waterfall'])

View File

@@ -16,8 +16,7 @@ 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, SessionHistory, SessionHistorySnapshot, SessionId,
SessionListState, WorkspaceListState,
ConversationSnapshot, 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.
@@ -83,42 +82,11 @@ 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',
@@ -132,7 +100,7 @@ async function bench() {
ctx.provide('conversation', {})
ctx.provide('sessions', {
binding: (sessionId: SessionId) => sessionId === SID
? { session: { history } }
? { session: { loadAllHistory } }
: undefined,
})
const fiber = ctx.plugin({ inject: [...inject], apply })
@@ -295,7 +263,7 @@ describe('span derivation', () => {
expect(container.firstChild).toBeNull()
render(createElement(
TrajectoryView,
{ ...standaloneProps([]), history: emptyHistory() },
{ ...standaloneProps([]), loadAllHistory: () => Promise.resolve() },
))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.queryByRole('row')).toBeNull()