test(client): cover independent trajectory history
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user