ordinaryChatLocal.test.ts 6.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  1. // @vitest-environment node
  2. import {mkdtempSync,rmSync} from 'node:fs'
  3. import {tmpdir} from 'node:os'
  4. import {join} from 'node:path'
  5. import {afterEach,describe,it,expect,vi} from 'vitest'
  6. import {ChatCacheStore,ChatCacheCoordinator} from '../../src/server/chatCache.js'
  7. import type {UpstreamServiceSession} from '../../src/server/localAuth.js'
  8. const fixtures:Array<{home:string;store:ChatCacheStore;coordinator:ChatCacheCoordinator}>=[]
  9. function setup(){const home=mkdtempSync(join(tmpdir(),'ordinary-local-')),store=new ChatCacheStore(home),request=vi.fn();const coordinator=new ChatCacheCoordinator(store,{request} as unknown as UpstreamServiceSession);fixtures.push({home,store,coordinator});return{store,coordinator,request,home}}
  10. afterEach(()=>{for(const f of fixtures.splice(0)){f.coordinator.close();if(!f.store.isClosed)f.store.close();rmSync(f.home,{recursive:true,force:true})}})
  11. function json(r:{response:{body:Buffer}}){return JSON.parse(r.response.body.toString())}
  12. describe('ordinary chat authoritative local reads',()=>{
  13. it('persists a complete streaming turn without upstream reads, including repeated chunks',()=>{
  14. const {store,coordinator,request}=setup();store.recordCommand('o','p','s','session.create',{source:'ios'})
  15. store.recordCommand('o','p','s','prompt.submit',{text:'问题',_delivery_id:'submit-1'})
  16. const send=(type:string,payload:object,delivery_id:string)=>coordinator.observe('o','p','s',{type,payload,delivery_id})
  17. send('message.start',{message_id:'a'},'1');send('reasoning.delta',{delta:'思考'},'2')
  18. send('message.delta',{delta:'哈'},'3');send('message.delta',{delta:'哈'},'4');send('message.delta',{delta:'哈'},'4')
  19. send('tool.start',{tool_id:'t',name:'read',arguments:{path:'a'}},'5');send('tool.complete',{tool_id:'t',result:'ok'},'6')
  20. send('message.complete',{},'7')
  21. const page=json(coordinator.readLocal('o','messages','p','s',{limit:20}))
  22. expect(page.messages).toHaveLength(2);expect(page.messages[1]).toMatchObject({content:'哈哈',reasoning:'思考',status:'complete',tool_calls:[{id:'t',result:'ok'}]})
  23. expect(json(coordinator.readLocal('o','list','p',undefined,{})).sessions[0].message_count).toBe(2)
  24. expect(request).not.toHaveBeenCalled()
  25. store.close();const reopened=new ChatCacheStore(fixtures.at(-1)!.home);expect(JSON.parse(reopened.messagePage('o','p','s',0,20)!.response.body.toString()).messages).toEqual(page.messages);reopened.close()
  26. })
  27. it('keeps sequence-less identical deltas and interim segments independently',()=>{
  28. const {store,coordinator}=setup();store.recordCommand('o','p','s','session.create',{})
  29. for(const [type,payload] of [['message.delta',{text:'ha'}],['message.delta',{text:'ha'}],['message.interim',{}],['message.delta',{text:'done'}],['message.complete',{}]] as const)coordinator.observe('o','p','s',{type,payload})
  30. const page=json(coordinator.readLocal('o','messages','p','s',{}));expect(page.messages.map((m:any)=>m.content)).toEqual(['haha','done'])
  31. })
  32. it('isolates preferences and clamps monotonic read cursors locally',()=>{
  33. const {store,coordinator,request}=setup();store.recordCommand('o','p','s','session.create',{});coordinator.observe('o','p','s',{type:'message.delta',payload:{text:'a'}})
  34. store.patchLocal('o','p','s',{pinned:true,title:'标题'});expect(store.pins('o','p')).toEqual(['s']);expect(store.pins('other','p')).toEqual([])
  35. expect(store.unread('o','p').total_unread).toBe(1);store.markRead('o','p','s',900);store.markRead('o','p','s',0);expect(store.unread('o','p').total_unread).toBe(0)
  36. expect(()=>coordinator.readLocal('other','messages','p','s',{})).toThrow('会话不存在');expect(request).not.toHaveBeenCalled()
  37. })
  38. it('returns cached data immediately and reports a missing page without awaiting repair',()=>{
  39. const {store,coordinator,request}=setup();store.recordRoute('o','p','legacy','r')
  40. expect(json(coordinator.readLocal('o','list','p',undefined,{})).sessions).toHaveLength(1)
  41. expect(()=>coordinator.readLocal('o','messages','p','legacy',{})).toThrow('历史正在后台补齐')
  42. expect(request).not.toHaveBeenCalled()
  43. })
  44. it('keeps newer local pin settings when remote metadata is ingested',()=>{
  45. const {store}=setup();store.recordCommand('o','p','s','session.create',{});store.patchLocal('o','p','s',{pinned:true})
  46. store.putSnapshot('o','d','detail','p','s',{status:200,headers:new Headers(),body:Buffer.from(JSON.stringify({id:'s',pinned:false,message_count:0}))})
  47. expect(store.pins('o','p')).toEqual(['s']);expect(JSON.parse(store.localList('o','p',{}).body.toString()).sessions[0].pinned).toBe(true)
  48. })
  49. })
  50. describe('ordinary chat migration and bounded recovery',()=>{
  51. it('preserves local messages and pins when a live session becomes a compressed tip',()=>{
  52. const {store,coordinator}=setup();store.recordRoute('o','p','old','runtime');store.recordCommand('o','p','old','session.create',{})
  53. coordinator.observe('o','p','old',{type:'message.delta',payload:{text:'kept'},delivery_id:'one'})
  54. store.patchLocal('o','p','old',{pinned:true,title:'local'})
  55. store.recordRoute('o','p','new','runtime')
  56. expect(store.canonicalSessionID('o','p','old')).toBe('new')
  57. expect(json(coordinator.readLocal('o','messages','p','old',{})).messages[0].content).toBe('kept')
  58. expect(store.pins('o','p')).toEqual(['new'])
  59. expect(store.canonicalSessionID('other','p','old')).toBe('old')
  60. })
  61. it('limits background repair to two concurrent conversations',async()=>{
  62. const {store,coordinator,request}=setup();let active=0,maximum=0
  63. request.mockImplementation(async(path:string)=>{active++;maximum=Math.max(maximum,active);await new Promise(r=>setTimeout(r,10));active--;
  64. return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify(path.endsWith('/messages')?{messages:[],pagination:{total:0,has_more:false}}:{id:path.split('/').at(-1),message_count:0}))}})
  65. for(let i=0;i<5;i++){store.recordRoute('o','p',`legacy-${i}`,`r-${i}`);coordinator.schedule('o','p',`legacy-${i}`)}
  66. await vi.waitFor(()=>expect(store.db.prepare('SELECT * FROM chat_recovery_jobs').all()).toHaveLength(0))
  67. expect(maximum).toBe(2)
  68. })
  69. it('promotes an assistant identity without creating a second history row',()=>{
  70. const {store,coordinator}=setup();store.recordCommand('o','p','s','session.create',{})
  71. coordinator.observe('o','p','s',{type:'message.delta',payload:{text:'hello'},delivery_id:'delta'})
  72. coordinator.observe('o','p','s',{type:'message.complete',payload:{message_id:'canonical',text:'hello'},delivery_id:'done'})
  73. const page=json(coordinator.readLocal('o','messages','p','s',{}));expect(page.messages).toHaveLength(1);expect(page.messages[0].id).toBe('canonical')
  74. })
  75. })