Connect a user-created Telegram bot to channel projects with encrypted credentials, numeric sender allowlists, unified execution and text replies. Use Electron networking for system proxy support, with polling recovery, cancellable delivery retries and live status updates. Add schema 61 channel support, lifecycle regression tests, Electron integration probes and feature documentation. Require the performance principles in AGENTS.md and record three alternating baseline/current Main benchmark rounds. Validation: typecheck, lint and diff checks passed. Full suite: 6087 passed, 87 skipped, one intermittent inline-help Electron failure that passed unchanged in isolation. Real Bot plus model acceptance remains pending; the local TLS proxy probe is opt-in. Release note: 新增 Telegram 私聊通道,可配置 Bot Token 和用户白名单,通过通道项目处理文字请求并回复结果。支持系统代理、断线重连和停用取消;真实 Bot 与模型的完整联调仍待完成。
709 lines
37 KiB
TypeScript
709 lines
37 KiB
TypeScript
// @vitest-environment node
|
|
import { randomUUID } from 'node:crypto'
|
|
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
|
|
import type { SubagentEvent } from '../../shared/contracts'
|
|
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database'
|
|
import {
|
|
getPendingAssistantStorageUpgrade,
|
|
hasPendingAssistantStorageUpgrade,
|
|
upgradeAssistantStorage
|
|
} from './assistant-storage-upgrade'
|
|
import {
|
|
compactSubagentPayload,
|
|
restoreSubagentPayload,
|
|
SubagentProgressStorage
|
|
} from './subagent-progress-storage'
|
|
|
|
const directories: string[] = []
|
|
const databases: AssistantDatabase[] = []
|
|
const historicalModeColumns = `
|
|
ALTER TABLE projects ADD COLUMN default_work_mode TEXT NOT NULL DEFAULT 'ask';
|
|
ALTER TABLE conversations ADD COLUMN work_mode TEXT NOT NULL DEFAULT 'ask';
|
|
ALTER TABLE tasks ADD COLUMN work_mode TEXT NOT NULL DEFAULT 'execute';
|
|
`
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks()
|
|
for (const database of databases.splice(0)) database.close()
|
|
await Promise.all(directories.splice(0).map(
|
|
(path) => rm(path, { recursive: true, force: true })
|
|
))
|
|
})
|
|
|
|
async function fixture(): Promise<{
|
|
path: string; database: AssistantDatabase; taskId: string
|
|
}> {
|
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-storage-upgrade-'))
|
|
directories.push(directory)
|
|
const path = join(directory, 'assistant.sqlite')
|
|
const database = new AssistantDatabase(path)
|
|
databases.push(database)
|
|
database.initialize(directory)
|
|
const taskId = randomUUID()
|
|
database.createTask({
|
|
id: taskId, title: 'Storage regression', instructions: 'Synthetic fixture',
|
|
})
|
|
return { path, database, taskId }
|
|
}
|
|
|
|
function subagent(requestId: string): SubagentEvent {
|
|
return {
|
|
requestId, type: 'subagent', childTaskId: randomUUID(),
|
|
expertId: randomUUID(), expertName: 'general', routingMode: 'native',
|
|
runtimeCallId: 'test-call', state: 'running', progress: []
|
|
}
|
|
}
|
|
|
|
describe('subagent progress storage', () => {
|
|
it.each(['ask', 'execute', 'plan', undefined])('removes schema 59 mode metadata (%s) without changing content or event identity', async (workMode) => {
|
|
const { path, database, taskId } = await fixture()
|
|
const schedule = database.createSchedule({ title: 'ask / execute', prompt: '/ask keep this text',
|
|
recurrence: 'daily', nextRunAt: '2030-01-01T00:00:00Z' })
|
|
const header = { id: schedule.conversationId, title: 'ask / execute', updatedAt: 1000 }
|
|
const event = { ...subagent(taskId), state: 'completed' as const,
|
|
output: 'workMode: ask', progress: [{ id: randomUUID(), type: 'text' as const, content: '/execute' }] }
|
|
const { requestId: _requestId, type: _type, ...activity } = event
|
|
void _requestId
|
|
void _type
|
|
const message = { id: randomUUID(), role: 'assistant' as const, state: 'complete' as const,
|
|
content: '/ask preserve user content', createdAt: 1000, subagents: [activity] }
|
|
database.saveLocalConversations([{ header, messages: [message] }])
|
|
database.close()
|
|
const sql = new DatabaseSync(path)
|
|
const nested = { workMode: 'user-value', toolApproval: 'user-policy', text: 'ask execute plan' }
|
|
const context = { workMode, knowledgeLibraryIds: [], storyGraphEnabled: true, custom: nested }
|
|
const template = { workMode, title: schedule.title, prompt: schedule.prompt, custom: nested }
|
|
const queuePayloads = [
|
|
{ workMode, prompt: '/execute keep', custom: nested },
|
|
{ input: { workMode, prompt: '/ask keep', custom: nested }, serializedContexts: [nested] }
|
|
]
|
|
let before: ReturnType<ReturnType<DatabaseSync['prepare']>['all']>
|
|
try {
|
|
sql.exec(`${historicalModeColumns} PRAGMA user_version = 59`)
|
|
sql.prepare('UPDATE projects SET default_work_mode = ?').run(workMode ?? 'ask')
|
|
sql.prepare('UPDATE conversations SET work_mode = ?, context_state_json = ? WHERE id = ?')
|
|
.run(workMode ?? 'ask', JSON.stringify(context), header.id)
|
|
sql.prepare('UPDATE tasks SET work_mode = ?').run(workMode ?? 'ask')
|
|
sql.prepare('UPDATE schedules SET task_template_json = ? WHERE id = ?').run(JSON.stringify(template), schedule.id)
|
|
const metadata = JSON.parse(sql.prepare('SELECT metadata_json FROM messages WHERE id = ?').get(message.id)!.metadata_json as string)
|
|
metadata.subagents[0].workMode = workMode
|
|
metadata.custom = nested
|
|
sql.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?').run(JSON.stringify(metadata), message.id)
|
|
queuePayloads.forEach((payload, index) => sql.prepare(`INSERT INTO conversation_queue_items
|
|
(id, conversation_id, source, label, payload_json, status, created_at)
|
|
VALUES (?, ?, 'user', ?, ?, 'pending', ?)`)
|
|
.run(randomUUID(), header.id, `queued ${index}`, JSON.stringify(payload), `2026-01-0${index + 1}`))
|
|
sql.prepare(`INSERT INTO task_events (task_id, kind, payload_json, created_at,
|
|
remote_binding_id, remote_operation_id, remote_semantic_sequence, remote_event_index)
|
|
VALUES (?, 'subagent', ?, '2026-01-01', 'binding', 'operation', '7', 0)`)
|
|
.run(taskId, JSON.stringify({ ...event, workMode, custom: nested }))
|
|
before = sql.prepare('SELECT * FROM task_events ORDER BY id').all()
|
|
} finally { sql.close() }
|
|
expect(getPendingAssistantStorageUpgrade(path)).toEqual({ migrateNotes: false, reclaimSpace: false })
|
|
upgradeAssistantStorage(path, () => undefined)
|
|
const check = new DatabaseSync(path)
|
|
try {
|
|
for (const [table, column] of [['projects', 'default_work_mode'], ['conversations', 'work_mode'], ['tasks', 'work_mode']]) {
|
|
expect(check.prepare(`PRAGMA table_info(${table})`).all().map(row => row.name)).not.toContain(column)
|
|
}
|
|
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
|
const readJson = (table: string, column: string, id: string) =>
|
|
JSON.parse(check.prepare(`SELECT ${column} AS value FROM ${table} WHERE id = ?`).get(id)!.value as string)
|
|
expect(readJson('conversations', 'context_state_json', header.id))
|
|
.toEqual({ knowledgeLibraryIds: [], storyGraphEnabled: true, custom: nested })
|
|
expect(readJson('schedules', 'task_template_json', schedule.id))
|
|
.toEqual({ title: schedule.title, prompt: schedule.prompt, custom: nested })
|
|
expect(readJson('messages', 'metadata_json', message.id)).toMatchObject({ subagents: [activity], custom: nested })
|
|
expect(readJson('messages', 'metadata_json', message.id).subagents[0]).not.toHaveProperty('workMode')
|
|
const queues = check.prepare('SELECT payload_json FROM conversation_queue_items ORDER BY created_at').all()
|
|
expect(queues.map(row => JSON.parse(row.payload_json as string))).toEqual([
|
|
{ prompt: '/execute keep', custom: nested },
|
|
{ input: { prompt: '/ask keep', custom: nested }, serializedContexts: [nested] }
|
|
])
|
|
expect(check.prepare('SELECT * FROM task_events ORDER BY id').all()).toEqual(before.map(row => {
|
|
const { workMode: obsolete, ...payload } = JSON.parse(row.payload_json as string)
|
|
void obsolete
|
|
return { ...row, payload_json: JSON.stringify(payload) }
|
|
}))
|
|
expect(check.prepare('PRAGMA integrity_check').get()!.integrity_check).toBe('ok')
|
|
expect(check.prepare('PRAGMA foreign_key_check').all()).toEqual([])
|
|
} finally { check.close() }
|
|
database.initialize(dirname(path))
|
|
expect(database.getConversation(header.id).messages[0]).toMatchObject(message)
|
|
expect(database.listSchedules().find(candidate => candidate.id === schedule.id)).toMatchObject({ nextRunAt: schedule.nextRunAt })
|
|
expect(database.appendRemoteTaskEventOnce({ taskId, bindingId: 'binding', operationId: 'operation',
|
|
semanticSequence: '7', eventIndex: 0, kind: 'subagent', payload: { ...event, workMode, custom: nested } })).toBe(false)
|
|
database.close()
|
|
expect(hasPendingAssistantStorageUpgrade(path)).toBe(false)
|
|
})
|
|
|
|
it('resumes schema 60 batches after cancellation and rolls back malformed JSON without losing its source', async () => {
|
|
const { path, database, taskId } = await fixture()
|
|
database.close()
|
|
const sql = new DatabaseSync(path)
|
|
try {
|
|
sql.exec(`${historicalModeColumns} PRAGMA user_version = 59`)
|
|
const insert = sql.prepare("INSERT INTO task_events (task_id, kind, payload_json, created_at) VALUES (?, 'status', ?, '2026-01-01')")
|
|
for (let index = 0; index < 70; index++) insert.run(taskId, JSON.stringify({ workMode: 'plan', index }))
|
|
const damagedId = Number(insert.run(taskId, '{broken').lastInsertRowid)
|
|
let cancelled = false
|
|
expect(() => upgradeAssistantStorage(path, progress => {
|
|
if (progress.processed >= 32) cancelled = true
|
|
}, () => cancelled)).toThrow('cancelled')
|
|
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(59)
|
|
expect(sql.prepare("SELECT COUNT(*) AS count FROM task_events WHERE instr(payload_json, 'workMode') > 0").get()!.count).toBeLessThan(70)
|
|
expect(() => upgradeAssistantStorage(path, () => undefined)).toThrow(/Invalid execution metadata: task_events/)
|
|
expect(sql.prepare('SELECT payload_json FROM task_events WHERE id = ?').get(damagedId)!.payload_json).toBe('{broken')
|
|
// The uncommitted final batch remains intact, while earlier batches survive.
|
|
expect(sql.prepare("SELECT COUNT(*) AS count FROM task_events WHERE instr(payload_json, 'workMode') > 0").get()!.count).toBeGreaterThan(0)
|
|
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(59)
|
|
sql.prepare('UPDATE task_events SET payload_json = ? WHERE id = ?').run('{"workMode":"ask"}', damagedId)
|
|
upgradeAssistantStorage(path, () => undefined)
|
|
expect(sql.prepare('SELECT payload_json FROM task_events WHERE id = ?').get(damagedId)!.payload_json).toBe('{}')
|
|
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
|
expect(sql.prepare('PRAGMA integrity_check').get()!.integrity_check).toBe('ok')
|
|
} finally { sql.close() }
|
|
})
|
|
|
|
it.each([45, 46, 47])('upgrades schema %i without reconverting history or vacuuming ordinary free pages', async (sourceVersion) => {
|
|
const { path, database, taskId } = await fixture()
|
|
const event = subagent(randomUUID())
|
|
event.progress = [{ id: randomUUID(), type: 'text', content: 'Keep current-format progress' }]
|
|
database.appendTaskEvent(taskId, 'subagent', event)
|
|
const note = database.getMagicNote(database.createMagicNote({ title: 'Already migrated', content: { version: 1, ops: [{ insert: 'Keep note\n' }] } }).id)
|
|
const header = { id: randomUUID(), title: 'Keep chat', updatedAt: 1000 }
|
|
const message = { id: randomUUID(), role: 'assistant' as const, state: 'complete' as const,
|
|
content: 'Normal chat update'.repeat(100_000), createdAt: 1000 }
|
|
database.saveLocalConversations([{ header, messages: [message] }])
|
|
database.saveLocalConversations([{ header, messages: [{ ...message, content: 'Final response' }] }])
|
|
const expected = database.getConversation(header.id)
|
|
database.close()
|
|
const sql = new DatabaseSync(path)
|
|
try {
|
|
if (sourceVersion < 47) sql.exec(`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;`)
|
|
if (sourceVersion === 45) sql.exec(`
|
|
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
|
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
|
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;`)
|
|
sql.exec(`ALTER TABLE magic_note_entries DROP COLUMN source_json;
|
|
${historicalModeColumns} PRAGMA user_version = ${sourceVersion}; PRAGMA wal_checkpoint(TRUNCATE)`)
|
|
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
|
|
const events = sql.prepare('SELECT * FROM task_events WHERE task_id = ? ORDER BY id').all(taskId)
|
|
expect(events.length).toBeGreaterThan(0)
|
|
expect(hasPendingAssistantStorageUpgrade(path)).toBe(true)
|
|
const confirmedUpgrade = getPendingAssistantStorageUpgrade(path)!
|
|
expect(confirmedUpgrade).toEqual({ migrateNotes: false, reclaimSpace: false })
|
|
const exec = vi.spyOn(DatabaseSync.prototype, 'exec')
|
|
const progress = vi.fn()
|
|
upgradeAssistantStorage(path, progress)
|
|
expect(exec.mock.calls.some(([statement]) => /VACUUM/i.test(statement))).toBe(false)
|
|
expect(progress.mock.calls.map(([value]) => value.stage)).not.toContain('compacting')
|
|
// Schema 60 checks bounded metadata batches but does not compact history.
|
|
expect(progress.mock.calls.map(([value]) => value.stage)).toContain('converting')
|
|
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
|
expect(sql.prepare('SELECT * FROM task_events WHERE task_id = ? ORDER BY id').all(taskId)).toEqual(events)
|
|
// A retry in the same startup must not gain reclamation just because free pages remain.
|
|
upgradeAssistantStorage(path, progress, () => false, { confirmedUpgrade })
|
|
expect(exec.mock.calls.some(([statement]) => /VACUUM/i.test(statement))).toBe(false)
|
|
database.initialize(dirname(path))
|
|
expect(database.getConversation(header.id)).toEqual(expected)
|
|
expect(database.getMagicNote(note.id)).toEqual(note)
|
|
database.close()
|
|
progress.mockClear()
|
|
upgradeAssistantStorage(path, progress)
|
|
expect(progress).not.toHaveBeenCalled()
|
|
expect(hasPendingAssistantStorageUpgrade(path)).toBe(false)
|
|
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
|
} finally { sql.close() }
|
|
})
|
|
|
|
it('does not migrate or vacuum ordinary chat free pages on repeated startup', async () => {
|
|
const { path, database } = await fixture()
|
|
const header = { id: randomUUID(), title: 'Chat restart', updatedAt: 1000 }
|
|
const message = {
|
|
id: randomUUID(), role: 'assistant' as const, state: 'complete' as const,
|
|
content: 'Long draft'.repeat(100_000), createdAt: 1000
|
|
}
|
|
database.saveLocalConversations([{ header, messages: [message] }])
|
|
database.saveLocalConversations([{ header, messages: [{ ...message, content: 'Final response' }] }])
|
|
const expected = database.getConversation(header.id)
|
|
database.close()
|
|
const sql = new DatabaseSync(path)
|
|
try {
|
|
const free = sql.prepare('PRAGMA freelist_count').get()!.freelist_count
|
|
expect(free).toBeGreaterThan(0)
|
|
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
|
expect(hasPendingAssistantStorageUpgrade(path)).toBe(false)
|
|
const exec = vi.spyOn(DatabaseSync.prototype, 'exec')
|
|
for (let restart = 0; restart < 2; restart++) {
|
|
const progress = vi.fn()
|
|
upgradeAssistantStorage(path, progress)
|
|
expect(progress).not.toHaveBeenCalled()
|
|
expect(exec.mock.calls.some(([statement]) => /VACUUM/i.test(statement))).toBe(false)
|
|
const reopened = new AssistantDatabase(path)
|
|
try {
|
|
reopened.initialize(tmpdir())
|
|
expect(reopened.getConversation(header.id)).toEqual(expected)
|
|
} finally { reopened.close() }
|
|
}
|
|
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBe(free)
|
|
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
|
} finally { sql.close() }
|
|
})
|
|
|
|
it('does not re-show migration after a note is already migrated and later chats free pages', async () => {
|
|
const { path, database } = await fixture()
|
|
const note = database.getMagicNote(database.createMagicNote({ title: 'Legacy note', content: { version: 1, ops: [{ insert: 'note body\n' }] } }).id)
|
|
database.close()
|
|
const legacy = new DatabaseSync(path)
|
|
try {
|
|
legacy.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?')
|
|
.run(JSON.stringify(note.entries[0]!.content), note.entries[0]!.id)
|
|
legacy.exec(`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;
|
|
${historicalModeColumns} PRAGMA user_version = 37`)
|
|
} finally { legacy.close() }
|
|
expect(hasPendingAssistantStorageUpgrade(path)).toBe(true)
|
|
upgradeAssistantStorage(path, () => undefined)
|
|
expect(hasPendingAssistantStorageUpgrade(path)).toBe(false)
|
|
database.initialize(dirname(path))
|
|
expect(database.getMagicNote(note.id)).toEqual(note)
|
|
const entry = database.createMagicNoteEntry({
|
|
noteId: note.id, content: { version: 1, ops: [{ insert: 'New entry\n' }] }, plainText: ''
|
|
}).entries[1]!
|
|
database.updateMagicNoteEntry({
|
|
entryId: entry.id, expectedRevision: entry.revision,
|
|
content: { version: 1, ops: [{ insert: 'Edited entry\n' }] }, plainText: ''
|
|
})
|
|
const expectedNote = database.getMagicNote(note.id)
|
|
const header = { id: randomUUID(), title: 'Chat after note', updatedAt: 1000 }
|
|
const message = {
|
|
id: randomUUID(), role: 'assistant' as const, state: 'complete' as const,
|
|
content: 'Long draft'.repeat(100_000), createdAt: 1000
|
|
}
|
|
database.saveLocalConversations([{ header, messages: [message] }])
|
|
database.saveLocalConversations([{ header, messages: [{ ...message, content: 'Final response' }] }])
|
|
const expectedConversation = database.getConversation(header.id)
|
|
database.close()
|
|
const sql = new DatabaseSync(path)
|
|
try {
|
|
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
|
|
expect(sql.prepare("SELECT COUNT(*) AS count FROM magic_note_entries WHERE json_extract(content_json, '$.storage') = 'file'").get()!.count).toBe(2)
|
|
const exec = vi.spyOn(DatabaseSync.prototype, 'exec')
|
|
for (let restart = 0; restart < 3; restart++) {
|
|
expect(hasPendingAssistantStorageUpgrade(path)).toBe(false)
|
|
const progress = vi.fn()
|
|
upgradeAssistantStorage(path, progress)
|
|
expect(progress).not.toHaveBeenCalled()
|
|
database.initialize(dirname(path))
|
|
expect(database.getMagicNote(note.id)).toEqual(expectedNote)
|
|
expect(database.getConversation(header.id)).toEqual(expectedConversation)
|
|
database.close()
|
|
}
|
|
expect(exec.mock.calls.some(([statement]) => /VACUUM/i.test(statement))).toBe(false)
|
|
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
|
} finally { sql.close() }
|
|
})
|
|
|
|
it('roundtrips every snapshot including corrections, reordered blocks and empty progress', () => {
|
|
const event = subagent(randomUUID())
|
|
const textId = randomUUID()
|
|
const toolId = randomUUID()
|
|
const text: ConversationMessageBlock = { id: textId, type: 'text', content: 'seed' }
|
|
const tool: ConversationMessageBlock = {
|
|
id: toolId, type: 'tool', tool: { name: 'read', state: 'completed', summary: 'Read', output: 'seed' }
|
|
}
|
|
const encoder = new Map<string, ConversationMessageBlock[]>()
|
|
const decoder = new Map<string, ConversationMessageBlock[]>()
|
|
for (const progress of [
|
|
[], [text], [text, tool],
|
|
[{ ...text, content: 'seed tail' }, tool],
|
|
[{ ...text, content: 'corrected' }, tool],
|
|
[tool, text], [], [text]
|
|
]) {
|
|
const full = { ...event, progress }
|
|
const compact = compactSubagentPayload(full, encoder)
|
|
restoreSubagentPayload(full, encoder)
|
|
expect(restoreSubagentPayload(compact, decoder)).toEqual(full)
|
|
}
|
|
})
|
|
|
|
it('stores small changes without repeating large tool outputs, including across reopen', async () => {
|
|
const fixtureData = await fixture()
|
|
let database = fixtureData.database
|
|
const event = subagent(fixtureData.taskId)
|
|
const tool: ConversationMessageBlock = {
|
|
id: randomUUID(), type: 'tool',
|
|
tool: {
|
|
name: 'read', summary: 'Read', state: 'completed',
|
|
input: undefined, output: 'x'.repeat(200_000), error: undefined
|
|
}
|
|
}
|
|
const textId = randomUUID()
|
|
try {
|
|
for (let index = 0; index < 200; index++) {
|
|
event.progress = [tool, { id: textId, type: 'text', content: `seed${'中'.repeat(index)}` }]
|
|
database.appendTaskEvent(fixtureData.taskId, 'subagent', event)
|
|
if (index === 99) {
|
|
database.close()
|
|
database = new AssistantDatabase(fixtureData.path)
|
|
database.initialize(tmpdir())
|
|
}
|
|
}
|
|
database.close()
|
|
const inspection = new DatabaseSync(fixtureData.path)
|
|
try {
|
|
const rows = inspection.prepare(
|
|
`SELECT payload_json FROM task_events WHERE kind = 'subagent' ORDER BY id`
|
|
).all() as Array<{ payload_json: string }>
|
|
expect(rows).toHaveLength(200)
|
|
expect(rows.reduce((sum, row) => sum + row.payload_json.length, 0)).toBeLessThan(400_000)
|
|
const state = new Map<string, ConversationMessageBlock[]>()
|
|
let restored: unknown
|
|
for (const row of rows) restored = restoreSubagentPayload(JSON.parse(row.payload_json), state)
|
|
expect(restored).toStrictEqual(JSON.parse(JSON.stringify(event)))
|
|
} finally { inspection.close() }
|
|
} finally { database.close() }
|
|
})
|
|
|
|
it('ignores omitted optional tool fields but preserves every actual field change', () => {
|
|
const event = subagent(randomUUID())
|
|
const block: ConversationMessageBlock = {
|
|
id: randomUUID(), type: 'tool',
|
|
tool: {
|
|
callId: 'read-1', name: 'read', summary: 'Read', state: 'completed',
|
|
input: undefined, output: 'unchanged', error: undefined
|
|
}
|
|
}
|
|
const state = new Map<string, ConversationMessageBlock[]>([
|
|
[event.childTaskId, JSON.parse(JSON.stringify([block]))]
|
|
])
|
|
expect(compactSubagentPayload({ ...event, progress: [block] }, state))
|
|
.toMatchObject({ progressUpdates: [] })
|
|
for (const change of [
|
|
{ callId: 'read-2' }, { name: 'write' }, { summary: 'Updated' },
|
|
{ state: 'failed' as const }, { input: '' }, { output: 'changed' },
|
|
{ error: 'failed' }, { output: undefined }
|
|
]) {
|
|
const updated = { ...block, tool: { ...block.tool, ...change } }
|
|
const compact = compactSubagentPayload({ ...event, progress: [updated] }, state)
|
|
expect(compact).toMatchObject({
|
|
progressUpdates: [{ type: 'upsert', block: updated }]
|
|
})
|
|
}
|
|
})
|
|
|
|
it('releases terminal task caches without clearing peers, and rebuilds after rollback', () => {
|
|
const database = new DatabaseSync(':memory:')
|
|
database.exec(`CREATE TABLE task_events(
|
|
id INTEGER PRIMARY KEY, task_id TEXT, kind TEXT, payload_json TEXT
|
|
)`)
|
|
const storage = new SubagentProgressStorage(database)
|
|
const caches = (storage as unknown as { tasks: Map<string, unknown> }).tasks
|
|
const tool: ConversationMessageBlock = {
|
|
id: randomUUID(), type: 'tool',
|
|
tool: {
|
|
name: 'read', state: 'completed', summary: 'Read',
|
|
output: 'x'.repeat(200_000), error: undefined
|
|
}
|
|
}
|
|
let id = 0
|
|
const append = (taskId: string, kind: string, event: unknown) => {
|
|
const payload = storage.serialize(taskId, kind, event)
|
|
database.prepare('INSERT INTO task_events VALUES (?, ?, ?, ?)')
|
|
.run(++id, taskId, kind, payload)
|
|
storage.inserted(taskId, kind, id, payload)
|
|
return JSON.parse(payload) as { progressUpdates: unknown[] }
|
|
}
|
|
try {
|
|
const peer = subagent(randomUUID())
|
|
append(peer.requestId, 'subagent', { ...peer, progress: [tool] })
|
|
for (const [kind, payload] of [
|
|
['done', {}], ['error', {}],
|
|
...['completed', 'failed', 'cancelled', 'interrupted']
|
|
.map((status) => ['status', { status }])
|
|
] as Array<[string, unknown]>) {
|
|
const event = { ...subagent(randomUUID()), progress: [tool] }
|
|
append(event.requestId, 'subagent', event)
|
|
expect(caches.size).toBe(2)
|
|
database.exec('BEGIN')
|
|
append(event.requestId, kind, payload)
|
|
expect(caches.has(event.requestId)).toBe(false)
|
|
expect(caches.has(peer.requestId)).toBe(true)
|
|
database.exec('ROLLBACK')
|
|
expect(append(event.requestId, 'subagent', event).progressUpdates).toEqual([])
|
|
append(event.requestId, kind, payload)
|
|
expect(caches.size).toBe(1)
|
|
}
|
|
for (let index = 0; index < 40; index++) {
|
|
const event = subagent(randomUUID())
|
|
append(event.requestId, 'subagent', { ...event, progress: [tool] })
|
|
expect(caches.size).toBeLessThanOrEqual(16)
|
|
}
|
|
} finally { database.close() }
|
|
})
|
|
|
|
it('keeps remote replay deduplication and conflicting payload checks after compaction', async () => {
|
|
const { database, taskId, path } = await fixture()
|
|
const event = subagent(taskId)
|
|
const textId = randomUUID()
|
|
const tool: ConversationMessageBlock = {
|
|
id: randomUUID(), type: 'tool',
|
|
tool: {
|
|
name: 'read', summary: 'Read', state: 'completed',
|
|
input: undefined, output: 'x'.repeat(20_000), error: undefined
|
|
}
|
|
}
|
|
const provenance = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' }
|
|
const first = { ...event, progress: [tool, { id: textId, type: 'text' as const, content: 'seed' }] }
|
|
const second = { ...event, progress: [tool, { id: textId, type: 'text' as const, content: 'seed tail' }] }
|
|
try {
|
|
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(true)
|
|
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(true)
|
|
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(false)
|
|
expect(() => database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: second })).toThrow('conflicts')
|
|
database.appendTaskEvent(taskId, 'done', {})
|
|
const caches = (database as unknown as {
|
|
subagentProgress: { tasks: Map<string, unknown> }
|
|
}).subagentProgress.tasks
|
|
expect(caches.size).toBe(0)
|
|
expect(database.appendRemoteTaskEventOnce({
|
|
...provenance, semanticSequence: '2', payload: second
|
|
})).toBe(false)
|
|
expect(caches.size).toBe(0)
|
|
} finally { database.close() }
|
|
const reopened = new AssistantDatabase(path)
|
|
reopened.initialize(tmpdir())
|
|
try {
|
|
expect(reopened.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(false)
|
|
expect(reopened.getHighestCommittedRemoteTaskEventSequence('binding', 'operation')).toBe('2')
|
|
} finally { reopened.close() }
|
|
})
|
|
|
|
it('rebuilds progress after a remote batch rolls back', async () => {
|
|
const { database, taskId } = await fixture()
|
|
const event = subagent(taskId)
|
|
const textId = randomUUID()
|
|
const base = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' }
|
|
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
|
|
try {
|
|
database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '2', payload: first })
|
|
expect(() => database.appendRemoteTaskEventsBatch([
|
|
{ ...base, semanticSequence: '1', payload: { ...first, progress: [] } },
|
|
{ ...base, semanticSequence: '2', payload: { ...first, output: 'conflict' } }
|
|
])).toThrow('conflicts')
|
|
expect(database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '3', payload: first })).toBe(true)
|
|
expect(database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '3', payload: first })).toBe(false)
|
|
} finally { database.close() }
|
|
})
|
|
|
|
it('keeps full remote conversation progress atomic with compact events', async () => {
|
|
const { database, path } = await fixture()
|
|
const hostId = randomUUID()
|
|
const project = database.createSshProject({
|
|
project: {
|
|
name: 'Remote regression', description: '', rootPath: '/tmp/goodbuddy-storage-test',
|
|
runtimeSelection: { provider: 'opencode' }
|
|
},
|
|
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/tmp/goodbuddy-storage-test' },
|
|
assertCurrent: () => undefined
|
|
})
|
|
const conversationId = randomUUID()
|
|
const userMessageId = randomUUID()
|
|
const assistantMessageId = randomUUID()
|
|
database.saveLocalConversations([{
|
|
header: { id: conversationId, projectId: project.id, title: 'Atomic regression', updatedAt: Date.now() },
|
|
messages: []
|
|
}])
|
|
const remoteTaskId = randomUUID()
|
|
database.createTask({
|
|
id: remoteTaskId, projectId: project.id, conversationId,
|
|
title: 'Atomic remote', instructions: 'Synthetic',
|
|
remoteRecovery: {
|
|
recoverable: true, currentUserMessageId: userMessageId,
|
|
currentAssistantMessageId: assistantMessageId
|
|
}
|
|
})
|
|
const event = subagent(remoteTaskId)
|
|
const textId = randomUUID()
|
|
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
|
|
const second = { ...event, state: 'completed' as const, output: 'Complete', progress: [
|
|
{ id: textId, type: 'text' as const, content: 'seed tail' }
|
|
] }
|
|
const owner = {
|
|
taskId: remoteTaskId, conversationId, assistantMessageId,
|
|
bindingId: 'atomic-binding', operationId: 'atomic-operation', eventIndex: 0
|
|
}
|
|
const inspection = new DatabaseSync(path)
|
|
try {
|
|
database.appendRemoteConversationTaskEventOnce({ ...owner, semanticSequence: '1', event: first })
|
|
inspection.exec(`CREATE TRIGGER reject_message BEFORE UPDATE ON messages
|
|
BEGIN SELECT RAISE(ABORT, 'fixture message write failed'); END;`)
|
|
expect(() => database.appendRemoteConversationTaskEventOnce({
|
|
...owner, semanticSequence: '2', event: second
|
|
})).toThrow('fixture message write failed')
|
|
expect(database.getHighestCommittedRemoteTaskEventSequence('atomic-binding', 'atomic-operation')).toBe('1')
|
|
inspection.exec('DROP TRIGGER reject_message')
|
|
expect(database.appendRemoteConversationTaskEventOnce({
|
|
...owner, semanticSequence: '2', event: second
|
|
})).toBe(true)
|
|
expect(database.appendRemoteConversationTaskEventOnce({
|
|
...owner, semanticSequence: '2', event: second
|
|
})).toBe(false)
|
|
const message = database.getConversation(conversationId).messages.find((row) => row.id === assistantMessageId)
|
|
expect(message?.subagents?.[0]).toMatchObject({
|
|
state: 'completed', progress: second.progress, output: 'Complete'
|
|
})
|
|
expect(inspection.prepare(
|
|
`SELECT COUNT(*) AS count FROM task_events WHERE task_id = ? AND kind = 'subagent'`
|
|
).get(remoteTaskId)).toEqual({ count: 2 })
|
|
} finally { inspection.close(); database.close() }
|
|
})
|
|
|
|
it.each([33, 34])('converts schema %i rows, resumes and shrinks without losing provenance', async (sourceVersion) => {
|
|
const { database, taskId, path } = await fixture()
|
|
database.close()
|
|
const event = subagent(taskId)
|
|
const tool: ConversationMessageBlock = {
|
|
id: randomUUID(), type: 'tool',
|
|
tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(300_000) }
|
|
}
|
|
const textId = randomUUID()
|
|
const legacy = new DatabaseSync(path)
|
|
const expected: SubagentEvent[] = []
|
|
try {
|
|
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;
|
|
${historicalModeColumns} PRAGMA user_version = ${sourceVersion}; BEGIN`)
|
|
const insert = legacy.prepare(
|
|
`INSERT INTO task_events(
|
|
task_id, kind, payload_json, created_at, remote_binding_id,
|
|
remote_operation_id, remote_semantic_sequence, remote_event_index
|
|
) VALUES (?, 'subagent', ?, ?, 'binding', 'operation', ?, 0)`
|
|
)
|
|
for (let index = 0; index < 150; index++) {
|
|
const full = {
|
|
...event,
|
|
progress: [tool, { id: textId, type: 'text' as const, content: `seed${'中'.repeat(index)}` }]
|
|
}
|
|
expected.push(full)
|
|
const { progress, ...metadata } = full
|
|
const stored = sourceVersion === 34 ? {
|
|
...metadata,
|
|
progressUpdates: progress.map((block) => ({ type: 'upsert', block }))
|
|
} : full
|
|
insert.run(taskId, JSON.stringify(stored), '2026-09-13T00:00:00Z', String(index + 1))
|
|
}
|
|
legacy.exec(`COMMIT;
|
|
CREATE TRIGGER reject_unchanged_payload BEFORE UPDATE OF payload_json ON task_events
|
|
WHEN OLD.payload_json = NEW.payload_json
|
|
BEGIN SELECT RAISE(ABORT, 'unchanged payload rewritten'); END;`)
|
|
} finally { legacy.close() }
|
|
const before = (await stat(path)).size
|
|
expect(before).toBeGreaterThan(40_000_000)
|
|
let cancelled = false
|
|
expect(() => upgradeAssistantStorage(path, (progress) => {
|
|
if (progress.processed >= 32) cancelled = true
|
|
}, () => cancelled)).toThrow('cancelled')
|
|
upgradeAssistantStorage(path, () => undefined)
|
|
const after = (await stat(path)).size
|
|
expect(after).toBeLessThan(before / 20)
|
|
const inspected = new DatabaseSync(path)
|
|
try {
|
|
expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
|
const rows = inspected.prepare(
|
|
`SELECT id, task_id, payload_json, created_at, remote_binding_id,
|
|
remote_operation_id, remote_semantic_sequence, remote_event_index
|
|
FROM task_events WHERE kind = 'subagent' ORDER BY id`
|
|
).all() as Array<{
|
|
id: number; task_id: string; payload_json: string; created_at: string
|
|
remote_binding_id: string; remote_operation_id: string
|
|
remote_semantic_sequence: string; remote_event_index: number
|
|
}>
|
|
expect(rows).toHaveLength(expected.length)
|
|
const blocks = new Map<string, ConversationMessageBlock[]>()
|
|
rows.forEach((row, index) => {
|
|
expect(restoreSubagentPayload(JSON.parse(row.payload_json), blocks)).toEqual(expected[index])
|
|
expect(row.created_at).toBe('2026-09-13T00:00:00Z')
|
|
expect(row.task_id).toBe(taskId)
|
|
expect(row.remote_binding_id).toBe('binding')
|
|
expect(row.remote_operation_id).toBe('operation')
|
|
expect(row.remote_semantic_sequence).toBe(String(index + 1))
|
|
expect(row.remote_event_index).toBe(0)
|
|
if (index > 0) expect(row.id).toBe(rows[index - 1]!.id + 1)
|
|
})
|
|
} finally { inspected.close() }
|
|
const reopened = new AssistantDatabase(path)
|
|
reopened.initialize(tmpdir())
|
|
reopened.close()
|
|
const messages: unknown[] = []
|
|
upgradeAssistantStorage(path, (value) => messages.push(value))
|
|
expect(messages).toEqual([])
|
|
const latest = new DatabaseSync(path)
|
|
try {
|
|
expect(latest.prepare('PRAGMA user_version').get()).toEqual({
|
|
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
|
|
})
|
|
} finally { latest.close() }
|
|
}, 30_000)
|
|
|
|
it('recompacts mixed full and delta events without changing corrections, resets or child state', () => {
|
|
const first = subagent(randomUUID())
|
|
const second = { ...subagent(first.requestId), state: 'completed' as const, output: 'Done' }
|
|
const textId = randomUUID()
|
|
const tool: ConversationMessageBlock = {
|
|
id: randomUUID(), type: 'tool',
|
|
tool: { name: 'read', state: 'completed', summary: 'Read', output: 'payload' }
|
|
}
|
|
const text: ConversationMessageBlock = { id: textId, type: 'text', content: 'seed' }
|
|
const stored = [
|
|
{ ...first, progress: [tool, text] },
|
|
{ ...second, progressUpdates: [{ type: 'upsert', block: text }] },
|
|
{ ...first, progress: undefined, progressUpdates: [
|
|
{ type: 'upsert', block: tool },
|
|
{ type: 'append', id: textId, blockType: 'text', delta: ' tail' }
|
|
] },
|
|
{ ...first, progress: undefined, progressUpdates: [
|
|
{ type: 'upsert', block: { ...text, content: 'corrected' } }
|
|
] },
|
|
{ ...first, progress: undefined, progressUpdates: [
|
|
{ type: 'reset', blocks: [text, tool] }
|
|
] },
|
|
{ ...second, progressUpdates: [] },
|
|
{ ...first, progress: [] },
|
|
{ ...first, progress: [text] }
|
|
]
|
|
const oldState = new Map<string, ConversationMessageBlock[]>()
|
|
const newState = new Map<string, ConversationMessageBlock[]>()
|
|
for (const input of stored) {
|
|
const event = JSON.parse(JSON.stringify(input))
|
|
const compact = compactSubagentPayload(event, oldState)
|
|
const expected = restoreSubagentPayload(event, oldState)
|
|
expect(restoreSubagentPayload(compact, newState)).toStrictEqual(expected)
|
|
}
|
|
})
|
|
})
|