feat: group chat session lifecycle, typing recovery, mention highlighting (#186)
* feat: restore group chat system with Socket.IO and SQLite persistence - GroupChatServer: Socket.IO server with room management, message history, typing indicators - SQLite storage for rooms, messages, and agent configuration - AgentClients: manages AI agent connections via socket.io-client, forwards @mentions to Hermes gateway - REST API: room CRUD, agent management, invite codes - Agent auto-restoration on server restart - Tests for all REST endpoints Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add context-engine design document for group chat compression Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: handle special-character session search * fix: keep unicode dotted session search on quoted FTS path * feat: add context engine and group chat frontend UI - Context engine: three-zone compression (head/tail/summary) with LLM summarization, incremental updates, TTL cache, and graceful degradation - Frontend: group chat page with Socket.IO client, room sidebar, message list, agent/member display, create/join-by-code modals - Integration: wire context engine into agent-clients before /v1/runs - Refactor ChatStorage to use global DB (getDb/ensureTable) with gc_ prefix - Add i18n keys for group chat to all 8 locales - Add sidebar nav entry and router for group chat page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove leftover main branch code from merge conflict resolution The `isNumericQuery`, `hasUnsafeChars`, and `runLikeContentSearch` functions no longer exist — they were replaced by HEAD's `shouldUseLiteralContentSearch` and `runLiteralContentSearch`. This dead code block caused a TypeScript compile error after the merge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: install missing socket.io dep and type ack params Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: enable WebSocket proxy and fix socket.io transport for group chat - Add ws: true to Vite proxy config so WebSocket upgrade requests are forwarded to the backend - Allow both polling and websocket transports on server and client (polling as fallback when WebSocket upgrade fails through proxy) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: separate socket.io path from REST routes for group chat socket.io was mounted at /api/hermes/group-chat which intercepted all REST requests to /api/hermes/group-chat/rooms etc, returning "Transport unknown". Changed socket.io path to /api/hermes/group-chat/ws to avoid conflicts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: improve group chat UI, agent management, and socket.io reliability - Redesign GroupChatPanel with Naive UI, stacked agent avatars, and popover management - Match GroupChatInput style with single chat input, add IME composition handling - Add agent add/remove per room with profile selection and duplicate prevention - Use @multiavatar for SVG avatar generation with caching - Decouple joinRoom from socket.io, use REST API for data loading - Switch socket.io to default path with /group-chat namespace to avoid proxy conflicts - Restore agent connections after server is listening - Add getRoomDetail REST endpoint and duplicate agent prevention (409) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: server-side @mention routing with context compression status and queue - Move @mention detection from agent socket listeners to server-side processMentions() - Add per-room processing lock to block mention dispatch during compression - Queue mentions during processing, drain only the latest when ready - Emit context_status events (compressing/replying/ready) to room via Socket.IO - Frontend displays compression status indicator above input - Token-based compression trigger (100k threshold) with CJK-aware estimation - Fix compressor type errors (countTokens parameter type) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: improve group chat profile handling and session sync Refine group chat room/session behavior with per-room compression controls, sidebar updates, and better stale session cleanup so multi-profile group chat state stays consistent. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: group chat improvements — session lifecycle, typing recovery, mention highlighting - Fix cross-profile session deletion with deferred delete queue - Move saveSessionProfile to after gateway response confirmation - Replace all console.log with logger in group-chat modules - Add server-side typing/context_status state tracking for room rejoin - Fix @ mention popup position to follow cursor - Add @ mention highlighting (blue) in chat message content - Fix mention regex to match all occurrences after HTML tags - Enable esbuild minify and treeShaking - Move @multiavatar/multiavatar to devDependencies - Add i18n keys for group chat features - Update tests for new functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version to 0.4.5 and move @multiavatar to devDependencies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Zhicheng Han <zhicheng.han@mathematik.uni-goettingen.de>
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SummaryCache } from '../../packages/server/src/services/hermes/context-engine/summary-cache'
|
||||
import {
|
||||
buildAgentInstructions,
|
||||
buildSummarizationSystemPrompt,
|
||||
buildFullSummaryPrompt,
|
||||
buildIncrementalUpdatePrompt,
|
||||
} from '../../packages/server/src/services/hermes/context-engine/prompt'
|
||||
import { ContextEngine } from '../../packages/server/src/services/hermes/context-engine/compressor'
|
||||
import type { StoredMessage, MessageFetcher, GatewayCaller } from '../../packages/server/src/services/hermes/context-engine/types'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function makeMessage(overrides: Partial<StoredMessage> = {}): StoredMessage {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
roomId: 'room-1',
|
||||
senderId: 'user-1',
|
||||
senderName: 'Alice',
|
||||
content: 'Hello world',
|
||||
timestamp: 1000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMessages(count: number, roomId = 'room-1', startTimestamp = 1000): StoredMessage[] {
|
||||
return Array.from({ length: count }, (_, i) => makeMessage({
|
||||
id: `msg-${i}`,
|
||||
roomId,
|
||||
senderId: i % 3 === 0 ? 'agent-socket' : `user-${i}`,
|
||||
senderName: i % 3 === 0 ? 'Claude' : `User${i}`,
|
||||
content: `Message ${i} with some content`,
|
||||
timestamp: startTimestamp + i * 1000,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── SummaryCache ─────────────────────────────────────────────
|
||||
|
||||
describe('SummaryCache', () => {
|
||||
it('stores and retrieves entries', () => {
|
||||
const cache = new SummaryCache(60_000)
|
||||
cache.set('room-1', {
|
||||
summary: 'Summary text',
|
||||
lastMessageId: 'msg-10',
|
||||
lastMessageTimestamp: 5000,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
const entry = cache.get('room-1')
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.summary).toBe('Summary text')
|
||||
})
|
||||
|
||||
it('returns undefined for expired entries', () => {
|
||||
const cache = new SummaryCache(100) // 100ms TTL
|
||||
cache.set('room-1', {
|
||||
summary: 'Old summary',
|
||||
lastMessageId: 'msg-5',
|
||||
lastMessageTimestamp: 5000,
|
||||
createdAt: Date.now() - 200, // created 200ms ago
|
||||
})
|
||||
expect(cache.get('room-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('invalidates entries for a room', () => {
|
||||
const cache = new SummaryCache(60_000)
|
||||
cache.set('room-1', { summary: 'A', lastMessageId: 'msg-1', lastMessageTimestamp: 1000, createdAt: Date.now() })
|
||||
cache.set('room-2', { summary: 'C', lastMessageId: 'msg-3', lastMessageTimestamp: 3000, createdAt: Date.now() })
|
||||
|
||||
cache.invalidate('room-1')
|
||||
expect(cache.get('room-1')).toBeUndefined()
|
||||
expect(cache.get('room-2')).toBeDefined()
|
||||
})
|
||||
|
||||
it('enforces max entry limit', () => {
|
||||
const cache = new SummaryCache(60_000)
|
||||
// Fill cache beyond limit (internal MAX_ENTRIES = 200)
|
||||
for (let i = 0; i < 210; i++) {
|
||||
cache.set(`room-${i}`, {
|
||||
summary: `Summary ${i}`,
|
||||
lastMessageId: `msg-${i}`,
|
||||
lastMessageTimestamp: i * 1000,
|
||||
createdAt: Date.now() - (210 - i), // earlier entries have older createdAt
|
||||
})
|
||||
}
|
||||
// Cache should not exceed 200 entries
|
||||
expect(cache.size).toBeLessThanOrEqual(200)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Prompts ──────────────────────────────────────────────────
|
||||
|
||||
describe('prompts', () => {
|
||||
it('builds agent instructions with all fields', () => {
|
||||
const result = buildAgentInstructions({
|
||||
agentName: 'Claude',
|
||||
roomName: 'general',
|
||||
agentDescription: 'AI coding assistant',
|
||||
memberNames: ['Alice', 'Bob', 'Claude'],
|
||||
members: [
|
||||
{ userId: 'u1', name: 'Alice', description: 'dev' },
|
||||
{ userId: 'u2', name: 'Bob', description: 'designer' },
|
||||
{ userId: 'u3', name: 'Claude', description: '' },
|
||||
],
|
||||
})
|
||||
expect(result).toContain('"Claude"')
|
||||
expect(result).toContain('general')
|
||||
expect(result).toContain('AI coding assistant')
|
||||
expect(result).toContain('Alice')
|
||||
expect(result).toContain('Bob')
|
||||
expect(result).toContain('@Claude')
|
||||
})
|
||||
|
||||
it('builds agent instructions with empty member list', () => {
|
||||
const result = buildAgentInstructions({
|
||||
agentName: 'GPT',
|
||||
roomName: 'dev',
|
||||
agentDescription: 'Helper',
|
||||
memberNames: [],
|
||||
members: [],
|
||||
})
|
||||
expect(result).toContain('"GPT"')
|
||||
expect(result).toContain('未知')
|
||||
})
|
||||
|
||||
it('builds agent instructions using memberNames when members is empty', () => {
|
||||
const result = buildAgentInstructions({
|
||||
agentName: 'GPT',
|
||||
roomName: 'dev',
|
||||
agentDescription: 'Helper',
|
||||
memberNames: ['Alice', 'Bob'],
|
||||
members: [],
|
||||
})
|
||||
expect(result).toContain('Alice')
|
||||
expect(result).toContain('Bob')
|
||||
})
|
||||
|
||||
it('builds summarization system prompt', () => {
|
||||
const result = buildSummarizationSystemPrompt()
|
||||
expect(result).toContain('摘要')
|
||||
})
|
||||
|
||||
it('builds full summary prompt', () => {
|
||||
const result = buildFullSummaryPrompt()
|
||||
expect(result).toContain('摘要')
|
||||
})
|
||||
|
||||
it('builds incremental update prompt', () => {
|
||||
const result = buildIncrementalUpdatePrompt()
|
||||
expect(result).toContain('更新')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ContextEngine.buildContext ────────────────────────────────
|
||||
|
||||
describe('ContextEngine.buildContext', () => {
|
||||
let mockSummarize = vi.fn().mockResolvedValue({ summary: 'Summary of conversation.', sessionId: 'comp-1' })
|
||||
const mockGatewayCaller: GatewayCaller = {
|
||||
summarize: mockSummarize,
|
||||
}
|
||||
|
||||
let mockFetcher: MessageFetcher
|
||||
let engine: ContextEngine
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetcher = {
|
||||
getMessages: vi.fn().mockReturnValue([]),
|
||||
getContextSnapshot: vi.fn().mockReturnValue(null),
|
||||
saveContextSnapshot: vi.fn(),
|
||||
deleteContextSnapshot: vi.fn(),
|
||||
}
|
||||
engine = new ContextEngine({
|
||||
config: { maxHistoryTokens: 4000, tailMessageCount: 10, triggerTokens: 100_000, charsPerToken: 4, summarizationTimeoutMs: 30_000 },
|
||||
messageFetcher: mockFetcher,
|
||||
gatewayCaller: { summarize: mockSummarize },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns all messages as history when under threshold', async () => {
|
||||
const messages = makeMessages(10) // 10 messages, under trigger threshold
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Claude',
|
||||
agentDescription: 'Helper',
|
||||
agentSocketId: 'agent-socket',
|
||||
roomName: 'general',
|
||||
memberNames: ['Alice'],
|
||||
members: [{ userId: 'u1', name: 'Alice', description: '' }],
|
||||
upstream: 'http://localhost:8642',
|
||||
apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
})
|
||||
|
||||
expect(result.meta.totalMessages).toBe(10)
|
||||
expect(result.meta.compressed).toBe(false)
|
||||
expect(result.conversationHistory).toHaveLength(10)
|
||||
expect(result.instructions).toContain('Claude')
|
||||
// No LLM call for short conversations
|
||||
expect(mockSummarize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('splits into head/tail and compresses middle when over threshold', async () => {
|
||||
const messages = makeMessages(20)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'Claude',
|
||||
agentDescription: 'Helper',
|
||||
agentSocketId: 'agent-socket',
|
||||
roomName: 'general',
|
||||
memberNames: [],
|
||||
members: [],
|
||||
upstream: 'http://localhost:8642',
|
||||
apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
compression: { triggerTokens: 10 }, // Force compression with tiny threshold
|
||||
})
|
||||
|
||||
expect(result.meta.totalMessages).toBe(20)
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(mockSummarize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses cache hit when available and no new messages', async () => {
|
||||
const messages = makeMessages(20)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
// First call — creates snapshot (with forced compression)
|
||||
await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
compression: { triggerTokens: 10 },
|
||||
})
|
||||
|
||||
// Verify snapshot was saved
|
||||
expect(mockFetcher.saveContextSnapshot).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Simulate that the snapshot now exists in storage
|
||||
const savedSnapshot = mockFetcher.saveContextSnapshot.mock.calls[0]
|
||||
mockFetcher.getContextSnapshot = vi.fn().mockReturnValue({
|
||||
roomId: 'room-1',
|
||||
summary: savedSnapshot[1],
|
||||
lastMessageId: savedSnapshot[2],
|
||||
lastMessageTimestamp: savedSnapshot[3],
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
// Second call — cache hit (snapshot exists, same messages)
|
||||
const result2 = await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
})
|
||||
|
||||
expect(result2.meta.hadSnapshot).toBe(true)
|
||||
// Only one LLM call (from the first buildContext)
|
||||
expect(mockSummarize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does incremental update when cache hit with new messages', async () => {
|
||||
const messages = makeMessages(20)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
// First call — full compression (with forced compression)
|
||||
await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
compression: { triggerTokens: 10 },
|
||||
})
|
||||
|
||||
// Simulate that the snapshot now exists in storage
|
||||
const savedSnapshot = mockFetcher.saveContextSnapshot.mock.calls[0]
|
||||
mockFetcher.getContextSnapshot = vi.fn().mockReturnValue({
|
||||
roomId: 'room-1',
|
||||
summary: savedSnapshot[1],
|
||||
lastMessageId: savedSnapshot[2],
|
||||
lastMessageTimestamp: savedSnapshot[3],
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
expect(mockSummarize).toHaveBeenCalledTimes(1)
|
||||
// First call: no previousSummary (4 args, index 4 is undefined)
|
||||
const firstCallArgs = mockSummarize.mock.calls[0]
|
||||
expect(firstCallArgs[4]).toBeUndefined() // previousSummary not passed
|
||||
|
||||
// Insert a new message
|
||||
const middleInsert = makeMessage({
|
||||
id: 'msg-new', roomId: 'room-1', senderId: 'user-99',
|
||||
senderName: 'NewUser', content: 'New middle message', timestamp: 12000,
|
||||
})
|
||||
const updatedMessages = [...messages.slice(0, 9), middleInsert, ...messages.slice(9)]
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(updatedMessages)
|
||||
|
||||
// Second call — incremental update
|
||||
await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: updatedMessages[updatedMessages.length - 1],
|
||||
compression: { triggerTokens: 10 },
|
||||
})
|
||||
|
||||
expect(mockSummarize).toHaveBeenCalledTimes(2)
|
||||
// Second call: has previousSummary
|
||||
const secondCallArgs = mockSummarize.mock.calls[1]
|
||||
expect(secondCallArgs[4]).toBe('Summary of conversation.')
|
||||
})
|
||||
|
||||
it('falls back to no-summary on LLM failure', async () => {
|
||||
mockSummarize.mockRejectedValue(new Error('LLM timeout'))
|
||||
|
||||
const messages = makeMessages(20)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
compression: { triggerTokens: 10 },
|
||||
})
|
||||
|
||||
// Should not throw, and should still return history
|
||||
expect(result.conversationHistory.length).toBeGreaterThan(0)
|
||||
// No summary pair in the output
|
||||
expect(result.conversationHistory[0]?.content).not.toContain('Previous conversation summary')
|
||||
})
|
||||
|
||||
it('trims tail when over token budget', async () => {
|
||||
const engine = new ContextEngine({
|
||||
config: {
|
||||
maxHistoryTokens: 50, // very small budget
|
||||
tailMessageCount: 10,
|
||||
triggerTokens: 10, // force compression
|
||||
charsPerToken: 4,
|
||||
summarizationTimeoutMs: 30_000,
|
||||
},
|
||||
messageFetcher: mockFetcher,
|
||||
gatewayCaller: { summarize: mockSummarize },
|
||||
})
|
||||
|
||||
const messages = makeMessages(20)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
})
|
||||
|
||||
// History should be trimmed to fit within 50 tokens
|
||||
const totalChars = result.conversationHistory.reduce((sum, m) => sum + m.content.length, 0)
|
||||
const estimatedTokens = Math.ceil(totalChars / 4)
|
||||
expect(estimatedTokens).toBeLessThanOrEqual(50)
|
||||
})
|
||||
|
||||
it('maps agent messages to assistant role', async () => {
|
||||
const messages = [
|
||||
makeMessage({ senderId: 'user-1', senderName: 'Alice', content: 'Hello', timestamp: 1000 }),
|
||||
makeMessage({ senderId: 'agent-socket', senderName: 'Claude', content: 'Hi there', timestamp: 2000 }),
|
||||
]
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
})
|
||||
|
||||
// First message from user → 'user' role with name prefix
|
||||
expect(result.conversationHistory[0].role).toBe('user')
|
||||
expect(result.conversationHistory[0].content).toContain('[Alice]')
|
||||
|
||||
// Second message from agent → 'assistant' role, no prefix
|
||||
expect(result.conversationHistory[1].role).toBe('assistant')
|
||||
expect(result.conversationHistory[1].content).toBe('Hi there')
|
||||
})
|
||||
|
||||
it('maps other messages to user role with name prefix', async () => {
|
||||
const messages = [
|
||||
makeMessage({ senderId: 'user-2', senderName: 'Bob', content: 'Hey', timestamp: 1000 }),
|
||||
]
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
})
|
||||
|
||||
expect(result.conversationHistory[0].role).toBe('user')
|
||||
expect(result.conversationHistory[0].content).toBe('[Bob]: Hey')
|
||||
})
|
||||
|
||||
it('generates instructions with agent identity', async () => {
|
||||
const messages = makeMessages(1)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
const result = await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: 'Code helper', agentSocketId: 'agent-socket', roomName: 'dev',
|
||||
memberNames: ['Alice', 'Bob'],
|
||||
members: [
|
||||
{ userId: 'u1', name: 'Alice', description: 'dev' },
|
||||
{ userId: 'u2', name: 'Bob', description: 'designer' },
|
||||
],
|
||||
upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[0],
|
||||
})
|
||||
|
||||
expect(result.instructions).toContain('"Claude"')
|
||||
expect(result.instructions).toContain('Code helper')
|
||||
expect(result.instructions).toContain('dev')
|
||||
expect(result.instructions).toContain('Alice')
|
||||
})
|
||||
|
||||
it('invalidates room cache', async () => {
|
||||
// Create a snapshot via the fetcher mock
|
||||
mockFetcher.getContextSnapshot = vi.fn().mockReturnValue({
|
||||
roomId: 'room-1',
|
||||
summary: 'Test',
|
||||
lastMessageId: 'msg-10',
|
||||
lastMessageTimestamp: 1000,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
const messages = makeMessages(5)
|
||||
mockFetcher.getMessages = vi.fn().mockReturnValue(messages)
|
||||
|
||||
// Build context to create snapshot
|
||||
await engine.buildContext({
|
||||
roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude',
|
||||
agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general',
|
||||
memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null,
|
||||
currentMessage: messages[messages.length - 1],
|
||||
})
|
||||
|
||||
// Invalidate
|
||||
engine.invalidateRoom('room-1')
|
||||
expect(mockFetcher.deleteContextSnapshot).toHaveBeenCalledWith('room-1')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@ const contentAllMock = vi.fn()
|
||||
const likeAllMock = vi.fn()
|
||||
const prepareMock = vi.fn((sql: string) => {
|
||||
if (sql.includes('messages_fts MATCH')) return ({ all: contentAllMock })
|
||||
if (sql.includes('m.content LIKE ?')) return ({ all: likeAllMock })
|
||||
if (sql.includes("LOWER(COALESCE(base.title, '')) LIKE ?")) return ({ all: titleAllMock })
|
||||
if (sql.includes('JOIN messages m') && sql.includes('LIKE')) return ({ all: likeAllMock })
|
||||
if (sql.includes('base.title') && sql.includes('LIKE')) return ({ all: titleAllMock })
|
||||
return ({ all: allMock })
|
||||
})
|
||||
const closeMock = vi.fn()
|
||||
@@ -231,25 +231,25 @@ describe('session DB summaries', () => {
|
||||
expect(rows[1].snippet).toContain('docker')
|
||||
})
|
||||
|
||||
it('falls back to LIKE search when messages_fts is missing for numeric queries', async () => {
|
||||
it('falls back to literal content search for punctuation-only queries instead of unsafe FTS', async () => {
|
||||
titleAllMock.mockReturnValue([])
|
||||
contentAllMock.mockImplementation(() => {
|
||||
throw new Error('no such table: messages_fts')
|
||||
throw new Error('fts5: syntax error near "."')
|
||||
})
|
||||
likeAllMock.mockReturnValue([
|
||||
{
|
||||
id: 'numeric-1',
|
||||
id: 'dot-1',
|
||||
source: 'cli',
|
||||
user_id: '',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: '',
|
||||
started_at: 1710002800,
|
||||
started_at: 1710004000,
|
||||
ended_at: null,
|
||||
end_reason: '',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 2,
|
||||
output_tokens: 3,
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
@@ -257,42 +257,39 @@ describe('session DB summaries', () => {
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: 'numeric preview',
|
||||
last_active: 1710002805,
|
||||
matched_message_id: 9,
|
||||
snippet: 'ticket 12345',
|
||||
preview: 'punctuation preview',
|
||||
last_active: 1710004001,
|
||||
matched_message_id: 21,
|
||||
snippet: 'value.with.dot',
|
||||
rank: 0,
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
const rows = await mod.searchSessionSummaries('123', undefined, 10)
|
||||
const rows = await mod.searchSessionSummaries('.', undefined, 10)
|
||||
|
||||
expect(likeAllMock).toHaveBeenCalledWith('123', '%123%')
|
||||
expect(contentAllMock).not.toHaveBeenCalled()
|
||||
expect(likeAllMock).toHaveBeenCalled()
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].id).toBe('numeric-1')
|
||||
expect(rows[0].snippet).toContain('123')
|
||||
expect(rows[0].id).toBe('dot-1')
|
||||
})
|
||||
|
||||
it('keeps the source filter when messages_fts is missing for numeric queries', async () => {
|
||||
it('keeps safe dotted queries on the FTS path', async () => {
|
||||
titleAllMock.mockReturnValue([])
|
||||
contentAllMock.mockImplementation(() => {
|
||||
throw new Error('no such table: messages_fts')
|
||||
})
|
||||
likeAllMock.mockReturnValue([
|
||||
contentAllMock.mockReturnValue([
|
||||
{
|
||||
id: 'numeric-telegram-1',
|
||||
source: 'telegram',
|
||||
id: 'node-1',
|
||||
source: 'cli',
|
||||
user_id: '',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: '',
|
||||
started_at: 1710002850,
|
||||
title: 'Node.js notes',
|
||||
started_at: 1710004500,
|
||||
ended_at: null,
|
||||
end_reason: '',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 2,
|
||||
output_tokens: 3,
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
@@ -300,38 +297,38 @@ describe('session DB summaries', () => {
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: 'telegram numeric preview',
|
||||
last_active: 1710002855,
|
||||
matched_message_id: 12,
|
||||
snippet: 'telegram 123 body',
|
||||
rank: 0,
|
||||
preview: 'dotted preview',
|
||||
last_active: 1710004501,
|
||||
matched_message_id: 22,
|
||||
snippet: '>>>node.js<<< runtime',
|
||||
rank: 0.2,
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
const rows = await mod.searchSessionSummaries('123', 'telegram', 10)
|
||||
const rows = await mod.searchSessionSummaries('node.js', undefined, 10)
|
||||
|
||||
expect(likeAllMock).toHaveBeenCalledWith('telegram', '123', '%123%')
|
||||
expect(contentAllMock).toHaveBeenCalled()
|
||||
expect(likeAllMock).not.toHaveBeenCalled()
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].source).toBe('telegram')
|
||||
expect(rows[0].id).toBe('numeric-telegram-1')
|
||||
expect(rows[0].id).toBe('node-1')
|
||||
})
|
||||
|
||||
it('preserves title matches when messages_fts is missing for numeric queries', async () => {
|
||||
it('keeps explicit wildcard dotted queries on the FTS path with valid syntax', async () => {
|
||||
titleAllMock.mockReturnValue([
|
||||
{
|
||||
id: 'title-123',
|
||||
id: 'node-wildcard-title-1',
|
||||
source: 'cli',
|
||||
user_id: '',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Issue 123',
|
||||
started_at: 1710002900,
|
||||
title: 'Node.js wildcard notes',
|
||||
started_at: 1710004590,
|
||||
ended_at: null,
|
||||
end_reason: '',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 2,
|
||||
output_tokens: 3,
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
@@ -339,30 +336,27 @@ describe('session DB summaries', () => {
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: 'title numeric preview',
|
||||
last_active: 1710002910,
|
||||
preview: 'wildcard title preview',
|
||||
last_active: 1710004595,
|
||||
matched_message_id: null,
|
||||
snippet: 'Issue 123',
|
||||
snippet: 'Node.js wildcard notes',
|
||||
rank: 0,
|
||||
},
|
||||
])
|
||||
contentAllMock.mockImplementation(() => {
|
||||
throw new Error('no such table: messages_fts')
|
||||
})
|
||||
likeAllMock.mockReturnValue([
|
||||
contentAllMock.mockReturnValue([
|
||||
{
|
||||
id: 'content-123',
|
||||
id: 'node-wildcard-1',
|
||||
source: 'cli',
|
||||
user_id: '',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: '',
|
||||
started_at: 1710002890,
|
||||
title: 'Node.js wildcard notes',
|
||||
started_at: 1710004600,
|
||||
ended_at: null,
|
||||
end_reason: '',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 2,
|
||||
output_tokens: 3,
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
@@ -370,26 +364,243 @@ describe('session DB summaries', () => {
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: 'content numeric preview',
|
||||
last_active: 1710002895,
|
||||
matched_message_id: 10,
|
||||
snippet: 'content 123 body',
|
||||
preview: 'wildcard dotted preview',
|
||||
last_active: 1710004601,
|
||||
matched_message_id: 24,
|
||||
snippet: '>>>node.js<<< runtime',
|
||||
rank: 0.15,
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
const rows = await mod.searchSessionSummaries('node.js*', undefined, 10)
|
||||
|
||||
expect(titleAllMock).toHaveBeenCalledWith('%node.js%', 10)
|
||||
expect(contentAllMock).toHaveBeenCalledWith('"node.js"*', 40)
|
||||
expect(likeAllMock).not.toHaveBeenCalled()
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].id).toBe('node-wildcard-title-1')
|
||||
expect(rows[1].id).toBe('node-wildcard-1')
|
||||
})
|
||||
|
||||
it('keeps quoted wildcard dotted queries on the FTS path with valid syntax', async () => {
|
||||
titleAllMock.mockReturnValue([
|
||||
{
|
||||
id: 'node-quoted-title-1',
|
||||
source: 'cli',
|
||||
user_id: '',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Quoted Node.js wildcard notes',
|
||||
started_at: 1710004640,
|
||||
ended_at: null,
|
||||
end_reason: '',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: '',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: 'quoted title preview',
|
||||
last_active: 1710004645,
|
||||
matched_message_id: null,
|
||||
snippet: 'Quoted Node.js wildcard notes',
|
||||
rank: 0,
|
||||
},
|
||||
])
|
||||
contentAllMock.mockReturnValue([
|
||||
{
|
||||
id: 'node-quoted-wildcard-1',
|
||||
source: 'cli',
|
||||
user_id: '',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Quoted Node.js wildcard notes',
|
||||
started_at: 1710004650,
|
||||
ended_at: null,
|
||||
end_reason: '',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: '',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: 'quoted wildcard dotted preview',
|
||||
last_active: 1710004651,
|
||||
matched_message_id: 25,
|
||||
snippet: '>>>node.js<<< runtime',
|
||||
rank: 0.12,
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
const rows = await mod.searchSessionSummaries('"node.js"*', undefined, 10)
|
||||
|
||||
expect(titleAllMock).toHaveBeenCalledWith('%node.js%', 10)
|
||||
expect(contentAllMock).toHaveBeenCalledWith('"node.js"*', 40)
|
||||
expect(likeAllMock).not.toHaveBeenCalled()
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].id).toBe('node-quoted-title-1')
|
||||
expect(rows[1].id).toBe('node-quoted-wildcard-1')
|
||||
})
|
||||
|
||||
it('keeps non-ASCII dotted queries on the safe quoted FTS path', async () => {
|
||||
titleAllMock.mockReturnValue([])
|
||||
contentAllMock.mockReturnValue([
|
||||
{
|
||||
id: 'unicode-dot-1',
|
||||
source: 'cli',
|
||||
user_id: '',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'naïve.js note',
|
||||
started_at: 1710004700,
|
||||
ended_at: null,
|
||||
end_reason: '',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: '',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: 'unicode dotted preview',
|
||||
last_active: 1710004701,
|
||||
matched_message_id: 23,
|
||||
snippet: 'naïve.js runtime',
|
||||
rank: 0,
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
const rows = await mod.searchSessionSummaries('123', undefined, 10)
|
||||
const rows = await mod.searchSessionSummaries('naïve.js', undefined, 10)
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].id).toBe('title-123')
|
||||
expect(rows[0].matched_message_id).toBeNull()
|
||||
expect(rows[1].id).toBe('content-123')
|
||||
expect(rows[1].matched_message_id).toBe(10)
|
||||
expect(contentAllMock).toHaveBeenCalledWith('"naïve.js"', 40)
|
||||
expect(likeAllMock).not.toHaveBeenCalled()
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].id).toBe('unicode-dot-1')
|
||||
})
|
||||
|
||||
it('falls back to LIKE search for CJK queries', async () => {
|
||||
it('escapes LIKE wildcards for literal special-character searches', async () => {
|
||||
titleAllMock.mockReturnValue([])
|
||||
likeAllMock.mockReturnValue([
|
||||
{
|
||||
id: 'percent-1',
|
||||
source: 'cli',
|
||||
user_id: '',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: '100% reproducible',
|
||||
started_at: 1710005000,
|
||||
ended_at: null,
|
||||
end_reason: '',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: '',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: 'literal percent preview',
|
||||
last_active: 1710005001,
|
||||
matched_message_id: 31,
|
||||
snippet: '100% reproducible',
|
||||
rank: 0,
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
const rows = await mod.searchSessionSummaries('100%', undefined, 10)
|
||||
|
||||
expect(titleAllMock).toHaveBeenCalledWith('%100\\%%', 10)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].id).toBe('percent-1')
|
||||
})
|
||||
|
||||
it('uses literal search for CJK queries even when FTS returns no rows', async () => {
|
||||
titleAllMock.mockReturnValue([])
|
||||
contentAllMock.mockReturnValue([])
|
||||
likeAllMock.mockReturnValue([
|
||||
{
|
||||
id: 'cjk-literal-1',
|
||||
source: 'cli',
|
||||
user_id: '',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: '',
|
||||
started_at: 1710002980,
|
||||
ended_at: null,
|
||||
end_reason: '',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 2,
|
||||
output_tokens: 3,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: '',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: '中文内容预览',
|
||||
last_active: 1710002985,
|
||||
matched_message_id: 10,
|
||||
snippet: '这里也有记忆断裂',
|
||||
rank: 0,
|
||||
},
|
||||
])
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
const rows = await mod.searchSessionSummaries('记忆断裂', undefined, 10)
|
||||
|
||||
expect(contentAllMock).not.toHaveBeenCalled()
|
||||
expect(likeAllMock).toHaveBeenCalledWith('记忆断裂', '%记忆断裂%', 40)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].id).toBe('cjk-literal-1')
|
||||
})
|
||||
|
||||
it('falls back to LIKE search for CJK queries while preserving title matches', async () => {
|
||||
titleAllMock.mockReturnValue([
|
||||
{
|
||||
id: 'cjk-title-1',
|
||||
source: 'cli',
|
||||
user_id: '',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: '记忆断裂标题',
|
||||
started_at: 1710002990,
|
||||
ended_at: null,
|
||||
end_reason: '',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 2,
|
||||
output_tokens: 2,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: '',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: 'title preview',
|
||||
last_active: 1710002995,
|
||||
matched_message_id: null,
|
||||
snippet: '记忆断裂标题',
|
||||
rank: 0,
|
||||
},
|
||||
])
|
||||
contentAllMock.mockImplementation(() => {
|
||||
throw new Error('fts5 tokenizer miss')
|
||||
})
|
||||
@@ -425,12 +636,96 @@ describe('session DB summaries', () => {
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
const rows = await mod.searchSessionSummaries('记忆断裂', undefined, 10)
|
||||
|
||||
expect(likeAllMock).toHaveBeenCalledWith('记忆断裂', '%记忆断裂%')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(likeAllMock).toHaveBeenCalledWith('记忆断裂', '%记忆断裂%', 40)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].id).toBe('cjk-1')
|
||||
expect(rows[1].id).toBe('cjk-title-1')
|
||||
expect(rows[0].snippet).toContain('记忆断裂')
|
||||
})
|
||||
|
||||
it('does not hide real database failures for safe FTS queries', async () => {
|
||||
titleAllMock.mockReturnValue([])
|
||||
contentAllMock.mockImplementation(() => {
|
||||
throw new Error('database malformed')
|
||||
})
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
|
||||
await expect(mod.searchSessionSummaries('docker', undefined, 10)).rejects.toThrow(
|
||||
'Failed to search sessions: database malformed',
|
||||
)
|
||||
expect(likeAllMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when messages_fts is missing for numeric queries', async () => {
|
||||
titleAllMock.mockReturnValue([])
|
||||
contentAllMock.mockImplementation(() => {
|
||||
throw new Error('no such table: messages_fts')
|
||||
})
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
|
||||
await expect(mod.searchSessionSummaries('123', undefined, 10)).rejects.toThrow(
|
||||
'Failed to search sessions: no such table: messages_fts',
|
||||
)
|
||||
expect(likeAllMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when messages_fts is missing for numeric queries with source filter', async () => {
|
||||
titleAllMock.mockReturnValue([])
|
||||
contentAllMock.mockImplementation(() => {
|
||||
throw new Error('no such table: messages_fts')
|
||||
})
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
|
||||
await expect(mod.searchSessionSummaries('123', 'telegram', 10)).rejects.toThrow(
|
||||
'Failed to search sessions: no such table: messages_fts',
|
||||
)
|
||||
expect(likeAllMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when messages_fts is missing for numeric queries even with title matches', async () => {
|
||||
titleAllMock.mockReturnValue([
|
||||
{
|
||||
id: 'title-123',
|
||||
source: 'cli',
|
||||
user_id: '',
|
||||
model: 'openai/gpt-5.4',
|
||||
title: 'Issue 123',
|
||||
started_at: 1710002900,
|
||||
ended_at: null,
|
||||
end_reason: '',
|
||||
message_count: 1,
|
||||
tool_call_count: 0,
|
||||
input_tokens: 2,
|
||||
output_tokens: 3,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
reasoning_tokens: 0,
|
||||
billing_provider: '',
|
||||
estimated_cost_usd: 0,
|
||||
actual_cost_usd: null,
|
||||
cost_status: '',
|
||||
preview: 'title numeric preview',
|
||||
last_active: 1710002910,
|
||||
matched_message_id: null,
|
||||
snippet: 'Issue 123',
|
||||
rank: 0,
|
||||
},
|
||||
])
|
||||
contentAllMock.mockImplementation(() => {
|
||||
throw new Error('no such table: messages_fts')
|
||||
})
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
|
||||
await expect(mod.searchSessionSummaries('123', undefined, 10)).rejects.toThrow(
|
||||
'Failed to search sessions: no such table: messages_fts',
|
||||
)
|
||||
expect(likeAllMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fall back to LIKE when messages_fts is missing for non-numeric queries', async () => {
|
||||
titleAllMock.mockReturnValue([])
|
||||
contentAllMock.mockImplementation(() => {
|
||||
|
||||
Reference in New Issue
Block a user