diff --git a/docs/features/document-processing/implementation.md b/docs/features/document-processing/implementation.md index 0a246d82..9ea68269 100644 --- a/docs/features/document-processing/implementation.md +++ b/docs/features/document-processing/implementation.md @@ -64,6 +64,10 @@ Main 先保存资产,再保存草稿或接受队列;消息、分支建立独 会话删除、项目删除及清空数据均核对引用,启动回收已无归属的记录和孤立 UUID 目录。 清理失败保留无引用数据库记录供下次重试,不回滚已接受的草稿。 +引用保存按输入顺序去重并查询资产。已有引用的顺序、会话归属均相同,且资产无需从临时目录 +转存时,直接返回,不重复开启写事务或删除、插入引用。空引用同样适用;顺序、归属或附件 +发生变化时仍按原事务更新,临时资产仍须转存。 + 助手库 schema 39 标记新附件元数据,阻止旧客户端将其当旧格式读取;没有批量改写历史附件。 知识库 schema 13 新增 `metadata.parsedResultId` 表达式索引,结果按主键定位,不扫描全部文档。 知识库新结果先写入独立 resultId 目录,数据库正文/chunks 发布成功后替换引用,再清理旧目录。 diff --git a/docs/features/magic-notes/progress.md b/docs/features/magic-notes/progress.md index ac5a5bce..1b4ecb82 100644 --- a/docs/features/magic-notes/progress.md +++ b/docs/features/magic-notes/progress.md @@ -1,5 +1,30 @@ # Magic Notes Progress +## 2026-09-28: Startup Body Read Reuse + +Startup repair now reuses the asset references collected during revision +inspection, as described in [file reconciliation](./technical-design.md#schema-38-migration). +The persisted format and revision recovery rules are unchanged. + +- Real temporary SQLite/filesystem fixture: 120 entries across 120 notes, + half rich text and half canvas, with 4,039,560 bytes of body JSON. + Five initializations each read 240 bodies before and 120 after the change. + Logical bytes read fell from 8,079,120 to 4,039,560 per initialization. +- Baseline durations were 148.23, 134.42, 131.06, 140.66 and 172.90 ms; + after durations were 136.89, 119.11, 116.41, 115.14 and 116.26 ms. + Medians were 140.66 and 116.41 ms. These Windows runs used warm filesystem + caches on a shared machine; they do not measure cold application startup. +- `npx vitest run src/main/magic-notes/magic-note-storage.test.ts src/main/assistant/assistant-database.test.ts src/main/assistant/assistant-storage-upgrade.test.ts src/main/assistant-storage-startup.test.ts tests/assistant-storage-worker.test.ts` + passed 154 tests. Coverage includes failed media inspection, missing/invalid + bodies, retained shared assets, revision mismatch recovery and migration. +- Full `npm test`: 5382 passed, 84 skipped, 3 failed. The failures were the + App question-round request wait and Electron note capture focus/layout waits. + All three passed in targeted reruns; the full run was not all green. + The capture rerun exercised production App/preload/IPC/SQLite and reopening. +- `npm run typecheck` passed. Full lint still reports 10 errors in the existing + untracked `sidebar-final-probe.ts` and `sidebar-final-run.mjs`; lint excluding + those two files passed. No provider calls were made for these storage benchmarks. + ## 2026-09-27: Shared Destructive Button Styles The draft discard Portal now receives the global `danger-solid` styles. The diff --git a/docs/features/magic-notes/technical-design.md b/docs/features/magic-notes/technical-design.md index cbe3912e..d54ef666 100644 --- a/docs/features/magic-notes/technical-design.md +++ b/docs/features/magic-notes/technical-design.md @@ -173,7 +173,12 @@ empty content. Failed creates leave no accepted database membership; their unaccepted files are removed on the next access or reopen. After commit, reconciliation derives `note.json` from database membership and -scans all retained entry files before removing unreferenced assets. Manifest or +inspects all retained entry files before removing unreferenced assets. Startup +repair reads each current body once to inspect its revision and collect asset +references, then passes the complete per-note reference set to reconciliation. +If media inspection fails, cleanup uses its existing deferred retry path rather +than trusting a partial set. Revision mismatch recovery still hydrates the body; +legacy conversion still verifies its written content. Manifest or GC failures log `console.warn` with the note ID and error and remain in an in-memory retry set. Later database access retries; reopening derives cleanup again from membership. These cleanup failures do not turn a committed save, diff --git a/docs/roadmap/product-performance-experience-improvement-plan.md b/docs/roadmap/product-performance-experience-improvement-plan.md index ea47afab..aa65fa09 100644 --- a/docs/roadmap/product-performance-experience-improvement-plan.md +++ b/docs/roadmap/product-performance-experience-improvement-plan.md @@ -280,6 +280,44 @@ Renderer 堆内存。 - **验收:** 测试数据、运行步骤和原始结果可重复执行。 +#### 2026-09-28 Measurement Evidence (Partial) + +`npx vitest run tests/streaming-markdown.electron.test.ts` runs the real +MarkdownRenderer in Electron 43.2.0 / Chromium 150.0.7871.129. It compares the +original normalizer with fast paths that skip unchanged text, retaining live +Markdown, math, code and HTML behavior. It also measures a 102,400-byte answer +at six growing prefixes, alternating original/current execution order in the +same page, with one warm-up sweep and three measured sweeps (18 samples each). + +| Measurement | Original | Current | +| --- | --- | --- | +| 100KiB growing-prefix render median, isolated run | 54.80 ms | 54.65 ms | +| Same run p95 | 95.50 ms | 106.10 ms | +| Growing-prefix render median, full-suite run | 53.50 ms | 53.10 ms | +| Same run p95 | 93.70 ms | 83.40 ms | + +These development-mode measurements include synchronous React rendering, DOM +commit and style/layout, but exclude frame waiting and paint. With 18 samples, +nearest-rank p95 is the maximum sample. The results do not establish an overall +rendering improvement. Smaller 6.4-7.2K-character corpora do show lower normalizer +cost: prose median 33-34 microseconds to below 1 microsecond, and mixed math/code +median 32-35 to 16-20 microseconds. The production changes remain limited to this +preprocessing and memoizing unchanged subagent-derived collections. In the +four-subagent regression, initial render plus six updates builds the lookup map +once instead of seven times; block-dependent scans still run when blocks change. + +Validation: 550 prefix normalization comparisons, 1,100 short-prefix DOM +comparisons and 24 long-prefix DOM comparisons passed. The real Electron +terminal-status probe covers cancellation, failure, retry and persistence via +three loopback model-stub requests; the Mermaid viewer covers nine scenarios +and ten PNG downloads. No external model calls were used for these probes. +Full `npm test` passed 459 files / 5,397 tests, with 11 files / 84 tests skipped. +Type checking and scoped lint passed. Full lint remains blocked by the existing +untracked sidebar-final probe files. + +This is partial evidence for PERF-03, not completion: full-App input latency, +frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured. + ### PERF-04 流式更新合并 - **优先级 / 状态:** P1 / 待开始 diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 8d13b604..8e1a02f9 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -1888,7 +1888,7 @@ export class AssistantDatabase { return JSON.stringify({ storage: 'file', version: content.version, revision }) } - private reconcileMagicNoteFiles(noteId?: string): void { + private reconcileMagicNoteFiles(noteId?: string, inspectedAssets?: ReadonlySet): void { const database = this.database! // SQL and authoritative bodies are already committed. Cleanup must never // turn an accepted save/delete/reset into a retryable UI write failure. @@ -1901,7 +1901,7 @@ export class AssistantDatabase { else this.noteStorage.clear() } else if (database.prepare('SELECT id FROM magic_notes WHERE id = ?').get(noteId)) { const entries = database.prepare('SELECT id FROM magic_note_entries WHERE note_id = ? ORDER BY created_at, rowid').all(noteId) as Array<{ id: string }> - this.noteStorage.reconcile(noteId, entries.map((entry) => entry.id)) + this.noteStorage.reconcile(noteId, entries.map((entry) => entry.id), inspectedAssets) } else { this.noteStorage.deleteNote(noteId) } @@ -1929,6 +1929,7 @@ export class AssistantDatabase { let processed = 0 let migrated = false for (const note of notes) { + let referencedAssets: Set | undefined = new Set() const entries = database.prepare('SELECT id FROM magic_note_entries WHERE note_id = ? ORDER BY created_at, rowid').all(note.id) as Array<{ id: string }> for (const { id } of entries) { if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError') @@ -1937,6 +1938,7 @@ export class AssistantDatabase { // One payload at a time: a canvas may contain a large PDF. const row = database.prepare('SELECT * FROM magic_note_entries WHERE id = ?').get(id) as MagicNoteEntryRow const pointer = JSON.parse(row.content_json) as { storage?: string; revision?: number } + const inspected = pointer.storage === 'file' ? this.noteStorage.inspect(note.id, id) : undefined if (pointer.storage !== 'file') { const content = JSON.parse(row.content_json) as MagicNoteContent this.noteStorage.write({ noteId: note.id, id, content, revision: row.revision, updatedAt: row.updated_at }) @@ -1948,7 +1950,7 @@ export class AssistantDatabase { magicNotePlainText(content), magicNoteEmbeddedBytes(content), id ) migrated = true - } else if (this.noteStorage.revision(note.id, id) !== pointer.revision) { + } else if (inspected!.revision !== pointer.revision) { const saved = this.noteStorage.read(note.id, id) database.prepare(`UPDATE magic_note_entries SET content_json = ?, plain_text = ?, image_bytes = ?, revision = MAX(revision, ?), updated_at = ?, comments_json = '[]', analyzed_at = NULL WHERE id = ?`).run( @@ -1958,6 +1960,9 @@ export class AssistantDatabase { database.prepare('UPDATE magic_notes SET revision = revision + 1, updated_at = ? WHERE id = ?').run(saved.updatedAt, note.id) this.syncMagicNoteTodos(database, note.id, id, saved.content, saved.updatedAt) } + const assets = (inspected ?? this.noteStorage.inspect(note.id, id)).referencedAssets + if (!assets) referencedAssets = undefined + else if (referencedAssets) for (const asset of assets) referencedAssets.add(asset) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') @@ -1966,7 +1971,7 @@ export class AssistantDatabase { processed++ if (migrated) onProgress?.({ stage: 'converting', processed, total, bytesBefore }) } - this.reconcileMagicNoteFiles(note.id) + this.reconcileMagicNoteFiles(note.id, referencedAssets) } if (!noteIds) this.reconcileMagicNoteFiles() this.dirtyMagicNotes.clear() diff --git a/src/main/conversation-attachment-storage.test.ts b/src/main/conversation-attachment-storage.test.ts index fec2c79b..b32e4f78 100644 --- a/src/main/conversation-attachment-storage.test.ts +++ b/src/main/conversation-attachment-storage.test.ts @@ -2,13 +2,147 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { randomUUID } from 'node:crypto' -import { DatabaseSync } from 'node:sqlite' -import { describe, expect, it } from 'vitest' +import { DatabaseSync, StatementSync } from 'node:sqlite' +import { describe, expect, it, vi } from 'vitest' import { ConversationAttachmentStorage } from './conversation-attachment-storage' import { DocumentResultStorage } from './document-result-storage' import { defaultDocumentParsingSettings } from './document-parsing-settings-store' describe('conversation attachment files and references', () => { + it.each([false, true])('measures repeated references with an attachment: %s', async (populated) => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-')) + const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing')) + const storage = new ConversationAttachmentStorage(root, results) + try { + const conversation = randomUUID(), id = randomUUID() + const context = { id, name: 'source.txt', kind: 'text' as const, size: 6, preview: 'source' } + if (populated) storage.save(context, JSON.stringify(context), Buffer.from('source')) + const ids = populated ? [id] : [] + storage.reference(conversation, 'draft', conversation, ids) + const get = vi.spyOn(StatementSync.prototype, 'get') + const all = vi.spyOn(StatementSync.prototype, 'all') + const run = vi.spyOn(StatementSync.prototype, 'run') + const exec = vi.spyOn(DatabaseSync.prototype, 'exec') + let counts: { selects: number; writes: number; transactions: number } + try { + for (let index = 0; index < 100; index++) storage.reference(conversation, 'draft', conversation, ids) + counts = { + selects: get.mock.calls.length + all.mock.calls.length, + writes: run.mock.calls.length, + transactions: exec.mock.calls.filter(([sql]) => sql === 'BEGIN IMMEDIATE').length + } + } finally { get.mockRestore(); all.mockRestore(); run.mockRestore(); exec.mockRestore() } + const durations: number[] = [] + for (let sample = 0; sample < 3; sample++) { + const start = performance.now() + for (let index = 0; index < 1000; index++) storage.reference(conversation, 'draft', conversation, ids) + durations.push(Number((performance.now() - start).toFixed(3))) + } + console.info(JSON.stringify({ populated, countedCalls: 100, ...counts, callsPerSample: 1000, durationsMs: durations })) + expect(counts.writes).toBe(0) + expect(counts.transactions).toBe(0) + expect(storage.draft(conversation)).toEqual(populated ? [context] : []) + } finally { storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) } + }) + + it.each(['draft', 'message', 'queue', 'parsing'] as const)('preserves ordered replacement, ownership and cleanup for %s references', async (kind) => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-')) + const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing')) + const storage = new ConversationAttachmentStorage(root, results) + const database = new DatabaseSync(join(root, 'conversation-attachments.sqlite')) + try { + const conversation = randomUUID(), otherConversation = randomUUID(), owner = conversation + const ids = [randomUUID(), randomUUID(), randomUUID()] as const + for (const id of ids) { + const context = { id, name: 'source.txt', kind: 'text' as const, size: 6, preview: 'source' } + storage.save(context, JSON.stringify(context), Buffer.from(id)) + } + const refs = () => database.prepare('SELECT attachment_id, conversation_id FROM attachment_refs WHERE kind = ? AND owner_id = ? ORDER BY rowid').all(kind, owner) + const expected = (ordered: string[], conversationId = conversation) => ordered.map((id) => ({ attachment_id: id, conversation_id: conversationId })) + storage.reference(conversation, kind, owner, [ids[0], 'missing', ids[0], ids[1]]) + expect(refs()).toEqual(expected(ids.slice(0, 2))) + expect(storage.original(ids[0]).path).toContain(join('conversation-assets', conversation, 'documents', ids[0])) + await expect(readFile(join(root, 'temp', 'document-parsing', ids[0], 'original.txt'))).rejects.toThrow() + const get = vi.spyOn(StatementSync.prototype, 'get') + const run = vi.spyOn(StatementSync.prototype, 'run') + const exec = vi.spyOn(DatabaseSync.prototype, 'exec') + try { + storage.reference(conversation, kind, owner, [ids[0], 'missing', ids[0], ids[1], 'missing']) + expect(get.mock.calls.map(([id]) => id)).toEqual([ids[0], 'missing', ids[1]]) + expect(run).not.toHaveBeenCalled() + expect(exec).not.toHaveBeenCalled() + } finally { get.mockRestore(); run.mockRestore(); exec.mockRestore() } + + storage.reference(conversation, kind, owner, [ids[1], ids[0]]) + expect(refs()).toEqual(expected([ids[1], ids[0]])) + if (kind === 'draft') expect(storage.draft(owner).map(({ id }) => id)).toEqual([ids[1], ids[0]]) + storage.reference(otherConversation, kind, owner, [ids[1], ids[0]]) + expect(refs()).toEqual(expected([ids[1], ids[0]], otherConversation)) + storage.deleteConversation(conversation, ids) + expect(refs()).toEqual(expected([ids[1], ids[0]], otherConversation)) + + storage.reference(otherConversation, 'message', 'shared-owner', [ids[0]]) + storage.reference(otherConversation, kind, owner, [ids[2]]) + expect(refs()).toEqual(expected([ids[2]], otherConversation)) + storage.collect() + expect(storage.has(ids[0])).toBe(true) + expect(storage.has(ids[1])).toBe(false) + expect(storage.original(ids[2]).data).toEqual(Buffer.from(ids[2])) + storage.reference(otherConversation, kind, owner, ['missing']) + expect(refs()).toEqual([]) + const emptyRun = vi.spyOn(StatementSync.prototype, 'run') + const emptyExec = vi.spyOn(DatabaseSync.prototype, 'exec') + try { + storage.reference(otherConversation, kind, owner, []) + storage.reference(otherConversation, kind, owner, ['missing', 'missing']) + expect(emptyRun).not.toHaveBeenCalled() + expect(emptyExec).not.toHaveBeenCalled() + } finally { emptyRun.mockRestore(); emptyExec.mockRestore() } + storage.collect() + expect(storage.has(ids[2])).toBe(false) + storage.deleteConversation(otherConversation) + expect(storage.has(ids[0])).toBe(false) + } finally { database.close(); storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) } + }) + + it('promotes temporary files even when references already match and rolls back failed replacements', async () => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-')) + const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing')) + const storage = new ConversationAttachmentStorage(root, results) + const database = new DatabaseSync(join(root, 'conversation-attachments.sqlite')) + try { + const conversation = randomUUID(), first = randomUUID(), second = randomUUID() + for (const id of [first, second]) { + const context = { id, name: 'image.png', kind: 'image' as const, size: 6, preview: 'source' } + storage.save(context, JSON.stringify(context), Buffer.from(id)) + } + database.prepare('INSERT INTO attachment_refs VALUES (?, ?, ?, ?)').run(first, conversation, 'draft', conversation) + database.exec(`CREATE TRIGGER reject_promotion BEFORE UPDATE OF path ON attachments + BEGIN SELECT RAISE(ABORT, 'promotion failed'); END`) + expect(() => storage.reference(conversation, 'draft', conversation, [first])).toThrow('promotion failed') + expect(storage.original(first).path).toContain(join('temp', 'document-parsing', first)) + expect(storage.original(first).data).toEqual(Buffer.from(first)) + database.exec('DROP TRIGGER reject_promotion') + storage.reference(conversation, 'draft', conversation, [first, first]) + expect(storage.original(first).path).toContain(join('conversation-assets', conversation, 'images', first)) + expect(storage.original(first).data).toEqual(Buffer.from(first)) + await expect(readFile(join(root, 'temp', 'document-parsing', first, 'original.png'))).rejects.toThrow() + + database.exec(`CREATE TRIGGER reject_reference BEFORE INSERT ON attachment_refs + WHEN NEW.attachment_id = '${second}' BEGIN SELECT RAISE(ABORT, 'reference failed'); END`) + expect(() => storage.reference(conversation, 'draft', conversation, [first, second])).toThrow('reference failed') + expect(storage.draft(conversation).map(({ id }) => id)).toEqual([first]) + // Promotion precedes the reference transaction and remains durable after its rollback. + expect(storage.original(second).path).toContain(join('conversation-assets', conversation, 'images', second)) + expect(storage.original(second).data).toEqual(Buffer.from(second)) + database.exec('DROP TRIGGER reject_reference') + storage.reference(conversation, 'draft', conversation, [second, first]) + expect(storage.draft(conversation).map(({ id }) => id)).toEqual([second, first]) + expect(database.prepare('PRAGMA integrity_check').get()).toMatchObject({ integrity_check: 'ok' }) + expect(database.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + } finally { database.close(); storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) } + }) + it('retains interrupted originals without turning them into sendable draft attachments', async () => { const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-')) let results = new DocumentResultStorage(join(root, 'temp', 'document-parsing')) diff --git a/src/main/conversation-attachment-storage.ts b/src/main/conversation-attachment-storage.ts index df664343..39bb4078 100644 --- a/src/main/conversation-attachment-storage.ts +++ b/src/main/conversation-attachment-storage.ts @@ -117,7 +117,17 @@ export class ConversationAttachmentStorage { } reference(conversationId: string, kind: 'draft' | 'message' | 'queue' | 'parsing', ownerId: string, ids: string[]): void { - const assets = ids.filter((id) => this.has(id)).map((id) => this.row(id)) + const assets: AssetRow[] = [] + for (const id of new Set(ids)) { + const asset = this.database.prepare('SELECT * FROM attachments WHERE id = ?').get(id) as AssetRow | undefined + if (asset) assets.push(asset) + } + const refs = this.database.prepare(`SELECT attachment_id, conversation_id FROM attachment_refs + WHERE kind = ? AND owner_id = ? ORDER BY rowid`).all(kind, ownerId) as { attachment_id: string; conversation_id: string }[] + // Draft order is visible, and matching references can still point to temporary files. + if (refs.length === assets.length && assets.every((asset, index) => + refs[index]?.attachment_id === asset.id && refs[index]?.conversation_id === conversationId && !asset.path.startsWith('temp') + )) return for (const asset of assets) { if (!asset.path.startsWith(`temp`)) continue const context = JSON.parse(asset.metadata) as ContextAttachment diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 4f23ab16..7e8e4591 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -33,6 +33,8 @@ import type { } from '../shared/ssh-host-contracts' import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology' import { AssistantDatabase } from './assistant/assistant-database' +import { ConversationAttachmentStorage } from './conversation-attachment-storage' +import { DocumentResultStorage } from './document-result-storage' import { ImageGenerationService } from './agent/image-generation-service' import { ApplicationSettingsStore } from './application-settings-store' import * as desktopNotification from './desktop-notification' @@ -4521,6 +4523,95 @@ describe('registerIpcHandlers local conversation persistence', () => { vi.clearAllMocks() }) + it('pins and unpins without attachment reconciliation or import checks', async () => { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-pin-ipc-')) + const database = new AssistantDatabase(join(root, 'assistant.sqlite')) + const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing')) + const assets = new ConversationAttachmentStorage(root, results) + let dispose: ReturnType | undefined + try { + database.initialize(root) + const conversationId = '00000000-0000-4000-8000-000000000301' + const messageId = '00000000-0000-4000-8000-000000000302' + database.saveLocalConversations([{ + header: { id: conversationId, title: 'Pin regression', updatedAt: 1 }, + messages: [{ id: messageId, role: 'user', content: 'Keep this message', createdAt: 1, state: 'complete' }] + }]) + const assetId = assets.beginParsing(conversationId, 'pending.pdf', Buffer.from('original')) + assets.reference(conversationId, 'draft', conversationId, [assetId]) + assets.reference(conversationId, 'message', messageId, [assetId]) + const before = database.getConversation(conversationId) + const reconcile = vi.spyOn(assets, 'reconcile') + const collect = vi.spyOn(assets, 'collect') + const hasOwner = vi.spyOn(database, 'hasAttachmentOwner') + const contextManager = { + assets, + activeContextIds: vi.fn(() => []), + cancelUnavailableImport: vi.fn(), + clear: vi.fn() + } + const webContents = { + on: vi.fn(), removeListener: vi.fn(), + mainFrame: { url: 'file:///goodbuddy/index.html' }, + getURL: vi.fn(() => 'file:///goodbuddy/index.html'), + send: vi.fn() + } + const window = { + webContents, isDestroyed: vi.fn(() => false), + on: vi.fn(), removeListener: vi.fn() + } + dispose = registerIpcHandlers( + window as never, { capability: 'text' } as never, + 'CommandOrControl+Shift+Space', {} as never, {} as never, + contextManager as never, {} as never, database, + { clear: vi.fn() } as never, {} as never, vi.fn(async () => {}) + ) + const event = { sender: webContents, senderFrame: webContents.mainFrame } + const pin = electronMocks.handlers.get(ipcChannels.conversationsSetPinned)! + expect(pin).toBeTypeOf('function') + webContents.send.mockClear() + for (const pinned of [true, true, false]) { + expect(pin(event, { conversationId, pinned })).toBeUndefined() + expect(database.getConversation(conversationId)).toEqual({ ...before, pinned }) + } + expect(webContents.send.mock.calls).toEqual([ + [ipcChannels.conversationsChanged], + [ipcChannels.conversationsChanged], + [ipcChannels.conversationsChanged] + ]) + expect({ + reconciliations: reconcile.mock.calls.length, + ownerChecks: hasOwner.mock.calls.length, + collections: collect.mock.calls.length, + activeContextReads: contextManager.activeContextIds.mock.calls.length, + importChecks: contextManager.cancelUnavailableImport.mock.calls.length + }).toEqual({ reconciliations: 0, ownerChecks: 0, collections: 0, activeContextReads: 0, importChecks: 0 }) + expect(assets.draft(conversationId).map((asset) => asset.id)).toEqual([assetId]) + expect(assets.get(assetId).parsingState).toBe('parsing') + expect(assets.original(assetId).data).toEqual(Buffer.from('original')) + + webContents.send.mockClear() + expect(() => pin(event, { conversationId, pinned: 'yes' })).toThrow() + expect(() => pin({ ...event, sender: {} }, { conversationId, pinned: true })).toThrow() + expect(() => pin(event, { + conversationId: '00000000-0000-4000-8000-000000000399', pinned: true + })).toThrow() + expect(webContents.send).not.toHaveBeenCalled() + expect(database.getConversation(conversationId)).toEqual(before) + + expect(electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)).toBe(true) + expect(collect).toHaveBeenCalledOnce() + expect(contextManager.cancelUnavailableImport).toHaveBeenCalledOnce() + expect(assets.has(assetId)).toBe(false) + } finally { + await dispose?.() + assets.close() + await results.close() + database.close() + await rm(root, { recursive: true, force: true }) + } + }) + it('validates and forwards incremental saves, branches, and explicit deletions', async () => { const conversationId = '00000000-0000-4000-8000-000000000301' diff --git a/src/main/ipc.ts b/src/main/ipc.ts index ebc73582..3610d5d1 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -6397,8 +6397,6 @@ export function registerIpcHandlers( assistantDatabase.setConversationPinned( conversationSetPinnedSchema.parse(input) ) - contextManager.assets?.reconcile((conversationId, kind, ownerId) => assistantDatabase.hasAttachmentOwner(conversationId, kind, ownerId), false, contextManager.activeContextIds()) - if (contextManager.assets) contextManager.cancelUnavailableImport() publishConversationChange() } ) diff --git a/src/main/magic-notes/magic-note-storage.test.ts b/src/main/magic-notes/magic-note-storage.test.ts index 4157e2d9..ea76f39e 100644 --- a/src/main/magic-notes/magic-note-storage.test.ts +++ b/src/main/magic-notes/magic-note-storage.test.ts @@ -8,6 +8,12 @@ import type { MagicNoteCanvasContent, MagicNoteContent } from '../../shared/magi import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from '../assistant/assistant-database' import { getPendingAssistantStorageUpgrade, upgradeAssistantStorage } from '../assistant/assistant-storage-upgrade' +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const readFileSync = vi.fn(actual.readFileSync) + return { ...actual, readFileSync, default: { ...actual, readFileSync } } +}) + const actualFs = { renameSync: fs.renameSync, rmSync: fs.rmSync } const directories: string[] = [] @@ -16,6 +22,7 @@ afterEach(() => { vi.restoreAllMocks() for (const connection of connections.splice(0)) connection.close() for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }) + vi.mocked(readFileSync).mockClear() }) function setup(options: ConstructorParameters[1] = {}) { @@ -53,6 +60,36 @@ function canvas(size = 40): MagicNoteCanvasContent { } describe('Magic note SQLite and filesystem storage', () => { + it('measures current entry startup body reads on real SQLite and filesystem fixtures', () => { + const { database, directory, sql } = setup() + const paths: string[] = [] + for (let index = 0; index < 120; index++) { + const content = index % 2 ? canvas() : structuredClone(rich) + if (content.version === 1) content.ops.unshift({ insert: 'x'.repeat(32 * 1024) }) + else content.pages[0]!.objects.push({ type: 'IText', text: 'x'.repeat(32 * 1024) }) + const note = database.createMagicNote({ title: `Synthetic ${index}`, content }) + paths.push(join(directory, 'notes', note.id, 'entries', `${note.entries[0]!.id}.json`)) + } + database.close() + const bodyPaths = new Set(paths) + const reads = vi.mocked(readFileSync) + const durations: number[] = [] + const counts: number[] = [] + for (let run = 0; run < 5; run++) { + reads.mockClear() + const start = performance.now() + database.initialize(directory) + durations.push(Number((performance.now() - start).toFixed(2))) + counts.push(reads.mock.calls.filter(([path]) => bodyPaths.has(String(path))).length) + for (const path of paths) expect(reads.mock.calls.filter(([readPath]) => String(readPath) === path)).toHaveLength(1) + expect(reads.mock.calls.some(([path]) => String(path).includes(join(directory, 'notes')) && String(path).includes('assets'))).toBe(false) + database.close() + } + console.info('Synthetic note startup', { entries: paths.length, bodyBytes: paths.reduce((sum, path) => sum + statSync(path).size, 0), counts, durationsMs: durations }) + expect(counts).toEqual(Array(5).fill(paths.length)) + expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' }) + }) + it('upgrades schema 47 with nullable sources without rewriting bodies and reopens idempotently', () => { const { database, sql, path, directory } = setup() const created = database.createMagicNote({ title: 'Existing note', content: rich }) @@ -77,6 +114,43 @@ describe('Magic note SQLite and filesystem storage', () => { expect(database.getMagicNote(created.id)).toEqual(before) }) + it.each(['missing', 'invalid-json', 'invalid-media'] as const)('retains all assets when a later retained body has %s corruption', (failure) => { + const { database, directory } = setup() + const note = database.createMagicNote({ title: 'Retained bodies', content: rich }) + const entries = database.createMagicNoteEntry({ noteId: note.id, content: canvas(), plainText: '' }).entries + const root = join(directory, 'notes', note.id) + const bodyPath = join(root, 'entries', `${entries[1]!.id}.json`) + const original = readFileSync(bodyPath, 'utf8') + const manifest = readFileSync(join(root, 'note.json'), 'utf8') + writeFileSync(join(root, 'assets', 'unused.bin'), 'keep until inspection succeeds') + const assets = readdirSync(join(root, 'assets')).map((name) => [name, readFileSync(join(root, 'assets', name))] as const) + database.close() + if (failure === 'missing') rmSync(bodyPath) + else if (failure === 'invalid-json') writeFileSync(bodyPath, '{') + else { + const body = JSON.parse(original) + body.content.pages = null + writeFileSync(bodyPath, JSON.stringify(body)) + } + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + if (failure === 'invalid-media') { + database.initialize(directory) + expect(warning).toHaveBeenCalledWith('Magic note file cleanup deferred', expect.objectContaining({ noteId: note.id })) + } else expect(() => database.initialize(directory)).toThrow() + for (const [name, bytes] of assets) expect(readFileSync(join(root, 'assets', name))).toEqual(bytes) + expect(readFileSync(join(root, 'note.json'), 'utf8')).toBe(manifest) + database.close() + writeFileSync(bodyPath, original) + vi.mocked(readFileSync).mockClear() + database.initialize(directory) + for (const entry of entries) { + const path = join(root, 'entries', `${entry.id}.json`) + expect(vi.mocked(readFileSync).mock.calls.filter(([readPath]) => String(readPath) === path)).toHaveLength(1) + } + expect(database.getMagicNote(note.id).entries).toEqual(entries) + expect(existsSync(join(root, 'assets', 'unused.bin'))).toBe(false) + }) + it('preserves textual data URLs and arbitrary $asset objects through save, migration and reopen', () => { const { database, sql, path, directory } = setup() const text = 'data:text/plain;base64,aGVsbG8' @@ -339,14 +413,20 @@ describe('Magic note SQLite and filesystem storage', () => { const { database, sql, directory } = setup() const note = database.createMagicNote({ title: 'Repair', content: rich }) const entry = note.entries[0]! + database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: entry.revision, + comments: [{ id: randomUUID(), kind: 'summary', content: 'Stale analysis' }] }) + const analyzed = database.getMagicNoteEntry(entry.id) sql.exec(`CREATE TRIGGER fail_note_update BEFORE UPDATE OF content_json ON magic_note_entries BEGIN SELECT RAISE(ABORT, 'index unavailable'); END`) - expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: entry.revision, content: canvas(), plainText: '' })).toThrow('index unavailable') + expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: analyzed.revision, content: canvas(), plainText: '' })).toThrow('index unavailable') expect(() => database.searchMagicNotes('PDF text', 10)).toThrow('index unavailable') sql.exec('DROP TRIGGER fail_note_update') if (reopen) { database.close(); database.initialize(directory) } expect(database.searchMagicNotes('PDF text', 10)).toHaveLength(1) - expect(database.getMagicNoteEntry(entry.id)).toMatchObject({ content: canvas(), revision: 1 }) + expect(database.getMagicNoteEntry(entry.id)).toMatchObject({ content: canvas(), revision: analyzed.revision + 1, comments: [], analyzedAt: undefined }) + const assetNames = readdirSync(join(directory, 'notes', note.id, 'assets')) + expect(assetNames).toHaveLength(2) + expect(assetNames.some((name) => name.endsWith('.pdf'))).toBe(true) expect(database.listMagicTodos()).toEqual([expect.objectContaining({ title: 'Canvas task' })]) expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: 0, content: rich, plainText: '' })).toThrow('已被更新') }) diff --git a/src/main/magic-notes/magic-note-storage.ts b/src/main/magic-notes/magic-note-storage.ts index 03d662b9..3bdeafba 100644 --- a/src/main/magic-notes/magic-note-storage.ts +++ b/src/main/magic-notes/magic-note-storage.ts @@ -75,8 +75,19 @@ export class MagicNoteStorage { return join(this.rootPath, noteId, 'entries', `${entryId}.json`) } - revision(noteId: string, entryId: string): number { - return (JSON.parse(readFileSync(this.entryPath(noteId, entryId), 'utf8')) as MagicNoteStoredEntry).revision + inspect(noteId: string, entryId: string): { revision: number; referencedAssets?: Set } { + const entry = JSON.parse(readFileSync(this.entryPath(noteId, entryId), 'utf8')) as MagicNoteStoredEntry + const referencedAssets = new Set() + try { + mapMediaFields(entry.content, (value) => { + if (value && typeof value === 'object' && '$asset' in value) referencedAssets.add(String(value.$asset)) + return value + }) + } catch { + // Let reconciliation retry failed media inspection through its deferred-cleanup path. + return { revision: entry.revision } + } + return { revision: entry.revision, referencedAssets } } write(entry: MagicNoteStoredEntry): void { @@ -108,18 +119,21 @@ export class MagicNoteStorage { return entry } - reconcile(noteId: string, entryIds: string[]): void { + reconcile(noteId: string, entryIds: string[], inspectedAssets?: ReadonlySet): void { const notePath = join(this.rootPath, noteId) const entriesPath = join(notePath, 'entries') const retained = new Set(entryIds.map((id) => `${id}.json`)) const referenced = new Set() - // Read every retained body before deleting anything, including shared assets. - for (const name of retained) { - const entry = JSON.parse(readFileSync(join(entriesPath, name), 'utf8')) as MagicNoteStoredEntry - mapMediaFields(entry.content, (value) => { - if (value && typeof value === 'object' && '$asset' in value) referenced.add(String(value.$asset)) - return value - }) + // Repair can supply references only after inspecting every retained body. + // Otherwise read them all before deleting anything, including shared assets. + if (!inspectedAssets) { + for (const name of retained) { + const entry = JSON.parse(readFileSync(join(entriesPath, name), 'utf8')) as MagicNoteStoredEntry + mapMediaFields(entry.content, (value) => { + if (value && typeof value === 'object' && '$asset' in value) referenced.add(String(value.$asset)) + return value + }) + } } this.atomicWrite(join(notePath, 'note.json'), JSON.stringify({ id: noteId, entries: entryIds })) if (existsSync(entriesPath)) { @@ -133,7 +147,7 @@ export class MagicNoteStorage { const assetsPath = join(notePath, 'assets') if (existsSync(assetsPath)) { for (const name of readdirSync(assetsPath)) { - if (!referenced.has(`assets/${name}`)) fs.rmSync(join(assetsPath, name), { force: true }) + if (!(inspectedAssets ?? referenced).has(`assets/${name}`)) fs.rmSync(join(assetsPath, name), { force: true }) } } } diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index 1713e4f8..9a4d2c18 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -15,6 +15,18 @@ import { ActivityPanel } from './ActivityPanel' import { upsertActivityRecord, type ActivityRecord } from './activity-store' import i18n from './i18n' +const activityRenderProbe = vi.hoisted(() => vi.fn()) +vi.mock('./WorkspacePrimitives', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + PageHeader: (props: Parameters[0]) => { + if (props.headingId === 'activity-panel-title') activityRenderProbe() + return + } + } +}) + function makeRecord( index: number, status: ActivityRecord['status'] = 'completed' @@ -77,6 +89,37 @@ function makeTokenUsage(): TokenUsageSummary { } describe('ActivityPanel', () => { + it('skips unchanged hidden renders while retaining selection and accepting real updates', () => { + const record = makeRecord(1, 'running') + const props = { records: [record], tokenUsage: makeTokenUsage(), onClear: vi.fn(), onOpenConversation: vi.fn() } + const { rerender } = render() + fireEvent.click(screen.getByRole('tab', { name: '活动时间线' })) + const node = screen.getByRole('button', { name: /活动 1,工具/u }) + fireEvent.click(node) + const detail = screen.getByLabelText('选中的活动节点详情') + activityRenderProbe.mockClear() + for (let index = 0; index < 3; index++) { + rerender() + } + expect(activityRenderProbe).toHaveBeenCalledTimes(0) + const updated = { ...props, records: [{ ...record, detail: 'Real update', status: 'failed' as const }], onOpenConversation: vi.fn(), onClear: vi.fn() } + rerender() + expect(activityRenderProbe).toHaveBeenCalledTimes(1) + rerender() + expect(activityRenderProbe).toHaveBeenCalledTimes(1) + expect(screen.getByLabelText('选中的活动节点详情')).toBe(detail) + expect(node).toHaveAttribute('aria-pressed', 'true') + expect(node).toHaveClass('activity-track__node--failed') + expect(within(detail).getByText('Real update')).toBeInTheDocument() + fireEvent.click(within(detail).getByRole('button', { name: '打开所属对话' })) + expect(updated.onOpenConversation).toHaveBeenCalledWith(record.conversationId) + expect(props.onOpenConversation).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: '清空记录' })) + fireEvent.click(screen.getByRole('button', { name: '确认清空 1 条活动记录' })) + expect(updated.onClear).toHaveBeenCalledOnce() + expect(props.onClear).not.toHaveBeenCalled() + }) + it('opens timeline guidance separately from tab navigation', () => { render() fireEvent.click(screen.getByRole('tab', { name: '活动时间线' })) diff --git a/src/renderer/src/ActivityPanel.tsx b/src/renderer/src/ActivityPanel.tsx index 4f0e9f0b..eaa5bdbf 100644 --- a/src/renderer/src/ActivityPanel.tsx +++ b/src/renderer/src/ActivityPanel.tsx @@ -1,5 +1,5 @@ import { Activity, ChevronRight, Trash2 } from 'lucide-react' -import { useMemo, useState } from 'react' +import { memo, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { InlineHelp } from './InlineHelp' import type { @@ -239,7 +239,7 @@ function groupActivityRecordsByProject( }) } -export function ActivityPanel({ +export const ActivityPanel = memo(function ActivityPanel({ projects = [], records, tokenUsage, @@ -1165,4 +1165,4 @@ export function ActivityPanel({ )} ) -} +}) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 38b7d619..33aad002 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -49,6 +49,18 @@ const speechRecognitionMocks = vi.hoisted(() => ({ const messageRenderProbe = vi.hoisted(() => vi.fn()); const assistantTasksProbe = vi.hoisted(() => vi.fn()); +const activityRenderProbe = vi.hoisted(() => vi.fn()); + +vi.mock("./WorkspacePrimitives", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + PageHeader: (props: Parameters[0]) => { + if (props.headingId === "activity-panel-title") activityRenderProbe(); + return ; + }, + }; +}); vi.mock("./use-unviewed-completions", async (importOriginal) => { const original = await importOriginal(); @@ -2982,6 +2994,46 @@ describe("App", () => { expect(route).not.toHaveAttribute("hidden"); }); + it("isolates hidden Activity renders from composer and streaming updates", async () => { + render(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Activity render isolation" } }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const requestId = run.mock.calls[0]![0].requestId; + fireEvent.click(screen.getByRole("button", { name: "运行记录" })); + const heading = await screen.findByRole("heading", { name: "运行记录" }); + fireEvent.click(screen.getByRole("tab", { name: "活动时间线" })); + const node = document.querySelector(".activity-track__node")!; + fireEvent.click(node); + const detail = screen.getByLabelText("选中的活动节点详情"); + fireEvent.click(screen.getByRole("button", { name: "对话" })); + const route = heading.closest('[data-route="activity"]')!; + expect(route).toHaveAttribute("hidden"); + activityRenderProbe.mockClear(); + for (const value of ["a", "ab", "abc"]) { + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value } }); + } + expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0); + activityRenderProbe.mockClear(); + for (const delta of ["Stream", " response", " updated"]) { + act(() => agentListener?.({ requestId, type: "text", delta })); + await screen.findByText(new RegExp(delta.trim(), "u")); + } + expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0); + act(() => agentListener?.({ requestId, type: "done" })); + await waitFor(() => expect(activityRenderProbe.mock.calls.length).toBeGreaterThan(0)); + expect(route).toHaveAttribute("hidden"); + fireEvent.click(screen.getByRole("button", { name: "运行记录" })); + expect(screen.getByRole("heading", { name: "运行记录" })).toBe(heading); + expect(screen.getByRole("tab", { name: "活动时间线" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByLabelText("选中的活动节点详情")).toBe(detail); + expect(node).toHaveAttribute("aria-pressed", "true"); + expect(document.querySelectorAll(".activity-track__node")).toHaveLength(2); + fireEvent.click(within(detail).getByRole("button", { name: "打开所属对话" })); + expect(route).toHaveAttribute("hidden"); + expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("abc"); + }); + it("keeps Settings outside the workspace KeepAlive cache", async () => { let now = 2_000_000; vi.spyOn(Date, "now").mockImplementation(() => ++now); @@ -12238,6 +12290,28 @@ describe("App", () => { expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理"); }); + it("does not save a duplicate question-resolved event after the first resolution is persisted", async () => { + render(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Persist question resolution" } }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const requestId = run.mock.calls[0]![0].requestId; + act(() => agentListener?.({ requestId, type: "question", questionId: "persisted-question", + questions: [{ header: "Input", question: "Resolve me?", options: [], multiple: false, custom: true }] })); + expect(screen.getByText("Resolve me?")).toBeInTheDocument(); + await act(async () => { await beforeQuitListener!(); }); + vi.mocked(api.conversations.saveLocal).mockClear(); + act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "persisted-question" })); + expect(screen.queryByText("Resolve me?")).not.toBeInTheDocument(); + await act(async () => { await beforeQuitListener!(); }); + expect(api.conversations.saveLocal).toHaveBeenCalledTimes(1); + expect(vi.mocked(api.conversations.saveLocal).mock.calls[0]![0].flatMap(item => item.messages)).toHaveLength(1); + vi.mocked(api.conversations.saveLocal).mockClear(); + act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "persisted-question" })); + await act(async () => { await beforeQuitListener!(); }); + expect(api.conversations.saveLocal).toHaveBeenCalledTimes(0); + }); + it("renders, saves and reloads OpenCode answers across question rounds", async () => { const view = render(); fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b62dee59..de2c108a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3876,19 +3876,21 @@ function App(): React.JSX.Element { messageId: string, update: (message: Message) => Message, ): void => { - setConversations((current) => - current.map((conversation) => - conversation.id === conversationId - ? { - ...conversation, - updatedAt: Date.now(), - messages: conversation.messages.map((message) => - message.id === messageId ? update(message) : message, - ), - } - : conversation, - ), - ); + setConversations((current) => { + const conversationIndex = current.findIndex(item => item.id === conversationId); + const conversation = current[conversationIndex]; + if (!conversation) return current; + const messageIndex = conversation.messages.findIndex(message => message.id === messageId); + const message = conversation.messages[messageIndex]; + if (!message) return current; + const updated = update(message); + if (updated === message) return current; + const messages = [...conversation.messages]; + messages[messageIndex] = updated; + const next = [...current]; + next[conversationIndex] = { ...conversation, updatedAt: Date.now(), messages }; + return next; + }); }, [], ); @@ -8159,7 +8161,13 @@ function App(): React.JSX.Element { return 'opened'; }; - const openActivityConversation = (conversationId: string): void => { + const clearActivity = useCallback((): void => { + legacyActivityHistoryMayBeIncompleteRef.current = false; + setLegacyActivityHistoryMayBeIncomplete(false); + setActivityRecords([]); + }, []); + + const openActivityConversation = useCallback((conversationId: string): void => { const open = async (): Promise => { let conversation = conversationsRef.current.find( (candidate) => candidate.id === conversationId, @@ -8195,7 +8203,7 @@ function App(): React.JSX.Element { if (narrowWindow) closeNarrowSidebar(); }; requestWorkspaceLeave("chat", () => { void open(); }); - }; + }, [closeNarrowSidebar, commitView, narrowWindow, requestWorkspaceLeave, setActiveId, t]); const openAssistantTask = (task: AssistantTask): void => { if (!task.conversationId) { @@ -11361,11 +11369,7 @@ function App(): React.JSX.Element { } > { - legacyActivityHistoryMayBeIncompleteRef.current = false; - setLegacyActivityHistoryMayBeIncomplete(false); - setActivityRecords([]); - }} + onClear={clearActivity} onOpenConversation={openActivityConversation} projects={projects} records={activityRecords} diff --git a/src/renderer/src/ChatTimeline.test.tsx b/src/renderer/src/ChatTimeline.test.tsx index c73f25cc..1d4a6af2 100644 --- a/src/renderer/src/ChatTimeline.test.tsx +++ b/src/renderer/src/ChatTimeline.test.tsx @@ -861,6 +861,97 @@ describe('ChatTimeline', () => { } }) + it('reuses subagent derivations during streaming and invalidates only changed dependencies', () => { + const props = { + ...callbacks, artifactById: new Map(), conversationId: 'subagent-stream', + hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const, + messageStartIndex: 0, totalMessageCount: 1 + } + const subagents: NonNullable = ['a', 'b', 'c', 'd'].map(id => ({ + childTaskId: id, expertId: id, expertName: `Expert ${id}`, + routingMode: 'native', state: 'running' + })) + const makeBlocks = (content: string): NonNullable => [ + { id: 'b', type: 'subagent', childTaskId: 'b' }, + { id: 'missing', type: 'subagent', childTaskId: 'missing' }, + { id: 'text', type: 'text', content }, + { id: 'a', type: 'subagent', childTaskId: 'a' } + ] + let message: Message = { + id: 'stream', role: 'assistant', content: 'Initial', createdAt: 0, + state: 'streaming', subagents, blocks: makeBlocks('Initial') + } + const map = vi.spyOn(subagents, 'map') + const filter = vi.spyOn(subagents, 'filter') + const blockFilters = [vi.spyOn(message.blocks!, 'filter')] + // Question IDs also filter blocks; count only passes returning subagent markers. + const counts = () => ({ + map: map.mock.calls.length, + ordered: blockFilters.reduce((count, probe) => count + probe.mock.results.filter( + result => result.type === 'return' && result.value[0]?.type === 'subagent' + ).length, 0), + unordered: filter.mock.calls.length + }) + try { + const view = render() + const cardNames = () => Array.from(view.container.querySelectorAll( + '.subagent-status-card__heading strong' + ), element => element.textContent) + expect(cardNames()).toEqual(['Expert b', 'Expert a', 'Expert c', 'Expert d']) + expect(counts()).toEqual({ map: 1, ordered: 1, unordered: 1 }) + + for (let index = 0; index < 3; index += 1) { + message = { ...message, status: `Streaming ${index}` } + view.rerender() + expect(screen.getByRole('status', { name: `Streaming ${index}` })).toBeVisible() + } + expect(counts()).toEqual({ map: 1, ordered: 1, unordered: 1 }) + + for (let index = 0; index < 3; index += 1) { + const content = `Streamed block ${index}` + message = { ...message, content, blocks: makeBlocks(content) } + blockFilters.push(vi.spyOn(message.blocks!, 'filter')) + view.rerender() + expect(screen.getByText(content)).toBeVisible() + expect(markdownRenderProbe).toHaveBeenLastCalledWith(content) + expect(cardNames()).toEqual(['Expert b', 'Expert a', 'Expert c', 'Expert d']) + } + expect(counts()).toEqual({ map: 1, ordered: 4, unordered: 4 }) + + // Both anchored lookup and unanchored fallback must receive new child data. + message = { ...message, subagents: subagents.map(child => ({ + ...child, expertName: `Updated ${child.childTaskId}`, state: 'completed', + output: `Result ${child.childTaskId}` + })) } + view.rerender() + expect(cardNames()).toEqual(['Updated b', 'Updated a', 'Updated c', 'Updated d']) + for (const card of view.container.querySelectorAll('.subagent-status-card')) { + expect(card).toHaveClass('subagent-status-card--completed') + fireEvent.click(card.querySelector('summary')!) + fireEvent(card, new Event('toggle')) + const id = card.querySelector('strong')!.textContent!.slice(-1) + expect(within(card).getByText(`Result ${id}`)).toBeVisible() + } + + // Move a fallback child into the ordered blocks without duplicating it. + message = { ...message, blocks: [{ id: 'd', type: 'subagent', childTaskId: 'd' }] } + view.rerender() + expect(cardNames()).toEqual(['Updated d', 'Updated a', 'Updated b', 'Updated c']) + for (const blocks of [undefined, []]) { + message = { ...message, blocks } + view.rerender() + expect(cardNames()).toEqual(['Updated a', 'Updated b', 'Updated c', 'Updated d']) + } + message = { ...message, subagents: undefined } + view.rerender() + expect(cardNames()).toEqual([]) + } finally { + map.mockRestore() + filter.mockRestore() + blockFilters.forEach(probe => probe.mockRestore()) + } + }) + it('shows every parallel expert output in its own expandable card', () => { const messages: Message[] = [ { diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 35616be2..fe0bbe3c 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -608,20 +608,20 @@ function ChatMessageRowView({ const compressionMarkers = message.contextCompressions ?? (message.contextCompression ? [message.contextCompression] : []) - const subagentsById = new Map( + const subagentsById = useMemo(() => new Map( (message.subagents ?? []).map((subagent) => [ subagent.childTaskId, subagent ]) - ) - const orderedSubagentIds = new Set( + ), [message.subagents]) + const orderedSubagentIds = useMemo(() => new Set( message.blocks ?.filter((block) => block.type === 'subagent') .map((block) => block.childTaskId) - ) - const unorderedSubagents = message.subagents?.filter( + ), [message.blocks]) + const unorderedSubagents = useMemo(() => message.subagents?.filter( (subagent) => !orderedSubagentIds.has(subagent.childTaskId) - ) + ), [message.subagents, orderedSubagentIds]) const compressionLabel = ( compression: ConversationContextCompressionMarker ): string => diff --git a/src/renderer/src/MarkdownRenderer.test.tsx b/src/renderer/src/MarkdownRenderer.test.tsx index 7deb8759..d6bb0460 100644 --- a/src/renderer/src/MarkdownRenderer.test.tsx +++ b/src/renderer/src/MarkdownRenderer.test.tsx @@ -17,7 +17,8 @@ import { import { changeUiLocale } from './i18n' import { InlineMarkdown, - MarkdownRenderer + MarkdownRenderer, + normalizeLatexDelimiters } from './MarkdownRenderer' const mermaidMock = vi.hoisted(() => ({ @@ -31,6 +32,26 @@ vi.mock('mermaid', () => ({ })) describe('MarkdownRenderer', () => { + it.each([ + ['# Title\n\n**bold** `code`\n', '# Title\n\n**bold** `code`\n'], + ['plain \\', 'plain \\'], + ['partial \\(x', 'partial $x'], + ['partial x\\)', 'partial x$'], + ['\\(x\\)', '$$\nx\n$$'], + ['\\[x\\]', '$$\nx\n$$'], + ['$x$', '$$\nx\n$$'], + ['`\\(code\\)` then \\(x\\)', '`\\(code\\)` then $x$'], + ['~~~tex\n$x$\n\\(x\\)\n~~~', '~~~tex\n$x$\n\\(x\\)\n~~~'] + ])('preserves normalization across fast paths: %j', (content, expected) => { + expect(normalizeLatexDelimiters(content)).toBe(expected) + }) + + it('preserves CRLF conversion inside code and standalone HTML without math', () => { + expect(normalizeLatexDelimiters('```txt\r\na\r\n```')).toBe('```txt\na\n```') + expect(normalizeLatexDelimiters('
\r\na\r\n
')).toBe('
\na\n
') + expect(normalizeLatexDelimiters('a\rb\r')).toBe('a\rb\r') + }) + beforeEach(() => { Object.defineProperty(window, 'goodbuddy', { configurable: true, value: { app: { onPreviewEscape: vi.fn((listener: () => void) => { diff --git a/src/renderer/src/MarkdownRenderer.tsx b/src/renderer/src/MarkdownRenderer.tsx index 66240c27..61baf6bf 100644 --- a/src/renderer/src/MarkdownRenderer.tsx +++ b/src/renderer/src/MarkdownRenderer.tsx @@ -134,6 +134,9 @@ const wholeMarkdownFence = /^```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```$/iu function replaceLatexDelimiters(line: string): string { + if (!line.includes('\\(') && !line.includes('\\)')) { + return line + } let output = '' let index = 0 let codeDelimiterLength = 0 @@ -171,7 +174,11 @@ function replaceLatexDelimiters(line: string): string { return output } -function normalizeLatexDelimiters(content: string): string { +export function normalizeLatexDelimiters(content: string): string { + // Without math markers or CRLF, every line would be returned unchanged. + if (!/[$\\\r]/u.test(content)) { + return content + } let fence: | { character: '`' | '~' diff --git a/tests/streaming-markdown.electron.test.ts b/tests/streaming-markdown.electron.test.ts new file mode 100644 index 00000000..450ed905 --- /dev/null +++ b/tests/streaming-markdown.electron.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment node +import { spawn } from 'node:child_process' +import { copyFile, mkdtemp, readFile, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import react from '@vitejs/plugin-react' +import { createServer } from 'vite' +import { expect, it } from 'vitest' + +it('measures real streaming Markdown and preserves every prefix against the original normalizer', async () => { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-streaming-markdown-')) + const server = await createServer({ + configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'), + plugins: [{ + name: 'streaming-markdown-instrumentation', enforce: 'pre', + async load(id) { + if (!id.endsWith('MarkdownRenderer.tsx?streaming-original')) return + const source = await readFile(id.split('?')[0]!, 'utf8') + return source.replace(/(?:export )?function replaceLatexDelimiters[\s\S]*?(?=function unwrapMarkdownFence)/u, + 'import { originalNormalize as normalizeLatexDelimiters } from "/tests/support/streaming-markdown-corpus.ts"\n\n') + }, + transform(source, id) { + if (id.split('?')[0]!.endsWith('/MarkdownRenderer.tsx')) { + return source + '\nexport { normalizeLatexDelimiters as benchmarkNormalize }\n' + } + }, + configureServer(server) { + server.middlewares.use('/streaming-markdown.html', async (_request, response) => { + response.setHeader('Content-Type', 'text/html; charset=utf-8') + response.end(await server.transformIndexHtml('/streaming-markdown.html', + '
')) + }) + } + }, react()], + server: { host: '127.0.0.1', port: 0, watch: null } + }) + try { + await server.listen() + const env: NodeJS.ProcessEnv = { ...process.env, GOODBUDDY_MARKDOWN_URL: server.resolvedUrls!.local[0] + 'streaming-markdown.html', + GOODBUDDY_MARKDOWN_DIRECTORY: directory } + delete env.ELECTRON_RUN_AS_NODE + const driver = join(directory, 'driver.mjs') + await copyFile(resolve('tests/support/streaming-markdown-driver.mjs'), driver) + const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', data => { output += data }) + child.stderr.on('data', data => { output += data }) + const timeout = setTimeout(() => child.kill(), 150_000) + try { + const code = await new Promise((resolve, reject) => { + child.once('exit', resolve) + child.once('error', reject) + }) + expect(code, output).toBe(0) + process.stdout.write(output.split(/\r?\n/u).filter(line => line.startsWith('Markdown ')).join('\n') + '\n') + } finally { + clearTimeout(timeout) + if (child.exitCode === null) child.kill() + } + } finally { + await server.close() + await rm(directory, { recursive: true, force: true }) + } +}, 180_000) diff --git a/tests/support/streaming-markdown-corpus.ts b/tests/support/streaming-markdown-corpus.ts new file mode 100644 index 00000000..4a64d39e --- /dev/null +++ b/tests/support/streaming-markdown-corpus.ts @@ -0,0 +1,101 @@ +// Frozen pre-optimization implementation: an independent streaming equivalence oracle. +function originalReplace(line: string): string { + let output = '' + let index = 0 + let codeDelimiterLength = 0 + while (index < line.length) { + if (line[index] === '`') { + let runLength = 1 + while (line[index + runLength] === '`') runLength += 1 + if (codeDelimiterLength === 0 || codeDelimiterLength === runLength) { + codeDelimiterLength = codeDelimiterLength === 0 ? runLength : 0 + } + output += line.slice(index, index + runLength) + index += runLength + continue + } + if (codeDelimiterLength === 0 && line[index] === '\\' && + line[index - 1] !== '\\' && (line[index + 1] === '(' || line[index + 1] === ')')) { + output += '$' + index += 2 + continue + } + output += line[index] + index += 1 + } + return output +} + +export function originalNormalize(content: string): string { + let fence: { character: '`' | '~'; length: number } | undefined + return content.split(/\r?\n/u).map(line => { + if (fence) { + const closingFence = new RegExp(`^ {0,3}${fence.character}{${fence.length},}\\s*$`, 'u') + if (closingFence.test(line)) fence = undefined + return line + } + const openingFence = /^ {0,3}(`{3,}|~{3,})/u.exec(line) + if (openingFence) { + const marker = openingFence[1]! + fence = { character: marker[0] as '`' | '~', length: marker.length } + return line + } + const standaloneDollar = /^ {0,3}\$(?!\$)(.+?)(?\r\n

\\(x\\)

\r\n', + '```html\r\n
bold
\r\n```', + '
**raw**\r\ntext
', + 'partial \\(x + \\) then `code \\( and `` and \\[\n\\', + '\n\nplain\rtext\r\n\n $indented$\n \\(indented\\)\n' +] + +const prose = '# Streaming answer\n\n' + ( + 'A growing paragraph with **emphasis**, `inline code`, and [a link](https://example.com).\n\n' + + '- First item\n- Second item\n\n> A quoted explanation.\n\n' +).repeat(48) +export const performanceCorpus = [ + { name: 'prose', content: prose }, + { name: 'mixed', content: (prose.slice(0, 1200) + '\n\\(x^2 + y^2\\)\n\n' + + '```ts\nconst value = "\\(literal\\)"\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n').repeat(5) }, + { name: 'crlf', content: prose.replaceAll('\n', '\r\n') } +] + +const longSection = [ + '## Deployment review\n', + 'The service processes incoming records in order and stores the last completed sequence. ' + + 'A restart resumes from that sequence so operators can inspect partial progress without repeating successful work. ' + + 'Keep **validation results** next to the source record and link the [runbook](https://example.com/runbook) in the report.\n', + 'Before deployment, compare the proposed configuration with the active settings. ' + + 'Check the database connection, confirm the worker count, and run a small representative batch. ' + + 'Record the elapsed time and any rejected records before increasing concurrency.\n', + '- Validate required fields before writing.\n- Commit each completed batch.\n- Review rejected records separately.\n', + '```ts\nasync function processBatch(records: Record[]) {\n' + + ' for (const record of records) {\n const label = record.name ?? "unnamed"\n' + + ' await store.save({ ...record, label })\n }\n}\n```\n', + '| Check | Expected result | Follow-up |\n| --- | --- | --- |\n' + + '| Validation | Required fields present | Review rejected rows |\n' + + '| Persistence | Completed batches stored | Resume from checkpoint |\n' + + '| Throughput | Stable processing time | Inspect slow operations |\n', + '> Keep the original input available until the final reconciliation has completed.\n\n' +].join('\n') +const longBody = longSection.repeat(Math.floor(102_400 / longSection.length)) +// ASCII makes character and UTF-8 byte lengths identical: exactly 100 KiB. +export const longAnswerCorpus = longBody + 'Final review notes. '.repeat(100) + .slice(0, 102_400 - longBody.length) diff --git a/tests/support/streaming-markdown-driver.mjs b/tests/support/streaming-markdown-driver.mjs new file mode 100644 index 00000000..22a91bcb --- /dev/null +++ b/tests/support/streaming-markdown-driver.mjs @@ -0,0 +1,34 @@ +import { app, BrowserWindow } from 'electron' +import assert from 'node:assert/strict' +import { join } from 'node:path' +import process from 'node:process' +import console from 'node:console' +import { setTimeout } from 'node:timers/promises' + +app.setPath('userData', join(process.env.GOODBUDDY_MARKDOWN_DIRECTORY, 'profile')) +app.commandLine.appendSwitch('force-device-scale-factor', '1') +app.whenReady().then(async () => { + const win = new BrowserWindow({ show: true, width: 1100, height: 800, + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }) + const errors = [] + win.webContents.on('console-message', event => { + if (event.level === 'error') errors.push(event.message) + }) + await win.loadURL(process.env.GOODBUDDY_MARKDOWN_URL) + for (let i = 0; i < 600; i++) { + if (await win.webContents.executeJavaScript('typeof window.streamingMarkdown === "function"')) break + await setTimeout(25) + } + const result = await win.webContents.executeJavaScript('window.streamingMarkdown()') + assert(result.prefixesChecked > 500) + assert.equal(result.domChecks, result.prefixesChecked * 2) + assert.equal(result.longAnswer.bytes, 102_400) + assert.equal(result.longAnswer.prefixLengths.length, 6) + assert.equal(result.longAnswer.samplesPerVariant, 18) + assert.equal(result.longAnswer.domChecks, 24) + assert.equal(result.longAnswer.finalDomEquivalent, true) + assert.deepEqual(errors, []) + console.log('Markdown ' + JSON.stringify({ electron: process.versions.electron, + chromium: process.versions.chrome, ...result })) + app.exit(0) +}).catch(error => { console.error(error); app.exit(1) }) diff --git a/tests/support/streaming-markdown-perf.tsx b/tests/support/streaming-markdown-perf.tsx new file mode 100644 index 00000000..632e301b --- /dev/null +++ b/tests/support/streaming-markdown-perf.tsx @@ -0,0 +1,149 @@ +import { flushSync } from 'react-dom' +import { createRoot } from 'react-dom/client' +import * as current from '../../src/renderer/src/MarkdownRenderer' +import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider' +import { equivalenceCorpus, longAnswerCorpus, originalNormalize, performanceCorpus } from './streaming-markdown-corpus' +import '../../src/renderer/src/fonts' +import '../../src/renderer/src/styles.css' + +type Instrumented = typeof current & { benchmarkNormalize: (content: string) => string } +const normalize = (current as Instrumented).benchmarkNormalize +const host = document.getElementById('root')! +host.className = 'markdown-content' +host.style.cssText = 'width: 800px; padding: 16px' +const root = createRoot(host) +function snapshot(container = host): string { + const clone = container.cloneNode(true) as HTMLElement + const ids = new Map() + // Separate component instances receive different React useId values. + // Canonicalize only those IDs/references; retain all content and iframe srcdoc. + for (const element of clone.querySelectorAll('*')) { + for (const name of ['id', 'aria-controls', 'aria-labelledby', 'aria-describedby']) { + const value = element.getAttribute(name) + if (value && /^_r_[a-z0-9]+_$/u.test(value)) { + if (!ids.has(value)) ids.set(value, `react-id-${ids.size}`) + element.setAttribute(name, ids.get(value)!) + } + } + } + return clone.innerHTML +} +const stats = (values: number[]) => { + const sorted = [...values].sort((a, b) => a - b) + return { median: (sorted[Math.floor((sorted.length - 1) / 2)]! + sorted[Math.floor(sorted.length / 2)]!) / 2, + p95: sorted[Math.ceil(sorted.length * 0.95) - 1] } +} + +async function run() { + const originalPath = '/src/renderer/src/MarkdownRenderer.tsx?streaming-original' + const original = await import(/* @vite-ignore */ originalPath) as Instrumented + const render = (Component: typeof current.MarkdownRenderer, content: string, renderHtml = false) => { + flushSync(() => root.render( + {content} + )) + } + // Warm fonts, styles, React, Markdown, and KaTeX before recording samples. + render(current.MarkdownRenderer, performanceCorpus[1]!.content) + await document.fonts.ready + const results = [] + for (const corpus of performanceCorpus) { + const prefixes = Array.from({ length: 24 }, (_, i) => corpus.content.slice(0, Math.ceil(corpus.content.length * (i + 1) / 24))) + const renderTimes: number[] = [] + const normalizeTimes: number[] = [] + let checksum = 0 + for (let round = 0; round < 4; round++) { + render(current.MarkdownRenderer, '') + for (const prefix of prefixes) { + await new Promise(resolve => requestAnimationFrame(() => resolve())) + const start = performance.now() + render(current.MarkdownRenderer, prefix) + // Include the synchronous DOM commit and Chromium style/layout, not frame waiting. + void host.offsetHeight + if (round > 0) renderTimes.push(performance.now() - start) + } + } + // Batch sub-millisecond calls to exceed Chromium timer resolution. + for (let round = 0; round < 4; round++) { + for (const prefix of prefixes) { + const start = performance.now() + for (let i = 0; i < 100; i++) checksum += normalize(prefix).length + if (round > 0) normalizeTimes.push((performance.now() - start) / 100) + } + } + results.push({ corpus: corpus.name, characters: corpus.content.length, samples: renderTimes.length, + renderMs: stats(renderTimes), normalizeMs: stats(normalizeTimes), checksum }) + } + const originalHost = document.createElement('div') + originalHost.className = host.className + originalHost.style.cssText = host.style.cssText + document.body.append(originalHost) + const originalRoot = createRoot(originalHost) + const longSamples = { original: [] as number[], current: [] as number[] } + const prefixLengths = Array.from({ length: 6 }, (_, i) => Math.ceil(longAnswerCorpus.length * (i + 1) / 6)) + let longDomChecks = 0 + try { + // One warm-up sweep, three measured sweeps: 48 renders total, on persistent roots. + for (let round = 0; round < 4; round++) { + render(current.MarkdownRenderer, '') + flushSync(() => originalRoot.render( + {''} + )) + for (const [index, length] of prefixLengths.entries()) { + const prefix = longAnswerCorpus.slice(0, length) + if (normalize(prefix) !== originalNormalize(prefix)) throw new Error(`Long normalizer mismatch: ${length}`) + const order = (round + index) % 2 === 0 + ? ['original', 'current'] as const : ['current', 'original'] as const + for (const variant of order) { + await new Promise(resolve => requestAnimationFrame(() => resolve())) + const start = performance.now() + if (variant === 'original') { + flushSync(() => originalRoot.render( + {prefix} + )) + void originalHost.offsetHeight + } else { + render(current.MarkdownRenderer, prefix) + void host.offsetHeight + } + if (round > 0) longSamples[variant].push(performance.now() - start) + } + // Compare outside timing, including the complete 100 KiB answer on every sweep. + if (snapshot() !== snapshot(originalHost)) throw new Error(`Long DOM mismatch: ${length}`) + longDomChecks++ + } + } + if (!host.querySelector('h2') || !host.querySelector('pre code') || !host.querySelector('table')) { + throw new Error('Long-answer prose/code/table formatting missing') + } + } finally { + flushSync(() => originalRoot.unmount()) + originalHost.remove() + } + const longAnswer = { characters: longAnswerCorpus.length, bytes: new TextEncoder().encode(longAnswerCorpus).length, + prefixLengths, warmupSweeps: 1, measuredSweeps: 3, samplesPerVariant: longSamples.current.length, + originalRenderMs: stats(longSamples.original), currentRenderMs: stats(longSamples.current), + rawRenderMs: longSamples, domChecks: longDomChecks, finalDomEquivalent: true } + let prefixesChecked = 0 + let domChecks = 0 + for (const content of equivalenceCorpus) { + for (let end = 0; end <= content.length; end++) { + const prefix = content.slice(0, end) + if (normalize(prefix) !== originalNormalize(prefix)) throw new Error(`Normalizer mismatch: ${JSON.stringify(prefix)}`) + prefixesChecked++ + for (const renderHtml of [false, true]) { + render(original.MarkdownRenderer, prefix, renderHtml) + const expected = snapshot() + render(current.MarkdownRenderer, prefix, renderHtml) + if (snapshot() !== expected) throw new Error(`DOM mismatch (${renderHtml}): ${JSON.stringify(prefix)}`) + domChecks++ + } + } + } + render(current.MarkdownRenderer, '# Live\n\n**formatted** and \\(x\\)') + if (!host.querySelector('h1') || !host.querySelector('strong') || !host.querySelector('.katex')) { + throw new Error('Real Markdown formatting missing') + } + return { results, longAnswer, prefixesChecked, domChecks } +} + +Object.assign(window, { streamingMarkdown: run })