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)