From 1ac0eb961102443c89d655292aafe4be570082cc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 28 Jul 2026 13:56:00 +0800 Subject: [PATCH] fix(client): keep trajectory history on the session seam --- packages/client/runtime/src/client/index.ts | 4 +- .../src/client/sessions/conversation.ts | 3 + .../runtime/src/client/sessions/history.ts | 71 +++++++++++-------- .../runtime/src/client/sessions/session.ts | 59 +++++---------- packages/client/runtime/tests/session.spec.ts | 12 ++-- .../src/client/TrajectoryView.tsx | 62 ++++++---------- .../client/ui-trajectory/src/client/index.ts | 2 +- .../ui-trajectory/tests/client-bundle.spec.ts | 8 +-- .../client/ui-trajectory/tests/views.spec.tsx | 38 +--------- 9 files changed, 100 insertions(+), 159 deletions(-) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 29e74c77ad..a14dd031b4 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -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' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 3d775aed46..37e79899f7 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -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 diff --git a/packages/client/runtime/src/client/sessions/history.ts b/packages/client/runtime/src/client/sessions/history.ts index ce5875d96e..bf11e9d6ab 100644 --- a/packages/client/runtime/src/client/sessions/history.ts +++ b/packages/client/runtime/src/client/sessions/history.ts @@ -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 +} /** - * 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 { - /** - * Load every earlier page currently available. - * @returns When paging is exhausted or cannot advance. - */ - loadAll(): Promise +export function createHistoryInspection( + entries: readonly HistoryEntry[], +): SessionHistoryInspection { + let conversation: ReturnType | undefined + let requests: ReturnType | 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 + }, + } } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 077a1cef1b..1ee11a744a 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -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 { /** 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 { 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 { 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 { * 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 { + async loadAllHistory(): Promise { while (this.openState === 'open' && this.hasMore && !this.loadingOlder) { const previousBaseSeq = this.baseSeq await this.loadOlder() @@ -572,7 +561,7 @@ export class Session implements ObservableSnapshot { 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 { return { sessionId: this.sessionId, nodes, + inspection: this.buildHistoryInspection(), foldDegraded: degraded, partial, runningCalls: this.callsCache.value, @@ -854,8 +844,8 @@ export class Session implements ObservableSnapshot { } } - /** 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 { }), } } - 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 } } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 520513b3a8..d1450800e3 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -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 () => { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 30727785f7..9c213d6a45 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -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 = 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 } 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>(EMPTY_IDS) const [collapsedAssistants, setCollapsedAssistants] = useState>(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( - () => 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(() => { const assistantsByStep = new Map() for (const context of contexts) { @@ -256,7 +236,7 @@ export function TrajectoryView({ useSession, history }: ConvViewProps & Trajecto partial, runningCalls, requests, - callSchemas, + ...(callSchemas === undefined ? {} : { callSchemas }), codeDispatches, }), [ diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index fd5dd207dd..24d697db68 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -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( diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 937c57dfd8..54397caacc 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -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']) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 02fe8ee850..d999444cb7 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -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({ - 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({ - 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()