openpencil/tests/engine/app/chat/history/controller.test.ts
Danila Poyarkov b4e479d9d0
feat: persist AI conversations and add chat history (#661)
* feat: persist AI conversations and add chat history

Store transcripts and attachment previews in IndexedDB, separate conversation history from transport lifetime, and add document-aware switching with shared Storybook coverage. Preserve interrupted activity and guard stale writes and async switches.

* test: group chat history tests by domain

* feat: simplify chat history navigation and diagnostics

Use a compact header with searchable document-scoped history and a correctly anchored conversation menu. Move diagnostic copying into the menu with copy-result feedback, and separate Storybook fixtures from composition.

* fix: address chat history review findings

* test: cover harness shutdown and restored chat scrolling

* fix: preserve Portless proxy port in MCP routes

* docs: clarify UI animation conventions

* feat: configure reasoning display and animate disclosure

* fix: separate transcript following from reasoning disclosure

* fix: restore selected chat after document recovery

* refactor: separate chat history persistence and sessions

* style: format reasoning story imports
2026-09-08 11:19:07 +03:00

181 lines
5.5 KiB
TypeScript

import 'fake-indexeddb/auto'
import { expect, test } from 'bun:test'
import type { UIMessage } from 'ai'
import { reactive } from 'vue'
import { createConversationHistory } from '@/app/ai/chat/history/controller'
import type { ChatDocumentEditor } from '@/app/ai/chat/history/document'
import { createConversationStore } from '@/app/ai/chat/history/idb'
function editor(): ChatDocumentEditor {
const id = crypto.randomUUID()
return {
state: { documentName: 'Test document' },
getRecoveryId: () => id,
getSourceIdentity: () => ({ path: null, handle: null }),
getDocumentFilePath: () => null,
getStorageBinding: () => null
}
}
test('adopting recovery in the same editor loads its selection instead of moving old chats', async () => {
const store = createConversationStore()
const active = editor()
const history = createConversationHistory(
{
getEditor: () => active,
ensureChat: async () => null,
profileId: () => null,
resetChat: async () => undefined,
backend: () => 'direct'
},
store
)
await history.initialize()
const original = history.current.value
if (!original) throw new Error('Missing draft')
await history.rename(original.id, 'Existing chat')
const recoveryId = crypto.randomUUID()
const restoredId = crypto.randomUUID()
await store.write({
...original,
id: restoredId,
documentId: `recovery:${recoveryId}`,
title: 'Recovered chat',
titleSource: 'manual'
})
await store.select(`recovery:${recoveryId}`, restoredId)
active.getRecoveryId = () => recoveryId
await history.initialize()
expect(history.current.value?.id).toBe(restoredId)
expect((await store.read(original.id))?.documentId).toBe(original.documentId)
})
function fixture() {
let activeEditor = editor()
let failWrites = false
let resets = 0
const store = createConversationStore()
const chat = reactive({
messages: [] as UIMessage[],
status: 'ready' as const,
stop: async () => undefined
})
const history = createConversationHistory(
{
getEditor: () => activeEditor,
ensureChat: async () => chat,
resetChat: async () => {
resets++
},
profileId: () => null,
backend: () => 'direct'
},
{
...store,
write: async (row) => {
if (failWrites) throw new DOMException('Storage full', 'QuotaExceededError')
await store.write(row)
}
}
)
return {
history,
store,
chat,
setFail: (value: boolean) => {
failWrites = value
},
getResets: () => resets,
switchEditor: () => {
activeEditor = editor()
}
}
}
test('failed flush keeps the current conversation and blocks switching until retry succeeds', async () => {
const f = fixture()
await f.history.ensureChat()
const id = f.history.current.value?.id
f.chat.messages = [
{ id: 'message', role: 'user', parts: [{ type: 'text', text: 'Keep this draft' }] }
]
f.setFail(true)
await expect(f.history.newChat()).rejects.toThrow('Storage full')
expect(f.history.current.value?.id).toBe(id)
expect(f.history.messages.value[0]?.id).toBe('message')
expect(f.history.storageError.value).toBe(true)
f.setFail(false)
await f.history.newChat()
expect(f.history.current.value?.id).not.toBe(id)
expect(f.history.storageError.value).toBe(false)
if (!id) throw new Error('Missing conversation')
expect((await f.store.read(id))?.messages[0]?.message.id).toBe('message')
})
test('queued chat switches finish on the last requested conversation without losing transcripts', async () => {
const f = fixture()
await f.history.initialize()
const first = f.history.current.value?.id
if (first) await f.history.rename(first, 'First')
await f.history.newChat()
const second = f.history.current.value?.id
if (second) await f.history.rename(second, 'Second')
if (!first || !second) throw new Error('Missing conversation')
await Promise.all(
Array.from({ length: 20 }, (_, index) => f.history.open(index % 2 === 0 ? first : second))
)
expect(f.history.current.value?.id).toBe(second)
expect(await f.store.getSelected(f.history.current.value.documentId)).toBe(second)
expect(await f.store.read(first)).not.toBeNull()
expect(f.history.busy.value).toBe(false)
})
test('opening the panel and repeatedly creating drafts does not persist empty chats', async () => {
const f = fixture()
await f.history.initialize()
const documentId = f.history.current.value?.documentId
await f.history.newChat()
await f.history.newChat()
expect(await f.store.list(documentId)).toEqual([])
if (!documentId) throw new Error('Missing document')
expect(await f.store.getSelected(documentId)).toBeNull()
})
test('a pending transport is rejected when the active document changes', async () => {
let activeEditor = editor()
const pending = Promise.withResolvers<{
messages: UIMessage[]
status: 'ready'
stop: () => Promise<void>
}>()
const started = Promise.withResolvers<undefined>()
let stops = 0
const history = createConversationHistory(
{
getEditor: () => activeEditor,
ensureChat: () => {
started.resolve(undefined)
return pending.promise
},
profileId: () => null,
resetChat: async () => undefined,
backend: () => 'direct'
},
createConversationStore()
)
const initializing = history.ensureChat()
await started.promise
activeEditor = editor()
pending.resolve({
messages: [],
status: 'ready',
stop: async () => {
stops++
}
})
expect(await initializing).toBeNull()
expect(stops).toBe(1)
})