History, State and Web become ordinary classes sharing the Session scope. Subscribe-before-read removes the tail repair, events and page expose the cursor-bearing history read, observeEvent keeps a pure event subscription, and follow stays passive unless activate is requested.
190 lines
6.8 KiB
TypeScript
190 lines
6.8 KiB
TypeScript
/** Shared Session history pagination, address validation and wire conversion. */
|
|
|
|
import {
|
|
isAppendSurfaceEvent,
|
|
SessionLogOffset,
|
|
} from '@deepseek-ai/dsh-session'
|
|
import type {
|
|
SessionEvent,
|
|
SessionHeader,
|
|
SessionId,
|
|
SessionLogOffset as SessionLogOffsetType,
|
|
SessionSeqCursor,
|
|
} from '@deepseek-ai/dsh-session'
|
|
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
|
|
import type {} from '@deepseek-ai/dsh-subagent'
|
|
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
|
import type {
|
|
SessionAddress,
|
|
SessionEventEntry,
|
|
SessionHistoryRecord,
|
|
SessionPageRequest,
|
|
SessionWireEvent,
|
|
} from './types.ts'
|
|
|
|
/** Message budget applied when a history request omits `maxMessages`. */
|
|
export const DEFAULT_MAX_MESSAGES = 50
|
|
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
|
|
|
|
/**
|
|
* Reject an invalid message budget or turn window.
|
|
* @param request - history window fields.
|
|
*/
|
|
export function validateHistoryWindow(request: Pick<SessionPageRequest, 'maxMessages' | 'turnWindow'>): void {
|
|
if (request.maxMessages !== undefined
|
|
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
|
|
throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
|
|
}
|
|
const window = request.turnWindow
|
|
if (window !== undefined) {
|
|
if (!Number.isSafeInteger(window.minMessages) || window.minMessages <= 0
|
|
|| window.minMessages > (request.maxMessages ?? DEFAULT_MAX_MESSAGES)) {
|
|
throw new RemoteError('gateway/bad-request', 'turnWindow.minMessages must be a positive safe integer no greater than maxMessages', {})
|
|
}
|
|
if (!Number.isSafeInteger(window.minTurns) || window.minTurns <= 0) {
|
|
throw new RemoteError('gateway/bad-request', 'turnWindow.minTurns must be a positive safe integer', {})
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Select the Session whose log an address names.
|
|
* @param address - ordinary Session or direct-subagent address.
|
|
* @returns the Session whose log the address reads.
|
|
*/
|
|
export function addressId(address: SessionAddress): SessionId {
|
|
return address.kind === 'session' ? address.sessionId : address.childSessionId
|
|
}
|
|
|
|
/**
|
|
* Reject an address that does not match the observed Session.
|
|
* @param address - ordinary Session or direct-subagent address.
|
|
* @param header - observed Session header.
|
|
* @param inheritedEventCount - observed fork-inherited event count.
|
|
* @param projections - observed projections; subagent addresses require them.
|
|
*/
|
|
export function validateAddress(
|
|
address: SessionAddress,
|
|
header: SessionHeader,
|
|
inheritedEventCount: SessionLogOffsetType,
|
|
projections: SessionObservation['projections'],
|
|
): void {
|
|
if (address.kind === 'session') {
|
|
if (header.origin === 'subagent') {
|
|
throw new RemoteError('session/agent-busy', 'subagent Sessions require their durable parent address', {
|
|
reason: 'use subagent delivery for this child session',
|
|
})
|
|
}
|
|
return
|
|
}
|
|
if (header.origin !== 'subagent' || header.parentSession !== address.parentSessionId) {
|
|
throw new RemoteError('subagent/unauthorized', 'subagent does not belong to the supplied parent', {
|
|
childSessionId: address.childSessionId,
|
|
})
|
|
}
|
|
const identity = projections?.values.subagent
|
|
if (identity === null) {
|
|
throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is corrupt', {
|
|
parentSessionId: address.parentSessionId,
|
|
childSessionId: address.childSessionId,
|
|
reason: 'corrupt',
|
|
})
|
|
}
|
|
if (identity === undefined || identity.seq < inheritedEventCount) {
|
|
throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is unavailable', {
|
|
parentSessionId: address.parentSessionId,
|
|
childSessionId: address.childSessionId,
|
|
reason: 'unsupported',
|
|
})
|
|
}
|
|
if (address.mode !== 'unknown' && identity.mode !== address.mode) {
|
|
throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', {
|
|
childSessionId: address.childSessionId,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reject a missing Session using its address-specific error.
|
|
* @param address - ordinary Session or direct-subagent address.
|
|
* @returns never; this operation always throws.
|
|
* @throws RemoteError `session/not-found` or `subagent/not-found`.
|
|
*/
|
|
export function rejectNotFound(address: SessionAddress): never {
|
|
if (address.kind === 'session') {
|
|
throw new RemoteError('session/not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId })
|
|
}
|
|
throw new RemoteError('subagent/not-found', 'subagent is unavailable', {
|
|
parentSessionId: address.parentSessionId,
|
|
childSessionId: address.childSessionId,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Cut one message-aligned backwards page from a contiguous event prefix.
|
|
* @param events - contiguous events starting at seq 0.
|
|
* @param beforeSeq - exclusive upper offset of an older page.
|
|
* @param maxMessages - message budget.
|
|
* @param throughSeq - inclusive log cut.
|
|
* @param turnWindow - optional turn-boundary stop rule.
|
|
* @returns the page events and whether older events remain.
|
|
*/
|
|
export function paginate(
|
|
events: readonly SessionEvent[],
|
|
beforeSeq: SessionLogOffsetType | undefined,
|
|
maxMessages: number,
|
|
throughSeq: SessionSeqCursor,
|
|
turnWindow?: SessionPageRequest['turnWindow'],
|
|
): { readonly events: SessionEvent[]; readonly hasMore: boolean } {
|
|
const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1))
|
|
let count = 0
|
|
let turns = 0
|
|
let cut = SessionLogOffset(0)
|
|
for (let index = end - 1; index >= 0; index--) {
|
|
const event = events[index] as SessionEvent
|
|
if (turnWindow !== undefined && event.type === 'turn/start') {
|
|
turns++
|
|
if (count >= turnWindow.minMessages && turns >= turnWindow.minTurns) {
|
|
cut = SessionLogOffset(index)
|
|
break
|
|
}
|
|
}
|
|
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
|
|
count++
|
|
const sources = event.sourceEventSeqs
|
|
let groupStart = event.seq
|
|
if (sources !== undefined) {
|
|
for (const source of sources) {
|
|
if (source < groupStart) groupStart = source
|
|
}
|
|
}
|
|
if (count >= maxMessages) {
|
|
cut = SessionLogOffset(groupStart)
|
|
break
|
|
}
|
|
}
|
|
return { events: events.slice(cut, end), hasMore: cut > 0 }
|
|
}
|
|
|
|
/**
|
|
* Convert a committed event to its wire entry.
|
|
* @param event - committed Session event.
|
|
* @returns the wire event entry.
|
|
*/
|
|
export function entryFor(event: SessionEvent): SessionEventEntry {
|
|
return {
|
|
type: 'event',
|
|
// Session.append validates and freezes event data as JSON before publication.
|
|
event: event as unknown as SessionWireEvent,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Encode one bounded logical page without changing its pagination cut.
|
|
* @param events - page events.
|
|
* @returns the wire records.
|
|
*/
|
|
export function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] {
|
|
return events.map(entryFor)
|
|
}
|