|
|
@@ -31,7 +31,7 @@ afterEach(() => {
|
|
|
|
|
|
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 => {
|
|
|
+ 'preserves sessions and cached files after repeated read 404s in %s mode', async mode => {
|
|
|
const f = fixture()
|
|
|
const coordinator = new ChatCacheCoordinator(f.store, f.coordinator.upstream, mode)
|
|
|
const sourcePath = '/tmp/final-report.png'
|
|
|
@@ -58,75 +58,41 @@ describe('durable source=web chat cache', () => {
|
|
|
|
|
|
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() }
|
|
|
+ expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
|
|
|
+ expect(readFileSync(asset.localPath, 'utf8')).toBe('report-bytes')
|
|
|
+ expect(changed).not.toHaveBeenCalled()
|
|
|
+ } finally { f.store.close() }
|
|
|
},
|
|
|
)
|
|
|
|
|
|
- it('cleans confirmed missing sessions behind a warm list without pruning filtered or unavailable sessions', async () => {
|
|
|
+ it('serves older cached sessions without probing or automatically cleaning them', async () => {
|
|
|
const f = fixture()
|
|
|
- const ids = ['missing', 'archived', 'unavailable', 'new-session', 'other-profile']
|
|
|
- for (const id of ids) {
|
|
|
- const scope = id === 'other-profile' ? 'another' : profile
|
|
|
- f.store.recordCommand(owner, scope, id, 'session.create', { source: 'web' })
|
|
|
- f.store.putSnapshot(owner, `detail:${id}`, 'detail', scope, id, response({ id, profile: scope, title: id }))
|
|
|
- }
|
|
|
- f.store.db.prepare('UPDATE chat_sessions SET owned_at=? WHERE session_id<>?').run(Date.now() - 120_000, 'new-session')
|
|
|
- const request = vi.mocked(f.coordinator.upstream.request).mockImplementation(async path => {
|
|
|
- if (path.endsWith('/archived')) return response({ id: 'archived', archived: true })
|
|
|
- if (path.endsWith('/unavailable')) return response({}, 503)
|
|
|
- return response({ detail: 'Session not found' }, 404)
|
|
|
- })
|
|
|
- const changed = vi.fn()
|
|
|
- f.coordinator.onSynchronized = changed
|
|
|
- const load = vi.fn(async () => response({ sessions: [], total: 0 }))
|
|
|
- await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
|
|
|
- await vi.waitFor(() => expect(f.store.ownsSession(owner, profile, 'missing')).toBe(false))
|
|
|
- expect(f.store.ownsSession(owner, profile, 'archived')).toBe(true)
|
|
|
- expect(f.store.ownsSession(owner, profile, 'unavailable')).toBe(true)
|
|
|
- expect(f.store.ownsSession(owner, profile, 'new-session')).toBe(true)
|
|
|
- expect(f.store.ownsSession(owner, 'another', 'other-profile')).toBe(true)
|
|
|
- expect(request.mock.calls.every(([, options]) => !options?.method || options.method === 'GET')).toBe(true)
|
|
|
- expect(changed).toHaveBeenCalledWith(owner, profile, 'missing')
|
|
|
- await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
|
|
|
- expect(load).toHaveBeenCalledTimes(1)
|
|
|
- f.store.close()
|
|
|
+ try {
|
|
|
+ f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
|
|
|
+ f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({ id: sessionID, title: '保留' }))
|
|
|
+ f.store.db.prepare('UPDATE chat_sessions SET owned_at=?').run(Date.now() - 120_000)
|
|
|
+ const load = vi.fn(async () => response({ sessions: [], total: 0 }))
|
|
|
+ vi.mocked(f.coordinator.upstream.request).mockResolvedValue(response({}, 404))
|
|
|
+ const result = await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
|
|
|
+ expect(result.source).toBe('local')
|
|
|
+ expect(JSON.parse(result.response.body.toString()).sessions).toContainEqual(expect.objectContaining({ id: sessionID }))
|
|
|
+ expect(load).not.toHaveBeenCalled()
|
|
|
+ expect(f.coordinator.upstream.request).not.toHaveBeenCalled()
|
|
|
+ expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
|
|
|
+ } finally { f.store.close() }
|
|
|
})
|
|
|
|
|
|
- it('removes a missing session when reconciliation receives a definitive 404', async () => {
|
|
|
+ it('retains a cached session when reconciliation receives a 404', async () => {
|
|
|
const f = fixture()
|
|
|
f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
|
|
|
f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({ id: sessionID, title: 'ghost' }))
|
|
|
vi.mocked(f.coordinator.upstream.request).mockResolvedValue(response({ detail: 'Session not found' }, 404))
|
|
|
- await f.coordinator.reconcile(owner, profile, sessionID).catch(() => {})
|
|
|
- expect(f.store.ownsSession(owner, profile, sessionID)).toBe(false)
|
|
|
- expect(f.store.localDetail(owner, profile, sessionID)).toBeUndefined()
|
|
|
+ await expect(f.coordinator.reconcile(owner, profile, sessionID)).rejects.toThrow('Unable to synchronize conversation metadata')
|
|
|
+ expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
|
|
|
+ expect(f.store.localDetail(owner, profile, sessionID)).toBeDefined()
|
|
|
f.store.close()
|
|
|
})
|
|
|
|
|
|
- it('keeps local chats during a list outage and ignores deletion probes superseded by a new turn', async () => {
|
|
|
- const f = fixture()
|
|
|
- f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
|
|
|
- f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({ id: sessionID, title: '保留' }))
|
|
|
- f.store.db.prepare('UPDATE chat_sessions SET owned_at=?').run(Date.now() - 120_000)
|
|
|
- const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(response({ sessions: [] }))
|
|
|
- vi.useFakeTimers({ toFake: ['Date'] })
|
|
|
- try {
|
|
|
- await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
|
|
|
- await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
- expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
|
|
|
- vi.setSystemTime(Date.now() + 31_000)
|
|
|
- vi.mocked(f.coordinator.upstream.request).mockImplementation(async () => {
|
|
|
- f.store.recordCommand(owner, profile, sessionID, 'prompt.submit', { text: 'new turn' })
|
|
|
- return response({}, 404)
|
|
|
- })
|
|
|
- await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
|
|
|
- await vi.waitFor(() => expect(f.coordinator.upstream.request).toHaveBeenCalled())
|
|
|
- expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
|
|
|
- } finally { vi.useRealTimers(); f.store.close() }
|
|
|
- })
|
|
|
-
|
|
|
it('keeps acknowledged flags across aliases and rejects a deleted session sync', () => {
|
|
|
const f = fixture()
|
|
|
f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
|