Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

Resolve translation-pairing hashes after the merge and adapt the
math-rendering e2e fixture to the simplified turn/start event shape.
This commit is contained in:
_Kerman
2026-08-05 21:42:33 +08:00
60 changed files with 3207 additions and 376 deletions

View File

@@ -9,6 +9,8 @@ export interface SessionHistorySnapshot {
state: 'cold' | 'loading' | 'ready' | 'error'
error: RpcError | null
hasMore: boolean
/** Absolute sequence of the first loaded raw event, or zero for an empty window. */
baseSeq: number
inspection: SessionHistoryInspection
}
@@ -17,11 +19,17 @@ export interface SessionHistoryFace
extends ObservableSnapshot<SessionHistorySnapshot> {
readonly sessionId: SessionId
/**
* Load the tail and exhaust every available older page.
* @param signal - Consumer lifetime; abort is observed between page requests.
* @returns When the available ledger is complete or stops advancing.
* Load the current tail without reading older pages.
* @param signal - Consumer lifetime.
* @returns When the tail is ready or loading fails.
*/
loadAll(signal?: AbortSignal): Promise<void>
loadTail(signal?: AbortSignal): Promise<void>
/**
* Prepend one older page when the current window has a predecessor.
* @param signal - Consumer lifetime.
* @returns Whether the loaded window advanced.
*/
loadOlder(signal?: AbortSignal): Promise<boolean>
}
/** Runtime service resolving independent history sources. */

View File

@@ -49,18 +49,10 @@ function assistantStepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
// Trajectory owns surface-window reconstruction so its immutable ledger does
// not depend on Chat's live fold adapter or Session's mutable state.
/* jscpd:ignore-start */
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
}
/* jscpd:ignore-end */
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
if (event?.type !== 'user/message') return 'rewrite'
@@ -84,27 +76,63 @@ function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']):
}
}
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
function foldContexts(
events: readonly SessionEvent[],
): readonly FoldedContext[] {
const replay: SessionEvent[] = []
const originalSeqs: number[] = []
const rebasedSeqByOriginal = new Map<number, number>()
const surface = new SurfaceManager(replay)
const contexts: FoldedContext[] = []
let generation = 0
let originSeq: number | undefined
const originalNodes = () => surface.nodes.map((seq) => {
const original = originalSeqs[seq]
if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`)
return original
})
for (const event of events) {
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
if (!isSurfaceEvent(event)) continue
if (event.surfaceOp !== 'append') {
contexts.push({
generation,
nodes: [...surface.nodes],
nodes: originalNodes(),
...(originSeq === undefined ? {} : { originSeq }),
})
generation++
originSeq = event.seq
}
replay.push(event)
const rebasedSeq = replay.length
const {
sourceEventSeqs: rawSources,
...eventWithoutSources
} = event as SessionEvent & { sourceEventSeqs?: readonly number[] }
const mappedSourceEventSeqs = rawSources?.flatMap((seq) => {
const rebased = rebasedSeqByOriginal.get(seq)
return rebased === undefined ? [] : [rebased]
})
const sourceEventSeqs = mappedSourceEventSeqs?.length === 0
? undefined
: mappedSourceEventSeqs
const surfaceOp = event.surfaceOp === 'append'
? event.surfaceOp
: {
...event.surfaceOp,
start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start,
end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end,
}
originalSeqs.push(event.seq)
rebasedSeqByOriginal.set(event.seq, rebasedSeq)
replay.push({
...eventWithoutSources,
seq: rebasedSeq,
surfaceOp,
...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
} as SessionEvent)
}
contexts.push({
generation,
nodes: [...surface.nodes],
nodes: originalNodes(),
...(originSeq === undefined ? {} : { originSeq }),
})
return contexts
@@ -326,10 +354,7 @@ export function projectConversationHistory(
): ConversationHistoryProjection {
const events = entries.map(entry => entry.event)
const baseSeq = events[0]?.seq ?? 0
const padded = [
...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)),
...events,
]
const eventsBySeq = new Map(events.map(event => [event.seq, event]))
const callIndex = new Map<string, CallIndexEntry>()
const resultViews = new Map<number, ToolResultView>()
const assistantSteps = new Map<string, AssistantStepMetadata>()
@@ -399,7 +424,7 @@ export function projectConversationHistory(
const materialize = (seq: number): ConversationNode | undefined => {
const cached = nodeCache.get(seq)
if (cached !== undefined) return cached
const event = padded[seq]
const event = eventsBySeq.get(seq)
if (event === undefined || !isSurfaceEligibleType(event.type)) return
const node = materializeNode(
event,
@@ -425,7 +450,7 @@ export function projectConversationHistory(
}]
} else {
try {
contexts = foldContexts(padded).map((context): ConversationContext => {
contexts = foldContexts(events).map((context): ConversationContext => {
const nodes = context.nodes.flatMap((seq) => {
const node = materialize(seq)
return node === undefined ? [] : [node]
@@ -438,7 +463,7 @@ export function projectConversationHistory(
nodes,
}
}
const originEvent = padded[context.originSeq]
const originEvent = eventsBySeq.get(context.originSeq)
return {
id: context.generation,
parentId: context.generation - 1,

View File

@@ -6,7 +6,9 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
SessionHistoryFace, SessionHistorySnapshot,
} from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts'
import {
compactHistoryInspectionEntries, createHistoryInspection,
} from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
@@ -18,7 +20,8 @@ function isAborted(signal: AbortSignal | undefined): boolean {
/** Independent raw-history owner used only by inspection consumers. */
export class SessionHistorySource implements SessionHistoryFace {
private entries: readonly HistoryEntry[] = []
private entries: HistoryEntry[] = []
private inspectionEntries: readonly HistoryEntry[] = []
private baseSeq = 0
private hasMore = false
private state: SessionHistorySnapshot['state'] = 'cold'
@@ -36,7 +39,6 @@ export class SessionHistorySource implements SessionHistoryFace {
value: SessionHistorySnapshot['inspection']
} | null = null
private streamPublishToken: object | null = null
private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
private streamPartial: PartialAccumulator | null = null
private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => {
@@ -73,37 +75,29 @@ export class SessionHistorySource implements SessionHistoryFace {
}
/**
* Load the tail and exhaust all available older pages.
* Load the current tail without reading older pages.
* @param signal - Consumer lifetime.
* @returns When paging completes, fails to advance, or is aborted.
* @returns When the tail is ready or loading fails.
*/
async loadAll(signal?: AbortSignal): Promise<void> {
if (signal?.aborted === true) return
async loadTail(signal?: AbortSignal): Promise<void> {
if (isAborted(signal)) return
this.trackConsumer(signal)
await this.open()
while (
!isAborted(signal)
&& this.state === 'ready'
&& this.hasMore
) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
}
}
/** Rebuild and page for whichever mounted consumers survive a reconnect. */
private async loadForConsumers(): Promise<void> {
/**
* Prepend one older page when the current window has a predecessor.
* @param signal - Consumer lifetime.
* @returns Whether the loaded window advanced.
*/
async loadOlder(signal?: AbortSignal): Promise<boolean> {
if (isAborted(signal)) return false
this.trackConsumer(signal)
await this.open()
while (
this.hasConsumer()
&& this.state === 'ready'
&& this.hasMore
) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return
}
if (isAborted(signal)) return false
const previousBaseSeq = this.baseSeq
await this.loadOlderPage()
return this.baseSeq !== previousBaseSeq
}
/**
@@ -144,12 +138,13 @@ export class SessionHistorySource implements SessionHistoryFace {
this.liveBuffer = []
this.subscribedLastSeq = null
this.entries = []
this.inspectionEntries = []
this.baseSeq = 0
this.hasMore = false
this.state = 'cold'
this.error = null
this.publishDirtyNow()
void this.loadForConsumers()
void this.open()
}
/** Stop future refresh work after the host removes the session. */
@@ -161,7 +156,6 @@ export class SessionHistorySource implements SessionHistoryFace {
this.olderPromise = null
this.liveBuffer = []
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
}
@@ -234,7 +228,7 @@ export class SessionHistorySource implements SessionHistoryFace {
}
}
private loadOlder(): Promise<void> {
private loadOlderPage(): Promise<void> {
if (this.olderPromise !== null) return this.olderPromise
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
const generation = this.generation
@@ -260,6 +254,7 @@ export class SessionHistorySource implements SessionHistoryFace {
return
}
this.entries = [...older, ...this.entries]
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
} catch (error) {
@@ -291,6 +286,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.entries = [...prefix, ...tail]
}
this.baseSeq = this.entries[0]?.event.seq ?? 0
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
const buffered = this.liveBuffer
this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry)
@@ -324,7 +320,11 @@ export class SessionHistorySource implements SessionHistoryFace {
private appendLive(entry: HistoryEntry): void {
const tailSeq = this.tailSeq()
if (tailSeq !== null && entry.event.seq <= tailSeq) return
this.entries = [...this.entries, entry]
this.entries.push(entry)
this.inspectionEntries = [...this.inspectionEntries, entry]
if (entry.event.type === 'assistant/message') {
this.inspectionEntries = compactHistoryInspectionEntries(this.inspectionEntries)
}
}
/** Append a chunk against the cached finalized projection; false means no visible publish. */
@@ -336,11 +336,10 @@ export class SessionHistorySource implements SessionHistoryFace {
if (!isVisibleAssistantChunk(chunk.type)) {
const inspection = this.currentInspection()
this.appendLive(entry)
this.inspectionCache = { entries: this.entries, value: inspection }
this.inspectionCache = { entries: this.inspectionEntries, value: inspection }
return false
}
const base = this.streamBaseInspection ?? this.currentInspection()
this.streamBaseInspection = base
const base = this.currentInspection()
if (
this.streamPartial === null
|| this.streamPartial.turn !== turn
@@ -356,7 +355,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.streamPartial.push(chunk)
this.appendLive(entry)
this.inspectionCache = {
entries: this.entries,
entries: this.inspectionEntries,
value: { ...base, partial: this.streamPartial.toPartial() },
}
return true
@@ -382,7 +381,6 @@ export class SessionHistorySource implements SessionHistoryFace {
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
private publishDirtyNow(): void {
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
this.notifier.markDirty()
}
@@ -415,14 +413,15 @@ export class SessionHistorySource implements SessionHistoryFace {
state: this.state,
error: this.error,
hasMore: this.hasMore,
baseSeq: this.baseSeq,
inspection: this.currentInspection(),
}
}
/** Inspection pinned to the source's current immutable entry array. */
private currentInspection(): SessionHistorySnapshot['inspection'] {
if (this.inspectionCache?.entries !== this.entries) {
const entries = this.entries
if (this.inspectionCache?.entries !== this.inspectionEntries) {
const entries = this.inspectionEntries
this.inspectionCache = {
entries,
value: createHistoryInspection(() => entries),

View File

@@ -7,6 +7,24 @@ import type { ConversationContext } from './conversation-context.ts'
import { projectConversationHistory } from '../session-history/history-fold.ts'
import { inspectRequests, type RequestView } from './request-inspection.ts'
function assistantStepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function isFirstTokenCandidate(entry: HistoryEntry): boolean {
const event = entry.event
if (event.type !== 'assistant/chunk') return false
switch (event.data.chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return event.data.chunk.text !== ''
case 'tool-call-delta':
return event.data.chunk.argumentsDelta !== '' || event.data.chunk.name !== undefined
default:
return false
}
}
/** Lazily derived inspection data for one immutable session-history window. */
export interface SessionHistoryInspection {
eventNodes: readonly ConversationNode[]
@@ -19,6 +37,47 @@ export interface SessionHistoryInspection {
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}
/**
* Remove completed-step token payloads that no inspection projection reads.
* The first visible token preserves timing, usage chunks preserve accounting,
* and unfinished steps retain every chunk for live or interrupted content.
* @param entries - Contiguous raw history entries in sequence order.
* @returns A projection-equivalent, usually much smaller entry ledger.
*/
export function compactHistoryInspectionEntries(
entries: readonly HistoryEntry[],
): readonly HistoryEntry[] {
const completedSteps = new Set<string>()
for (const { event } of entries) {
if (event.type === 'assistant/message') {
completedSteps.add(assistantStepKey(event.data.turn, event.data.step))
}
}
const firstTokenSteps = new Set<string>()
const compacted: HistoryEntry[] = []
let changed = false
for (const entry of entries) {
const event = entry.event
if (event.type !== 'assistant/chunk') {
compacted.push(entry)
continue
}
const key = assistantStepKey(event.data.turn, event.data.step)
if (!completedSteps.has(key) || event.data.chunk.type === 'usage') {
compacted.push(entry)
continue
}
if (isFirstTokenCandidate(entry) && !firstTokenSteps.has(key)) {
firstTokenSteps.add(key)
compacted.push(entry)
} else {
changed = true
}
}
return changed ? compacted : entries
}
/**
* Create a lazy inspection projection over an immutable history window.
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots

View File

@@ -94,7 +94,8 @@ export interface RequestInspectionSnapshot {
/**
* Derive the request-centric read model from one immutable history window.
* Compaction participates as a request purpose rather than a parallel
* top-level collection.
* top-level collection. A leading resume/change header exposes its prompt but
* cannot project a change until the preceding header enters the window.
* @param entries - Contiguous raw session history.
* @returns Requests and call-time schemas derived from that history.
*/
@@ -218,6 +219,7 @@ function promptChange(
prompt: ConversationPromptSnapshot,
event: SessionEvent<'request/header'>,
): RequestPromptChange | undefined {
if (previous === undefined && event.data.reason !== 'initial') return
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)