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
}
// History projection owns its node mapping so Chat's live adapter remains free
// of inspection metadata and lifecycle coupling.
/* jscpd:ignore-start */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
@@ -165,6 +168,7 @@ function materializeNode(
}
}
}
/* jscpd:ignore-end */
function projectTransient(entries: readonly HistoryEntry[]): Pick<
ConversationHistoryProjection,
@@ -185,6 +189,9 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
arguments: unknown
}
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, {
callId: data.subCallId,
name: data.name,
@@ -194,6 +201,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
time: event.time,
callView: null,
}])
/* jscpd:ignore-end */
continue
}
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
break
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), {
callId: String(event.data.callId),
name: event.data.name,
@@ -248,6 +259,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
time: event.time,
callView: entry.view?.for === 'call' ? entry.view.view : null,
})
/* jscpd:ignore-end */
break
case 'tool/result':
openCalls.delete(String(event.data.message.source.callId))
@@ -269,6 +281,9 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
for (const [callId, call] of openCalls) {
if (call.turn !== event.data.turn) continue
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({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01,
time: event.time,
@@ -281,6 +296,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
callView: call.callView,
resultView: null,
})
/* jscpd:ignore-end */
}
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
// 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.
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 { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
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
}
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). */
function materializeNode(
event: SessionEvent,
@@ -137,7 +144,7 @@ export class FoldAdapter {
for (const event of events) this.padded.push(event)
this.surface = new SurfaceManager(this.padded)
this.nodeCache.clear()
this.degraded = false
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
@@ -160,6 +167,7 @@ export class FoldAdapter {
append(event: SessionEvent, view?: ToolEventView): void {
this.rev++
this.padded.push(event)
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
this.indexCall(event, view)
this.indexCommand(event)
}

View File

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

View File

@@ -71,13 +71,13 @@ describe('SessionHistorySource', () => {
it('observes consumer cancellation between older pages', async () => {
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const olderStarted = deferred<void>()
const olderStarted = deferred<undefined>()
const api = new FakeApiClient()
api.onHistory = (payload) => {
if (payload.beforeSeq === undefined) {
return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
}
olderStarted.resolve()
olderStarted.resolve(undefined)
return middle.promise
}
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 { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type {
AssistantMessageNode, ConversationContext, RequestView,
AssistantMessageNode, ConversationContext,
SessionHistoryFace,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
@@ -26,7 +26,6 @@ import {
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_REQUESTS: readonly RequestView[] = []
/** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
@@ -161,7 +160,7 @@ export function TrajectoryView({
void loadAllHistoryRef.current(controller.signal)
return () => { controller.abort() }
}, [])
const requests = inspection.requests ?? EMPTY_REQUESTS
const requests = inspection.requests
const callSchemas = inspection.callSchemas
const contexts = useMemo<readonly ConversationContext[]>(
() => inspection.contexts.length === 0
@@ -324,7 +323,7 @@ export function TrajectoryView({
partial,
runningCalls,
requests: selectedRequests,
...(callSchemas === undefined ? {} : { callSchemas }),
callSchemas,
codeDispatches,
}),
[

View File

@@ -124,15 +124,15 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
}
/** 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 slots = new SlotsService(ctx)
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
const historyStore = createSnapshotStore(historySnapshot(NODES))
const historyStore = createSnapshotStore(snapshot)
const history: SessionHistoryFace = {
sessionId: SID,
getSnapshot: historyStore.getSnapshot,
subscribe: historyStore.subscribe,
getSnapshot: () => historyStore.getSnapshot(),
subscribe: listener => historyStore.subscribe(listener),
loadAll: loadAllHistory,
}
// 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)
const injectedProps = 'hooks' in injected
? {
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
useHistory: bindSnapshotSelector(
(injected as TrajectoryViewInjected).hooks.history,
),
}
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
useHistory: bindSnapshotSelector(
(injected as TrajectoryViewInjected).hooks.history,
),
}
: injected
return (
<View
@@ -310,8 +310,8 @@ describe('tab switching in ConversationRoot', () => {
})
it('empty window keeps the toolbar and reports no timing data', async () => {
const b = await bench()
mount(b.slots, [])
const b = await bench(historySnapshot([]))
mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.getByText('No timing data')).toBeTruthy()