diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index a1a7a8fe39..93c105721a 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -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, @@ -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 } diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 039c36056b..fc9efb70e4 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -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) } diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index 6c44aa04f0..0544d84e7d 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -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, diff --git a/packages/client/runtime/tests/session-history-source.spec.ts b/packages/client/runtime/tests/session-history-source.spec.ts index d4e8fc9b41..1375338e90 100644 --- a/packages/client/runtime/tests/session-history-source.spec.ts +++ b/packages/client/runtime/tests/session-history-source.spec.ts @@ -71,13 +71,13 @@ describe('SessionHistorySource', () => { it('observes consumer cancellation between older pages', async () => { const middle = deferred>>() - const olderStarted = deferred() + const olderStarted = deferred() 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) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 67d4b929d9..c7f4e193da 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -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 = 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( () => inspection.contexts.length === 0 @@ -324,7 +323,7 @@ export function TrajectoryView({ partial, runningCalls, requests: selectedRequests, - ...(callSchemas === undefined ? {} : { callSchemas }), + callSchemas, codeDispatches, }), [ diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 5d391bc8cb..c79b7407ab 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -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 ( { }) 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()