ituoke/frontend/tests/conversations.test.mjs

277 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import * as Vue from 'vue'
import ts from 'typescript'
// Exercise the real composable and Vue reactivity with an in-memory Wails bridge.
// HTTP and durable storage behavior are covered by the Go conversation tests.
const source = readFileSync(new URL('../src/lib/useConversations.ts', import.meta.url), 'utf8')
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const clone = value => JSON.parse(JSON.stringify(value))
const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve() }
function storage() { return { records: new Map(), selection: new Map(), attachments: new Map(), nextFile: 1, next: 1, failSave: false, beforeSave: null } }
function harness(store = storage()) {
const listeners = new Set(), timers = new Map(), cleanup = [], calls = []
let timerID = 0
class CancelError extends Error {}
const summary = c => ({ id: c.id, title: c.title, createdAt: c.createdAt, updatedAt: c.updatedAt, preview: c.messages.at(-1)?.content || '暂无消息', messageCount: c.messages.length })
const api = {
async ListConversations() { return [...store.records.values()].map(summary) },
async CreateConversation() {
const c = { id: `session-${store.next++}`, title: '新会话', createdAt: Date.now(), updatedAt: Date.now(), draft: '', messages: [] }
store.records.set(c.id, clone(c)); return clone(c)
},
async GetConversation(id) { if (!store.records.has(id)) throw new Error('missing'); return clone(store.records.get(id)) },
async SaveConversation(input) {
if (store.beforeSave) await store.beforeSave(input)
if (store.failSave) throw new Error('disk full')
const previous = store.records.get(input.id)
if (!previous) throw new Error('deleted')
const c = { ...clone(input), title: previous.title, memory: previous.memory, updatedAt: Date.now() }
if (c.title === '新会话' && c.messages.some(m => m.role === 'user')) c.title = c.messages.find(m => m.role === 'user').content
store.records.set(c.id, c); return summary(c)
},
async RenameConversation(id, title) { const c = store.records.get(id); c.title = title; return summary(c) },
async DeleteConversation(id) { store.records.delete(id) },
async UploadAttachment(sessionID, input) {
if (store.beforeUpload) await store.beforeUpload()
const file = { id: `file-${store.nextFile++}`, name: input.name, size: Buffer.from(input.data, 'base64').length, mimeType: 'text/plain', kind: 'text', textLength: Buffer.from(input.data, 'base64').length, sha256: 'fixture-hash' }
store.attachments.set(file.id, { sessionID, file, data: input.data }); return clone(file)
},
async RemoveAttachment(sessionID, id) { store.attachments.delete(id) },
ChatStreamWithFiles(requestID, sessionID, message, attachmentIDs) { const promise = api.ChatStream(requestID, sessionID, message); calls.at(-1).attachmentIDs = [...attachmentIDs]; return promise },
ChatStream(requestID, sessionID, message) {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
promise.cancel = async () => reject(new CancelError('stopped'))
calls.push({ requestID, sessionID, message, resolve, reject })
return promise
},
}
const module = { exports: {} }
runInNewContext(compiled, {
module, exports: module.exports,
require(name) {
if (name === 'vue') return { ...Vue, onUnmounted: fn => cleanup.push(fn) }
if (name === '@wailsio/runtime') return { CancelError, Events: { On: (_, fn) => { listeners.add(fn); return () => listeners.delete(fn) } } }
return { Service: api }
},
crypto: globalThis.crypto,
btoa: globalThis.btoa,
Uint8Array,
localStorage: { getItem: key => store.selection.get(key) || null, setItem: (key, value) => store.selection.set(key, value) },
setTimeout: fn => { const id = ++timerID; timers.set(id, fn); return id },
clearTimeout: id => timers.delete(id),
})
const chat = module.exports.useConversations(() => {})
return { chat, store, calls, listeners,
emit(requestID, delta) { for (const fn of [...listeners]) fn({ data: { requestID, delta } }) },
emitTool(requestID, tool) { for (const fn of [...listeners]) fn({ data: { requestID, tool } }) },
emitReasoning(requestID, reasoningDelta) { for (const fn of [...listeners]) fn({ data: { requestID, reasoningDelta } }) },
emitMemory(requestID, memory) { for (const fn of [...listeners]) fn({ data: { requestID, memory } }) },
tick() { for (const [id, fn] of [...timers]) { timers.delete(id); fn() } },
async dispose() { for (const fn of cleanup) fn(); await settle() },
}
}
test('separate drafts and saved conversations survive a controller restart', async () => {
const h = harness(); await h.chat.load()
const a = h.chat.activeID.value
h.chat.updateDraft('draft A'); await h.chat.saveCurrent()
await h.chat.create(); const b = h.chat.activeID.value
assert.notEqual(a, b); assert.equal(h.chat.current.value.draft, '')
h.chat.updateDraft('draft B'); await h.chat.saveCurrent()
await h.chat.select(a); assert.equal(h.chat.current.value.draft, 'draft A')
await h.dispose()
const restarted = harness(h.store); await restarted.chat.load()
assert.equal(restarted.chat.activeID.value, a)
assert.equal(restarted.chat.current.value.draft, 'draft A')
await restarted.chat.select(b); assert.equal(restarted.chat.current.value.draft, 'draft B')
await restarted.dispose()
})
test('switching while two conversations stream never mixes their messages', async () => {
const h = harness(); await h.chat.load(); const a = h.chat.activeID.value
const first = h.chat.send('question A'); await settle()
await h.chat.create(); const b = h.chat.activeID.value
const second = h.chat.send('question B'); await settle()
assert.equal(h.calls.length, 2)
const callA = h.calls.find(c => c.sessionID === a), callB = h.calls.find(c => c.sessionID === b)
h.emit(callA.requestID, 'answer A'); h.emit(callB.requestID, 'answer B')
assert.equal(h.chat.messages.value.at(-1).content, 'answer B')
await h.chat.select(a); assert.equal(h.chat.messages.value.at(-1).content, 'answer A')
h.tick(); await settle()
assert.equal(h.store.records.get(a).messages.at(-1).content, 'answer A')
callB.resolve({ content: 'answer B', finishReason: 'stop' }); await second
callA.resolve({ content: 'answer A', finishReason: 'stop' }); await first
assert.equal(h.store.records.get(a).messages.at(-1).status, 'complete')
assert.equal(h.store.records.get(b).messages.at(-1).status, 'complete')
assert.equal(h.listeners.size, 0)
await h.dispose()
})
test('stopping and deleting an active conversation preserves partial text then removes it', async () => {
const h = harness(); await h.chat.load(); const id = h.chat.activeID.value
const sending = h.chat.send('question'); await settle()
h.emit(h.calls[0].requestID, 'partial')
h.chat.stop(); await sending
assert.equal(h.store.records.get(id).messages.at(-1).status, 'stopped')
assert.equal(h.store.records.get(id).messages.at(-1).content, 'partial')
await h.chat.remove(id)
h.emit(h.calls[0].requestID, 'late token'); h.tick(); await settle()
assert.equal(h.store.records.has(id), false)
assert.notEqual(h.chat.activeID.value, id)
await h.dispose()
})
test('queued autosaves cannot overwrite a newer draft and failed saves can retry', async () => {
const h = harness(); await h.chat.load(); const id = h.chat.activeID.value
let release
const blocked = new Promise(resolve => { release = resolve })
let writes = 0
h.store.beforeSave = async () => { if (++writes === 1) await blocked }
h.chat.updateDraft('old'); const first = h.chat.saveCurrent(); await settle()
h.chat.updateDraft('new'); const second = h.chat.saveCurrent(); await settle()
assert.equal(writes, 1)
release(); await Promise.all([first, second])
assert.equal(h.store.records.get(id).draft, 'new')
assert.equal(h.chat.saveLabel.value, '已保存到本机')
h.store.failSave = true; h.chat.updateDraft('retry'); await h.chat.saveCurrent()
assert.match(h.chat.saveError.value, /disk full/)
assert.equal(h.store.records.get(id).draft, 'new')
h.store.failSave = false; await h.chat.saveCurrent()
assert.equal(h.store.records.get(id).draft, 'retry')
assert.equal(h.chat.saveError.value, '')
await h.dispose()
})
test('reopened incomplete replies are marked stopped and saved without losing content', async () => {
const store = storage()
store.records.set('saved', { id: 'saved', title: 'saved', createdAt: 1, updatedAt: 2, draft: '', messages: [{ id: 'a', role: 'assistant', content: 'partial', createdAt: 2, status: 'streaming' }] })
const h = harness(store); await h.chat.load(); await h.chat.saveCurrent()
assert.equal(h.chat.messages.value[0].status, 'stopped')
assert.equal(store.records.get('saved').messages[0].content, 'partial')
assert.equal(store.records.get('saved').messages[0].status, 'stopped')
await h.dispose()
})
test('tool progress stays in its conversation, persists and stops with generation', async () => {
const h = harness(); await h.chat.load(); const first = h.chat.activeID.value
const sending = h.chat.send('打开手机设置'); await settle()
const requestID = h.calls[0].requestID
const tool = { id: 'tool-1', server: 'Mobile', tool: 'launch_app', status: 'running', arguments: '{}' }
h.emitTool(requestID, tool)
assert.equal(h.chat.messages.value.at(-1).tools[0].status, 'running')
await h.chat.create()
h.emitTool(requestID, { ...tool, status: 'complete', result: 'opened', elapsedMs: 123, images: [{ id: 'screenshot', name: 'screen.png', kind: 'image' }] })
assert.equal(h.chat.messages.value.length, 0)
await h.chat.select(first)
assert.equal(h.chat.messages.value.at(-1).tools[0].status, 'complete')
h.emitTool(requestID, { ...tool, id: 'tool-2' })
h.chat.stop(); await sending
assert.deepEqual(h.store.records.get(first).messages.at(-1).tools.map(t => t.status), ['complete', 'stopped'])
const restarted = harness(h.store); await restarted.chat.load()
assert.equal(restarted.chat.messages.value.at(-1).tools[0].result, 'opened')
assert.equal(restarted.chat.messages.value.at(-1).tools[0].images[0].id, 'screenshot')
await restarted.dispose(); await h.dispose()
})
test('summary progress and server-managed memory stay with their conversation', async () => {
const h = harness(); await h.chat.load(); const first = h.chat.activeID.value
const sending = h.chat.send('继续讨论'); await settle()
const requestID = h.calls[0].requestID
h.emitMemory(requestID, { status: 'summarizing', message: '正在整理历史…' })
assert.equal(h.chat.memoryStatus.value, '正在整理历史…')
await h.chat.create()
assert.equal(h.chat.memoryStatus.value, '')
const memory = { text: '预算3500,数量500,只起草不发送', throughId: 'old-answer', prefixHash: 'hash', messageCount: 20, updatedAt: 100, version: 1 }
h.store.records.get(first).memory = memory
h.emitMemory(requestID, { status: 'complete', memory })
assert.equal(h.chat.current.value.memory, undefined)
await h.chat.select(first)
assert.equal(h.chat.current.value.memory.text, memory.text)
assert.equal(h.chat.memoryStatus.value, '')
h.calls[0].resolve({ content: '预算3500', finishReason: 'stop' }); await sending
assert.equal(h.store.records.get(first).memory.text, memory.text)
const restarted = harness(h.store); await restarted.chat.load()
assert.equal(restarted.chat.current.value.memory.text, memory.text)
await restarted.dispose(); await h.dispose()
})
test('file drafts survive restart, send without text, and remain with their original conversation', async () => {
const h = harness(); await h.chat.load(); const first = h.chat.activeID.value
await h.chat.uploadFiles([new File(['预算3500,数量500'], '需求.txt', { type: 'text/plain' })])
assert.equal(h.chat.uploadError.value, '')
assert.equal(h.chat.current.value.draftAttachments[0].name, '需求.txt')
assert.equal(h.store.records.get(first).draftAttachments.length, 1)
await h.chat.create(); assert.equal(h.chat.current.value.draftAttachments, undefined)
await h.chat.select(first)
const restarted = harness(h.store); await restarted.chat.load()
assert.equal(restarted.chat.current.value.draftAttachments[0].name, '需求.txt')
const send = restarted.chat.send(''); await settle()
assert.equal(restarted.calls[0].attachmentIDs.length, 1)
assert.match(restarted.calls[0].message, /分析这些附件/)
assert.equal(restarted.chat.messages.value[0].attachments[0].name, '需求.txt')
assert.equal(restarted.chat.current.value.draftAttachments.length, 0)
restarted.calls[0].resolve({ content: '预算3500,数量500', finishReason: 'stop' }); await send
assert.equal(h.store.records.get(first).messages[0].attachments.length, 1)
const sentFiles = restarted.chat.messages.value[0].attachments
restarted.chat.setDraftAttachments(sentFiles)
assert.equal(restarted.chat.current.value.draftAttachments[0].id, sentFiles[0].id)
await restarted.chat.removeDraftAttachment(sentFiles[0].id)
assert.equal(h.store.attachments.has(sentFiles[0].id), true)
await restarted.dispose(); await h.dispose()
})
test('upload can finish in a background conversation and oversized selections stay out', async () => {
const h = harness(); await h.chat.load(); const first = h.chat.activeID.value
let release; h.store.beforeUpload = () => new Promise(resolve => { release = resolve })
const uploading = h.chat.uploadFiles([new File(['text'], 'draft.md')]); await settle()
assert.equal(h.chat.uploading.value, true)
await h.chat.create(); assert.equal(h.chat.uploading.value, false)
release(); await uploading
assert.equal(h.chat.current.value.draftAttachments, undefined)
await h.chat.select(first)
assert.equal(h.chat.current.value.draftAttachments[0].name, 'draft.md')
const id = h.chat.current.value.draftAttachments[0].id
await h.chat.removeDraftAttachment(id)
assert.equal(h.store.attachments.has(id), false)
assert.equal(h.chat.current.value.draftAttachments.length, 0)
await h.chat.uploadFiles([{ name: 'huge.txt', size: 21 * 1024 * 1024 }])
assert.match(h.chat.uploadError.value, /20 MB/)
assert.equal(h.chat.uploading.value, false)
await h.dispose()
})
test('reasoning streams separately, stays in its conversation, and survives stopping and restart', async () => {
const h = harness(); await h.chat.load(); const first = h.chat.activeID.value
const sending = h.chat.send('检查当前页面'); await settle()
const requestID = h.calls[0].requestID
h.emitReasoning(requestID, '先确认设备,'); h.emitReasoning(requestID, '再读取页面。')
assert.equal(h.chat.messages.value.at(-1).reasoning, '先确认设备,再读取页面。')
assert.equal(h.chat.messages.value.at(-1).content, '')
await h.chat.create(); h.emitReasoning(requestID, '核对应用。')
assert.equal(h.chat.messages.value.length, 0)
await h.chat.select(first); h.chat.stop()
h.emitReasoning(requestID, '停止后的内容应忽略'); await sending
assert.equal(h.store.records.get(first).messages.at(-1).reasoning, '先确认设备,再读取页面。核对应用。')
const restarted = harness(h.store); await restarted.chat.load()
assert.equal(restarted.chat.messages.value.at(-1).reasoning, '先确认设备,再读取页面。核对应用。')
assert.equal(restarted.chat.messages.value.at(-1).status, 'stopped')
await restarted.dispose(); await h.dispose()
})
test('completed reasoning uses the final result and does not enter answer text', async () => {
const h = harness(); await h.chat.load()
const sending = h.chat.send('整理方案'); await settle()
h.emitReasoning(h.calls[0].requestID, '部分思考')
h.calls[0].resolve({ content: '完整回答', reasoning: '完整思考', finishReason: 'stop' }); await sending
assert.equal(h.chat.messages.value.at(-1).content, '完整回答')
assert.equal(h.chat.messages.value.at(-1).reasoning, '完整思考')
assert.equal(h.store.records.get(h.chat.activeID.value).messages.at(-1).reasoning, '完整思考')
await h.dispose()
})