From 943ef7403e03001102b59586bf4b0d301b842ae0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 28 Jul 2026 22:54:46 +0800 Subject: [PATCH] fix(client): resolve trajectory review follow-ups --- .../src/client/sessions/fold-adapter.ts | 101 +++++++++++------- .../src/client/sessions/request-inspection.ts | 51 ++++++++- .../runtime/src/client/sessions/session.ts | 20 +++- .../client/runtime/tests/fold-adapter.spec.ts | 39 +++++++ .../runtime/tests/request-inspection.spec.ts | 50 +++++++++ packages/client/runtime/tests/session.spec.ts | 46 ++++++++ .../src/client/TrajectoryView.tsx | 12 ++- .../src/client/context-branches.ts | 76 ++++++------- .../client/ui-trajectory/src/client/index.ts | 4 +- .../tests/context-branches.spec.ts | 83 ++++++++++++++ .../client/ui-trajectory/tests/views.spec.tsx | 6 +- 11 files changed, 392 insertions(+), 96 deletions(-) create mode 100644 packages/client/ui-trajectory/tests/context-branches.spec.ts diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 5225b59770..bbc906eb7b 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -78,6 +78,15 @@ interface FoldedContext { originSeq?: number } +interface AssistantStepMetadata { + stepStartTime: number | null + firstTokenTime: number | null +} + +function assistantStepKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + /** * Replay surface replacements into frozen generations while keeping replacement * validation and mutation in the canonical core manager. @@ -206,6 +215,10 @@ export class FoldAdapter { private contextGeneration = 0 private activePrompt: ConversationPromptSnapshot | undefined private promptsByContext = new Map() + private assistantSteps = new Map() + private assistantTimings = new Map() + private activeRequestConfig: AssistantRequestConfig | undefined + private assistantRequestConfigs = new Map() /** * @param projectContexts - Whether to maintain context-generation indexes @@ -240,6 +253,10 @@ export class FoldAdapter { this.contextGeneration = 0 this.activePrompt = undefined this.promptsByContext = new Map() + this.assistantSteps = new Map() + this.assistantTimings = new Map() + this.activeRequestConfig = undefined + this.assistantRequestConfigs = new Map() this.commandIdx = new Map() for (let i = 0; i < events.length; i++) { const event = events[i] @@ -247,6 +264,7 @@ export class FoldAdapter { if (event !== undefined) { this.indexCall(event, views?.[i]) if (this.projectContexts) this.indexContextPrompt(event) + this.indexAssistantMetadata(event) this.indexCommand(event) } } @@ -266,6 +284,7 @@ export class FoldAdapter { this.padded.push(event) this.indexCall(event, view) if (this.projectContexts) this.indexContextPrompt(event) + this.indexAssistantMetadata(event) this.indexCommand(event) } @@ -403,51 +422,13 @@ export class FoldAdapter { event, this.callIdx, this.resultViews.get(seq) ?? null, - event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, - event.type === 'assistant/message' ? this.assistantRequestConfig(event) : undefined, + this.assistantTimings.get(seq), + this.assistantRequestConfigs.get(seq), ) this.nodeCache.set(seq, node) return node } - private assistantTiming(event: SessionEvent<'assistant/message'>): AssistantTiming { - let stepStartTime: number | null = null - let firstTokenTime: number | null = null - for (let i = this.baseSeq; i < this.padded.length; i++) { - const candidate = this.padded[i] - if (candidate === undefined || candidate.seq > event.seq) break - if ( - candidate.type === 'step/start' - && candidate.data.turn === event.data.turn - && candidate.data.step === event.data.step - ) { - stepStartTime = candidate.time - continue - } - if ( - firstTokenTime === null - && candidate.type === 'assistant/chunk' - && candidate.data.turn === event.data.turn - && candidate.data.step === event.data.step - && isTokenDelta(candidate.data.chunk) - ) { - firstTokenTime = candidate.time - } - } - return { stepStartTime, firstTokenTime, completedTime: event.time } - } - - private assistantRequestConfig( - event: SessionEvent<'assistant/message'>, - ): AssistantRequestConfig | undefined { - for (let i = event.seq; i >= this.baseSeq; i--) { - const candidate = this.padded[i] - if (candidate?.type !== 'request/header') continue - return candidate.data.header.config - } - return undefined - } - /** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */ private indexCommand(event: SessionEvent): void { // Log-only plugin events: the host-side dsh-commands declaration cannot @@ -493,6 +474,46 @@ export class FoldAdapter { // (window order puts the call before its result; cannot happen on the normal path). } + private indexAssistantMetadata(event: SessionEvent): void { + if (event.type === 'request/header') { + this.activeRequestConfig = event.data.header.config + return + } + if (event.type === 'step/start') { + this.assistantSteps.set( + assistantStepKey(event.data.turn, event.data.step), + { stepStartTime: event.time, firstTokenTime: null }, + ) + return + } + if (event.type === 'assistant/chunk') { + if (!isTokenDelta(event.data.chunk)) return + const key = assistantStepKey(event.data.turn, event.data.step) + const current = this.assistantSteps.get(key) ?? { + stepStartTime: null, + firstTokenTime: null, + } + if (current.firstTokenTime === null) { + this.assistantSteps.set(key, { + ...current, + firstTokenTime: event.time, + }) + } + return + } + if (event.type !== 'assistant/message') return + const timing = this.assistantSteps.get( + assistantStepKey(event.data.turn, event.data.step), + ) ?? { stepStartTime: null, firstTokenTime: null } + this.assistantTimings.set(event.seq, { + ...timing, + completedTime: event.time, + }) + if (this.activeRequestConfig !== undefined) { + this.assistantRequestConfigs.set(event.seq, this.activeRequestConfig) + } + } + private indexContextPrompt(event: SessionEvent): void { if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { this.contextGeneration++ diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/runtime/src/client/sessions/request-inspection.ts index 4318a89899..e15ad55c8b 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/runtime/src/client/sessions/request-inspection.ts @@ -2,7 +2,7 @@ // calls share one chronological projection; presentation-specific grouping // remains in the trajectory consumer. -import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types' +import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { @@ -138,6 +138,32 @@ function requestKey(turn: number, step: number): string { return `${turn}\u0000${step}` } +function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage { + const previous = current as TokenUsage | undefined + return { + inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens, + outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens, + ...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined + ? {} + : { + cacheReadTokens: + (previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0), + }), + ...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined + ? {} + : { + cacheWriteTokens: + (previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0), + }), + ...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined + ? {} + : { + reasoningTokens: + (previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0), + }), + } +} + function deriveCallSchemas( events: readonly SessionEvent[], ): ReadonlyMap { @@ -244,8 +270,25 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] }) continue } + if ( + sourceEvent.type === 'assistant/chunk' + && sourceEvent.data.chunk.type === 'usage' + ) { + const index = ordinaryByStep.get( + requestKey(sourceEvent.data.turn, sourceEvent.data.step), + ) + const request = index === undefined ? undefined : requests[index] + update(index, { + usage: addTokenUsage(request?.usage, sourceEvent.data.chunk.usage), + }) + continue + } if (sourceEvent.type === 'assistant/message') { - update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, sourceEvent.data.step)), { + const index = ordinaryByStep.get( + requestKey(sourceEvent.data.turn, sourceEvent.data.step), + ) + const request = index === undefined ? undefined : requests[index] + update(index, { completedAt: sourceEvent.time, status: 'complete', resultSeq: sourceEvent.seq, @@ -253,7 +296,9 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] provider: sourceEvent.data.message.source.provider, model: sourceEvent.data.message.source.model, }, - ...(sourceEvent.data.usage === undefined ? {} : { usage: sourceEvent.data.usage }), + ...(request?.usage !== undefined || sourceEvent.data.usage === undefined + ? {} + : { usage: sourceEvent.data.usage }), }) continue } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index fc1b2a8ffb..2fedaa9227 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -51,6 +51,10 @@ export interface SessionOptions { /** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */ const QUEUE_PREVIEW_CHARS = 200 +function isAborted(signal: AbortSignal | undefined): boolean { + return signal?.aborted === true +} + /** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */ interface QueuedEntry { row: QueuedMessage @@ -326,14 +330,20 @@ export class Session implements ObservableSnapshot { /** * Exhaust history paging for inspection surfaces that require a complete * session ledger. Stops after a failed or non-advancing page so a transient - * backend failure cannot become an automatic retry loop. + * backend failure cannot become an automatic retry loop, and observes + * cancellation between pages without abandoning an active unary request. + * @param signal - Mounted consumer lifetime; abort stops before the next page. * @returns When the available history has been exhausted or paging stops making progress. */ - async loadAllHistory(): Promise { - while (this.openState === 'open' && this.hasMore) { + async loadAllHistory(signal?: AbortSignal): Promise { + while ( + !isAborted(signal) + && this.openState === 'open' + && this.hasMore + ) { const previousBaseSeq = this.baseSeq await this.loadOlder() - if (this.baseSeq === previousBaseSeq) return + if (isAborted(signal) || this.baseSeq === previousBaseSeq) return } } @@ -350,6 +360,8 @@ export class Session implements ObservableSnapshot { if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) this.openGeneration++ this.openPromise = null + this.loadOlderPromise = null + this.loadingOlder = false this.openState = 'cold' this.openError = null this.events = [] diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index 3dd4b2b4c9..1ee0e23998 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -178,6 +178,45 @@ 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([ + ev.stepStart(0, 1, 2), + at(1, { type: 'request/header', data: { + reason: 'initial', + header: { + config: { provider: 'fake', model: 'first' }, + tools: [], + }, + } }), + ev.chunkStart(2, 1, 2), + ev.chunkText(3, 1, 'token', 2), + ev.assistant(4, 1, 'done', 2), + ], 0) + + expect(adapter.nodes().nodes[0]).toMatchObject({ + kind: 'assistant', + timing: { + stepStartTime: 1_700_000_000_000, + firstTokenTime: 1_700_000_000_003, + completedTime: 1_700_000_000_004, + }, + 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({ + timing: { + stepStartTime: 1_700_000_000_005, + firstTokenTime: 1_700_000_000_006, + completedTime: 1_700_000_000_007, + }, + requestConfig: { provider: 'fake', model: 'first' }, + }) + }) + it('exposes the in-window call index for runningCalls material', () => { const adapter = new FoldAdapter() adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0) diff --git a/packages/client/runtime/tests/request-inspection.spec.ts b/packages/client/runtime/tests/request-inspection.spec.ts index d7e78e9b5f..a76036c076 100644 --- a/packages/client/runtime/tests/request-inspection.spec.ts +++ b/packages/client/runtime/tests/request-inspection.spec.ts @@ -109,6 +109,56 @@ describe('inspectRequests', () => { expect(snapshot.callSchemas.get('nested')?.name).toBe('read') }) + it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => { + const chunkUsage = { inputTokens: 21, outputTokens: 3 } + const retryUsage = { + inputTokens: 5, + outputTokens: 2, + cacheReadTokens: 8, + reasoningTokens: 1, + } + const snapshot = inspectRequests(entriesOf([ + at(0, 'step/start', { turn: 1, step: 1 }), + at(1, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'usage', usage: chunkUsage }, + }), + at(2, 'llm/retry', { + turn: 1, + step: 1, + retry: 1, + maxRetries: 2, + delayMs: 100, + failure: { message: 'rate limited' }, + }), + at(3, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'usage', usage: retryUsage }, + }), + at(4, 'assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'recovered' }], + source: { provider: 'fake', model: 'model' }, + }), + usage: { inputTokens: 1, outputTokens: 1 }, + }), + ])) + + expect(snapshot.requests[0]).toMatchObject({ + status: 'complete', + usage: { + inputTokens: 26, + outputTokens: 5, + cacheReadTokens: 8, + reasoningTokens: 1, + }, + }) + }) + it('treats a scrubbed durable-fixture tool catalog as unavailable', () => { const snapshot = inspectRequests(entriesOf([ at(0, 'step/start', { turn: 1, step: 1 }), diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 31d46e051c..3639c6ca43 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -292,6 +292,26 @@ describe('paging', () => { .toEqual([1, 3, 7, 9, 13, 15]) }) + it('stops complete-history loading between pages after its consumer aborts', async () => { + const middle = deferred>>() + const { api, session } = makeSession() + api.onHistory = payload => payload.beforeSeq === undefined + ? histResponse(plainTurn(12, 2, '最新问', '最新答'), true) + : middle.promise + await session.open() + const controller = new AbortController() + const completeHistory = session.loadAllHistory(controller.signal) + controller.abort() + middle.resolve(ok({ + events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[], + hasMore: true, + })) + await completeHistory + + expect(api.callsOf('session.history')).toHaveLength(2) + expect(session.getSnapshot().hasMore).toBe(true) + }) + it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => { const { api, session } = makeSession() api.onHistory = payload => payload.beforeSeq === undefined @@ -767,6 +787,32 @@ describe('resync', () => { expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9]) }) + + it('starts fresh paging while an older generation page is still pending', async () => { + const stalePage = deferred>>() + const { api, session } = makeSession() + let call = 0 + api.onHistory = () => { + call++ + if (call === 1) return histResponse(plainTurn(6, 1, '新', '页'), true) + if (call === 2) return stalePage.promise + if (call === 3) return histResponse(plainTurn(6, 1, '新', '代'), true) + return histResponse(plainTurn(0, 0, '旧', '页'), false) + } + await session.open() + const stale = session.loadOlder() + await session.resync() + const fresh = session.loadAllHistory() + await vi.waitFor(() => { expect(call).toBe(4) }) + stalePage.resolve(ok({ + events: entries(plainTurn(0, 0, '废', '弃')) as never[], + hasMore: false, + })) + await Promise.all([stale, fresh]) + + expect(session.getSnapshot().hasMore).toBe(false) + expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([1, 3, 7, 9]) + }) }) describe('run_code sub-dispatch indexing', () => { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 1fef4a7042..06871675c5 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -6,7 +6,7 @@ import type { AssistantMessageNode, ConversationContext, ConversationNode, RequestView, } from '@deepseek-ai/dsh-client-runtime/client' import { - deriveTrajectoryContextBranches, trajectoryBranchContainsSeq, + deriveTrajectoryContextBranches, trajectoryBranchContainsRequest, } from './context-branches.ts' import { TrajectoryTable, @@ -28,7 +28,7 @@ const EMPTY_REQUESTS: readonly RequestView[] = [] /** Session-history paging needed by the event-complete trajectory view. */ export interface TrajectoryViewInjected { - loadAllHistory: () => Promise + loadAllHistory: (signal: AbortSignal) => Promise } interface UsageLike { @@ -160,7 +160,11 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T const loadAllHistoryRef = useRef(loadAllHistory) loadAllHistoryRef.current = loadAllHistory useEffect(() => { - if (openState === 'open' && hasMore) void loadAllHistoryRef.current() + const controller = new AbortController() + if (openState === 'open' && hasMore) { + void loadAllHistoryRef.current(controller.signal) + } + return () => { controller.abort() } }, [hasMore, openState]) const requests = inspection?.requests ?? EMPTY_REQUESTS const callSchemas = inspection?.callSchemas @@ -185,7 +189,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T }, [currentBranch, nodes]) const selectedRequests = useMemo( () => requests.filter(request => - trajectoryBranchContainsSeq(currentBranch, request.startSeq), + trajectoryBranchContainsRequest(currentBranch, request), ), [currentBranch, requests], ) diff --git a/packages/client/ui-trajectory/src/client/context-branches.ts b/packages/client/ui-trajectory/src/client/context-branches.ts index ab2c65b3f1..239953b5fd 100644 --- a/packages/client/ui-trajectory/src/client/context-branches.ts +++ b/packages/client/ui-trajectory/src/client/context-branches.ts @@ -1,7 +1,7 @@ /** Rewind-delimited trajectory branches assembled across surface rewrites. */ import type { - ConversationContext, ConversationNode, + ConversationContext, ConversationNode, RequestView, } from '@deepseek-ai/dsh-client-runtime/client' /** One continuous context branch; compactions stay inline while rewinds start a successor branch. */ @@ -10,13 +10,10 @@ export interface TrajectoryContextBranch { contexts: readonly ConversationContext[] latest: ConversationContext nodes: readonly ConversationNode[] - ranges: readonly TrajectoryBranchRange[] -} - -/** One half-open session-event range carried by a rewind branch. */ -export interface TrajectoryBranchRange { - start: number - end: number + /** Seq that opened this branch; earlier requests require retained surface provenance. */ + startSeq: number + /** Exact pre-rewind surface records inherited by this branch. */ + retainedSurfaceSeqs: ReadonlySet } interface MutableBranch { @@ -24,7 +21,8 @@ interface MutableBranch { contexts: ConversationContext[] latest: ConversationContext nodes: Map - ranges: TrajectoryBranchRange[] + startSeq: number + retainedSurfaceSeqs: Set } function isCompactionCheckpoint(node: ConversationNode): boolean { @@ -51,29 +49,18 @@ export function deriveTrajectoryContextBranches( const startsBranch = mutable.length === 0 || context.origin === 'rewind' if (startsBranch) { const previous = mutable.at(-1) - const originSeq = context.originSeq ?? Number.POSITIVE_INFINITY - if (previous !== undefined) { - const openRange = previous.ranges.at(-1) - if (openRange === undefined) { - throw new Error('trajectory branch must contain an open event range') - } - openRange.end = originSeq - } - const retainedCutoff = Math.max( - Number.NEGATIVE_INFINITY, - ...context.nodes - .filter(node => node.seq < originSeq) + const retainedSurfaceSeqs = new Set( + context.nodes + .filter(node => + context.originSeq !== undefined && node.seq < context.originSeq, + ) .map(node => node.seq), ) const inheritedNodes = previous === undefined ? [] - : [...previous.nodes.values()].filter(node => node.seq <= retainedCutoff) - const inheritedRanges = previous === undefined - ? [] - : previous.ranges.flatMap((range) => { - const end = Math.min(range.end, retainedCutoff + 1) - return end <= range.start ? [] : [{ start: range.start, end }] - }) + : [...previous.nodes.values()].filter(node => + retainedSurfaceSeqs.has(node.seq), + ) mutable.push({ id: context.id, contexts: [context], @@ -82,13 +69,8 @@ export function deriveTrajectoryContextBranches( [...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))] .map(node => [node.seq, node]), ), - ranges: [ - ...inheritedRanges, - { - start: context.originSeq ?? Number.NEGATIVE_INFINITY, - end: Number.POSITIVE_INFINITY, - }, - ], + startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY, + retainedSurfaceSeqs, }) continue } @@ -105,19 +87,27 @@ export function deriveTrajectoryContextBranches( contexts: branch.contexts, latest: branch.latest, nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq), - ranges: branch.ranges, + startSeq: branch.startSeq, + retainedSurfaceSeqs: branch.retainedSurfaceSeqs, })) } /** - * Test whether a session event belongs to one rewind branch's continuous history. - * @param branch - Branch carrying inherited and post-rewind log ranges. - * @param seq - Session event sequence. - * @returns Whether the event belongs to the branch. + * Test whether a provider request belongs to one rewind branch. + * @param branch - Branch carrying exact inherited surface provenance. + * @param request - Provider request to classify. + * @returns Whether the request began on this branch or produced a retained surface record. */ -export function trajectoryBranchContainsSeq( +export function trajectoryBranchContainsRequest( branch: TrajectoryContextBranch, - seq: number, + request: RequestView, ): boolean { - return branch.ranges.some(range => seq >= range.start && seq < range.end) + if (request.startSeq >= branch.startSeq) return true + return ( + request.resultSeq !== undefined + && branch.retainedSurfaceSeqs.has(request.resultSeq) + ) || ( + request.replacementSeq !== undefined + && branch.retainedSurfaceSeqs.has(request.replacementSeq) + ) } diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 50d25f4df4..de0ea3ed31 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -34,7 +34,9 @@ export function apply(ctx: Context): void { if (session === undefined) { throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`) } - return { loadAllHistory: () => session.loadAllHistory() } + return { + loadAllHistory: signal => session.loadAllHistory(signal), + } }, }, TrajectoryView) } diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts new file mode 100644 index 0000000000..dc5068a471 --- /dev/null +++ b/packages/client/ui-trajectory/tests/context-branches.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import type { + ConversationContext, ConversationNode, RequestView, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + deriveTrajectoryContextBranches, + trajectoryBranchContainsRequest, +} from '../src/client/context-branches.ts' + +const checkpoint = { + kind: 'context', + seq: 100, + time: 100, + content: [], + source: { kind: 'plugin', plugin: 'compact' }, +} as ConversationNode + +const abandoned = { + kind: 'assistant', + seq: 20, + time: 20, + turn: 1, + step: 1, + blocks: [{ kind: 'text', text: 'abandoned' }], +} as ConversationNode + +const current = { + kind: 'user', + seq: 110, + time: 110, + content: [{ type: 'text', text: 'rewound' }], + source: { kind: 'plugin', plugin: 'rewind' }, +} as ConversationNode + +function request( + purpose: RequestView['purpose'], + startSeq: number, + resultSeq?: number, + replacementSeq?: number, +): RequestView { + return { + purpose, + startSeq, + turn: 1, + step: purpose === 'assistant' ? 1 : 0, + startedAt: startSeq, + completedAt: startSeq + 1, + status: 'complete', + ...(resultSeq === undefined ? {} : { resultSeq }), + ...(replacementSeq === undefined ? {} : { replacementSeq }), + } +} + +describe('trajectory context branches', () => { + it('inherits nodes and requests by retained surface position rather than seq cutoff', () => { + const contexts: ConversationContext[] = [ + { id: 0, nodes: [checkpoint, abandoned] }, + { + id: 1, + parentId: 0, + origin: 'rewind', + originSeq: 110, + nodes: [checkpoint, current], + }, + ] + const branches = deriveTrajectoryContextBranches(contexts) + const successor = branches[1]! + + expect(successor.nodes.map(node => node.seq)).toEqual([110]) + expect(trajectoryBranchContainsRequest( + successor, + request('assistant', 10, 20), + )).toBe(false) + expect(trajectoryBranchContainsRequest( + successor, + request('compaction', 90, 95, 100), + )).toBe(true) + expect(trajectoryBranchContainsRequest( + successor, + request('assistant', 111), + )).toBe(true) + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 5cdb101a7a..7cfce3e5c3 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -92,7 +92,7 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { async function bench() { const ctx = new Context() const slots = new SlotsService(ctx) - const loadAllHistory = vi.fn(() => Promise.resolve()) + const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve()) // The conversation entry's role: declare the ring, then seed the chat entry. slots.register({ name: 'root', @@ -211,6 +211,10 @@ describe('tab switching in ConversationRoot', () => { await vi.waitFor(() => { expect(b.loadAllHistory).toHaveBeenCalledOnce() }) + const signal = b.loadAllHistory.mock.calls[0]?.[0] + expect(signal?.aborted).toBe(false) + fireEvent.click(screen.getByRole('tab', { name: 'Chat' })) + expect(signal?.aborted).toBe(true) }) it('opens a local record inspector and switches payload tabs without opening chat details', async () => {