Files
goodbuddy/src/main/assistant/assistant-database.test.ts
T
mesalogo 9c88ca20f3 feat: layer runtime and model selection across conversation, project and global
- Store runtime selection as { provider?, model? } layers and resolve them per
  field (conversation -> project -> global); drop the legacy auto provider.
- Migrate stored selections (database v49) and clear deleted profile ids.
- Re-resolve selections in Main per request and at queue dispatch.
- Add ProjectRuntimeSelector and a two-column RuntimeModelPicker; manage
  link opens model connections; restore per-runtime default model tabs.
- Stop duplicating trusted instructions and history across OpenCode,
  Continue and DeepSeek Harness runtimes.
- Document the layered design, global provider removal plan and planned
  purpose-specific models.
2026-09-29 21:33:06 +08:00

7535 lines
261 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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<AssistantDatabase> {
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<string, unknown>
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<string, unknown>
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<AssistantDatabase['listSchedules']>[number]
runId: string
}> {
database.queueDueSchedules(now, limit)
const claims: Array<{
schedule: ReturnType<AssistantDatabase['listSchedules']>[number]
runId: string
}> = []
const seenConversations = new Set<string>()
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<AssistantDatabase['listSchedules']>[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<AssistantDatabase['appendRemoteConversationTaskEventOnce']>[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<string, unknown>
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()
})
})