fix(client): resolve trajectory review follow-ups

This commit is contained in:
_Kerman
2026-07-28 22:54:46 +08:00
parent a245c8a011
commit 943ef7403e
11 changed files with 392 additions and 96 deletions
@@ -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<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
@@ -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++
@@ -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<string, ToolSchema> {
@@ -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
}
@@ -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<ConversationSnapshot> {
/**
* 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<void> {
while (this.openState === 'open' && this.hasMore) {
async loadAllHistory(signal?: AbortSignal): Promise<void> {
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<ConversationSnapshot> {
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 = []