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() })