chatTranscript.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. // @vitest-environment node
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { ChatCacheStore, ChatCacheCoordinator } from '../../src/server/chatCache'
  7. import { applyTranscriptEvent, type TranscriptSnapshot } from '../../src/shared/chatTranscript'
  8. import type { UpstreamServiceSession } from '../../src/server/localAuth'
  9. const fixtures:Array<{home:string;store:ChatCacheStore;cache:ChatCacheCoordinator}>=[]
  10. function fixture(){const home=mkdtempSync(join(tmpdir(),'ordinary-transcript-')),store=new ChatCacheStore(home),cache=new ChatCacheCoordinator(store,{request:vi.fn()} as unknown as UpstreamServiceSession);fixtures.push({home,store,cache});return {home,store,cache}}
  11. afterEach(()=>{for(const f of fixtures.splice(0)){f.cache.close();if(!f.store.isClosed)f.store.close();rmSync(f.home,{recursive:true,force:true})}})
  12. function snapshot(store:ChatCacheStore,id='s',owner='owner'):TranscriptSnapshot {
  13. store.transcripts.seed(owner,'p',id)
  14. const detail=store.localDetail(owner,'p',id)!
  15. return store.transcripts.snapshot(owner,'p',id,JSON.parse(detail.response.body.toString()),detail.state)
  16. }
  17. describe('ordinary server-owned transcript',()=>{
  18. it('preserves interim text, reasoning, tool results and final text as separate records',()=>{
  19. const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
  20. store.recordCommand('owner','p','s','prompt.submit',{text:'问题',_delivery_id:'ios:prompt:user-one'})
  21. let n=0
  22. const send=(type:string,payload:object)=>cache.observe('owner','p','s',{type,payload,delivery_id:`event-${++n}`})
  23. send('message.start',{})
  24. send('reasoning.delta',{text:'先思考'})
  25. send('message.interim',{text:'先检查文件'})
  26. send('tool.start',{tool_id:'tool-one',name:'read_file'})
  27. send('tool.complete',{tool_id:'tool-one',result:'完整工具输出'})
  28. send('message.delta',{text:'最终答案'})
  29. send('message.complete',{text:'最终答案'})
  30. const value=snapshot(store)
  31. expect(value.messages.map(m=>m.content)).toEqual(['问题','先检查文件','最终答案'])
  32. expect(value.messages[1]!.reasoning).toBe('先思考')
  33. expect(value.messages[2]!.tool_calls).toMatchObject([{id:'tool-one',result:'完整工具输出'}])
  34. expect(new Set(value.messages.map(m=>m.id)).size).toBe(3)
  35. expect(value.messages.map(m=>m.seq)).toEqual([1,2,3])
  36. })
  37. it('keeps a queued prompt from detaching the currently streaming assistant',()=>{
  38. const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
  39. store.recordCommand('owner','p','s','prompt.submit',{text:'第一问',_delivery_id:'ios:prompt:first'})
  40. cache.observe('owner','p','s',{type:'message.delta',payload:{text:'前半'},delivery_id:'one'})
  41. const assistant=snapshot(store).messages.find(m=>m.role==='assistant')!
  42. store.recordCommand('owner','p','s','prompt.submit',{text:'排队的第二问',_delivery_id:'ios:prompt:second'})
  43. cache.observe('owner','p','s',{type:'message.delta',payload:{text:'后半'},delivery_id:'two'})
  44. expect(snapshot(store).messages.find(m=>m.id===assistant.id)?.content).toBe('前半后半')
  45. expect(snapshot(store).messages.filter(m=>m.role==='assistant')).toHaveLength(1)
  46. })
  47. it('commits replayable append patches and ignores duplicate deliveries',()=>{
  48. const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
  49. cache.observe('owner','p','s',{type:'message.delta',payload:{text:'中文'},delivery_id:'one'})
  50. const base=snapshot(store),id=base.messages[0]!.id
  51. cache.observe('owner','p','s',{type:'message.delta',payload:{text:'🙂'},delivery_id:'two'})
  52. cache.observe('owner','p','s',{type:'message.delta',payload:{text:'🙂'},delivery_id:'two'})
  53. const events=store.transcripts.events('owner','p','s',base.cursor)
  54. expect(events).toHaveLength(1);expect(events[0]!.type).toBe('message.patch')
  55. const replay=events.reduce(applyTranscriptEvent,base.messages)
  56. expect(replay).toEqual(snapshot(store).messages)
  57. expect(replay[0]).toMatchObject({id,content:'中文🙂',revision:base.messages[0]!.revision+1})
  58. expect(applyTranscriptEvent(replay,events[0]!)).toEqual(replay)
  59. })
  60. it('retains stable message identity when an upstream row ID becomes known',()=>{
  61. const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
  62. cache.observe('owner','p','s',{type:'message.delta',payload:{text:'回复'},delivery_id:'one'})
  63. const before=snapshot(store).messages[0]!
  64. cache.observe('owner','p','s',{type:'message.complete',payload:{message_id:'upstream-42',text:'完整回复'},delivery_id:'two'})
  65. expect(snapshot(store).messages).toHaveLength(1)
  66. expect(snapshot(store).messages[0]).toMatchObject({id:before.id,seq:before.seq,source_message_id:'upstream-42',content:'完整回复',status:'complete'})
  67. })
  68. it('rolls back content and event log together when durable event storage fails',async()=>{
  69. const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
  70. const listener=vi.fn();store.transcripts.subscribe(listener)
  71. store.db.exec("CREATE TRIGGER fail_transcript_event BEFORE INSERT ON chat_transcript_events BEGIN SELECT RAISE(ABORT,'disk full'); END")
  72. expect(()=>cache.observe('owner','p','s',{type:'message.delta',payload:{text:'不能发布'},delivery_id:'fail'})).toThrow('disk full')
  73. await Promise.resolve()
  74. expect(store.db.prepare('SELECT COUNT(*) n FROM chat_messages').get()!.n).toBe(0)
  75. expect(store.db.prepare('SELECT COUNT(*) n FROM chat_transcript_messages').get()!.n).toBe(0)
  76. expect(store.transcripts.events('owner','p','s',0)).toEqual([])
  77. expect(listener).not.toHaveBeenCalled()
  78. })
  79. it('replays committed events after server restart without resubmitting prompts',()=>{
  80. const {home,store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
  81. cache.observe('owner','p','s',{type:'message.delta',payload:{text:'开始'},delivery_id:'one'})
  82. const before=snapshot(store)
  83. cache.observe('owner','p','s',{type:'message.complete',payload:{text:'结束'},delivery_id:'two'})
  84. const after=snapshot(store);cache.close();store.close()
  85. const reopened=new ChatCacheStore(home)
  86. try{expect(reopened.transcripts.epochFor('owner','p','s')).toBe(before.epoch);expect(reopened.transcripts.events('owner','p','s',before.cursor).reduce(applyTranscriptEvent,before.messages)).toEqual(after.messages)}finally{reopened.close()}
  87. })
  88. it('keeps owner/session scopes and history pagination independent',()=>{
  89. const {store,cache}=fixture()
  90. for(const owner of ['owner','other'])for(const id of ['s','t']){
  91. store.recordCommand(owner,'p',id,'session.create',{})
  92. for(let i=0;i<5;i++)store.recordCommand(owner,'p',id,'prompt.submit',{text:`${owner}:${id}:${i}`,_delivery_id:`${owner}:${id}:${i}`})
  93. }
  94. snapshot(store)
  95. const detail=store.localDetail('owner','p','s')!
  96. const tail=store.transcripts.snapshot('owner','p','s',{},detail.state,Number.MAX_SAFE_INTEGER,2)
  97. const old=store.transcripts.snapshot('owner','p','s',{},detail.state,tail.messages[0]!.seq,2)
  98. expect(tail.messages.map(m=>m.content)).toEqual(['owner:s:3','owner:s:4'])
  99. expect(old.messages.map(m=>m.content)).toEqual(['owner:s:1','owner:s:2'])
  100. expect(store.transcripts.events('unknown','p','s',0)).toEqual([])
  101. cache.close()
  102. })
  103. })