surface.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  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 { Message } from '@deepseek-ai/dsh-llm'
  11. import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
  12. /** Runtime counterpart of the message-producing event union. */
  13. const SURFACE_EVENT_TYPES = new Set<string>([
  14. 'user/message',
  15. 'assistant/message',
  16. 'tool/result',
  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 three 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. /**
  64. * Project a single event into the LLM message it derives to, or null when it
  65. * produces none — a non-surface event (chunk, boundary, log-only record) or an
  66. * empty-content assistant/message (which exists only to host usage). This is
  67. * THE per-node projection rule: `Session.deriveMessages` folds it over the
  68. * live surface, external reconstructors and pure projections fold the same
  69. * function over a log prefix's surface to rebuild the exact messages any
  70. * request was built from. The returned message is the already frozen message
  71. * nested in the event wrapper and shared by delivery, durable history, and
  72. * model requests.
  73. * @param event - the event to project.
  74. * @returns the derived message, or null when the event produces none.
  75. */
  76. export function deriveEventMessage(event: SessionEvent): Message | null {
  77. // Intentionally non-exhaustive: only message-producing events derive
  78. // history; turn/step boundaries, chunks, usage, and errors are trace/replay
  79. // data.
  80. switch (event.type) {
  81. // Ordinary prompts and injected context project in user role: the event's
  82. // model-facing content stays verbatim. Do NOT re-add per-type framing
  83. // (e.g. `<context>`) here: framing is caller-owned — a producer bakes it
  84. // into `content`, as workspace-context does with `<system-reminder>` — or,
  85. // if reintroduced, must be driven by the event `meta` map and a dedicated
  86. // renderer, keeping this projection a verbatim pass-through. See the
  87. // deferred design note in
  88. // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
  89. case 'user/message': {
  90. return event.data
  91. }
  92. case 'assistant/message': {
  93. // Skip an empty-content assistant/message: it exists only to host a
  94. // max-tokens step's usage and must not inject a content-less assistant
  95. // turn into the provider transcript.
  96. if (event.data.message.content.length === 0) return null
  97. return event.data.message
  98. }
  99. case 'tool/result': {
  100. return event.data.message
  101. }
  102. default:
  103. // A non-surface event (boundary, chunk, log-only record) projects to
  104. // no message. Merge-extensible union: no assertNever here.
  105. return null
  106. }
  107. }
  108. /** One replacement operation observed while folding a session surface. */
  109. export interface SurfaceFoldReplacement {
  110. /** Seq of the event that replaced the prior surface range. */
  111. seq: number
  112. /** Declared inclusive start seq of the replaced surface range. */
  113. start: number
  114. /** Declared inclusive end seq of the replaced surface range. */
  115. end: number
  116. /** Actual surface entries removed by the operation, in surface order. */
  117. shadowedSeqs: number[]
  118. }
  119. /** Complete result of replaying the surface operations in a session log. */
  120. export interface SurfaceFoldResult {
  121. /** Current surface event sequences in model-visible order. */
  122. nodes: number[]
  123. /** Replacement operations in event order. */
  124. replacements: SurfaceFoldReplacement[]
  125. }
  126. /** Readonly live projection of the message-producing session events. */
  127. export interface SessionSurface {
  128. /** Current surface event sequences in model-visible order. */
  129. readonly nodes: readonly number[]
  130. /** Monotonic count of committed positional replacements. */
  131. readonly replaceGeneration: number
  132. }
  133. /** Mutable state shared by complete and incremental folds. */
  134. interface SurfaceFoldState {
  135. nodes: number[]
  136. replaceGeneration: number
  137. }
  138. /** A validated replacement transition that has not mutated fold state yet. */
  139. interface SurfaceReplacePlan extends SurfaceFoldReplacement {
  140. kind: 'replace'
  141. startIdx: number
  142. endIdx: number
  143. }
  144. /** One validated surface transition that has not mutated fold state yet. */
  145. type SurfacePlan =
  146. | { kind: 'append'; seq: number }
  147. | SurfaceReplacePlan
  148. /** Create an empty surface fold state. */
  149. function createFoldState(): SurfaceFoldState {
  150. return { nodes: [], replaceGeneration: 0 }
  151. }
  152. /** Whether a runtime value is a non-negative safe event sequence. */
  153. function isEventSeq(value: unknown): value is number {
  154. return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
  155. }
  156. /** Whether a runtime value is the exact positional-replacement shape. */
  157. function isReplaceOp(value: object): value is Extract<SurfaceOp, { op: 'replace' }> {
  158. const op = value as Record<string, unknown>
  159. return Object.keys(op).length === 3
  160. && Object.hasOwn(op, 'op')
  161. && Object.hasOwn(op, 'start')
  162. && Object.hasOwn(op, 'end')
  163. && op['op'] === 'replace'
  164. && isEventSeq(op['start'])
  165. && isEventSeq(op['end'])
  166. }
  167. /** Validate event-local surface eligibility and return its operation. */
  168. function surfaceOpOf(event: SessionEvent): SurfaceOp | undefined {
  169. const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
  170. if (!isSurfaceEligibleType(event.type)) {
  171. if (raw.surfaceOp !== undefined) {
  172. throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`)
  173. }
  174. if (raw.sourceEventSeqs !== undefined) {
  175. throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`)
  176. }
  177. return
  178. }
  179. const op = raw.surfaceOp
  180. if (op === undefined) {
  181. throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`)
  182. }
  183. if (op === 'append') return op
  184. if (op === null || typeof op !== 'object' || Array.isArray(op)) {
  185. throw new Error(`session event "${event.type}" carries an invalid surfaceOp`)
  186. }
  187. if (!isReplaceOp(op)) {
  188. throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`)
  189. }
  190. return op
  191. }
  192. /** Validate cited source-event seqs against prior log entries and the replacement range. */
  193. function assertProvenance(
  194. event: SessionEvent,
  195. shadowedSeqs: readonly number[],
  196. ): void {
  197. const raw = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
  198. const sources = new Set<number>()
  199. if (raw !== undefined) {
  200. if (!Array.isArray(raw)) {
  201. throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`)
  202. }
  203. if (raw.length === 0 && event.type !== 'assistant/message') {
  204. throw new Error('sourceEventSeqs must not be empty except on assistant/message')
  205. }
  206. let nonEarlierSource: number | undefined
  207. for (const source of raw) {
  208. if (!isEventSeq(source)) {
  209. throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`)
  210. }
  211. sources.add(source)
  212. if (nonEarlierSource === undefined && source >= event.seq) nonEarlierSource = source
  213. }
  214. if (sources.size !== raw.length) {
  215. throw new Error('sourceEventSeqs must not contain duplicates')
  216. }
  217. if (nonEarlierSource !== undefined) {
  218. throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`)
  219. }
  220. }
  221. const missing = shadowedSeqs.filter(seq => !sources.has(seq))
  222. if (missing.length > 0) {
  223. throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
  224. }
  225. }
  226. /** Locate one replacement range without mutating the current fold state. */
  227. function replacementRange(
  228. state: SurfaceFoldState,
  229. op: Extract<SurfaceOp, { op: 'replace' }>,
  230. ): Pick<SurfaceReplacePlan, 'startIdx' | 'endIdx' | 'shadowedSeqs'> {
  231. const startIdx = state.nodes.indexOf(op.start)
  232. if (startIdx === -1) {
  233. throw new Error(`surface replace: start seq ${op.start} not found in surface`)
  234. }
  235. const endIdx = state.nodes.indexOf(op.end)
  236. if (endIdx === -1) {
  237. throw new Error(`surface replace: end seq ${op.end} not found in surface`)
  238. }
  239. if (startIdx > endIdx) {
  240. throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`)
  241. }
  242. return {
  243. startIdx,
  244. endIdx,
  245. shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1),
  246. }
  247. }
  248. /**
  249. * Deep structural equality over the session-event JSON value domain
  250. * (null/boolean/number/string, arrays, plain objects). Replaces
  251. * `node:util`'s isDeepStrictEqual to keep this module browser-safe.
  252. */
  253. function isDeepEqualJson(a: unknown, b: unknown): boolean {
  254. if (a === b) return true
  255. if (Array.isArray(a) || Array.isArray(b)) {
  256. if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
  257. return a.every((item, i) => isDeepEqualJson(item, b[i]))
  258. }
  259. if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
  260. const aKeys = Object.keys(a)
  261. const bRecord = b as Record<string, unknown>
  262. if (aKeys.length !== Object.keys(b).length) return false
  263. return aKeys.every(key => Object.hasOwn(b, key) && isDeepEqualJson((a as Record<string, unknown>)[key], bRecord[key]))
  264. }
  265. /** Restrict a tool-result replacement to one current result's content. */
  266. function assertToolResultRewrite(
  267. event: SessionEvent,
  268. shadowedSeqs: readonly number[],
  269. events: readonly SessionEvent[],
  270. baseSeq: number,
  271. ): void {
  272. if (event.type !== 'tool/result') return
  273. if (shadowedSeqs.length !== 1) {
  274. throw new Error('tool/result surface replacement must rewrite exactly one current node')
  275. }
  276. for (const originalSeq of shadowedSeqs) {
  277. const original = events[originalSeq - baseSeq]
  278. if (original?.type !== 'tool/result') {
  279. throw new Error('tool/result surface replacement must target a current tool/result')
  280. }
  281. const originalRest = { ...original.data } as Record<string, unknown>
  282. const replacementRest = { ...event.data } as Record<string, unknown>
  283. const originalResult = original.data.message.content[0]
  284. const replacementResult = event.data.message.content[0]
  285. originalRest['message'] = {
  286. ...original.data.message,
  287. content: [{ ...originalResult, content: null }],
  288. }
  289. replacementRest['message'] = {
  290. ...event.data.message,
  291. content: [{ ...replacementResult, content: null }],
  292. }
  293. if (!isDeepEqualJson(originalRest, replacementRest)) {
  294. throw new Error('tool/result surface replacement may change only content')
  295. }
  296. }
  297. }
  298. /** Validate one event at its replay boundary and prepare its atomic fold transition. */
  299. function planSurfaceEvent(
  300. state: SurfaceFoldState,
  301. event: SessionEvent,
  302. expectedSeq: number,
  303. events: readonly SessionEvent[],
  304. baseSeq: number,
  305. ): SurfacePlan | undefined {
  306. if (event.seq !== expectedSeq) {
  307. throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
  308. }
  309. const surfaceOp = surfaceOpOf(event)
  310. if (surfaceOp === undefined) return
  311. if (surfaceOp === 'append') {
  312. assertProvenance(event, [])
  313. return { kind: 'append', seq: event.seq }
  314. }
  315. const range = replacementRange(state, surfaceOp)
  316. assertProvenance(event, range.shadowedSeqs)
  317. assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq)
  318. return {
  319. kind: 'replace',
  320. seq: event.seq,
  321. start: surfaceOp.start,
  322. end: surfaceOp.end,
  323. ...range,
  324. }
  325. }
  326. /** Apply one event and return replacement metadata only when one occurred. */
  327. function applySurfaceEvent(
  328. state: SurfaceFoldState,
  329. event: SessionEvent,
  330. expectedSeq: number,
  331. events: readonly SessionEvent[],
  332. baseSeq: number,
  333. ): SurfaceFoldReplacement | undefined {
  334. const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq)
  335. return applySurfacePlan(state, plan)
  336. }
  337. /** Commit one previously validated surface transition. */
  338. function applySurfacePlan(
  339. state: SurfaceFoldState,
  340. plan: SurfacePlan | undefined,
  341. ): SurfaceFoldReplacement | undefined {
  342. if (plan?.kind === 'append') {
  343. state.nodes.push(plan.seq)
  344. } else if (plan?.kind === 'replace') {
  345. state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq)
  346. state.replaceGeneration += 1
  347. }
  348. if (plan?.kind !== 'replace') return
  349. return {
  350. seq: plan.seq,
  351. start: plan.start,
  352. end: plan.end,
  353. shadowedSeqs: plan.shadowedSeqs,
  354. }
  355. }
  356. /**
  357. * Replay a complete session log through the canonical surface fold.
  358. * @param events - session events in contiguous seq order.
  359. * @returns detached current sequences and replacement history.
  360. * @throws when an event violates surface metadata, source-event references, range, or tool-result rewrite rules.
  361. */
  362. export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
  363. const state = createFoldState()
  364. const replacements: SurfaceFoldReplacement[] = []
  365. for (const [index, event] of events.entries()) {
  366. const replacement = applySurfaceEvent(state, event, index, events, 0)
  367. if (replacement !== undefined) replacements.push(replacement)
  368. }
  369. return { nodes: [...state.nodes], replacements }
  370. }
  371. /** Incremental ordered surface view and append-boundary validator. */
  372. export class SurfaceManager implements SessionSurface {
  373. /** Shared transition state; replacement history is not retained. */
  374. private _state = createFoldState()
  375. /** Last processed absolute seq. */
  376. private _lastProcessedSeq: number
  377. /** Candidate already validated by `validateNext`, pending exact log admission. */
  378. private _pendingPlan: { event: SessionEvent; expectedSeq: number; plan: SurfacePlan | undefined } | undefined
  379. /**
  380. * @param log - Contiguous complete log or loaded event window.
  381. * @param baseSeq - Absolute sequence of the window's first event.
  382. */
  383. constructor(
  384. private log: readonly SessionEvent[],
  385. private readonly baseSeq = 0,
  386. ) {
  387. this._lastProcessedSeq = baseSeq - 1
  388. }
  389. /**
  390. * Validate the next candidate without mutating the committed surface.
  391. * @param event - candidate event that has not entered the log yet.
  392. */
  393. validateNext(event: SessionEvent): void {
  394. if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
  395. const expectedSeq = this.baseSeq + this.log.length
  396. this._pendingPlan = {
  397. event,
  398. expectedSeq,
  399. plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq),
  400. }
  401. }
  402. /** Monotonic count of folded positional replacements. */
  403. get replaceGeneration(): number {
  404. if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
  405. return this._state.replaceGeneration
  406. }
  407. /** Surface event sequences in model-visible order. */
  408. get nodes(): readonly number[] {
  409. if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
  410. return this._state.nodes
  411. }
  412. /** Fold events appended since the previous access. */
  413. private _processDelta(): void {
  414. const tailSeq = this.baseSeq + this.log.length - 1
  415. for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {
  416. const index = seq - this.baseSeq
  417. // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
  418. const event = this.log[index]!
  419. const pending = this._pendingPlan
  420. if (pending?.event === event && pending.expectedSeq === seq) {
  421. applySurfacePlan(this._state, pending.plan)
  422. } else {
  423. applySurfaceEvent(this._state, event, seq, this.log, this.baseSeq)
  424. }
  425. if (pending !== undefined && pending.expectedSeq <= seq) this._pendingPlan = undefined
  426. this._lastProcessedSeq = seq
  427. }
  428. }
  429. }