refactor(schedule): keep reminder delivery conversational

This commit is contained in:
Tianyi Cui
2026-08-09 15:22:53 +08:00
parent 2f3e8974ec
commit 36ef892559
134 changed files with 598 additions and 3139 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, PresentedEventNode, QueuedMessage,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
@@ -252,23 +252,6 @@ export interface CommandNode {
} | null
}
/**
* Host-computed presentation for one durable non-surface event. The generic
* runtime carries the durable event type and JSON-compatible payload without
* importing the producing domain; a client plugin owns the keyed renderer.
*/
export interface PresentedEventNode {
kind: 'presented-event'
/** Seq of the durable event whose sidecar produced this node. */
seq: number
/** Unix epoch ms from the source Session event. */
time: number
/** Durable event type selecting an optional domain renderer. */
eventType: string
/** Domain-owned JSON-compatible presentation payload. */
view: unknown
}
/** Finalized conversation node union (kind discriminates; seq is the React key). */
export type ConversationNode =
| UserMessageNode
@@ -279,7 +262,6 @@ export type ConversationNode =
| TurnErrorNode
| ToolResultNode
| CommandNode
| PresentedEventNode
| CompactionSummaryNode
| UnknownSurfaceNode
@@ -6,7 +6,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
RpcId, RpcResponse, RpcResult, SessionEventView, SessionId, SubagentAddress,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -74,36 +74,6 @@ function queueTextOf(content: readonly ContentBlock[]): string | null {
return content.map(block => block.text).join('')
}
/** Browser-safe structural equality for JSON-compatible wire values. */
function sameWireValue(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true
if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') return false
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false
return left.every((value, index) => sameWireValue(value, right[index]))
}
const leftRecord = left as Record<string, unknown>
const rightRecord = right as Record<string, unknown>
const leftKeys = Object.keys(leftRecord).sort()
const rightKeys = Object.keys(rightRecord).sort()
return leftKeys.length === rightKeys.length
&& leftKeys.every((key, index) =>
key === rightKeys[index] && sameWireValue(leftRecord[key], rightRecord[key]))
}
/** Same-seq deliveries may add a sidecar, but must carry the identical durable event. */
function assertSameEvent(left: SessionEvent, right: SessionEvent): void {
if (!sameWireValue(left, right)) {
throw new Error(`session event identity mismatch at seq ${left.seq}`)
}
}
/** One in-flight older-page request and the late sidecars that may belong to its result. */
interface OlderPageLoad {
readonly beforeSeq: number
readonly views: Map<number, { event: SessionEvent; view: SessionEventView }>
}
/**
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer. Features see only
@@ -115,7 +85,7 @@ export class Session implements SessionFace {
private events: SessionEvent[] = []
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
* Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */
private views: (SessionEventView | undefined)[] = []
private views: (ToolEventView | undefined)[] = []
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
@@ -125,7 +95,7 @@ export class Session implements SessionFace {
* a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen
* passes drop all writes once the generation moves on. */
private openGeneration = 0
private loadingOlder: OlderPageLoad | null = null
private loadingOlder = false
private readonly transcript = new TranscriptAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map<string, RunningToolCall>()
@@ -177,7 +147,7 @@ export class Session implements SessionFace {
private promptError: PromptError | null = null
private lastAgentError: string | null = null
/** Live events buffered during open/resync and stitched by sequence once history lands. */
private liveBuffer: { event: SessionEvent; view: SessionEventView | undefined }[] = []
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
/** Gap repair in flight; live events detour to the buffer until the tail page lands. */
private stitching = false
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
@@ -396,16 +366,11 @@ export class Session implements SessionFace {
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
async loadOlder(): Promise<void> {
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder !== null) return
const loading: OlderPageLoad = { beforeSeq: this.baseSeq, views: new Map() }
this.loadingOlder = loading
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
this.loadingOlder = true
this.notifier.markDirty()
try {
const { result } = await this.history({ beforeSeq: loading.beforeSeq, maxMessages: PAGE_MESSAGES })
if (this.loadingOlder !== loading) return
// A concurrent gap repair may replace the window with a newer tail page.
// The captured older page no longer adjoins that window and must be dropped.
if (this.baseSeq !== loading.beforeSeq) return
const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
const older = result.value.events
if (older.length === 0) {
@@ -413,42 +378,24 @@ export class Session implements SessionFace {
return
}
const tail = older[older.length - 1]
if (tail === undefined || tail.event.seq + 1 !== loading.beforeSeq) {
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${loading.beforeSeq}`)
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`)
this.hasMore = false
return
}
let settled: HistoryEntry[]
try {
settled = older.map((entry): HistoryEntry => {
const late = loading.views.get(entry.event.seq)
if (late === undefined) return entry
assertSameEvent(entry.event, late.event)
return { ...entry, view: late.view }
})
} catch (error) {
console.error('[web-runtime] older-page session event failed identity validation:', error)
void this.resync()
return
}
this.events = [...settled.map(entry => entry.event), ...this.events]
this.views = [...settled.map(entry => entry.view), ...this.views]
/* v8 ignore next -- the empty-page branch returned above. */
this.events = [...older.map(e => e.event), ...this.events]
this.views = [...older.map(e => e.view), ...this.views]
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
this.transcript.reset(this.events, this.views)
this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head)
this.rebuildDerivedFromWindow()
} catch (error) {
if (this.loadingOlder === loading) {
console.error('[web-runtime] loadOlder failed:', error)
}
console.error('[web-runtime] loadOlder failed:', error)
} finally {
if (this.loadingOlder === loading) {
this.loadingOlder = null
if (this.liveBuffer.length > 0) void this.repairGap()
this.notifier.markDirty()
}
this.loadingOlder = false
this.notifier.markDirty()
}
}
@@ -476,8 +423,6 @@ export class Session implements SessionFace {
this.pendingRev++
this.subscribedLastSeq = null
this.liveBuffer = []
this.loadingOlder = null
this.stitching = false
this.notifier.markDirty()
await this.open()
}
@@ -682,9 +627,7 @@ export class Session implements SessionFace {
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
const { hasGap } = this.mergeWindow()
this.openState = 'open'
if (hasGap) void this.repairGap()
} catch (error) {
if (generation !== this.openGeneration) return
this.openState = 'error'
@@ -696,137 +639,29 @@ export class Session implements SessionFace {
}
}
/**
* Install one history window and settle every buffered overlap or safe
* contiguous suffix through {@link mergeWindow}. A carried projections
* block seeds the value store (higher seq wins, so a stale baseline cannot
* overwrite a newer push frame); the window events themselves are never
* folded — the host is the only computation site.
*/
private installWindow(
entries: HistoryEntry[],
hasMore: boolean,
projections?: ProjectionsBaseline,
): { changed: boolean; hasGap: boolean } {
const merged = this.mergeWindow(entries)
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
* (doOpen flips it after install), so recursing would push every buffered event straight
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1).
* A carried projections block seeds the value store (higher seq wins, so a stale
* baseline cannot overwrite a newer push frame); the window events themselves are
* never folded — the host is the only computation site. */
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
this.transcript.reset(this.events, this.views)
this.rebuildDerivedFromWindow()
if (projections !== undefined) this.projections.seed(projections)
const buffered = this.liveBuffer
this.liveBuffer = []
for (const item of buffered) this.appendLive(item.event, item.view)
this.notifier.markDirty()
return merged
}
/**
* Reconcile a history snapshot (when supplied), the current window, and
* buffered live deliveries by seq. Same-seq events must be identical;
* defined late sidecars upgrade but an absent sidecar never erases an
* existing one. Only the contiguous suffix joins the window, leaving a real
* gap buffered for the existing repair path.
* @param entries - replacement/prepended history window, or undefined to
* settle the current window after an RPC failure or empty page.
* @returns whether the visible window changed and whether a true gap remains.
*/
private mergeWindow(entries?: readonly HistoryEntry[]): { changed: boolean; hasGap: boolean } {
const current = new Map<number, { event: SessionEvent; view: SessionEventView | undefined }>()
for (let index = 0; index < this.events.length; index++) {
const event = this.events[index]
/* v8 ignore next -- dense-array guard: index stays within events.length. */
if (event !== undefined) current.set(event.seq, { event, view: this.views[index] })
}
const events: SessionEvent[] = []
const views: (SessionEventView | undefined)[] = []
if (entries === undefined) {
events.push(...this.events)
views.push(...this.views)
} else {
let previousSeq: number | undefined
for (const entry of entries) {
if (previousSeq !== undefined && entry.event.seq !== previousSeq + 1) {
throw new Error(`history window is not contiguous at seq ${entry.event.seq}`)
}
previousSeq = entry.event.seq
const retained = current.get(entry.event.seq)
if (retained !== undefined) assertSameEvent(retained.event, entry.event)
events.push(entry.event)
views.push(entry.view ?? retained?.view)
}
}
const buffered = new Map<number, { event: SessionEvent; view: SessionEventView | undefined }>()
for (const item of this.liveBuffer) {
const retained = buffered.get(item.event.seq)
if (retained !== undefined) {
assertSameEvent(retained.event, item.event)
if (item.view !== undefined) retained.view = item.view
} else {
buffered.set(item.event.seq, { ...item })
}
}
const bySeq = new Map<number, number>()
for (let index = 0; index < events.length; index++) {
const event = events[index]
/* v8 ignore next -- dense-array guard: index stays within events.length. */
if (event !== undefined) bySeq.set(event.seq, index)
}
const consumed = new Set<number>()
let viewChanged = false
const baseSeq = events[0]?.seq
const tailSeq = events.at(-1)?.seq
for (const [seq, item] of buffered) {
const index = bySeq.get(seq)
if (index !== undefined) {
const event = events[index]
/* v8 ignore next -- bySeq indexes the dense events array. */
if (event === undefined) continue
assertSameEvent(event, item.event)
if (item.view !== undefined && !sameWireValue(views[index], item.view)) {
views[index] = item.view
viewChanged = true
}
consumed.add(seq)
continue
}
// A replay older than the retained tail window is irrelevant to this
// page and cannot become a future suffix.
if (baseSeq !== undefined && seq < baseSeq) {
consumed.add(seq)
continue
}
if (tailSeq !== undefined && seq <= tailSeq) {
throw new Error(`history window is missing buffered seq ${seq}`)
}
}
const appended: SessionEvent[] = []
let expectedSeq = tailSeq === undefined ? 0 : tailSeq + 1
for (let item = buffered.get(expectedSeq); item !== undefined; item = buffered.get(++expectedSeq)) {
events.push(item.event)
views.push(item.view)
appended.push(item.event)
consumed.add(expectedSeq)
}
const remaining = [...buffered.entries()]
.filter(([seq]) => !consumed.has(seq))
.sort(([left], [right]) => left - right)
.map(([, item]) => item)
this.liveBuffer = remaining
const changed = entries !== undefined || viewChanged || appended.length > 0
if (changed) {
this.events = events
this.views = views
this.baseSeq = events[0]?.seq ?? 0
this.transcript.reset(events, views)
this.rebuildDerivedFromWindow()
for (const event of appended) this.handoffPendingSteering(event)
}
return { changed, hasGap: remaining.length > 0 }
}
/** Seq-guarded append shared by stitching and the open-state live path. */
private appendLive(event: SessionEvent, view?: SessionEventView): void {
private appendLive(event: SessionEvent, view?: ToolEventView): void {
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
this.events.push(event)
@@ -836,21 +671,6 @@ export class Session implements SessionFace {
this.applyEventSideEffects(event, view)
}
/** Verify one retained event and apply a defined late sidecar immediately. */
private upgradeLiveView(event: SessionEvent, view?: SessionEventView): boolean {
const index = this.events.findIndex(candidate => candidate.seq === event.seq)
if (index === -1) return false
const retained = this.events[index]
/* v8 ignore next -- findIndex returned a dense-array position. */
if (retained === undefined) return false
assertSameEvent(retained, event)
if (view === undefined || sameWireValue(this.views[index], view)) return false
this.views[index] = view
this.transcript.reset(this.events, this.views)
this.rebuildDerivedFromWindow()
return true
}
/** Retire the first matching live steering occurrence when its durable message takes over. */
private handoffPendingSteering(event: SessionEvent): void {
if (event.type !== 'user/message') return
@@ -862,52 +682,21 @@ export class Session implements SessionFace {
this.queueRev++
}
/** Land a live session/event (open/repair in flight -> buffer; retained overlap -> validate
* and upgrade; an overlap below the window waits only for its in-flight older page). A seq gap
* buffers and repulls the tail instead of appending a hole (audit S3: a gap is an expected
* reconnect-window artifact, repaired by refetch). The window stays one contiguous raw range,
* which lets the transcript render every event between its ends and a compaction checkpoint
* find its cited summary event. */
private acceptLiveEvent(event: SessionEvent, view?: SessionEventView): void {
const loading = this.loadingOlder
if (loading !== null && view !== undefined && event.seq < loading.beforeSeq) {
try {
const retained = loading.views.get(event.seq)
if (retained !== undefined) assertSameEvent(retained.event, event)
loading.views.set(event.seq, { event, view })
} catch (error) {
console.error('[web-runtime] older-page late session event failed identity validation:', error)
void this.resync()
}
return
}
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
* expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
* raw range, which is what lets the transcript render every event between its ends and lets a
* compaction checkpoint find its cited summary event. */
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
if (this.openState === 'loading' || this.stitching) {
this.liveBuffer.push({ event, view })
return
}
if (this.openState !== 'open') return // cold/error: no window upkeep (history fully backfills on open)
const tailSeq = this.windowTailSeq()
if (tailSeq !== null && event.seq <= tailSeq) {
try {
if (event.seq < this.baseSeq) {
return
}
const changed = this.upgradeLiveView(event, view)
if (changed) this.notifier.markDirty()
} catch (error) {
console.error('[web-runtime] duplicate session event failed identity validation:', error)
void this.resync()
}
return
}
if (tailSeq !== null && event.seq > tailSeq + 1) {
this.liveBuffer.push({ event, view })
if (this.loadingOlder === null) void this.repairGap()
return
}
if (tailSeq === null && event.seq !== 0) {
this.liveBuffer.push({ event, view })
if (this.loadingOlder === null) void this.repairGap()
void this.repairGap()
return
}
this.appendLive(event, view)
@@ -926,54 +715,22 @@ export class Session implements SessionFace {
if (this.stitching) return
this.stitching = true
const generation = this.openGeneration
let retryGap = false
let acceptedHistory = false
try {
const { result } = await this.history({ maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration || this.openState !== 'open') return
if (result.ok) {
acceptedHistory = true
const previousTail = this.windowTailSeq()
const { hasGap } = this.installWindow(
result.value.events,
result.value.hasMore,
result.value.projections,
)
const repairedTail = this.windowTailSeq()
retryGap = hasGap && repairedTail !== null
&& (previousTail === null || repairedTail > previousTail)
} else {
// Keep buffered events for the next live frame or reconnect; retrying
// immediately would spin against the same unavailable history endpoint.
this.mergeWindow()
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
} catch (error) {
if (generation === this.openGeneration) {
if (acceptedHistory) {
console.error('[web-runtime] gap repair snapshot failed validation:', error)
void this.resync()
return
}
console.error('[web-runtime] gap repair failed:', error)
try {
this.mergeWindow()
} catch (mergeError) {
console.error('[web-runtime] gap repair buffer merge failed:', mergeError)
void this.resync()
}
}
console.error('[web-runtime] gap repair failed:', error)
} finally {
if (generation === this.openGeneration) {
this.stitching = false
this.notifier.markDirty()
if (retryGap) void this.repairGap()
}
this.stitching = false
}
}
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk/retry projection and openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: SessionEventView): void {
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
const eventType = event.type as string
if (eventType === 'llm/retry') {
const data = parseRetryEventData(event.data)
@@ -1209,7 +966,7 @@ export class Session implements SessionFace {
openState: this.openState,
openError: this.openError,
hasMore: this.hasMore,
loadingOlder: this.loadingOlder !== null,
loadingOlder: this.loadingOlder,
promptError: this.promptError,
blank: this.blankBit,
lastAgentError: this.lastAgentError,
@@ -19,9 +19,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
// `sessions: ISessions` (TS2717, the one-program-per-side rule in
// docs/development.md).
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
import type {
PresentedEventView, SessionEventView, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
import { contextForm, contextProvenance } from './context-provenance.ts'
@@ -50,7 +48,7 @@ interface CallIndexEntry {
callView: ToolCallView | null
}
/** One ordinary surface event -> UI node. */
/** One event -> UI node (pure function; the ten-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
@@ -119,17 +117,6 @@ function materializeNode(
}
}
/** One host-presented non-surface event -> generic keyed conversation node. */
function materializePresented(event: SessionEvent, sidecar: PresentedEventView): ConversationNode {
return {
kind: 'presented-event',
seq: event.seq,
time: event.time,
eventType: event.type,
view: sidecar.view,
}
}
/**
* Whether an event is a landed compaction checkpoint — all three conditions,
* matching the terminal's `isCompactCheckpoint`: a `user/message`, carrying the
@@ -268,7 +255,7 @@ export class TranscriptAdapter {
* @param events - the new window contents (seq-ascending).
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
*/
reset(events: readonly SessionEvent[], views?: readonly (SessionEventView | undefined)[]): void {
reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void {
this.rev++
this.eventIndex = new Map()
this.callIdx = new Map()
@@ -290,13 +277,8 @@ export class TranscriptAdapter {
// Indexes first, then project: a tool/result materializes against the
// complete call index, and a checkpoint against the complete event index.
const projected: ConversationNode[] = []
for (let index = 0; index < events.length; index++) {
const event = events[index]
/* v8 ignore next -- dense-array guard: index stays within events.length. */
if (event === undefined) continue
const view = views?.[index]
if (view?.for === 'event') projected.push(materializePresented(event, view))
else if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq)))
for (const event of events) {
if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq)))
}
this.projected = projected
}
@@ -310,17 +292,12 @@ export class TranscriptAdapter {
* @param event - the live event (seq = window tail + 1).
* @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
*/
append(event: SessionEvent, view?: SessionEventView): void {
append(event: SessionEvent, view?: ToolEventView): void {
this.eventIndex.set(event.seq, event)
this.indexCall(event, view)
const steering = this.steeringHistory.apply(event)
indexAssistantStepTiming(this.stepTimings, event)
if (this.indexCommand(event)) this.rev++
if (view?.for === 'event') {
this.projected = [...this.projected, materializePresented(event, view)]
this.rev++
return
}
if (!isTranscriptEvent(event)) return
this.projected = [...this.projected, this.materialize(event, steering)]
this.rev++
@@ -415,7 +392,7 @@ export class TranscriptAdapter {
return true
}
private indexCall(event: SessionEvent, view?: SessionEventView): void {
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
return