message-feedback.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. import { randomUUID } from 'node:crypto'
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
  5. import { Session, SessionId, SessionLogOffset, SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session'
  6. import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
  7. import MessageFeedbackService from '../src/index.ts'
  8. import type {
  9. MessageFeedbackItem,
  10. MessageFeedbackVersion,
  11. } from '../src/index.ts'
  12. import {
  13. appendMessageFixture,
  14. messageFixture,
  15. setupHarness,
  16. type TestHarness,
  17. } from './helpers.ts'
  18. const harnesses: TestHarness[] = []
  19. async function harness(maxNoteBytes = 64): Promise<TestHarness> {
  20. const value = await setupHarness(maxNoteBytes)
  21. harnesses.push(value)
  22. return value
  23. }
  24. afterEach(async () => {
  25. vi.useRealTimers()
  26. await Promise.all(harnesses.splice(0).map(value => value.dispose()))
  27. })
  28. function staleVersion(): MessageFeedbackVersion {
  29. return randomUUID() as MessageFeedbackVersion
  30. }
  31. function expectItem(
  32. result: Awaited<ReturnType<TestHarness['ctx']['messageFeedback']['put']>>,
  33. ): MessageFeedbackItem {
  34. if (!result.ok) throw new Error(`expected feedback item, got ${result.error.code}`)
  35. return result.value
  36. }
  37. describe('MessageFeedbackService public contract', () => {
  38. it('publishes the exact Gateway namespace and Remote method names', async () => {
  39. const { ctx } = await harness()
  40. const binding = ctx.messageFeedback.typertRemote
  41. expect(binding.serviceKey).toBe('messageFeedback')
  42. expect(binding.namespace).toBe('messageFeedback')
  43. expect(remoteMethods(ctx.messageFeedback)).toEqual([
  44. { method: 'list', invocation: { kind: 'direct' } },
  45. { method: 'put', invocation: { kind: 'direct' } },
  46. { method: 'delete', invocation: { kind: 'direct' } },
  47. ])
  48. })
  49. it('returns session-not-found only for a definite persistence miss', async () => {
  50. const { ctx, persistence } = await harness()
  51. const missing = SessionId('missing-session')
  52. await expect(ctx.messageFeedback.list({ sessionId: missing })).resolves.toEqual({
  53. ok: false,
  54. error: { code: 'session-not-found', sessionId: missing },
  55. })
  56. const fixture = messageFixture('corrupt-session')
  57. persistence.setDurable({ meta: fixture.session.header, events: fixture.session.snapshotEvents() })
  58. const corruption = new Error('stored log checksum mismatch')
  59. persistence.readFailure = corruption
  60. await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption)
  61. })
  62. it('rechecks live ownership before returning a cold catalog miss', async () => {
  63. const { ctx, persistence } = await harness()
  64. const sessionId = SessionId('catalog-live-race')
  65. const listed = Promise.withResolvers<undefined>()
  66. const release = Promise.withResolvers<undefined>()
  67. persistence.onStat = async () => {
  68. listed.resolve(undefined)
  69. await release.promise
  70. }
  71. const pending = ctx.messageFeedback.list({ sessionId })
  72. await listed.promise
  73. ctx.sessions.create(sessionId, { meta: { createdAt: 1_700_000_000_001 } })
  74. release.resolve(undefined)
  75. await expect(pending).resolves.toEqual({ ok: true, value: { items: [] } })
  76. expect(persistence.statCalls).toBe(1)
  77. expect(persistence.readCalls).toBe(0)
  78. })
  79. it('returns session-not-found from mutations and conflicts on an observed version for an absent item', async () => {
  80. const { ctx, persistence } = await harness()
  81. const missing = SessionId('missing-mutations')
  82. const missingMessage = 'missing-message' as MessageId
  83. await expect(ctx.messageFeedback.put({
  84. sessionId: missing,
  85. messageId: missingMessage,
  86. rating: 'positive',
  87. ifVersion: null,
  88. })).resolves.toEqual({
  89. ok: false,
  90. error: { code: 'session-not-found', sessionId: missing },
  91. })
  92. await expect(ctx.messageFeedback.delete({
  93. sessionId: missing,
  94. messageId: missingMessage,
  95. ifVersion: staleVersion(),
  96. })).resolves.toEqual({
  97. ok: false,
  98. error: { code: 'session-not-found', sessionId: missing },
  99. })
  100. const fixture = messageFixture('absent-version-conflict')
  101. persistence.persist(fixture.session)
  102. const expected = staleVersion()
  103. await expect(ctx.messageFeedback.put({
  104. sessionId: fixture.session.id,
  105. messageId: fixture.assistantMessageIds[0],
  106. rating: 'positive',
  107. ifVersion: expected,
  108. })).resolves.toEqual({
  109. ok: false,
  110. error: { code: 'version-conflict', current: null },
  111. })
  112. })
  113. it('creates, updates, and retry-reads immutable items with monotonic Host times', async () => {
  114. const { ctx, persistence } = await harness()
  115. const fixture = messageFixture('timestamps')
  116. persistence.persist(fixture.session)
  117. const messageId = fixture.assistantMessageIds[0]
  118. vi.useFakeTimers()
  119. vi.setSystemTime(1_700_000_001_000)
  120. const created = expectItem(await ctx.messageFeedback.put({
  121. sessionId: fixture.session.id,
  122. messageId,
  123. rating: 'positive',
  124. note: ' exact prose ',
  125. ifVersion: null,
  126. }))
  127. expect(created).toMatchObject({
  128. messageId,
  129. rating: 'positive',
  130. note: ' exact prose ',
  131. createdAt: 1_700_000_001_000,
  132. updatedAt: 1_700_000_001_000,
  133. })
  134. expect(created.version).toMatch(/^[0-9a-f-]{36}$/u)
  135. expect(Object.isFrozen(created)).toBe(true)
  136. vi.setSystemTime(1_700_000_000_000)
  137. const updated = expectItem(await ctx.messageFeedback.put({
  138. sessionId: fixture.session.id,
  139. messageId,
  140. rating: 'negative',
  141. ifVersion: created.version,
  142. }))
  143. expect(updated).toMatchObject({
  144. messageId,
  145. rating: 'negative',
  146. createdAt: created.createdAt,
  147. updatedAt: created.updatedAt,
  148. })
  149. expect(updated.version).not.toBe(created.version)
  150. const retry = expectItem(await ctx.messageFeedback.put({
  151. sessionId: fixture.session.id,
  152. messageId,
  153. rating: 'negative',
  154. ifVersion: updated.version,
  155. }))
  156. expect(retry).toEqual(updated)
  157. const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id })
  158. if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`)
  159. expect(listed.value.items).toEqual([updated])
  160. expect(listed.value.items[0]).not.toBe(updated)
  161. expect(Object.isFrozen(listed.value)).toBe(true)
  162. expect(Object.isFrozen(listed.value.items)).toBe(true)
  163. expect(Object.isFrozen(listed.value.items[0])).toBe(true)
  164. })
  165. it('reports non-blank and complete UTF-8 byte limits without touching persistence', async () => {
  166. const { ctx, persistence } = await harness(4)
  167. const fixture = messageFixture('note-limits')
  168. persistence.persist(fixture.session)
  169. const messageId = fixture.assistantMessageIds[0]
  170. const before = persistence.statCalls + persistence.readCalls
  171. await expect(ctx.messageFeedback.put({
  172. sessionId: fixture.session.id,
  173. messageId,
  174. rating: 'positive',
  175. note: ' \n\t ',
  176. ifVersion: null,
  177. })).resolves.toEqual({ ok: false, error: { code: 'note-blank' } })
  178. await expect(ctx.messageFeedback.put({
  179. sessionId: fixture.session.id,
  180. messageId,
  181. rating: 'positive',
  182. note: 'ééé',
  183. ifVersion: null,
  184. })).resolves.toEqual({
  185. ok: false,
  186. error: { code: 'note-too-large', maxBytes: 4, actualBytes: 6 },
  187. })
  188. expect(persistence.statCalls + persistence.readCalls).toBe(before)
  189. expectItem(await ctx.messageFeedback.put({
  190. sessionId: fixture.session.id,
  191. messageId,
  192. rating: 'positive',
  193. note: '😀',
  194. ifVersion: null,
  195. }))
  196. })
  197. it('accepts only non-empty assistant projections as targets', async () => {
  198. const { ctx, persistence } = await harness()
  199. const fixture = messageFixture('targets')
  200. persistence.persist(fixture.session)
  201. const rejectedTargets: MessageId[] = [
  202. fixture.userMessageId,
  203. fixture.emptyAssistantMessageId,
  204. ]
  205. for (const messageId of rejectedTargets) {
  206. await expect(ctx.messageFeedback.put({
  207. sessionId: fixture.session.id,
  208. messageId,
  209. rating: 'positive',
  210. ifVersion: null,
  211. })).resolves.toEqual({
  212. ok: false,
  213. error: {
  214. code: 'target-not-found',
  215. sessionId: fixture.session.id,
  216. messageId,
  217. },
  218. })
  219. }
  220. expectItem(await ctx.messageFeedback.put({
  221. sessionId: fixture.session.id,
  222. messageId: fixture.assistantMessageIds[0],
  223. rating: 'positive',
  224. ifVersion: null,
  225. }))
  226. })
  227. it('rejects invalid configuration', async () => {
  228. const ctx = new Context()
  229. try {
  230. expect(() => new MessageFeedbackService(ctx, { maxNoteBytes: 0 })).toThrow(/positive safe integer/u)
  231. } finally {
  232. await ctx.fiber.dispose()
  233. }
  234. })
  235. })
  236. describe('MessageFeedbackService item concurrency', () => {
  237. it('allows only one of two concurrent creates for the same message', async () => {
  238. const { ctx, persistence } = await harness()
  239. const fixture = messageFixture('same-item-race')
  240. persistence.persist(fixture.session)
  241. const request = {
  242. sessionId: fixture.session.id, messageId: fixture.assistantMessageIds[0], rating: 'positive' as const, ifVersion: null,
  243. }
  244. const [first, second] = await Promise.all([ctx.messageFeedback.put(request), ctx.messageFeedback.put(request)])
  245. const item = expectItem(first)
  246. expect(second).toEqual({ ok: false, error: { code: 'version-conflict', current: item } })
  247. expect(persistence.appendCalls).toBe(1)
  248. })
  249. it('serializes canonical event writes while keeping versions independent per message', async () => {
  250. const { ctx, persistence } = await harness()
  251. const fixture = messageFixture('concurrent-items')
  252. persistence.persist(fixture.session)
  253. const [firstId, secondId] = fixture.assistantMessageIds
  254. const [firstResult, secondResult] = await Promise.all([
  255. ctx.messageFeedback.put({
  256. sessionId: fixture.session.id,
  257. messageId: firstId,
  258. rating: 'positive',
  259. ifVersion: null,
  260. }),
  261. ctx.messageFeedback.put({
  262. sessionId: fixture.session.id,
  263. messageId: secondId,
  264. rating: 'negative',
  265. ifVersion: null,
  266. }),
  267. ])
  268. const first = expectItem(firstResult)
  269. const second = expectItem(secondResult)
  270. const updated = expectItem(await ctx.messageFeedback.put({
  271. sessionId: fixture.session.id,
  272. messageId: firstId,
  273. rating: 'negative',
  274. note: 'changed',
  275. ifVersion: first.version,
  276. }))
  277. await expect(ctx.messageFeedback.put({
  278. sessionId: fixture.session.id,
  279. messageId: firstId,
  280. rating: 'positive',
  281. note: 'stale change',
  282. ifVersion: first.version,
  283. })).resolves.toEqual({
  284. ok: false,
  285. error: { code: 'version-conflict', current: updated },
  286. })
  287. const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id })
  288. if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`)
  289. expect(listed.value.items).toEqual([updated, second])
  290. expect(listed.value.items[1]?.version).toBe(second.version)
  291. })
  292. it('rejects a stale put even when the current value has returned to the same state', async () => {
  293. const { ctx, persistence } = await harness()
  294. const fixture = messageFixture('put-aba')
  295. persistence.persist(fixture.session)
  296. const messageId = fixture.assistantMessageIds[0]
  297. const first = expectItem(await ctx.messageFeedback.put({
  298. sessionId: fixture.session.id,
  299. messageId,
  300. rating: 'positive',
  301. ifVersion: null,
  302. }))
  303. const second = expectItem(await ctx.messageFeedback.put({
  304. sessionId: fixture.session.id,
  305. messageId,
  306. rating: 'negative',
  307. ifVersion: first.version,
  308. }))
  309. const current = expectItem(await ctx.messageFeedback.put({
  310. sessionId: fixture.session.id,
  311. messageId,
  312. rating: 'positive',
  313. ifVersion: second.version,
  314. }))
  315. await expect(ctx.messageFeedback.put({
  316. sessionId: fixture.session.id,
  317. messageId,
  318. rating: 'positive',
  319. ifVersion: first.version,
  320. })).resolves.toEqual({
  321. ok: false,
  322. error: { code: 'version-conflict', current },
  323. })
  324. })
  325. it('makes delete retries stable and prevents delete/recreate ABA', async () => {
  326. const { ctx, persistence } = await harness()
  327. const fixture = messageFixture('delete-aba')
  328. persistence.persist(fixture.session)
  329. const messageId = fixture.assistantMessageIds[0]
  330. const created = expectItem(await ctx.messageFeedback.put({
  331. sessionId: fixture.session.id,
  332. messageId,
  333. rating: 'positive',
  334. ifVersion: null,
  335. }))
  336. await expect(ctx.messageFeedback.delete({
  337. sessionId: fixture.session.id,
  338. messageId,
  339. ifVersion: staleVersion(),
  340. })).resolves.toEqual({
  341. ok: false,
  342. error: { code: 'version-conflict', current: created },
  343. })
  344. const request = {
  345. sessionId: fixture.session.id,
  346. messageId,
  347. ifVersion: created.version,
  348. }
  349. await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
  350. ok: true,
  351. value: { absent: true },
  352. })
  353. await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
  354. ok: true,
  355. value: { absent: true },
  356. })
  357. const recreated = expectItem(await ctx.messageFeedback.put({
  358. sessionId: fixture.session.id,
  359. messageId,
  360. rating: 'negative',
  361. ifVersion: null,
  362. }))
  363. expect(recreated.version).not.toBe(created.version)
  364. await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
  365. ok: false,
  366. error: { code: 'version-conflict', current: recreated },
  367. })
  368. })
  369. it('starts clean when a stored log is replaced without feedback events', async () => {
  370. const { ctx, persistence } = await harness()
  371. const old = messageFixture('reused-session', { createdAt: 10, cwd: '/old' })
  372. persistence.persist(old.session)
  373. const oldItem = expectItem(await ctx.messageFeedback.put({
  374. sessionId: old.session.id,
  375. messageId: old.assistantMessageIds[0],
  376. rating: 'positive',
  377. ifVersion: null,
  378. }))
  379. const replacement = Session.create(
  380. old.session.id,
  381. old.session.snapshotEvents(),
  382. { ...old.session.header, createdAt: 20, cwd: '/new' },
  383. )
  384. persistence.persist(replacement)
  385. await expect(ctx.messageFeedback.list({ sessionId: replacement.id })).resolves.toEqual({
  386. ok: true,
  387. value: { items: [] },
  388. })
  389. await expect(ctx.messageFeedback.delete({
  390. sessionId: replacement.id,
  391. messageId: old.assistantMessageIds[0],
  392. ifVersion: oldItem.version,
  393. })).resolves.toEqual({ ok: true, value: { absent: true } })
  394. const newItem = expectItem(await ctx.messageFeedback.put({
  395. sessionId: replacement.id,
  396. messageId: old.assistantMessageIds[0],
  397. rating: 'negative',
  398. ifVersion: null,
  399. }))
  400. expect(newItem.version).not.toBe(oldItem.version)
  401. })
  402. it('drains admitted mutations before disposal and rejects later admission', async () => {
  403. const current = await harness()
  404. const { ctx, persistence } = current
  405. const fixture = messageFixture('dispose-quiescence')
  406. persistence.persist(fixture.session)
  407. const service = ctx.messageFeedback
  408. const lifecycle = service as unknown as { readonly mutationAdmissionOpen: boolean }
  409. const started = Promise.withResolvers<undefined>()
  410. const release = Promise.withResolvers<undefined>()
  411. let physicalReads = 0
  412. persistence.onRead = async () => {
  413. physicalReads += 1
  414. if (physicalReads !== 1) return
  415. started.resolve(undefined)
  416. await release.promise
  417. }
  418. const first = service.put({
  419. sessionId: fixture.session.id,
  420. messageId: fixture.assistantMessageIds[0],
  421. rating: 'positive',
  422. ifVersion: null,
  423. })
  424. await started.promise
  425. const second = service.put({
  426. sessionId: fixture.session.id,
  427. messageId: fixture.assistantMessageIds[1],
  428. rating: 'negative',
  429. ifVersion: null,
  430. })
  431. const disposal = current.disposeFeedback()
  432. await vi.waitFor(() => { expect(lifecycle.mutationAdmissionOpen).toBe(false) })
  433. await expect(service.delete({
  434. sessionId: fixture.session.id,
  435. messageId: fixture.assistantMessageIds[0],
  436. ifVersion: staleVersion(),
  437. })).rejects.toThrow('message-feedback: service is disposing')
  438. release.resolve(undefined)
  439. expectItem(await first)
  440. expectItem(await second)
  441. await disposal
  442. expect(physicalReads).toBe(2)
  443. expect(persistence.appendCalls).toBe(2)
  444. expect(persistence.closeCalls).toBe(2)
  445. })
  446. })
  447. describe('canonical message feedback history', () => {
  448. it('appends only material cold mutations and leaves lifecycle and model history alone', async () => {
  449. const { ctx, persistence } = await harness()
  450. const fixture = messageFixture('cold-log')
  451. const sessionId = fixture.session.id
  452. const messageId = fixture.assistantMessageIds[0]
  453. persistence.persist(fixture.session)
  454. const prefix = fixture.session.snapshotEvents()
  455. const lifecycle: string[] = []
  456. ctx.on('session/created', () => { lifecycle.push('created') })
  457. ctx.on('session/event', () => { lifecycle.push('event') })
  458. const created = expectItem(await ctx.messageFeedback.put({ sessionId, messageId, rating: 'positive', note: ' exact\ntext ', ifVersion: null }))
  459. const edited = expectItem(await ctx.messageFeedback.put({ sessionId, messageId, rating: 'negative', ifVersion: created.version }))
  460. expectItem(await ctx.messageFeedback.put({ sessionId, messageId, rating: 'negative', ifVersion: edited.version }))
  461. await ctx.messageFeedback.delete({ sessionId, messageId, ifVersion: edited.version })
  462. await ctx.messageFeedback.delete({ sessionId, messageId, ifVersion: edited.version })
  463. const events = persistence.durable.get(sessionId)!.events
  464. expect(events.slice(0, prefix.length)).toEqual(prefix)
  465. expect(events.slice(prefix.length).map(({ type, data }) => ({ type, data }))).toEqual([
  466. { type: 'feedback/message-put', data: { sessionId, item: created } },
  467. { type: 'feedback/message-put', data: { sessionId, item: edited } },
  468. { type: 'feedback/message-delete', data: { sessionId, messageId } },
  469. ])
  470. expect(events.map(event => event.seq)).toEqual(events.map((_, index) => index))
  471. expect(lifecycle).toEqual([])
  472. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  473. expect(persistence.openCalls).toEqual(['write', 'write', 'write', 'write', 'write'])
  474. expect(persistence.closeCalls).toBe(5)
  475. })
  476. it('starts a fork without inherited feedback and keeps parent mutations independent', async () => {
  477. const { ctx, persistence } = await harness()
  478. const parent = messageFixture('feedback-parent')
  479. persistence.persist(parent.session)
  480. const messageId = parent.assistantMessageIds[0]
  481. const parentItem = expectItem(await ctx.messageFeedback.put({ sessionId: parent.session.id, messageId, rating: 'positive', ifVersion: null }))
  482. const seed = persistence.durable.get(parent.session.id)!.events
  483. const childId = SessionId('feedback-child')
  484. const child = Session.create(childId, seed, {
  485. ...parent.session.header, id: childId, isSeeded: true, parentSession: parent.session.id,
  486. }, SessionLogOffset(seed.length))
  487. persistence.persist(child)
  488. await expect(ctx.messageFeedback.list({ sessionId: childId })).resolves.toEqual({ ok: true, value: { items: [] } })
  489. const childItem = expectItem(await ctx.messageFeedback.put({ sessionId: childId, messageId, rating: 'negative', ifVersion: null }))
  490. await ctx.messageFeedback.delete({ sessionId: parent.session.id, messageId, ifVersion: parentItem.version })
  491. await expect(ctx.messageFeedback.list({ sessionId: childId })).resolves.toEqual({ ok: true, value: { items: [childItem] } })
  492. })
  493. it('flushes live feedback with the target and retries durability without a duplicate event', async () => {
  494. const { ctx, persistence } = await harness()
  495. const session = ctx.sessions.create(SessionId('live-log'))
  496. const fixture = appendMessageFixture(session)
  497. const before = session.snapshotEvents().length
  498. const failure = new Error('disk unavailable')
  499. let fail = true
  500. ctx.on('session/flush', (current) => {
  501. if (fail) throw failure
  502. persistence.persist(current)
  503. })
  504. const request = { sessionId: session.id, messageId: fixture.assistantMessageIds[0], rating: 'positive' as const, ifVersion: null }
  505. await expect(ctx.messageFeedback.put(request)).rejects.toBe(failure)
  506. const listed = await ctx.messageFeedback.list({ sessionId: session.id })
  507. if (!listed.ok) throw new Error('missing live session')
  508. const item = listed.value.items[0]!
  509. fail = false
  510. expectItem(await ctx.messageFeedback.put({ ...request, ifVersion: item.version }))
  511. expect(session.snapshotEvents()).toHaveLength(before + 1)
  512. expect(persistence.durable.get(session.id)?.events).toEqual(session.snapshotEvents())
  513. expect(persistence.openCalls).toEqual(['read'])
  514. })
  515. it.each(['missing-tail', 'different-tail', 'different-lifecycle'] as const)(
  516. 'rejects a live checkpoint with %s and closes its verification handle', async (kind) => {
  517. const { ctx, persistence } = await harness()
  518. const session = ctx.sessions.create(SessionId('mismatched-checkpoint'))
  519. const fixture = appendMessageFixture(session)
  520. ctx.on('session/flush', () => {
  521. const events = [...session.snapshotEvents()]
  522. if (kind === 'missing-tail') events.pop()
  523. if (kind === 'different-tail') events[events.length - 1] = { ...events.at(-1)!, time: 0 }
  524. const meta = kind === 'different-lifecycle' ? { ...session.header, createdAt: 0 } : session.header
  525. persistence.setDurable({ meta, events })
  526. })
  527. await expect(ctx.messageFeedback.put({
  528. sessionId: session.id, messageId: fixture.assistantMessageIds[0], rating: 'positive', ifVersion: null,
  529. })).rejects.toThrow(/feedback prefix is not durable/u)
  530. expect(persistence.closeCalls).toBe(1)
  531. },
  532. )
  533. it('verifies an empty live no-op and captures its checkpoint before concurrent appends', async () => {
  534. const { ctx, persistence } = await harness()
  535. const session = ctx.sessions.create(SessionId('checkpoint-prefix'))
  536. ctx.on('session/flush', () => { persistence.persist(session) })
  537. await expect(ctx.messageFeedback.delete({ sessionId: session.id, messageId: 'absent' as MessageId, ifVersion: staleVersion() }))
  538. .resolves.toEqual({ ok: true, value: { absent: true } })
  539. const fixture = appendMessageFixture(session)
  540. persistence.onRead = () => { session.append('turn/start', { turn: 2 }) }
  541. expectItem(await ctx.messageFeedback.put({
  542. sessionId: session.id, messageId: fixture.assistantMessageIds[0], rating: 'positive', ifVersion: null,
  543. }))
  544. expect(persistence.durable.get(session.id)!.events.length).toBe(session.snapshotEvents().length - 1)
  545. })
  546. it('rejects an unowned durability checkpoint and closes cold handles on failures', async () => {
  547. const { ctx, persistence } = await harness()
  548. const live = ctx.sessions.create(SessionId('no-flush-owner'))
  549. const fixture = appendMessageFixture(live)
  550. await expect(ctx.messageFeedback.put({ sessionId: live.id, messageId: fixture.assistantMessageIds[0], rating: 'positive', ifVersion: null }))
  551. .rejects.toThrow(/no durability listener participated/u)
  552. const cold = messageFixture('cold-failures')
  553. persistence.persist(cold.session)
  554. const request = { sessionId: cold.session.id, messageId: cold.assistantMessageIds[0], rating: 'positive' as const, ifVersion: null }
  555. const appendFailure = new Error('append failed')
  556. persistence.appendFailure = appendFailure
  557. await expect(ctx.messageFeedback.put(request)).rejects.toBe(appendFailure)
  558. expect(persistence.closeCalls).toBe(1)
  559. persistence.appendFailure = undefined
  560. const flushFailure = new Error('flush failed')
  561. persistence.flushFailure = flushFailure
  562. await expect(ctx.messageFeedback.put(request)).rejects.toBe(flushFailure)
  563. expect(persistence.closeCalls).toBe(2)
  564. })
  565. it.each([
  566. { type: 'feedback/message-put', data: null },
  567. { type: 'feedback/message-put', data: { sessionId: 'x', item: {} } },
  568. ...[
  569. { messageId: '' }, { rating: 'neutral' }, { version: 'bad-token' }, { note: ' ' },
  570. { createdAt: -1 }, { updatedAt: 0 }, { updatedAt: 1.5 },
  571. ].map(patch => ({ type: 'feedback/message-put', data: { sessionId: 'x', item: {
  572. messageId: 'message', rating: 'positive', version: randomUUID(), createdAt: 1, updatedAt: 1, ...patch,
  573. } } })),
  574. { type: 'feedback/message-delete', data: { sessionId: 'x', messageId: '' } },
  575. { type: 'feedback/message-delete', data: { sessionId: 12, messageId: 'message' } },
  576. ])('rejects malformed durable feedback payload %#', async (record) => {
  577. const { ctx, persistence } = await harness()
  578. const fixture = messageFixture('invalid-feedback')
  579. const events = fixture.session.snapshotEvents()
  580. persistence.setDurable({ meta: fixture.session.header, events: [...events, {
  581. ...record, seq: SessionSeq(events.length), time: 1,
  582. } as SessionEvent] })
  583. await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toThrow()
  584. expect(persistence.closeCalls).toBe(1)
  585. })
  586. })