realtimeReceipts.test.ts 4.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. // @vitest-environment node
  2. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  3. import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { RealtimeReceipts } from '../../src/server/realtimeReceipts'
  7. import { RealtimeBroker, type RealtimePrincipal } from '../../src/server/realtimeBroker'
  8. let home: string, receipts: RealtimeReceipts
  9. beforeEach(() => {
  10. home = mkdtempSync(join(tmpdir(), 'yaoyao-receipt-recovery-'))
  11. receipts = new RealtimeReceipts(home)
  12. })
  13. afterEach(() => { receipts.close(); vi.restoreAllMocks(); rmSync(home, { recursive: true, force: true }) })
  14. it('retains owner-scoped deduplication after result expiry and database reopen', () => {
  15. const now = vi.spyOn(Date, 'now').mockReturnValue(100_000)
  16. receipts.reserve('alice', 'prompt-1', 'private prompt')
  17. receipts.finish('alice', 'prompt-1', 'confirmed', { result: { status: 'submitted' } })
  18. now.mockReturnValue(100_000 + 86_400_001)
  19. receipts.reserve('alice', 'new-command', '{}')
  20. receipts.close(); receipts = new RealtimeReceipts(home)
  21. expect(receipts.lookup('alice', 'prompt-1')).toEqual({ requestId: 'prompt-1', state: 'unknown' })
  22. expect(receipts.reserve('alice', 'prompt-1', 'private prompt')).toMatchObject({ state: 'unknown' })
  23. expect(() => receipts.reserve('alice', 'prompt-1', 'changed payload')).toThrow('payload conflict')
  24. expect(receipts.lookup('bob', 'prompt-1')).toBeUndefined()
  25. expect(receipts.reserve('bob', 'prompt-1', 'private prompt')).toBeUndefined()
  26. for (const file of ['realtime-receipts.sqlite3', 'realtime-receipts.sqlite3-wal']) {
  27. expect(readFileSync(join(home, file)).includes(Buffer.from('private prompt'))).toBe(false)
  28. }
  29. })
  30. it('does not call the executor a second time after expired results are compacted', async () => {
  31. receipts.close()
  32. const now = vi.spyOn(Date, 'now').mockReturnValue(100_000)
  33. const broker = new RealtimeBroker(home)
  34. const perform = vi.fn(async () => ({ result: { status: 'submitted' } }))
  35. Object.assign(broker, { perform })
  36. const principal: RealtimePrincipal = { key: 'alice', upstreamKey: 'mock', paired: false,
  37. valid: () => true, url: async () => { throw new Error('No network allowed') } }
  38. try {
  39. const channel = { kind: 'chat', principal } as Parameters<RealtimeBroker['command']>[0]
  40. const frame = { method: 'prompt.submit', params: { session_id: 'runtime', text: 'do work' } }
  41. await broker.command(channel, 'prompt-1', frame)
  42. now.mockReturnValue(100_000 + 86_400_001)
  43. await broker.command(channel, 'new-prompt', frame)
  44. // Simulate losing the process-local full response, as on a restart.
  45. Object.assign(broker, { completed: new Map() })
  46. expect(await broker.command(channel, 'prompt-1', frame)).toMatchObject({ state: 'unknown' })
  47. expect(perform).toHaveBeenCalledTimes(2)
  48. } finally {
  49. broker.close()
  50. await Promise.resolve()
  51. receipts = new RealtimeReceipts(home)
  52. }
  53. })
  54. it.each(['file.attach', 'image.attach_bytes', 'pdf.attach'])('restores %s references without persisting attachment contents', method => {
  55. const id = method.replaceAll('.', '_')
  56. receipts.reserve('alice', id, '{}')
  57. receipts.finish('alice', id, 'confirmed', { result: {
  58. path: '/files/upload', image_path: '/images/upload', ref_path: 'upload', ref_text: '@file:upload',
  59. content: 'private bytes', content_base64: 'private bytes', text: 'private bytes',
  60. } }, { method })
  61. receipts.close(); receipts = new RealtimeReceipts(home)
  62. expect(receipts.lookup('alice', id)?.response?.result).toEqual({
  63. path: '/files/upload', image_path: '/images/upload', ref_path: 'upload', ref_text: '@file:upload',
  64. })
  65. })
  66. it('restores model confirmation metadata only for allowed configuration keys', () => {
  67. receipts.reserve('alice', 'model', '{}')
  68. receipts.finish('alice', 'model', 'confirmed', { result: {
  69. value: 'model --provider provider', deferred: false, confirm_required: true,
  70. confirm_message: 'Please confirm', warning: 'Cost warning', api_key: 'private key',
  71. } }, { method: 'config.set', params: { key: 'model' } })
  72. receipts.close(); receipts = new RealtimeReceipts(home)
  73. expect(receipts.lookup('alice', 'model')?.response?.result).toEqual({
  74. value: 'model --provider provider', deferred: false, confirm_required: true,
  75. confirm_message: 'Please confirm', warning: 'Cost warning',
  76. })
  77. receipts.reserve('alice', 'unrelated', '{}')
  78. receipts.finish('alice', 'unrelated', 'confirmed', { result: { value: 'private key', path: '/private' } },
  79. { method: 'config.set', params: { key: 'api_key' } })
  80. expect(receipts.lookup('alice', 'unrelated')?.response?.result).toEqual({})
  81. })
  82. it('does not persist unbounded recovery strings', () => {
  83. receipts.reserve('alice', 'large', '{}')
  84. receipts.finish('alice', 'large', 'confirmed', { result: { path: 'x'.repeat(4097) } }, { method: 'file.attach' })
  85. expect(receipts.lookup('alice', 'large')?.response?.result).toEqual({})
  86. })