Files
goodbuddy/src/main/assistant/heartbeat-database.test.ts
T
mesalogo c98fe67f1a feat: add durable context compression
Long direct-model conversations mixed provider usage with local estimates,
and Agent tool rounds could remain above the configured compression target.
Compression state and status markers also did not reliably survive restarts
or bounded history rollover.

Direct-model calls now prefer provider-reported usage, compact complete
conversation turns and Agent tool rounds within reserved payload budgets,
and persist reusable summaries with scope-specific markers. The chat meter
separates latest-call usage from estimated compressed conversation size,
while failed or cancelled calls retain the last successful measurement.

Release note: 直连模型现可在长对话和多轮工具执行中自动压缩旧上下文,并分别显示本次调用用量与压缩后对话估算;摘要会自动保存并跨重启复用,无需手动操作。
2026-08-16 00:36:11 +08:00

344 lines
9.2 KiB
TypeScript

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'
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function createDatabase(): Promise<{
database: AssistantDatabase
path: string
}> {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-heartbeat-db-')
)
temporaryDirectories.push(directory)
const path = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(path)
database.initialize('C:\\Workspace')
return { database, path }
}
const input = {
name: 'Daily heartbeat',
timezone: 'UTC',
recurrence: { type: 'daily' as const, localTime: '18:00' },
enabled: true,
lookbackHours: 24,
retentionDays: 7
}
const summary = {
summary: 'A durable summary',
highlights: ['A highlight'],
proposedMemories: [
{
scope: 'global' as const,
type: 'fact' as const,
content: 'A proposed fact',
confidence: 0.7,
salience: 0.8
}
],
followUpTasks: [
{
title: 'A proposed follow-up',
instructions: 'Review this task before starting it.'
}
]
}
describe('AssistantDatabase heartbeat persistence', () => {
it('migrates a v2 database without changing existing schedules', async () => {
const { database, path } = await createDatabase()
const schedule = database.createSchedule({
title: 'Existing schedule',
prompt: 'Keep this schedule',
workMode: 'ask',
recurrence: 'weekly',
nextRunAt: '2026-08-03T09:00:00.000Z'
})
database.close()
const raw = new DatabaseSync(path)
raw.exec('PRAGMA user_version = 2')
raw.close()
const migrated = new AssistantDatabase(path)
migrated.initialize('C:\\Workspace')
expect(migrated.listSchedules()).toEqual([
expect.objectContaining({
id: schedule.id,
title: 'Existing schedule',
recurrence: 'weekly',
nextRunAt: '2026-08-03T09:00:00.000Z'
})
])
const check = new DatabaseSync(path)
const version = (
check.prepare('PRAGMA user_version').get() as {
user_version: number
}
).user_version
const heartbeatTableCount = (
check
.prepare(
`SELECT COUNT(*) AS count FROM sqlite_master
WHERE type = 'table' AND name LIKE 'heartbeat_%'`
)
.get() as { count: number }
).count
check.close()
migrated.close()
expect(version).toBe(20)
expect(heartbeatTableCount).toBe(3)
})
it('claims one scheduled run durably and advances local recurrence', async () => {
const { database } = await createDatabase()
const config = database.createHeartbeatConfig(
input,
new Date('2026-08-01T12:00:00.000Z')
)
const claims = database.claimDueHeartbeats(
'worker-1',
new Date('2026-08-01T18:05:00.000Z')
)
expect(claims).toEqual([
expect.objectContaining({
acquired: true,
run: expect.objectContaining({
configId: config.id,
trigger: 'scheduled',
scheduledFor: '2026-08-01T18:00:00.000Z',
status: 'claimed',
attemptCount: 1
})
})
])
expect(
database.claimDueHeartbeats(
'worker-2',
new Date('2026-08-01T18:05:00.000Z')
)
).toEqual([])
expect(database.getHeartbeatConfig(config.id)).toMatchObject({
nextRunAt: '2026-08-02T18:00:00.000Z',
lastStatus: 'claimed'
})
database.close()
})
it('skips runs missed by over two hours without catch-up storms', async () => {
const { database } = await createDatabase()
const config = database.createHeartbeatConfig(
input,
new Date('2026-08-01T12:00:00.000Z')
)
expect(
database.claimDueHeartbeats(
'worker-1',
new Date('2026-08-02T21:00:00.000Z')
)
).toEqual([])
expect(database.listHeartbeatRuns(config.id)).toEqual([
expect.objectContaining({
scheduledFor: '2026-08-01T18:00:00.000Z',
status: 'skipped',
attemptCount: 0,
error: 'Missed by more than 2 hours'
})
])
expect(database.getHeartbeatConfig(config.id)).toMatchObject({
nextRunAt: '2026-08-03T18:00:00.000Z',
lastStatus: 'skipped'
})
expect(
database.claimDueHeartbeats(
'worker-1',
new Date('2026-08-02T21:01:00.000Z')
)
).toEqual([])
database.close()
})
it('reclaims expired leases and stops after three attempts', async () => {
const { database } = await createDatabase()
database.createHeartbeatConfig(
input,
new Date('2026-08-01T12:00:00.000Z')
)
const [first] = database.claimDueHeartbeats(
'worker-1',
new Date('2026-08-01T18:00:00.000Z')
)
expect(first).toBeDefined()
const [second] = database.claimDueHeartbeats(
'worker-2',
new Date('2026-08-01T18:06:00.000Z')
)
expect(second?.run).toMatchObject({
id: first!.run.id,
attemptCount: 2
})
const secondFailure = database.failHeartbeatRun(
second!,
'temporary failure',
new Date('2026-08-01T18:06:00.000Z')
)
expect(secondFailure.nextAttemptAt).toBe(
'2026-08-01T18:11:00.000Z'
)
const [third] = database.claimDueHeartbeats(
'worker-3',
new Date('2026-08-01T18:11:00.000Z')
)
expect(third?.run.attemptCount).toBe(3)
const terminal = database.failHeartbeatRun(
third!,
'still failing',
new Date('2026-08-01T18:11:00.000Z')
)
expect(terminal.nextAttemptAt).toBeUndefined()
expect(
database.claimDueHeartbeats(
'worker-4',
new Date('2026-08-01T19:00:00.000Z')
)
).toEqual([])
database.close()
})
it('deduplicates manual claims and persists completion atomically', async () => {
const { database } = await createDatabase()
const project = database.listProjects()[0]!
const config = database.createHeartbeatConfig(
{ ...input, projectId: project.id },
new Date('2026-08-01T12:00:00.000Z')
)
const claim = database.claimHeartbeatNow(
config.id,
'button-click-1',
'worker-1',
new Date('2026-08-01T12:30:00.000Z')
)
const duplicate = database.claimHeartbeatNow(
config.id,
'button-click-1',
'worker-2',
new Date('2026-08-01T12:31:00.000Z')
)
expect(duplicate).toMatchObject({
acquired: false,
run: { id: claim.run.id }
})
const concurrent = database.claimHeartbeatNow(
config.id,
'button-click-2',
'worker-3',
new Date('2026-08-01T12:31:30.000Z')
)
expect(concurrent).toMatchObject({
acquired: false,
run: { id: claim.run.id }
})
const completed = database.completeHeartbeatRun(
claim,
summary,
new Date('2026-08-01T12:32:00.000Z')
)
expect(completed).toMatchObject({
status: 'completed',
entryId: expect.any(String)
})
const [entry] = database.listHeartbeatEntries(config.id)
expect(entry).toMatchObject({
runId: claim.run.id,
proposedMemoryIds: [expect.any(String)],
followUpTaskIds: [expect.any(String)]
})
expect(
database
.listMemories()
.find((memory) => memory.id === entry!.proposedMemoryIds[0])
).toMatchObject({ status: 'proposed' })
const followUpTaskId = entry!.followUpTaskIds[0]!
database.resolveAssistantSuggestionTask(
followUpTaskId,
'completed'
)
expect(
database
.listTasks()
.find((task) => task.id === followUpTaskId)
).toMatchObject({ status: 'completed' })
expect(() =>
database.resolveAssistantSuggestionTask(
followUpTaskId,
'cancelled'
)
).toThrow('状态已变化')
database.close()
})
it('rejects completion after lease expiry and prunes retained history', async () => {
const { database } = await createDatabase()
const config = database.createHeartbeatConfig(
{ ...input, retentionDays: 1 },
new Date('2026-08-01T12:00:00.000Z')
)
const expired = database.claimHeartbeatNow(
config.id,
'expired',
'worker-1',
new Date('2026-08-01T12:00:00.000Z'),
1_000
)
expect(() =>
database.completeHeartbeatRun(
expired,
summary,
new Date('2026-08-01T12:00:02.000Z')
)
).toThrow('lease is no longer active')
const active = database.claimHeartbeatNow(
config.id,
'complete',
'worker-2',
new Date('2026-08-01T13:00:00.000Z')
)
database.completeHeartbeatRun(
active,
summary,
new Date('2026-08-01T13:01:00.000Z')
)
database.pruneHeartbeatHistory(
config.id,
new Date('2026-08-03T13:01:00.000Z')
)
expect(database.listHeartbeatEntries(config.id)).toEqual([])
expect(
database
.listHeartbeatRuns(config.id)
.filter((run) => run.status === 'completed')
).toEqual([])
database.close()
})
})