surface.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. /**
  2. * Surface layer on top of the session event log: an ordered view of events
  3. * that produce LLM messages. The append-only log remains the source of truth.
  4. *
  5. * Browser-safe: web clients consume this subpath export, so it must stay free
  6. * of `node:` imports (they break the vite bundle).
  7. *
  8. * @module @deepseek-ai/dsh-session/surface
  9. */
  10. import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
  11. /** Runtime counterpart of the message-producing event union. */
  12. const SURFACE_EVENT_TYPES = new Set<string>([
  13. 'user/message',
  14. 'assistant/message',
  15. 'tool/result',
  16. 'steering/message',
  17. ])
  18. /**
  19. * Whether an event type can join the model-visible surface.
  20. * @param type - event type to test.
  21. * @returns true for one of the four message-producing event types.
  22. */
  23. export function isSurfaceEligibleType(type: string): boolean {
  24. return SURFACE_EVENT_TYPES.has(type)
  25. }
  26. /**
  27. * Narrow an event to a surface-eligible event carrying its required marker.
  28. * @param event - event to test.
  29. * @returns true when both the type and marker identify a surface event.
  30. */
  31. export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
  32. if (!SURFACE_EVENT_TYPES.has(event.type)) return false
  33. return (event as SessionEvent<SurfaceEventType>).surfaceOp !== undefined
  34. }
  35. /**
  36. * Narrow an event to an append-origin surface event: one that entered the
  37. * surface at its own log position and was never itself a replacement copy.
  38. *
  39. * The model-visible surface deliberately shadows replaced ranges, so it is the
  40. * wrong source for a human transcript — a landed replacement would erase
  41. * conversation the user already saw. Append-origin events are that transcript's
  42. * durable source material; replacement copies stay model-only.
  43. * @param event - event to test.
  44. * @returns true when the event appended to the surface tail.
  45. */
  46. export function isAppendSurfaceEvent(
  47. event: SessionEvent,
  48. ): event is SurfaceEvent & { surfaceOp: 'append' } {
  49. return isSurfaceEvent(event) && event.surfaceOp === 'append'
  50. }
  51. /**
  52. * Narrow an event to a surface replacement: a node that shadowed an existing
  53. * surface range instead of appending to the tail. The counterpart of
  54. * {@link isAppendSurfaceEvent} over the two {@link SurfaceOp} variants.
  55. * @param event - event to test.
  56. * @returns true when the event replaced a surface range.
  57. */
  58. export function isReplacementSurfaceEvent(
  59. event: SessionEvent,
  60. ): event is SurfaceEvent & { surfaceOp: Extract<SurfaceOp, { op: 'replace' }> } {
  61. return isSurfaceEvent(event) && event.surfaceOp !== 'append'
  62. }
  63. /** One replacement operation observed while folding a session surface. */
  64. export interface SurfaceFoldReplacement {
  65. /** Seq of the event that replaced the prior surface range. */
  66. seq: number
  67. /** Declared inclusive start seq of the replaced surface range. */
  68. start: number
  69. /** Declared inclusive end seq of the replaced surface range. */
  70. end: number
  71. /** Actual surface entries removed by the operation, in surface order. */
  72. shadowedSeqs: number[]
  73. }
  74. /** Complete result of replaying the surface operations in a session log. */
  75. export interface SurfaceFoldResult {
  76. /** Current surface event sequences in model-visible order. */
  77. nodes: number[]
  78. /** Replacement operations in event order. */
  79. replacements: SurfaceFoldReplacement[]
  80. }
  81. /** Readonly live projection of the message-producing session events. */
  82. export interface SessionSurface {
  83. /** Current surface event sequences in model-visible order. */
  84. readonly nodes: readonly number[]
  85. /** Monotonic count of committed positional replacements. */
  86. readonly replaceGeneration: number
  87. }
  88. /** Mutable state shared by complete and incremental folds. */
  89. interface SurfaceFoldState {
  90. nodes: number[]
  91. replaceGeneration: number
  92. }
  93. /** A validated replacement transition that has not mutated fold state yet. */
  94. interface SurfaceReplacePlan extends SurfaceFoldReplacement {
  95. kind: 'replace'
  96. startIdx: number
  97. endIdx: number
  98. }
  99. /** One validated surface transition that has not mutated fold state yet. */
  100. type SurfacePlan =
  101. | { kind: 'append'; seq: number }
  102. | SurfaceReplacePlan
  103. /** Create an empty surface fold state. */
  104. function createFoldState(): SurfaceFoldState {
  105. return { nodes: [], replaceGeneration: 0 }
  106. }
  107. /** Whether a runtime value is a non-negative safe event sequence. */
  108. function isEventSeq(value: unknown): value is number {
  109. return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
  110. }
  111. /** Whether a runtime value is the exact positional-replacement shape. */
  112. function isReplaceOp(value: object): value is Extract<SurfaceOp, { op: 'replace' }> {
  113. const op = value as Record<string, unknown>
  114. return Object.keys(op).length === 3
  115. && Object.hasOwn(op, 'op')
  116. && Object.hasOwn(op, 'start')
  117. && Object.hasOwn(op, 'end')
  118. && op['op'] === 'replace'
  119. && isEventSeq(op['start'])
  120. && isEventSeq(op['end'])
  121. }
  122. /** Validate event-local surface eligibility and return its operation. */
  123. function surfaceOpOf(event: SessionEvent): SurfaceOp | undefined {
  124. const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
  125. if (!isSurfaceEligibleType(event.type)) {
  126. if (raw.surfaceOp !== undefined) {
  127. throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`)
  128. }
  129. if (raw.sourceEventSeqs !== undefined) {
  130. throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`)
  131. }
  132. return
  133. }
  134. const op = raw.surfaceOp
  135. if (op === undefined) {
  136. throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`)
  137. }
  138. if (op === 'append') return op
  139. if (op === null || typeof op !== 'object' || Array.isArray(op)) {
  140. throw new Error(`session event "${event.type}" carries an invalid surfaceOp`)
  141. }
  142. if (!isReplaceOp(op)) {
  143. throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`)
  144. }
  145. return op
  146. }
  147. /** Validate provenance against prior log entries and the replacement range. */
  148. function assertProvenance(
  149. event: SessionEvent,
  150. shadowedSeqs: readonly number[],
  151. ): void {
  152. const raw = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
  153. const sources = new Set<number>()
  154. if (raw !== undefined) {
  155. if (!Array.isArray(raw)) {
  156. throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`)
  157. }
  158. if (raw.length === 0 && event.type !== 'assistant/message') {
  159. throw new Error('sourceEventSeqs must not be empty except on assistant/message')
  160. }
  161. let nonEarlierSource: number | undefined
  162. for (const source of raw) {
  163. if (!isEventSeq(source)) {
  164. throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`)
  165. }
  166. sources.add(source)
  167. if (nonEarlierSource === undefined && source >= event.seq) nonEarlierSource = source
  168. }
  169. if (sources.size !== raw.length) {
  170. throw new Error('sourceEventSeqs must not contain duplicates')
  171. }
  172. if (nonEarlierSource !== undefined) {
  173. throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`)
  174. }
  175. }
  176. const missing = shadowedSeqs.filter(seq => !sources.has(seq))
  177. if (missing.length > 0) {
  178. throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
  179. }
  180. }
  181. /** Locate one replacement range without mutating the current fold state. */
  182. function replacementRange(
  183. state: SurfaceFoldState,
  184. op: Extract<SurfaceOp, { op: 'replace' }>,
  185. ): Pick<SurfaceReplacePlan, 'startIdx' | 'endIdx' | 'shadowedSeqs'> {
  186. const startIdx = state.nodes.indexOf(op.start)
  187. if (startIdx === -1) {
  188. throw new Error(`surface replace: start seq ${op.start} not found in surface`)
  189. }
  190. const endIdx = state.nodes.indexOf(op.end)
  191. if (endIdx === -1) {
  192. throw new Error(`surface replace: end seq ${op.end} not found in surface`)
  193. }
  194. if (startIdx > endIdx) {
  195. throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`)
  196. }
  197. return {
  198. startIdx,
  199. endIdx,
  200. shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1),
  201. }
  202. }
  203. /**
  204. * Deep structural equality over the session-event JSON value domain
  205. * (null/boolean/number/string, arrays, plain objects). Replaces
  206. * `node:util`'s isDeepStrictEqual to keep this module browser-safe.
  207. */
  208. function isDeepEqualJson(a: unknown, b: unknown): boolean {
  209. if (a === b) return true
  210. if (Array.isArray(a) || Array.isArray(b)) {
  211. if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
  212. return a.every((item, i) => isDeepEqualJson(item, b[i]))
  213. }
  214. if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
  215. const aKeys = Object.keys(a)
  216. const bRecord = b as Record<string, unknown>
  217. if (aKeys.length !== Object.keys(b).length) return false
  218. return aKeys.every(key => Object.hasOwn(b, key) && isDeepEqualJson((a as Record<string, unknown>)[key], bRecord[key]))
  219. }
  220. /** Restrict a tool-result replacement to one current result's content. */
  221. function assertToolResultRewrite(
  222. event: SessionEvent,
  223. shadowedSeqs: readonly number[],
  224. events: readonly SessionEvent[],
  225. ): void {
  226. if (event.type !== 'tool/result') return
  227. if (shadowedSeqs.length !== 1) {
  228. throw new Error('tool/result surface replacement must rewrite exactly one current node')
  229. }
  230. for (const originalSeq of shadowedSeqs) {
  231. const original = events[originalSeq]
  232. if (original?.type !== 'tool/result') {
  233. throw new Error('tool/result surface replacement must target a current tool/result')
  234. }
  235. const originalRest = { ...original.data } as Record<string, unknown>
  236. const replacementRest = { ...event.data } as Record<string, unknown>
  237. const originalResult = original.data.message.content[0]
  238. const replacementResult = event.data.message.content[0]
  239. originalRest['message'] = {
  240. ...original.data.message,
  241. content: [{ ...originalResult, content: null }],
  242. }
  243. replacementRest['message'] = {
  244. ...event.data.message,
  245. content: [{ ...replacementResult, content: null }],
  246. }
  247. if (!isDeepEqualJson(originalRest, replacementRest)) {
  248. throw new Error('tool/result surface replacement may change only content')
  249. }
  250. }
  251. }
  252. /** Validate one event at its replay boundary and prepare its atomic fold transition. */
  253. function planSurfaceEvent(
  254. state: SurfaceFoldState,
  255. event: SessionEvent,
  256. expectedSeq: number,
  257. events: readonly SessionEvent[],
  258. ): SurfacePlan | undefined {
  259. if (event.seq !== expectedSeq) {
  260. throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
  261. }
  262. const surfaceOp = surfaceOpOf(event)
  263. if (surfaceOp === undefined) return
  264. if (surfaceOp === 'append') {
  265. assertProvenance(event, [])
  266. return { kind: 'append', seq: event.seq }
  267. }
  268. const range = replacementRange(state, surfaceOp)
  269. assertProvenance(event, range.shadowedSeqs)
  270. assertToolResultRewrite(event, range.shadowedSeqs, events)
  271. return {
  272. kind: 'replace',
  273. seq: event.seq,
  274. start: surfaceOp.start,
  275. end: surfaceOp.end,
  276. ...range,
  277. }
  278. }
  279. /** Apply one event and return replacement metadata only when one occurred. */
  280. function applySurfaceEvent(
  281. state: SurfaceFoldState,
  282. event: SessionEvent,
  283. expectedSeq: number,
  284. events: readonly SessionEvent[],
  285. ): SurfaceFoldReplacement | undefined {
  286. const plan = planSurfaceEvent(state, event, expectedSeq, events)
  287. if (plan?.kind === 'append') {
  288. state.nodes.push(plan.seq)
  289. } else if (plan?.kind === 'replace') {
  290. state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq)
  291. state.replaceGeneration += 1
  292. }
  293. if (plan?.kind !== 'replace') return
  294. return {
  295. seq: plan.seq,
  296. start: plan.start,
  297. end: plan.end,
  298. shadowedSeqs: plan.shadowedSeqs,
  299. }
  300. }
  301. /**
  302. * Replay a complete session log through the canonical surface fold.
  303. * @param events - session events in contiguous seq order.
  304. * @returns detached current sequences and replacement history.
  305. * @throws when an event violates surface metadata, provenance, range, or tool-result rewrite rules.
  306. */
  307. export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
  308. const state = createFoldState()
  309. const replacements: SurfaceFoldReplacement[] = []
  310. for (const [index, event] of events.entries()) {
  311. const replacement = applySurfaceEvent(state, event, index, events)
  312. if (replacement !== undefined) replacements.push(replacement)
  313. }
  314. return { nodes: [...state.nodes], replacements }
  315. }
  316. /** Incremental ordered surface view and append-boundary validator. */
  317. export class SurfaceManager implements SessionSurface {
  318. /** Shared transition state; replacement history is not retained. */
  319. private _state = createFoldState()
  320. /** Last processed seq; -1 folds a seeded log on first access. */
  321. private _lastProcessedSeq = -1
  322. constructor(private log: readonly SessionEvent[]) {}
  323. /**
  324. * Validate the next candidate without mutating the committed surface.
  325. * @param event - candidate event that has not entered the log yet.
  326. */
  327. validateNext(event: SessionEvent): void {
  328. if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
  329. planSurfaceEvent(this._state, event, this.log.length, this.log)
  330. }
  331. /** Monotonic count of folded positional replacements. */
  332. get replaceGeneration(): number {
  333. if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
  334. return this._state.replaceGeneration
  335. }
  336. /** Surface event sequences in model-visible order. */
  337. get nodes(): readonly number[] {
  338. if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
  339. return this._state.nodes
  340. }
  341. /** Fold events appended since the previous access. */
  342. private _processDelta(): void {
  343. for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
  344. // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
  345. applySurfaceEvent(this._state, this.log[i]!, i, this.log)
  346. this._lastProcessedSeq = i
  347. }
  348. }
  349. }