region.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. /**
  2. * Surface retention selection and the shared log-recorded compaction
  3. * transaction for automatic open-turn and manual idle-session compaction.
  4. *
  5. * @module @deepseek-ai/dsh-compaction-basic/region
  6. */
  7. import { randomUUID } from 'node:crypto'
  8. import { isDeepStrictEqual } from 'node:util'
  9. import {
  10. CompactionId,
  11. ManualCompactionError,
  12. compactCheckpointSource,
  13. toolPairingBalancedAfter,
  14. toolPairingBalancedBefore,
  15. } from '@deepseek-ai/dsh-compaction'
  16. import type { CompactionResult } from '@deepseek-ai/dsh-compaction'
  17. import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
  18. import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
  19. import type { Message, UserMessage } from '@deepseek-ai/dsh-llm'
  20. import type { TokenMeasurement, TokenMeter } from '@deepseek-ai/dsh-token-meter'
  21. import { SessionSeq, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
  22. import type { Agent } from '@deepseek-ai/dsh-agent'
  23. import { frameSummary } from './summarizer.ts'
  24. import type { SummarizationInput, SummaryResult } from './summarizer.ts'
  25. interface RegionDependencies {
  26. readonly meter: TokenMeter
  27. summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
  28. recover(error: unknown, agent: Agent, sourceEventSeqs: readonly SessionSeq[], signal?: AbortSignal): boolean
  29. }
  30. /** One validated inclusive span of current surface positions. */
  31. interface SurfaceSelection {
  32. readonly start: SessionSeq
  33. readonly end: SessionSeq
  34. readonly startIdx: number
  35. readonly endIdx: number
  36. readonly shadowedSeqs: readonly SessionSeq[]
  37. }
  38. /** A selection with its priced snapshot and the replay input built from it. */
  39. interface PreparedCompaction extends SurfaceSelection {
  40. readonly measurement: TokenMeasurement
  41. readonly selectedNodes: TokenMeasurement['nodes']
  42. readonly shadowedTokenCount: number
  43. /** Route-priced total of the selected span; the shrink comparison's unit. */
  44. readonly shadowedRouteTokenCount: number
  45. readonly input: SummarizationInput
  46. }
  47. type SummarizedCompaction = PreparedCompaction & SummaryResult & {
  48. readonly checkpointMessage: UserMessage
  49. }
  50. interface CompactionTransactionOptions {
  51. /** `current-turn` derives a numbered owner; `null` writes a standalone bracket. */
  52. readonly owner: 'current-turn' | null
  53. /** Surface relationship that must survive asynchronous summarization. */
  54. readonly stability: 'whole-surface' | 'selected-span'
  55. /** Optional durability checkpoint after a successfully closed bracket. */
  56. readonly flush?: () => Promise<void>
  57. /** Manual command that initiated this transaction, when present. */
  58. readonly sourceCommandId?: CommandId
  59. }
  60. interface CompactionEntryState {
  61. readonly openTurn: number | null
  62. readonly unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined
  63. readonly latestEndSeedSeq: SessionSeq | undefined
  64. }
  65. /**
  66. * Rejects a summary whose replacement boundaries are no longer the ones it was
  67. * built from, distinguished from summarizer and shrink failures so a manual
  68. * caller can report the two causes differently.
  69. */
  70. class SurfaceChangedError extends Error {}
  71. /** Whether the summary may still replace the span it was built from. */
  72. type StabilityCheck = (
  73. dependencies: RegionDependencies,
  74. session: Session,
  75. prepared: PreparedCompaction,
  76. ) => void
  77. /** Failure captured after `compaction/start` has committed. */
  78. interface TransactionFailure {
  79. readonly error: unknown
  80. readonly stage: 'summary' | 'commit'
  81. }
  82. /**
  83. * The `system/message` holding surface node 0, or `undefined` when another
  84. * message-producing event starts the surface.
  85. * @param session - session supplying the log behind the current surface.
  86. * @param headSeq - seq at surface node 0 of a non-empty surface.
  87. * @returns the system head event, or `undefined` without one.
  88. */
  89. function systemHead(session: Session, headSeq: SessionSeq): SessionEvent<'system/message'> | undefined {
  90. // Surface nodes are current log seqs, so the event exists.
  91. // Existing Session history read; migration deferred.
  92. // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated
  93. const head = session.eventAt(headSeq)!
  94. return head.type === 'system/message' ? head : undefined
  95. }
  96. /**
  97. * Resolve the next range starting at the first non-system surface node while
  98. * retaining a priced recent tail and never splitting an assistant
  99. * tool-call/result pair. A `system/message` at surface node 0 is never inside
  100. * the range; without one the range starts at node 0.
  101. * @param session - session supplying authoritative current surface positions.
  102. * @param measurement - unified pressure and surface measurement from the conversation meter.
  103. * @param retainTokens - minimum recent tail budget retained verbatim.
  104. * @returns the inclusive positional seq range to compact, or `null`.
  105. */
  106. export function selectCompactableRange(
  107. session: Session,
  108. measurement: TokenMeasurement,
  109. retainTokens: number,
  110. ): { start: SessionSeq; end: SessionSeq } | null {
  111. const pricedNodes = measurement.nodes
  112. if (pricedNodes.length === 0) return null
  113. const surfaceNodes = session.surface.nodes
  114. if (surfaceNodes.length !== pricedNodes.length
  115. || surfaceNodes.some((seq, index) => seq !== pricedNodes[index]?.seq)) {
  116. throw new Error('compaction: token-meter surface does not match the current session surface')
  117. }
  118. // oxlint-disable-next-line typescript/no-non-null-assertion
  119. const firstIdx = systemHead(session, surfaceNodes[0]!) === undefined ? 0 : 1
  120. let accumulated = 0
  121. let keepFromIdx = pricedNodes.length
  122. for (let index = pricedNodes.length - 1; index >= 0; index -= 1) {
  123. // oxlint-disable-next-line typescript/no-non-null-assertion
  124. accumulated += pricedNodes[index]!.tokens
  125. keepFromIdx = index
  126. if (accumulated >= retainTokens) break
  127. }
  128. if (keepFromIdx <= firstIdx) return null
  129. while (keepFromIdx > firstIdx) {
  130. // oxlint-disable-next-line typescript/no-non-null-assertion
  131. if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break
  132. keepFromIdx -= 1
  133. }
  134. if (keepFromIdx <= firstIdx) return null
  135. // oxlint-disable-next-line typescript/no-non-null-assertion
  136. const first = surfaceNodes[firstIdx]!
  137. // oxlint-disable-next-line typescript/no-non-null-assertion
  138. const cutoff = surfaceNodes[keepFromIdx - 1]!
  139. return { start: first, end: cutoff }
  140. }
  141. /**
  142. * Run the single compaction transaction over one selected positional span.
  143. * Selection and validation are read-only. Idle/log validation and
  144. * `compaction/start` are synchronously adjacent, so the durable opening marker is
  145. * the compaction lock before summarization yields. Every later failure makes
  146. * exactly one `compaction/end` attempt; a failed close deliberately leaves the
  147. * unmatched start detectable.
  148. * @param dependencies - conversation meter and dynamically dispatched summarizer hook.
  149. * @param session - session whose surface is mutated.
  150. * @param start - inclusive first surface-node seq.
  151. * @param end - inclusive last surface-node seq.
  152. * @param agent - agent used by the summarizer.
  153. * @param options - bracket owner, stability rule, and optional durability checkpoint.
  154. * @param signal - optional summarization cancellation signal.
  155. * @returns the successful durable compaction result.
  156. */
  157. export async function compactSurfaceRegion(
  158. dependencies: RegionDependencies,
  159. session: Session,
  160. start: SessionSeq,
  161. end: SessionSeq,
  162. agent: Agent,
  163. options: CompactionTransactionOptions,
  164. signal?: AbortSignal,
  165. ): Promise<CompactionResult> {
  166. if (options.owner === null) signal?.throwIfAborted()
  167. const selection = validateSurfaceRegion(session, start, end)
  168. const entryState = inspectCompactionEntryState(session)
  169. assertCompactionInactive(
  170. entryState.unmatchedCompactionStart,
  171. entryState.latestEndSeedSeq,
  172. 'compaction',
  173. )
  174. let owner: number | null
  175. if (options.owner === null) {
  176. if (entryState.openTurn !== null) {
  177. throw new ManualCompactionError('busy', 'manual compaction: the session already has an open turn')
  178. }
  179. owner = null
  180. } else {
  181. if (entryState.openTurn === null) {
  182. throw new Error('compactRegion: no open turn — automatic compaction events must be enclosed in a turn')
  183. }
  184. owner = entryState.openTurn
  185. }
  186. const compactionId = CompactionId(randomUUID())
  187. const lifecycle = {
  188. compactionId,
  189. ...options.sourceCommandId === undefined ? {} : { sourceCommandId: options.sourceCommandId },
  190. turn: owner,
  191. }
  192. const startEvent = session.append('compaction/start', lifecycle)
  193. const assertStable: StabilityCheck = options.stability === 'whole-surface'
  194. ? assertWholeSurfaceUnchanged
  195. : assertSelectedSpanStable
  196. let failure: TransactionFailure | undefined
  197. let flushFailure: unknown
  198. let result: CompactionResult | undefined
  199. let closed = false
  200. let closing = false
  201. let stage: TransactionFailure['stage'] = 'summary'
  202. try {
  203. const prepared = prepareCompaction(dependencies, session, selection)
  204. const summarized = await summarizeCompaction(
  205. dependencies,
  206. prepared,
  207. agent,
  208. compactionId,
  209. options.sourceCommandId,
  210. assertStable,
  211. signal,
  212. )
  213. if (options.owner === null) signal?.throwIfAborted()
  214. assertStable(dependencies, session, summarized)
  215. stage = 'commit'
  216. const pending = commitCompactionBody(session, startEvent, summarized)
  217. closing = true
  218. const endEvent = session.append('compaction/end', lifecycle)
  219. closed = true
  220. result = completeCompaction(pending, endEvent)
  221. } catch (error: unknown) {
  222. failure = { error, stage: closing ? 'commit' : stage }
  223. if (!closing) {
  224. closing = true
  225. try {
  226. session.append('compaction/end', { ...lifecycle, error: errorChain(error) })
  227. closed = true
  228. } catch (closeError: unknown) {
  229. failure = { error: closeError, stage: 'commit' }
  230. }
  231. }
  232. }
  233. if (closed && options.flush !== undefined) {
  234. try {
  235. await options.flush()
  236. } catch (error: unknown) {
  237. flushFailure = error
  238. }
  239. }
  240. if (options.owner === null) signal?.throwIfAborted()
  241. if (failure !== undefined) {
  242. if (options.owner === null) throwManualFailure(failure)
  243. throw failure.error
  244. }
  245. if (flushFailure !== undefined) {
  246. throw new ManualCompactionError(
  247. 'persistence',
  248. 'manual compaction durability checkpoint failed',
  249. { cause: flushFailure },
  250. )
  251. }
  252. /* v8 ignore next -- every path without a result records and throws a failure above. */
  253. if (result === undefined) throw new Error('compaction committed without a result')
  254. return result
  255. }
  256. /** Classify one closed manual attempt without weakening cancellation precedence. */
  257. function throwManualFailure(failure: TransactionFailure): never {
  258. if (failure.stage === 'commit') {
  259. throw new ManualCompactionError(
  260. 'commit',
  261. 'manual compaction did not commit cleanly',
  262. { cause: failure.error },
  263. )
  264. }
  265. if (failure.error instanceof SurfaceChangedError) {
  266. throw new ManualCompactionError(
  267. 'changed',
  268. 'the compacted history changed during manual compaction',
  269. { cause: failure.error },
  270. )
  271. }
  272. throw new ManualCompactionError(
  273. 'summary',
  274. 'manual compaction could not produce a smaller summary',
  275. { cause: failure.error },
  276. )
  277. }
  278. /**
  279. * Reject a durable unmatched compaction marker unless a later constructor-seed
  280. * boundary proves that its owner belongs to an earlier session lifecycle.
  281. * @param unmatchedCompactionStart - latest unmatched opening marker, if any.
  282. * @param latestEndSeedSeq - newest constructor-seed boundary, if any.
  283. * @param stage - operation label included in the busy diagnostic.
  284. */
  285. function assertCompactionInactive(
  286. unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined,
  287. latestEndSeedSeq: SessionSeq | undefined,
  288. stage: string,
  289. ): void {
  290. if (unmatchedCompactionStart === undefined
  291. || (latestEndSeedSeq !== undefined
  292. && latestEndSeedSeq > unmatchedCompactionStart.seq)) return
  293. throw new ManualCompactionError(
  294. 'busy',
  295. `${stage}: compaction already in progress; the session compaction lock is already active`,
  296. )
  297. }
  298. /**
  299. * Recheck the durable compaction lock after an asynchronous policy decision.
  300. * @param session - session whose latest marker state is inspected.
  301. * @param stage - operation label included in the busy diagnostic.
  302. */
  303. export function assertNoActiveCompaction(session: Session, stage: string): void {
  304. const entryState = inspectCompactionEntryState(session)
  305. assertCompactionInactive(
  306. entryState.unmatchedCompactionStart,
  307. entryState.latestEndSeedSeq,
  308. stage,
  309. )
  310. }
  311. /** Validate one requested surface-position span before asynchronous work begins. */
  312. function validateSurfaceRegion(session: Session, start: SessionSeq, end: SessionSeq): SurfaceSelection {
  313. const nodes = session.surface.nodes
  314. const startIdx = nodes.indexOf(start)
  315. const endIdx = nodes.indexOf(end)
  316. if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
  317. if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
  318. if (startIdx > endIdx) {
  319. throw new Error(
  320. `compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`,
  321. )
  322. }
  323. // oxlint-disable-next-line typescript/no-non-null-assertion
  324. if (!toolPairingBalancedBefore(session, nodes[startIdx]!)) {
  325. throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
  326. }
  327. // oxlint-disable-next-line typescript/no-non-null-assertion
  328. if (!toolPairingBalancedAfter(session, nodes[endIdx]!)) {
  329. throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
  330. }
  331. return { start, end, startIdx, endIdx, shadowedSeqs: nodes.slice(startIdx, endIdx + 1) }
  332. }
  333. /** Snapshot pricing and replay input for a validated surface range. */
  334. function prepareCompaction(
  335. dependencies: RegionDependencies,
  336. session: Session,
  337. selection: SurfaceSelection,
  338. ): PreparedCompaction {
  339. const measurement = dependencies.meter.measure(session)
  340. const selectedNodes = measurement.nodes.slice(selection.startIdx, selection.endIdx + 1)
  341. if (selectedNodes.length !== selection.shadowedSeqs.length
  342. || selectedNodes.some((node, index) => node.seq !== selection.shadowedSeqs[index])) {
  343. throw new SurfaceChangedError('compaction: selected surface changed before summarization began')
  344. }
  345. return {
  346. ...selection,
  347. measurement,
  348. selectedNodes,
  349. // The shadow-price protocol prices replacements with the fixed heuristic
  350. // so the O(1) projection fold stays in agreement with its own appends;
  351. // retention, range selection, and the shrink comparison read the
  352. // route-priced `tokens` instead.
  353. shadowedTokenCount: selectedNodes.reduce((total, node) => total + node.heuristicTokens, 0),
  354. shadowedRouteTokenCount: selectedNodes.reduce((total, node) => total + node.tokens, 0),
  355. input: buildSummarizationInput(session, selection.shadowedSeqs),
  356. }
  357. }
  358. /** Run the summarizer and frame its replacement checkpoint. */
  359. async function summarizeCompaction(
  360. dependencies: RegionDependencies,
  361. prepared: PreparedCompaction,
  362. agent: Agent,
  363. compactionId: CompactionResult['compactionId'],
  364. sourceCommandId: CommandId | undefined,
  365. assertStable: StabilityCheck,
  366. signal?: AbortSignal,
  367. ): Promise<SummarizedCompaction> {
  368. let summaryResult: SummaryResult
  369. for (;;) {
  370. signal?.throwIfAborted()
  371. try {
  372. summaryResult = await dependencies.summarize(prepared.input, agent, signal)
  373. break
  374. } catch (error: unknown) {
  375. if (signal?.aborted === true) throw error
  376. assertStable(dependencies, agent.session, prepared)
  377. if (!dependencies.recover(error, agent, prepared.shadowedSeqs, signal)) throw error
  378. prepared = prepareCompaction(dependencies, agent.session,
  379. validateSurfaceRegion(agent.session, prepared.start, prepared.end))
  380. }
  381. }
  382. const checkpointMessage = createUserMessage({
  383. content: frameSummary(summaryResult.summary),
  384. source: compactCheckpointSource(compactionId, sourceCommandId),
  385. })
  386. // The checkpoint is text-only, so its fixed-heuristic price IS its route
  387. // price; comparing it against the span's route price asks the real
  388. // question — does the replacement lower the next request's pressure.
  389. const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
  390. if (framedSummaryTokenCount >= prepared.shadowedRouteTokenCount) {
  391. throw new Error(
  392. `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedRouteTokenCount})`,
  393. )
  394. }
  395. return {
  396. ...prepared,
  397. ...summaryResult,
  398. checkpointMessage,
  399. }
  400. }
  401. /** Reject a summary prepared against any earlier surface generation. */
  402. function assertWholeSurfaceUnchanged(
  403. dependencies: RegionDependencies,
  404. session: Session,
  405. prepared: PreparedCompaction,
  406. ): void {
  407. const current = dependencies.meter.measure(session)
  408. if (!isDeepStrictEqual(current.nodes, prepared.measurement.nodes)) {
  409. throw new SurfaceChangedError('compaction: session surface changed during summarization')
  410. }
  411. }
  412. /**
  413. * Require only that the selected span remain the same present, contiguous,
  414. * equally priced, balanced replacement target. Nodes added outside it remain
  415. * visible and do not invalidate the summary.
  416. */
  417. function assertSelectedSpanStable(
  418. dependencies: RegionDependencies,
  419. session: Session,
  420. prepared: PreparedCompaction,
  421. ): void {
  422. let current: SurfaceSelection
  423. try {
  424. current = validateSurfaceRegion(session, prepared.start, prepared.end)
  425. } catch (error: unknown) {
  426. throw new SurfaceChangedError(
  427. 'compaction: the selected span is no longer a valid replacement target',
  428. { cause: error },
  429. )
  430. }
  431. if (!isDeepStrictEqual([...current.shadowedSeqs], [...prepared.shadowedSeqs])) {
  432. throw new SurfaceChangedError('compaction: the selected span changed during summarization')
  433. }
  434. const measured = dependencies.meter.measure(session).nodes.slice(current.startIdx, current.endIdx + 1)
  435. if (!isDeepStrictEqual(measured, prepared.selectedNodes)) {
  436. throw new SurfaceChangedError('compaction: the selected span was rewritten during summarization')
  437. }
  438. }
  439. /** Append one completed summary record and replacement body without yielding. */
  440. function commitCompactionBody(
  441. session: Session,
  442. startEvent: SessionEvent<'compaction/start'>,
  443. summarized: SummarizedCompaction,
  444. ): Omit<CompactionResult, 'endSeq'> {
  445. const {
  446. start,
  447. end,
  448. shadowedSeqs,
  449. shadowedTokenCount,
  450. summary,
  451. provider,
  452. model,
  453. maxTokens,
  454. usage,
  455. checkpointMessage,
  456. } = summarized
  457. const callRecord = summarized.llmStreamCall === true
  458. ? { rawOutput: summarized.rawOutput, llmStreamCall: true as const }
  459. : summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput }
  460. const summaryEvent = session.append('compaction/summary', {
  461. compactionId: startEvent.data.compactionId,
  462. ...startEvent.data.sourceCommandId === undefined
  463. ? {}
  464. : { sourceCommandId: startEvent.data.sourceCommandId },
  465. summary,
  466. ...callRecord,
  467. shadowedRange: { start, end },
  468. shadowedSeqs: [...shadowedSeqs],
  469. shadowedTokenCount,
  470. provider,
  471. model,
  472. ...maxTokens === undefined ? {} : { maxTokens },
  473. ...usage === undefined ? {} : { usage },
  474. })
  475. session.append('user/message', checkpointMessage, {
  476. surfaceOp: { op: 'replace', startSeq: start, endSeq: end },
  477. sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
  478. })
  479. return {
  480. compactionId: startEvent.data.compactionId,
  481. ...startEvent.data.sourceCommandId === undefined
  482. ? {}
  483. : { sourceCommandId: startEvent.data.sourceCommandId },
  484. startSeq: startEvent.seq,
  485. summarySeq: summaryEvent.seq,
  486. summary,
  487. shadowedRange: { start, end },
  488. shadowedSeqs: [...shadowedSeqs],
  489. shadowedTokenCount,
  490. }
  491. }
  492. /** Attach the successfully appended close event to a pending result. */
  493. function completeCompaction(
  494. pending: Omit<CompactionResult, 'endSeq'>,
  495. endEvent: SessionEvent<'compaction/end'>,
  496. ): CompactionResult {
  497. return { ...pending, endSeq: endEvent.seq }
  498. }
  499. /**
  500. * Reconstruct the last routed request's cacheable prefix for the shadowed
  501. * region: the system prompt held by the `system/message` at surface node 0,
  502. * the header's tool schemas, then the region's own derived messages in surface
  503. * order. The summarizer appends only the compaction instruction after this, so
  504. * the call is a genuine prefix of the conversation and reuses the provider's
  505. * KV cache. A surface without a system head, or whose head projects to no
  506. * message, contributes no leading system message.
  507. * @param session - session supplying the surface head, request header, and per-node projection.
  508. * @param shadowedSeqs - the surface-node seqs, in order, being compacted.
  509. * @returns the replayed conversation prefix to condense.
  510. */
  511. function buildSummarizationInput(
  512. session: Session,
  513. shadowedSeqs: readonly SessionSeq[],
  514. ): SummarizationInput {
  515. const header = session.requestHeader()
  516. // shadowedSeqs are current surface seqs, so the surface has a node 0.
  517. // oxlint-disable-next-line typescript/no-non-null-assertion
  518. const head = systemHead(session, session.surface.nodes[0]!)
  519. const system = head === undefined ? null : session.deriveEventMessage(head)
  520. const regionMessages = shadowedSeqs
  521. // shadowedSeqs are current surface seqs, so each is a valid log index.
  522. // Existing Session history read; migration deferred.
  523. // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated
  524. .map(seq => session.deriveEventMessage(session.eventAt(seq)!))
  525. .filter((message): message is Message => message !== null)
  526. return {
  527. ...header?.tools === undefined ? {} : { tools: header.tools },
  528. messages: system === null ? regionMessages : [system, ...regionMessages],
  529. }
  530. }
  531. /** Inspect open-turn, unmatched-compaction, and latest seed-boundary state independently. */
  532. function inspectCompactionEntryState(session: Session): CompactionEntryState {
  533. let openTurn: number | null = null
  534. let openTurnStateKnown = false
  535. let unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined
  536. let compactionEntryStateKnown = false
  537. let latestEndSeedSeq: SessionSeq | undefined
  538. for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
  539. // Existing Session history read; migration deferred.
  540. // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated
  541. const event = session.eventAt(SessionSeq(seq))!
  542. if (latestEndSeedSeq === undefined && event.type === 'session/end-seed') {
  543. latestEndSeedSeq = event.seq
  544. }
  545. if (!compactionEntryStateKnown) {
  546. if (event.type === 'compaction/start') {
  547. unmatchedCompactionStart = event
  548. compactionEntryStateKnown = true
  549. } else if (event.type === 'compaction/end') {
  550. compactionEntryStateKnown = true
  551. }
  552. }
  553. if (!openTurnStateKnown) {
  554. if (event.type === 'turn/start') {
  555. openTurn = event.data.turn
  556. openTurnStateKnown = true
  557. } else if (event.type === 'turn/end') {
  558. openTurnStateKnown = true
  559. }
  560. }
  561. if (openTurnStateKnown
  562. && compactionEntryStateKnown
  563. && latestEndSeedSeq !== undefined) break
  564. }
  565. return { openTurn, unmatchedCompactionStart, latestEndSeedSeq }
  566. }