fix: normalize legacy tool names and harden cli chat command

- Add normalizeBuiltinToolNames() to map old session-based tool names
  (search_sessions, get_session_messages, get_session_summaries) to
  current segment names; applied in assistant parser, agent-stream-runner,
  and desktop tool-filter so existing assistant configs keep working
- Propagate agent stream error chunks as thrown Errors in CLI chat command
- Pass assistantId through ResolvedAIChatTarget to agent stream
- Preserve legacy chat_session rows in v6 migration when segment table
  already exists (INSERT OR IGNORE + DROP); revert v3/v5 DDL to chat_session
- Rename session_id alias to segment_id in relationship query
- Add tests: assistant-parser normalization, tool-filter legacy names,
  agent-stream-runner legacy allowlist, REPL error resilience, migration paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
diguaandClaude Sonnet 4.6 committed 2026-06-07 20:27:27 +08:00
1 parent 9b17b09354
commit 62ad3c6248
14 files changed
+422 -43

No files matched your search

@@ -40,6 +40,17 @@ describe('CLI chart capability tool filtering', () => {
assert.ok(!toolNames.includes('execute_sql'))
})
it('accepts legacy session tool names in assistant allowlists', () => {
const allowedToolSet = getAllowedToolSet(false, ['get_session_summaries'])
assert.ok(allowedToolSet instanceof Set)
const toolNames = getAvailableToolDefs(false, allowedToolSet).map((tool) => tool.name)
assert.ok(toolNames.includes('get_segment_summaries'))
assert.ok(!toolNames.includes('get_session_summaries'))
})
it('preserves an empty assistant allowlist instead of treating it as unrestricted', () => {
const allowedToolSet = getAllowedToolSet(false, [])
+3 -3
View File
@@ -14,7 +14,7 @@ import {
getSkillConfigWithBuiltinChart,
resolveChartRuntimeForRequest,
} from '@openchatlab/node-runtime'
import { getChatOverview } from '@openchatlab/core'
import { getChatOverview, normalizeBuiltinToolNames } from '@openchatlab/core'
import type { DataSnapshot } from '@openchatlab/node-runtime'
import type { AgentStreamRequest } from '@openchatlab/http-routes'
import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools'
@@ -54,9 +54,9 @@ export function getAllowedToolSet(
allowedBuiltinTools?: readonly string[]
): Set<string> | null {
if (isChartCapability) {
return new Set(allowedBuiltinTools)
return new Set(normalizeBuiltinToolNames(allowedBuiltinTools ?? []))
}
return allowedBuiltinTools === undefined ? null : new Set(allowedBuiltinTools)
return allowedBuiltinTools === undefined ? null : new Set(normalizeBuiltinToolNames(allowedBuiltinTools))
}
export function createCliRunAgentStream(
+132 -6
View File
@@ -1,8 +1,9 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { Readable } from 'node:stream'
import { Writable } from 'node:stream'
import type { AIChatManager, DatabaseManager } from '@openchatlab/node-runtime'
import { resolveAIChatTarget, runChatTurn } from './chat-command'
import { resolveAIChatTarget, runChatCommand, runChatTurn } from './chat-command'
function createDbManager(sessionIds: string[]): DatabaseManager {
return {
@@ -11,9 +12,11 @@ function createDbManager(sessionIds: string[]): DatabaseManager {
} as unknown as DatabaseManager
}
function createAIChatManager(existing: Array<{ id: string; sessionId: string }> = []): AIChatManager {
function createAIChatManager(
existing: Array<{ id: string; sessionId: string; assistantId?: string }> = []
): AIChatManager {
const chats = new Map<string, { id: string; sessionId: string; title: string | null; assistantId: string }>(
existing.map((chat) => [chat.id, { ...chat, title: null, assistantId: 'general_cn' }])
existing.map((chat) => [chat.id, { ...chat, title: null, assistantId: chat.assistantId ?? 'general_cn' }])
)
const messages: Array<{ aiChatId: string; role: string; content: string }> = []
@@ -35,9 +38,12 @@ function createAIChatManager(existing: Array<{ id: string; sessionId: string }>
class MemoryWritable extends Writable {
chunks: string[] = []
onChunk?: (text: string) => void
_write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
this.chunks.push(String(chunk))
const text = String(chunk)
this.chunks.push(text)
this.onChunk?.(text)
callback()
}
@@ -46,6 +52,28 @@ class MemoryWritable extends Writable {
}
}
class PromptDrivenReadable extends Readable {
private nextLines: string[]
constructor(lines: string[]) {
super()
this.nextLines = lines
}
_read(): void {
// Input is pushed when the CLI writes a prompt.
}
pushNext(): void {
const line = this.nextLines.shift()
if (line === undefined) {
this.push(null)
return
}
this.push(`${line}\n`)
}
}
describe('resolveAIChatTarget', () => {
it('creates a new AI chat for an explicit session id', () => {
const target = resolveAIChatTarget(
@@ -67,7 +95,12 @@ describe('resolveAIChatTarget', () => {
}
)
assert.deepEqual(target, { sessionId: 'session-1', aiChatId: 'ai-chat-1', created: false })
assert.deepEqual(target, {
sessionId: 'session-1',
aiChatId: 'ai-chat-1',
assistantId: 'general_cn',
created: false,
})
})
it('rejects mismatched explicit session id and aiChatId', () => {
@@ -89,6 +122,7 @@ describe('runChatTurn', () => {
it('collects streamed answer and persists user and assistant messages', async () => {
const stdout = new MemoryWritable()
const aiChatManager = createAIChatManager()
let streamedAssistantId: string | undefined
const result = await runChatTurn(
{ sessionId: 'session-1', question: 'hello', json: true },
{
@@ -96,7 +130,8 @@ describe('runChatTurn', () => {
pathProvider: {} as never,
aiChatManager,
stdout,
createRunAgentStream: () => async (_params, onEvent) => {
createRunAgentStream: () => async (params, onEvent) => {
streamedAssistantId = params.assistantId
onEvent({ type: 'content', content: 'hi' })
onEvent({
type: 'done',
@@ -111,10 +146,101 @@ describe('runChatTurn', () => {
assert.equal(result.aiChatId, 'ai_chat_1')
assert.equal(result.answer, 'hi')
assert.equal(result.usage.tokenUsage?.totalTokens, 2)
assert.equal(streamedAssistantId, 'general_cn')
assert.equal(stdout.text(), '')
assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages, [
{ aiChatId: 'ai_chat_1', role: 'user', content: 'hello' },
{ aiChatId: 'ai_chat_1', role: 'assistant', content: 'hi' },
])
})
it('fails the turn and does not persist messages when the agent stream reports an error', async () => {
const stdout = new MemoryWritable()
const aiChatManager = createAIChatManager()
await assert.rejects(
() =>
runChatTurn(
{ sessionId: 'session-1', question: 'hello', json: true },
{
dbManager: createDbManager(['session-1']),
pathProvider: {} as never,
aiChatManager,
stdout,
createRunAgentStream: () => async (_params, onEvent) => {
onEvent({ type: 'error', error: { name: 'ConfigError', message: 'LLM service not configured' } })
onEvent({ type: 'done', isFinished: true })
},
}
),
/LLM service not configured/
)
assert.equal(stdout.text(), '')
assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages, [])
})
it('passes the resolved AI chat assistant id into the agent stream', async () => {
const stdout = new MemoryWritable()
const aiChatManager = createAIChatManager([
{ id: 'ai-chat-1', sessionId: 'session-1', assistantId: 'custom_assistant' },
])
let streamedAssistantId: string | undefined
await runChatTurn(
{ aiChatId: 'ai-chat-1', question: 'hello', json: true },
{
dbManager: createDbManager(['session-1']),
pathProvider: {} as never,
aiChatManager,
stdout,
createRunAgentStream: () => async (params, onEvent) => {
streamedAssistantId = params.assistantId
onEvent({ type: 'content', content: 'hi' })
onEvent({ type: 'done', isFinished: true })
},
}
)
assert.equal(streamedAssistantId, 'custom_assistant')
})
})
describe('runChatCommand', () => {
it('keeps interactive mode alive after a single failed turn', async () => {
const stdout = new MemoryWritable()
const stderr = new MemoryWritable()
const stdin = new PromptDrivenReadable(['fail', 'recover', 'exit'])
const aiChatManager = createAIChatManager()
stdout.onChunk = (text) => {
if (text.includes('chatlab> ')) stdin.pushNext()
}
const command = runChatCommand(
{ sessionId: 'session-1' },
{
dbManager: createDbManager(['session-1']),
pathProvider: {} as never,
aiChatManager,
stdout,
stderr,
stdin,
createRunAgentStream: () => async (params, onEvent) => {
if (params.userMessage === 'fail') {
throw new Error('temporary failure')
}
onEvent({ type: 'content', content: 'recovered' })
onEvent({ type: 'done', isFinished: true })
},
}
)
await command
assert.match(stderr.text(), /temporary failure/)
assert.match(stdout.text(), /recovered/)
assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages, [
{ aiChatId: 'ai_chat_2', role: 'user', content: 'recover' },
{ aiChatId: 'ai_chat_2', role: 'assistant', content: 'recovered' },
])
})
})
+32 -7
View File
@@ -26,6 +26,7 @@ export interface ChatCommandDeps {
export interface ResolvedAIChatTarget {
sessionId: string
aiChatId: string
assistantId: string
created: boolean
}
@@ -50,6 +51,21 @@ function buildTitle(question?: string): string {
return trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed
}
function createAgentStreamError(error: unknown): Error {
if (error instanceof Error) return error
if (typeof error === 'string' && error) return new Error(error)
if (typeof error === 'object' && error !== null) {
const record = error as { name?: unknown; message?: unknown; stack?: unknown }
const streamError = new Error(
typeof record.message === 'string' && record.message ? record.message : 'Agent stream failed'
)
if (typeof record.name === 'string' && record.name) streamError.name = record.name
if (typeof record.stack === 'string' && record.stack) streamError.stack = record.stack
return streamError
}
return new Error('Agent stream failed')
}
function resolveSingleSessionId(dbManager: DatabaseManager): string {
const sessionIds = dbManager.listSessionIds()
if (sessionIds.length === 1) return sessionIds[0]!
@@ -79,13 +95,13 @@ export function resolveAIChatTarget(
throw new Error(`AI chat ${options.aiChatId} belongs to session ${aiChat.sessionId}, not ${options.sessionId}`)
}
assertSessionExists(deps.dbManager, aiChat.sessionId)
return { sessionId: aiChat.sessionId, aiChatId: aiChat.id, created: false }
return { sessionId: aiChat.sessionId, aiChatId: aiChat.id, assistantId: aiChat.assistantId, created: false }
}
const sessionId = options.sessionId ?? resolveSingleSessionId(deps.dbManager)
assertSessionExists(deps.dbManager, sessionId)
const aiChat = deps.aiChatManager.createAIChat(sessionId, buildTitle(options.question), 'general_cn')
return { sessionId, aiChatId: aiChat.id, created: true }
return { sessionId, aiChatId: aiChat.id, assistantId: aiChat.assistantId, created: true }
}
export async function runChatTurn(
@@ -98,6 +114,7 @@ export async function runChatTurn(
const startedAt = Date.now()
let answer = ''
let tokenUsage: TokenUsageData | null = null
let streamError: Error | null = null
const runAgentStream = (deps.createRunAgentStream ?? createCliRunAgentStream)(deps.dbManager, deps.aiChatManager)
@@ -106,10 +123,15 @@ export async function runChatTurn(
userMessage: options.question,
sessionId: target.sessionId,
aiChatId: target.aiChatId,
assistantId: target.assistantId,
chatType: 'group',
locale: options.locale ?? 'zh-CN',
},
(chunk: AgentStreamChunk) => {
if (chunk.type === 'error') {
streamError = createAgentStreamError(chunk.error)
return
}
if (chunk.type === 'content' && chunk.content) {
answer += chunk.content
if (!options.json && options.stream !== false) write(stdout, chunk.content)
@@ -121,6 +143,8 @@ export async function runChatTurn(
new AbortController().signal
)
if (streamError) throw streamError
if (!options.json && options.stream !== false && answer && !answer.endsWith('\n')) {
write(stdout, '\n')
}
@@ -172,12 +196,13 @@ export async function runChatCommand(options: ChatCommandOptions, deps: ChatComm
while (true) {
const question = (await rl.question('chatlab> ')).trim()
if (!question || question === 'exit' || question === 'quit') break
const result = await runChatTurn({ ...options, aiChatId, question, json: false, stream: true }, deps)
aiChatId = result.aiChatId
try {
const result = await runChatTurn({ ...options, aiChatId, question, json: false, stream: true }, deps)
aiChatId = result.aiChatId
} catch (error) {
write(stderr, `${error instanceof Error ? error.message : String(error)}\n`)
}
}
} catch (error) {
write(stderr, `${error instanceof Error ? error.message : String(error)}\n`)
throw error
} finally {
rl.close()
}
@@ -13,4 +13,8 @@ describe('Electron analysis tool filtering', () => {
assert.equal(isAnalysisToolAllowed('keyword_frequency', ['keyword_frequency']), true)
assert.equal(isAnalysisToolAllowed('execute_sql', ['keyword_frequency']), false)
})
it('accepts legacy session tool names in assistant allowlists', () => {
assert.equal(isAnalysisToolAllowed('get_segment_summaries', ['get_session_summaries']), true)
})
})
+3 -1
View File
@@ -1,3 +1,5 @@
import { normalizeBuiltinToolNames } from '@openchatlab/core'
export function isAnalysisToolAllowed(toolName: string, allowedTools?: readonly string[] | null): boolean {
return !!allowedTools?.includes(toolName)
return !!allowedTools && normalizeBuiltinToolNames(allowedTools).includes(toolName)
}
+1 -1
View File
@@ -4,7 +4,7 @@
// 内置工具目录
export type { ToolCategory, BuiltinToolCatalogEntry } from './tool-catalog'
export { BUILTIN_TOOL_CATALOG } from './tool-catalog'
export { BUILTIN_TOOL_CATALOG, normalizeBuiltinToolName, normalizeBuiltinToolNames } from './tool-catalog'
export { CHART_CAPABILITY_SKILL_ID } from './chart-capability'
// LLM 模型系统类型
+14
View File
@@ -12,6 +12,20 @@ export interface BuiltinToolCatalogEntry {
category: ToolCategory
}
const LEGACY_BUILTIN_TOOL_NAME_ALIASES: Record<string, string> = {
search_sessions: 'search_segments',
get_session_messages: 'get_segment_messages',
get_session_summaries: 'get_segment_summaries',
}
export function normalizeBuiltinToolName(toolName: string): string {
return LEGACY_BUILTIN_TOOL_NAME_ALIASES[toolName] ?? toolName
}
export function normalizeBuiltinToolNames(toolNames: readonly string[]): string[] {
return Array.from(new Set(toolNames.map(normalizeBuiltinToolName)))
}
export const BUILTIN_TOOL_CATALOG: BuiltinToolCatalogEntry[] = [
// Core 工具
{ name: 'get_chat_overview', category: 'core' },
+1 -1
View File
@@ -230,7 +230,7 @@ export type {
// AI(内置工具目录、LLM 模型系统等静态数据)
export type { ToolCategory, BuiltinToolCatalogEntry } from './ai'
export { BUILTIN_TOOL_CATALOG } from './ai'
export { BUILTIN_TOOL_CATALOG, normalizeBuiltinToolName, normalizeBuiltinToolNames } from './ai'
export type {
ProviderKind,
ProviderDefinition,
@@ -102,9 +102,9 @@ export function getRelationshipStats(
const whereClause = timeConditions.length > 0 ? `WHERE ${timeConditions.join(' AND ')}` : ''
const sessionRows = db
const segmentRows = db
.prepare(
`SELECT cs.id AS session_id, cs.start_ts, cs.end_ts,
`SELECT cs.id AS segment_id, cs.start_ts, cs.end_ts,
(SELECT m.sender_id FROM message_context mc JOIN message m ON m.id = mc.message_id
WHERE mc.segment_id = cs.id ORDER BY m.ts ASC, m.id ASC LIMIT 1) AS initiator_id,
(SELECT m.sender_id FROM message_context mc JOIN message m ON m.id = mc.message_id
@@ -112,7 +112,7 @@ export function getRelationshipStats(
FROM segment cs ${whereClause} ORDER BY cs.start_ts ASC`
)
.all(...params) as Array<{
session_id: number
segment_id: number
start_ts: number
end_ts: number
initiator_id: number | null
@@ -135,7 +135,7 @@ export function getRelationshipStats(
let totalIceBreaks = 0
let prevEndTs: number | null = null
for (const row of sessionRows) {
for (const row of segmentRows) {
const month = toLocalMonth(row.start_ts)
if (!monthMap.has(month)) monthMap.set(month, { initiateMap: new Map(), closeMap: new Map(), totalSessions: 0 })
const ms = monthMap.get(month)!
@@ -192,13 +192,13 @@ export function getRelationshipStats(
}
}
const sessionIdList = sessionRows.map((r) => r.session_id)
const msgStats = queryMessageLevelStats(db, sessionIdList, memberNames, perseveranceThreshold)
const segmentIdList = segmentRows.map((r) => r.segment_id)
const msgStats = queryMessageLevelStats(db, segmentIdList, memberNames, perseveranceThreshold)
return {
months,
members,
totalSessions: sessionRows.length,
totalSessions: segmentRows.length,
hasSessionIndex: true,
iceBreakers,
totalIceBreaks,
@@ -209,7 +209,7 @@ export function getRelationshipStats(
function queryMessageLevelStats(
db: DatabaseAdapter,
sessionIds: number[],
segmentIds: number[],
memberNames: Map<number, string>,
perseveranceThreshold: number
): {
@@ -226,7 +226,7 @@ function queryMessageLevelStats(
monthlyResponseLatency: [],
monthlyPerseverance: [],
}
if (sessionIds.length === 0) return empty
if (segmentIds.length === 0) return empty
const BATCH_SIZE = 500
const responseTotals = new Map<number, { sum: number; count: number }>()
@@ -234,8 +234,8 @@ function queryMessageLevelStats(
const monthlyRespMap = new Map<string, Map<number, { sum: number; count: number }>>()
const monthlyDtMap = new Map<string, Map<number, number>>()
for (let i = 0; i < sessionIds.length; i += BATCH_SIZE) {
const batch = sessionIds.slice(i, i + BATCH_SIZE)
for (let i = 0; i < segmentIds.length; i += BATCH_SIZE) {
const batch = segmentIds.slice(i, i + BATCH_SIZE)
const placeholders = batch.map(() => '?').join(',')
const latencyRows = db
@@ -0,0 +1,29 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { parseAssistantFile } from '../assistant-parser'
describe('parseAssistantFile', () => {
it('normalizes legacy session tool names in allowedBuiltinTools', () => {
const config = parseAssistantFile(
`---
id: legacy_tools
name: Legacy Tools
allowedBuiltinTools:
- search_sessions
- get_session_messages
- get_session_summaries
- keyword_frequency
---
Use selected tools.`,
'legacy_tools.md'
)
assert.ok(config)
assert.deepEqual(config.allowedBuiltinTools, [
'search_segments',
'get_segment_messages',
'get_segment_summaries',
'keyword_frequency',
])
})
})
@@ -4,6 +4,7 @@
import * as path from 'path'
import matter from 'gray-matter'
import { normalizeBuiltinToolNames } from '@openchatlab/core'
import type { AssistantConfig } from './types'
export function parseAssistantFile(content: string, filePath: string): AssistantConfig | null {
@@ -19,7 +20,7 @@ export function parseAssistantFile(content: string, filePath: string): Assistant
name,
systemPrompt: body.trim(),
presetQuestions: parseStringArray(fm.presetQuestions),
allowedBuiltinTools: parseStringArray(fm.allowedBuiltinTools),
allowedBuiltinTools: normalizeBuiltinToolNames(parseStringArray(fm.allowedBuiltinTools)),
builtinId: typeof fm.builtinId === 'string' ? fm.builtinId : undefined,
applicableChatTypes: parseChatTypes(fm.applicableChatTypes),
supportedLocales: parseStringArray(fm.supportedLocales),
@@ -38,7 +39,7 @@ export function serializeAssistant(config: AssistantConfig): string {
if (config.builtinId) fm.builtinId = config.builtinId
if (config.applicableChatTypes?.length) fm.applicableChatTypes = config.applicableChatTypes
if (config.supportedLocales?.length) fm.supportedLocales = config.supportedLocales
if (config.allowedBuiltinTools?.length) fm.allowedBuiltinTools = config.allowedBuiltinTools
if (config.allowedBuiltinTools?.length) fm.allowedBuiltinTools = normalizeBuiltinToolNames(config.allowedBuiltinTools)
if (config.presetQuestions?.length) fm.presetQuestions = config.presetQuestions
return matter.stringify(`\n${config.systemPrompt}\n`, fm)
@@ -131,7 +131,7 @@ test('open backfills FTS index when migrating legacy sessions', () => {
assert.ok(db)
const version = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number }
assert.equal(version.schema_version, 5)
assert.equal(version.schema_version, CURRENT_SCHEMA_VERSION)
const ftsCount = db.prepare('SELECT COUNT(*) as total FROM message_fts').get() as { total: number }
assert.equal(ftsCount.total, 1)
@@ -144,6 +144,163 @@ test('open backfills FTS index when migrating legacy sessions', () => {
manager.closeAll()
})
test('open migrates v2 chat_session schema to current segment schema', () => {
const root = makeTempDir()
const dbDir = path.join(root, 'data', 'databases')
fs.mkdirSync(dbDir, { recursive: true })
const dbPath = path.join(dbDir, 'v2-segment-schema.db')
const rawDb = new Database(dbPath, { nativeBinding })
rawDb.exec(`
CREATE TABLE meta (
name TEXT NOT NULL,
platform TEXT NOT NULL,
type TEXT NOT NULL,
imported_at INTEGER NOT NULL,
schema_version INTEGER DEFAULT 2
);
INSERT INTO meta (name, platform, type, imported_at, schema_version)
VALUES ('V2 Segment Schema', 'qq', 'group', 1000, 2);
CREATE TABLE member (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform_id TEXT NOT NULL UNIQUE,
account_name TEXT
);
INSERT INTO member (platform_id, account_name) VALUES ('u1', 'Alice');
CREATE TABLE message (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_id INTEGER NOT NULL,
ts INTEGER NOT NULL,
type INTEGER NOT NULL,
content TEXT,
platform_message_id TEXT DEFAULT NULL
);
INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'hello v2');
`)
rawDb.close()
const manager = new DatabaseManager(createPathProvider(root), { nativeBinding })
const db = manager.open('v2-segment-schema')
assert.ok(db)
const version = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number }
assert.equal(version.schema_version, CURRENT_SCHEMA_VERSION)
const segmentTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'segment'").get()
const legacyTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_session'").get()
assert.ok(segmentTable)
assert.equal(legacyTable, undefined)
const contextColumns = db.pragma('table_info(message_context)') as Array<{ name: string }>
assert.equal(
contextColumns.some((col) => col.name === 'segment_id'),
true
)
assert.equal(
contextColumns.some((col) => col.name === 'session_id'),
false
)
manager.closeAll()
})
test('open migrates legacy chat_session rows into segment after v5 creates segment table', () => {
const root = makeTempDir()
const dbDir = path.join(root, 'data', 'databases')
fs.mkdirSync(dbDir, { recursive: true })
const dbPath = path.join(dbDir, 'legacy-segments.db')
const rawDb = new Database(dbPath, { nativeBinding })
rawDb.exec(`
CREATE TABLE meta (
name TEXT NOT NULL,
platform TEXT NOT NULL,
type TEXT NOT NULL,
imported_at INTEGER NOT NULL,
schema_version INTEGER DEFAULT 4
);
INSERT INTO meta (name, platform, type, imported_at, schema_version)
VALUES ('Legacy Segment Chat', 'qq', 'group', 1000, 4);
CREATE TABLE member (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform_id TEXT NOT NULL UNIQUE,
account_name TEXT
);
INSERT INTO member (platform_id, account_name) VALUES ('u1', 'Alice');
CREATE TABLE message (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_id INTEGER NOT NULL,
ts INTEGER NOT NULL,
type INTEGER NOT NULL,
content TEXT
);
INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'hello segment');
CREATE TABLE chat_session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_ts INTEGER NOT NULL,
end_ts INTEGER NOT NULL,
message_count INTEGER DEFAULT 0,
is_manual INTEGER DEFAULT 0,
summary TEXT
);
INSERT INTO chat_session (id, start_ts, end_ts, message_count, is_manual, summary)
VALUES (7, 1000, 1010, 1, 0, 'legacy summary');
CREATE TABLE message_context (
message_id INTEGER PRIMARY KEY,
session_id INTEGER NOT NULL,
topic_id INTEGER
);
INSERT INTO message_context (message_id, session_id, topic_id) VALUES (1, 7, 3);
`)
rawDb.close()
const manager = new DatabaseManager(createPathProvider(root), { nativeBinding })
const db = manager.open('legacy-segments')
assert.ok(db)
const version = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number }
assert.equal(version.schema_version, CURRENT_SCHEMA_VERSION)
const legacyTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_session'").get()
assert.equal(legacyTable, undefined)
const segment = db.prepare('SELECT id, start_ts, end_ts, message_count, summary FROM segment').get() as
| { id: number; start_ts: number; end_ts: number; message_count: number; summary: string | null }
| undefined
assert.deepEqual(segment, {
id: 7,
start_ts: 1000,
end_ts: 1010,
message_count: 1,
summary: 'legacy summary',
})
const contextColumns = db.pragma('table_info(message_context)') as Array<{ name: string }>
assert.equal(
contextColumns.some((col) => col.name === 'segment_id'),
true
)
assert.equal(
contextColumns.some((col) => col.name === 'session_id'),
false
)
const context = db.prepare('SELECT message_id, segment_id, topic_id FROM message_context').get() as {
message_id: number
segment_id: number
topic_id: number
}
assert.deepEqual(context, { message_id: 1, segment_id: 7, topic_id: 3 })
manager.closeAll()
})
test('open preserves readonly access for current-schema databases', { skip: process.platform === 'win32' }, () => {
const root = makeTempDir()
const dbDir = path.join(root, 'data', 'databases')
@@ -71,10 +71,10 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] {
},
{
version: 3,
description: 'Add segment and message_context tables',
description: 'Add chat_session and message_context tables',
up: (db: DatabaseAdapter) => {
db.exec(`
CREATE TABLE IF NOT EXISTS segment (
CREATE TABLE IF NOT EXISTS chat_session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_ts INTEGER NOT NULL,
end_ts INTEGER NOT NULL,
@@ -85,7 +85,7 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] {
`)
try {
db.exec('CREATE INDEX IF NOT EXISTS idx_segment_time ON segment(start_ts, end_ts)')
db.exec('CREATE INDEX IF NOT EXISTS idx_session_time ON chat_session(start_ts, end_ts)')
} catch {
// Index may already exist
}
@@ -93,13 +93,13 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] {
db.exec(`
CREATE TABLE IF NOT EXISTS message_context (
message_id INTEGER PRIMARY KEY,
segment_id INTEGER NOT NULL,
session_id INTEGER NOT NULL,
topic_id INTEGER
)
`)
try {
db.exec('CREATE INDEX IF NOT EXISTS idx_context_segment ON message_context(segment_id)')
db.exec('CREATE INDEX IF NOT EXISTS idx_context_session ON message_context(session_id)')
} catch {
// Index may already exist
}
@@ -202,7 +202,7 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] {
addColumnIfMissing(db, 'message', 'platform_message_id', 'TEXT DEFAULT NULL')
db.exec(`
CREATE TABLE IF NOT EXISTS segment (
CREATE TABLE IF NOT EXISTS chat_session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_ts INTEGER NOT NULL,
end_ts INTEGER NOT NULL,
@@ -214,15 +214,15 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] {
db.exec(`
CREATE TABLE IF NOT EXISTS message_context (
message_id INTEGER PRIMARY KEY,
segment_id INTEGER NOT NULL,
session_id INTEGER NOT NULL,
topic_id INTEGER
)
`)
db.exec('CREATE INDEX IF NOT EXISTS idx_message_platform_id ON message(platform_message_id)')
db.exec('CREATE INDEX IF NOT EXISTS idx_member_name_history_member_id ON member_name_history(member_id)')
db.exec('CREATE INDEX IF NOT EXISTS idx_segment_time ON segment(start_ts, end_ts)')
db.exec('CREATE INDEX IF NOT EXISTS idx_context_segment ON message_context(segment_id)')
db.exec('CREATE INDEX IF NOT EXISTS idx_session_time ON chat_session(start_ts, end_ts)')
db.exec('CREATE INDEX IF NOT EXISTS idx_context_session ON message_context(session_id)')
},
},
{
@@ -241,8 +241,18 @@ export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] {
DROP INDEX IF EXISTS idx_context_session;
`)
if (hasTable('chat_session') && !hasTable('segment')) {
const hasLegacyChatSession = hasTable('chat_session')
const hasSegment = hasTable('segment')
if (hasLegacyChatSession && !hasSegment) {
db.exec('ALTER TABLE chat_session RENAME TO segment')
} else if (hasLegacyChatSession && hasSegment) {
db.exec(`
INSERT OR IGNORE INTO segment (id, start_ts, end_ts, message_count, is_manual, summary)
SELECT id, start_ts, end_ts, message_count, is_manual, summary
FROM chat_session;
DROP TABLE chat_session;
`)
}
if (hasTable('message_context') && hasColumn(db, 'message_context', 'session_id')) {