message-feedback.spec.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  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('stores a category with the judgment, treats a category change as material, and validates stored categories', async () => {
  166. const { ctx, persistence } = await harness()
  167. const fixture = messageFixture('categories')
  168. persistence.persist(fixture.session)
  169. const messageId = fixture.assistantMessageIds[0]
  170. const created = expectItem(await ctx.messageFeedback.put({
  171. sessionId: fixture.session.id,
  172. messageId,
  173. rating: 'negative',
  174. note: 'wrong file',
  175. category: 'task-result',
  176. ifVersion: null,
  177. }))
  178. expect(created).toMatchObject({ rating: 'negative', note: 'wrong file', category: 'task-result' })
  179. // The same value is a no-op; a different category is a material edit;
  180. // omitting the category drops it.
  181. const same = expectItem(await ctx.messageFeedback.put({
  182. sessionId: fixture.session.id, messageId, rating: 'negative', note: 'wrong file', category: 'task-result',
  183. ifVersion: created.version,
  184. }))
  185. expect(same).toEqual(created)
  186. const recategorized = expectItem(await ctx.messageFeedback.put({
  187. sessionId: fixture.session.id, messageId, rating: 'negative', note: 'wrong file', category: 'other',
  188. ifVersion: created.version,
  189. }))
  190. expect(recategorized.version).not.toBe(created.version)
  191. expect(recategorized.category).toBe('other')
  192. const dropped = expectItem(await ctx.messageFeedback.put({
  193. sessionId: fixture.session.id, messageId, rating: 'negative', ifVersion: recategorized.version,
  194. }))
  195. expect(dropped).not.toHaveProperty('category')
  196. expect(dropped).not.toHaveProperty('note')
  197. const events = (persistence.durable.get(fixture.session.id)?.events ?? [])
  198. .filter(event => event.type === 'feedback/message-put')
  199. .map(event => event.data.item.category)
  200. expect(events).toEqual(['task-result', 'other', undefined])
  201. // A stored payload outside the fixed taxonomy is refused on read.
  202. const corrupt = messageFixture('corrupt-category')
  203. corrupt.session.append('feedback/message-put', {
  204. sessionId: corrupt.session.id,
  205. item: {
  206. messageId: corrupt.assistantMessageIds[0],
  207. rating: 'negative',
  208. category: 'not-a-category' as never,
  209. version: staleVersion(),
  210. createdAt: 1,
  211. updatedAt: 1,
  212. },
  213. })
  214. persistence.persist(corrupt.session)
  215. await expect(ctx.messageFeedback.list({ sessionId: corrupt.session.id })).rejects.toThrow()
  216. })
  217. it('reports non-blank and complete UTF-8 byte limits without touching persistence', async () => {
  218. const { ctx, persistence } = await harness(4)
  219. const fixture = messageFixture('note-limits')
  220. persistence.persist(fixture.session)
  221. const messageId = fixture.assistantMessageIds[0]
  222. const before = persistence.statCalls + persistence.readCalls
  223. await expect(ctx.messageFeedback.put({
  224. sessionId: fixture.session.id,
  225. messageId,
  226. rating: 'positive',
  227. note: ' \n\t ',
  228. ifVersion: null,
  229. })).resolves.toEqual({ ok: false, error: { code: 'note-blank' } })
  230. await expect(ctx.messageFeedback.put({
  231. sessionId: fixture.session.id,
  232. messageId,
  233. rating: 'positive',
  234. note: 'ééé',
  235. ifVersion: null,
  236. })).resolves.toEqual({
  237. ok: false,
  238. error: { code: 'note-too-large', maxBytes: 4, actualBytes: 6 },
  239. })
  240. expect(persistence.statCalls + persistence.readCalls).toBe(before)
  241. expectItem(await ctx.messageFeedback.put({
  242. sessionId: fixture.session.id,
  243. messageId,
  244. rating: 'positive',
  245. note: '😀',
  246. ifVersion: null,
  247. }))
  248. })
  249. it('accepts only non-empty assistant projections as targets', async () => {
  250. const { ctx, persistence } = await harness()
  251. const fixture = messageFixture('targets')
  252. persistence.persist(fixture.session)
  253. const rejectedTargets: MessageId[] = [
  254. fixture.userMessageId,
  255. fixture.emptyAssistantMessageId,
  256. ]
  257. for (const messageId of rejectedTargets) {
  258. await expect(ctx.messageFeedback.put({
  259. sessionId: fixture.session.id,
  260. messageId,
  261. rating: 'positive',
  262. ifVersion: null,
  263. })).resolves.toEqual({
  264. ok: false,
  265. error: {
  266. code: 'target-not-found',
  267. sessionId: fixture.session.id,
  268. messageId,
  269. },
  270. })
  271. }
  272. expectItem(await ctx.messageFeedback.put({
  273. sessionId: fixture.session.id,
  274. messageId: fixture.assistantMessageIds[0],
  275. rating: 'positive',
  276. ifVersion: null,
  277. }))
  278. })
  279. it('rejects invalid configuration', async () => {
  280. const ctx = new Context()
  281. try {
  282. expect(() => new MessageFeedbackService(ctx, { maxNoteBytes: 0 })).toThrow(/positive safe integer/u)
  283. } finally {
  284. await ctx.fiber.dispose()
  285. }
  286. })
  287. })
  288. describe('MessageFeedbackService item concurrency', () => {
  289. it('allows only one of two concurrent creates for the same message', async () => {
  290. const { ctx, persistence } = await harness()
  291. const fixture = messageFixture('same-item-race')
  292. persistence.persist(fixture.session)
  293. const request = {
  294. sessionId: fixture.session.id, messageId: fixture.assistantMessageIds[0], rating: 'positive' as const, ifVersion: null,
  295. }
  296. const [first, second] = await Promise.all([ctx.messageFeedback.put(request), ctx.messageFeedback.put(request)])
  297. const item = expectItem(first)
  298. expect(second).toEqual({ ok: false, error: { code: 'version-conflict', current: item } })
  299. expect(persistence.appendCalls).toBe(1)
  300. })
  301. it('serializes canonical event writes while keeping versions independent per message', async () => {
  302. const { ctx, persistence } = await harness()
  303. const fixture = messageFixture('concurrent-items')
  304. persistence.persist(fixture.session)
  305. const [firstId, secondId] = fixture.assistantMessageIds
  306. const [firstResult, secondResult] = await Promise.all([
  307. ctx.messageFeedback.put({
  308. sessionId: fixture.session.id,
  309. messageId: firstId,
  310. rating: 'positive',
  311. ifVersion: null,
  312. }),
  313. ctx.messageFeedback.put({
  314. sessionId: fixture.session.id,
  315. messageId: secondId,
  316. rating: 'negative',
  317. ifVersion: null,
  318. }),
  319. ])
  320. const first = expectItem(firstResult)
  321. const second = expectItem(secondResult)
  322. const updated = expectItem(await ctx.messageFeedback.put({
  323. sessionId: fixture.session.id,
  324. messageId: firstId,
  325. rating: 'negative',
  326. note: 'changed',
  327. ifVersion: first.version,
  328. }))
  329. await expect(ctx.messageFeedback.put({
  330. sessionId: fixture.session.id,
  331. messageId: firstId,
  332. rating: 'positive',
  333. note: 'stale change',
  334. ifVersion: first.version,
  335. })).resolves.toEqual({
  336. ok: false,
  337. error: { code: 'version-conflict', current: updated },
  338. })
  339. const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id })
  340. if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`)
  341. expect(listed.value.items).toEqual([updated, second])
  342. expect(listed.value.items[1]?.version).toBe(second.version)
  343. })
  344. it('rejects a stale put even when the current value has returned to the same state', async () => {
  345. const { ctx, persistence } = await harness()
  346. const fixture = messageFixture('put-aba')
  347. persistence.persist(fixture.session)
  348. const messageId = fixture.assistantMessageIds[0]
  349. const first = expectItem(await ctx.messageFeedback.put({
  350. sessionId: fixture.session.id,
  351. messageId,
  352. rating: 'positive',
  353. ifVersion: null,
  354. }))
  355. const second = expectItem(await ctx.messageFeedback.put({
  356. sessionId: fixture.session.id,
  357. messageId,
  358. rating: 'negative',
  359. ifVersion: first.version,
  360. }))
  361. const current = expectItem(await ctx.messageFeedback.put({
  362. sessionId: fixture.session.id,
  363. messageId,
  364. rating: 'positive',
  365. ifVersion: second.version,
  366. }))
  367. await expect(ctx.messageFeedback.put({
  368. sessionId: fixture.session.id,
  369. messageId,
  370. rating: 'positive',
  371. ifVersion: first.version,
  372. })).resolves.toEqual({
  373. ok: false,
  374. error: { code: 'version-conflict', current },
  375. })
  376. })
  377. it('makes delete retries stable and prevents delete/recreate ABA', async () => {
  378. const { ctx, persistence } = await harness()
  379. const fixture = messageFixture('delete-aba')
  380. persistence.persist(fixture.session)
  381. const messageId = fixture.assistantMessageIds[0]
  382. const created = expectItem(await ctx.messageFeedback.put({
  383. sessionId: fixture.session.id,
  384. messageId,
  385. rating: 'positive',
  386. ifVersion: null,
  387. }))
  388. await expect(ctx.messageFeedback.delete({
  389. sessionId: fixture.session.id,
  390. messageId,
  391. ifVersion: staleVersion(),
  392. })).resolves.toEqual({
  393. ok: false,
  394. error: { code: 'version-conflict', current: created },
  395. })
  396. const request = {
  397. sessionId: fixture.session.id,
  398. messageId,
  399. ifVersion: created.version,
  400. }
  401. await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
  402. ok: true,
  403. value: { absent: true },
  404. })
  405. await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
  406. ok: true,
  407. value: { absent: true },
  408. })
  409. const recreated = expectItem(await ctx.messageFeedback.put({
  410. sessionId: fixture.session.id,
  411. messageId,
  412. rating: 'negative',
  413. ifVersion: null,
  414. }))
  415. expect(recreated.version).not.toBe(created.version)
  416. await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
  417. ok: false,
  418. error: { code: 'version-conflict', current: recreated },
  419. })
  420. })
  421. it('starts clean when a stored log is replaced without feedback events', async () => {
  422. const { ctx, persistence } = await harness()
  423. const old = messageFixture('reused-session', { createdAt: 10, cwd: '/old' })
  424. persistence.persist(old.session)
  425. const oldItem = expectItem(await ctx.messageFeedback.put({
  426. sessionId: old.session.id,
  427. messageId: old.assistantMessageIds[0],
  428. rating: 'positive',
  429. ifVersion: null,
  430. }))
  431. const replacement = Session.create(
  432. old.session.id,
  433. old.session.snapshotEvents(),
  434. { ...old.session.header, createdAt: 20, cwd: '/new' },
  435. )
  436. persistence.persist(replacement)
  437. await expect(ctx.messageFeedback.list({ sessionId: replacement.id })).resolves.toEqual({
  438. ok: true,
  439. value: { items: [] },
  440. })
  441. await expect(ctx.messageFeedback.delete({
  442. sessionId: replacement.id,
  443. messageId: old.assistantMessageIds[0],
  444. ifVersion: oldItem.version,
  445. })).resolves.toEqual({ ok: true, value: { absent: true } })
  446. const newItem = expectItem(await ctx.messageFeedback.put({
  447. sessionId: replacement.id,
  448. messageId: old.assistantMessageIds[0],
  449. rating: 'negative',
  450. ifVersion: null,
  451. }))
  452. expect(newItem.version).not.toBe(oldItem.version)
  453. })
  454. it('drains admitted mutations before disposal and rejects later admission', async () => {
  455. const current = await harness()
  456. const { ctx, persistence } = current
  457. const fixture = messageFixture('dispose-quiescence')
  458. persistence.persist(fixture.session)
  459. const service = ctx.messageFeedback
  460. const lifecycle = service as unknown as { readonly mutationAdmissionOpen: boolean }
  461. const started = Promise.withResolvers<undefined>()
  462. const release = Promise.withResolvers<undefined>()
  463. let physicalReads = 0
  464. persistence.onRead = async () => {
  465. physicalReads += 1
  466. if (physicalReads !== 1) return
  467. started.resolve(undefined)
  468. await release.promise
  469. }
  470. const first = service.put({
  471. sessionId: fixture.session.id,
  472. messageId: fixture.assistantMessageIds[0],
  473. rating: 'positive',
  474. ifVersion: null,
  475. })
  476. await started.promise
  477. const second = service.put({
  478. sessionId: fixture.session.id,
  479. messageId: fixture.assistantMessageIds[1],
  480. rating: 'negative',
  481. ifVersion: null,
  482. })
  483. const disposal = current.disposeFeedback()
  484. await vi.waitFor(() => { expect(lifecycle.mutationAdmissionOpen).toBe(false) })
  485. await expect(service.delete({
  486. sessionId: fixture.session.id,
  487. messageId: fixture.assistantMessageIds[0],
  488. ifVersion: staleVersion(),
  489. })).rejects.toThrow('message-feedback: service is disposing')
  490. release.resolve(undefined)
  491. expectItem(await first)
  492. expectItem(await second)
  493. await disposal
  494. expect(physicalReads).toBe(2)
  495. expect(persistence.appendCalls).toBe(2)
  496. expect(persistence.closeCalls).toBe(2)
  497. })
  498. })
  499. describe('canonical message feedback history', () => {
  500. it('appends only material cold mutations and leaves lifecycle and model history alone', async () => {
  501. const { ctx, persistence } = await harness()
  502. const fixture = messageFixture('cold-log')
  503. const sessionId = fixture.session.id
  504. const messageId = fixture.assistantMessageIds[0]
  505. persistence.persist(fixture.session)
  506. const prefix = fixture.session.snapshotEvents()
  507. const lifecycle: string[] = []
  508. ctx.on('session/created', () => { lifecycle.push('created') })
  509. ctx.on('session/event', () => { lifecycle.push('event') })
  510. const created = expectItem(await ctx.messageFeedback.put({ sessionId, messageId, rating: 'positive', note: ' exact\ntext ', ifVersion: null }))
  511. const edited = expectItem(await ctx.messageFeedback.put({ sessionId, messageId, rating: 'negative', ifVersion: created.version }))
  512. expectItem(await ctx.messageFeedback.put({ sessionId, messageId, rating: 'negative', ifVersion: edited.version }))
  513. await ctx.messageFeedback.delete({ sessionId, messageId, ifVersion: edited.version })
  514. await ctx.messageFeedback.delete({ sessionId, messageId, ifVersion: edited.version })
  515. const events = persistence.durable.get(sessionId)!.events
  516. expect(events.slice(0, prefix.length)).toEqual(prefix)
  517. expect(events.slice(prefix.length).map(({ type, data }) => ({ type, data }))).toEqual([
  518. { type: 'feedback/message-put', data: { sessionId, item: created } },
  519. { type: 'feedback/message-put', data: { sessionId, item: edited } },
  520. { type: 'feedback/message-delete', data: { sessionId, messageId } },
  521. ])
  522. expect(events.map(event => event.seq)).toEqual(events.map((_, index) => index))
  523. expect(lifecycle).toEqual([])
  524. expect(ctx.sessions.get(sessionId)).toBeUndefined()
  525. expect(persistence.openCalls).toEqual(['write', 'write', 'write', 'write', 'write'])
  526. expect(persistence.closeCalls).toBe(5)
  527. })
  528. it('starts a fork without inherited feedback and keeps parent mutations independent', async () => {
  529. const { ctx, persistence } = await harness()
  530. const parent = messageFixture('feedback-parent')
  531. persistence.persist(parent.session)
  532. const messageId = parent.assistantMessageIds[0]
  533. const parentItem = expectItem(await ctx.messageFeedback.put({ sessionId: parent.session.id, messageId, rating: 'positive', ifVersion: null }))
  534. const seed = persistence.durable.get(parent.session.id)!.events
  535. const childId = SessionId('feedback-child')
  536. const child = Session.create(childId, seed, {
  537. ...parent.session.header, id: childId, isSeeded: true, parentSession: parent.session.id,
  538. }, SessionLogOffset(seed.length))
  539. persistence.persist(child)
  540. await expect(ctx.messageFeedback.list({ sessionId: childId })).resolves.toEqual({ ok: true, value: { items: [] } })
  541. const childItem = expectItem(await ctx.messageFeedback.put({ sessionId: childId, messageId, rating: 'negative', ifVersion: null }))
  542. await ctx.messageFeedback.delete({ sessionId: parent.session.id, messageId, ifVersion: parentItem.version })
  543. await expect(ctx.messageFeedback.list({ sessionId: childId })).resolves.toEqual({ ok: true, value: { items: [childItem] } })
  544. })
  545. it('flushes live feedback with the target and retries durability without a duplicate event', async () => {
  546. const { ctx, persistence } = await harness()
  547. const session = ctx.sessions.create(SessionId('live-log'))
  548. const fixture = appendMessageFixture(session)
  549. const before = session.snapshotEvents().length
  550. const failure = new Error('disk unavailable')
  551. let fail = true
  552. ctx.on('session/flush', (current) => {
  553. if (fail) throw failure
  554. persistence.persist(current)
  555. })
  556. const request = { sessionId: session.id, messageId: fixture.assistantMessageIds[0], rating: 'positive' as const, ifVersion: null }
  557. await expect(ctx.messageFeedback.put(request)).rejects.toBe(failure)
  558. const listed = await ctx.messageFeedback.list({ sessionId: session.id })
  559. if (!listed.ok) throw new Error('missing live session')
  560. const item = listed.value.items[0]!
  561. fail = false
  562. expectItem(await ctx.messageFeedback.put({ ...request, ifVersion: item.version }))
  563. expect(session.snapshotEvents()).toHaveLength(before + 1)
  564. expect(persistence.durable.get(session.id)?.events).toEqual(session.snapshotEvents())
  565. expect(persistence.openCalls).toEqual(['read'])
  566. })
  567. it.each(['missing-tail', 'different-tail', 'different-lifecycle'] as const)(
  568. 'rejects a live checkpoint with %s and closes its verification handle', async (kind) => {
  569. const { ctx, persistence } = await harness()
  570. const session = ctx.sessions.create(SessionId('mismatched-checkpoint'))
  571. const fixture = appendMessageFixture(session)
  572. ctx.on('session/flush', () => {
  573. const events = [...session.snapshotEvents()]
  574. if (kind === 'missing-tail') events.pop()
  575. if (kind === 'different-tail') events[events.length - 1] = { ...events.at(-1)!, time: 0 }
  576. const meta = kind === 'different-lifecycle' ? { ...session.header, createdAt: 0 } : session.header
  577. persistence.setDurable({ meta, events })
  578. })
  579. await expect(ctx.messageFeedback.put({
  580. sessionId: session.id, messageId: fixture.assistantMessageIds[0], rating: 'positive', ifVersion: null,
  581. })).rejects.toThrow(/feedback prefix is not durable/u)
  582. expect(persistence.closeCalls).toBe(1)
  583. },
  584. )
  585. it('verifies an empty live no-op and captures its checkpoint before concurrent appends', async () => {
  586. const { ctx, persistence } = await harness()
  587. const session = ctx.sessions.create(SessionId('checkpoint-prefix'))
  588. ctx.on('session/flush', () => { persistence.persist(session) })
  589. await expect(ctx.messageFeedback.delete({ sessionId: session.id, messageId: 'absent' as MessageId, ifVersion: staleVersion() }))
  590. .resolves.toEqual({ ok: true, value: { absent: true } })
  591. const fixture = appendMessageFixture(session)
  592. persistence.onRead = () => { session.append('turn/start', { turn: 2 }) }
  593. expectItem(await ctx.messageFeedback.put({
  594. sessionId: session.id, messageId: fixture.assistantMessageIds[0], rating: 'positive', ifVersion: null,
  595. }))
  596. expect(persistence.durable.get(session.id)!.events.length).toBe(session.snapshotEvents().length - 1)
  597. })
  598. it('rejects an unowned durability checkpoint and closes cold handles on failures', async () => {
  599. const { ctx, persistence } = await harness()
  600. const live = ctx.sessions.create(SessionId('no-flush-owner'))
  601. const fixture = appendMessageFixture(live)
  602. await expect(ctx.messageFeedback.put({ sessionId: live.id, messageId: fixture.assistantMessageIds[0], rating: 'positive', ifVersion: null }))
  603. .rejects.toThrow(/no durability listener participated/u)
  604. const cold = messageFixture('cold-failures')
  605. persistence.persist(cold.session)
  606. const request = { sessionId: cold.session.id, messageId: cold.assistantMessageIds[0], rating: 'positive' as const, ifVersion: null }
  607. const appendFailure = new Error('append failed')
  608. persistence.appendFailure = appendFailure
  609. await expect(ctx.messageFeedback.put(request)).rejects.toBe(appendFailure)
  610. expect(persistence.closeCalls).toBe(1)
  611. persistence.appendFailure = undefined
  612. const flushFailure = new Error('flush failed')
  613. persistence.flushFailure = flushFailure
  614. await expect(ctx.messageFeedback.put(request)).rejects.toBe(flushFailure)
  615. expect(persistence.closeCalls).toBe(2)
  616. })
  617. it.each([
  618. { type: 'feedback/message-put', data: null },
  619. { type: 'feedback/message-put', data: { sessionId: 'x', item: {} } },
  620. ...[
  621. { messageId: '' }, { rating: 'neutral' }, { version: 'bad-token' }, { note: ' ' },
  622. { createdAt: -1 }, { updatedAt: 0 }, { updatedAt: 1.5 },
  623. ].map(patch => ({ type: 'feedback/message-put', data: { sessionId: 'x', item: {
  624. messageId: 'message', rating: 'positive', version: randomUUID(), createdAt: 1, updatedAt: 1, ...patch,
  625. } } })),
  626. { type: 'feedback/message-delete', data: { sessionId: 'x', messageId: '' } },
  627. { type: 'feedback/message-delete', data: { sessionId: 12, messageId: 'message' } },
  628. ])('rejects malformed durable feedback payload %#', async (record) => {
  629. const { ctx, persistence } = await harness()
  630. const fixture = messageFixture('invalid-feedback')
  631. const events = fixture.session.snapshotEvents()
  632. persistence.setDurable({ meta: fixture.session.header, events: [...events, {
  633. ...record, seq: SessionSeq(events.length), time: 1,
  634. } as SessionEvent] })
  635. await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toThrow()
  636. expect(persistence.closeCalls).toBe(1)
  637. })
  638. })