fix(client): resolve trajectory review follow-ups
This commit is contained in:
@@ -78,6 +78,15 @@ interface FoldedContext {
|
|||||||
originSeq?: number
|
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
|
* Replay surface replacements into frozen generations while keeping replacement
|
||||||
* validation and mutation in the canonical core manager.
|
* validation and mutation in the canonical core manager.
|
||||||
@@ -206,6 +215,10 @@ export class FoldAdapter {
|
|||||||
private contextGeneration = 0
|
private contextGeneration = 0
|
||||||
private activePrompt: ConversationPromptSnapshot | undefined
|
private activePrompt: ConversationPromptSnapshot | undefined
|
||||||
private promptsByContext = new Map<number, ConversationPromptSnapshot>()
|
private promptsByContext = new Map<number, ConversationPromptSnapshot>()
|
||||||
|
private assistantSteps = new Map<string, AssistantStepMetadata>()
|
||||||
|
private assistantTimings = new Map<number, AssistantTiming>()
|
||||||
|
private activeRequestConfig: AssistantRequestConfig | undefined
|
||||||
|
private assistantRequestConfigs = new Map<number, AssistantRequestConfig>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param projectContexts - Whether to maintain context-generation indexes
|
* @param projectContexts - Whether to maintain context-generation indexes
|
||||||
@@ -240,6 +253,10 @@ export class FoldAdapter {
|
|||||||
this.contextGeneration = 0
|
this.contextGeneration = 0
|
||||||
this.activePrompt = undefined
|
this.activePrompt = undefined
|
||||||
this.promptsByContext = new Map()
|
this.promptsByContext = new Map()
|
||||||
|
this.assistantSteps = new Map()
|
||||||
|
this.assistantTimings = new Map()
|
||||||
|
this.activeRequestConfig = undefined
|
||||||
|
this.assistantRequestConfigs = new Map()
|
||||||
this.commandIdx = new Map()
|
this.commandIdx = new Map()
|
||||||
for (let i = 0; i < events.length; i++) {
|
for (let i = 0; i < events.length; i++) {
|
||||||
const event = events[i]
|
const event = events[i]
|
||||||
@@ -247,6 +264,7 @@ export class FoldAdapter {
|
|||||||
if (event !== undefined) {
|
if (event !== undefined) {
|
||||||
this.indexCall(event, views?.[i])
|
this.indexCall(event, views?.[i])
|
||||||
if (this.projectContexts) this.indexContextPrompt(event)
|
if (this.projectContexts) this.indexContextPrompt(event)
|
||||||
|
this.indexAssistantMetadata(event)
|
||||||
this.indexCommand(event)
|
this.indexCommand(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,6 +284,7 @@ export class FoldAdapter {
|
|||||||
this.padded.push(event)
|
this.padded.push(event)
|
||||||
this.indexCall(event, view)
|
this.indexCall(event, view)
|
||||||
if (this.projectContexts) this.indexContextPrompt(event)
|
if (this.projectContexts) this.indexContextPrompt(event)
|
||||||
|
this.indexAssistantMetadata(event)
|
||||||
this.indexCommand(event)
|
this.indexCommand(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,51 +422,13 @@ export class FoldAdapter {
|
|||||||
event,
|
event,
|
||||||
this.callIdx,
|
this.callIdx,
|
||||||
this.resultViews.get(seq) ?? null,
|
this.resultViews.get(seq) ?? null,
|
||||||
event.type === 'assistant/message' ? this.assistantTiming(event) : undefined,
|
this.assistantTimings.get(seq),
|
||||||
event.type === 'assistant/message' ? this.assistantRequestConfig(event) : undefined,
|
this.assistantRequestConfigs.get(seq),
|
||||||
)
|
)
|
||||||
this.nodeCache.set(seq, node)
|
this.nodeCache.set(seq, node)
|
||||||
return 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). */
|
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
|
||||||
private indexCommand(event: SessionEvent): void {
|
private indexCommand(event: SessionEvent): void {
|
||||||
// Log-only plugin events: the host-side dsh-commands declaration cannot
|
// 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).
|
// (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 {
|
private indexContextPrompt(event: SessionEvent): void {
|
||||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||||
this.contextGeneration++
|
this.contextGeneration++
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// calls share one chronological projection; presentation-specific grouping
|
// calls share one chronological projection; presentation-specific grouping
|
||||||
// remains in the trajectory consumer.
|
// 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 { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||||
import type {
|
import type {
|
||||||
@@ -138,6 +138,32 @@ function requestKey(turn: number, step: number): string {
|
|||||||
return `${turn}\u0000${step}`
|
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(
|
function deriveCallSchemas(
|
||||||
events: readonly SessionEvent[],
|
events: readonly SessionEvent[],
|
||||||
): ReadonlyMap<string, ToolSchema> {
|
): ReadonlyMap<string, ToolSchema> {
|
||||||
@@ -244,8 +270,25 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
|||||||
})
|
})
|
||||||
continue
|
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') {
|
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,
|
completedAt: sourceEvent.time,
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
resultSeq: sourceEvent.seq,
|
resultSeq: sourceEvent.seq,
|
||||||
@@ -253,7 +296,9 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
|
|||||||
provider: sourceEvent.data.message.source.provider,
|
provider: sourceEvent.data.message.source.provider,
|
||||||
model: sourceEvent.data.message.source.model,
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ export interface SessionOptions {
|
|||||||
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
|
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
|
||||||
const QUEUE_PREVIEW_CHARS = 200
|
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. */
|
/** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */
|
||||||
interface QueuedEntry {
|
interface QueuedEntry {
|
||||||
row: QueuedMessage
|
row: QueuedMessage
|
||||||
@@ -326,14 +330,20 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
|||||||
/**
|
/**
|
||||||
* Exhaust history paging for inspection surfaces that require a complete
|
* Exhaust history paging for inspection surfaces that require a complete
|
||||||
* session ledger. Stops after a failed or non-advancing page so a transient
|
* 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.
|
* @returns When the available history has been exhausted or paging stops making progress.
|
||||||
*/
|
*/
|
||||||
async loadAllHistory(): Promise<void> {
|
async loadAllHistory(signal?: AbortSignal): Promise<void> {
|
||||||
while (this.openState === 'open' && this.hasMore) {
|
while (
|
||||||
|
!isAborted(signal)
|
||||||
|
&& this.openState === 'open'
|
||||||
|
&& this.hasMore
|
||||||
|
) {
|
||||||
const previousBaseSeq = this.baseSeq
|
const previousBaseSeq = this.baseSeq
|
||||||
await this.loadOlder()
|
await this.loadOlder()
|
||||||
if (this.baseSeq === previousBaseSeq) return
|
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +360,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
|||||||
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
|
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.openGeneration++
|
||||||
this.openPromise = null
|
this.openPromise = null
|
||||||
|
this.loadOlderPromise = null
|
||||||
|
this.loadingOlder = false
|
||||||
this.openState = 'cold'
|
this.openState = 'cold'
|
||||||
this.openError = null
|
this.openError = null
|
||||||
this.events = []
|
this.events = []
|
||||||
|
|||||||
@@ -178,6 +178,45 @@ 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', () => {
|
||||||
|
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', () => {
|
it('exposes the in-window call index for runningCalls material', () => {
|
||||||
const adapter = new FoldAdapter()
|
const adapter = new FoldAdapter()
|
||||||
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
|
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
|
||||||
|
|||||||
@@ -109,6 +109,56 @@ describe('inspectRequests', () => {
|
|||||||
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
|
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', () => {
|
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
|
||||||
const snapshot = inspectRequests(entriesOf([
|
const snapshot = inspectRequests(entriesOf([
|
||||||
at(0, 'step/start', { turn: 1, step: 1 }),
|
at(0, 'step/start', { turn: 1, step: 1 }),
|
||||||
|
|||||||
@@ -292,6 +292,26 @@ describe('paging', () => {
|
|||||||
.toEqual([1, 3, 7, 9, 13, 15])
|
.toEqual([1, 3, 7, 9, 13, 15])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('stops complete-history loading between pages after its consumer aborts', async () => {
|
||||||
|
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||||
|
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 () => {
|
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
|
||||||
const { api, session } = makeSession()
|
const { api, session } = makeSession()
|
||||||
api.onHistory = payload => payload.beforeSeq === undefined
|
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.openState).toBe('open') // stale failure did not settle the fresh generation into error
|
||||||
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
|
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<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||||
|
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', () => {
|
describe('run_code sub-dispatch indexing', () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
AssistantMessageNode, ConversationContext, ConversationNode, RequestView,
|
AssistantMessageNode, ConversationContext, ConversationNode, RequestView,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import {
|
import {
|
||||||
deriveTrajectoryContextBranches, trajectoryBranchContainsSeq,
|
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
|
||||||
} from './context-branches.ts'
|
} from './context-branches.ts'
|
||||||
import {
|
import {
|
||||||
TrajectoryTable,
|
TrajectoryTable,
|
||||||
@@ -28,7 +28,7 @@ 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 {
|
||||||
loadAllHistory: () => Promise<void>
|
loadAllHistory: (signal: AbortSignal) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsageLike {
|
interface UsageLike {
|
||||||
@@ -160,7 +160,11 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
|||||||
const loadAllHistoryRef = useRef(loadAllHistory)
|
const loadAllHistoryRef = useRef(loadAllHistory)
|
||||||
loadAllHistoryRef.current = loadAllHistory
|
loadAllHistoryRef.current = loadAllHistory
|
||||||
useEffect(() => {
|
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])
|
}, [hasMore, openState])
|
||||||
const requests = inspection?.requests ?? EMPTY_REQUESTS
|
const requests = inspection?.requests ?? EMPTY_REQUESTS
|
||||||
const callSchemas = inspection?.callSchemas
|
const callSchemas = inspection?.callSchemas
|
||||||
@@ -185,7 +189,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
|
|||||||
}, [currentBranch, nodes])
|
}, [currentBranch, nodes])
|
||||||
const selectedRequests = useMemo(
|
const selectedRequests = useMemo(
|
||||||
() => requests.filter(request =>
|
() => requests.filter(request =>
|
||||||
trajectoryBranchContainsSeq(currentBranch, request.startSeq),
|
trajectoryBranchContainsRequest(currentBranch, request),
|
||||||
),
|
),
|
||||||
[currentBranch, requests],
|
[currentBranch, requests],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
|
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ConversationContext, ConversationNode,
|
ConversationContext, ConversationNode, RequestView,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
|
|
||||||
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
|
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
|
||||||
@@ -10,13 +10,10 @@ export interface TrajectoryContextBranch {
|
|||||||
contexts: readonly ConversationContext[]
|
contexts: readonly ConversationContext[]
|
||||||
latest: ConversationContext
|
latest: ConversationContext
|
||||||
nodes: readonly ConversationNode[]
|
nodes: readonly ConversationNode[]
|
||||||
ranges: readonly TrajectoryBranchRange[]
|
/** Seq that opened this branch; earlier requests require retained surface provenance. */
|
||||||
}
|
startSeq: number
|
||||||
|
/** Exact pre-rewind surface records inherited by this branch. */
|
||||||
/** One half-open session-event range carried by a rewind branch. */
|
retainedSurfaceSeqs: ReadonlySet<number>
|
||||||
export interface TrajectoryBranchRange {
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MutableBranch {
|
interface MutableBranch {
|
||||||
@@ -24,7 +21,8 @@ interface MutableBranch {
|
|||||||
contexts: ConversationContext[]
|
contexts: ConversationContext[]
|
||||||
latest: ConversationContext
|
latest: ConversationContext
|
||||||
nodes: Map<number, ConversationNode>
|
nodes: Map<number, ConversationNode>
|
||||||
ranges: TrajectoryBranchRange[]
|
startSeq: number
|
||||||
|
retainedSurfaceSeqs: Set<number>
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCompactionCheckpoint(node: ConversationNode): boolean {
|
function isCompactionCheckpoint(node: ConversationNode): boolean {
|
||||||
@@ -51,29 +49,18 @@ export function deriveTrajectoryContextBranches(
|
|||||||
const startsBranch = mutable.length === 0 || context.origin === 'rewind'
|
const startsBranch = mutable.length === 0 || context.origin === 'rewind'
|
||||||
if (startsBranch) {
|
if (startsBranch) {
|
||||||
const previous = mutable.at(-1)
|
const previous = mutable.at(-1)
|
||||||
const originSeq = context.originSeq ?? Number.POSITIVE_INFINITY
|
const retainedSurfaceSeqs = new Set(
|
||||||
if (previous !== undefined) {
|
context.nodes
|
||||||
const openRange = previous.ranges.at(-1)
|
.filter(node =>
|
||||||
if (openRange === undefined) {
|
context.originSeq !== undefined && node.seq < context.originSeq,
|
||||||
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)
|
|
||||||
.map(node => node.seq),
|
.map(node => node.seq),
|
||||||
)
|
)
|
||||||
const inheritedNodes = previous === undefined
|
const inheritedNodes = previous === undefined
|
||||||
? []
|
? []
|
||||||
: [...previous.nodes.values()].filter(node => node.seq <= retainedCutoff)
|
: [...previous.nodes.values()].filter(node =>
|
||||||
const inheritedRanges = previous === undefined
|
retainedSurfaceSeqs.has(node.seq),
|
||||||
? []
|
)
|
||||||
: previous.ranges.flatMap((range) => {
|
|
||||||
const end = Math.min(range.end, retainedCutoff + 1)
|
|
||||||
return end <= range.start ? [] : [{ start: range.start, end }]
|
|
||||||
})
|
|
||||||
mutable.push({
|
mutable.push({
|
||||||
id: context.id,
|
id: context.id,
|
||||||
contexts: [context],
|
contexts: [context],
|
||||||
@@ -82,13 +69,8 @@ export function deriveTrajectoryContextBranches(
|
|||||||
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
|
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
|
||||||
.map(node => [node.seq, node]),
|
.map(node => [node.seq, node]),
|
||||||
),
|
),
|
||||||
ranges: [
|
startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY,
|
||||||
...inheritedRanges,
|
retainedSurfaceSeqs,
|
||||||
{
|
|
||||||
start: context.originSeq ?? Number.NEGATIVE_INFINITY,
|
|
||||||
end: Number.POSITIVE_INFINITY,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -105,19 +87,27 @@ export function deriveTrajectoryContextBranches(
|
|||||||
contexts: branch.contexts,
|
contexts: branch.contexts,
|
||||||
latest: branch.latest,
|
latest: branch.latest,
|
||||||
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),
|
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.
|
* Test whether a provider request belongs to one rewind branch.
|
||||||
* @param branch - Branch carrying inherited and post-rewind log ranges.
|
* @param branch - Branch carrying exact inherited surface provenance.
|
||||||
* @param seq - Session event sequence.
|
* @param request - Provider request to classify.
|
||||||
* @returns Whether the event belongs to the branch.
|
* @returns Whether the request began on this branch or produced a retained surface record.
|
||||||
*/
|
*/
|
||||||
export function trajectoryBranchContainsSeq(
|
export function trajectoryBranchContainsRequest(
|
||||||
branch: TrajectoryContextBranch,
|
branch: TrajectoryContextBranch,
|
||||||
seq: number,
|
request: RequestView,
|
||||||
): boolean {
|
): 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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export function apply(ctx: Context): void {
|
|||||||
if (session === undefined) {
|
if (session === undefined) {
|
||||||
throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`)
|
throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`)
|
||||||
}
|
}
|
||||||
return { loadAllHistory: () => session.loadAllHistory() }
|
return {
|
||||||
|
loadAllHistory: signal => session.loadAllHistory(signal),
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}, TrajectoryView)
|
}, TrajectoryView)
|
||||||
}
|
}
|
||||||
|
|||||||
83
packages/client/ui-trajectory/tests/context-branches.spec.ts
Normal file
83
packages/client/ui-trajectory/tests/context-branches.spec.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -92,7 +92,7 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
|||||||
async function bench() {
|
async function bench() {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
const slots = new SlotsService(ctx)
|
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.
|
// The conversation entry's role: declare the ring, then seed the chat entry.
|
||||||
slots.register({
|
slots.register({
|
||||||
name: 'root',
|
name: 'root',
|
||||||
@@ -211,6 +211,10 @@ describe('tab switching in ConversationRoot', () => {
|
|||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(b.loadAllHistory).toHaveBeenCalledOnce()
|
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 () => {
|
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user