|
@@ -1,5 +1,5 @@
|
|
|
// @vitest-environment node
|
|
// @vitest-environment node
|
|
|
-import { mkdtempSync, rmSync } from 'node:fs'
|
|
|
|
|
|
|
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
|
import { tmpdir } from 'node:os'
|
|
import { tmpdir } from 'node:os'
|
|
|
import { join } from 'node:path'
|
|
import { join } from 'node:path'
|
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
@@ -30,6 +30,40 @@ afterEach(() => {
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
describe('durable source=web chat cache', () => {
|
|
describe('durable source=web chat cache', () => {
|
|
|
|
|
+ it.each(['prefer-local', 'shadow', 'upstream-only'] as const)(
|
|
|
|
|
+ 'preserves newer session activity and cached files after a stale 404 in %s mode', async mode => {
|
|
|
|
|
+ const f = fixture()
|
|
|
|
|
+ const coordinator = new ChatCacheCoordinator(f.store, f.coordinator.upstream, mode)
|
|
|
|
|
+ const sourcePath = '/tmp/final-report.png'
|
|
|
|
|
+ try {
|
|
|
|
|
+ f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
|
|
|
|
|
+ f.store.putSnapshot(owner, 'tail', 'messages', profile, sessionID, response({
|
|
|
|
|
+ messages: [{ id: 'latest', role: 'assistant', content: `` }],
|
|
|
|
|
+ pagination: { total: 2, offset: 0, has_more: true },
|
|
|
|
|
+ }))
|
|
|
|
|
+ f.store.storeAttachment(owner, sourcePath, response('report-bytes', 200, 'image/png'))
|
|
|
|
|
+ const asset = f.store.attachment(owner, sourcePath)!
|
|
|
|
|
+ const changed = vi.fn()
|
|
|
|
|
+ coordinator.onSynchronized = changed
|
|
|
|
|
+ const result = await coordinator.messages(owner, 'older', profile, sessionID, 1, 1, async () => {
|
|
|
|
|
+ f.store.recordCommand(owner, profile, sessionID, 'prompt.submit', { text: '新消息' })
|
|
|
|
|
+ return response({ detail: 'Session not found' }, 404)
|
|
|
|
|
+ })
|
|
|
|
|
+ expect(result.response.status).toBe(404)
|
|
|
|
|
+ expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
|
|
|
|
|
+ expect(JSON.parse(f.store.messagePage(owner, profile, sessionID, 0, 1)!.response.body.toString()).messages)
|
|
|
|
|
+ .toEqual([expect.objectContaining({ id: 'latest' })])
|
|
|
|
|
+ expect(readFileSync(asset.localPath, 'utf8')).toBe('report-bytes')
|
|
|
|
|
+ expect(changed).not.toHaveBeenCalled()
|
|
|
|
|
+
|
|
|
|
|
+ await coordinator.messages(owner, 'older', profile, sessionID, 1, 1,
|
|
|
|
|
+ async () => response({ detail: 'Session not found' }, 404))
|
|
|
|
|
+ expect(f.store.ownsSession(owner, profile, sessionID)).toBe(false)
|
|
|
|
|
+ expect(changed).toHaveBeenCalledWith(owner, profile, sessionID)
|
|
|
|
|
+ } finally { coordinator.close(); f.store.close() }
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
it('cleans confirmed missing sessions behind a warm list without pruning filtered or unavailable sessions', async () => {
|
|
it('cleans confirmed missing sessions behind a warm list without pruning filtered or unavailable sessions', async () => {
|
|
|
const f = fixture()
|
|
const f = fixture()
|
|
|
const ids = ['missing', 'archived', 'unavailable', 'new-session', 'other-profile']
|
|
const ids = ['missing', 'archived', 'unavailable', 'new-session', 'other-profile']
|