fix: reduce activity history disk writes during streaming
Save only changed activity records instead of rewriting the entire history snapshot. Migrate existing history to schema 44 while preserving order and duplicate IDs. Run unit tests with up to four workers and keep the configured integration phase serial. Include activity-history I/O, storage-upgrade, settings, and OpenCode isolation regression coverage. Validation: 4 related test files / 123 tests passed; typecheck and lint passed. Release note: Reduce disk writes while agent activity updates stream in, preserving existing activity history.
This commit is contained in:
@@ -43,6 +43,22 @@ npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Vitest runs two ordered projects from `vitest.config.ts`. The `unit` project runs
|
||||
first with up to four workers, bounded by Node's available CPU parallelism. Once
|
||||
it finishes, the `integration` project runs with one worker. The serial group
|
||||
contains all test files under `tests/` (including `*.electron.test.ts`),
|
||||
`src/main/agent/**/*runtime*.test.ts`, and
|
||||
`src/agent-daemon/private-endpoint.test.ts`. Other tests remain in the unit group.
|
||||
Both projects inherit the React plugin, jsdom environment, renderer setup, and
|
||||
existing exclusions; test-file isolation remains enabled.
|
||||
|
||||
This preserves serialization for the process/socket tests that previously timed
|
||||
out on Linux with two workers (commit `93721d6`). `sequence.groupOrder` keeps the
|
||||
two phases from overlapping. Existing file filters still work, for example
|
||||
`npm test -- src/main/agent/opencode-runtime.test.ts`. To select a whole phase,
|
||||
use `npm test -- --project unit` or `npm test -- --project integration`. To inspect
|
||||
file selection without running tests, use `npx vitest list --filesOnly`.
|
||||
|
||||
监听模式:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// @vitest-environment node
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import spawn from 'cross-spawn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runtimeSettingsInputSchema } from '../../shared/contracts'
|
||||
import { RuntimeSettingsStore } from '../runtime-settings-store'
|
||||
import { applyRuntimeSelection } from './runtime-selection'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
|
||||
const cipher = {
|
||||
isAvailable: () => false,
|
||||
encrypt: (): never => { throw new Error('No credentials in this test') },
|
||||
decrypt: (): never => { throw new Error('No credentials in this test') }
|
||||
}
|
||||
const newId = '00000000-0000-4000-8000-000000000123'
|
||||
const denial = "OpenCode's free tier can only be used from within OpenCode"
|
||||
|
||||
describe('OpenCode isolated configuration paths', () => {
|
||||
it.each(['default', 'profile', 'platform', 'external'] as const)(
|
||||
'resolves persisted %s source independently of the global default', async (source) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-source-matrix-'))
|
||||
try {
|
||||
const path = join(root, 'runtime-settings.json')
|
||||
const store = new RuntimeSettingsStore(path, cipher, {})
|
||||
const fresh = await store.getPublicSettings()
|
||||
await store.update(runtimeSettingsInputSchema.parse({
|
||||
...Object.fromEntries(Object.entries(fresh).filter(([key]) => key in runtimeSettingsInputSchema.shape)),
|
||||
embeddingConnections: undefined,
|
||||
apiKey: { action: 'keep' },
|
||||
modelProfiles: [...fresh.modelProfiles, {
|
||||
...fresh.modelProfiles[0], id: newId, name: 'Local probe',
|
||||
modelName: 'probe-model', baseUrl: 'http://127.0.0.1:12345/v1'
|
||||
}].map((profile) => ({
|
||||
id: profile.id, name: profile.name, baseUrl: profile.baseUrl,
|
||||
modelName: profile.modelName, protocol: profile.protocol,
|
||||
authentication: profile.authentication, imageGenerationQuality: profile.imageGenerationQuality,
|
||||
apiKey: { action: 'keep' }
|
||||
})),
|
||||
opencodeModelSource: source === 'profile'
|
||||
? { kind: 'profile', profileId: newId }
|
||||
: { kind: source === 'external' ? 'default' : source },
|
||||
opencodeBaseUrl: source === 'external' ? 'http://127.0.0.1:12346' : ''
|
||||
}))
|
||||
const reloaded = new RuntimeSettingsStore(path, cipher, {})
|
||||
const selected = applyRuntimeSelection(await reloaded.getResolvedSettings(), { provider: 'opencode' })
|
||||
expect(selected.settings.opencodeEmbedded).toBe(source !== 'external')
|
||||
expect(selected.settings.opencodeModelProfile?.modelName).toBe(
|
||||
source === 'default' ? 'qwen3' : source === 'profile' ? 'probe-model' : undefined
|
||||
)
|
||||
expect((await reloaded.getPublicSettings()).opencodeModelSource.kind).toBe(
|
||||
source === 'external' ? 'platform' : source
|
||||
)
|
||||
if (source === 'platform') {
|
||||
expect(applyRuntimeSelection(await reloaded.getResolvedSettings(), {
|
||||
provider: 'opencode', profileId: newId
|
||||
}).settings.opencodeModelProfile?.id).toBe(newId)
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['profile', 'native-file', 'native-empty'] as const)(
|
||||
'runs bundled OpenCode with isolated %s configuration and loopback-only model calls', async (source) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-isolation-'))
|
||||
const requests: Array<{ path: string; model: string }> = []
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = ''
|
||||
for await (const chunk of request) body += String(chunk)
|
||||
requests.push({ path: request.url ?? '', model: JSON.parse(body).model })
|
||||
response.writeHead(403, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: denial, type: 'forbidden' } }))
|
||||
})
|
||||
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('No loopback address')
|
||||
const baseURL = `http://127.0.0.1:${address.port}/v1`
|
||||
const configPath = join(root, 'native.json')
|
||||
await writeFile(configPath, JSON.stringify({
|
||||
model: 'local-native/probe-model',
|
||||
provider: { 'local-native': {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
options: { baseURL }, models: { 'probe-model': {} }
|
||||
} }
|
||||
}))
|
||||
let launchEnvironment: NodeJS.ProcessEnv = {}
|
||||
let serverUrl = ''
|
||||
const binaryPath = join(process.cwd(), 'node_modules', 'opencode-ai', 'bin',
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode')
|
||||
const runtime = new OpenCodeRuntime({
|
||||
embedded: true, binaryPath, defaultWorkspace: root,
|
||||
configPath: source === 'native-empty' ? '' : configPath,
|
||||
sharedCacheRoot: join(root, 'runtime-cache'),
|
||||
bundledConfigPath: join(process.cwd(), '.runtime-resources', 'opencode-config'),
|
||||
...(source === 'profile' ? { modelProfile: {
|
||||
id: newId, name: 'Local mock', baseUrl: baseURL, modelName: 'probe-model',
|
||||
protocol: 'openai-chat-completions' as const, authentication: 'none' as const
|
||||
} } : {})
|
||||
}, {
|
||||
startupTimeoutMs: 20_000,
|
||||
validateBinary: async () => ({ path: binaryPath, detail: 'Isolated bundled binary' }),
|
||||
spawn: ((command, args, options) => {
|
||||
// Allow only OS plumbing and the configuration generated by this runtime.
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const [key, value] of Object.entries(options?.env ?? {})) {
|
||||
if (/^(PATH|SYSTEMROOT|WINDIR|COMSPEC|PATHEXT)$/i.test(key) ||
|
||||
/^OPENCODE_(CONFIG|CONFIG_DIR|CONFIG_CONTENT|SERVER_USERNAME|SERVER_PASSWORD|DISABLE_[A-Z_]+|EXPERIMENTAL_DISABLE_FILEWATCHER)$/.test(key) ||
|
||||
key.startsWith('XDG_')) env[key] = value
|
||||
}
|
||||
Object.assign(env, { HOME: root, USERPROFILE: root, APPDATA: join(root, 'appdata'),
|
||||
LOCALAPPDATA: join(root, 'localappdata'), TEMP: root, TMP: root,
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: '1' })
|
||||
launchEnvironment = env
|
||||
serverUrl = `http://127.0.0.1:${args!.find((arg) => arg.startsWith('--port='))!.split('=')[1]}`
|
||||
return spawn(command, args!, { ...options, env })
|
||||
}) as typeof spawn
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 25_000)
|
||||
try {
|
||||
expect(await runtime.testConnection()).toMatchObject({ available: true })
|
||||
const config = JSON.parse(launchEnvironment.OPENCODE_CONFIG_CONTENT!)
|
||||
expect(config.model).toBe(source === 'profile' ? 'goodbuddy-openai-chat/probe-model' : undefined)
|
||||
expect(launchEnvironment.OPENCODE_CONFIG).toBe(source === 'native-file' ? configPath : undefined)
|
||||
expect(launchEnvironment.XDG_CONFIG_HOME).toBe(join(root, 'runtime-cache', 'config'))
|
||||
if (source === 'native-empty') {
|
||||
// Inspect the bundled catalog without issuing any inference request.
|
||||
const requestOptions = {
|
||||
headers: { authorization: `Basic ${Buffer.from(
|
||||
`${launchEnvironment.OPENCODE_SERVER_USERNAME}:${launchEnvironment.OPENCODE_SERVER_PASSWORD}`
|
||||
).toString('base64')}` }, signal: controller.signal
|
||||
}
|
||||
const health = await (await fetch(`${serverUrl}/global/health`, requestOptions)).json() as { version: string }
|
||||
const installed = JSON.parse(await readFile(join(process.cwd(), 'node_modules', 'opencode-ai', 'package.json'), 'utf8'))
|
||||
expect(health.version).toBe(installed.version)
|
||||
const response = await fetch(`${serverUrl}/config/providers`, requestOptions)
|
||||
expect(response.status).toBe(200)
|
||||
const catalog = await response.json() as {
|
||||
providers: Array<{ id: string; models: Record<string, { cost: { input: number } }> }>;
|
||||
default: Record<string, string>
|
||||
}
|
||||
const native = catalog.providers.find((provider) => provider.id === 'opencode')
|
||||
expect(native).toBeDefined()
|
||||
expect(Object.values(native!.models).every((model) => model.cost.input === 0)).toBe(true)
|
||||
expect(native!.models[catalog.default.opencode!]).toBeDefined()
|
||||
process.stdout.write('Isolated native catalog: ' + JSON.stringify({
|
||||
version: health.version,
|
||||
providers: catalog.providers.map((provider) => provider.id),
|
||||
default: catalog.default, freeModels: Object.keys(native!.models)
|
||||
}) + '\n')
|
||||
expect(requests).toEqual([])
|
||||
} else {
|
||||
let failure = ''
|
||||
try {
|
||||
for await (const event of runtime.run({ requestId: '403-isolation',
|
||||
conversationId: '403-isolation', prompt: 'Reply OK', workMode: 'ask'
|
||||
}, controller.signal)) void event
|
||||
} catch (error) {
|
||||
failure = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
expect(requests.length).toBeGreaterThan(0)
|
||||
expect(requests.every((request) => request.path === '/v1/chat/completions' &&
|
||||
request.model === 'probe-model')).toBe(true)
|
||||
expect(failure).toContain(denial)
|
||||
expect(failure).toContain('statusCode: 403')
|
||||
process.stdout.write('Isolated 403 result: ' + JSON.stringify({ source, requests: requests.length, failure }) + '\n')
|
||||
}
|
||||
expect(JSON.parse(await readFile(configPath, 'utf8')).model).toBe('local-native/probe-model')
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
controller.abort()
|
||||
await runtime.dispose()
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>((done) => server.close(() => done()))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 40_000
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import { expect, it } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { upgradeAssistantStorage } from './assistant-storage-upgrade'
|
||||
|
||||
it('migrates activity history and writes only changed records during streaming', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-save-io-'))
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
try {
|
||||
database.initialize(directory)
|
||||
const raw = (database as unknown as { database: DatabaseSync }).database
|
||||
const records = Array.from({ length: 10_000 }, (_, index) => ({
|
||||
id: String(index), conversationId: randomUUID(), requestId: randomUUID(),
|
||||
scope: { kind: 'global' as const }, kind: 'tool' as const,
|
||||
title: 'Tool', detail: 'h'.repeat(280),
|
||||
status: 'running' as const, createdAt: Date.now()
|
||||
}))
|
||||
// Build a released schema-43 snapshot, including an allowed duplicate ID.
|
||||
records[1]!.id = records[0]!.id
|
||||
raw.exec(`DROP TABLE activity_history_records;
|
||||
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
||||
PRAGMA user_version=43`)
|
||||
const legacySave = raw.prepare('UPDATE activity_history SET records_json = ?, legacy_history_may_be_incomplete = 1')
|
||||
legacySave.run(JSON.stringify(records))
|
||||
raw.exec('PRAGMA wal_checkpoint(TRUNCATE); PRAGMA wal_autocheckpoint=0')
|
||||
for (let index = 0; index < 10; index++) {
|
||||
records[0]!.detail += 'x'
|
||||
legacySave.run(JSON.stringify(records))
|
||||
}
|
||||
const beforeBytes = statSync(`${path}-wal`).size
|
||||
database.close()
|
||||
upgradeAssistantStorage(path, () => undefined)
|
||||
database.initialize(directory)
|
||||
expect(database.getActivityHistory()).toEqual({ records, legacyHistoryMayBeIncomplete: true })
|
||||
const migrated = (database as unknown as { database: DatabaseSync }).database
|
||||
migrated.exec('PRAGMA wal_checkpoint(TRUNCATE); PRAGMA wal_autocheckpoint=0')
|
||||
for (let index = 0; index < 10; index++) {
|
||||
records[0]!.detail += 'x'
|
||||
database.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: true })
|
||||
}
|
||||
const afterBytes = statSync(`${path}-wal`).size
|
||||
console.log({ beforeBytes, afterBytes })
|
||||
expect(afterBytes).toBeLessThan(beforeBytes / 100)
|
||||
database.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: true })
|
||||
expect(statSync(`${path}-wal`).size).toBe(afterBytes)
|
||||
records.unshift(records.pop()!)
|
||||
records.splice(2, 1)
|
||||
database.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: false })
|
||||
database.close()
|
||||
database.initialize(directory)
|
||||
expect(database.getActivityHistory()).toEqual({ records, legacyHistoryMayBeIncomplete: false })
|
||||
} finally {
|
||||
database.close()
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -118,7 +118,7 @@ import {
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 43
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 44
|
||||
|
||||
export type RemoteTaskEventInput = {
|
||||
taskId: string
|
||||
@@ -1838,7 +1838,7 @@ export class AssistantDatabase {
|
||||
const database = new DatabaseSync(this.databasePath, { timeout: 5_000 })
|
||||
try {
|
||||
database.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;')
|
||||
this.migrate(database)
|
||||
this.migrate(database, onProgress, isCancelled)
|
||||
this.database = database
|
||||
this.repairMagicNoteStorage(onProgress, isCancelled)
|
||||
if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
@@ -2254,13 +2254,14 @@ export class AssistantDatabase {
|
||||
'channel_events',
|
||||
'tasks',
|
||||
'messages',
|
||||
'conversations'
|
||||
'conversations',
|
||||
'activity_history_records'
|
||||
]) {
|
||||
database.exec(`DELETE FROM ${table}`)
|
||||
}
|
||||
database.exec(
|
||||
`UPDATE activity_history
|
||||
SET records_json = '[]', legacy_history_may_be_incomplete = 0
|
||||
SET record_order_json = '[]', legacy_history_may_be_incomplete = 0
|
||||
WHERE singleton = 1`
|
||||
)
|
||||
database.exec('COMMIT')
|
||||
@@ -4838,20 +4839,27 @@ export class AssistantDatabase {
|
||||
}
|
||||
|
||||
getActivityHistory(): ActivityHistorySnapshot {
|
||||
const row = this.requireDatabase()
|
||||
const database = this.requireDatabase()
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT records_json, legacy_history_may_be_incomplete
|
||||
`SELECT record_order_json, legacy_history_may_be_incomplete
|
||||
FROM activity_history
|
||||
WHERE singleton = 1`
|
||||
)
|
||||
.get() as
|
||||
| {
|
||||
records_json: string
|
||||
record_order_json: string
|
||||
legacy_history_may_be_incomplete: number
|
||||
}
|
||||
| undefined
|
||||
const records = database.prepare(
|
||||
`SELECT records.record_json
|
||||
FROM json_each(?) AS ordering
|
||||
JOIN activity_history_records AS records ON records.record_key = ordering.value
|
||||
ORDER BY CAST(ordering.key AS INTEGER)`
|
||||
).all(row?.record_order_json ?? '[]') as Array<{ record_json: string }>
|
||||
return activityHistorySnapshotSchema.parse({
|
||||
records: row ? JSON.parse(row.records_json) : [],
|
||||
records: records.map((record) => JSON.parse(record.record_json)),
|
||||
legacyHistoryMayBeIncomplete:
|
||||
row?.legacy_history_may_be_incomplete === 1
|
||||
})
|
||||
@@ -4859,16 +4867,53 @@ export class AssistantDatabase {
|
||||
|
||||
replaceActivityHistory(input: ActivityHistorySnapshot): void {
|
||||
const snapshot = activityHistorySnapshotSchema.parse(input)
|
||||
this.requireDatabase()
|
||||
.prepare(
|
||||
`UPDATE activity_history
|
||||
SET records_json = ?, legacy_history_may_be_incomplete = ?
|
||||
WHERE singleton = 1`
|
||||
)
|
||||
.run(
|
||||
JSON.stringify(snapshot.records),
|
||||
Number(snapshot.legacyHistoryMayBeIncomplete)
|
||||
)
|
||||
const database = this.requireDatabase()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.writeActivityHistory(database, snapshot)
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private writeActivityHistory(
|
||||
database: DatabaseSync,
|
||||
snapshot: ActivityHistorySnapshot,
|
||||
onProgress?: (processed: number) => void
|
||||
): void {
|
||||
const rows = database.prepare(
|
||||
'SELECT record_key, record_json FROM activity_history_records'
|
||||
).all() as Array<{ record_key: string; record_json: string }>
|
||||
const previous = new Map(rows.map((row) => [row.record_key, row.record_json]))
|
||||
const occurrences = new Map<string, number>()
|
||||
const order: string[] = []
|
||||
const upsert = database.prepare(
|
||||
`INSERT INTO activity_history_records (record_key, record_json) VALUES (?, ?)
|
||||
ON CONFLICT(record_key) DO UPDATE SET record_json = excluded.record_json`
|
||||
)
|
||||
for (const record of snapshot.records) {
|
||||
if (order.length % 128 === 0) onProgress?.(order.length)
|
||||
// IDs were never unique in the snapshot schema; retain every occurrence.
|
||||
const occurrence = occurrences.get(record.id) ?? 0
|
||||
occurrences.set(record.id, occurrence + 1)
|
||||
const key = JSON.stringify([record.id, occurrence])
|
||||
const json = JSON.stringify(record)
|
||||
order.push(key)
|
||||
if (previous.get(key) !== json) upsert.run(key, json)
|
||||
previous.delete(key)
|
||||
}
|
||||
const remove = database.prepare('DELETE FROM activity_history_records WHERE record_key = ?')
|
||||
for (const key of previous.keys()) remove.run(key)
|
||||
const orderJson = JSON.stringify(order)
|
||||
const incomplete = Number(snapshot.legacyHistoryMayBeIncomplete)
|
||||
database.prepare(
|
||||
`UPDATE activity_history
|
||||
SET record_order_json = ?, legacy_history_may_be_incomplete = ?
|
||||
WHERE singleton = 1
|
||||
AND (record_order_json != ? OR legacy_history_may_be_incomplete != ?)`
|
||||
).run(orderJson, incomplete, orderJson, incomplete)
|
||||
}
|
||||
|
||||
createTask(input: {
|
||||
@@ -9131,7 +9176,11 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
private migrate(database: DatabaseSync): void {
|
||||
private migrate(
|
||||
database: DatabaseSync,
|
||||
onProgress?: (progress: AssistantStorageProgress) => void,
|
||||
isCancelled?: () => boolean
|
||||
): void {
|
||||
const version = database
|
||||
.prepare('PRAGMA user_version')
|
||||
.get() as { user_version: number }
|
||||
@@ -10992,6 +11041,42 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (version.user_version < 44) {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const bytesBefore = onProgress && this.databasePath !== ':memory:'
|
||||
? statSync(this.databasePath).size
|
||||
: 0
|
||||
onProgress?.({ stage: 'scanning', processed: 0, total: 0, bytesBefore })
|
||||
if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
const row = database.prepare(
|
||||
'SELECT records_json, legacy_history_may_be_incomplete FROM activity_history WHERE singleton = 1'
|
||||
).get() as { records_json: string; legacy_history_may_be_incomplete: number }
|
||||
onProgress?.({ stage: 'converting', processed: 0, total: 0, bytesBefore })
|
||||
if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
const snapshot = activityHistorySnapshotSchema.parse({
|
||||
records: JSON.parse(row.records_json),
|
||||
legacyHistoryMayBeIncomplete: row.legacy_history_may_be_incomplete === 1
|
||||
})
|
||||
database.exec(`
|
||||
CREATE TABLE activity_history_records (
|
||||
record_key TEXT PRIMARY KEY NOT NULL,
|
||||
record_json TEXT NOT NULL
|
||||
);
|
||||
ALTER TABLE activity_history RENAME COLUMN records_json TO record_order_json;
|
||||
`)
|
||||
this.writeActivityHistory(database, snapshot, onProgress || isCancelled ? (processed) => {
|
||||
onProgress?.({ stage: 'converting', processed, total: snapshot.records.length, bytesBefore })
|
||||
if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
} : undefined)
|
||||
onProgress?.({ stage: 'converting', processed: snapshot.records.length, total: snapshot.records.length, bytesBefore })
|
||||
if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
||||
database.exec('PRAGMA user_version = 44; COMMIT;')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requireDatabase(): DatabaseSync {
|
||||
|
||||
@@ -96,7 +96,10 @@ describe('subagent progress storage', () => {
|
||||
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('PRAGMA user_version = 37')
|
||||
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;
|
||||
PRAGMA user_version = 37`)
|
||||
} finally { legacy.close() }
|
||||
expect(hasPendingAssistantStorageUpgrade(path)).toBe(true)
|
||||
upgradeAssistantStorage(path, () => undefined)
|
||||
@@ -408,6 +411,9 @@ describe('subagent progress storage', () => {
|
||||
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;
|
||||
PRAGMA user_version = ${sourceVersion}; BEGIN`)
|
||||
const insert = legacy.prepare(
|
||||
`INSERT INTO task_events(
|
||||
|
||||
@@ -4403,6 +4403,57 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(headers).toHaveValue('{"Authorization":"replacement"}')
|
||||
})
|
||||
|
||||
it.each([false, true])('resolves a fresh persisted store after adding a text connection (default: %s)', async (makeDefault) => {
|
||||
const { mkdtemp, rm } = await import('node:fs/promises')
|
||||
const { tmpdir } = await import('node:os')
|
||||
const { join } = await import('node:path')
|
||||
// Load main-process code only in this integration test, outside the web TS project.
|
||||
const storeModule = '../../main/runtime-settings-store'
|
||||
const selectionModule = '../../main/agent/runtime-selection'
|
||||
const { RuntimeSettingsStore } = await import(storeModule)
|
||||
const { applyRuntimeSelection } = await import(selectionModule)
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-fresh-selection-'))
|
||||
const cipher = {
|
||||
isAvailable: () => false,
|
||||
encrypt: () => { throw new Error('No credentials in this test') },
|
||||
decrypt: () => { throw new Error('No credentials in this test') }
|
||||
}
|
||||
const path = join(root, 'runtime-settings.json')
|
||||
const store = new RuntimeSettingsStore(path, cipher, {})
|
||||
try {
|
||||
const fresh = await store.getPublicSettings()
|
||||
expect(fresh.opencodeModelSource).toEqual({ kind: 'default' })
|
||||
expect(fresh.modelName).toBe('qwen3')
|
||||
getRuntime.mockResolvedValueOnce(fresh)
|
||||
updateRuntime.mockImplementationOnce((input) => store.update(input))
|
||||
render(<SettingsPanel {...heartbeatSettingsProps} open
|
||||
onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
|
||||
await screen.findByDisplayValue('qwen3')
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加自定义' }))
|
||||
fireEvent.change(screen.getByLabelText('模型接口 URL'), {
|
||||
target: { value: 'http://127.0.0.1:12345/v1' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('模型'), { target: { value: 'isolated-new-model' } })
|
||||
if (makeDefault) fireEvent.click(screen.getByRole('radio', { name: '默认连接' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||
await waitFor(() => expect(updateRuntime).toHaveBeenCalled())
|
||||
await updateRuntime.mock.results[0]!.value
|
||||
const reloaded = new RuntimeSettingsStore(path, cipher, {})
|
||||
const selected = applyRuntimeSelection(await reloaded.getResolvedSettings(), { provider: 'opencode' })
|
||||
expect(selected.settings.opencodeEmbedded).toBe(true)
|
||||
expect(selected.settings.opencodeModelProfile).toMatchObject({
|
||||
modelName: makeDefault ? 'isolated-new-model' : 'qwen3',
|
||||
baseUrl: makeDefault ? 'http://127.0.0.1:12345/v1' : 'http://127.0.0.1:11434/v1',
|
||||
authentication: 'none'
|
||||
})
|
||||
expect((await reloaded.getPublicSettings()).opencodeModelSource).toEqual({ kind: 'default' })
|
||||
} finally {
|
||||
cleanup()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps saved Runtime sources valid when defaulting a new text profile', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
|
||||
+29
-2
@@ -1,5 +1,12 @@
|
||||
import { availableParallelism } from 'node:os'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { configDefaults, defineConfig } from 'vitest/config'
|
||||
|
||||
const serialTests = [
|
||||
...configDefaults.include.map((pattern) => `tests/${pattern}`),
|
||||
'src/main/agent/**/*runtime*.test.ts',
|
||||
'src/agent-daemon/private-endpoint.test.ts'
|
||||
]
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -14,7 +21,27 @@ export default defineConfig({
|
||||
'**/build-server/**',
|
||||
'**/.git/**'
|
||||
],
|
||||
maxWorkers: 1,
|
||||
maxWorkers: Math.min(4, availableParallelism()),
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: 'unit',
|
||||
exclude: serialTests,
|
||||
sequence: { groupOrder: 0 }
|
||||
}
|
||||
},
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: 'integration',
|
||||
include: serialTests,
|
||||
maxWorkers: 1,
|
||||
// Keep process/socket tests separate from the parallel unit phase.
|
||||
sequence: { groupOrder: 1 }
|
||||
}
|
||||
}
|
||||
],
|
||||
coverage: {
|
||||
reporter: ['text', 'html']
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user