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 { AssistantDatabase } from './assistant-database' const temporaryDirectories: string[] = [] afterEach(async () => { vi.useRealTimers() await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }) ) ) }) async function createDatabase(): Promise { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-assistant-')) temporaryDirectories.push(directory) const database = new AssistantDatabase(join(directory, 'assistant.sqlite')) database.initialize('C:\\Workspace') return database } describe('AssistantDatabase', () => { 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('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 schema version 8', 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; 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(8) expect( current .prepare( `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'model_usage_calls'` ) .get() ).toEqual({ name: 'model_usage_calls' }) 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' } ]) current.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; 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(8) 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('creates a default project and persists project updates', async () => { const database = await createDatabase() const [defaultProject] = database.listProjects() expect(defaultProject).toMatchObject({ name: '默认项目', rootPath: 'C:\\Workspace', defaultWorkMode: 'ask', status: 'active' }) expect(database.listExperts()).toHaveLength(3) const project = database.createProject({ name: '产品发布', description: '发布资料和任务', rootPath: 'C:\\Release', defaultWorkMode: 'plan' }) expect(database.listProjects()).toHaveLength(2) const updated = database.updateProject(project.id, { name: '产品发布 2', description: '更新后的项目', rootPath: 'C:\\Release', defaultWorkMode: 'execute' }) expect(updated).toMatchObject({ name: '产品发布 2', defaultWorkMode: 'execute' }) 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('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 artifact = database.createTextArtifact({ projectId: project.id, taskId, title: '发布说明', content: '# 发布说明\n\n内容' }) expect(database.listArtifacts(project.id)).toEqual([ expect.objectContaining({ id: artifact.id, kind: 'markdown', content: '# 发布说明\n\n内容' }) ]) 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( database.claimDueSchedules(new Date('2026-07-31T00:01:00.000Z')) ).toEqual([expect.objectContaining({ id: schedule.id })]) expect(database.listSchedules(project.id)[0]).toMatchObject({ id: schedule.id, nextRunAt: '2026-08-01T00:00:00.000Z', lastRunAt: '2026-07-31T00:01:00.000Z' }) const overdue = database.createSchedule({ projectId: project.id, title: '过期摘要', prompt: '总结任务状态', workMode: 'ask', recurrence: 'daily', nextRunAt: '2025-07-31T00:00:00.000Z' }) database.claimDueSchedules( 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.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', profileId: '00000000-0000-4000-8000-000000000299' }, 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', artifactIds: [ '00000000-0000-4000-8000-000000000216' ], sourceReferences: [ { libraryId: '00000000-0000-4000-8000-000000000214', libraryName: '产品知识', documentId: '00000000-0000-4000-8000-000000000215', documentName: '发布说明.md', sourceName: '发布目录', snippet: '发布前需要完成验证。', rank: -0.03, retrievalChannels: ['fts', 'vector'] } ] } ] } ]) expect(database.listConversations()).toEqual([ expect.objectContaining({ id: conversationId, projectId: project.id, runtimeSelection: { provider: 'model', profileId: '00000000-0000-4000-8000-000000000299' }, 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: 'error', status: expect.stringContaining('意外中断'), reasoning: '先分析发布范围', blocks: [ expect.objectContaining({ type: 'reasoning', content: '先分析发布范围' }), expect.objectContaining({ type: 'tool', tool: expect.objectContaining({ callId: 'call-1', state: 'interrupted' }) }), expect.objectContaining({ type: 'text', content: '处理中' }) ], artifactIds: [ '00000000-0000-4000-8000-000000000216' ], sourceReferences: [ expect.objectContaining({ documentName: '发布说明.md', retrievalChannels: ['fts', 'vector'] }) ] }) ] }) ]) database.replaceConversations([]) expect(database.listConversations()).toEqual([]) database.close() }) it('rebinds persisted conversations whose model profile was removed', async () => { const database = await createDatabase() const removedProfileId = '00000000-0000-4000-8000-000000000291' const defaultProfileId = '00000000-0000-4000-8000-000000000292' const runtimeProfileId = '00000000-0000-4000-8000-000000000293' database.replaceConversations( ([ ['model', removedProfileId], ['opencode', removedProfileId], ['continue', removedProfileId], ['model', runtimeProfileId] ] as const).map(([provider, profileId], index) => ({ id: `00000000-0000-4000-8000-00000000030${index}`, runtimeSelection: { provider, profileId }, title: `对话 ${index}`, updatedAt: index + 1, messages: [] })) ) expect( database.repairConversationRuntimeSelections({ modelProfiles: [ { id: defaultProfileId }, { id: runtimeProfileId } ], defaultModelProfileId: defaultProfileId, opencodeModelSource: { kind: 'profile', profileId: runtimeProfileId }, continueModelSource: { kind: 'platform' } }) ).toBe(3) expect( database .listConversations() .sort((left, right) => left.title.localeCompare(right.title)) .map((conversation) => conversation.runtimeSelection) ).toEqual([ { provider: 'model', profileId: defaultProfileId }, { provider: 'opencode', profileId: runtimeProfileId }, { provider: 'continue' }, { provider: 'model', profileId: runtimeProfileId } ]) database.close() }) it('durably interrupts active tool 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 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' } ] } ] } ]) 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[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('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 }) expect(database.getTokenUsageSummary()).toEqual({ totals: { callCount: 1, input: 125, output: 25, cacheRead: 40, cacheWrite: 12, totalTokens: 150 }, records: [ expect.objectContaining({ requestId: taskId, callCount: 1, input: 125, output: 25, cacheRead: 40, cacheWrite: 12, 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, 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, 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('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( { projectId: 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 }) 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.getTokenUsageSummary()).toEqual({ totals: { callCount: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, records: [] }) database.close() }) })