fix: reduce redundant work during chat and note startup

Avoid hidden activity renders, no-op message saves and attachment scans when pinning conversations. Reuse note body inspection and skip unchanged attachment reference writes while preserving recovery and cleanup behavior.

Skip unnecessary Markdown normalization and memoize subagent lookups without delaying live rich text. Add storage operation counts and real Electron rendering equivalence benchmarks; full-render speedup remains unproven.

Validation: 5397 tests passed, 84 skipped; typecheck passed. Lint passed excluding two existing untracked sidebar probe files.

Release note: Reduce repeated rendering and disk work during chat, conversation pinning and note startup while preserving existing content and interactions.
This commit is contained in:
mesalogo
2026-09-28 20:53:43 +08:00
parent 594222a2b5
commit 13ff182858
23 changed files with 1047 additions and 54 deletions
@@ -64,6 +64,10 @@ Main 先保存资产,再保存草稿或接受队列;消息、分支建立独
会话删除、项目删除及清空数据均核对引用,启动回收已无归属的记录和孤立 UUID 目录。
清理失败保留无引用数据库记录供下次重试,不回滚已接受的草稿。
引用保存按输入顺序去重并查询资产。已有引用的顺序、会话归属均相同,且资产无需从临时目录
转存时,直接返回,不重复开启写事务或删除、插入引用。空引用同样适用;顺序、归属或附件
发生变化时仍按原事务更新,临时资产仍须转存。
助手库 schema 39 标记新附件元数据,阻止旧客户端将其当旧格式读取;没有批量改写历史附件。
知识库 schema 13 新增 `metadata.parsedResultId` 表达式索引,结果按主键定位,不扫描全部文档。
知识库新结果先写入独立 resultId 目录,数据库正文/chunks 发布成功后替换引用,再清理旧目录。
+25
View File
@@ -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
@@ -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,
@@ -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 / 待开始
+9 -4
View File
@@ -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<string>): 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<string> | undefined = new Set<string>()
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()
@@ -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'))
+11 -1
View File
@@ -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
+91
View File
@@ -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<typeof registerIpcHandlers> | 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'
-2
View File
@@ -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()
}
)
@@ -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<typeof import('node:fs')>()
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<typeof AssistantDatabase>[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('已被更新')
})
+25 -11
View File
@@ -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<string> } {
const entry = JSON.parse(readFileSync(this.entryPath(noteId, entryId), 'utf8')) as MagicNoteStoredEntry
const referencedAssets = new Set<string>()
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<string>): 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<string>()
// 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 })
}
}
}
+43
View File
@@ -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<typeof import('./WorkspacePrimitives')>()
return {
...original,
PageHeader: (props: Parameters<typeof original.PageHeader>[0]) => {
if (props.headingId === 'activity-panel-title') activityRenderProbe()
return <original.PageHeader {...props} />
}
}
})
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(<div hidden={false}><ActivityPanel {...props} /></div>)
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(<div hidden data-update={index}><ActivityPanel {...props} /></div>)
}
expect(activityRenderProbe).toHaveBeenCalledTimes(0)
const updated = { ...props, records: [{ ...record, detail: 'Real update', status: 'failed' as const }], onOpenConversation: vi.fn(), onClear: vi.fn() }
rerender(<div hidden><ActivityPanel {...updated} /></div>)
expect(activityRenderProbe).toHaveBeenCalledTimes(1)
rerender(<div hidden={false}><ActivityPanel {...updated} /></div>)
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(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[makeRecord(1)]} tokenUsage={makeTokenUsage()} />)
fireEvent.click(screen.getByRole('tab', { name: '活动时间线' }))
+3 -3
View File
@@ -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({
)}
</section>
)
}
})
+74
View File
@@ -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<typeof import("./WorkspacePrimitives")>();
return {
...original,
PageHeader: (props: Parameters<typeof original.PageHeader>[0]) => {
if (props.headingId === "activity-panel-title") activityRenderProbe();
return <original.PageHeader {...props} />;
},
};
});
vi.mock("./use-unviewed-completions", async (importOriginal) => {
const original = await importOriginal<typeof import("./use-unviewed-completions")>();
@@ -2982,6 +2994,46 @@ describe("App", () => {
expect(route).not.toHaveAttribute("hidden");
});
it("isolates hidden Activity renders from composer and streaming updates", async () => {
render(<App />);
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<HTMLButtonElement>(".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(<App />);
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(<App />);
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
+24 -20
View File
@@ -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<void> => {
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 {
}
>
<ActivityPanel
onClear={() => {
legacyActivityHistoryMayBeIncompleteRef.current = false;
setLegacyActivityHistoryMayBeIncomplete(false);
setActivityRecords([]);
}}
onClear={clearActivity}
onOpenConversation={openActivityConversation}
projects={projects}
records={activityRecords}
+91
View File
@@ -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<Message['subagents']> = ['a', 'b', 'c', 'd'].map(id => ({
childTaskId: id, expertId: id, expertName: `Expert ${id}`,
routingMode: 'native', state: 'running'
}))
const makeBlocks = (content: string): NonNullable<Message['blocks']> => [
{ 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(<ChatTimeline {...props} messages={[message]} />)
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(<ChatTimeline {...props} messages={[message]} />)
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(<ChatTimeline {...props} messages={[message]} />)
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(<ChatTimeline {...props} messages={[message]} />)
expect(cardNames()).toEqual(['Updated b', 'Updated a', 'Updated c', 'Updated d'])
for (const card of view.container.querySelectorAll<HTMLDetailsElement>('.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(<ChatTimeline {...props} messages={[message]} />)
expect(cardNames()).toEqual(['Updated d', 'Updated a', 'Updated b', 'Updated c'])
for (const blocks of [undefined, []]) {
message = { ...message, blocks }
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(cardNames()).toEqual(['Updated a', 'Updated b', 'Updated c', 'Updated d'])
}
message = { ...message, subagents: undefined }
view.rerender(<ChatTimeline {...props} messages={[message]} />)
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[] = [
{
+6 -6
View File
@@ -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 =>
+22 -1
View File
@@ -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('<section>\r\na\r\n</section>')).toBe('<section>\na\n</section>')
expect(normalizeLatexDelimiters('a\rb\r')).toBe('a\rb\r')
})
beforeEach(() => {
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
app: { onPreviewEscape: vi.fn((listener: () => void) => {
+8 -1
View File
@@ -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: '`' | '~'
+65
View File
@@ -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',
'<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/tests/support/streaming-markdown-perf.tsx"></script></body></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)
+101
View File
@@ -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}\$(?!\$)(.+?)(?<!\\)\$\s*$/u.exec(line)
if (standaloneDollar) return `$$\n${standaloneDollar[1]}\n$$`
const standaloneParentheses = /^ {0,3}\\\(\s*(.*?)\s*\\\)\s*$/u.exec(line)
if (standaloneParentheses) return `$$\n${standaloneParentheses[1]}\n$$`
const singleLineDisplay = /^ {0,3}\\\[\s*(.*?)\s*\\\]\s*$/u.exec(line)
if (singleLineDisplay) return `$$\n${singleLineDisplay[1]}\n$$`
if (/^ {0,3}\\\[\s*$/u.test(line)) return '$$'
if (/^ {0,3}\\\]\s*$/u.test(line)) return '$$'
return originalReplace(line)
}).join('\n')
}
export const equivalenceCorpus = [
'# Heading\n\n**bold** and [link](https://example.com)\n- [x] ready\n',
'Inline \\(a+b\\), $x$ and \\\\(escaped\\).\n$x^2$\n\\(y\\)\n\\[z\\]\n\\[\na+b\n\\]\n',
'`\\(code\\)` ``a ` \\(b\\)`` and \\(x\\)\n```tex\n\\(x\\)\n```\n~~~\n$y$\n~~~\n',
'````tex\n```\n\\[x\\]\n````\n ~~~txt\n\\(y\\)\n ~~~~\n\\(z\\)\n',
'a\r\nb\r\n$x$\r\n```txt\r\n\\(code\\)\r\n```\r\nend\r',
'```markdown\r\n# Title\r\n\r\n\\(x\\)\r\n```',
'<html>\r\n<body><p>\\(x\\)</p></body>\r\n</html>',
'```html\r\n<section><strong>bold</strong></section>\r\n```',
'<section>**raw**\r\ntext</section>',
'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<string, string>[]) {\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)
@@ -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) })
+149
View File
@@ -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<string, string>()
// 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(<UiLocaleProvider initialPreference="en-US">
<Component renderHtml={renderHtml}>{content}</Component>
</UiLocaleProvider>))
}
// 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<void>(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(<UiLocaleProvider initialPreference="en-US">
<original.MarkdownRenderer>{''}</original.MarkdownRenderer>
</UiLocaleProvider>))
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<void>(resolve => requestAnimationFrame(() => resolve()))
const start = performance.now()
if (variant === 'original') {
flushSync(() => originalRoot.render(<UiLocaleProvider initialPreference="en-US">
<original.MarkdownRenderer>{prefix}</original.MarkdownRenderer>
</UiLocaleProvider>))
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 })