| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104 |
- // @vitest-environment node
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { mkdtempSync, rmSync } from 'node:fs'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { ChatCacheStore, ChatCacheCoordinator } from '../../src/server/chatCache'
- import { applyTranscriptEvent, type TranscriptSnapshot } from '../../src/shared/chatTranscript'
- import type { UpstreamServiceSession } from '../../src/server/localAuth'
- const fixtures:Array<{home:string;store:ChatCacheStore;cache:ChatCacheCoordinator}>=[]
- 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}}
- 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})}})
- function snapshot(store:ChatCacheStore,id='s',owner='owner'):TranscriptSnapshot {
- store.transcripts.seed(owner,'p',id)
- const detail=store.localDetail(owner,'p',id)!
- return store.transcripts.snapshot(owner,'p',id,JSON.parse(detail.response.body.toString()),detail.state)
- }
- describe('ordinary server-owned transcript',()=>{
- it('preserves interim text, reasoning, tool results and final text as separate records',()=>{
- const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
- store.recordCommand('owner','p','s','prompt.submit',{text:'问题',_delivery_id:'ios:prompt:user-one'})
- let n=0
- const send=(type:string,payload:object)=>cache.observe('owner','p','s',{type,payload,delivery_id:`event-${++n}`})
- send('message.start',{})
- send('reasoning.delta',{text:'先思考'})
- send('message.interim',{text:'先检查文件'})
- send('tool.start',{tool_id:'tool-one',name:'read_file'})
- send('tool.complete',{tool_id:'tool-one',result:'完整工具输出'})
- send('message.delta',{text:'最终答案'})
- send('message.complete',{text:'最终答案'})
- const value=snapshot(store)
- expect(value.messages.map(m=>m.content)).toEqual(['问题','先检查文件','最终答案'])
- expect(value.messages[1]!.reasoning).toBe('先思考')
- expect(value.messages[2]!.tool_calls).toMatchObject([{id:'tool-one',result:'完整工具输出'}])
- expect(new Set(value.messages.map(m=>m.id)).size).toBe(3)
- expect(value.messages.map(m=>m.seq)).toEqual([1,2,3])
- })
- it('keeps a queued prompt from detaching the currently streaming assistant',()=>{
- const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
- store.recordCommand('owner','p','s','prompt.submit',{text:'第一问',_delivery_id:'ios:prompt:first'})
- cache.observe('owner','p','s',{type:'message.delta',payload:{text:'前半'},delivery_id:'one'})
- const assistant=snapshot(store).messages.find(m=>m.role==='assistant')!
- store.recordCommand('owner','p','s','prompt.submit',{text:'排队的第二问',_delivery_id:'ios:prompt:second'})
- cache.observe('owner','p','s',{type:'message.delta',payload:{text:'后半'},delivery_id:'two'})
- expect(snapshot(store).messages.find(m=>m.id===assistant.id)?.content).toBe('前半后半')
- expect(snapshot(store).messages.filter(m=>m.role==='assistant')).toHaveLength(1)
- })
- it('commits replayable append patches and ignores duplicate deliveries',()=>{
- const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
- cache.observe('owner','p','s',{type:'message.delta',payload:{text:'中文'},delivery_id:'one'})
- const base=snapshot(store),id=base.messages[0]!.id
- cache.observe('owner','p','s',{type:'message.delta',payload:{text:'🙂'},delivery_id:'two'})
- cache.observe('owner','p','s',{type:'message.delta',payload:{text:'🙂'},delivery_id:'two'})
- const events=store.transcripts.events('owner','p','s',base.cursor)
- expect(events).toHaveLength(1);expect(events[0]!.type).toBe('message.patch')
- const replay=events.reduce(applyTranscriptEvent,base.messages)
- expect(replay).toEqual(snapshot(store).messages)
- expect(replay[0]).toMatchObject({id,content:'中文🙂',revision:base.messages[0]!.revision+1})
- expect(applyTranscriptEvent(replay,events[0]!)).toEqual(replay)
- })
- it('retains stable message identity when an upstream row ID becomes known',()=>{
- const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
- cache.observe('owner','p','s',{type:'message.delta',payload:{text:'回复'},delivery_id:'one'})
- const before=snapshot(store).messages[0]!
- cache.observe('owner','p','s',{type:'message.complete',payload:{message_id:'upstream-42',text:'完整回复'},delivery_id:'two'})
- expect(snapshot(store).messages).toHaveLength(1)
- expect(snapshot(store).messages[0]).toMatchObject({id:before.id,seq:before.seq,source_message_id:'upstream-42',content:'完整回复',status:'complete'})
- })
- it('rolls back content and event log together when durable event storage fails',async()=>{
- const {store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
- const listener=vi.fn();store.transcripts.subscribe(listener)
- store.db.exec("CREATE TRIGGER fail_transcript_event BEFORE INSERT ON chat_transcript_events BEGIN SELECT RAISE(ABORT,'disk full'); END")
- expect(()=>cache.observe('owner','p','s',{type:'message.delta',payload:{text:'不能发布'},delivery_id:'fail'})).toThrow('disk full')
- await Promise.resolve()
- expect(store.db.prepare('SELECT COUNT(*) n FROM chat_messages').get()!.n).toBe(0)
- expect(store.db.prepare('SELECT COUNT(*) n FROM chat_transcript_messages').get()!.n).toBe(0)
- expect(store.transcripts.events('owner','p','s',0)).toEqual([])
- expect(listener).not.toHaveBeenCalled()
- })
- it('replays committed events after server restart without resubmitting prompts',()=>{
- const {home,store,cache}=fixture();store.recordCommand('owner','p','s','session.create',{})
- cache.observe('owner','p','s',{type:'message.delta',payload:{text:'开始'},delivery_id:'one'})
- const before=snapshot(store)
- cache.observe('owner','p','s',{type:'message.complete',payload:{text:'结束'},delivery_id:'two'})
- const after=snapshot(store);cache.close();store.close()
- const reopened=new ChatCacheStore(home)
- 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()}
- })
- it('keeps owner/session scopes and history pagination independent',()=>{
- const {store,cache}=fixture()
- for(const owner of ['owner','other'])for(const id of ['s','t']){
- store.recordCommand(owner,'p',id,'session.create',{})
- for(let i=0;i<5;i++)store.recordCommand(owner,'p',id,'prompt.submit',{text:`${owner}:${id}:${i}`,_delivery_id:`${owner}:${id}:${i}`})
- }
- snapshot(store)
- const detail=store.localDetail('owner','p','s')!
- const tail=store.transcripts.snapshot('owner','p','s',{},detail.state,Number.MAX_SAFE_INTEGER,2)
- const old=store.transcripts.snapshot('owner','p','s',{},detail.state,tail.messages[0]!.seq,2)
- expect(tail.messages.map(m=>m.content)).toEqual(['owner:s:3','owner:s:4'])
- expect(old.messages.map(m=>m.content)).toEqual(['owner:s:1','owner:s:2'])
- expect(store.transcripts.events('unknown','p','s',0)).toEqual([])
- cache.close()
- })
- })
|