test(client): cover independent trajectory history

This commit is contained in:
imccyu
2026-07-29 20:46:02 +08:00
parent e0022ff19e
commit 64acc5874b
6 changed files with 51 additions and 28 deletions

View File

@@ -106,6 +106,9 @@ function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[]
return contexts return contexts
} }
// History projection owns its node mapping so Chat's live adapter remains free
// of inspection metadata and lifecycle coupling.
/* jscpd:ignore-start */
function materializeNode( function materializeNode(
event: SessionEvent, event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>, callIndex: ReadonlyMap<string, CallIndexEntry>,
@@ -165,6 +168,7 @@ function materializeNode(
} }
} }
} }
/* jscpd:ignore-end */
function projectTransient(entries: readonly HistoryEntry[]): Pick< function projectTransient(entries: readonly HistoryEntry[]): Pick<
ConversationHistoryProjection, ConversationHistoryProjection,
@@ -185,6 +189,9 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
arguments: unknown arguments: unknown
} }
const siblings = codeDispatches.get(data.parentCallId) ?? [] const siblings = codeDispatches.get(data.parentCallId) ?? []
// The independent replay emits the same public running-call shape as
// Chat without reading or mutating Session's live index.
/* jscpd:ignore-start */
codeDispatches.set(data.parentCallId, [...siblings, { codeDispatches.set(data.parentCallId, [...siblings, {
callId: data.subCallId, callId: data.subCallId,
name: data.name, name: data.name,
@@ -194,6 +201,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
time: event.time, time: event.time,
callView: null, callView: null,
}]) }])
/* jscpd:ignore-end */
continue continue
} }
if ((event.type as string) === 'tool/code-dispatch') { if ((event.type as string) === 'tool/code-dispatch') {
@@ -239,6 +247,9 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null
break break
case 'tool/call': case 'tool/call':
// History reconstructs its own in-flight index; this intentionally
// mirrors the published Chat node shape, not Chat's mutable state.
/* jscpd:ignore-start */
openCalls.set(String(event.data.callId), { openCalls.set(String(event.data.callId), {
callId: String(event.data.callId), callId: String(event.data.callId),
name: event.data.name, name: event.data.name,
@@ -248,6 +259,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
time: event.time, time: event.time,
callView: entry.view?.for === 'call' ? entry.view.view : null, callView: entry.view?.for === 'call' ? entry.view.view : null,
}) })
/* jscpd:ignore-end */
break break
case 'tool/result': case 'tool/result':
openCalls.delete(String(event.data.message.source.callId)) openCalls.delete(String(event.data.message.source.callId))
@@ -269,6 +281,9 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
for (const [callId, call] of openCalls) { for (const [callId, call] of openCalls) {
if (call.turn !== event.data.turn) continue if (call.turn !== event.data.turn) continue
openCalls.delete(callId) openCalls.delete(callId)
// Interrupted terminal nodes are reconstructed independently so a
// Trajectory replay cannot observe Session's frozen-node lifecycle.
/* jscpd:ignore-start */
interruptedNodes.push({ interruptedNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01,
time: event.time, time: event.time,
@@ -281,6 +296,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
callView: call.callView, callView: call.callView,
resultView: null, resultView: null,
}) })
/* jscpd:ignore-end */
} }
break break
} }

View File

@@ -7,7 +7,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports // Subpath export (package.json exports "./surface", alias added for this): all value imports
// go through it — the package root points at lib/index.js (needs a build) which the vite // go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies. // browser bundle cannot resolve; surface.ts has no Node dependencies.
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
} from '@deepseek-ai/dsh-session/surface'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, ConversationNode } from './conversation.ts' import type { CommandNode, ConversationNode } from './conversation.ts'
@@ -33,6 +35,11 @@ function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
} }
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
}
/** One event -> UI node (pure function; the six-variant ConversationNode union). */ /** One event -> UI node (pure function; the six-variant ConversationNode union). */
function materializeNode( function materializeNode(
event: SessionEvent, event: SessionEvent,
@@ -137,7 +144,7 @@ export class FoldAdapter {
for (const event of events) this.padded.push(event) for (const event of events) this.padded.push(event)
this.surface = new SurfaceManager(this.padded) this.surface = new SurfaceManager(this.padded)
this.nodeCache.clear() this.nodeCache.clear()
this.degraded = false this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
this.callIdx = new Map() this.callIdx = new Map()
this.resultViews.clear() this.resultViews.clear()
this.commandIdx = new Map() this.commandIdx = new Map()
@@ -160,6 +167,7 @@ export class FoldAdapter {
append(event: SessionEvent, view?: ToolEventView): void { append(event: SessionEvent, view?: ToolEventView): void {
this.rev++ this.rev++
this.padded.push(event) this.padded.push(event)
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
this.indexCall(event, view) this.indexCall(event, view)
this.indexCommand(event) this.indexCommand(event)
} }

View File

@@ -28,6 +28,7 @@ describe('FoldAdapter', () => {
const adapter = new FoldAdapter() const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0) adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
const first = adapter.nodes() const first = adapter.nodes()
expect(adapter.nodes()).toBe(first)
adapter.append(ev.user(6, '追加')) adapter.append(ev.user(6, '追加'))
const second = adapter.nodes() const second = adapter.nodes()
expect(second.nodes).toHaveLength(3) expect(second.nodes).toHaveLength(3)
@@ -239,9 +240,8 @@ describe('FoldAdapter', () => {
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } }) expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
}) })
it('indexes assistant timing and the active request header in one replay pass', () => { it('projects assistant timing and the active request header from history', () => {
const adapter = new FoldAdapter() const projection = projectConversationHistory([
adapter.reset([
ev.stepStart(0, 1, 2), ev.stepStart(0, 1, 2),
at(1, { type: 'request/header', data: { at(1, { type: 'request/header', data: {
reason: 'initial', reason: 'initial',
@@ -253,9 +253,12 @@ describe('FoldAdapter', () => {
ev.chunkStart(2, 1, 2), ev.chunkStart(2, 1, 2),
ev.chunkText(3, 1, 'token', 2), ev.chunkText(3, 1, 'token', 2),
ev.assistant(4, 1, 'done', 2), ev.assistant(4, 1, 'done', 2),
], 0) ev.stepStart(5, 2, 1),
ev.chunkText(6, 2, 'next', 1),
ev.assistant(7, 2, 'next done', 1),
].map(event => ({ event })))
expect(adapter.nodes().nodes[0]).toMatchObject({ expect(projection.eventNodes[0]).toMatchObject({
kind: 'assistant', kind: 'assistant',
timing: { timing: {
stepStartTime: 1_700_000_000_000, stepStartTime: 1_700_000_000_000,
@@ -265,10 +268,7 @@ describe('FoldAdapter', () => {
requestConfig: { provider: 'fake', model: 'first' }, requestConfig: { provider: 'fake', model: 'first' },
}) })
adapter.append(ev.stepStart(5, 2, 1)) expect(projection.eventNodes.at(-1)).toMatchObject({
adapter.append(ev.chunkText(6, 2, 'next', 1))
adapter.append(ev.assistant(7, 2, 'next done', 1))
expect(adapter.nodes().nodes.at(-1)).toMatchObject({
timing: { timing: {
stepStartTime: 1_700_000_000_005, stepStartTime: 1_700_000_000_005,
firstTokenTime: 1_700_000_000_006, firstTokenTime: 1_700_000_000_006,

View File

@@ -71,13 +71,13 @@ describe('SessionHistorySource', () => {
it('observes consumer cancellation between older pages', async () => { it('observes consumer cancellation between older pages', async () => {
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>() const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const olderStarted = deferred<void>() const olderStarted = deferred<undefined>()
const api = new FakeApiClient() const api = new FakeApiClient()
api.onHistory = (payload) => { api.onHistory = (payload) => {
if (payload.beforeSeq === undefined) { if (payload.beforeSeq === undefined) {
return histResponse(plainTurn(12, 2, '最新问', '最新答'), true) return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
} }
olderStarted.resolve() olderStarted.resolve(undefined)
return middle.promise return middle.promise
} }
const source = new SessionHistorySource(SID, api) const source = new SessionHistorySource(SID, api)

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type { import type {
AssistantMessageNode, ConversationContext, RequestView, AssistantMessageNode, ConversationContext,
SessionHistoryFace, SessionHistoryFace,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import { import {
@@ -26,7 +26,6 @@ import {
import css from './views.module.css' import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set() const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_REQUESTS: readonly RequestView[] = []
/** Session-history paging needed by the event-complete trajectory view. */ /** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected { export interface TrajectoryViewInjected {
@@ -161,7 +160,7 @@ export function TrajectoryView({
void loadAllHistoryRef.current(controller.signal) void loadAllHistoryRef.current(controller.signal)
return () => { controller.abort() } return () => { controller.abort() }
}, []) }, [])
const requests = inspection.requests ?? EMPTY_REQUESTS const requests = inspection.requests
const callSchemas = inspection.callSchemas const callSchemas = inspection.callSchemas
const contexts = useMemo<readonly ConversationContext[]>( const contexts = useMemo<readonly ConversationContext[]>(
() => inspection.contexts.length === 0 () => inspection.contexts.length === 0
@@ -324,7 +323,7 @@ export function TrajectoryView({
partial, partial,
runningCalls, runningCalls,
requests: selectedRequests, requests: selectedRequests,
...(callSchemas === undefined ? {} : { callSchemas }), callSchemas,
codeDispatches, codeDispatches,
}), }),
[ [

View File

@@ -124,15 +124,15 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
} }
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */ /** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
async function bench() { async function bench(snapshot = historySnapshot(NODES)) {
const ctx = new Context() const ctx = new Context()
const slots = new SlotsService(ctx) const slots = new SlotsService(ctx)
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve()) const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
const historyStore = createSnapshotStore(historySnapshot(NODES)) const historyStore = createSnapshotStore(snapshot)
const history: SessionHistoryFace = { const history: SessionHistoryFace = {
sessionId: SID, sessionId: SID,
getSnapshot: historyStore.getSnapshot, getSnapshot: () => historyStore.getSnapshot(),
subscribe: historyStore.subscribe, subscribe: listener => historyStore.subscribe(listener),
loadAll: loadAllHistory, loadAll: loadAllHistory,
} }
// The conversation entry's role: declare the ring, then seed the chat entry. // The conversation entry's role: declare the ring, then seed the chat entry.
@@ -181,11 +181,11 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
: injectEntry(SID) : injectEntry(SID)
const injectedProps = 'hooks' in injected const injectedProps = 'hooks' in injected
? { ? {
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory, loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
useHistory: bindSnapshotSelector( useHistory: bindSnapshotSelector(
(injected as TrajectoryViewInjected).hooks.history, (injected as TrajectoryViewInjected).hooks.history,
), ),
} }
: injected : injected
return ( return (
<View <View
@@ -310,8 +310,8 @@ describe('tab switching in ConversationRoot', () => {
}) })
it('empty window keeps the toolbar and reports no timing data', async () => { it('empty window keeps the toolbar and reports no timing data', async () => {
const b = await bench() const b = await bench(historySnapshot([]))
mount(b.slots, []) mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.getByText('No timing data')).toBeTruthy() expect(screen.getByText('No timing data')).toBeTruthy()