message-feedback.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  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 } from '@deepseek-ai/dsh-session'
  6. import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
  7. import MessageFeedbackService, { messageFeedbackRowSchema } 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.events })
  58. const corruption = new Error('stored log checksum mismatch')
  59. persistence.inspectFailure = 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.onListSnapshots = 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.inspectCalls).toBe(1)
  77. })
  78. it('returns session-not-found from mutations and conflicts on an observed version for an absent item', async () => {
  79. const { ctx, persistence } = await harness()
  80. const missing = SessionId('missing-mutations')
  81. const missingMessage = 'missing-message' as MessageId
  82. await expect(ctx.messageFeedback.put({
  83. sessionId: missing,
  84. messageId: missingMessage,
  85. rating: 'positive',
  86. ifVersion: null,
  87. })).resolves.toEqual({
  88. ok: false,
  89. error: { code: 'session-not-found', sessionId: missing },
  90. })
  91. await expect(ctx.messageFeedback.delete({
  92. sessionId: missing,
  93. messageId: missingMessage,
  94. ifVersion: staleVersion(),
  95. })).resolves.toEqual({
  96. ok: false,
  97. error: { code: 'session-not-found', sessionId: missing },
  98. })
  99. const fixture = messageFixture('absent-version-conflict')
  100. persistence.persist(fixture.session)
  101. const expected = staleVersion()
  102. await expect(ctx.messageFeedback.put({
  103. sessionId: fixture.session.id,
  104. messageId: fixture.assistantMessageIds[0],
  105. rating: 'positive',
  106. ifVersion: expected,
  107. })).resolves.toEqual({
  108. ok: false,
  109. error: { code: 'version-conflict', current: null },
  110. })
  111. })
  112. it('creates, updates, and retry-reads immutable items with monotonic Host times', async () => {
  113. const { ctx, persistence } = await harness()
  114. const fixture = messageFixture('timestamps')
  115. persistence.persist(fixture.session)
  116. const messageId = fixture.assistantMessageIds[0]
  117. vi.useFakeTimers()
  118. vi.setSystemTime(1_700_000_001_000)
  119. const created = expectItem(await ctx.messageFeedback.put({
  120. sessionId: fixture.session.id,
  121. messageId,
  122. rating: 'positive',
  123. note: ' exact prose ',
  124. ifVersion: null,
  125. }))
  126. expect(created).toMatchObject({
  127. messageId,
  128. rating: 'positive',
  129. note: ' exact prose ',
  130. createdAt: 1_700_000_001_000,
  131. updatedAt: 1_700_000_001_000,
  132. })
  133. expect(created.version).toMatch(/^[0-9a-f-]{36}$/u)
  134. expect(Object.isFrozen(created)).toBe(true)
  135. vi.setSystemTime(1_700_000_000_000)
  136. const updated = expectItem(await ctx.messageFeedback.put({
  137. sessionId: fixture.session.id,
  138. messageId,
  139. rating: 'negative',
  140. ifVersion: created.version,
  141. }))
  142. expect(updated).toMatchObject({
  143. messageId,
  144. rating: 'negative',
  145. createdAt: created.createdAt,
  146. updatedAt: created.updatedAt,
  147. })
  148. expect(updated.version).not.toBe(created.version)
  149. const retry = expectItem(await ctx.messageFeedback.put({
  150. sessionId: fixture.session.id,
  151. messageId,
  152. rating: 'negative',
  153. ifVersion: updated.version,
  154. }))
  155. expect(retry).toEqual(updated)
  156. const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id })
  157. if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`)
  158. expect(listed.value.items).toEqual([updated])
  159. expect(listed.value.items[0]).not.toBe(updated)
  160. expect(Object.isFrozen(listed.value)).toBe(true)
  161. expect(Object.isFrozen(listed.value.items)).toBe(true)
  162. expect(Object.isFrozen(listed.value.items[0])).toBe(true)
  163. })
  164. it('reports non-blank and complete UTF-8 byte limits without touching persistence', async () => {
  165. const { ctx, persistence } = await harness(4)
  166. const fixture = messageFixture('note-limits')
  167. persistence.persist(fixture.session)
  168. const messageId = fixture.assistantMessageIds[0]
  169. const before = persistence.inspectCalls
  170. await expect(ctx.messageFeedback.put({
  171. sessionId: fixture.session.id,
  172. messageId,
  173. rating: 'positive',
  174. note: ' \n\t ',
  175. ifVersion: null,
  176. })).resolves.toEqual({ ok: false, error: { code: 'note-blank' } })
  177. await expect(ctx.messageFeedback.put({
  178. sessionId: fixture.session.id,
  179. messageId,
  180. rating: 'positive',
  181. note: 'ééé',
  182. ifVersion: null,
  183. })).resolves.toEqual({
  184. ok: false,
  185. error: { code: 'note-too-large', maxBytes: 4, actualBytes: 6 },
  186. })
  187. expect(persistence.inspectCalls).toBe(before)
  188. expectItem(await ctx.messageFeedback.put({
  189. sessionId: fixture.session.id,
  190. messageId,
  191. rating: 'positive',
  192. note: '😀',
  193. ifVersion: null,
  194. }))
  195. })
  196. it('accepts only non-empty append-origin assistant projections as targets', async () => {
  197. const { ctx, persistence } = await harness()
  198. const fixture = messageFixture('targets')
  199. persistence.persist(fixture.session)
  200. const rejectedTargets: MessageId[] = [
  201. fixture.userMessageId,
  202. fixture.emptyAssistantMessageId,
  203. fixture.replacementAssistantMessageId,
  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('fails invalid direct configuration and a read before domain initialization', async () => {
  228. const invalidCtx = new Context()
  229. expect(() => new MessageFeedbackService(invalidCtx, { maxNoteBytes: 0 }))
  230. .toThrow(/positive safe integer/u)
  231. await invalidCtx.fiber.dispose()
  232. const fixture = messageFixture('uninitialized-domain')
  233. const rawCtx = new Context()
  234. rawCtx.provide('sessions', { get: () => undefined } as never)
  235. rawCtx.provide('sessionPersistence', {
  236. listSnapshots: () => Promise.resolve([{ header: fixture.session.header, revision: 'test' }]),
  237. inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.events }),
  238. } as never)
  239. const raw = new MessageFeedbackService(rawCtx, { maxNoteBytes: 1 })
  240. await expect(raw.list({ sessionId: fixture.session.id }))
  241. .rejects.toThrow(/durable domain is not initialized/u)
  242. await rawCtx.fiber.dispose()
  243. })
  244. it('rejects durable rows with duplicate message ids or reused item versions', () => {
  245. const version = staleVersion()
  246. const duplicate = messageFeedbackRowSchema.safeParse({
  247. session: { createdAt: 1 },
  248. items: [
  249. {
  250. messageId: 'same-message',
  251. rating: 'positive',
  252. version,
  253. createdAt: 1,
  254. updatedAt: 1,
  255. },
  256. {
  257. messageId: 'same-message',
  258. rating: 'negative',
  259. version,
  260. createdAt: 1,
  261. updatedAt: 1,
  262. },
  263. ],
  264. })
  265. expect(duplicate.success).toBe(false)
  266. if (duplicate.success) throw new Error('expected duplicate row rejection')
  267. expect(duplicate.error.issues.map(issue => issue.path.join('.')))
  268. .toEqual(['items.1.messageId', 'items.1.version'])
  269. })
  270. })
  271. describe('MessageFeedbackService item concurrency', () => {
  272. it('serializes whole-row writes while keeping versions independent per message', async () => {
  273. const { ctx, persistence } = await harness()
  274. const fixture = messageFixture('concurrent-items')
  275. persistence.persist(fixture.session)
  276. const [firstId, secondId] = fixture.assistantMessageIds
  277. const [firstResult, secondResult] = await Promise.all([
  278. ctx.messageFeedback.put({
  279. sessionId: fixture.session.id,
  280. messageId: firstId,
  281. rating: 'positive',
  282. ifVersion: null,
  283. }),
  284. ctx.messageFeedback.put({
  285. sessionId: fixture.session.id,
  286. messageId: secondId,
  287. rating: 'negative',
  288. ifVersion: null,
  289. }),
  290. ])
  291. const first = expectItem(firstResult)
  292. const second = expectItem(secondResult)
  293. const updated = expectItem(await ctx.messageFeedback.put({
  294. sessionId: fixture.session.id,
  295. messageId: firstId,
  296. rating: 'negative',
  297. note: 'changed',
  298. ifVersion: first.version,
  299. }))
  300. await expect(ctx.messageFeedback.put({
  301. sessionId: fixture.session.id,
  302. messageId: firstId,
  303. rating: 'positive',
  304. note: 'stale change',
  305. ifVersion: first.version,
  306. })).resolves.toEqual({
  307. ok: false,
  308. error: { code: 'version-conflict', current: updated },
  309. })
  310. const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id })
  311. if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`)
  312. expect(listed.value.items).toEqual([updated, second])
  313. expect(listed.value.items[1]?.version).toBe(second.version)
  314. })
  315. it('rejects a stale put even when the current value has returned to the same state', async () => {
  316. const { ctx, persistence } = await harness()
  317. const fixture = messageFixture('put-aba')
  318. persistence.persist(fixture.session)
  319. const messageId = fixture.assistantMessageIds[0]
  320. const first = expectItem(await ctx.messageFeedback.put({
  321. sessionId: fixture.session.id,
  322. messageId,
  323. rating: 'positive',
  324. ifVersion: null,
  325. }))
  326. const second = expectItem(await ctx.messageFeedback.put({
  327. sessionId: fixture.session.id,
  328. messageId,
  329. rating: 'negative',
  330. ifVersion: first.version,
  331. }))
  332. const current = expectItem(await ctx.messageFeedback.put({
  333. sessionId: fixture.session.id,
  334. messageId,
  335. rating: 'positive',
  336. ifVersion: second.version,
  337. }))
  338. await expect(ctx.messageFeedback.put({
  339. sessionId: fixture.session.id,
  340. messageId,
  341. rating: 'positive',
  342. ifVersion: first.version,
  343. })).resolves.toEqual({
  344. ok: false,
  345. error: { code: 'version-conflict', current },
  346. })
  347. })
  348. it('makes delete retries stable and prevents delete/recreate ABA', async () => {
  349. const { ctx, persistence } = await harness()
  350. const fixture = messageFixture('delete-aba')
  351. persistence.persist(fixture.session)
  352. const messageId = fixture.assistantMessageIds[0]
  353. const created = expectItem(await ctx.messageFeedback.put({
  354. sessionId: fixture.session.id,
  355. messageId,
  356. rating: 'positive',
  357. ifVersion: null,
  358. }))
  359. await expect(ctx.messageFeedback.delete({
  360. sessionId: fixture.session.id,
  361. messageId,
  362. ifVersion: staleVersion(),
  363. })).resolves.toEqual({
  364. ok: false,
  365. error: { code: 'version-conflict', current: created },
  366. })
  367. const request = {
  368. sessionId: fixture.session.id,
  369. messageId,
  370. ifVersion: created.version,
  371. }
  372. await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
  373. ok: true,
  374. value: { absent: true },
  375. })
  376. await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
  377. ok: true,
  378. value: { absent: true },
  379. })
  380. const recreated = expectItem(await ctx.messageFeedback.put({
  381. sessionId: fixture.session.id,
  382. messageId,
  383. rating: 'negative',
  384. ifVersion: null,
  385. }))
  386. expect(recreated.version).not.toBe(created.version)
  387. await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({
  388. ok: false,
  389. error: { code: 'version-conflict', current: recreated },
  390. })
  391. })
  392. it('fences a reused Session id and lets the new lifecycle start cleanly', async () => {
  393. const { ctx, persistence } = await harness()
  394. const old = messageFixture('reused-session', { createdAt: 10, cwd: '/old' })
  395. persistence.persist(old.session)
  396. const oldItem = expectItem(await ctx.messageFeedback.put({
  397. sessionId: old.session.id,
  398. messageId: old.assistantMessageIds[0],
  399. rating: 'positive',
  400. ifVersion: null,
  401. }))
  402. const replacement = Session.create(
  403. old.session.id,
  404. old.session.events,
  405. { ...old.session.header, createdAt: 20, cwd: '/new' },
  406. )
  407. persistence.persist(replacement)
  408. await expect(ctx.messageFeedback.list({ sessionId: replacement.id })).resolves.toEqual({
  409. ok: true,
  410. value: { items: [] },
  411. })
  412. await expect(ctx.messageFeedback.delete({
  413. sessionId: replacement.id,
  414. messageId: old.assistantMessageIds[0],
  415. ifVersion: oldItem.version,
  416. })).resolves.toEqual({ ok: true, value: { absent: true } })
  417. const newItem = expectItem(await ctx.messageFeedback.put({
  418. sessionId: replacement.id,
  419. messageId: old.assistantMessageIds[0],
  420. rating: 'negative',
  421. ifVersion: null,
  422. }))
  423. expect(newItem.version).not.toBe(oldItem.version)
  424. })
  425. it('drains admitted mutations before domain close and rejects later admission', async () => {
  426. const current = await harness()
  427. const { ctx, persistence } = current
  428. const fixture = messageFixture('dispose-quiescence')
  429. persistence.persist(fixture.session)
  430. const service = ctx.messageFeedback
  431. const lifecycle = service as unknown as { readonly mutationAdmissionOpen: boolean }
  432. const started = Promise.withResolvers<undefined>()
  433. const release = Promise.withResolvers<undefined>()
  434. let physicalReads = 0
  435. let committed = 0
  436. persistence.onReadFrom = async () => {
  437. physicalReads += 1
  438. if (physicalReads !== 1) return
  439. started.resolve(undefined)
  440. await release.promise
  441. }
  442. ctx.on('domain/changed', (change) => {
  443. if (change.domain === 'message_feedback') committed += 1
  444. })
  445. const first = service.put({
  446. sessionId: fixture.session.id,
  447. messageId: fixture.assistantMessageIds[0],
  448. rating: 'positive',
  449. ifVersion: null,
  450. })
  451. await started.promise
  452. const second = service.put({
  453. sessionId: fixture.session.id,
  454. messageId: fixture.assistantMessageIds[1],
  455. rating: 'negative',
  456. ifVersion: null,
  457. })
  458. const disposal = current.disposeFeedback()
  459. await vi.waitFor(() => { expect(lifecycle.mutationAdmissionOpen).toBe(false) })
  460. await expect(service.delete({
  461. sessionId: fixture.session.id,
  462. messageId: fixture.assistantMessageIds[0],
  463. ifVersion: staleVersion(),
  464. })).rejects.toThrow('message-feedback: service is disposing')
  465. release.resolve(undefined)
  466. expectItem(await first)
  467. expectItem(await second)
  468. await disposal
  469. expect(physicalReads).toBe(2)
  470. expect(committed).toBe(2)
  471. })
  472. })
  473. describe('MessageFeedbackService durability ordering', () => {
  474. it('rejects a logical target missing from the cold physical durable prefix', async () => {
  475. const { ctx, persistence } = await harness()
  476. const fixture = messageFixture('cold-prefix')
  477. persistence.logical.set(fixture.session.id, {
  478. meta: fixture.session.header,
  479. events: fixture.session.events,
  480. })
  481. persistence.setDurable({ meta: fixture.session.header, events: [] })
  482. await expect(ctx.messageFeedback.put({
  483. sessionId: fixture.session.id,
  484. messageId: fixture.assistantMessageIds[0],
  485. rating: 'positive',
  486. ifVersion: null,
  487. })).resolves.toEqual({
  488. ok: false,
  489. error: {
  490. code: 'target-not-found',
  491. sessionId: fixture.session.id,
  492. messageId: fixture.assistantMessageIds[0],
  493. },
  494. })
  495. expect(persistence.readFromCalls).toBe(1)
  496. await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).resolves.toEqual({
  497. ok: true,
  498. value: { items: [] },
  499. })
  500. })
  501. it('commits and physically verifies a live target checkpoint before the sidecar write', async () => {
  502. const { ctx, persistence } = await harness()
  503. const session = ctx.sessions.create(SessionId('live-checkpoint'), {
  504. meta: { createdAt: 30, cwd: '/live' },
  505. })
  506. const fixture = appendMessageFixture(session)
  507. const order: string[] = []
  508. ctx.on('session/flush', (current) => {
  509. order.push('session:durable')
  510. persistence.persist(current)
  511. })
  512. ctx.on('domain/changed', (change) => {
  513. if (change.domain === 'message_feedback') order.push('sidecar:durable')
  514. })
  515. persistence.onReadFrom = () => { order.push('session:verified') }
  516. expectItem(await ctx.messageFeedback.put({
  517. sessionId: session.id,
  518. messageId: fixture.assistantMessageIds[0],
  519. rating: 'positive',
  520. ifVersion: null,
  521. }))
  522. expect(order).toEqual(['session:durable', 'session:verified', 'sidecar:durable'])
  523. expect(persistence.readFromCalls).toBe(1)
  524. expect(persistence.durable.get(session.id)?.events).toContainEqual(
  525. expect.objectContaining({ type: 'assistant/message' }),
  526. )
  527. })
  528. it('fails closed when a live checkpoint fails, has no participant, or is not physically durable', async () => {
  529. const failed = await harness()
  530. const failedSession = failed.ctx.sessions.create(SessionId('live-flush-failure'))
  531. const failedFixture = appendMessageFixture(failedSession)
  532. const diskFailure = new Error('disk unavailable')
  533. failed.ctx.on('session/flush', () => { throw diskFailure })
  534. await expect(failed.ctx.messageFeedback.put({
  535. sessionId: failedSession.id,
  536. messageId: failedFixture.assistantMessageIds[0],
  537. rating: 'positive',
  538. ifVersion: null,
  539. })).rejects.toBe(diskFailure)
  540. await expect(failed.ctx.messageFeedback.list({ sessionId: failedSession.id })).resolves.toEqual({
  541. ok: true,
  542. value: { items: [] },
  543. })
  544. const absent = await harness()
  545. const absentSession = absent.ctx.sessions.create(SessionId('live-no-flush'))
  546. const absentFixture = appendMessageFixture(absentSession)
  547. await expect(absent.ctx.messageFeedback.put({
  548. sessionId: absentSession.id,
  549. messageId: absentFixture.assistantMessageIds[0],
  550. rating: 'positive',
  551. ifVersion: null,
  552. })).rejects.toThrow(/no durability listener participated/u)
  553. await expect(absent.ctx.messageFeedback.list({ sessionId: absentSession.id })).resolves.toEqual({
  554. ok: true,
  555. value: { items: [] },
  556. })
  557. const noDurability = await harness()
  558. const unpersistedSession = noDurability.ctx.sessions.create(SessionId('live-unpersisted'))
  559. const unpersistedFixture = appendMessageFixture(unpersistedSession)
  560. noDurability.ctx.on('session/flush', () => {})
  561. await expect(noDurability.ctx.messageFeedback.put({
  562. sessionId: unpersistedSession.id,
  563. messageId: unpersistedFixture.assistantMessageIds[0],
  564. rating: 'positive',
  565. ifVersion: null,
  566. })).rejects.toThrow(/not found/u)
  567. expect(noDurability.persistence.durable.has(unpersistedSession.id)).toBe(false)
  568. await expect(noDurability.ctx.messageFeedback.list({ sessionId: unpersistedSession.id })).resolves.toEqual({
  569. ok: true,
  570. value: { items: [] },
  571. })
  572. })
  573. it('finishes the captured live checkpoint when the Session detaches mid-flush', async () => {
  574. const { ctx, persistence } = await harness()
  575. const session = ctx.sessions.prepare(SessionId('detach-during-flush'), {
  576. meta: { createdAt: 40, cwd: '/detach' },
  577. })
  578. const detach = ctx.sessions.enter(session)
  579. ctx.sessions.announce(session)
  580. const fixture = appendMessageFixture(session)
  581. const started = Promise.withResolvers<undefined>()
  582. const release = Promise.withResolvers<undefined>()
  583. ctx.on('session/flush', async (current) => {
  584. started.resolve(undefined)
  585. await release.promise
  586. persistence.persist(current)
  587. })
  588. const pending = ctx.messageFeedback.put({
  589. sessionId: session.id,
  590. messageId: fixture.assistantMessageIds[0],
  591. rating: 'positive',
  592. ifVersion: null,
  593. })
  594. await started.promise
  595. detach()
  596. expect(ctx.sessions.get(session.id)).toBeUndefined()
  597. release.resolve(undefined)
  598. expectItem(await pending)
  599. expect(persistence.readFromCalls).toBe(1)
  600. await expect(ctx.messageFeedback.list({ sessionId: session.id })).resolves.toMatchObject({
  601. ok: true,
  602. value: { items: [{ messageId: fixture.assistantMessageIds[0] }] },
  603. })
  604. })
  605. })