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

View File

@@ -40,8 +40,6 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's cited `compact/summary` event; a window cut that left that event outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
A Host may redeliver the same Session event seq with a new or changed non-persistent view after the event reaches its presentation commit point. `Session` first requires deep event identity, then upgrades only the sidecar; a generic event view becomes one `PresentedEventNode` keyed by the durable event type. Tail loading and true gap repair continue to use the existing `liveBuffer`; repair continues while each accepted snapshot advances the tail and a buffered gap remains, while an identity-conflicting snapshot triggers a full resync. Ordinary `loadOlder` leaves live-tail appends in the current window and prepends its page after the await, while an overlapping late sidecar upgrades immediately. Reconnect advances the generation and clears page or repair ownership, so an older request's result or `finally` cannot mutate or block the rebuilt window.
## Request inspection
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.

View File

@@ -40,8 +40,6 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点引用的 `compact/summary` 事件;窗口切分把该事件留在窗口外时这些字段不可用,后续包含该事件的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约定:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
一个 Session event 到达其 presentation 提交点后Host 可以用同一 seq 重新投递完全相同的事件,并携带新增或变化的非持久 view。`Session` 会先要求事件深度一致,再只升级 sidecar通用 event view 会按持久事件类型形成一个 `PresentedEventNode``liveBuffer` 仍只用于尾部加载与真正的 gap repair每当已接受的快照推进 tail 后仍留有已缓冲的 gaprepair 就会继续;身份冲突的快照则会触发全量重新同步。普通 `loadOlder` 会将 live-tail 追加项留在当前窗口中,并在 await 后前插所取页面;重叠的迟到 sidecar 则会立即升级。重连会推进 generation 并清除 pagerepair 的所有权,因此旧请求的结果或 `finally` 既不能改写,也不能阻塞重建后的窗口。
## 请求检查
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn``step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。

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'

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -33,22 +33,6 @@ function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
function logRange(start: number, end: number, label = 'fixture/log'): SessionEvent[] {
return Array.from({ length: end - start }, (_value, offset) =>
at(start + offset, { type: label, data: { index: start + offset } }))
}
function reminderEvent(seq: number, id: string): SessionEvent {
return at(seq, { type: 'schedule/change', data: { version: 1, operation: 'dispatch', id } })
}
function reminderView(id: string, prompt = '检查日志') {
return {
for: 'event' as const,
view: { id, prompt },
}
}
describe('open', () => {
it('installs the tail page: cold → loading → open with window and nodes in place', async () => {
const { api, session } = makeSession()
@@ -98,9 +82,9 @@ describe('open', () => {
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const opening = session.open()
const page = plainTurn(10, 0, '早', '安')
// Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15).
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: page[5]! })
const page = plainTurn(10, 0, '早', '安')
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) })
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') })
gate.resolve(ok({
events: entries(page) as never[],
@@ -114,284 +98,6 @@ describe('open', () => {
})
})
describe('late event views', () => {
it('upgrades an already-open raw event without duplicating it or letting an absent sidecar erase it', async () => {
const { api, session } = makeSession()
const event = reminderEvent(0, 'schedule-1')
api.onHistory = () => histResponse([event])
await session.open()
expect(session.getSnapshot().nodes).toEqual([])
session.handleMuxEnvelope('rv1' as never, {
type: 'session/event', sessionId: SID, event, view: reminderView('schedule-1'),
})
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 0, eventType: 'schedule/change',
view: { id: 'schedule-1', prompt: '检查日志' },
}])
session.handleMuxEnvelope('rv2' as never, {
type: 'session/event', sessionId: SID, event,
})
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', view: { prompt: '检查日志' },
}])
session.handleMuxEnvelope('rv3' as never, {
type: 'session/event', sessionId: SID, event, view: reminderView('schedule-1', '检查发布'),
})
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', view: { prompt: '检查发布' },
}])
})
it('merges a view delivered while the tail history is loading', async () => {
const { api, session } = makeSession()
const event = reminderEvent(0, 'schedule-loading')
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const opening = session.open()
session.handleMuxEnvelope('rv' as never, {
type: 'session/event', sessionId: SID, event, view: reminderView('schedule-loading'),
})
gate.resolve(ok({ events: [{ event }] as never[], hasMore: false }))
await opening
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 0, view: { id: 'schedule-loading' },
}])
})
it('merges a late view buffered behind a gap repair snapshot', async () => {
const { api, session } = makeSession()
const first = logRange(0, 6)
api.onHistory = () => histResponse(first)
await session.open()
const due = reminderEvent(9, 'schedule-gap')
const full = [...first, ...logRange(6, 9), due]
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
session.handleMuxEnvelope('raw' as never, {
type: 'session/event', sessionId: SID, event: due,
})
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: due, view: reminderView('schedule-gap'),
})
gate.resolve(ok({ events: entries(full) as never[], hasMore: false }))
await vi.waitFor(() => {
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 9, view: { id: 'schedule-gap' },
}])
})
})
it('upgrades a retained view during loadOlder and preserves it across prepend', async () => {
const { api, session } = makeSession()
const target = reminderEvent(9, 'schedule-loading-older')
const newer = [...logRange(6, 9), target, ...logRange(10, 12)]
api.onHistory = () => histResponse(newer, true)
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const loading = session.loadOlder()
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: target,
view: reminderView('schedule-loading-older'),
})
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: target.seq,
view: { id: 'schedule-loading-older' },
}])
gate.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
await loading
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: target.seq,
view: { id: 'schedule-loading-older' },
}])
})
it('keeps a late view for the raw event returned by an in-flight older page', async () => {
const { api, session } = makeSession()
const target = reminderEvent(3, 'schedule-older-page')
const older = [...logRange(0, 3), target, ...logRange(4, 6)]
api.onHistory = () => histResponse(logRange(6, 12), true)
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const loading = session.loadOlder()
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: target,
view: reminderView('schedule-older-page'),
})
session.handleMuxEnvelope('later' as never, {
type: 'session/event', sessionId: SID, event: target,
view: reminderView('schedule-older-page', '检查更新'),
})
expect(session.getSnapshot().nodes).toEqual([])
gate.resolve(ok({ events: entries(older) as never[], hasMore: false }))
await loading
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: target.seq,
view: { id: 'schedule-older-page', prompt: '检查更新' },
}])
})
it('keeps an older-page late view while a gap repair is also in flight', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(logRange(6, 12), true)
await session.open()
const repair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const page = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = payload => payload.beforeSeq === undefined ? repair.promise : page.promise
const gapTail = ev.user(15, '修复后的尾部')
session.handleMuxEnvelope('gap' as never, {
type: 'session/event', sessionId: SID, event: gapTail,
})
await vi.waitFor(() => {
expect(api.callsOf('session.history')).toHaveLength(2)
})
const loading = session.loadOlder()
const target = reminderEvent(3, 'schedule-overlapping-repairs')
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: target,
view: reminderView('schedule-overlapping-repairs'),
})
repair.resolve(ok({
events: entries([...logRange(6, 15), gapTail]) as never[],
hasMore: true,
}))
await vi.waitFor(() => {
expect(session.getSnapshot().nodes).toMatchObject([{ kind: 'user', seq: 15 }])
})
page.resolve(ok({
events: entries([...logRange(0, 3), target, ...logRange(4, 6)]) as never[],
hasMore: false,
}))
await loading
expect(session.getSnapshot().nodes).toMatchObject([
{
kind: 'presented-event', seq: target.seq,
view: { id: 'schedule-overlapping-repairs' },
},
{ kind: 'user', seq: 15 },
])
})
it('drops an older page after a concurrent gap repair advances the window base', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(logRange(50, 100), true)
await session.open()
const repair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const page = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = payload => payload.beforeSeq === undefined ? repair.promise : page.promise
const gapTail = ev.user(200, '修复后的新窗口')
session.handleMuxEnvelope('gap' as never, {
type: 'session/event', sessionId: SID, event: gapTail,
})
await vi.waitFor(() => {
expect(api.callsOf('session.history')).toHaveLength(2)
})
const loading = session.loadOlder()
repair.resolve(ok({
events: entries([...logRange(150, 200), gapTail]) as never[],
hasMore: true,
}))
await vi.waitFor(() => {
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([200])
})
page.resolve(ok({
events: entries([...logRange(0, 44), ...plainTurn(44, 0, '陈旧问题', '陈旧回答')]) as never[],
hasMore: false,
}))
await loading
expect(session.getSnapshot()).toMatchObject({ hasMore: true })
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([200])
})
it('resyncs when repeated older-page late views disagree on event identity', async () => {
const { api, session } = makeSession()
const newer = logRange(6, 12)
api.onHistory = () => histResponse(newer, true)
await session.open()
const page = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => page.promise
const loading = session.loadOlder()
const delivered = reminderEvent(3, 'schedule-delivered')
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: delivered,
view: reminderView('schedule-delivered'),
})
api.onHistory = () => histResponse(newer)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
session.handleMuxEnvelope('drifted' as never, {
type: 'session/event', sessionId: SID, event: reminderEvent(3, 'schedule-drifted'),
view: reminderView('schedule-drifted'),
})
await vi.waitFor(() => {
expect(api.callsOf('session.history')).toHaveLength(3)
expect(session.getSnapshot().openState).toBe('open')
})
expect(errorSpy).toHaveBeenCalledWith(
'[web-runtime] older-page late session event failed identity validation:',
expect.objectContaining({ message: 'session event identity mismatch at seq 3' }),
)
} finally {
errorSpy.mockRestore()
page.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
await loading
}
})
it('resyncs when an older page disagrees with its buffered late event identity', async () => {
const { api, session } = makeSession()
const newer = logRange(6, 12)
const pageEvent = reminderEvent(3, 'schedule-page')
const delivered = reminderEvent(3, 'schedule-delivered')
const older = [...logRange(0, 3), pageEvent, ...logRange(4, 6)]
api.onHistory = () => histResponse(newer, true)
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const loading = session.loadOlder()
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: delivered,
view: reminderView('schedule-delivered'),
})
api.onHistory = () => histResponse(newer)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
gate.resolve(ok({ events: entries(older) as never[], hasMore: false }))
await loading
await vi.waitFor(() => {
expect(api.callsOf('session.history')).toHaveLength(3)
expect(session.getSnapshot().openState).toBe('open')
})
expect(errorSpy).toHaveBeenCalledWith(
'[web-runtime] older-page session event failed identity validation:',
expect.objectContaining({ message: 'session event identity mismatch at seq 3' }),
)
} finally {
errorSpy.mockRestore()
}
})
})
describe('live event path', () => {
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
@@ -842,12 +548,11 @@ describe('live event path', () => {
})
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
const first = plainTurn(0, 0, 'a', 'b')
const { api, session } = await opened(first) // tail seq = 5
const repaired = [...first, ...plainTurn(6, 1, 'c', 'd')]
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
api.onHistory = () => histResponse(repaired)
// seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires.
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: repaired[9]! })
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(9, 1, 'd') })
await vi.waitFor(() => {
expect(api.callsOf('session.history').length).toBe(2)
})
@@ -855,64 +560,6 @@ describe('live event path', () => {
const seqs = session.getSnapshot().nodes.map(n => n.seq)
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
})
it('continues repair when one tail snapshot leaves a later buffered gap', async () => {
const initial = logRange(0, 6)
const firstGap = ev.user(9, 'first repaired event')
const laterGap = ev.user(12, 'later buffered event')
const firstSnapshot = [...initial, ...logRange(6, 9), firstGap]
const completeSnapshot = [...firstSnapshot, ...logRange(10, 12), laterGap]
const firstRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const secondRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const { api, session } = await opened(initial)
let repairs = 0
api.onHistory = () => ++repairs === 1 ? firstRepair.promise : secondRepair.promise
session.handleMuxEnvelope('first-gap' as never, {
type: 'session/event', sessionId: SID, event: firstGap,
})
session.handleMuxEnvelope('later-gap' as never, {
type: 'session/event', sessionId: SID, event: laterGap,
})
firstRepair.resolve(ok({ events: entries(firstSnapshot) as never[], hasMore: false }))
await vi.waitFor(() => { expect(repairs).toBe(2) })
secondRepair.resolve(ok({ events: entries(completeSnapshot) as never[], hasMore: false }))
await vi.waitFor(() => {
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([9, 12])
})
})
it('resyncs when a successful gap snapshot conflicts with a buffered event identity', async () => {
const initial = logRange(0, 6)
const live = ev.user(9, 'live identity')
const conflicting = ev.user(9, 'conflicting history identity')
const consistent = [...initial, ...logRange(6, 9), live]
const { api, session } = await opened(initial)
let repairs = 0
api.onHistory = () => {
repairs++
return repairs === 1
? histResponse([...initial, ...logRange(6, 9), conflicting])
: histResponse(consistent)
}
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
session.handleMuxEnvelope('gap' as never, {
type: 'session/event', sessionId: SID, event: live,
})
await vi.waitFor(() => {
expect(repairs).toBe(2)
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([9])
})
expect(errorSpy).toHaveBeenCalledWith(
'[web-runtime] gap repair snapshot failed validation:',
expect.objectContaining({ message: 'session event identity mismatch at seq 9' }),
)
} finally {
errorSpy.mockRestore()
}
})
})
describe('paging', () => {
@@ -931,27 +578,6 @@ describe('paging', () => {
expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
})
it('keeps a concurrent live tail in the current window before prepending the older page', async () => {
const older = plainTurn(0, 0, '旧问', '旧答')
const newer = plainTurn(6, 1, '新问', '新答')
const page = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(newer, true)
: page.promise
await session.open()
const loading = session.loadOlder()
session.handleMuxEnvelope('live-tail' as never, {
type: 'session/event', sessionId: SID, event: ev.user(12, '并发尾部'),
})
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9, 12])
page.resolve(await histResponse(older, false))
await loading
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([1, 3, 7, 9, 12])
})
it('renders a page whose checkpoint shadows seqs below the window head, logging nothing', async () => {
// Pagination no longer spends maxMessages quota on replacement copies, so a
// page can carry a compaction checkpoint whose surfaceOp.start lies outside
@@ -1257,12 +883,11 @@ describe('remaining branches', () => {
it('subscribed baseline past the window tail triggers the second stitch pull in doOpen', async () => {
const { api, session } = makeSession()
const first = plainTurn(0, 0, 'a', 'b')
const full = [...first, ...plainTurn(6, 1, 'c', 'd')]
const full = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
let call = 0
api.onHistory = () => {
call++
return histResponse(call === 1 ? first : full)
return histResponse(call === 1 ? plainTurn(0, 0, 'a', 'b') : full)
}
// Baseline arrives before open: lastSeq 11 > first page tail 5 → doOpen repulls once.
session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 })
@@ -1550,107 +1175,6 @@ describe('resync', () => {
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
})
it('a stale loadOlder success and finally cannot mutate or clear a fresh-generation page request', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(logRange(6, 12), true)
await session.open()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const staleLoad = session.loadOlder()
api.onHistory = () => histResponse(logRange(12, 18), true)
await session.resync()
const fresh = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => fresh.promise
const freshLoad = session.loadOlder()
expect(session.getSnapshot()).toMatchObject({ loadingOlder: true, hasMore: true })
stale.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
await staleLoad
expect(session.getSnapshot()).toMatchObject({ loadingOlder: true, hasMore: true })
fresh.resolve(ok({ events: entries(logRange(6, 12)) as never[], hasMore: false }))
await freshLoad
expect(session.getSnapshot()).toMatchObject({ loadingOlder: false, hasMore: false })
})
it('a stale rejected or never-settled loadOlder cannot freeze the new generation', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(logRange(6, 12), true)
await session.open()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const staleLoad = session.loadOlder()
api.onHistory = () => histResponse(logRange(12, 18), false)
await session.resync()
expect(session.getSnapshot()).toMatchObject({ openState: 'open', loadingOlder: false })
stale.reject(new Error('old page connection closed'))
await staleLoad
expect(session.getSnapshot()).toMatchObject({ openState: 'open', loadingOlder: false })
const never = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
// Re-open a pageable generation and park a request that never settles.
api.onHistory = () => histResponse(logRange(18, 24), true)
await session.resync()
api.onHistory = () => never.promise
void session.loadOlder()
expect(session.getSnapshot().loadingOlder).toBe(true)
api.onHistory = () => histResponse(logRange(24, 30), false)
await session.resync()
expect(session.getSnapshot()).toMatchObject({ openState: 'open', loadingOlder: false })
})
it('stale gap success, rejection, and finally cannot clear a fresh repair owner', async () => {
const { api, session } = makeSession()
const initial = logRange(0, 6)
api.onHistory = () => histResponse(initial)
await session.open()
const staleRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => staleRepair.promise
session.handleMuxEnvelope('old-gap' as never, {
type: 'session/event', sessionId: SID, event: reminderEvent(9, 'old-gap'),
})
const freshBase = logRange(10, 16)
api.onHistory = () => histResponse(freshBase)
await session.resync()
const freshRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
let freshRepairCalls = 0
api.onHistory = () => {
freshRepairCalls++
return freshRepair.promise
}
const due = reminderEvent(18, 'fresh-gap')
session.handleMuxEnvelope('fresh-gap' as never, {
type: 'session/event', sessionId: SID, event: due, view: reminderView('fresh-gap'),
})
expect(freshRepairCalls).toBe(1)
staleRepair.reject(new Error('stale gap connection closed'))
await Promise.resolve()
await Promise.resolve()
const trailing = at(19, { type: 'fixture/log', data: { index: 19 } })
session.handleMuxEnvelope('fresh-trailing' as never, {
type: 'session/event', sessionId: SID, event: trailing,
})
expect(freshRepairCalls).toBe(1)
freshRepair.resolve(ok({
events: entries([...freshBase, ...logRange(16, 18), due, trailing]) as never[],
hasMore: false,
}))
await vi.waitFor(() => {
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: 18, view: { id: 'fresh-gap' },
}])
})
})
})
describe('nested run_code sub-dispatches', () => {

View File

@@ -434,32 +434,6 @@ describe('TranscriptAdapter', () => {
})
})
it('materializes generic presented-event nodes on replay and live append', () => {
const replayed = at(0, { type: 'schedule/change', data: { operation: 'dispatch', id: 'schedule-1' } })
const live = at(1, { type: 'schedule/change', data: { operation: 'dispatch', id: 'schedule-2' } })
const adapter = new TranscriptAdapter()
adapter.reset([replayed], [{
for: 'event',
view: { id: 'schedule-1', prompt: '检查日志' },
}])
adapter.append(live, {
for: 'event',
view: { id: 'schedule-2', prompt: '检查发布' },
})
expect(adapter.nodes()).toEqual([
{
kind: 'presented-event', seq: 0, time: 1_700_000_000_000,
eventType: 'schedule/change',
view: { id: 'schedule-1', prompt: '检查日志' },
},
{
kind: 'presented-event', seq: 1, time: 1_700_000_000_001,
eventType: 'schedule/change',
view: { id: 'schedule-2', prompt: '检查发布' },
},
])
})
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
const adapter = new TranscriptAdapter()
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }