import { randomUUID } from 'node:crypto' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' import { afterEach, describe, expect, it, vi } from 'vitest' import { builtInDefaultProjectSeedDescription, builtInDefaultProjectSeedName, conversationSnapshotSchema, conversationSetPinnedSchema, localConversationHeaderSchema, isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts' import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database' import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts' import type { ImageOperation } from '../../shared/image-generation-contracts' import type { StoredSupervisionResult } from './supervisor-service' const temporaryDirectories: string[] = [] it.each(['text-fallback', 'canvas-images'] as const)('invalidates canvas source comments by actual %s input while retaining todo identity', async (inputMode) => { const database = await createDatabase() try { const content = { version: 2 as const, kind: 'paged-canvas' as const, pages: [{ id: 'text-page', width: 794, height: 1123, background: { type: 'template' as const, template: 'blank' as const }, objects: [{ type: 'IText', text: 'Context', left: 1 }] }], flow: { ops: [{ insert: 'Task' }, { insert: '\n', attributes: { list: 'unchecked' } }] }, assets: [] } const entry = database.createMagicNote({ title: 'Source', content }).entries[0]! const todo = database.listMagicTodos()[0]! const comments = [{ id: randomUUID(), kind: 'summary' as const, content: 'Analysis', inputMode }] database.saveMagicTodoAnalysis({ todoId: todo.id, expectedRevision: todo.revision, sourceEntryRevision: entry.revision, comments }) database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: entry.revision, comments }) const analyzed = database.getMagicNoteEntry(entry.id) const analyzedTodo = database.getMagicTodo(todo.id) const moved = structuredClone(content) moved.pages[0]!.objects[0]!.left = 50 database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: analyzed.revision, content: moved, plainText: '' }) const afterMove = database.getMagicNoteEntry(entry.id) const todoAfterMove = database.getMagicTodo(todo.id) expect(afterMove.comments.length).toBe(inputMode === 'text-fallback' ? 1 : 0) expect(todoAfterMove.comments.length).toBe(inputMode === 'text-fallback' ? 1 : 0) expect(todoAfterMove.revision).toBe(analyzedTodo.revision + (inputMode === 'canvas-images' ? 1 : 0)) if (inputMode === 'text-fallback') { // Inserting a blank first page leaves plainText identical but changes cited page numbers. moved.pages.unshift({ ...moved.pages[0]!, id: 'blank-page', objects: [] }) database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: afterMove.revision, content: moved, plainText: '' }) expect(database.getMagicNoteEntry(entry.id)).toMatchObject({ plainText: afterMove.plainText, comments: [] }) expect(database.getMagicTodo(todo.id)).toMatchObject({ id: todo.id, comments: [], revision: todoAfterMove.revision + 1 }) } expect(() => database.saveMagicTodoAnalysis({ todoId: todo.id, expectedRevision: database.getMagicTodo(todo.id).revision, sourceEntryRevision: entry.revision, comments })).toThrow('来源记录已被更新') expect(() => database.saveMagicTodoAnalysis({ todoId: todo.id, expectedRevision: database.getMagicTodo(todo.id).revision, comments })).toThrow('来源记录已被更新') } finally { database.close() } }) it('roundtrips canvas assets and keeps todos stable across geometry edits and completion', async () => { const database = await createDatabase() try { const content = { version: 2 as const, kind: 'paged-canvas' as const, pages: [{ id: 'page', width: 794, height: 1123, background: { type: 'template' as const, template: 'grid' as const }, objects: [{ type: 'IText', text: 'Canvas annotation', left: 1 }] }], flow: { version: 1 as const, ops: [{ insert: 'Canvas task' }, { insert: '\n', attributes: { list: 'unchecked' } }] }, assets: [{ id: 'file', name: 'file.txt', mimeType: 'text/plain', dataUrl: 'data:text/plain;base64,aGVsbG8=' }] } const note = database.createMagicNote({ title: 'Canvas', content }) const entry = note.entries[0]! const todo = database.listMagicTodos()[0]! database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: entry.revision, comments: [{ id: randomUUID(), kind: 'summary', content: 'Old analysis', inputMode: 'canvas-images' }] }) const analyzed = database.getMagicNoteEntry(entry.id) const moved = { ...content, pages: [{ ...content.pages[0]!, objects: [{ type: 'IText', text: 'Canvas annotation', left: 500 }] }] } database.updateMagicNoteEntry({ entryId: entry.id, content: moved, plainText: 'stale text', expectedRevision: analyzed.revision }) expect(database.getMagicNoteEntry(entry.id)).toMatchObject({ content: moved, plainText: 'Canvas task\nCanvas annotation', comments: [] }) expect(database.listMagicTodos()[0]).toMatchObject({ id: todo.id, sourceIndex: 0, revision: todo.revision }) database.updateMagicTodo({ todoId: todo.id, completed: true, expectedRevision: todo.revision }) const saved = database.getMagicNoteEntry(entry.id) expect(saved.content).toMatchObject({ pages: moved.pages, assets: content.assets, flow: { ops: [{ insert: 'Canvas task' }, { insert: '\n', attributes: { list: 'checked' } }] } }) expect(() => database.updateMagicNoteEntry({ entryId: entry.id, content, plainText: '', expectedRevision: entry.revision })).toThrow('已被更新') database.close() database.initialize('C:\\Workspace') expect(database.getMagicNoteEntry(entry.id)).toEqual(saved) expect(database.searchMagicNotes('Canvas annotation', 10)).toHaveLength(1) database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: saved.revision, comments: [{ id: randomUUID(), kind: 'summary', content: 'Text analysis', inputMode: 'text-fallback' }] }) const textAnalyzed = database.getMagicNoteEntry(entry.id) const repositioned = { ...moved, pages: [{ ...moved.pages[0]!, objects: [{ type: 'IText', text: 'Canvas annotation', left: 200 }] }] } database.updateMagicNoteEntry({ entryId: entry.id, content: repositioned, plainText: '', expectedRevision: textAnalyzed.revision }) const textPreserved = database.getMagicNoteEntry(entry.id) expect(textPreserved.comments).toEqual(textAnalyzed.comments) expect(textPreserved.analyzedAt).toEqual(textAnalyzed.analyzedAt) const revised = { ...repositioned, pages: [{ ...repositioned.pages[0]!, objects: [{ type: 'IText', text: 'Changed annotation', left: 200 }] }] } database.updateMagicNoteEntry({ entryId: entry.id, content: revised, plainText: '', expectedRevision: textPreserved.revision }) expect(database.getMagicNoteEntry(entry.id).comments).toEqual([]) } finally { database.close() } }) it('persists conversation pins without changing timestamps or allowing autosaves to overwrite them', async () => { const database = await createDatabase() const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 } const message = { id: randomUUID(), role: 'user' as const, content: 'Keep me', state: 'complete' as const, createdAt: 900 } try { database.saveLocalConversations([{ header, messages: [message] }]) const original = database.getConversation(header.id) expect(original.pinned).toBe(false) expect(conversationSnapshotSchema.parse({ ...header, messages: [] }).pinned).toBeUndefined() expect(localConversationHeaderSchema.safeParse({ ...header, pinned: false }).success).toBe(false) expect(conversationSetPinnedSchema.safeParse({ conversationId: header.id, pinned: 'true' }).success).toBe(false) expect(conversationSetPinnedSchema.safeParse({ conversationId: header.id, pinned: true, updatedAt: 2000 }).success).toBe(false) database.setConversationPinned({ conversationId: header.id, pinned: true }) database.setConversationPinned({ conversationId: header.id, pinned: true }) // Even a stale snapshot passed directly to storage cannot overwrite the pin. database.saveLocalConversations([{ header: { ...header, ...{ pinned: false } }, messages: [message] }]) expect(database.getConversation(header.id)).toEqual({ ...original, pinned: true }) database.close() database.initialize('C:\\Workspace') expect(database.getConversation(header.id)).toEqual({ ...original, pinned: true }) database.setConversationPinned({ conversationId: header.id, pinned: false }) database.close() database.initialize('C:\\Workspace') expect(database.getConversation(header.id)).toEqual(original) expect(() => database.setConversationPinned({ conversationId: randomUUID(), pinned: true })).toThrow('对话不存在') } finally { database.close() } }) it('includes older pinned conversations beyond the recent 100 and sorts pins by update time', async () => { const database = await createDatabase() const headers = Array.from({ length: 103 }, (_, index) => ({ id: randomUUID(), title: `Conversation ${index}`, updatedAt: 1000 + index })) try { database.saveLocalConversations(headers.map(header => ({ header, messages: [] }))) database.setConversationPinned({ conversationId: headers[0]!.id, pinned: true }) database.setConversationPinned({ conversationId: headers[1]!.id, pinned: true }) const expectedIds = [headers[1]!.id, headers[0]!.id, ...headers.slice(2).reverse().map(header => header.id)] expect(database.listConversations().map(item => item.id)).toEqual(expectedIds) const summaries = database.listConversationSummaries() expect(summaries.map(item => item.id)).toEqual(expectedIds) expect(summaries.slice(0, 2).every(item => item.pinned)).toBe(true) expect(database.searchConversations('Conversation 0')).toEqual([headers[0]!.id]) database.setConversationPinned({ conversationId: headers[0]!.id, pinned: false }) expect(database.listConversations().map(item => item.id)).toEqual([ headers[1]!.id, ...headers.slice(2).reverse().map(header => header.id), headers[0]!.id ]) expect(database.getConversation(headers[0]!.id)).toMatchObject({ pinned: false, updatedAt: 1000 }) } finally { database.close() } }) it('migrates schema 35 conversations to unpinned while retaining existing data across reopening', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-conversation-pins-')) temporaryDirectories.push(directory) const path = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(path) database.initialize('C:\\Workspace') const header = { id: randomUUID(), title: 'Before migration', updatedAt: 1000 } database.saveLocalConversations([{ header, messages: [] }]) const original = database.getConversation(header.id) database.close() const legacy = new DatabaseSync(path) legacy.exec(`ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 35;`) legacy.close() try { database.initialize('C:\\Workspace') expect(database.getConversation(header.id)).toEqual(original) database.setConversationPinned({ conversationId: header.id, pinned: true }) database.close() database.initialize('C:\\Workspace') expect(database.getConversation(header.id)).toEqual({ ...original, pinned: true }) } finally { database.close() } const inspected = new DatabaseSync(path) try { expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION }) expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' }) expect(inspected.prepare('SELECT created_at, updated_at FROM conversations WHERE id = ?').get(header.id)).toEqual({ created_at: new Date(1000).toISOString(), updated_at: new Date(1000).toISOString() }) } finally { inspected.close() } }) it('atomically completes conversation images and retains operations and upload references across saves and reopening', async () => { const database = await createDatabase() const conversationId = randomUUID() const messageId = randomUUID() const header = { id: conversationId, title: 'Image operation', updatedAt: Date.now() } const message = { id: messageId, role: 'assistant' as const, content: '', state: 'complete' as const, createdAt: Date.now() } database.saveLocalConversations([{ header, messages: [message] }]) const operation: ImageOperation = { id: randomUUID(), conversationId, messageId, requestId: randomUUID(), callId: 'image-call', modelProfileId: randomUUID(), modelName: 'Image model', input: { intent: 'create', prompt: 'Blue', sourceArtifactIds: [] }, state: 'running', createdAt: Date.now(), updatedAt: Date.now(), artifactIds: [] } database.saveConversationImageOperation(operation) const sources = database.saveConversationImageSources({ conversationId, messageId, images: [{ name: 'source.png', mediaType: 'image/png', data: 'iVBORw0KGgo=' }] }) const completed = database.saveConversationImageOperation(operation, { mimeType: 'image/png', data: 'iVBORw0KGgo=' }) expect(database.saveConversationImageOperation(operation, { mimeType: 'image/png', data: 'iVBORw0KGgo=' })).toEqual(completed) expect(database.listArtifacts()).toHaveLength(2) database.saveLocalConversations([{ header, messages: [message] }]) database.close() database.initialize(process.cwd()) const restored = database.getConversation(conversationId).messages[0]! expect(restored.imageOperations).toEqual([completed]) expect(restored.artifactIds).toEqual(completed.artifactIds) expect(restored.imageSourceArtifactIds).toEqual(sources) expect(database.getArtifact(sources[0]!).content).toContain('iVBORw0KGgo=') const failed = { ...operation, id: randomUUID() } database.saveConversationImageOperation(failed) expect(() => database.saveConversationImageOperation(failed, { mimeType: 'image/png', data: 'a'.repeat(5 * 1024 * 1024) })).toThrow('5MB') expect(database.listArtifacts()).toHaveLength(2) expect(database.getConversation(conversationId).messages[0]!.imageOperations?.find(item => item.id === failed.id)?.state).toBe('running') database.deleteLocalConversation(conversationId) expect(() => database.saveConversationImageOperation(failed, { mimeType: 'image/png', data: 'iVBORw0KGgo=' })).toThrow('no longer exists') database.close() }) const channelDefaultProfileId = '00000000-0000-4000-8000-000000000001' afterEach(async () => { vi.useRealTimers() await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }) ) ) }) async function createDatabase( options: { onMagicNotesChanged?: () => void onMagicTodosChanged?: () => void } = {} ): Promise { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-assistant-')) temporaryDirectories.push(directory) const database = new AssistantDatabase( join(directory, 'assistant.sqlite'), options ) database.initialize('C:\\Workspace') return database } it('persists supervision knowledge locators and keeps memory sources distinct', async () => { const database = await createDatabase() try { const result: StoredSupervisionResult = { request: { trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00.000Z', to: '2026-09-22T00:00:00.000Z' } }, evidence: [ { id: 'knowledge-ref', sourceType: 'knowledge', sourceId: 'chunk-1', title: '本地文档', content: '引用片段', occurredAt: '2026-09-21T00:00:00.000Z', locator: { libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' } }, { id: 'memory-ref', sourceType: 'memory', sourceId: 'memory-1', title: '已确认记忆', content: '记忆内容', occurredAt: '2026-09-21T00:00:00.000Z' } ], output: { summary: '回顾', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } } database.saveSupervisionResult(result) const graph = database.getSupervisionGraph() expect(graph.sources).toEqual(expect.arrayContaining([ expect.objectContaining({ source_type: 'knowledge', locator_json: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) }), expect.objectContaining({ source_type: 'memory', locator_json: null }) ])) const source = (graph.sources as Array<{ id: string; source_type: string }>).find((item) => item.source_type === 'knowledge')! expect(database.getSupervisionSource(source.id)).toMatchObject({ sourceType: 'knowledge', locatorJson: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) }) } finally { database.close() } }) it('preserves existing supervision data when upgrading schema 41 and reopening', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-supervision-migration-')) temporaryDirectories.push(directory) const path = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(path) database.initialize(process.cwd()) database.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00.000Z', to: '2026-09-22T00:00:00.000Z' } }, evidence: [{ id: 'source', sourceType: 'knowledge', sourceId: 'chunk', title: 'Source', content: 'Keep content', occurredAt: '2026-09-21T00:00:00.000Z', locator: { chunkId: 'chunk' } }], output: { summary: 'Keep summary', changeDigest: '', openItems: [], entities: [{ id: 'entity', label: 'Keep label', description: 'Keep description', sourceReferenceIds: ['source'] }], events: [], entityChanges: [], relations: [] } }) const graph = database.getSupervisionGraph() const entityId = (graph.entities as Array<{ id: string }>)[0]!.id database.applySupervisionEntityAction({ entityId, action: 'confirm' }) const results = database.listSupervisionResults() const source = database.getSupervisionSource(String(results[0]!.sourceId)) database.close() const legacy = new DatabaseSync(path) legacy.exec(`ALTER TABLE supervision_entities DROP COLUMN source_reference_ids_json; ALTER TABLE supervision_entity_changes DROP COLUMN source_reference_ids_json; ALTER TABLE supervision_relations DROP COLUMN source_reference_ids_json; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW supervision_review_current; DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches; DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 41;`) legacy.close() try { for (let attempt = 0; attempt < 2; attempt++) { database.initialize(process.cwd()) expect(database.listSupervisionResults()).toEqual(results) expect(database.getSupervisionSource(String(results[0]!.sourceId))).toEqual(source) expect(database.getSupervisionGraph().entities).toEqual([expect.objectContaining({ id: entityId, canonical_label: 'Keep label', description: 'Keep description', confirmation_state: 'confirmed', source_reference_ids_json: '[]' })]) database.close() } } finally { database.close() } }) it.each(['same-project', 'different-project'] as const)('isolates supervision model IDs across overlapping runs (%s)', async (scope) => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-supervision-')) temporaryDirectories.push(directory) const path = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(path) database.initialize(process.cwd()) const projectId = randomUUID() const result: StoredSupervisionResult = { request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projectId] }, timeRange: { from: '2026-09-01T00:00:00.000Z', to: '2026-09-22T00:00:00.000Z' } }, evidence: [{ id: 'source', sourceType: 'knowledge', sourceId: 'chunk', title: 'First source', content: 'First content', occurredAt: '2026-09-21T00:00:00.000Z', locator: { libraryId: 'library', documentId: 'document', chunkId: 'chunk' } }], output: { summary: 'First', changeDigest: '', openItems: [], entities: ['entity', 'other'].map((id) => ({ id, label: id, description: 'Original', sourceReferenceIds: ['source'] })), events: [{ title: 'Event', description: '', occurredAt: '2026-09-21T00:00:00.000Z', eventType: 'discussion', entityIds: ['entity', 'other'], sourceReferenceIds: ['source'] }], entityChanges: [{ entityId: 'entity', changeType: 'added', description: 'Change', sourceReferenceIds: ['source'] }], relations: [{ fromEntityId: 'entity', toEntityId: 'other', relationType: 'related', reason: 'Reason', sourceReferenceIds: ['source'] }] } } try { database.saveSupervisionResult(result) const firstGraph = database.getSupervisionGraph() const entity = (firstGraph.entities as Array<{ id: string; canonical_label: string }>).find((item) => item.canonical_label === 'entity')! database.applySupervisionEntityAction({ entityId: entity.id, action: 'revise', label: 'User label', description: 'User description' }) const next = structuredClone(result) if (scope === 'different-project') next.request.scope = { kind: 'projects', projectIds: [randomUUID()] } next.output.summary = 'Second' next.output.entities[0]!.label = 'Model replacement' next.evidence[0]!.title = 'Second source' next.evidence[0]!.locator = { libraryId: 'second-library', documentId: 'second-document', chunkId: 'chunk' } database.saveSupervisionResult(next) database.close() database.initialize(process.cwd()) const results = database.listSupervisionResults() expect(results).toHaveLength(2) expect(new Set(results.map((item) => item.sourceId)).size).toBe(2) for (const row of results) { expect(database.getSupervisionSource(String(row.sourceId))).toMatchObject({ resultId: row.id, title: row.summary === 'First' ? 'First source' : 'Second source' }) } const inspected = new DatabaseSync(path) try { expect(inspected.prepare('SELECT * FROM supervision_entities WHERE id = ?').get(entity.id)).toMatchObject({ canonical_label: 'User label', description: 'User description', confirmation_state: 'revised' }) expect(inspected.prepare('SELECT id FROM supervision_entities').all()).toHaveLength(4) const events = inspected.prepare('SELECT * FROM supervision_events').all() as Array<{ id: string; result_id: string; story_line_id: string }> for (const event of events) { const sourceId = String(results.find((row) => row.id === event.result_id)!.sourceId) expect(inspected.prepare('SELECT source_id FROM supervision_event_sources WHERE event_id = ?').all(event.id)).toEqual([{ source_id: sourceId }]) const entities = inspected.prepare(`SELECT e.* FROM supervision_entities e JOIN supervision_event_entities ee ON ee.entity_id = e.id WHERE ee.event_id = ?`).all(event.id) as Array<{ id: string; story_line_id: string; source_reference_ids_json: string }> expect(entities).toHaveLength(2) expect(entities.every((item) => item.story_line_id === event.story_line_id && item.source_reference_ids_json === JSON.stringify([sourceId]))).toBe(true) const change = inspected.prepare('SELECT * FROM supervision_entity_changes WHERE result_id = ?').get(event.result_id) as Record expect(entities.map((item) => item.id)).toContain(change.entity_id) expect(change.source_reference_ids_json).toBe(JSON.stringify([sourceId])) const relation = inspected.prepare('SELECT * FROM supervision_relations WHERE from_entity_id = ?').get(change.entity_id as string) as Record expect(entities.map((item) => item.id)).toContain(relation.to_entity_id) expect(relation.source_reference_ids_json).toBe(JSON.stringify([sourceId])) const graph = database.getSupervisionGraph({ storyLineId: event.story_line_id, resultId: event.result_id }) expect(graph.events).toHaveLength(1) expect(graph.entities).toHaveLength(2) expect(graph.sources).toHaveLength(1) expect(graph.sources).toEqual(expect.arrayContaining([expect.objectContaining({ id: sourceId, locator_json: JSON.stringify(results.find((row) => row.id === event.result_id)!.summary === 'First' ? result.evidence[0]!.locator : next.evidence[0]!.locator) })])) } expect(inspected.prepare('PRAGMA foreign_key_check').all()).toEqual([]) } finally { inspected.close() } } finally { database.close() } }) const validatedSshHostId = '00000000-0000-4000-8000-000000000321' function validatedSshProjectWrite( overrides: { rootPath?: string hostId?: string assertCurrent?: Parameters< AssistantDatabase['createSshProject'] >[0]['assertCurrent'] } = {} ): Parameters< AssistantDatabase['createSshProject'] >[0] { const rootPath = overrides.rootPath ?? '/srv/goodbuddy' const hostId = overrides.hostId ?? validatedSshHostId return { project: { name: '远程项目', description: '原子持久化', rootPath, defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } }, executionSpace: { kind: 'ssh', hostId, remoteRootPath: rootPath }, assertCurrent: overrides.assertCurrent ?? (() => {}) } } function claimQueuedSchedules( database: AssistantDatabase, now: Date, limit = 4 ): Array<{ schedule: ReturnType[number] runId: string }> { database.queueDueSchedules(now, limit) const claims: Array<{ schedule: ReturnType[number] runId: string }> = [] const seenConversations = new Set() for (const item of database.listConversationQueueItems()) { if ( item.source !== 'schedule' || seenConversations.has(item.conversationId) || claims.length >= limit ) { continue } const claimed = database.claimConversationQueueItem( item.conversationId, item.id ) if (claimed?.source === 'schedule') { claims.push({ schedule: claimed.schedule, runId: claimed.runId }) seenConversations.add(item.conversationId) } } return claims } function claimManualScheduleQueueItem( database: AssistantDatabase, scheduleId: string ): { schedule: ReturnType[number] runId: string } { const item = database.queueScheduleNow(scheduleId) const claimed = database.claimConversationQueueItem( item.conversationId, item.id ) if (claimed?.source !== 'schedule') { throw new Error('Expected a claimed schedule queue item') } return { schedule: claimed.schedule, runId: claimed.runId } } describe('AssistantDatabase', () => { it('round trips long checklist metadata and explicit clears across reopen', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-checklist-')) temporaryDirectories.push(directory) const path = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(path) await database.initialize(process.cwd()) const id = randomUUID() const message = { id: randomUUID(), role: 'assistant' as const, state: 'complete' as const, content: '', createdAt: 1, runtimeChecklist: { source: 'continue' as const, items: [{ content: 'task'.repeat(1500), status: 'pending' as const }] } } database.replaceConversations([{ id, title: 'Checklist', updatedAt: 1, messages: [message] }]) database.close() const reopened = new AssistantDatabase(path) await reopened.initialize(process.cwd()) try { expect(reopened.getConversation(id).messages[0]?.runtimeChecklist).toEqual(message.runtimeChecklist) reopened.saveLocalConversations([{ header: { id, title: 'Checklist', updatedAt: 2 }, messages: [{ ...message, runtimeChecklist: { source: 'continue', items: [] } }] }]) expect(reopened.getConversation(id).messages[0]?.runtimeChecklist).toEqual({ source: 'continue', items: [] }) } finally { reopened.close() } }) it('lists lightweight history without dropping searchable text or full process metadata', async () => { const database = await createDatabase() try { const snapshots = Array.from({ length: 100 }, (_, index) => ({ id: randomUUID(), title: `History ${index}`, updatedAt: index + 1, messages: Array.from({ length: 16 }, (_, messageIndex) => ({ id: randomUUID(), role: 'assistant' as const, state: 'complete' as const, content: messageIndex === 0 ? `ÄPFEL 中文 ${index} %_` : 'Body', reasoning: 'Complete retained process history. '.repeat(64), createdAt: index * 100 + messageIndex })) })) database.replaceConversations(snapshots) const summaries = database.listConversationSummaries() expect(summaries).toHaveLength(100) expect(summaries.every(item => item.messages.length === 0 && item.messageSummary?.count === 16 && item.messageSummary.firstRole === 'assistant')).toBe(true) for (const snapshot of snapshots) { expect(summaries.find(item => item.id === snapshot.id)?.messageSummary?.latestMessageAt) .toBe(Math.max(...snapshot.messages.map(message => message.createdAt))) } expect(JSON.stringify(summaries).length).toBeLessThan(JSON.stringify(snapshots).length * 0.02) const id = snapshots[0]!.id const detail = database.listConversationSummaries([id]).find(item => item.id === id)! expect(detail.messageSummary).toBeUndefined() expect(detail.messages).toEqual(snapshots[0]!.messages) expect(database.getConversation(id).messages).toEqual(snapshots[0]!.messages) expect(database.searchConversations('äpfel 中文 0 %_')).toEqual([id]) expect(database.searchConversations('Complete retained process')).toEqual([]) expect(database.searchConversations('history 99')).toEqual([snapshots[99]!.id]) expect(database.searchConversations(' ')).toEqual([]) const added = { id: randomUUID(), title: 'Newer', updatedAt: 1000 } database.saveLocalConversations([{ header: added, messages: [] }]) expect(database.listConversationSummaries().find(item => item.id === added.id)?.messageSummary?.latestMessageAt).toBeUndefined() expect(database.listConversationSummaries().some(item => item.id === id)).toBe(true) expect(database.searchConversations('äpfel 中文 0 %_')).toEqual([id]) expect(database.listConversationSummaries([id]).find(item => item.id === id)?.messages).toHaveLength(16) // A header-only save of a summary cannot replace or erase messages. database.saveLocalConversations([{ header: { id, title: 'Renamed', updatedAt: 101 }, messages: [] }]) expect(database.getConversation(id).messages).toEqual(snapshots[0]!.messages) expect(database.listConversationSummaries().find(item => item.id === id)?.messageSummary?.latestMessageAt).toBe(15) } finally { database.close() } }) it.each([0, 1, 100, 500, 999])('keeps listTasks(%i) recent history plus old visible live tasks', async (limit) => { const database = await createDatabase() vi.useFakeTimers() vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) try { const projectId = database.listProjects()[0]!.id const otherProject = database.createProject({ name: 'Other project', description: '', rootPath: 'C:\\Other', defaultWorkMode: 'ask' }) const active = ['running', 'waiting_approval'].map((status, index) => database.createTask({ id: randomUUID(), projectId: index ? otherProject.id : projectId, title: status, instructions: '', workMode: 'execute', status: status as 'running' | 'waiting_approval' }) ) database.createTask({ id: randomUUID(), title: 'Hidden live task', instructions: '', workMode: 'execute', visible: false }) const history = Array.from({ length: 501 }, (_, index) => { vi.setSystemTime(new Date(Date.UTC(2026, 1, 1) + index * 1000)) return database.createTask({ id: randomUUID(), projectId, title: `History ${index}`, instructions: '', workMode: 'ask', status: index === 500 ? 'running' : 'completed' }) }).reverse() const expected = [ ...history.slice(0, Math.max(1, Math.min(500, limit))), ...active ] const tasks = database.listTasks(limit) expect(tasks).toHaveLength(expected.length) expect(new Set(tasks.map((task) => task.id))).toEqual( new Set(expected.map((task) => task.id)) ) expect(tasks.slice(0, expected.length - active.length)).toEqual( history.slice(0, Math.max(1, Math.min(500, limit))) ) expect(tasks.find((task) => task.id === active[1]!.id)?.projectId) .toBe(otherProject.id) if (limit === 100) { expect(database.listTasks()).toEqual(tasks) } } finally { database.close() } }) it('lists all active conversations while preserving task visibility and excluding archived conversations', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-live-history-')) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const raw = new DatabaseSync(databasePath) vi.useFakeTimers() vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) try { const projectId = database.listProjects()[0]!.id const schedules = ['running', 'approval', 'pending', 'idle', 'hidden', 'archived'] .map((title) => database.createSchedule({ projectId, title, prompt: title, recurrence: 'daily', nextRunAt: '2027-01-01T00:00:00Z' })) const [running, approval, pending, idle, hidden, archived] = schedules for (const schedule of [running!, approval!, hidden!, archived!]) { claimManualScheduleQueueItem(database, schedule.id) } database.updateTaskStatus(approval!.taskId, 'waiting_approval') database.queueScheduleNow(pending!.id) database.updateTaskStatus(pending!.taskId, 'running') raw.prepare('UPDATE tasks SET visible = 0 WHERE id = ?').run(hidden!.taskId) raw.prepare("UPDATE conversations SET status = 'archived' WHERE id = ?") .run(archived!.conversationId) // Queued schedule tasks normally read as idle, unless a run overrides them. raw.prepare("UPDATE tasks SET status = 'queued' WHERE id = ?").run(running!.taskId) expect(database.listTasks().find((task) => task.id === pending!.taskId)?.status) .toBe('queued') const liveConversations = ['running', 'waiting_approval'].map((status) => { const id = randomUUID() database.saveLocalConversations([{ header: { id, projectId, title: status, updatedAt: 1 }, messages: [] }]) database.createTask({ id: randomUUID(), projectId, conversationId: id, title: status, instructions: '', workMode: 'execute', status: status as 'running' | 'waiting_approval' }) return id }) const streamingId = randomUUID() const streamingMessage = { id: randomUUID(), role: 'assistant' as const, content: 'Still working', state: 'streaming' as const, createdAt: 1 } database.saveLocalConversations([{ header: { id: streamingId, projectId, title: 'Streaming', updatedAt: 1 }, messages: [streamingMessage] }]) const recent = Array.from({ length: 101 }, (_, index) => ({ header: { id: randomUUID(), projectId, title: `Recent ${index}`, updatedAt: Date.UTC(2026, 1, 1) + index * 1000 }, messages: [] })) database.saveLocalConversations(recent) vi.setSystemTime(new Date('2026-02-01T00:00:00Z')) const historyTask = database.createTask({ id: randomUUID(), projectId, title: 'Recent completed task', instructions: '', workMode: 'ask', status: 'completed' }) const tasks = database.listTasks(1) expect(tasks).toHaveLength(6) expect(tasks.some(task => task.id === hidden!.taskId)).toBe(false) expect(tasks).toEqual(expect.arrayContaining([ historyTask, expect.objectContaining({ id: running!.taskId, status: 'running' }), expect.objectContaining({ id: approval!.taskId, status: 'waiting_approval' }), expect.objectContaining({ id: archived!.taskId, status: 'running' }) ])) const conversations = database.listConversations() expect(conversations).toHaveLength(109) expect(new Set(conversations.map((conversation) => conversation.id))).toEqual( new Set([ ...recent.map((conversation) => conversation.header.id), running!.conversationId, approval!.conversationId, pending!.conversationId, idle!.conversationId, hidden!.conversationId, streamingId, ...liveConversations ]) ) expect(conversations.find((conversation) => conversation.id === streamingId)) .toMatchObject({ projectId, messages: [streamingMessage] }) expect(conversations.slice(0, recent.length).map((conversation) => conversation.id)) .toEqual(recent.slice().reverse().map((conversation) => conversation.header.id)) } finally { raw.close() database.close() } }) it('keeps timed messages independent of creation-time execution settings', async () => { const database = await createDatabase() const project = database.createProject({ name: 'Scheduled project', rootPath: 'C:\\Workspace', description: '', defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } }) const schedule = database.createSchedule({ projectId: project.id, title: 'Timed message', prompt: 'Continue our work', recurrence: 'daily', nextRunAt: '2026-09-10T00:00:00Z' }) expect(schedule.runtimeSelection).toBeUndefined() const conversation = database.getConversation(schedule.conversationId) expect(conversation.runtimeSelection).toBeUndefined() expect(conversation.workMode).toBeUndefined() const { messages, ...header } = conversation database.saveLocalConversations([{ header: { ...header, workMode: 'execute', knowledgeRetrievalMode: 'always', runtimeSelection: { provider: 'continue' } }, messages }]) const existing = database.createSchedule({ projectId: project.id, conversationId: conversation.id, title: 'Second message', prompt: 'Continue', workMode: 'ask', recurrence: 'once', nextRunAt: '2026-09-11T00:00:00Z' }) expect(existing.runtimeSelection).toBeUndefined() database.close() database.initialize('C:\\Workspace') expect(database.getConversation(conversation.id)).toMatchObject({ workMode: 'execute', runtimeSelection: { provider: 'continue' }, knowledgeRetrievalMode: 'always' }) database.close() }) it('releases scheduled dispatches for retry and completes accepted ordinary runs without replay on restart', async () => { const database = await createDatabase() const schedule = database.createSchedule({ title: 'Timed message', prompt: 'Continue', recurrence: 'daily', nextRunAt: '2026-09-10T00:00:00Z' }) const item = database.queueScheduleNow(schedule.id) expect(database.claimConversationQueueItem(schedule.conversationId)?.source).toBe('schedule') expect(database.isConversationUserQueueItemDispatching(item.id)).toBe(true) database.releaseConversationUserQueueItem(item.id) expect(database.claimConversationQueueItem(schedule.conversationId)?.source).toBe('schedule') database.createTask({ id: item.scheduleRunId!, conversationId: schedule.conversationId, title: 'Continue', instructions: 'Continue', workMode: 'execute', visible: false }) database.completeConversationUserQueueItem(item.id) database.updateTaskStatus(item.scheduleRunId!, 'completed') database.completeTaskScheduleRun(item.scheduleRunId!) database.completeTaskScheduleRun(item.scheduleRunId!) database.close() database.initialize('C:\\Workspace') expect(database.listConversationQueueItems(schedule.conversationId)).toEqual([]) expect(database.listSchedules().find(candidate => candidate.id === schedule.id)?.lastRunAt).toBeDefined() database.close() }) it('does not resend an accepted scheduled message interrupted by a local restart', async () => { const database = await createDatabase() const schedule = database.createSchedule({ title: 'Timed message', prompt: 'Continue', recurrence: 'once', nextRunAt: '2026-09-10T00:00:00Z' }) const item = database.queueScheduleNow(schedule.id) database.claimConversationQueueItem(schedule.conversationId) database.createTask({ id: item.scheduleRunId!, conversationId: schedule.conversationId, title: 'Continue', instructions: 'Continue', workMode: 'execute', visible: false }) database.completeConversationUserQueueItem(item.id) database.close() database.initialize('C:\\Workspace') expect(database.listConversationQueueItems(schedule.conversationId)).toEqual([]) expect(database.listTasks().find(task => task.id === schedule.taskId)?.status).toBe('failed') database.close() }) it('rejects a newer unsupported schema without changing its version', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-assistant-future-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') initial.close() const future = new DatabaseSync(databasePath) future.exec('PRAGMA user_version = 99;') future.close() const downgraded = new AssistantDatabase(databasePath) expect(() => downgraded.initialize('C:\\Workspace')).toThrow( '不支持助理数据库版本 99' ) const unchanged = new DatabaseSync(databasePath) expect( ( unchanged.prepare('PRAGMA user_version').get() as { user_version: number } ).user_version ).toBe(99) unchanged.close() }) it('persists more than 500 tool activities in one message', async () => { const database = await createDatabase() const conversationId = '00000000-0000-4000-8000-000000000901' const messageId = '00000000-0000-4000-8000-000000000902' const tools = Array.from({ length: 501 }, (_, index) => ({ callId: `call-${index + 1}`, name: 'read', state: 'completed' as const, summary: `Tool ${index + 1}` })) database.saveLocalConversations([ { header: { id: conversationId, title: '长工具链', updatedAt: 1 }, messages: [ { id: messageId, role: 'assistant', content: '', createdAt: 1, state: 'complete', tools } ] } ]) expect( database.getConversation(conversationId)?.messages[0]?.tools ).toHaveLength(501) database.close() }) it('retains answeredQuestions through replacement, incremental saves, and reopening', async () => { const database = await createDatabase() const header = { id: '00000000-0000-4000-8000-000000000921', title: 'Answered questions', updatedAt: 1 } const customAnswer = `Custom answer: ${'x'.repeat(1_001)}\nEnd of answer.` const answeredQuestions = [ { questionId: 'question-choices', skipped: false, questions: [ { header: 'Targets', question: 'Which platforms should be supported?', options: [ { label: 'Windows', description: 'Windows desktop' }, { label: 'Linux', description: 'Linux desktop' } ], multiple: true, custom: false, answer: ['Windows', 'Linux'] }, { header: 'Details', question: 'What else should be included?', options: [], multiple: false, custom: true, answer: [customAnswer] } ] }, { questionId: 'question-skipped', skipped: true, questions: [ { header: 'Timing', question: 'When should this ship?', options: [{ label: 'Now', description: 'Ship immediately' }], multiple: false, custom: true } ] } ] const message = { id: '00000000-0000-4000-8000-000000000922', role: 'assistant' as const, content: 'Recorded answers', createdAt: 1, state: 'complete' as const, answeredQuestions } try { database.replaceConversations([{ ...header, messages: [message] }]) expect(database.getConversation(header.id).messages).toEqual([message]) database.close() database.initialize('C:\\Workspace') expect(database.getConversation(header.id).messages).toEqual([message]) const updatedMessage = { ...message, answeredQuestions: [ ...answeredQuestions, { questionId: 'question-follow-up', skipped: false, questions: [ { header: 'Confirm', question: 'Proceed with the selected platforms?', options: [{ label: 'Yes', description: 'Proceed' }], multiple: false, custom: false, answer: ['Yes'] } ] } ] } const insertedMessage = { ...message, id: '00000000-0000-4000-8000-000000000923', createdAt: 2 } database.saveLocalConversations([ { header: { ...header, updatedAt: 2 }, messages: [updatedMessage, insertedMessage] } ]) expect(database.getConversation(header.id).messages).toEqual([ updatedMessage, insertedMessage ]) database.close() database.initialize('C:\\Workspace') expect(database.getConversation(header.id).messages).toEqual([ updatedMessage, insertedMessage ]) } finally { database.close() } }) it('persists complete Run history beyond the former Renderer limits', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-activity-history-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const records = Array.from({ length: 501 }, (_, index) => ({ id: `activity-${index}`, conversationId: 'conversation-1', requestId: 'request-1', scope: { kind: 'global' as const }, kind: 'tool' as const, title: `工具调用 ${index}`, detail: index === 500 ? 'x'.repeat(4_001) : '读取文件', status: 'completed' as const, createdAt: index })) const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') initial.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: true }) initial.close() const reopened = new AssistantDatabase(databasePath) reopened.initialize('C:\\Workspace') const history = reopened.getActivityHistory() expect(history.records).toHaveLength(501) expect(history.records.at(-1)?.detail).toHaveLength(4_001) expect(history.legacyHistoryMayBeIncomplete).toBe(true) reopened.clearAssistantData() expect(reopened.getActivityHistory()).toEqual({ records: [], legacyHistoryMayBeIncomplete: false }) reopened.close() }) it('keeps the truthful truncation marker on legacy messages', async () => { const database = await createDatabase() const conversationId = '00000000-0000-4000-8000-000000000911' database.saveLocalConversations([ { header: { id: conversationId, title: '旧版截断记录', updatedAt: 1 }, messages: [ { id: '00000000-0000-4000-8000-000000000912', role: 'assistant', content: '', createdAt: 1, state: 'complete', displayCaptureTruncated: true } ] } ]) expect( database.getConversation(conversationId).messages[0] ?.displayCaptureTruncated ).toBe(true) database.close() }) it('lists projects by creation time with newer projects last', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-08-07T00:00:00.000Z')) const database = await createDatabase() const defaultProject = database.listProjects()[0]! vi.setSystemTime(new Date('2026-08-07T00:01:00.000Z')) const secondProject = database.createProject({ name: '第二项目', description: '', rootPath: 'C:\\Second', defaultWorkMode: 'ask' }) vi.setSystemTime(new Date('2026-08-07T00:02:00.000Z')) const thirdProject = database.createProject({ name: '第三项目', description: '', rootPath: 'C:\\Third', defaultWorkMode: 'execute' }) database.updateProject(secondProject.id, { name: '第二项目(已更新)', description: '', rootPath: 'C:\\Second', defaultWorkMode: 'ask' }) expect(database.listProjects().map((project) => project.id)).toEqual([ defaultProject.id, secondProject.id, thirdProject.id ]) expect( database.listProjects(true).map((project) => project.id) ).toEqual([ defaultProject.id, secondProject.id, thirdProject.id ]) database.close() }) it('migrates existing databases to the current schema version', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-assistant-migration-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') initial.close() const oldDatabase = new DatabaseSync(databasePath) oldDatabase.exec(` DROP TABLE model_usage_calls; ALTER TABLE projects DROP COLUMN runtime_selection_json; ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 3; `) oldDatabase.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') migrated.close() const current = new DatabaseSync(databasePath) expect( ( current.prepare('PRAGMA user_version').get() as { user_version: number } ).user_version ).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION) expect( current .prepare( `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'model_usage_calls'` ) .get() ).toEqual({ name: 'model_usage_calls' }) expect( current .prepare('PRAGMA table_info(projects)') .all() ).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'runtime_selection_json' }), expect.objectContaining({ name: 'built_in_default', notnull: 1, dflt_value: '0' }) ]) ) expect( current .prepare('PRAGMA table_info(conversations)') .all() ).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'branch_source_conversation_id' }), expect.objectContaining({ name: 'branch_source_title' }) ]) ) const foreignKeys = current .prepare('PRAGMA foreign_key_list(model_usage_calls)') .all() as Array<{ table: string from: string to: string on_delete: string }> expect(foreignKeys).toEqual( expect.arrayContaining([ expect.objectContaining({ table: 'tasks', from: 'request_id', to: 'id', on_delete: 'CASCADE' }) ]) ) expect( current .prepare( `SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('tasks_status_idx', 'messages_state_idx') ORDER BY name` ) .all() ).toEqual([ { name: 'messages_state_idx' }, { name: 'tasks_status_idx' } ]) expect( ( current.prepare('PRAGMA table_info(tasks)').all() as Array<{ name: string }> ).some((column) => column.name === 'visible') ).toBe(true) expect( ( current .prepare('PRAGMA table_info(magic_note_entries)') .all() as Array<{ name: string }> ).some((column) => column.name === 'image_bytes') ).toBe(true) expect( current .prepare( `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'magic_todos'` ) .get() ).toEqual({ name: 'magic_todos' }) expect( current .prepare( `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'conversation_queue_items'` ) .get() ).toEqual({ name: 'conversation_queue_items' }) expect( current .prepare( `SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'conversations_branch_source_idx'` ) .get() ).toEqual({ name: 'conversations_branch_source_idx' }) expect( current .prepare( `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'activity_history'` ) .get() ).toEqual({ name: 'activity_history' }) current.close() }) it('backfills every v26 project as a local execution space without changing project data', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-execution-space-migration-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('') const blankRootProject = initial.listProjects()[0]! const project = initial.createProject({ name: '迁移项目', description: '保留全部项目字段', rootPath: 'D:\\Migration', defaultWorkMode: 'execute', runtimeSelection: { provider: 'continue' } }) initial.close() const legacy = new DatabaseSync(databasePath) const projectRowsBefore = legacy .prepare('SELECT * FROM projects ORDER BY rowid') .all() legacy.exec(` DROP TABLE project_execution_spaces; ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 26; `) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\MustNotReplaceBlank') expect(migrated.getProject(blankRootProject.id)).toMatchObject({ id: blankRootProject.id, rootPath: '', executionSpace: { kind: 'local', rootPath: '' } }) expect(migrated.getProject(project.id)).toMatchObject({ id: project.id, name: '迁移项目', description: '保留全部项目字段', rootPath: 'D:\\Migration', defaultWorkMode: 'execute', runtimeSelection: { provider: 'continue' }, executionSpace: { kind: 'local', rootPath: 'D:\\Migration' } }) migrated.close() const inspected = new DatabaseSync(databasePath, { enableForeignKeyConstraints: true }) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION }) expect( inspected.prepare('SELECT * FROM projects ORDER BY rowid').all() ).toEqual(projectRowsBefore) expect( inspected .prepare( `SELECT project_id, kind, root_path, ssh_host_id FROM project_execution_spaces ORDER BY project_id` ) .all() ).toEqual([ { project_id: blankRootProject.id, kind: 'local', root_path: '', ssh_host_id: null }, { project_id: project.id, kind: 'local', root_path: 'D:\\Migration', ssh_host_id: null } ].sort((left, right) => left.project_id.localeCompare(right.project_id) )) expect( inspected .prepare('PRAGMA foreign_key_list(project_execution_spaces)') .all() ).toEqual([ expect.objectContaining({ table: 'projects', from: 'project_id', to: 'id', on_delete: 'CASCADE' }) ]) expect(() => inspected .prepare( `UPDATE project_execution_spaces SET ssh_host_id = ? WHERE project_id = ?` ) .run( '00000000-0000-4000-8000-000000000310', blankRootProject.id ) ).toThrow() inspected.close() }) it('preserves SSH project locations when removing legacy evidence', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-v27-ssh-migration-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') const project = initial.createSshProject( validatedSshProjectWrite({ rootPath: '/srv/legacy' }) ) initial.close() const legacy = new DatabaseSync(databasePath) legacy.exec(` ALTER TABLE project_execution_spaces ADD COLUMN workspace_identity TEXT; UPDATE project_execution_spaces SET workspace_identity = 'workspace-legacy' WHERE project_id = '${project.id}'; CREATE TABLE project_runtime_validations ( project_id TEXT PRIMARY KEY, runtime_bundle_digest TEXT NOT NULL ); ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 30; `) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') expect(migrated.getProject(project.id)).toMatchObject({ id: project.id, executionSpace: { kind: 'ssh', hostId: validatedSshHostId, remoteRootPath: '/srv/legacy' } }) migrated.close() const inspected = new DatabaseSync(databasePath) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION }) expect( inspected .prepare( `SELECT project_id, kind, root_path, ssh_host_id FROM project_execution_spaces WHERE project_id = ?` ) .get(project.id) ).toEqual({ project_id: project.id, kind: 'ssh', root_path: '/srv/legacy', ssh_host_id: validatedSshHostId }) inspected.close() }) it('migrates v30 projects while deleting installation evidence', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-v30-evidence-migration-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') const project = initial.createSshProject( validatedSshProjectWrite() ) const taskId = '00000000-0000-4000-8000-000000000399' initial.createTask({ id: taskId, projectId: project.id, title: '保留任务', instructions: '迁移后仍应存在', workMode: 'execute' }) initial.close() const legacy = new DatabaseSync(databasePath) legacy.exec(` ALTER TABLE project_execution_spaces ADD COLUMN agent_installation_id_at_validation TEXT; UPDATE project_execution_spaces SET agent_installation_id_at_validation = 'old-agent' WHERE project_id = '${project.id}'; CREATE TABLE project_runtime_validations ( project_id TEXT PRIMARY KEY, runtime_bundle_digest TEXT NOT NULL ); INSERT INTO project_runtime_validations (project_id, runtime_bundle_digest) VALUES ('${project.id}', 'sha256:${'d'.repeat(64)}'); ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 30; `) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') expect(migrated.getProject(project.id)).toMatchObject({ id: project.id, defaultWorkMode: 'execute', executionSpace: { kind: 'ssh', hostId: validatedSshHostId, remoteRootPath: '/srv/goodbuddy' } }) expect( migrated.listTasks().find((task) => task.id === taskId) ).toMatchObject({ id: taskId, projectId: project.id }) const inspected = new DatabaseSync(databasePath) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION }) expect( inspected .prepare('PRAGMA table_info(project_execution_spaces)') .all() .map((column) => column.name) ).toEqual([ 'project_id', 'kind', 'root_path', 'ssh_host_id' ]) expect( inspected .prepare( `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'project_runtime_validations'` ) .get() ).toBeUndefined() inspected.close() migrated.close() }) it('fails closed when a project execution-space row is missing', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-missing-execution-space-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const project = database.listProjects()[0]! const raw = new DatabaseSync(databasePath) raw .prepare( 'DELETE FROM project_execution_spaces WHERE project_id = ?' ) .run(project.id) raw.close() expect(() => database.getProject(project.id)).toThrow( '缺少执行空间配置' ) expect(() => database.listProjects()).toThrow( '缺少执行空间配置' ) database.close() }) it('backfills one exact legacy built-in default candidate', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-default-project-migration-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') initial.close() const legacy = new DatabaseSync(databasePath) legacy.exec(` DROP INDEX projects_built_in_default_unique; ALTER TABLE projects DROP COLUMN built_in_default; ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 24; `) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') const [project] = migrated.listProjects() expect(project).toMatchObject({ name: builtInDefaultProjectSeedName, description: builtInDefaultProjectSeedDescription, builtInDefault: true }) expect( project && isUntouchedBuiltInDefaultProject(project) ).toBe(true) migrated.close() }) it('does not backfill an ambiguous legacy default identity', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-ambiguous-default-project-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') const independent = initial.createProject({ name: builtInDefaultProjectSeedName, description: builtInDefaultProjectSeedDescription, rootPath: 'D:\\Independent', defaultWorkMode: 'ask' }) expect(independent.builtInDefault).toBe(false) expect(isUntouchedBuiltInDefaultProject(independent)).toBe(false) initial.close() const legacy = new DatabaseSync(databasePath) legacy.exec(` DROP INDEX projects_built_in_default_unique; ALTER TABLE projects DROP COLUMN built_in_default; ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 24; `) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') expect( migrated .listProjects() .map((project) => project.builtInDefault) ).toEqual([false, false]) migrated.close() }) it('does not mark a later exact clone after the original default was edited', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-edited-default-project-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') const original = initial.listProjects()[0]! initial.updateProject(original.id, { name: '已编辑默认项目', description: builtInDefaultProjectSeedDescription, rootPath: original.rootPath, defaultWorkMode: 'ask' }) const clone = initial.createProject({ name: builtInDefaultProjectSeedName, description: builtInDefaultProjectSeedDescription, rootPath: original.rootPath, defaultWorkMode: 'ask' }) initial.close() const legacy = new DatabaseSync(databasePath) legacy.exec(` DROP INDEX projects_built_in_default_unique; ALTER TABLE projects DROP COLUMN built_in_default; ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 24; `) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') expect( migrated .listProjects() .filter((project) => project.builtInDefault) ).toEqual([]) expect(migrated.getProject(clone.id).builtInDefault).toBe(false) migrated.close() }) it('does not mark a later exact clone after the original default was deleted', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-deleted-default-project-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') const original = initial.listProjects()[0]! const clone = initial.createProject({ name: builtInDefaultProjectSeedName, description: builtInDefaultProjectSeedDescription, rootPath: original.rootPath, defaultWorkMode: 'ask' }) initial.deleteProject(original.id, original.name) initial.close() const legacy = new DatabaseSync(databasePath) legacy.exec(` DROP INDEX projects_built_in_default_unique; ALTER TABLE projects DROP COLUMN built_in_default; ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 24; `) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') expect(migrated.getProject(clone.id).builtInDefault).toBe(false) migrated.close() }) it('does not backfill when no exact legacy candidate exists', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-missing-default-project-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') initial.close() const legacy = new DatabaseSync(databasePath) legacy.exec(` UPDATE projects SET updated_at = created_at || '-edited'; DROP INDEX projects_built_in_default_unique; ALTER TABLE projects DROP COLUMN built_in_default; ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 24; `) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') expect(migrated.listProjects()[0]?.builtInDefault).toBe(false) migrated.close() }) it('idempotently migrates version 5 databases to computer control audit schema', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-control-audit-migration-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') initial.close() const versionFive = new DatabaseSync(databasePath) versionFive.exec(` DROP TABLE computer_control_actions; ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 5; `) versionFive.close() for (let attempt = 0; attempt < 2; attempt += 1) { const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') migrated.close() } const current = new DatabaseSync(databasePath) expect( ( current.prepare('PRAGMA user_version').get() as { user_version: number } ).user_version ).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION) expect( current .prepare( `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'computer_control_actions'` ) .get() ).toEqual({ name: 'computer_control_actions' }) expect( current .prepare( `SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'computer_control_actions_recent_idx'` ) .get() ).toEqual({ name: 'computer_control_actions_recent_idx' }) expect( current .prepare( 'PRAGMA foreign_key_list(computer_control_actions)' ) .all() ).toEqual( expect.arrayContaining([ expect.objectContaining({ table: 'tasks', from: 'task_id', to: 'id', on_delete: 'CASCADE' }) ]) ) current.close() }) it('backfills one stable Task and Conversation for each v21 schedule', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-stable-schedule-migration-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') const project = initial.listProjects()[0]! const legacySchedule = initial.createSchedule({ projectId: project.id, title: '旧版每日报告', prompt: '生成每日报告', workMode: 'ask', recurrence: 'daily', nextRunAt: '2026-08-20T00:00:00.000Z' }) initial.close() const legacyTaskId = '00000000-0000-4000-8000-000000000221' const legacyRunId = '00000000-0000-4000-8000-000000000222' const raw = new DatabaseSync(databasePath) raw.exec('BEGIN IMMEDIATE') raw .prepare('DELETE FROM tasks WHERE id = ?') .run(legacySchedule.taskId) raw .prepare('DELETE FROM conversations WHERE id = ?') .run(legacySchedule.conversationId) raw .prepare( `INSERT INTO tasks (id, project_id, conversation_id, schedule_id, parent_task_id, expert_id, routing_mode, title, instructions, origin, status, priority, work_mode, progress, created_at, started_at, completed_at, error, visible) VALUES (?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, 'schedule', 'completed', 0, 'ask', NULL, ?, ?, ?, NULL, 1)` ) .run( legacyTaskId, project.id, `schedule:${legacySchedule.id}`, legacySchedule.title, legacySchedule.prompt, '2026-08-18T00:00:00.000Z', '2026-08-18T00:00:00.000Z', '2026-08-18T00:01:00.000Z' ) raw .prepare( `INSERT INTO schedule_runs (id, schedule_id, scheduled_for, task_id, status) VALUES (?, ?, ?, ?, 'completed')` ) .run( legacyRunId, legacySchedule.id, '2026-08-18T00:00:00.000Z', legacyTaskId ) raw.exec(` DROP INDEX IF EXISTS idx_tasks_schedule; ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 21; COMMIT; `) raw.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') const [schedule] = migrated.listSchedules(project.id) expect(schedule).toMatchObject({ id: legacySchedule.id, title: legacySchedule.title, taskId: expect.any(String), conversationId: expect.any(String) }) expect(schedule!.taskId).not.toBe(legacyTaskId) expect( migrated.getConversation(schedule!.conversationId) ).toMatchObject({ projectId: project.id, title: legacySchedule.title, messages: [] }) expect(migrated.listTasks()).toEqual([ expect.objectContaining({ id: schedule!.taskId, conversationId: schedule!.conversationId, scheduleId: schedule!.id, status: 'idle' }) ]) migrated.close() const inspected = new DatabaseSync(databasePath) expect( inspected.prepare('PRAGMA user_version').get() ).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION }) expect( inspected .prepare( `SELECT parent_task_id, visible FROM tasks WHERE id = ?` ) .get(legacyTaskId) ).toEqual({ parent_task_id: schedule!.taskId, visible: 0 }) expect( inspected .prepare( `SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_tasks_schedule'` ) .get() ).toEqual({ name: 'idx_tasks_schedule' }) inspected.close() }) it('backfills checklist todos when migrating existing magic notes', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-magic-todo-migration-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') const note = initial.createMagicNote({ title: '迁移笔记' }) initial.createMagicNoteEntry({ noteId: note.id, content: { version: 1, ops: [ { insert: '迁移待办' }, { insert: '\n', attributes: { list: 'unchecked' } } ] }, plainText: '迁移待办' }) const legacyContent = initial.getMagicNote(note.id).entries[0]!.content initial.close() const legacy = new DatabaseSync(databasePath) legacy.prepare('UPDATE magic_note_entries SET content_json = ? WHERE note_id = ?').run(JSON.stringify(legacyContent), note.id) legacy.exec(` DELETE FROM magic_todos; ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 9; `) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') expect(migrated.listMagicTodos()).toEqual([ expect.objectContaining({ noteId: note.id, source: 'note', title: '迁移待办', completed: false }) ]) migrated.close() }) it('makes existing notes global and migrates manual todos into one note', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-global-magic-notes-migration-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') const project = initial.listProjects()[0]! const note = initial.createMagicNote({ title: '原项目笔记' }) initial.close() const legacy = new DatabaseSync(databasePath) const now = '2026-08-10T00:00:00.000Z' legacy .prepare('UPDATE magic_notes SET project_id = ? WHERE id = ?') .run(project.id, note.id) legacy .prepare( `INSERT INTO magic_todos (id, project_id, note_id, entry_id, source_index, source, title, instructions, completed, comments_json, analyzed_at, revision, created_at, updated_at) VALUES (?, ?, NULL, NULL, NULL, 'manual', ?, ?, 1, '[]', NULL, 0, ?, ?)` ) .run( '00000000-0000-4000-8000-000000000099', project.id, '旧手动待办', '保留的说明', now, now ) legacy.exec(`ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 16`) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') expect(migrated.listMagicNotes()).toEqual( expect.arrayContaining([ expect.objectContaining({ id: note.id, title: '原项目笔记' }), expect.objectContaining({ title: '迁入的待办' }) ]) ) expect(migrated.listMagicTodos()).toEqual([ expect.objectContaining({ source: 'note', title: '旧手动待办', instructions: '保留的说明', completed: true, noteTitle: '迁入的待办' }) ]) migrated.close() }) it('creates a default project and persists project updates', async () => { const database = await createDatabase() const [defaultProject] = database.listProjects() expect(defaultProject).toMatchObject({ name: builtInDefaultProjectSeedName, description: builtInDefaultProjectSeedDescription, rootPath: 'C:\\Workspace', executionSpace: { kind: 'local', rootPath: 'C:\\Workspace' }, defaultWorkMode: 'ask', kind: 'user', builtInDefault: true, status: 'active' }) expect(defaultProject?.runtimeSelection).toBeUndefined() expect(defaultProject?.createdAt).toBe(defaultProject?.updatedAt) expect( defaultProject && isUntouchedBuiltInDefaultProject(defaultProject) ).toBe(true) const reconfiguredDefault = database.updateProject( defaultProject!.id, { name: builtInDefaultProjectSeedName, description: builtInDefaultProjectSeedDescription, rootPath: 'D:\\Moved', defaultWorkMode: 'execute', runtimeSelection: { provider: 'continue' } } ) expect(reconfiguredDefault.builtInDefault).toBe(true) expect( isUntouchedBuiltInDefaultProject(reconfiguredDefault) ).toBe(true) expect(database.listExperts()).toHaveLength(3) const project = database.createProject({ name: '产品发布', description: '发布资料和任务', rootPath: 'C:\\Release', defaultWorkMode: 'ask' }) expect(project.builtInDefault).toBe(false) expect(project.executionSpace).toEqual({ kind: 'local', rootPath: 'C:\\Release' }) expect(database.listProjects()).toHaveLength(2) const updated = database.updateProject(project.id, { name: '产品发布 2', description: '更新后的项目', rootPath: 'C:\\Release', defaultWorkMode: 'execute', runtimeSelection: { provider: 'continue' } }) expect(updated).toMatchObject({ name: '产品发布 2', defaultWorkMode: 'execute', executionSpace: { kind: 'local', rootPath: 'C:\\Release' }, runtimeSelection: { provider: 'continue' } }) database.setProjectArchived(project.id, true) expect(database.listProjects()).toHaveLength(1) expect(database.listProjects(true)).toEqual( expect.arrayContaining([ expect.objectContaining({ id: project.id, status: 'archived' }) ]) ) database.close() }) it('atomically creates and updates projects with local execution spaces', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-project-execution-rollback-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const project = database.createProject({ name: '原始项目', description: '原始说明', rootPath: '', defaultWorkMode: 'ask' }) expect(project).toMatchObject({ rootPath: '', executionSpace: { kind: 'local', rootPath: '' } }) const raw = new DatabaseSync(databasePath) raw.exec(` CREATE TRIGGER reject_blocked_execution_space_insert BEFORE INSERT ON project_execution_spaces WHEN NEW.root_path = 'blocked-insert' BEGIN SELECT RAISE(ABORT, 'forced execution-space insert failure'); END; CREATE TRIGGER reject_blocked_execution_space_update BEFORE UPDATE ON project_execution_spaces WHEN NEW.root_path = 'blocked-update' BEGIN SELECT RAISE(ABORT, 'forced execution-space update failure'); END; `) raw.close() expect(() => database.createProject({ name: '不能半创建', description: '', rootPath: 'blocked-insert', defaultWorkMode: 'ask' }) ).toThrow('forced execution-space insert failure') expect( database .listProjects(true) .some((candidate) => candidate.name === '不能半创建') ).toBe(false) expect(() => database.updateProject(project.id, { name: '不能半更新', description: '不能保留', rootPath: 'blocked-update', defaultWorkMode: 'execute' }) ).toThrow('forced execution-space update failure') expect(database.getProject(project.id)).toMatchObject({ name: '原始项目', description: '原始说明', rootPath: '', defaultWorkMode: 'ask', executionSpace: { kind: 'local', rootPath: '' } }) database.close() }) it('maps SSH execution spaces and lists projects referencing a host', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-ssh-project-reference-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const project = database.createProject({ name: '远程项目', description: '', rootPath: 'C:\\LegacyProjection', defaultWorkMode: 'ask', runtimeSelection: { provider: 'opencode' } }) const hostId = '00000000-0000-4000-8000-000000000311' const raw = new DatabaseSync(databasePath) raw .prepare( `UPDATE project_execution_spaces SET kind = 'ssh', root_path = ?, ssh_host_id = ? WHERE project_id = ?` ) .run( '/srv/goodbuddy', hostId, project.id ) raw.close() expect(database.getProject(project.id)).toEqual({ ...project, rootPath: '/srv/goodbuddy', id: project.id, executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/srv/goodbuddy' } }) database.setProjectArchived(project.id, true) const taskId = '00000000-0000-4000-8000-000000000313' database.createTask({ id: taskId, projectId: project.id, title: '无法连接的远程任务', instructions: '本地清理不应等待远端任务停止', workMode: 'execute' }) expect(database.listProjectsReferencingSshHost(hostId)).toEqual([ { id: project.id, name: project.name } ]) expect(database.listSshHostProjectReferences()).toEqual([ { hostId, id: project.id, name: project.name } ]) expect( database.listProjectsReferencingSshHost( '00000000-0000-4000-8000-000000000312' ) ).toEqual([]) expect( database.deleteProjectsReferencingSshHost(hostId) ).toEqual([{ id: project.id, name: project.name }]) expect( database.listProjects(true).some( (candidate) => candidate.id === project.id ) ).toBe(false) expect( database.listTasks().some((task) => task.id === taskId) ).toBe(false) expect(database.listProjects()).toHaveLength(1) database.close() }) it('keeps the final active project when deleting its Host', async () => { const database = await createDatabase() const localProject = database.listProjects()[0]! const remoteProject = database.createSshProject( validatedSshProjectWrite() ) database.setProjectArchived(localProject.id, true) expect(() => database.deleteProjectsReferencingSshHost( validatedSshHostId ) ).toThrow('至少需要保留一个可用项目') expect(database.getProject(remoteProject.id)).toMatchObject({ id: remoteProject.id, status: 'active' }) database.close() }) it('requires a local fallback before deleting an active remote project', async () => { const database = await createDatabase() const localProject = database.listProjects()[0]! const remoteProject = database.createSshProject( validatedSshProjectWrite() ) database.createSshProject( validatedSshProjectWrite({ rootPath: '/srv/other-project' }) ) database.setProjectArchived(localProject.id, true) expect(() => database.deleteProject( remoteProject.id, remoteProject.name, { allowActiveTasks: true } ) ).toThrow('至少需要保留一个可用项目') expect(database.getProject(remoteProject.id).id).toBe( remoteProject.id ) database.close() }) it('lets an SSH project without a Runtime selection follow global settings', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-invalid-ssh-project-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const project = database.listProjects()[0]! const raw = new DatabaseSync(databasePath) raw .prepare( `UPDATE project_execution_spaces SET kind = 'ssh', root_path = ?, ssh_host_id = ? WHERE project_id = ?` ) .run( '/srv/goodbuddy', '00000000-0000-4000-8000-000000000313', project.id ) raw.close() // Resolution limits remote projects to OpenCode or Continue at run time. expect(database.getProject(project.id).runtimeSelection).toBeUndefined() database.close() }) it('atomically creates a fully validated SSH project', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-validated-ssh-create-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const precondition = vi.fn() const project = database.createSshProject( validatedSshProjectWrite({ assertCurrent: precondition }) ) expect(precondition).toHaveBeenCalledWith() expect(project).toMatchObject({ name: '远程项目', rootPath: '/srv/goodbuddy', runtimeSelection: { provider: 'opencode' }, executionSpace: { kind: 'ssh', hostId: validatedSshHostId, remoteRootPath: '/srv/goodbuddy' } }) expect(project).toEqual(database.getProject(project.id)) const raw = new DatabaseSync(databasePath) expect( raw .prepare( `SELECT (SELECT COUNT(*) FROM projects WHERE id = ?) AS projects, (SELECT COUNT(*) FROM project_execution_spaces WHERE project_id = ?) AS execution_spaces` ) .get(project.id, project.id) ).toEqual({ projects: 1, execution_spaces: 1 }) raw.close() database.close() }) it('rejects mismatched SSH candidates before persisting rows', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-invalid-ssh-candidate-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const baseline = database.listProjects().length const rootMismatch = validatedSshProjectWrite() rootMismatch.executionSpace.remoteRootPath = '/srv/other' expect(() => database.createSshProject(rootMismatch) ).toThrow('目录不匹配') expect(() => database.createSshProject( validatedSshProjectWrite({ assertCurrent: () => { throw new Error('SSH Host 已变化') } }) ) ).toThrow('SSH Host 已变化') expect(database.listProjects()).toHaveLength(baseline) database.close() }) it('optimistically updates validated SSH projects without moving them', async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-08-21T01:00:00.000Z')) const database = await createDatabase() const created = database.createSshProject( validatedSshProjectWrite() ) const write = validatedSshProjectWrite() write.project.name = '已重新验证' write.project.description = '更新后的配置' const updated = database.updateSshProject( created.id, created.updatedAt, write ) expect(updated.name).toBe('已重新验证') expect(updated.updatedAt).not.toBe(created.updatedAt) expect(updated.executionSpace).toEqual({ kind: 'ssh', hostId: validatedSshHostId, remoteRootPath: '/srv/goodbuddy' }) expect(updated).toEqual(database.getProject(created.id)) const beforeRejectedPrecondition = database.getProject(created.id) const rejectedWrite = validatedSshProjectWrite({ assertCurrent: () => { throw new Error('SSH Host 已变化') } }) rejectedWrite.project.name = '不应保存' expect(() => database.updateSshProject( created.id, updated.updatedAt, rejectedWrite ) ).toThrow('SSH Host 已变化') expect(database.getProject(created.id)).toEqual( beforeRejectedPrecondition ) expect(() => database.updateSshProject( created.id, created.updatedAt, write ) ).toThrow('项目已被其他操作更新') const moved = validatedSshProjectWrite({ rootPath: '/srv/moved' }) expect(() => database.updateSshProject( created.id, updated.updatedAt, moved ) ).toThrow('执行位置不能通过此更新修改') const otherHost = validatedSshProjectWrite({ hostId: '00000000-0000-4000-8000-000000000322' }) expect(() => database.updateSshProject( created.id, updated.updatedAt, otherHost ) ).toThrow('执行位置不能通过此更新修改') const localProject = database.createProject({ name: '本地项目', description: '', rootPath: 'D:\\Local', defaultWorkMode: 'ask' }) expect(() => database.updateSshProject( localProject.id, localProject.updatedAt, validatedSshProjectWrite() ) ).toThrow('仅支持更新已有远程项目') const [channelProject] = database.ensureChannelProjects( 'C:\\Channel', channelDefaultProfileId ) expect(() => database.updateSshProject( channelProject!.id, channelProject!.updatedAt, validatedSshProjectWrite() ) ).toThrow('系统通道项目不支持远程执行空间') database.close() }) it('rolls back SSH writes when strict project mapping fails before commit', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-ssh-mapping-rollback-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const raw = new DatabaseSync(databasePath) raw.exec(` CREATE TRIGGER corrupt_created_execution_space AFTER INSERT ON project_execution_spaces WHEN NEW.kind = 'ssh' BEGIN DELETE FROM project_execution_spaces WHERE project_id = NEW.project_id; END; `) raw.close() const projectsBefore = database.listProjects().length expect(() => database.createSshProject(validatedSshProjectWrite()) ).toThrow('缺少执行空间配置') expect(database.listProjects()).toHaveLength(projectsBefore) const setup = new DatabaseSync(databasePath) setup.exec('DROP TRIGGER corrupt_created_execution_space') setup.close() const created = database.createSshProject( validatedSshProjectWrite() ) const persistedBeforeUpdate = database.getProject(created.id) const updateTrigger = new DatabaseSync(databasePath) updateTrigger.exec(` CREATE TRIGGER corrupt_updated_execution_space AFTER UPDATE ON project_execution_spaces WHEN NEW.kind = 'ssh' BEGIN DELETE FROM project_execution_spaces WHERE project_id = NEW.project_id; END; `) updateTrigger.close() const write = validatedSshProjectWrite() write.project.name = '不应提交的项目名' expect(() => database.updateSshProject( created.id, created.updatedAt, write ) ).toThrow('缺少执行空间配置') expect(database.getProject(created.id)).toEqual( persistedBeforeUpdate ) database.close() }) it('rolls back every SSH project table when a trigger rejects the write', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-ssh-atomic-rollback-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const raw = new DatabaseSync(databasePath) raw.exec(` CREATE TRIGGER reject_ssh_execution_space BEFORE INSERT ON project_execution_spaces WHEN NEW.kind = 'ssh' BEGIN SELECT RAISE(ABORT, 'test SSH rejection'); END; `) raw.close() const projectsBefore = database.listProjects().length expect(() => database.createSshProject(validatedSshProjectWrite()) ).toThrow('test SSH rejection') const inspected = new DatabaseSync(databasePath) expect( inspected.prepare('SELECT COUNT(*) AS count FROM projects').get() ).toEqual({ count: projectsBefore }) expect( inspected .prepare( `SELECT COUNT(*) AS count FROM project_execution_spaces WHERE kind = 'ssh'` ) .get() ).toEqual({ count: 0 }) inspected.close() database.close() }) it('idempotently creates protected channel projects by channel identity', async () => { const database = await createDatabase() const sameName = database.createProject({ name: '微信 ClawBot', description: '普通同名项目', rootPath: 'C:\\Ordinary', defaultWorkMode: 'execute' }) const first = database.ensureChannelProjects( 'C:\\Users\\test', channelDefaultProfileId ) const second = database.ensureChannelProjects( 'C:\\Ignored', channelDefaultProfileId ) expect(first).toEqual([ expect.objectContaining({ name: '微信 ClawBot', rootPath: 'C:\\Users\\test', executionSpace: { kind: 'local', rootPath: 'C:\\Users\\test' }, defaultWorkMode: 'ask', // The model follows global settings instead of freezing today's default. runtimeSelection: { provider: 'model' }, kind: 'channel', channel: 'weixin' }), expect.objectContaining({ kind: 'channel', channel: 'wecom' }), expect.objectContaining({ kind: 'channel', channel: 'dingtalk' }) ]) expect(second.map((project) => project.id)).toEqual( first.map((project) => project.id) ) expect(database.getProject(sameName.id)).toMatchObject({ kind: 'user', channel: undefined, rootPath: 'C:\\Ordinary' }) const weixin = first[0]! const updated = database.updateProject(weixin.id, { name: '不可重命名', description: '更新后的通道说明', rootPath: 'C:\\Remote', defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode', model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000019' } } }) expect(updated).toMatchObject({ name: '微信 ClawBot', description: '更新后的通道说明', rootPath: 'C:\\Remote', executionSpace: { kind: 'local', rootPath: 'C:\\Remote' }, defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode', model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000019' } } }) expect(() => database.updateProject(weixin.id, { name: weixin.name, description: weixin.description, rootPath: ' ', defaultWorkMode: 'execute' }) ).toThrow('通道项目必须设置默认工作目录') expect(() => database.setProjectArchived(weixin.id, true) ).toThrow('系统通道项目不能归档') expect(() => database.deleteProject(weixin.id, weixin.name) ).toThrow('系统通道项目不能删除') database.close() }) it('persists one protected remote conversation per channel identity', async () => { const database = await createDatabase() const project = database.ensureChannelProjects( 'C:\\Users\\test', channelDefaultProfileId )[0]! const first = database.getOrCreateRemoteConversation({ projectId: project.id, channel: 'weixin', accountId: 'default', externalConversationId: 'remote-user-1', conversationType: 'direct', title: '微信 ClawBot · ****0001', accountDisplay: '发送者 ****0001', runtimeSelection: { provider: 'continue' } }) const second = database.getOrCreateRemoteConversation({ projectId: project.id, channel: 'weixin', accountId: 'default', externalConversationId: 'remote-user-1', conversationType: 'direct', title: '微信 ClawBot · ****0001', accountDisplay: '发送者 ****0001', runtimeSelection: { provider: 'continue' } }) expect(second.id).toBe(first.id) database.appendRemoteConversationMessage({ conversationId: first.id, role: 'user', content: '请分析状态', attachments: [ { id: '00000000-0000-4000-8000-000000000090', name: '状态.txt', size: 12, preview: '状态', kind: 'text' } ], status: '微信 ClawBot · 对话' }) database.appendRemoteConversationMessage({ conversationId: first.id, role: 'assistant', content: '状态正常', artifactIds: [ '00000000-0000-4000-8000-000000000091' ], status: '微信 ClawBot · 已完成' }) expect(database.getConversation(first.id)).toMatchObject({ projectId: project.id, runtimeSelection: { provider: 'continue' }, remote: { channel: 'weixin', accountDisplay: '发送者 ****0001', conversationType: 'direct' }, messages: [ { role: 'user', content: '请分析状态', attachments: [ expect.objectContaining({ name: '状态.txt' }) ], status: '微信 ClawBot · 对话' }, { role: 'assistant', content: '状态正常', artifactIds: [ '00000000-0000-4000-8000-000000000091' ], status: '微信 ClawBot · 已完成' } ] }) database.replaceConversations([]) expect(database.getConversation(first.id).remote?.channel).toBe( 'weixin' ) database.close() }) it('returns all remote messages in chronological order beyond 500', () => { // Exercise the query boundary without 502 disk-synchronized fixture writes. const database = new AssistantDatabase(':memory:') database.initialize('C:\\Workspace') const project = database.ensureChannelProjects( 'C:\\Users\\test', channelDefaultProfileId )[0]! const conversation = database.getOrCreateRemoteConversation({ projectId: project.id, channel: 'weixin', accountId: 'default', externalConversationId: 'long-remote-history', conversationType: 'direct', title: '微信 ClawBot · 长对话', accountDisplay: '发送者 ****0002', runtimeSelection: { provider: 'continue' } }) for (let index = 0; index < 502; index += 1) { database.appendRemoteConversationMessage({ conversationId: conversation.id, role: index % 2 === 0 ? 'user' : 'assistant', content: `消息 ${index}` }) } const messages = database.getConversation(conversation.id).messages expect(messages).toHaveLength(502) expect(messages[0]?.content).toBe('消息 0') expect(messages.at(-1)?.content).toBe('消息 501') database.close() }) it('persists remote event deduplication and failed reply outbox state', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-channel-state-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') expect( database.claimChannelEvent('weixin', 'account-1', 'event-1') ).toBe(true) expect( database.claimChannelEvent('weixin', 'account-1', 'event-1') ).toBe(false) expect( database.claimChannelEvent('weixin', 'account-2', 'event-1') ).toBe(true) expect( database.claimChannelEvent('dingtalk', 'account-1', 'event-1') ).toBe(true) const entry = database.enqueueChannelResult({ channel: 'weixin', eventId: 'event-1', conversationId: 'conversation-1', recipientId: 'sender-1', status: 'completed', output: '已完成' }) database.markChannelResult(entry.id, 'failed') expect(database.listUndeliveredChannelResults()).toEqual([ { ...entry, state: 'failed', attempts: 1 } ]) database.markChannelResult(entry.id, 'delivered') expect(database.listUndeliveredChannelResults()).toEqual([]) database.close() const reopened = new AssistantDatabase(databasePath) reopened.initialize('C:\\Workspace') expect( reopened.claimChannelEvent('weixin', 'account-1', 'event-1') ).toBe(false) reopened.close() }) it('keeps exhausted channel results terminal and observable', async () => { const database = await createDatabase() const entry = database.enqueueChannelResult({ channel: 'weixin', eventId: 'terminal-event', conversationId: 'conversation-1', recipientId: 'sender-1', status: 'completed', output: '已完成', attachments: [ { name: 'result.txt', mimeType: 'text/plain', size: 1, kind: 'file', dataBase64: 'eA==' } ] }) for (let attempt = 0; attempt < 5; attempt += 1) { database.markChannelResult(entry.id, 'failed') } const terminal = database.listUndeliveredChannelResults() expect(terminal).toEqual([ expect.objectContaining({ id: entry.id, state: 'terminal', attempts: 5, message: expect.objectContaining({ eventId: 'terminal-event', output: '已完成' }) }) ]) expect(terminal[0]?.message).not.toHaveProperty('attachments') database.close() }) it('migrates exhausted legacy outbox failures to terminal state', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-channel-terminal-migration-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') const entry = initial.enqueueChannelResult({ channel: 'weixin', eventId: 'legacy-terminal-event', conversationId: 'conversation-1', recipientId: 'sender-1', status: 'completed', output: '已完成', attachments: [ { name: 'legacy.txt', mimeType: 'text/plain', size: 1, kind: 'file', dataBase64: 'eA==' } ] }) initial.close() const legacy = new DatabaseSync(databasePath) legacy .prepare( `UPDATE channel_outbox SET state = 'failed', attempts = 5 WHERE id = ?` ) .run(entry.id) legacy.exec(`ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 23`) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') const terminal = migrated.listUndeliveredChannelResults() expect(terminal).toEqual([ expect.objectContaining({ id: entry.id, state: 'terminal', attempts: 5 }) ]) expect(terminal[0]?.message).not.toHaveProperty('attachments') migrated.close() }) it('rolls back both heartbeat failure updates atomically', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-heartbeat-rollback-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const config = database.createHeartbeatConfig( { scope: { kind: 'global' }, name: '事务心跳', timezone: 'UTC', recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 24, retentionDays: 30 }, new Date('2026-08-16T00:00:00.000Z') ) const claim = database.claimHeartbeatNow( config.id, 'heartbeat-rollback', 'test-owner', new Date('2026-08-16T01:00:00.000Z') ) const raw = new DatabaseSync(databasePath) raw.exec(` CREATE TRIGGER reject_heartbeat_config_failure BEFORE UPDATE OF last_status ON heartbeat_configs BEGIN SELECT RAISE(ABORT, 'forced config update failure'); END; `) raw.close() expect(() => database.failHeartbeatRun( claim, 'runtime failed', new Date('2026-08-16T01:01:00.000Z') ) ).toThrow('forced config update failure') expect(database.getHeartbeatRun(claim.run.id)).toMatchObject({ status: 'claimed', attemptCount: 1, completedAt: undefined, error: undefined }) expect(database.getHeartbeatConfig(config.id).lastStatus).toBe( 'claimed' ) database.close() }) it('renews only a live heartbeat lease with matching ownership and attempt', async () => { const database = await createDatabase() const start = new Date('2026-08-16T01:00:00.000Z') const config = database.createHeartbeatConfig({ scope: { kind: 'global' }, name: 'Lease', timezone: 'UTC', recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 24, retentionDays: 30 }, start) const claim = database.claimHeartbeatNow(config.id, 'first', 'owner', start, 60_000) const admission = new Date(start.getTime() + 30_000) expect(() => database.renewHeartbeatLease({ ...claim, leaseOwner: 'other' }, 300_000, admission)).toThrow('no longer active') expect(() => database.renewHeartbeatLease({ ...claim, run: { ...claim.run, attemptCount: 2 } }, 300_000, admission)).toThrow('no longer active') database.renewHeartbeatLease(claim, 300_000, admission) expect(database.claimHeartbeatNow(config.id, 'second', 'other', new Date(start.getTime() + 329_999)).acquired).toBe(false) const expired = new Date(start.getTime() + 330_000) expect(() => database.renewHeartbeatLease(claim, 300_000, expired)).toThrow('no longer active') const [retry] = database.claimDueHeartbeats('owner', expired) expect(retry?.run.attemptCount).toBe(2) expect(() => database.renewHeartbeatLease(claim, 300_000, expired)).toThrow('no longer active') database.renewHeartbeatLease(retry!, 300_000, expired) database.completeHeartbeatRun(retry!, { summary: 'Report', highlights: [], proposedMemories: [], followUpTasks: [] }, expired) expect(() => database.renewHeartbeatLease(retry!, 300_000, expired)).toThrow('no longer active') database.close() }) it('rejects heartbeat failure after its lease expires', async () => { const database = await createDatabase() const config = database.createHeartbeatConfig( { scope: { kind: 'global' }, name: '租约过期心跳', timezone: 'UTC', recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 24, retentionDays: 30 }, new Date('2026-08-16T00:00:00.000Z') ) const claim = database.claimHeartbeatNow( config.id, 'expired-heartbeat', 'expired-owner', new Date('2026-08-16T01:00:00.000Z'), 60_000 ) expect(() => database.failHeartbeatRun( claim, 'late worker failure', new Date('2026-08-16T01:01:00.001Z') ) ).toThrow('Heartbeat lease is no longer active') expect(database.getHeartbeatRun(claim.run.id)).toMatchObject({ status: 'claimed', completedAt: undefined, error: undefined }) expect(database.getHeartbeatConfig(config.id).lastStatus).toBe( 'claimed' ) database.close() }) it('preserves legacy channel event claims while adding account identity', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-channel-event-migration-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') initial.close() const legacy = new DatabaseSync(databasePath) legacy.exec(` DROP TABLE channel_events; CREATE TABLE channel_events ( channel TEXT NOT NULL, event_id TEXT NOT NULL, claimed_at INTEGER NOT NULL, PRIMARY KEY(channel, event_id) ); CREATE INDEX channel_events_claimed_at ON channel_events(claimed_at); INSERT INTO channel_events(channel, event_id, claimed_at) VALUES ('weixin', 'legacy-event', 1); ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 18; `) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') expect( migrated.claimChannelEvent( 'weixin', 'default', 'legacy-event' ) ).toBe(false) expect( migrated.claimChannelEvent( 'weixin', 'new-account', 'legacy-event' ) ).toBe(true) migrated.close() }) it('safely deletes a confirmed project and its scoped data', async () => { const database = await createDatabase() const project = database.createProject({ name: '待删除项目', description: '删除测试', rootPath: 'C:\\Delete', defaultWorkMode: 'execute' }) const conversationId = '00000000-0000-4000-8000-000000000111' const taskId = '00000000-0000-4000-8000-000000000211' database.replaceConversations([ { id: conversationId, projectId: project.id, title: '项目对话', updatedAt: Date.now(), messages: [] } ]) database.createTask({ id: taskId, projectId: project.id, conversationId, title: '项目任务', instructions: '执行任务', workMode: 'execute' }) database.createTextArtifact({ projectId: project.id, taskId, title: '项目成果', content: '内容' }) database.createMemory({ scope: 'project', scopeId: project.id, type: 'fact', content: '项目记忆' }) database.createSchedule({ projectId: project.id, title: '项目计划', prompt: '执行计划', workMode: 'ask', recurrence: 'daily', nextRunAt: '2026-08-08T00:00:00.000Z' }) expect(() => database.deleteProject(project.id, project.name) ).toThrow('项目仍有进行中的任务') database.updateTaskStatus(taskId, 'completed') expect(() => database.deleteProject(project.id, '错误名称') ).toThrow('项目名称确认不匹配') database.deleteProject(project.id, project.name) expect( database.listProjects(true).some((item) => item.id === project.id) ).toBe(false) expect( database.listConversations().some( (conversation) => conversation.projectId === project.id ) ).toBe(false) expect( database.listTasks().some((task) => task.projectId === project.id) ).toBe(false) expect(database.listArtifacts(project.id)).toEqual([]) expect(database.listSchedules(project.id)).toEqual([]) expect( database .listMemories(project.id) .some((memory) => memory.scopeId === project.id) ).toBe(false) expect(database.listProjects()).toHaveLength(1) database.close() }) it('does not delete the final active project', async () => { const database = await createDatabase() const project = database.listProjects()[0]! expect(() => database.deleteProject(project.id, project.name) ).toThrow('至少需要保留一个可用项目') expect(database.listProjects()).toHaveLength(1) database.close() }) it('creates, updates, and soft-deletes expert roles', async () => { const database = await createDatabase() const expert = database.createExpert({ name: '代码审查专家', description: '检查代码正确性', systemInstructions: 'Review code for actionable bugs.', routingKeywords: [' CODE ', 'code', '代码审查'] }) expect(expert.routingKeywords).toEqual(['code', '代码审查']) const updated = database.updateExpert(expert.id, { name: '高级代码审查专家', description: '检查正确性和安全性', systemInstructions: 'Review correctness and security risks.', routingKeywords: ['security', '安全审查'] }) expect(updated).toMatchObject({ id: expert.id, name: '高级代码审查专家', description: '检查正确性和安全性', systemInstructions: 'Review correctness and security risks.', routingKeywords: ['security', '安全审查'], enabled: true }) database.removeExpert(expert.id) expect( database.listExperts().some((item) => item.id === expert.id) ).toBe(false) expect(() => database.getExpert(expert.id)).toThrow( '专家不存在或已停用' ) database.close() }) it('roundtrips expert model profiles and tolerates malformed model policies', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-expert-model-policy-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const firstModelProfileId = '00000000-0000-4000-8000-000000000401' const secondModelProfileId = '00000000-0000-4000-8000-000000000402' const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const expert = database.createExpert({ name: '模型绑定专家', description: '验证模型策略持久化', systemInstructions: 'Use the assigned model connection.', modelProfileId: firstModelProfileId, routingKeywords: ['模型绑定'] }) expect(expert.modelProfileId).toBe(firstModelProfileId) expect( database.listExperts().find((item) => item.id === expert.id) ).toMatchObject({ modelProfileId: firstModelProfileId, routingKeywords: ['模型绑定'] }) const updated = database.updateExpert(expert.id, { name: expert.name, description: expert.description, systemInstructions: expert.systemInstructions, modelProfileId: secondModelProfileId, routingKeywords: expert.routingKeywords }) expect(updated.modelProfileId).toBe(secondModelProfileId) database.close() const persisted = new DatabaseSync(databasePath) expect( JSON.parse( ( persisted .prepare( 'SELECT model_policy_json FROM experts WHERE id = ?' ) .get(expert.id) as { model_policy_json: string } ).model_policy_json ) ).toEqual({ modelProfileId: secondModelProfileId }) expect( ( persisted.prepare('PRAGMA table_info(experts)').all() as Array<{ name: string }> ).some((column) => column.name === 'model_profile_id') ).toBe(false) persisted .prepare( 'UPDATE experts SET model_policy_json = ? WHERE id = ?' ) .run('{malformed-json', expert.id) persisted.close() const reopened = new AssistantDatabase(databasePath) reopened.initialize('C:\\Workspace') const recoveredExpert = reopened .listExperts() .find((item) => item.id === expert.id) reopened.close() expect(recoveredExpert).toMatchObject({ id: expert.id, routingKeywords: ['模型绑定'] }) expect(recoveredExpert?.modelProfileId).toBeUndefined() }) it('persists task lifecycle and events', async () => { const database = await createDatabase() const project = database.listProjects()[0]! const taskId = '00000000-0000-4000-8000-000000000201' database.createTask({ id: taskId, projectId: project.id, conversationId: 'conversation-1', title: '整理发布说明', instructions: '根据本次变更整理说明', workMode: 'execute' }) expect(database.listTasks()[0]).toMatchObject({ id: taskId, status: 'running', projectId: project.id }) const expert = database.listExperts()[0]! const childTaskId = '00000000-0000-4000-8000-000000000202' database.createTask({ id: childTaskId, projectId: project.id, conversationId: 'conversation-1', parentTaskId: taskId, expertId: expert.id, routingMode: 'smart', title: '研究子任务', instructions: '只读分析', workMode: 'ask', origin: 'subagent', status: 'queued' }) expect(database.listTasks()[0]).toMatchObject({ id: childTaskId, parentTaskId: taskId, expertId: expert.id, routingMode: 'smart', status: 'queued' }) database.updateTaskStatus(taskId, 'waiting_approval') expect( database.listTasks().find((task) => task.id === taskId) ).toMatchObject({ status: 'waiting_approval' }) database.updateTaskStatus(taskId, 'completed') expect( database.listTasks().find((task) => task.id === taskId) ).toMatchObject({ status: 'completed', completedAt: expect.any(String) }) const memory = database.createMemory({ scope: 'project', scopeId: project.id, type: 'preference', content: '使用简洁中文回复' }) expect(database.listMemories(project.id)).toEqual([ expect.objectContaining({ id: memory.id, status: 'confirmed', content: '使用简洁中文回复' }) ]) database.removeMemory(memory.id) expect(database.listMemories(project.id)).toEqual([]) const schedule = database.createSchedule({ projectId: project.id, title: '每日摘要', prompt: '总结今天的任务状态', workMode: 'ask', recurrence: 'daily', nextRunAt: '2026-07-31T00:00:00.000Z' }) expect(schedule).toMatchObject({ projectId: project.id, workMode: 'ask', taskId: expect.any(String), conversationId: expect.any(String) }) expect( database.listTasks().find((task) => task.id === schedule.taskId) ).toMatchObject({ conversationId: schedule.conversationId, scheduleId: schedule.id, origin: 'schedule', status: 'idle' }) expect(database.getConversation(schedule.conversationId)).toMatchObject({ id: schedule.conversationId, projectId: project.id, title: '每日摘要', messages: [] }) const [claim] = claimQueuedSchedules( database, new Date('2026-07-31T00:01:00.000Z') ) expect(claim?.schedule).toEqual( expect.objectContaining({ id: schedule.id }) ) expect(database.listSchedules(project.id)[0]).toMatchObject({ id: schedule.id, nextRunAt: '2026-07-31T00:00:00.000Z', lastRunAt: undefined }) database.completeScheduleRun( claim!.runId, 'completed', new Date('2026-07-31T00:01:00.000Z') ) expect(database.listSchedules(project.id)[0]).toMatchObject({ id: schedule.id, nextRunAt: '2026-08-01T00:00:00.000Z', lastRunAt: '2026-07-31T00:01:00.000Z' }) expect( database.listTasks().find((task) => task.id === schedule.taskId) ).toMatchObject({ status: 'idle', completedAt: undefined }) const manualClaim = claimManualScheduleQueueItem( database, schedule.id ) expect(manualClaim.schedule).toMatchObject({ taskId: schedule.taskId, conversationId: schedule.conversationId }) expect(() => claimManualScheduleQueueItem(database, schedule.id) ).toThrow( '已有一次运行正在进行' ) database.completeScheduleRun( manualClaim.runId, 'completed', new Date('2026-07-31T00:02:00.000Z') ) database.appendConversationMessage({ conversationId: schedule.conversationId, role: 'assistant', content: '今日任务状态正常', status: '定时任务', task: { id: schedule.taskId, title: schedule.title } }) expect( database.getConversation(schedule.conversationId).messages ).toEqual([ expect.objectContaining({ content: '今日任务状态正常', task: { id: schedule.taskId, title: schedule.title } }) ]) const sharedConversationSchedule = database.createSchedule({ projectId: project.id, conversationId: schedule.conversationId, title: '每周复盘', prompt: '复盘本周任务', workMode: 'execute', recurrence: 'weekly', nextRunAt: '2027-01-01T00:00:00.000Z' }) expect(sharedConversationSchedule.conversationId).toBe( schedule.conversationId ) expect( database .listTasks() .filter( (task) => task.conversationId === schedule.conversationId && task.origin === 'schedule' ) ).toHaveLength(2) const countsBeforeFailedCreate = { schedules: database.listSchedules().length, tasks: database.listTasks().length, conversations: database.listConversations().length } expect(() => database.createSchedule({ projectId: project.id, conversationId: '00000000-0000-4000-8000-000000000299', title: '不应创建', prompt: '无效对话', workMode: 'execute', recurrence: 'once', nextRunAt: '2027-01-02T00:00:00.000Z' }) ).toThrow('所选对话不存在或不可用于任务') expect({ schedules: database.listSchedules().length, tasks: database.listTasks().length, conversations: database.listConversations().length }).toEqual(countsBeforeFailedCreate) const overdue = database.createSchedule({ projectId: project.id, title: '过期摘要', prompt: '总结任务状态', workMode: 'ask', recurrence: 'daily', nextRunAt: '2025-07-31T00:00:00.000Z' }) const [overdueClaim] = claimQueuedSchedules( database, new Date('2026-07-31T00:01:00.000Z') ) database.completeScheduleRun( overdueClaim!.runId, 'completed', new Date('2026-07-31T00:01:00.000Z') ) expect( database .listSchedules(project.id) .find((item) => item.id === overdue.id) ).toMatchObject({ nextRunAt: '2026-08-01T00:00:00.000Z' }) database.removeSchedule(sharedConversationSchedule.id) expect( database .listSchedules(project.id) .some((item) => item.id === sharedConversationSchedule.id) ).toBe(false) expect( database .listTasks() .find((task) => task.id === sharedConversationSchedule.taskId) ).toMatchObject({ conversationId: schedule.conversationId, scheduleId: undefined, status: 'completed', workMode: 'execute' }) database.close() }) it('migrates task event provenance without changing ordinary events', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-task-event-migration-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const taskId = '00000000-0000-4000-8000-000000000205' const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') initial.createTask({ id: taskId, title: '迁移任务事件', instructions: '保留普通事件', workMode: 'ask', status: 'completed' }) initial.appendTaskEvent(taskId, 'output', { text: '原有事件' }) initial.close() const legacy = new DatabaseSync(databasePath) legacy.exec(` DROP INDEX tasks_remote_recovery_idx; ALTER TABLE tasks DROP COLUMN current_assistant_message_id; ALTER TABLE tasks DROP COLUMN current_user_message_id; ALTER TABLE tasks DROP COLUMN remote_recoverable; DROP INDEX task_events_remote_provenance_unique; ALTER TABLE task_events DROP COLUMN remote_event_index; ALTER TABLE task_events DROP COLUMN remote_semantic_sequence; ALTER TABLE task_events DROP COLUMN remote_operation_id; ALTER TABLE task_events DROP COLUMN remote_binding_id; ALTER TABLE conversations DROP COLUMN pinned; ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; DROP TABLE activity_history_records; ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json; DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update; DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete; DROP VIEW IF EXISTS supervision_review_current; DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches; DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs; DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision; ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 31; `) legacy.close() const migrated = new AssistantDatabase(databasePath) migrated.initialize('C:\\Workspace') migrated.close() const inspected = new DatabaseSync(databasePath) expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION }) expect( inspected.prepare('PRAGMA table_info(task_events)').all() ).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'remote_binding_id' }), expect.objectContaining({ name: 'remote_operation_id' }), expect.objectContaining({ name: 'remote_semantic_sequence' }), expect.objectContaining({ name: 'remote_event_index' }) ]) ) expect( inspected.prepare('PRAGMA table_info(tasks)').all() ).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'remote_recoverable', notnull: 1, dflt_value: '0' }), expect.objectContaining({ name: 'current_user_message_id' }), expect.objectContaining({ name: 'current_assistant_message_id' }) ]) ) expect( inspected .prepare( `SELECT kind, payload_json, remote_binding_id, remote_operation_id, remote_semantic_sequence, remote_event_index FROM task_events WHERE task_id = ? AND kind = 'output'` ) .get(taskId) ).toEqual({ kind: 'output', payload_json: '{"text":"原有事件"}', remote_binding_id: null, remote_operation_id: null, remote_semantic_sequence: null, remote_event_index: null }) expect( inspected .prepare( `SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'task_events_remote_provenance_unique'` ) .get() ).toEqual({ name: 'task_events_remote_provenance_unique' }) inspected.close() }) it('persists remote task events idempotently across reopen', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-remote-task-event-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const taskId = '00000000-0000-4000-8000-000000000206' const event = { taskId, bindingId: 'binding-remote-events', operationId: 'operation-remote-events', semanticSequence: '1', eventIndex: 0, kind: 'output', payload: { text: '只保存一次' } } const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') initial.createTask({ id: taskId, title: '远程事件任务', instructions: '验证幂等落库', workMode: 'execute', status: 'completed' }) expect(initial.appendRemoteTaskEventOnce(event)).toBe(true) expect(initial.appendRemoteTaskEventOnce(event)).toBe(false) expect( initial.getHighestCommittedRemoteTaskEventSequence( event.bindingId, event.operationId ) ).toBe('1') expect(() => initial.appendRemoteTaskEventOnce({ ...event, payload: { text: '冲突内容' } }) ).toThrow('provenance conflicts') initial.close() const reopened = new AssistantDatabase(databasePath) reopened.initialize('C:\\Workspace') expect(reopened.appendRemoteTaskEventOnce(event)).toBe(false) expect( reopened.getHighestCommittedRemoteTaskEventSequence( event.bindingId, event.operationId ) ).toBe('1') reopened.appendTaskEvent(taskId, 'ordinary', { local: true }) reopened.close() const inspected = new DatabaseSync(databasePath) expect( inspected .prepare( `SELECT COUNT(*) AS count FROM task_events WHERE remote_binding_id = ? AND remote_operation_id = ?` ) .get(event.bindingId, event.operationId) ).toEqual({ count: 1 }) expect( inspected .prepare( `SELECT remote_binding_id, remote_operation_id, remote_semantic_sequence, remote_event_index FROM task_events WHERE task_id = ? AND kind = 'ordinary'` ) .get(taskId) ).toEqual({ remote_binding_id: null, remote_operation_id: null, remote_semantic_sequence: null, remote_event_index: null }) inspected.close() const cleaned = new AssistantDatabase(databasePath) cleaned.initialize('C:\\Workspace') cleaned.clearAssistantData() expect( cleaned.getHighestCommittedRemoteTaskEventSequence( event.bindingId, event.operationId ) ).toBe('0') cleaned.close() }) it('atomically commits ordered remote task event batches', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-remote-task-event-batch-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const taskId = '00000000-0000-4000-8000-000000000207' const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') database.createTask({ id: taskId, title: '远程事件批次', instructions: '验证批次事务', workMode: 'execute', status: 'completed' }) const provenance = { taskId, bindingId: 'binding-event-batch', operationId: 'operation-event-batch' } const batch = [ { ...provenance, semanticSequence: '1', eventIndex: 0, kind: 'reasoning', payload: { text: '分析' } }, { ...provenance, semanticSequence: '1', eventIndex: 1, kind: 'output', payload: { text: '第一段' } }, { ...provenance, semanticSequence: '2', eventIndex: 0, kind: 'output', payload: { text: '第二段' } } ] expect(database.appendRemoteTaskEventsBatch(batch)).toBe('2') expect(database.appendRemoteTaskEventsBatch(batch)).toBe('2') expect( database.getHighestCommittedRemoteTaskEventSequence( provenance.bindingId, provenance.operationId ) ).toBe('2') database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '4', eventIndex: 0, kind: 'output', payload: { text: '已保存的第四段' } }) expect(() => database.appendRemoteTaskEventsBatch([ { ...provenance, semanticSequence: '3', eventIndex: 0, kind: 'output', payload: { text: '必须回滚的第三段' } }, { ...provenance, semanticSequence: '4', eventIndex: 0, kind: 'output', payload: { text: '冲突的第四段' } } ]) ).toThrow('provenance conflicts') expect(() => database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '01', eventIndex: 0, kind: 'output', payload: {} }) ).toThrow() database.close() const inspected = new DatabaseSync(databasePath) expect( inspected .prepare( `SELECT remote_semantic_sequence, remote_event_index FROM task_events WHERE remote_binding_id = ? AND remote_operation_id = ? ORDER BY CAST(remote_semantic_sequence AS INTEGER), remote_event_index` ) .all(provenance.bindingId, provenance.operationId) ).toEqual([ { remote_semantic_sequence: '1', remote_event_index: 0 }, { remote_semantic_sequence: '1', remote_event_index: 1 }, { remote_semantic_sequence: '2', remote_event_index: 0 }, { remote_semantic_sequence: '4', remote_event_index: 0 } ]) inspected.close() }) it('derives remote recovery evidence from semantic order across a restart', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-tool-recovery-')) temporaryDirectories.push(directory) const path = join(directory, 'assistant.sqlite') let database = new AssistantDatabase(path) database.initialize('C:\\Workspace') const taskId = '00000000-0000-4000-8000-000000000702' database.createTask({ id: taskId, title: 'Recovery', instructions: 'Read', workMode: 'execute' }) const append = (sequence: string, kind: string, payload: object) => database.appendRemoteTaskEventOnce({ taskId, bindingId: 'tool-recovery', operationId: taskId, semanticSequence: sequence, eventIndex: 0, kind, payload }) expect(database.hasRemoteResponseTextAfterToolFailure(taskId)).toBe(false) append('1', 'text', { delta: 'Before failure' }) append('2', 'tool', { state: 'failed', callId: 'read' }) append('3', 'reasoning', { delta: 'Still thinking' }) append('4', 'text', { delta: '\n\t\u3000' }) expect(database.hasRemoteResponseTextAfterToolFailure(taskId)).toBe(false) append('10', 'text', { delta: 'Handled the failure' }) // Late insertion of an older event must not change semantic ordering. append('9', 'tool', { state: 'failed', callId: 'read-again' }) expect(database.hasRemoteResponseTextAfterToolFailure(taskId)).toBe(true) database.close() database = new AssistantDatabase(path) database.initialize('C:\\Workspace') expect(database.hasRemoteResponseTextAfterToolFailure(taskId)).toBe(true) append('11', 'tool', { state: 'recoverable', callId: 'read' }) expect(database.hasRemoteResponseTextAfterToolFailure(taskId)).toBe(true) append('12', 'tool', { state: 'failed', callId: 'third-read' }) expect(database.hasRemoteResponseTextAfterToolFailure(taskId)).toBe(false) expect(database.hasRemoteResponseTextAfterToolFailure('00000000-0000-4000-8000-000000000703')).toBe(false) database.close() }) it('persists inherited remote metrics and replay without pinning the conversation', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-inherited-remote-metrics-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') let database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const project = database.createSshProject(validatedSshProjectWrite()) const conversationId = '00000000-0000-4000-8000-000000000701' const taskId = '00000000-0000-4000-8000-000000000702' const assistantMessageId = '00000000-0000-4000-8000-000000000704' database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: '继承', updatedAt: 0 }, messages: [] }]) database.createTask({ id: taskId, projectId: project.id, conversationId, title: '继承', instructions: '回复', workMode: 'ask', remoteRecovery: { recoverable: true, currentUserMessageId: '00000000-0000-4000-8000-000000000703', currentAssistantMessageId: assistantMessageId } }) const provenance = { taskId, conversationId, assistantMessageId, bindingId: 'inherited-metrics', operationId: 'inherited-metrics', eventIndex: 0 } const metrics = { ...provenance, semanticSequence: '1', event: { requestId: taskId, type: 'context-metrics' as const, contextTokens: 123, source: 'provider' as const, effectiveTriggerTokens: 1000, compressionEnabled: false } } expect(database.appendRemoteConversationTaskEventOnce(metrics)).toBe(true) expect(database.getConversation(conversationId)).toMatchObject({ contextMetrics: { runtimeSelectionKey: agentRuntimeSelectionKey({ provider: 'opencode' }), contextTokens: 123 } }) database.close() database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') expect(database.appendRemoteConversationTaskEventOnce(metrics)).toBe(false) const runtimeSelection = { provider: 'opencode' as const, profileId: channelDefaultProfileId } expect(database.appendRemoteConversationTaskEventOnce({ ...metrics, semanticSequence: '2', runtimeSelection })).toBe(true) expect(database.getConversation(conversationId).contextMetrics?.runtimeSelectionKey) .toBe(agentRuntimeSelectionKey(runtimeSelection)) expect(database.appendRemoteConversationTaskEventOnce({ ...provenance, semanticSequence: '3', runtimeSelection, event: { requestId: taskId, type: 'context-compression', state: 'completed', scope: 'conversation', estimatedBeforeTokens: 123, estimatedAfterTokens: 45, effectiveTriggerTokens: 1000, recentRawTokens: 45 } })).toBe(true) expect(database.getConversation(conversationId)).toMatchObject({ contextMetrics: { runtimeSelectionKey: agentRuntimeSelectionKey(runtimeSelection), contextTokens: 45, source: 'estimated', basis: 'conversation' } }) expect(database.getConversation(conversationId).runtimeSelection).toBeUndefined() database.close() const raw = new DatabaseSync(databasePath) expect(raw.prepare('SELECT runtime_selection_json FROM conversations WHERE id = ?').get(conversationId)) .toEqual({ runtime_selection_json: null }) raw.close() }) it.each(['failed', 'cancelled'] as const)('creates and recovers a remote-authoritative conversation task, then ends it as %s', async (terminalStatus) => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-remote-conversation-recovery-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const project = database.createSshProject( validatedSshProjectWrite() ) const conversationId = '00000000-0000-4000-8000-000000000701' const taskId = '00000000-0000-4000-8000-000000000702' const userMessageId = '00000000-0000-4000-8000-000000000703' const assistantMessageId = '00000000-0000-4000-8000-000000000704' database.saveLocalConversations([ { header: { id: conversationId, projectId: project.id, runtimeSelection: { provider: 'opencode' }, title: '远程恢复对话', updatedAt: Date.now() }, messages: [] } ]) database.createTask({ id: taskId, projectId: project.id, conversationId, title: '远程恢复', instructions: '恢复这次回答', workMode: 'execute', remoteRecovery: { recoverable: true, currentUserMessageId: userMessageId, currentAssistantMessageId: assistantMessageId } }) expect(database.getConversation(conversationId).messages).toEqual([ expect.objectContaining({ id: userMessageId, role: 'user', content: '恢复这次回答', state: 'complete' }), expect.objectContaining({ id: assistantMessageId, role: 'assistant', content: '', blocks: [], state: 'streaming' }) ]) expect(database.listRecoverableRemoteTasks()).toEqual([ { taskId, projectId: project.id, conversationId, currentUserMessageId: userMessageId, currentAssistantMessageId: assistantMessageId, instructions: '恢复这次回答', workMode: 'execute', status: 'running' } ]) const provenance = { taskId, bindingId: 'binding-conversation-recovery', operationId: 'operation-conversation-recovery', conversationId, assistantMessageId } const append = ( semanticSequence: string, eventIndex: number, event: Parameters< AssistantDatabase['appendRemoteConversationTaskEventOnce'] >[0]['event'] ): boolean => database.appendRemoteConversationTaskEventOnce({ ...provenance, semanticSequence, eventIndex, event }) const recoveredText = `${'x'.repeat(1_000_001)} recovered tail` const textEvent = { requestId: taskId, type: 'text' as const, delta: recoveredText } expect(append('1', 0, textEvent)).toBe(true) expect(append('1', 0, textEvent)).toBe(false) expect( database.getConversation(conversationId).messages[1]?.content ).toBe(recoveredText) expect(() => append('1', 0, { requestId: taskId, type: 'text', delta: '冲突' }) ).toThrow('provenance conflicts') expect( database.getConversation(conversationId).messages[1]?.content ).toBe(recoveredText) append('2', 0, { requestId: taskId, type: 'reasoning', delta: '先验证状态' }) append('3', 0, { requestId: taskId, type: 'tool', callId: 'call-1', name: 'read', state: 'running', summary: '读取状态' }) append('4', 0, { requestId: taskId, type: 'subagent', childTaskId: '00000000-0000-4000-8000-000000000705', expertId: '00000000-0000-4000-8000-000000000706', expertName: '恢复专家', routingMode: 'native', runtimeCallId: 'call-1', state: 'completed', output: '已检查' }) append('5', 0, { requestId: taskId, type: 'artifact', artifactId: '00000000-0000-4000-8000-000000000707', kind: 'image', title: '恢复图片' }) append('6', 0, { requestId: taskId, type: 'knowledge-retrieval', mode: 'always', state: 'succeeded', libraryCount: 1, resultCount: 1, usedChannels: ['fts'], warnings: [] }) append('7', 0, { requestId: taskId, type: 'source-references', references: [ { libraryId: '00000000-0000-4000-8000-000000000708', libraryName: '恢复知识', documentId: '00000000-0000-4000-8000-000000000709', documentName: '恢复说明.md', sourceName: '恢复目录', snippet: '可以继续恢复。', rank: 1, retrievalChannels: ['fts'] }, ...['first', 'second', 'first'].map(remoteChunkId => ({ libraryId: '00000000-0000-4000-8000-000000000708', libraryName: 'External', documentName: 'Remote', sourceName: 'Remote', snippet: 'same excerpt', rank: 2, external: { kind: 'external' as const, provider: 'dify' as const, instanceId: 'instance', remoteKnowledgeBaseId: 'dataset', remoteChunkId } })) ] }) const checklistEvent = { requestId: taskId, type: 'checklist' as const, checklist: { source: 'opencode' as const, items: [{ content: 'task', status: 'in_progress' as const }] } } expect(() => append('7', 1, { ...checklistEvent, checklist: { source: 'invalid' as never, items: [] } })).toThrow() expect(append('7', 1, checklistEvent)).toBe(true) expect(append('7', 1, checklistEvent)).toBe(false) expect(database.getConversation(conversationId).messages[1]?.runtimeChecklist).toEqual(checklistEvent.checklist) expect(append('7', 2, { ...checklistEvent, checklist: { source: 'opencode', items: [] } })).toBe(true) append('8', 0, { requestId: taskId, type: 'done' }) append('8', 1, { requestId: taskId, type: 'remote-semantic-checkpoint' }) const recoveredMessage = database.getConversation(conversationId).messages[1] expect( database.listTasks().find((task) => task.id === taskId)?.status ).toBe('completed') expect(database.listRecoverableRemoteTasks()).toEqual([]) expect(recoveredMessage).toMatchObject({ content: recoveredText, reasoning: '先验证状态', runtimeChecklist: { source: 'opencode', items: [] }, state: 'complete', artifactIds: [ '00000000-0000-4000-8000-000000000707' ], knowledgeRetrieval: { state: 'succeeded', resultCount: 1 }, sourceReferences: [ expect.objectContaining({ documentName: '恢复说明.md' }), expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'first' }) }), expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'second' }) }) ], subagents: [ expect.objectContaining({ childTaskId: '00000000-0000-4000-8000-000000000705', state: 'completed' }) ], blocks: [ expect.objectContaining({ type: 'text', content: recoveredText }), expect.objectContaining({ type: 'reasoning', content: '先验证状态' }), expect.objectContaining({ type: 'subagent', childTaskId: '00000000-0000-4000-8000-000000000705' }) ] }) database.saveLocalConversations([ { header: { id: conversationId, projectId: project.id, runtimeSelection: { provider: 'opencode' }, title: '远程恢复对话', updatedAt: Date.now() + 1 }, messages: [ { id: userMessageId, role: 'user', content: '恢复这次回答', createdAt: Date.now(), state: 'complete', attachments: [ { id: '00000000-0000-4000-8000-000000000710', name: '补充.txt', size: 2, preview: '补充', kind: 'text' } ] }, { id: assistantMessageId, role: 'assistant', content: '过期的渲染进程内容', createdAt: Date.now(), state: 'streaming' } ] } ]) expect(database.getConversation(conversationId).messages).toEqual([ expect.objectContaining({ id: userMessageId, attachments: [ expect.objectContaining({ name: '补充.txt' }) ] }), expect.objectContaining({ id: assistantMessageId, content: recoveredText, state: 'complete' }) ]) database.updateTaskStatus(taskId, 'running') database.close() const reopened = new AssistantDatabase(databasePath) reopened.initialize('C:\\Workspace') expect(reopened.listRecoverableRemoteTasks()).toEqual([ expect.objectContaining({ taskId, status: 'running' }) ]) expect( reopened.getConversation(conversationId).messages[1] ).toMatchObject({ id: assistantMessageId, content: recoveredText, state: 'complete' }) reopened.endRecoverableRemoteTask( taskId, '远端请求已不存在', terminalStatus ) expect( reopened.listTasks().find((task) => task.id === taskId) ).toMatchObject({ status: terminalStatus, error: '远端请求已不存在' }) expect( reopened.getConversation(conversationId).messages[1] ).toMatchObject({ id: assistantMessageId, content: recoveredText, state: 'error', terminalStatus, runtimeChecklist: { source: 'opencode', items: [] }, status: '远端请求已不存在' }) expect(reopened.listRecoverableRemoteTasks()).toEqual([]) reopened.close() }) it('returns every recoverable remote task beyond one hundred', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-many-remote-recoveries-') ) temporaryDirectories.push(directory) const database = new AssistantDatabase( join(directory, 'assistant.sqlite') ) database.initialize('C:\\Workspace') const project = database.createSshProject( validatedSshProjectWrite() ) const conversationId = '00000000-0000-4000-8000-000000000801' database.saveLocalConversations([ { header: { id: conversationId, projectId: project.id, runtimeSelection: { provider: 'opencode' }, title: '批量远程恢复', updatedAt: Date.now() }, messages: [] } ]) const id = (value: number): string => `00000000-0000-4000-8000-${String(value).padStart(12, '0')}` for (let index = 1; index <= 101; index += 1) { database.createTask({ id: id(index), projectId: project.id, conversationId, title: `远程恢复 ${index}`, instructions: `恢复回答 ${index}`, workMode: 'execute', remoteRecovery: { recoverable: true, currentUserMessageId: id(index + 200), currentAssistantMessageId: id(index + 400) } }) } const tasks = database.listRecoverableRemoteTasks() expect(tasks).toHaveLength(101) expect(tasks[0]?.taskId).toBe(id(1)) expect(tasks.at(-1)?.taskId).toBe(id(101)) database.close() }) it.each([false, true])('keeps remote questions in arrival order through replay and reopen (legacy=%s)', async legacy => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-question-order-')) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') try { const project = database.createSshProject(validatedSshProjectWrite()) const conversationId = randomUUID() const taskId = randomUUID() const assistantMessageId = randomUUID() database.saveLocalConversations([{ header: { id: conversationId, projectId: project.id, title: 'Question order', updatedAt: Date.now() }, messages: [] }]) database.createTask({ id: taskId, projectId: project.id, conversationId, title: 'Question order', instructions: 'Ask then acknowledge', workMode: 'execute', remoteRecovery: { recoverable: true, currentUserMessageId: randomUUID(), currentAssistantMessageId: assistantMessageId } }) if (legacy) { const raw = new DatabaseSync(databasePath) try { raw.prepare("UPDATE messages SET metadata_json = json_remove(metadata_json, '$.blocks') WHERE id = ?").run(assistantMessageId) } finally { raw.close() } } const append = (sequence: string, event: Parameters[0]['event']) => database.appendRemoteConversationTaskEventOnce({ taskId, conversationId, assistantMessageId, bindingId: 'question-binding', operationId: 'question-operation', semanticSequence: sequence, eventIndex: 0, event }) const question = { requestId: taskId, type: 'question' as const, questionId: 'first-question', questions: [{ header: 'Choice', question: 'Choose one', options: [{ label: 'Yes', description: 'Continue' }], multiple: false, custom: false }] } expect(append('1', question)).toBe(true) const initialBlocks = database.getConversation(conversationId).messages[1]!.blocks expect(initialBlocks).toEqual(legacy ? undefined : [expect.objectContaining({ type: 'question', questionId: question.questionId })]) expect(append('1', question)).toBe(false) expect(append('2', { requestId: taskId, type: 'text', delta: 'After first. ' })).toBe(true) expect(append('3', { requestId: taskId, type: 'tool', callId: 'read-1', name: 'read', state: 'running', summary: 'Read fixture' })).toBe(true) expect(database.recordRemoteTaskQuestionArrival(taskId, 'second-question')).toBe(true) expect(database.recordRemoteTaskQuestionArrival(taskId, 'second-question')).toBe(true) expect(append('4', { ...question, questionId: 'second-question' })).toBe(true) // A fresh semantic envelope for the same live question must not move its marker. expect(append('5', question)).toBe(true) expect(append('6', { requestId: taskId, type: 'text', delta: 'After second.' })).toBe(true) const answer = { questionId: question.questionId, skipped: false, questions: question.questions.map(item => ({ ...item, answer: ['Yes'] })) } expect(database.recordRemoteTaskQuestionAnswer(taskId, answer)).toBe(true) expect(database.recordRemoteTaskQuestionAnswer(taskId, answer)).toBe(true) expect(database.recordRemoteTaskQuestionAnswer(taskId, { questionId: 'second-question', skipped: true, questions: question.questions })).toBe(true) const beforeTerminal = database.getConversation(conversationId).messages[1]! expect(beforeTerminal.blocks?.map(block => block.type)).toEqual(legacy ? undefined : ['question', 'text', 'tool', 'question', 'text']) if (!legacy) expect(beforeTerminal.blocks?.[0]).toEqual(initialBlocks?.[0]) expect(append('7', { requestId: taskId, type: 'done' })).toBe(true) const completed = database.getConversation(conversationId).messages[1]! expect(completed.answeredQuestions).toHaveLength(2) expect(completed.content).toBe('After first. After second.') expect(completed.state).toBe('complete') expect(completed.blocks?.filter(block => block.type === 'question')).toEqual(beforeTerminal.blocks?.filter(block => block.type === 'question')) database.close() database.initialize('C:\\Workspace') expect(append('1', question)).toBe(false) expect(database.getConversation(conversationId).messages[1]).toEqual(completed) } finally { database.close() } }) it.each(['failed', 'cancelled'] as const)('rolls back and durably recovers a %s event without creating reply text', async (terminalStatus) => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-remote-event-atomic-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const project = database.createSshProject( validatedSshProjectWrite() ) const conversationId = '00000000-0000-4000-8000-000000000711' const taskId = '00000000-0000-4000-8000-000000000712' const assistantMessageId = '00000000-0000-4000-8000-000000000714' database.saveLocalConversations([ { header: { id: conversationId, projectId: project.id, runtimeSelection: { provider: 'opencode' }, title: '原子恢复', updatedAt: Date.now() }, messages: [] } ]) database.createTask({ id: taskId, projectId: project.id, conversationId, title: '原子恢复', instructions: '不能半写入', workMode: 'execute', remoteRecovery: { recoverable: true, currentUserMessageId: '00000000-0000-4000-8000-000000000713', currentAssistantMessageId: assistantMessageId } }) const raw = new DatabaseSync(databasePath) raw.exec(` CREATE TRIGGER reject_recovered_message_update BEFORE UPDATE ON messages WHEN NEW.id = '${assistantMessageId}' BEGIN SELECT RAISE(ABORT, 'forced recovered message failure'); END; `) raw.close() expect(() => database.appendRemoteConversationTaskEventOnce({ taskId, bindingId: 'binding-atomic-recovery', operationId: 'operation-atomic-recovery', semanticSequence: '1', eventIndex: 0, conversationId, assistantMessageId, event: { requestId: taskId, type: 'error', status: terminalStatus, message: '远程失败' } }) ).toThrow('forced recovered message failure') database.close() const inspected = new DatabaseSync(databasePath) expect( inspected .prepare( `SELECT COUNT(*) AS count FROM task_events WHERE remote_binding_id = 'binding-atomic-recovery'` ) .get() ).toEqual({ count: 0 }) expect( inspected .prepare( 'SELECT content, state FROM messages WHERE id = ?' ) .get(assistantMessageId) ).toEqual({ content: '', state: 'streaming' }) inspected.exec('DROP TRIGGER reject_recovered_message_update') inspected.close() const recovered = new AssistantDatabase(databasePath) recovered.initialize('C:\\Workspace') expect( recovered.getConversation(conversationId).messages[1] ).toMatchObject({ id: assistantMessageId, content: '', state: 'streaming' }) expect( recovered.appendRemoteConversationTaskEventOnce({ taskId, bindingId: 'binding-atomic-recovery', operationId: 'operation-atomic-recovery', semanticSequence: '1', eventIndex: 0, conversationId, assistantMessageId, event: { requestId: taskId, type: 'error', status: terminalStatus, message: '远程失败' } }) ).toBe(true) expect( recovered.appendRemoteConversationTaskEventOnce({ taskId, bindingId: 'binding-atomic-recovery', operationId: 'operation-atomic-recovery', semanticSequence: '1', eventIndex: 1, conversationId, assistantMessageId, event: { requestId: taskId, type: 'remote-semantic-checkpoint' } }) ).toBe(true) expect( recovered.getConversation(conversationId).messages[1] ).toMatchObject({ content: '', state: 'error', terminalStatus, status: '远程失败' }) expect( recovered.listTasks().find((task) => task.id === taskId) ).toMatchObject({ status: terminalStatus, error: '远程失败' }) recovered.close() const reopened = new AssistantDatabase(databasePath) reopened.initialize('C:\\Workspace') const message = reopened.getConversation(conversationId).messages[1] expect(message).toMatchObject({ content: '', state: 'error', terminalStatus, status: '远程失败' }) expect(message?.blocks?.some(block => block.type === 'text')).toBe(false) reopened.close() }) it('persists and arbitrates a FIFO conversation input queue', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-conversation-queue-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') const project = initial.listProjects()[0]! const conversationId = '00000000-0000-4000-8000-000000000901' initial.replaceConversations([ { id: conversationId, projectId: project.id, title: '排队对话', updatedAt: Date.now(), messages: [] } ]) for (const [payload, hasAttachments] of [ [{ prompt: 'text' }, false], [{ input: { attachments: [] } }, false], [{ input: { attachments: [{ id: 'attachment' }] } }, true], [{ attachments: [{ id: 'attachment' }] }, true] ] as const) { const queued = initial.enqueueConversationUserInput({ conversationId, label: 'Attachment visibility', payloadJson: JSON.stringify(payload) }) expect(queued.hasAttachments).toBe(hasAttachments) expect(initial.listConversationQueueItems(conversationId)[0]?.hasAttachments).toBe(hasAttachments) initial.cancelConversationQueueItem(queued.id) } const first = initial.enqueueConversationUserInput({ conversationId, label: '第一条消息', payloadJson: JSON.stringify({ prompt: '第一条消息' }) }) const second = initial.enqueueConversationUserInput({ conversationId, label: '第二条消息', payloadJson: JSON.stringify({ prompt: '第二条消息' }) }) expect( initial .listConversationQueueItems(conversationId) .map((item) => item.id) ).toEqual([first.id, second.id]) const preferred = initial.claimConversationQueueItem( conversationId, second.id ) expect(preferred).toMatchObject({ source: 'user', item: { id: second.id, source: 'user' }, payloadJson: JSON.stringify({ prompt: '第二条消息' }) }) expect(initial.listConversationQueueItems(conversationId)).toEqual([ expect.objectContaining({ id: first.id }) ]) initial.completeConversationUserQueueItem(second.id) const firstClaim = initial.claimConversationQueueItem(conversationId) expect(firstClaim).toMatchObject({ source: 'user', item: { id: first.id } }) initial.close() const recovered = new AssistantDatabase(databasePath) recovered.initialize('C:\\Workspace') expect( recovered.listConversationQueueItems(conversationId) ).toEqual([ expect.objectContaining({ id: first.id, source: 'user', label: '第一条消息' }) ]) recovered.cancelConversationQueueItem(first.id) expect( recovered.listConversationQueueItems(conversationId) ).toEqual([]) recovered.close() }) it('does not replay a dispatched user queue item whose message was already persisted', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-conversation-queue-recovery-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') const project = initial.listProjects()[0]! const conversationId = '00000000-0000-4000-8000-000000000911' const userMessageId = '00000000-0000-4000-8000-000000000912' const assistantMessageId = '00000000-0000-4000-8000-000000000913' initial.replaceConversations([ { id: conversationId, projectId: project.id, title: '崩溃恢复对话', updatedAt: 1, messages: [] } ]) const queued = initial.enqueueConversationUserInput({ conversationId, label: '只发送一次', payloadJson: JSON.stringify({ prompt: '只发送一次' }) }) expect( initial.claimConversationQueueItem(conversationId, queued.id) ).toMatchObject({ source: 'user', item: { id: queued.id } }) initial.saveLocalConversations([ { header: { id: conversationId, projectId: project.id, title: '崩溃恢复对话', updatedAt: 2 }, messages: [ { id: userMessageId, queueItemId: queued.id, role: 'user', content: '只发送一次', createdAt: 2, state: 'complete' }, { id: assistantMessageId, role: 'assistant', content: '', createdAt: 3, state: 'streaming' } ] } ]) initial.close() const recovered = new AssistantDatabase(databasePath) recovered.initialize('C:\\Workspace') expect( recovered.listConversationQueueItems(conversationId) ).toEqual([]) expect(recovered.getConversation(conversationId).messages).toEqual([ expect.objectContaining({ id: userMessageId, queueItemId: queued.id, role: 'user', content: '只发送一次' }), expect.objectContaining({ id: assistantMessageId, role: 'assistant', state: 'error', status: '上次运行意外中断,可以重新发送问题' }) ]) recovered.close() }) it('materializes due and manual schedule runs in the conversation queue', async () => { const database = await createDatabase() const schedule = database.createSchedule({ title: '排队提醒', prompt: '检查排队结果', workMode: 'execute', recurrence: 'daily', nextRunAt: '2026-08-20T09:00:00.000Z' }) const taskStatus = (): string | undefined => database.listTasks().find((task) => task.id === schedule.taskId)?.status expect(taskStatus()).toBe('idle') const [dueItem] = database.queueDueSchedules( new Date('2026-08-20T09:01:00.000Z') ) expect(dueItem).toMatchObject({ conversationId: schedule.conversationId, source: 'schedule', scheduleId: schedule.id, taskId: schedule.taskId }) expect(taskStatus()).toBe('queued') expect(() => database.queueScheduleNow(schedule.id)).toThrow( '定时任务已有一次运行正在进行' ) expect(database.listConversationQueueItems()).toEqual([ expect.objectContaining({ id: dueItem!.id }) ]) expect( database.listPendingScheduleQueueConversationIds() ).toEqual([schedule.conversationId]) expect(database.listPendingConversationQueueIds()).toEqual([ schedule.conversationId ]) const claimed = database.claimConversationQueueItem( schedule.conversationId ) expect(claimed).toMatchObject({ source: 'schedule', item: { id: dueItem!.id }, schedule: { id: schedule.id }, runId: dueItem!.id }) expect(taskStatus()).toBe('running') database.updateTaskStatus(schedule.taskId, 'waiting_approval') expect(taskStatus()).toBe('waiting_approval') database.updateTaskStatus(schedule.taskId, 'running') expect(taskStatus()).toBe('running') database.completeScheduleRun( dueItem!.id, 'completed', new Date('2026-08-20T09:02:00.000Z') ) expect(taskStatus()).toBe('idle') database.setScheduleEnabled(schedule.id, false) expect(taskStatus()).toBe('paused') const manualItem = database.queueScheduleNow(schedule.id) expect(taskStatus()).toBe('queued') expect(manualItem).toMatchObject({ source: 'schedule', scheduleId: schedule.id }) database.cancelConversationQueueItem(manualItem.id) expect(taskStatus()).toBe('paused') expect(database.listConversationQueueItems()).toEqual([]) database.close() }) it('recovers a claimed schedule without swallowing its occurrence', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-schedule-recovery-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') const schedule = initial.createSchedule({ title: '一次提醒', prompt: '提醒我检查结果', workMode: 'ask', recurrence: 'once', nextRunAt: '2026-08-13T00:00:00.000Z' }) const [claimed] = claimQueuedSchedules( initial, new Date('2026-08-13T00:01:00.000Z') ) expect(claimed?.schedule.id).toBe(schedule.id) initial.close() const recovered = new AssistantDatabase(databasePath) recovered.initialize('C:\\Workspace') const [reclaimed] = claimQueuedSchedules( recovered, new Date('2026-08-13T00:02:00.000Z') ) expect(reclaimed).toMatchObject({ runId: claimed!.runId, schedule: { id: schedule.id, enabled: true, nextRunAt: '2026-08-13T00:00:00.000Z' } }) recovered.completeScheduleRun( reclaimed!.runId, 'completed', new Date('2026-08-13T00:02:00.000Z') ) expect(recovered.listSchedules()[0]).toMatchObject({ id: schedule.id, enabled: false, lastRunAt: '2026-08-13T00:02:00.000Z' }) expect(() => recovered.setScheduleEnabled(schedule.id, true) ).toThrow('已执行的一次性计划不能恢复自动运行') recovered.close() }) it('claims a bounded batch of independent due schedules', async () => { const database = await createDatabase() const scheduleIds = Array.from({ length: 3 }, (_, index) => database.createSchedule({ title: `批量任务 ${index + 1}`, prompt: `执行批量任务 ${index + 1}`, workMode: 'execute', recurrence: 'once', nextRunAt: '2026-08-13T00:00:00.000Z' }).id ) const firstBatch = claimQueuedSchedules( database, new Date('2026-08-13T00:01:00.000Z'), 2 ) expect(firstBatch).toHaveLength(2) expect( new Set(firstBatch.map((claim) => claim.schedule.id)).size ).toBe(2) const secondBatch = claimQueuedSchedules( database, new Date('2026-08-13T00:01:00.000Z'), 2 ) expect(secondBatch).toHaveLength(1) expect(scheduleIds).toContain(secondBatch[0]?.schedule.id) for (const claim of [...firstBatch, ...secondBatch]) { database.completeScheduleRun(claim.runId, 'completed') } database.close() }) it('durably interrupts active tasks with completion times and audit events on startup', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-assistant-recovery-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const runningTaskId = '00000000-0000-4000-8000-000000000202' const approvalTaskId = '00000000-0000-4000-8000-000000000203' const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') initial.createTask({ id: runningTaskId, title: '运行中的任务', instructions: '等待启动恢复', workMode: 'execute' }) initial.createTask({ id: approvalTaskId, title: '等待审批的任务', instructions: '等待启动恢复', workMode: 'execute' }) initial.updateTaskStatus(approvalTaskId, 'waiting_approval') initial.close() const recovered = new AssistantDatabase(databasePath) recovered.initialize('C:\\Workspace') const recoveredTasks = recovered .listTasks() .filter((task) => [runningTaskId, approvalTaskId].includes(task.id) ) expect(recoveredTasks).toHaveLength(2) expect(recoveredTasks).toEqual( expect.arrayContaining([ expect.objectContaining({ id: runningTaskId, status: 'interrupted', completedAt: expect.any(String), error: '应用退出时任务仍在运行' }), expect.objectContaining({ id: approvalTaskId, status: 'interrupted', completedAt: expect.any(String), error: '应用退出时任务仍在运行' }) ]) ) recovered.close() const reopenedAgain = new AssistantDatabase(databasePath) reopenedAgain.initialize('C:\\Workspace') expect( reopenedAgain .listTasks() .filter((task) => [runningTaskId, approvalTaskId].includes(task.id) ) ).toEqual(recoveredTasks) reopenedAgain.close() const durable = new DatabaseSync(databasePath) const statusEvents = durable .prepare( `SELECT task_id, payload_json FROM task_events WHERE task_id IN (?, ?) AND kind = 'status' ORDER BY task_id, id` ) .all(runningTaskId, approvalTaskId) as Array<{ task_id: string payload_json: string }> const recoveryEvents = statusEvents .map((event) => ({ taskId: event.task_id, payload: JSON.parse(event.payload_json) as { status: string error?: string } })) .filter((event) => event.payload.status === 'interrupted') expect(recoveryEvents).toEqual([ { taskId: runningTaskId, payload: { status: 'interrupted', error: '应用退出时任务仍在运行' } }, { taskId: approvalTaskId, payload: { status: 'interrupted', error: '应用退出时任务仍在运行' } } ]) durable.close() }) it('replaces and restores bounded conversation snapshots', async () => { const database = await createDatabase() const project = database.listProjects()[0]! const conversationId = '00000000-0000-4000-8000-000000000211' database.replaceConversations([ { id: conversationId, projectId: project.id, runtimeSelection: { provider: 'model', model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000299' } }, knowledgeLibraryIds: [ '00000000-0000-4000-8000-000000000214' ], knowledgeRetrievalMode: 'always', title: '发布讨论', updatedAt: 1_775_000_000_000, messages: [ { id: '00000000-0000-4000-8000-000000000212', role: 'user', content: '整理发布说明', createdAt: 1_775_000_000_000, state: 'complete', attachments: [ { id: '00000000-0000-4000-8000-000000000220', name: '发布清单.md', size: 2_048, preview: '发布前检查项', kind: 'text' }, { id: '00000000-0000-4000-8000-000000000221', name: '发布页面.png', size: 4_096, preview: '1280 × 720', kind: 'image', thumbnailUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB' } ] }, { id: '00000000-0000-4000-8000-000000000213', role: 'assistant', content: '处理中', reasoning: '先分析发布范围', blocks: [ { id: '00000000-0000-4000-8000-000000000217', type: 'reasoning', content: '先分析发布范围' }, { id: '00000000-0000-4000-8000-000000000218', type: 'tool', tool: { callId: 'call-1', name: 'read', state: 'running', summary: 'OpenCode 工具:read' } }, { id: '00000000-0000-4000-8000-000000000219', type: 'text', content: '处理中' } ], createdAt: 1_775_000_001_000, state: 'streaming', contextCompression: { state: 'completed', scope: 'conversation', estimatedBeforeTokens: 22_000, estimatedAfterTokens: 9_000 }, artifactIds: [ '00000000-0000-4000-8000-000000000216' ], imageContextNotice: 'editing-unavailable', sourceReferences: [ { libraryId: '00000000-0000-4000-8000-000000000214', libraryName: '产品知识', documentId: '00000000-0000-4000-8000-000000000215', documentName: '发布说明.md', sourceName: '发布目录', snippet: '发布前需要完成验证。', rank: -0.03, retrievalChannels: ['fts', 'vector'] } ], knowledgeRetrieval: { mode: 'always', state: 'succeeded', libraryCount: 1, resultCount: 1, durationMs: 42, usedChannels: ['fts', 'vector'], warnings: [] } } ] } ]) expect(database.listConversations()).toEqual([ expect.objectContaining({ id: conversationId, projectId: project.id, runtimeSelection: { provider: 'model', model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000299' } }, knowledgeLibraryIds: [ '00000000-0000-4000-8000-000000000214' ], knowledgeRetrievalMode: 'always', messages: [ expect.objectContaining({ role: 'user', state: 'complete', attachments: [ expect.objectContaining({ name: '发布清单.md', kind: 'text' }), expect.objectContaining({ name: '发布页面.png', kind: 'image', thumbnailUrl: expect.stringContaining( 'data:image/png;base64,' ) }) ] }), expect.objectContaining({ role: 'assistant', state: 'streaming', reasoning: '先分析发布范围', imageContextNotice: 'editing-unavailable', contextCompression: { state: 'completed', scope: 'conversation', estimatedBeforeTokens: 22_000, estimatedAfterTokens: 9_000 }, blocks: [ expect.objectContaining({ type: 'reasoning', content: '先分析发布范围' }), expect.objectContaining({ type: 'tool', tool: expect.objectContaining({ callId: 'call-1', state: 'running' }) }), expect.objectContaining({ type: 'text', content: '处理中' }) ], artifactIds: [ '00000000-0000-4000-8000-000000000216' ], sourceReferences: [ expect.objectContaining({ documentName: '发布说明.md', retrievalChannels: ['fts', 'vector'] }) ], knowledgeRetrieval: { mode: 'always', state: 'succeeded', libraryCount: 1, resultCount: 1, durationMs: 42, usedChannels: ['fts', 'vector'], warnings: [] } }) ] }) ]) database.replaceConversations([]) expect(database.listConversations()).toEqual([]) database.close() }) it('incrementally saves local changes without replacing unrelated or remote data', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-incremental-conversations-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const project = database.listProjects()[0]! const conversationId = '00000000-0000-4000-8000-000000000501' const unrelatedId = '00000000-0000-4000-8000-000000000502' const streamingMessageId = '00000000-0000-4000-8000-000000000503' const newMessageId = '00000000-0000-4000-8000-000000000504' database.replaceConversations([ { id: conversationId, projectId: project.id, title: '增量对话', updatedAt: 1_775_000_000_000, messages: [ { id: streamingMessageId, role: 'assistant', content: '生成中', createdAt: 1_775_000_000_001, state: 'streaming', status: '正在生成' } ] }, { id: unrelatedId, title: '不相关本地对话', updatedAt: 1_775_000_000_002, messages: [] } ]) const channelProject = database.ensureChannelProjects( 'C:\\Users\\test', channelDefaultProfileId )[0]! const remote = database.getOrCreateRemoteConversation({ projectId: channelProject.id, channel: 'weixin', accountId: 'default', externalConversationId: 'incremental-preserved', conversationType: 'direct', title: '保留的远程对话', accountDisplay: '发送者 ****0501' }) const raw = new DatabaseSync(databasePath) raw .prepare('UPDATE messages SET request_id = ? WHERE id = ?') .run('preserved-request-id', streamingMessageId) raw.close() const save = [ { header: { id: conversationId, projectId: project.id, contextMetrics: { runtimeSelectionKey: `model:${channelDefaultProfileId}`, contextTokens: 9_000, source: 'estimated' as const, basis: 'conversation' as const }, contextCompressionState: { coveredHistoryDigest: 'a'.repeat(64), coveredMessageCount: 2, coveredFromMessageId: '00000000-0000-4000-8000-000000000503', coveredThroughMessageId: '00000000-0000-4000-8000-000000000504', summary: '持久化摘要' }, title: '增量对话(已完成)', updatedAt: 1_775_000_001_000 }, messages: [ { id: streamingMessageId, role: 'assistant' as const, content: '生成完成', createdAt: 1_775_000_000_001, state: 'complete' as const, status: '已完成', tools: [ { callId: 'remote-tool-call', name: '远程工具', state: 'completed' as const, summary: '完整远程工具结果', output: '远程输出'.repeat(10_000) } ], subagents: [ { childTaskId: '00000000-0000-4000-8000-000000000513', expertId: '00000000-0000-4000-8000-000000000514', expertName: 'explorer', routingMode: 'native' as const, runtimeCallId: 'call-task-1', state: 'completed' as const, reason: 'Review application architecture', output: 'Architecture review complete.' } ], contextCompressions: [ { state: 'completed' as const, scope: 'agent-run' as const, estimatedBeforeTokens: 24_000, estimatedAfterTokens: 11_000, compressionCount: 2 }, { state: 'completed' as const, scope: 'conversation' as const, estimatedBeforeTokens: 22_000, estimatedAfterTokens: 9_000 } ] }, { id: newMessageId, role: 'user' as const, content: '继续', createdAt: 1_775_000_001_000, state: 'complete' as const } ] } ] database.saveLocalConversations(save) database.saveLocalConversations(save) expect(database.getConversation(conversationId)).toMatchObject({ title: '增量对话(已完成)', contextMetrics: { contextTokens: 9_000, source: 'estimated', basis: 'conversation' }, contextCompressionState: { coveredHistoryDigest: 'a'.repeat(64), coveredMessageCount: 2, coveredFromMessageId: '00000000-0000-4000-8000-000000000503', coveredThroughMessageId: '00000000-0000-4000-8000-000000000504', summary: '持久化摘要' }, messages: [ { id: streamingMessageId, content: '生成完成', state: 'complete', status: '已完成', tools: [ { callId: 'remote-tool-call', name: '远程工具', state: 'completed', summary: '完整远程工具结果', output: '远程输出'.repeat(10_000) } ], subagents: [ { childTaskId: '00000000-0000-4000-8000-000000000513', expertId: '00000000-0000-4000-8000-000000000514', expertName: 'explorer', routingMode: 'native', runtimeCallId: 'call-task-1', state: 'completed', reason: 'Review application architecture', output: 'Architecture review complete.' } ], contextCompressions: [ { state: 'completed', scope: 'agent-run', estimatedBeforeTokens: 24_000, estimatedAfterTokens: 11_000, compressionCount: 2 }, { state: 'completed', scope: 'conversation', estimatedBeforeTokens: 22_000, estimatedAfterTokens: 9_000 } ] }, { id: newMessageId, content: '继续', state: 'complete' } ] }) expect(database.getConversation(unrelatedId).title).toBe( '不相关本地对话' ) expect(database.getConversation(remote.id).remote?.channel).toBe( 'weixin' ) const durable = new DatabaseSync(databasePath) const contextStateRow = durable .prepare( `SELECT context_state_json FROM conversations WHERE id = ?` ) .get(conversationId) as { context_state_json: string } const contextState = JSON.parse( contextStateRow.context_state_json ) as { contextMetrics?: unknown } expect(contextState.contextMetrics).toEqual({ runtimeSelectionKey: `model:${channelDefaultProfileId}`, contextTokens: 9_000, source: 'estimated', basis: 'conversation' }) expect( durable .prepare( `SELECT id, sequence, request_id FROM messages WHERE conversation_id = ? ORDER BY sequence` ) .all(conversationId) ).toEqual([ { id: streamingMessageId, sequence: 0, request_id: 'preserved-request-id' }, { id: newMessageId, sequence: 1, request_id: null } ]) durable .prepare( `UPDATE conversations SET context_state_json = ? WHERE id = ?` ) .run( JSON.stringify({ contextMetrics: { runtimeSelectionKey: `model:${channelDefaultProfileId}`, contextTokens: 9_000, effectiveTriggerTokens: 20_000, contextWindowTokens: 32_000, compressionEnabled: true, source: 'estimated', basis: 'conversation' } }), conversationId ) expect(database.getConversation(conversationId).contextMetrics).toEqual({ runtimeSelectionKey: `model:${channelDefaultProfileId}`, contextTokens: 9_000, source: 'estimated', basis: 'conversation' }) durable.close() database.close() }) it('atomically branches a stable local conversation without cloning owned work', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-branched-conversation-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const project = database.listProjects()[0]! const sourceConversationId = '00000000-0000-4000-8000-000000000541' const sourceMessageIds = [ '00000000-0000-4000-8000-000000000542', '00000000-0000-4000-8000-000000000543' ] database.replaceConversations([ { id: sourceConversationId, projectId: project.id, runtimeSelection: { provider: 'model', model: { kind: 'profile', profileId: channelDefaultProfileId } }, knowledgeLibraryIds: [ '00000000-0000-4000-8000-000000000559' ], knowledgeRetrievalMode: 'always', contextMetrics: { runtimeSelectionKey: `model:${channelDefaultProfileId}`, contextTokens: 4_096, source: 'estimated', basis: 'conversation' }, contextCompressionState: { coveredHistoryDigest: 'b'.repeat(64), coveredMessageCount: 2, coveredFromMessageId: sourceMessageIds[0], coveredThroughMessageId: sourceMessageIds[1], summary: '来源会话摘要' }, title: '发布方案讨论', updatedAt: 1_775_000_000_000, messages: [ { id: sourceMessageIds[0]!, queueItemId: '00000000-0000-4000-8000-000000000551', role: 'user', content: '比较两个发布方案', createdAt: 1_775_000_000_000, state: 'complete', attachments: [ { id: '00000000-0000-4000-8000-000000000544', name: '发布清单.md', size: 128, preview: '发布步骤', kind: 'text' } ] }, { id: sourceMessageIds[1]!, role: 'assistant', content: '可以从风险和成本两个方向比较。', createdAt: 1_775_000_001_000, state: 'complete', task: { id: '00000000-0000-4000-8000-000000000545', title: '来源任务' }, artifactIds: [ '00000000-0000-4000-8000-000000000546' ] } ] } ]) const raw = new DatabaseSync(databasePath) raw .prepare( `UPDATE messages SET request_id = ? WHERE id = ?` ) .run( '00000000-0000-4000-8000-000000000547', sourceMessageIds[1]! ) const sourceMetadataRow = raw .prepare( `SELECT metadata_json FROM messages WHERE id = ?` ) .get(sourceMessageIds[1]!) as { metadata_json: string } raw .prepare( `UPDATE messages SET metadata_json = ? WHERE id = ?` ) .run( JSON.stringify({ ...(JSON.parse(sourceMetadataRow.metadata_json) as object), subagents: [ { childTaskId: '00000000-0000-4000-8000-000000000549', expertId: '00000000-0000-4000-8000-000000000550', expertName: '来源专家', routingMode: 'manual', state: 'completed' } ] }), sourceMessageIds[1]! ) raw.close() const sourceBefore = database.getConversation(sourceConversationId) const branch = database.branchLocalConversation({ sourceConversationId, title: '发布方案讨论 · 分支' }) expect(conversationSnapshotSchema.safeParse(branch).success).toBe(true) expect(branch).toMatchObject({ projectId: project.id, runtimeSelection: { provider: 'model', model: { kind: 'profile', profileId: channelDefaultProfileId } }, knowledgeLibraryIds: [ '00000000-0000-4000-8000-000000000559' ], knowledgeRetrievalMode: 'always', branch: { sourceConversationId, sourceTitle: '发布方案讨论' }, title: '发布方案讨论 · 分支' }) expect(branch).not.toHaveProperty('contextMetrics') expect(branch).not.toHaveProperty('contextCompressionState') expect(branch.id).not.toBe(sourceConversationId) expect(branch.messages.map((message) => message.content)).toEqual( sourceBefore.messages.map((message) => message.content) ) expect( branch.messages.every( (message) => !sourceMessageIds.includes(message.id) ) ).toBe(true) expect(branch.messages[0]?.attachments).toEqual( sourceBefore.messages[0]?.attachments ) expect(branch.messages[0]?.queueItemId).toBeUndefined() expect(branch.messages[1]?.task).toBeUndefined() expect(branch.messages[1]?.artifactIds).toBeUndefined() expect(database.getConversation(sourceConversationId)).toEqual( sourceBefore ) const durable = new DatabaseSync(databasePath) expect( durable .prepare( `SELECT sequence, request_id FROM messages WHERE conversation_id = ? ORDER BY sequence` ) .all(branch.id) ).toEqual([ { sequence: 0, request_id: null }, { sequence: 1, request_id: null } ]) const branchMetadataRow = durable .prepare( `SELECT metadata_json FROM messages WHERE conversation_id = ? AND sequence = 1` ) .get(branch.id) as { metadata_json: string } const branchMetadata = JSON.parse( branchMetadataRow.metadata_json ) as Record expect(branchMetadata).not.toHaveProperty('artifactIds') expect(branchMetadata).not.toHaveProperty('subagents') expect(branchMetadata).not.toHaveProperty('task') const branchUserMetadataRow = durable .prepare( `SELECT metadata_json FROM messages WHERE conversation_id = ? AND sequence = 0` ) .get(branch.id) as { metadata_json: string } expect( JSON.parse(branchUserMetadataRow.metadata_json) ).not.toHaveProperty('queueItemId') durable.close() database.saveLocalConversations([ { header: { id: branch.id, projectId: branch.projectId, runtimeSelection: branch.runtimeSelection, knowledgeLibraryIds: branch.knowledgeLibraryIds, knowledgeRetrievalMode: branch.knowledgeRetrievalMode, branch: branch.branch, title: '发布方案讨论 · 分支(独立更新)', updatedAt: 1_775_000_003_000 }, messages: [ { id: '00000000-0000-4000-8000-000000000548', role: 'user', content: '只在分支中继续', createdAt: 1_775_000_003_000, state: 'complete' } ] } ]) expect(database.getConversation(sourceConversationId)).toEqual( sourceBefore ) expect(database.getConversation(branch.id)).toMatchObject({ title: '发布方案讨论 · 分支(独立更新)', messages: [ { content: '比较两个发布方案' }, { content: '可以从风险和成本两个方向比较。' }, { content: '只在分支中继续' } ] }) expect(database.deleteLocalConversation(sourceConversationId)).toBe( true ) expect(database.getConversation(branch.id)).toMatchObject({ branch: { sourceConversationId, sourceTitle: '发布方案讨论' }, messages: [ { content: '比较两个发布方案' }, { content: '可以从风险和成本两个方向比较。' }, { content: '只在分支中继续' } ] }) database.close() }) it('rejects unstable or remote branch sources and rolls back copy failures', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-conversation-branch-guards-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const sourceConversationId = '00000000-0000-4000-8000-000000000551' const sourceMessageId = '00000000-0000-4000-8000-000000000552' database.replaceConversations([ { id: sourceConversationId, title: '必须稳定后分支', updatedAt: 1, messages: [ { id: sourceMessageId, role: 'user', content: '稳定内容', createdAt: 1, state: 'complete' } ] } ]) const queued = database.enqueueConversationUserInput({ conversationId: sourceConversationId, label: '待发送内容', payloadJson: '{}' }) expect(() => database.branchLocalConversation({ sourceConversationId, title: '不应创建的队列分支' }) ).toThrow('仍有待发送内容') database.removeConversationUserQueueItem(queued.id) const taskId = '00000000-0000-4000-8000-000000000553' database.createTask({ id: taskId, conversationId: sourceConversationId, title: '运行中的任务', instructions: '保持运行', workMode: 'ask' }) database.updateTaskStatus(taskId, 'running') expect(() => database.branchLocalConversation({ sourceConversationId, title: '不应创建的运行分支' }) ).toThrow('仍有正在运行的任务') database.updateTaskStatus(taskId, 'completed') const channelProject = database.ensureChannelProjects( 'C:\\Users\\test', channelDefaultProfileId )[0]! const remote = database.getOrCreateRemoteConversation({ projectId: channelProject.id, channel: 'weixin', accountId: 'default', externalConversationId: 'branch-protected', conversationType: 'direct', title: '远程来源', accountDisplay: '发送者 ****0551' }) expect(() => database.branchLocalConversation({ sourceConversationId: remote.id, title: '不应创建的远程分支' }) ).toThrow('只能从现有的本地会话创建分支') const countBeforeFailure = database.listConversations().length const raw = new DatabaseSync(databasePath) raw.exec(` CREATE TRIGGER reject_branched_message BEFORE INSERT ON messages WHEN NEW.conversation_id != '${sourceConversationId}' BEGIN SELECT RAISE(ABORT, 'forced branch copy failure'); END; `) raw.close() expect(() => database.branchLocalConversation({ sourceConversationId, title: '必须完整回滚' }) ).toThrow('forced branch copy failure') expect(database.listConversations()).toHaveLength(countBeforeFailure) database.close() }) it('preserves history beyond 500 messages across incremental saves', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-bounded-local-conversation-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const project = database.listProjects()[0]! const conversationId = '00000000-0000-4000-8000-000000000521' const messageId = (index: number): string => `00000000-0000-4000-8001-${String(index).padStart(12, '0')}` database.replaceConversations([ { id: conversationId, projectId: project.id, title: '有界增量对话', updatedAt: 1_775_000_000_000, messages: Array.from({ length: 500 }, (_, index) => ({ id: messageId(index), role: index % 2 === 0 ? 'user' as const : 'assistant' as const, content: `消息 ${index}`, createdAt: 1_775_000_000_000 + index, state: 'complete' as const })) } ]) const newestMessageId = messageId(500) database.saveLocalConversations([ { header: { id: conversationId, projectId: project.id, title: '有界增量对话', updatedAt: 1_775_000_001_000 }, messages: [ { id: newestMessageId, role: 'user', content: '最新消息', createdAt: 1_775_000_001_000, state: 'complete' } ] } ]) const restored = database.getConversation(conversationId) expect(restored.messages).toHaveLength(501) expect(restored.messages[0]?.id).toBe(messageId(0)) expect(database.listConversations().find((item) => item.id === conversationId)?.messages).toHaveLength(501) expect(database.listConversationSummaries().find(item => item.id === conversationId)?.messageSummary?.count).toBe(501) expect(database.listConversationSummaries([conversationId]).find(item => item.id === conversationId)?.messages).toHaveLength(501) expect(restored.messages.at(-1)?.id).toBe(newestMessageId) const raw = new DatabaseSync(databasePath) expect( raw .prepare( `SELECT COUNT(*) AS count, MIN(sequence) AS minimum, MAX(sequence) AS maximum FROM messages WHERE conversation_id = ?` ) .get(conversationId) ).toEqual({ count: 501, minimum: 0, maximum: 500 }) raw.close() database.close() }) it('rolls back incremental saves when message ownership or role changes', async () => { const database = await createDatabase() const firstConversationId = '00000000-0000-4000-8000-000000000511' const secondConversationId = '00000000-0000-4000-8000-000000000512' const firstMessageId = '00000000-0000-4000-8000-000000000513' const secondMessageId = '00000000-0000-4000-8000-000000000514' const rolledBackMessageId = '00000000-0000-4000-8000-000000000515' database.replaceConversations([ { id: firstConversationId, title: '第一对话', updatedAt: 1, messages: [ { id: firstMessageId, role: 'user', content: '第一条', createdAt: 1, state: 'complete' } ] }, { id: secondConversationId, title: '第二对话', updatedAt: 2, messages: [ { id: secondMessageId, role: 'assistant', content: '第二条', createdAt: 2, state: 'complete' } ] } ]) expect(() => database.saveLocalConversations([ { header: { id: firstConversationId, title: '不应提交的标题', updatedAt: 3 }, messages: [ { id: rolledBackMessageId, role: 'user', content: '不应提交', createdAt: 3, state: 'complete' }, { id: secondMessageId, role: 'assistant', content: '错误归属', createdAt: 2, state: 'complete' } ] } ]) ).toThrow('消息 ID 已属于其他对话') expect(database.getConversation(firstConversationId)).toMatchObject({ title: '第一对话', messages: [{ id: firstMessageId }] }) expect(() => database.saveLocalConversations([ { header: { id: firstConversationId, title: '仍不应提交的标题', updatedAt: 4 }, messages: [ { id: firstMessageId, role: 'assistant', content: '错误角色', createdAt: 1, state: 'complete' } ] } ]) ).toThrow('消息角色不能更改') expect(database.getConversation(firstConversationId)).toMatchObject({ title: '第一对话', messages: [ { id: firstMessageId, role: 'user', content: '第一条' } ] }) database.close() }) it('explicitly deletes only local conversations and cascades messages', async () => { const database = await createDatabase() const localId = '00000000-0000-4000-8000-000000000521' const localTaskId = '00000000-0000-4000-8000-000000000523' database.replaceConversations([ { id: localId, title: '待删除本地对话', updatedAt: 1, messages: [ { id: '00000000-0000-4000-8000-000000000522', role: 'user', content: '待删除消息', createdAt: 1, state: 'complete' } ] } ]) const channelProject = database.ensureChannelProjects( 'C:\\Users\\test', channelDefaultProfileId )[0]! const remote = database.getOrCreateRemoteConversation({ projectId: channelProject.id, channel: 'weixin', accountId: 'default', externalConversationId: 'protected-delete', conversationType: 'direct', title: '受保护远程对话', accountDisplay: '发送者 ****0521' }) database.appendRemoteConversationMessage({ conversationId: remote.id, role: 'user', content: '远程消息' }) const linkedSchedule = database.createSchedule({ conversationId: localId, title: '随会话删除的任务', prompt: '整理会话', workMode: 'execute', recurrence: 'daily', nextRunAt: '2027-01-01T00:00:00.000Z' }) database.createTask({ id: localTaskId, conversationId: localId, title: '本地对话任务', instructions: '生成仅属于对话的回复', workMode: 'ask' }) database.updateTaskStatus(localTaskId, 'completed') const hiddenReply = database.createTextArtifact({ taskId: localTaskId, title: '本地对话回复', content: '删除对话后不得进入成果列表' }) database.saveDelegationResult(localTaskId, { status: 'completed', output: '不应残留' }) expect( database .listArtifacts() .some((artifact) => artifact.id === hiddenReply.id) ).toBe(false) expect(database.deleteLocalConversation(localId)).toBe(true) expect(database.deleteLocalConversation(localId)).toBe(false) expect( database .listSchedules() .some((schedule) => schedule.id === linkedSchedule.id) ).toBe(false) expect( database .listTasks() .some((task) => task.id === linkedSchedule.taskId) ).toBe(false) expect(() => database.getConversation(localId) ).toThrow('对话不存在') expect(() => database.getArtifact(hiddenReply.id)).toThrow( '成果不存在' ) expect(database.listPendingDelegationResults()).toEqual([]) expect(() => database.deleteLocalConversation(remote.id) ).toThrow('远程对话不能作为本地对话删除') expect(() => database.saveLocalConversations([ { header: { id: remote.id, title: '冲突本地标题', updatedAt: 2 }, messages: [] } ]) ).toThrow('本地对话 ID 与远程对话冲突') expect(database.getConversation(remote.id)).toMatchObject({ title: '受保护远程对话', messages: [{ content: '远程消息' }] }) database.close() }) it('lists and searches all conversations beyond the latest 100', async () => { const database = await createDatabase() database.saveLocalConversations( Array.from({ length: 100 }, (_, index) => ({ header: { id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`, title: `较新对话 ${index}`, updatedAt: index + 2 }, messages: [] })) ) const oldestId = '00000000-0000-4000-8000-000000000999' database.saveLocalConversations([ { header: { id: oldestId, title: '第 101 个对话', updatedAt: 1 }, messages: [] } ]) expect(database.listConversations()).toHaveLength(101) expect( database.listConversations().some( (conversation) => conversation.id === oldestId ) ).toBe(true) expect(database.listConversationSummaries()).toHaveLength(101) expect(database.searchConversations('第 101 个对话')).toEqual([oldestId]) expect(database.getConversation(oldestId)).toMatchObject({ id: oldestId, title: '第 101 个对话', messages: [] }) database.close() }) it('clears deleted model connections from every project and conversation layer', async () => { const database = await createDatabase() const removedProfileId = '00000000-0000-4000-8000-000000000291' const keptProfileId = '00000000-0000-4000-8000-000000000293' const imageProfileId = '00000000-0000-4000-8000-000000000294' database.replaceConversations( ([ ['model', removedProfileId], ['opencode', removedProfileId], ['continue', keptProfileId], [undefined, removedProfileId] ] as const).map(([provider, profileId], index) => ({ id: `00000000-0000-4000-8000-00000000030${index}`, runtimeSelection: { ...(provider ? { provider } : {}), model: { kind: 'profile' as const, profileId } }, title: `对话 ${index}`, updatedAt: index + 1, messages: [] })) ) const [channelProject, imageChannelProject] = database.ensureChannelProjects('C:\\Users\\test') database.updateProject(channelProject!.id, { ...channelProject!, runtimeSelection: { provider: 'opencode', model: { kind: 'profile', profileId: removedProfileId } } }) database.updateProject(imageChannelProject!.id, { ...imageChannelProject!, runtimeSelection: { provider: 'model', model: { kind: 'profile', profileId: imageProfileId } } }) const localProject = database.createProject({ name: '本地', description: '', rootPath: 'C:\\Local', defaultWorkMode: 'ask', runtimeSelection: { model: { kind: 'profile', profileId: removedProfileId } } }) expect( database.repairConversationRuntimeSelections({ modelProfiles: [{ id: keptProfileId }, { id: imageProfileId, protocol: 'openai-images-generations' }] }) ).toBe(5) expect( database .listConversations() .filter((conversation) => !conversation.remote) .sort((left, right) => left.title.localeCompare(right.title)) .map((conversation) => conversation.runtimeSelection) ).toEqual([ { provider: 'model' }, { provider: 'opencode' }, { provider: 'continue', model: { kind: 'profile', profileId: keptProfileId } }, undefined ]) expect(database.getProject(channelProject!.id).runtimeSelection).toEqual({ provider: 'opencode' }) // An existing but incompatible model is kept so the UI can explain the fallback. expect(database.getProject(imageChannelProject!.id).runtimeSelection).toEqual({ provider: 'model', model: { kind: 'profile', profileId: imageProfileId } }) expect(database.getProject(localProject.id).runtimeSelection).toBeUndefined() database.close() }) it('migrates legacy stored selections to layers', async () => { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-runtime-layer-migration-')) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') const project = database.listProjects()[0]! database.close() const raw = new DatabaseSync(databasePath) raw.prepare('UPDATE projects SET runtime_selection_json = ? WHERE id = ?') .run(JSON.stringify({ provider: 'continue', profileId: '00000000-0000-4000-8000-000000000301' }), project.id) raw.prepare( `INSERT INTO conversations (id, project_id, runtime_selection_json, work_mode, title, status, created_at, updated_at) VALUES (?, ?, ?, 'ask', 'legacy', 'active', ?, ?)` ).run('00000000-0000-4000-8000-000000000302', project.id, JSON.stringify({ provider: 'auto' }), '2026-01-01', '2026-01-01') raw.exec('PRAGMA user_version = 48') raw.close() const reopened = new AssistantDatabase(databasePath) reopened.initialize('C:\\Workspace') const check = new DatabaseSync(databasePath) expect(check.prepare('SELECT runtime_selection_json AS value FROM projects WHERE id = ?').get(project.id)) .toEqual({ value: JSON.stringify({ provider: 'continue', model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000301' } }) }) expect(check.prepare('SELECT runtime_selection_json AS value FROM conversations WHERE id = ?').get('00000000-0000-4000-8000-000000000302')) .toEqual({ value: null }) check.close() reopened.close() }) it('durably interrupts active tool and subagent metadata during startup recovery', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-conversation-recovery-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const conversationId = '00000000-0000-4000-8000-000000000217' const messageId = '00000000-0000-4000-8000-000000000218' const cancelledMessageId = '00000000-0000-4000-8000-000000000219' const subagentMessageId = '00000000-0000-4000-8000-000000000220' const initial = new AssistantDatabase(databasePath) initial.initialize('C:\\Workspace') initial.replaceConversations([ { id: conversationId, title: '工具恢复', updatedAt: 1_775_000_000_000, messages: [ { id: messageId, role: 'assistant', content: '工具仍在运行', createdAt: 1_775_000_001_000, state: 'streaming', status: '正在执行工具', tools: [ { name: 'pending-tool', state: 'pending', summary: '等待调用' }, { name: 'running-tool', state: 'running', summary: '正在调用' }, { name: 'completed-tool', state: 'completed', summary: '调用完成' }, { name: 'failed-tool', state: 'failed', summary: '调用失败' } ] }, { id: cancelledMessageId, role: 'assistant', content: '请求已取消', createdAt: 1_775_000_002_000, state: 'error', status: '请求已取消', tools: [ { name: 'cancelled-tool', state: 'running', summary: '取消前仍在运行', error: 'runtime parser detail' } ] }, { id: subagentMessageId, role: 'assistant', content: '父请求已完成', createdAt: 1_775_000_003_000, state: 'complete', status: '已完成', subagents: [ { childTaskId: '00000000-0000-4000-8000-000000000221', expertId: '00000000-0000-4000-8000-000000000222', expertName: 'explorer', routingMode: 'native', runtimeCallId: 'task-1', state: 'running', reason: '检查项目' }, { childTaskId: '00000000-0000-4000-8000-000000000223', expertId: '00000000-0000-4000-8000-000000000224', expertName: 'worker', routingMode: 'native', runtimeCallId: 'task-2', state: 'completed', output: '已完成' } ] } ] } ]) initial.close() const recovered = new AssistantDatabase(databasePath) recovered.initialize('C:\\Workspace') expect(recovered.listConversations()[0]?.messages[0]).toMatchObject({ id: messageId, state: 'error', status: '上次运行意外中断,可以重新发送问题', tools: [ expect.objectContaining({ name: 'pending-tool', state: 'interrupted' }), expect.objectContaining({ name: 'running-tool', state: 'interrupted' }), expect.objectContaining({ name: 'completed-tool', state: 'completed' }), expect.objectContaining({ name: 'failed-tool', state: 'failed' }) ] }) expect(recovered.listConversations()[0]?.messages[2]).toMatchObject({ id: subagentMessageId, state: 'complete', status: '已完成', subagents: [ expect.objectContaining({ runtimeCallId: 'task-1', state: 'cancelled' }), expect.objectContaining({ runtimeCallId: 'task-2', state: 'completed' }) ] }) expect(recovered.listConversations()[0]?.messages[1]).toMatchObject({ id: cancelledMessageId, state: 'error', status: '请求已取消', tools: [ expect.objectContaining({ name: 'cancelled-tool', state: 'interrupted', error: 'runtime parser detail' }) ] }) recovered.close() const durable = new DatabaseSync(databasePath) const row = durable .prepare( `SELECT state, metadata_json FROM messages WHERE id = ?` ) .get(messageId) as { state: string metadata_json: string } const metadata = JSON.parse(row.metadata_json) as { status?: string tools?: Array<{ name: string; state: string }> } expect(row.state).toBe('error') expect(metadata.status).toBe( '上次运行意外中断,可以重新发送问题' ) expect(metadata.tools?.map((tool) => tool.state)).toEqual([ 'interrupted', 'interrupted', 'completed', 'failed' ]) const cancelledRow = durable .prepare( `SELECT metadata_json FROM messages WHERE id = ?` ) .get(cancelledMessageId) as { metadata_json: string } expect( ( JSON.parse(cancelledRow.metadata_json) as { tools?: Array<{ state: string }> } ).tools?.[0]?.state ).toBe('interrupted') durable.close() }) it('keeps a live streaming message streaming until startup recovery runs', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-live-streaming-read-') ) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const conversationId = '00000000-0000-4000-8000-000000000971' const assistantMessageId = '00000000-0000-4000-8000-000000000972' const database = new AssistantDatabase(databasePath) database.initialize('C:\\Workspace') database.saveLocalConversations([ { header: { id: conversationId, title: '流式进行中', updatedAt: 1_775_000_000_000 }, messages: [ { id: assistantMessageId, role: 'assistant', content: '正在回答', createdAt: 1_775_000_000_001, state: 'streaming', status: '正在生成', tools: [ { callId: 'call-1', name: 'read', state: 'running', summary: '正在调用' } ] } ] } ]) const liveExpectation = { id: assistantMessageId, state: 'streaming', status: '正在生成', tools: [ expect.objectContaining({ callId: 'call-1', state: 'running' }) ] } // Reading during a live run must not manufacture an interruption; the // renderer keeps saving the streaming message while the run continues. expect( database.getConversation(conversationId).messages[0] ).toMatchObject(liveExpectation) expect( database.listConversations()[0]?.messages[0] ).toMatchObject(liveExpectation) database.close() const recovered = new AssistantDatabase(databasePath) recovered.initialize('C:\\Workspace') expect( recovered.getConversation(conversationId).messages[0] ).toMatchObject({ id: assistantMessageId, state: 'error', status: '上次运行意外中断,可以重新发送问题', tools: [ expect.objectContaining({ callId: 'call-1', state: 'interrupted' }) ] }) recovered.close() }) it('keeps duplicate chat replies out of artifact listings', async () => { const database = await createDatabase() const project = database.listProjects()[0]! const chatTaskId = '00000000-0000-4000-8000-000000000231' const channelTaskId = '00000000-0000-4000-8000-000000000232' const scheduleTaskId = '00000000-0000-4000-8000-000000000233' const delegationTaskId = '00000000-0000-4000-8000-000000000234' database.createTask({ id: chatTaskId, projectId: project.id, conversationId: 'conversation-chat', title: '普通对话', instructions: '回答问题', workMode: 'ask' }) database.createTask({ id: channelTaskId, projectId: project.id, conversationId: 'conversation-channel', title: '远程对话', instructions: '回答远程消息', workMode: 'ask', origin: 'delegation' }) database.createTask({ id: scheduleTaskId, projectId: project.id, conversationId: 'schedule:daily', title: '每日报告', instructions: '生成报告', workMode: 'ask', origin: 'schedule' }) database.createTask({ id: delegationTaskId, projectId: project.id, conversationId: 'delegation:weekly-report', title: '委派报告', instructions: '生成远程委派报告', workMode: 'ask', origin: 'delegation' }) const chatReply = database.createTextArtifact({ projectId: project.id, taskId: chatTaskId, title: '普通对话', content: '普通回复' }) const channelReply = database.createTextArtifact({ projectId: project.id, taskId: channelTaskId, title: '远程对话', content: '远程回复' }) const scheduledReport = database.createTextArtifact({ projectId: project.id, taskId: scheduleTaskId, title: '每日报告', content: '# 每日报告' }) const delegatedReport = database.createTextArtifact({ projectId: project.id, taskId: delegationTaskId, title: '委派报告', content: '# 委派报告' }) const generatedImage = database.createImageArtifact({ projectId: project.id, taskId: chatTaskId, title: '生成图片', mimeType: 'image/png', base64: 'iVBORw0KGgo=' }) const visibleArtifactIds = database .listArtifacts(project.id) .map((artifact) => artifact.id) expect(visibleArtifactIds).toEqual( expect.arrayContaining([ scheduledReport.id, delegatedReport.id, generatedImage.id ]) ) expect(visibleArtifactIds).not.toContain(chatReply.id) expect(visibleArtifactIds).not.toContain(channelReply.id) expect(database.getArtifact(chatReply.id)).toMatchObject({ id: chatReply.id, content: '普通回复' }) database.close() }) it('loads image artifact content only when requested by id', async () => { const database = await createDatabase() const artifact = database.createInlineArtifact({ kind: 'image', title: '生成图片', mimeType: 'image/png', content: 'data:image/png;base64,iVBORw0KGgo=' }) expect( database.listArtifacts().find((item) => item.id === artifact.id) ).toMatchObject({ id: artifact.id, content: undefined }) expect(database.getArtifact(artifact.id)).toMatchObject({ id: artifact.id, content: 'data:image/png;base64,iVBORw0KGgo=' }) database.close() }) it('persists remote delegation results until delivery succeeds', async () => { const database = await createDatabase() const taskId = '00000000-0000-4000-8000-000000000221' database.saveDelegationResult(taskId, { status: 'completed', output: '远程结果' }) expect(database.listPendingDelegationResults()).toEqual([ { taskId, result: { status: 'completed', output: '远程结果' } } ]) database.markDelegationDelivered(taskId) expect(database.listPendingDelegationResults()).toEqual([]) expect(database.getDelegationDeliveryStatus(taskId)).toBe( 'delivered' ) database.close() }) it('upserts absolute token usage snapshots idempotently', async () => { const database = await createDatabase() const taskId = '00000000-0000-4000-8000-000000000301' database.createTask({ id: taskId, title: '统计令牌', instructions: '记录模型调用', workMode: 'ask' }) database.upsertModelUsageCall({ requestId: taskId, callId: 'call-1', runtime: 'opencode', provider: 'anthropic', model: 'claude-sonnet', input: 100, output: 20, cacheRead: 30, cacheWrite: 10 }) database.upsertModelUsageCall({ requestId: taskId, callId: 'call-1', runtime: 'opencode', provider: 'anthropic', model: 'claude-sonnet', input: 125, output: 25, cacheRead: 40, cacheWrite: 12 }) const usageTotals = { callCount: 1, input: 125, output: 25, cacheRead: 40, cacheWrite: 12, cacheInput: 177, totalTokens: 150 } expect(database.getTokenUsageSummary()).toEqual({ totals: usageTotals, conversationTotals: usageTotals, systemTotals: expect.objectContaining({ callCount: 0, totalTokens: 0 }), records: [ expect.objectContaining({ requestId: taskId, callCount: 1, input: 125, output: 25, cacheRead: 40, cacheWrite: 12, cacheInput: 177, totalTokens: 150 }) ] }) expect(() => database.upsertModelUsageCall({ requestId: taskId, callId: 'negative', runtime: 'opencode', provider: 'anthropic', model: 'claude-sonnet', input: -1, output: 0, cacheRead: 0, cacheWrite: 0 }) ).toThrow('input must be a nonnegative safe integer') expect(() => database.upsertModelUsageCall({ requestId: taskId, callId: 'fractional', runtime: 'opencode', provider: 'anthropic', model: 'claude-sonnet', input: 0, output: 0.5, cacheRead: 0, cacheWrite: 0 }) ).toThrow('output must be a nonnegative safe integer') expect(() => database.upsertModelUsageCall({ requestId: taskId, callId: 'call-2', runtime: 'opencode', provider: 'anthropic', model: 'x'.repeat(501), input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }) ).toThrow('model must contain between 1 and 500 characters') database.close() }) it('aggregates token usage with project and conversation metadata', async () => { const database = await createDatabase() const firstProject = database.listProjects()[0]! const secondProject = database.createProject({ name: '第二项目', description: '', rootPath: 'C:\\Second', defaultWorkMode: 'ask' }) const firstConversationId = '00000000-0000-4000-8000-000000000311' const secondConversationId = '00000000-0000-4000-8000-000000000312' database.replaceConversations([ { id: firstConversationId, projectId: firstProject.id, title: '第一会话', updatedAt: 1_775_000_000_000, messages: [] }, { id: secondConversationId, projectId: secondProject.id, title: '第二会话', updatedAt: 1_775_000_001_000, messages: [] } ]) const firstTaskId = '00000000-0000-4000-8000-000000000321' const secondTaskId = '00000000-0000-4000-8000-000000000322' database.createTask({ id: firstTaskId, projectId: firstProject.id, conversationId: firstConversationId, title: '第一请求', instructions: '测试', workMode: 'ask' }) database.createTask({ id: secondTaskId, projectId: secondProject.id, conversationId: secondConversationId, title: '第二请求', instructions: '测试', workMode: 'ask' }) for (const usage of [ { requestId: firstTaskId, callId: 'call-1', runtime: 'opencode', provider: 'anthropic', model: 'claude-sonnet', input: 100, output: 40, cacheRead: 30, cacheWrite: 10 }, { requestId: firstTaskId, callId: 'call-2', runtime: 'opencode', provider: 'anthropic', model: 'claude-sonnet', input: 50, output: 20, cacheRead: 5, cacheWrite: 2 }, { requestId: firstTaskId, callId: 'call-3', runtime: 'continue', provider: 'openai', model: 'gpt-5', input: 80, output: 30, cacheRead: 0, cacheWrite: 0 }, { requestId: secondTaskId, callId: 'call-1', runtime: 'opencode', provider: 'anthropic', model: 'claude-sonnet', input: 25, output: 15, cacheRead: 7, cacheWrite: 3 } ]) { database.upsertModelUsageCall(usage) } const summary = database.getTokenUsageSummary() expect(summary.totals).toEqual({ callCount: 4, input: 255, output: 105, cacheRead: 42, cacheWrite: 15, cacheInput: 312, totalTokens: 360 }) expect(summary.records).toHaveLength(3) expect(summary.records).toEqual( expect.arrayContaining([ expect.objectContaining({ requestId: firstTaskId, projectId: firstProject.id, projectName: firstProject.name, conversationId: firstConversationId, conversationTitle: '第一会话', runtime: 'opencode', provider: 'anthropic', model: 'claude-sonnet', callCount: 2, input: 150, output: 60, cacheRead: 35, cacheWrite: 12, cacheInput: 197, totalTokens: 210 }), expect.objectContaining({ requestId: firstTaskId, runtime: 'continue', provider: 'openai', model: 'gpt-5', callCount: 1, totalTokens: 110 }), expect.objectContaining({ requestId: secondTaskId, projectId: secondProject.id, projectName: '第二项目', conversationId: secondConversationId, conversationTitle: '第二会话', callCount: 1, totalTokens: 40 }) ]) ) database.close() }) it('persists global magic notes and AI comments without todo proposals', async () => { const database = await createDatabase() const globalNote = database.createMagicNote({ title: '全局笔记' }) const secondNote = database.createMagicNote({ title: '第二篇笔记' }) expect(database.listMagicNotes()).toEqual( expect.arrayContaining([ expect.objectContaining({ id: globalNote.id, title: '全局笔记' }), expect.objectContaining({ id: secondNote.id, title: '第二篇笔记' }) ]) ) expect(database.searchMagicNotes('全局', 5)).toEqual([ expect.objectContaining({ noteId: globalNote.id, noteTitle: '全局笔记', content: '' }) ]) expect(database.searchMagicNotes('全局', 5)[0]?.entryId).toBeUndefined() const withEntry = database.createMagicNoteEntry({ noteId: secondNote.id, content: { version: 1, ops: [ { insert: '整理发布清单', attributes: { bold: true } }, { insert: '\n' } ] }, plainText: '整理发布清单' }) const entry = withEntry.entries[0]! expect(withEntry).toMatchObject({ entryCount: 1, preview: '整理发布清单' }) expect(database.searchMagicNotes('发布', 5)).toEqual([ expect.objectContaining({ noteId: secondNote.id, noteTitle: '第二篇笔记', entryId: entry.id, content: '整理发布清单' }) ]) const analyzed = database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: entry.revision, comments: [ { id: '00000000-0000-4000-8000-000000000401', kind: 'suggestion', content: '可以拆成可检查的发布步骤。' } ] }) expect(analyzed.entries[0]!.comments).toEqual([ expect.objectContaining({ kind: 'suggestion', content: '可以拆成可检查的发布步骤。' }) ]) const reanalyzed = database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: analyzed.entries[0]!.revision, comments: [ { id: '00000000-0000-4000-8000-000000000402', kind: 'narrative', content: '可以继续补充目标读者和发布场景。', direction: 'expand', format: 'narrative' } ] }) expect(reanalyzed.entries[0]!.comments).toEqual([ expect.objectContaining({ content: '可以拆成可检查的发布步骤。' }), expect.objectContaining({ content: '可以继续补充目标读者和发布场景。', direction: 'expand', format: 'narrative', analyzedAt: expect.any(String) }) ]) expect(database.listTasks()).toEqual([]) database.close() }) it('synchronizes derived todos when note checklists change', async () => { const database = await createDatabase() const note = database.createMagicNote({ title: '发布笔记' }) const withEntry = database.createMagicNoteEntry({ noteId: note.id, content: { version: 1, ops: [ { insert: '核对发布材料' }, { insert: '\n', attributes: { list: 'unchecked' } }, { insert: '上传构建产物' }, { insert: '\n', attributes: { list: 'checked' } } ] }, plainText: '核对发布材料\n上传构建产物' }) const entry = withEntry.entries[0]! const noteTodos = database.listMagicTodos() expect(noteTodos).toEqual([ expect.objectContaining({ noteId: note.id, entryId: entry.id, source: 'note', title: '核对发布材料', completed: false }), expect.objectContaining({ source: 'note', title: '上传构建产物', completed: true }) ]) const updatedEntry = database.getMagicNote(note.id).entries[0]! database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: updatedEntry.revision, content: { version: 1, ops: [ { insert: '新增首项' }, { insert: '\n', attributes: { list: 'unchecked' } }, { insert: '上传构建产物' }, { insert: '\n', attributes: { list: 'unchecked' } }, { insert: '核对发布材料' }, { insert: '\n', attributes: { list: 'checked' } } ] }, plainText: '新增首项\n上传构建产物\n核对发布材料' }) const reordered = database.listMagicTodos() expect( reordered.find((todo) => todo.title === '核对发布材料') ).toMatchObject({ id: noteTodos[0]!.id, completed: true, sourceIndex: 2 }) expect( reordered.find((todo) => todo.title === '上传构建产物') ).toMatchObject({ id: noteTodos[1]!.id, completed: false, sourceIndex: 1 }) database.close() }) it('updates a derived todo and its source checklist together', async () => { const database = await createDatabase() const note = database.createMagicNote({ title: '发布笔记' }) database.createMagicNoteEntry({ noteId: note.id, content: { version: 1, ops: [ { insert: '核对发布材料' }, { insert: '\n', attributes: { list: 'unchecked' } } ] }, plainText: '核对发布材料' }) const todo = database.listMagicTodos()[0]! const updated = database.updateMagicTodo({ todoId: todo.id, completed: true, expectedRevision: todo.revision }) expect(updated).toMatchObject({ id: todo.id, completed: true, revision: todo.revision + 1 }) const updatedContent = database.getMagicNote(note.id).entries[0]!.content expect(updatedContent.version === 1 && updatedContent.ops).toEqual([ { insert: '核对发布材料' }, { insert: '\n', attributes: { list: 'checked' } } ]) expect(() => database.updateMagicTodo({ todoId: todo.id, completed: false, expectedRevision: todo.revision }) ).toThrow('待办已被更新,请刷新后重试') database.close() }) it('publishes committed note, entry, todo and analysis writes but not failed writes', async () => { const onMagicNotesChanged = vi.fn() const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-note-events-')) temporaryDirectories.push(directory) const databasePath = join(directory, 'assistant.sqlite') const database = new AssistantDatabase(databasePath, { onMagicNotesChanged }) database.initialize('C:\\Workspace') const reader = new DatabaseSync(databasePath, { readOnly: true }) // A separate connection must already see each emitted write. const observed: string[][] = [] onMagicNotesChanged.mockImplementation(() => { observed.push((reader.prepare('SELECT title FROM magic_notes').all() as Array<{ title: string }>).map((note) => note.title)) }) try { const note = database.createMagicNote({ title: 'Empty note' }) expect(observed).toEqual([['Empty note']]) database.updateMagicNote({ noteId: note.id, title: 'Renamed', pinned: true, expectedRevision: 0 }) expect(observed.at(-1)).toEqual(['Renamed']) expect(() => database.updateMagicNote({ noteId: note.id, title: 'Stale', expectedRevision: 0 })).toThrow() expect(onMagicNotesChanged).toHaveBeenCalledTimes(2) const content = { version: 1 as const, ops: [{ insert: 'Task' }, { insert: '\n', attributes: { list: 'unchecked' as const } }] } const withEntry = database.createMagicNoteEntry({ noteId: note.id, content, plainText: 'Task' }) const entry = withEntry.entries[0]! expect(onMagicNotesChanged).toHaveBeenCalledTimes(3) database.updateMagicNoteEntry({ entryId: entry.id, content, plainText: 'Task', expectedRevision: entry.revision }) expect(() => database.updateMagicNoteEntry({ entryId: entry.id, content, plainText: 'Stale', expectedRevision: entry.revision })).toThrow() expect(() => database.createMagicNoteEntry({ noteId: randomUUID(), content, plainText: 'Missing' })).toThrow() expect(onMagicNotesChanged).toHaveBeenCalledTimes(4) const todo = database.listMagicTodos()[0]! database.updateMagicTodo({ todoId: todo.id, completed: false, expectedRevision: todo.revision }) expect(onMagicNotesChanged).toHaveBeenCalledTimes(4) const completed = database.updateMagicTodo({ todoId: todo.id, completed: true, expectedRevision: todo.revision }) expect(onMagicNotesChanged).toHaveBeenCalledTimes(5) database.saveMagicTodoAnalysis({ todoId: todo.id, comments: [], expectedRevision: completed.revision }) expect(onMagicNotesChanged).toHaveBeenCalledTimes(6) database.saveMagicNoteAnalysis({ entryId: entry.id, comments: [], expectedRevision: database.getMagicNoteEntry(entry.id).revision }) expect(() => database.saveMagicNoteAnalysis({ entryId: entry.id, comments: [], expectedRevision: 0 })).toThrow() expect(onMagicNotesChanged).toHaveBeenCalledTimes(7) database.deleteMagicNoteEntry(entry.id) expect(onMagicNotesChanged).toHaveBeenCalledTimes(8) database.deleteMagicNote(note.id) expect(() => database.deleteMagicNote(note.id)).toThrow() expect(() => database.deleteMagicNoteEntry(entry.id)).toThrow() expect(onMagicNotesChanged).toHaveBeenCalledTimes(9) expect(observed.at(-1)).toEqual([]) database.createMagicNote({ title: 'With content', content }) expect(onMagicNotesChanged).toHaveBeenCalledTimes(10) database.clearAssistantData() expect(onMagicNotesChanged).toHaveBeenCalledTimes(11) expect(observed.at(-1)).toEqual([]) } finally { reader.close() database.close() } }) it('counts incomplete todos and publishes todo-changing writes', async () => { const onMagicTodosChanged = vi.fn() const database = await createDatabase({ onMagicTodosChanged }) const note = database.createMagicNote({ title: '发布笔记' }) expect(database.getMagicTodoStatus()).toEqual({ incompleteCount: 0 }) expect(onMagicTodosChanged).not.toHaveBeenCalled() database.createMagicNoteEntry({ noteId: note.id, content: { version: 1, ops: [ { insert: '核对发布材料' }, { insert: '\n', attributes: { list: 'unchecked' } }, { insert: '上传构建产物' }, { insert: '\n', attributes: { list: 'unchecked' } }, { insert: '通知负责人' }, { insert: '\n', attributes: { list: 'checked' } } ] }, plainText: '核对发布材料\n上传构建产物\n通知负责人' }) expect(database.getMagicTodoStatus()).toEqual({ incompleteCount: 2 }) expect(onMagicTodosChanged).toHaveBeenCalledTimes(1) const todo = database .listMagicTodos() .find((candidate) => !candidate.completed)! database.updateMagicTodo({ todoId: todo.id, completed: true, expectedRevision: todo.revision }) expect(database.getMagicTodoStatus()).toEqual({ incompleteCount: 1 }) expect(onMagicTodosChanged).toHaveBeenCalledTimes(2) database.deleteMagicNote(note.id) expect(database.getMagicTodoStatus()).toEqual({ incompleteCount: 0 }) expect(onMagicTodosChanged).toHaveBeenCalledTimes(3) database.close() }) it('protects magic note records from stale revisions', async () => { const database = await createDatabase() const note = database.createMagicNote({ title: '并发笔记' }) const withEntry = database.createMagicNoteEntry({ noteId: note.id, content: { version: 1, ops: [{ insert: '初始内容\n' }] }, plainText: '初始内容' }) const entry = withEntry.entries[0]! database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: entry.revision, content: { version: 1, ops: [{ insert: '新内容\n' }] }, plainText: '新内容' }) expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: entry.revision, content: { version: 1, ops: [{ insert: '过期内容\n' }] }, plainText: '过期内容' }) ).toThrow('记录已被更新') database.close() }) it('clears private assistant content while preserving workspace configuration', async () => { const database = await createDatabase() const project = database.listProjects()[0]! database.createMemory({ scope: 'project', scopeId: project.id, type: 'fact', content: '待清除记忆' }) database.createSchedule({ projectId: project.id, title: '待清除任务', prompt: '总结', workMode: 'ask', recurrence: 'daily', nextRunAt: '2026-08-02T00:00:00.000Z' }) database.createHeartbeatConfig( { scope: { kind: 'projects', projectIds: [project.id] }, name: '待清除心跳', timezone: 'Asia/Shanghai', recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 48, retentionDays: 90 }, new Date('2026-08-01T00:00:00.000Z') ) const taskId = '00000000-0000-4000-8000-000000000331' database.createTask({ id: taskId, projectId: project.id, title: '待清除用量', instructions: '测试', workMode: 'ask' }) database.upsertModelUsageCall({ requestId: taskId, callId: 'call-1', runtime: 'opencode', provider: 'anthropic', model: 'claude-sonnet', input: 10, output: 5, cacheRead: 2, cacheWrite: 1 }) database.createMagicNote({ title: '待清除笔记' }) expect(database.getTokenUsageSummary().totals.totalTokens).toBe(15) database.clearAssistantData() expect(database.listProjects()).toHaveLength(1) expect(database.listExperts()).toHaveLength(3) expect(database.listMemories(project.id)).toEqual([]) expect(database.listSchedules(project.id)).toEqual([]) expect(database.listHeartbeatConfigs(project.id)).toEqual([]) expect(database.listTasks()).toEqual([]) expect(database.listArtifacts(project.id)).toEqual([]) expect(database.listMagicNotes()).toEqual([]) const emptyTotals = { callCount: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheInput: 0, totalTokens: 0 } expect(database.getTokenUsageSummary()).toEqual({ totals: emptyTotals, conversationTotals: emptyTotals, systemTotals: emptyTotals, records: [] }) database.close() }) it('separates system task usage from conversation usage', async () => { const database = await createDatabase() const usage = { runtime: 'model', provider: 'openai', model: 'gpt', input: 10, output: 5, cacheRead: 0, cacheWrite: 0 } const conversationTask = '00000000-0000-4000-8000-000000000401' database.createTask({ id: conversationTask, conversationId: 'chat-1', title: 'Chat', instructions: 'Reply', workMode: 'ask' }) database.upsertModelUsageCall({ ...usage, requestId: conversationTask, callId: 'chat' }) const systemTasks = [ ['00000000-0000-4000-8000-000000000402', 'heartbeat:a', 'heartbeat'], ['00000000-0000-4000-8000-000000000403', 'supervision:b', 'supervision'], ['00000000-0000-4000-8000-000000000404', undefined, 'magic-notes'] ] as const for (const [id, conversationId] of systemTasks) { database.createTask({ id, conversationId, title: 'System', instructions: 'Run', workMode: 'ask', origin: 'assistant', visible: false }) database.upsertModelUsageCall({ ...usage, requestId: id, callId: 'system' }) } database.recordSystemModelUsage({ ...usage, runtime: 'embedding', source: 'knowledge', bucket: 'embedding', output: 0 }) database.recordSystemModelUsage({ ...usage, runtime: 'embedding', source: 'knowledge', bucket: 'embedding', output: 0 }) const summary = database.getTokenUsageSummary() expect(summary.conversationTotals).toMatchObject({ callCount: 1, input: 10, output: 5 }) expect(summary.systemTotals).toMatchObject({ callCount: 5, input: 50, output: 15 }) expect(summary.totals).toMatchObject({ callCount: 6, input: 60, output: 20 }) expect(summary.records.find(record => record.requestId === conversationTask)?.systemSource).toBeUndefined() for (const [id, , source] of systemTasks) { expect(summary.records.find(record => record.requestId === id)?.systemSource).toBe(source) } const knowledge = summary.records.filter(record => record.systemSource === 'knowledge') expect(knowledge).toHaveLength(1) expect(knowledge[0]).toMatchObject({ callCount: 2, input: 20, output: 0 }) expect(database.listTasks().map(task => task.id)).toEqual([conversationTask]) database.close() }) })