feat: expand secure assistant workflows
Harden runtime execution and add local knowledge, Smart Heartbeat, usage visibility, responsive product surfaces, and cross-platform packaging support. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
6ef1795b81
commit
b3fdf96962
@@ -1,6 +1,7 @@
|
||||
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 } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
|
||||
@@ -23,6 +24,77 @@ async function createDatabase(): Promise<AssistantDatabase> {
|
||||
}
|
||||
|
||||
describe('AssistantDatabase', () => {
|
||||
it('migrates existing databases to schema version 5', 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(5)
|
||||
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('creates a default project and persists project updates', async () => {
|
||||
const database = await createDatabase()
|
||||
const [defaultProject] = database.listProjects()
|
||||
@@ -158,6 +230,110 @@ describe('AssistantDatabase', () => {
|
||||
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]!
|
||||
@@ -181,7 +357,22 @@ describe('AssistantDatabase', () => {
|
||||
role: 'assistant',
|
||||
content: '处理中',
|
||||
createdAt: 1_775_000_001_000,
|
||||
state: 'streaming'
|
||||
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']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -196,7 +387,16 @@ describe('AssistantDatabase', () => {
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
state: 'error',
|
||||
status: expect.stringContaining('意外中断')
|
||||
status: expect.stringContaining('意外中断'),
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000216'
|
||||
],
|
||||
sourceReferences: [
|
||||
expect.objectContaining({
|
||||
documentName: '发布说明.md',
|
||||
retrievalChannels: ['fts', 'vector']
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
@@ -206,6 +406,174 @@ describe('AssistantDatabase', () => {
|
||||
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: '取消前仍在运行'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
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'
|
||||
})
|
||||
]
|
||||
})
|
||||
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'
|
||||
@@ -230,4 +598,319 @@ describe('AssistantDatabase', () => {
|
||||
)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user