feat: 重构问问微信并完善知识库增量检索

统一问问微信与 Agent Hub 的查询链路
完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论
基于会话实现真正的增量追新与历史补齐
支持后台同步、取消恢复、重启续传以及同步期间继续查询
优化知识库跨会话检索、同步状态、进度展示和侧栏布局
This commit is contained in:
Wxw-Gu
2026-09-11 17:44:48 +08:00
parent 0c4932d740
commit ab3b3731b7
69 changed files with 8317 additions and 898 deletions
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import {
matchGroupReportIntent,
queryAgentReplyText,
QUERY_AGENT_UNAVAILABLE_TEXT,
resolveInboundRoute
} from '../../src/main/services/agent-hub-routing'
import type { AskWechatQueryResult } from '../../src/shared/query-agent'
describe('Agent Hub 入站路由 — Query / Action 分流', () => {
it('A. 「TraceMemo 交流群最近聊了啥」→ Query Agent(不是 report)', () => {
expect(resolveInboundRoute('TraceMemo 交流群最近聊了啥')).toEqual({ kind: 'knowledge_query' })
})
it('B. 「总结一下 TraceMemo 交流群最近聊了什么」→ Query Agent("总结"不等于 report)', () => {
expect(resolveInboundRoute('总结一下 TraceMemo 交流群最近聊了什么')).toEqual({
kind: 'knowledge_query'
})
})
it('B2. 「总结」缺少产物词时,report 快捷匹配不成立', () => {
expect(matchGroupReportIntent('总结一下这个群最近聊了什么')).toBeNull()
expect(matchGroupReportIntent('帮我总结一下 TraceMemo 交流群的近期内容')).toBeNull()
})
it('C. 「生成 TraceMemo 交流群今天的群聊总结图片」→ Report Action(图片)', () => {
const route = resolveInboundRoute('生成 TraceMemo 交流群今天的群聊总结图片')
expect(route.kind).toBe('report_action')
if (route.kind !== 'report_action') throw new Error('unreachable')
expect(route.intent.group).toContain('TraceMemo')
expect(route.intent.range).toBe('today')
})
it('D. 「帮我做一份 TraceMemo 交流群今日日报」→ Report Action(日报)', () => {
const route = resolveInboundRoute('帮我做一份 TraceMemo 交流群今日日报')
expect(route.kind).toBe('report_action')
if (route.kind !== 'report_action') throw new Error('unreachable')
expect(route.intent.group).toContain('TraceMemo')
})
it('D2. 群 + 时间但不要产物 → Query Agent', () => {
expect(resolveInboundRoute('TraceMemo 交流群昨天聊了啥')).toEqual({ kind: 'knowledge_query' })
})
it('E. 「BOBO 最近说过什么」→ Query Agent', () => {
expect(resolveInboundRoute('BOBO 最近说过什么')).toEqual({ kind: 'knowledge_query' })
})
it('F. 「我和 BOBO 第一次聊了什么」→ Query Agent', () => {
expect(resolveInboundRoute('我和 BOBO 第一次聊了什么')).toEqual({ kind: 'knowledge_query' })
})
it('G. 群成员分析仍是专用 Action', () => {
const route = resolveInboundRoute('看看 TraceMemo交流群里 BOBO 最近说了什么')
expect(route.kind).toBe('group_member_action')
if (route.kind !== 'group_member_action') throw new Error('unreachable')
expect(route.intent.member).toBe('BOBO')
expect(route.intent.group).toContain('TraceMemo')
})
it('H. 会话列表是确定性快捷路径(不经过模型)', () => {
expect(resolveInboundRoute('最近有哪些会话')).toEqual({ kind: 'recent_list', limit: 5 })
expect(resolveInboundRoute('最近3条消息')).toEqual({ kind: 'recent_list', limit: 3 })
})
})
describe('Agent Hub 回复文案映射', () => {
const diagnostics = {
entry: 'agent-hub' as const,
provider: 'P',
model: 'm',
modelCallCount: 2,
toolCallCount: 1,
tools: ['query_messages'],
totalMs: 10,
outcome: 'answered' as const
}
it('answered → 直接用 Query Agent 的文本回答', () => {
expect(
queryAgentReplyText({
engine: 'query-agent',
status: 'answered',
answer: '最近聊了两件事。',
diagnostics
})
).toBe('最近聊了两件事。')
})
it('provider_unavailable / error → 使用安全文案', () => {
const unavailable: AskWechatQueryResult = {
engine: 'query-agent',
status: 'provider_unavailable',
message: '当前 AI 查询服务暂时不可用,请稍后再试。',
diagnostics: { ...diagnostics, outcome: 'provider_failure' }
}
expect(queryAgentReplyText(unavailable)).toBe('当前 AI 查询服务暂时不可用,请稍后再试。')
const error: AskWechatQueryResult = {
engine: 'query-agent',
status: 'error',
message: '本次查询没有完成,请稍后再试或换一种问法。',
diagnostics: { ...diagnostics, outcome: 'runtime_error' }
}
expect(queryAgentReplyText(error)).toBe('本次查询没有完成,请稍后再试或换一种问法。')
})
it('legacy 结果不会出现在 Agent Hub 路径上,兜底为服务不可用文案', () => {
const legacy: AskWechatQueryResult = {
engine: 'legacy',
status: 'legacy',
reason: 'runtime_error',
result: {} as never
}
expect(queryAgentReplyText(legacy)).toBe(QUERY_AGENT_UNAVAILABLE_TEXT)
})
})
@@ -4,8 +4,9 @@ import type {
AiSearchPipelineResult,
AiSearchTimeRange
} from '../../src/shared/ai-search'
import type { KnowledgeRuntimeStatus } from '../../src/shared/knowledge'
import type { KnowledgePassProgress, KnowledgeRuntimeStatus } from '../../src/shared/knowledge'
import type { Contact } from '../../src/shared/types'
import { decodeMessageRef, encodeMessageRef } from '../../src/shared/local-query-api'
import {
contactLabel,
formatBytes,
@@ -249,13 +250,54 @@ describe('AI Search workspace pure formatters', () => {
)
})
it('keeps every knowledge runtime state label unchanged', () => {
it('labels Knowledge states with the mandated freshness vocabulary', () => {
const usable = (
state: KnowledgeRuntimeStatus['state'],
extra: Partial<KnowledgeRuntimeStatus> = {}
): KnowledgeRuntimeStatus => ({
...makeKnowledgeStatus(state),
indexedMessageCount: 20,
indexedChunkCount: 4,
...extra
})
const pass = (phase: 'full' | 'catchup' | 'idle'): KnowledgePassProgress => ({
phase,
cancellable: phase !== 'idle',
startedAt: 1_700_000_000_000,
scannedMessages: 120,
indexedMessages: 20,
processedConversations: 3,
totalConversations: 10,
skippedConversations: 1,
mainLoopLagMs: 4
})
expect(knowledgeStateLabel(null)).toBe('读取中')
// 一个分片都没有:这不是"落后",是"还没建立"。
expect(knowledgeStateLabel(makeKnowledgeStatus('unavailable'))).toBe('未建立')
expect(knowledgeStateLabel(makeKnowledgeStatus('building'))).toBe('建立中')
expect(knowledgeStateLabel(makeKnowledgeStatus('syncing'))).toBe('增量同步')
expect(knowledgeStateLabel(makeKnowledgeStatus('ready'))).toBe('已同步')
expect(knowledgeStateLabel(makeKnowledgeStatus('error'))).toBe('异常')
expect(knowledgeStateLabel(makeKnowledgeStatus('building'))).toBe('正在建立')
// ready ≠ fresh:这两件事必须在文案里分开。
expect(knowledgeStateLabel(usable('building'))).toBe('可用 · 正在补齐历史')
expect(knowledgeStateLabel(usable('syncing', { pass: pass('catchup') }))).toBe(
'可用 · 正在追新'
)
expect(knowledgeStateLabel(usable('syncing', { pass: pass('full') }))).toBe(
'可用 · 正在补齐历史'
)
expect(
knowledgeStateLabel(usable('ready', { indexLatestAt: 1_700_000_000_000, sourceLatestAt: 1_700_000_999_000 }))
).toBe('可用 · 待追新')
expect(
knowledgeStateLabel(usable('ready', { indexLatestAt: 1_700_000_999_000, sourceLatestAt: 1_700_000_999_000 }))
).toBe('可用 · 已追至最新')
expect(knowledgeStateLabel(usable('cancelled'))).toBe('可用 · 同步已取消')
expect(knowledgeStateLabel(usable('error'))).toBe('可用 · 更新失败')
// 笼统的「已同步」把"能查"和"追平"混成一句话,已被彻底移除。
for (const state of ['unavailable', 'building', 'syncing', 'ready', 'cancelled', 'error'] as const) {
expect(knowledgeStateLabel(usable(state))).not.toBe('已同步')
}
// 派生库不可查询时不允许冒充"可用"(残留的历史计数不算数)。
expect(knowledgeStateLabel(usable('unavailable'))).toBe('未建立')
})
it('keeps the existing contact label fallback order', () => {
@@ -287,6 +329,8 @@ describe('AI Search pipeline evidence mapping', () => {
evidenceId: 'E1',
sourceKind: 'text',
contact: aiSearchContact,
// Legacy 路径同样要带稳定引用:否则跳转只能靠"会话 + 秒级时间戳"猜。
messageRef: encodeMessageRef(aiSearchContact.md5, item.messageId),
message: {
id: 'message-1',
from: 'sender-1',
@@ -299,6 +343,10 @@ describe('AI Search pipeline evidence mapping', () => {
createTime: Math.floor(item.timestamp / 1_000)
}
})
expect(decodeMessageRef(mapped.messageRef)).toEqual({
conversationId: aiSearchContact.md5,
messageId: item.messageId
})
})
it('creates the existing group fallback and voice presentation for an unloaded conversation', () => {
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import {
askWechatToolLabels,
formatAskWechatStats,
mapAskWechatEvidence
} from '../../src/renderer/src/components/search/askWechatPresentation'
import type { AskWechatStats } from '../../src/shared/query-agent'
const stats = (patch: Partial<AskWechatStats> = {}): AskWechatStats => ({
tools: ['query_messages'],
reads: { messageCount: 0, matchedCount: 0, overviewSourceCount: 0, evidenceCount: 0 },
scope: { kind: 'all', label: '所有聊天记录' },
modelCallCount: 2,
toolCallCount: 1,
totalMs: 3343,
...patch
})
describe('mapAskWechatEvidence', () => {
it('群消息证据保留群名与成员,且不泄露 md5', () => {
const [item] = mapAskWechatEvidence([
{
messageRef: 'ref-1',
conversationName: 'TraceMemo 交流群',
conversationType: 'group',
sender: '张三',
timestamp: 1_787_650_302_000,
messageType: 'text',
text: '最近重新开始健身了',
source: 'search_messages'
}
])
expect(item.evidenceId).toBe('E1')
expect(item.contact.m_nsNickName).toBe('TraceMemo 交流群')
expect(item.contact.type).toBe('group')
expect(item.message.name).toBe('张三')
expect(item.message.content).toBe('最近重新开始健身了')
expect(item.message.createTime).toBe(1_787_650_302)
expect(item.contact.md5).not.toMatch(/^[0-9a-f]{32}$/)
})
it('缺少群名时给出可读回退,而不是把群消息归成"群聊"', () => {
const [item] = mapAskWechatEvidence([{ messageRef: 'ref-2', source: 'conversation_overview' }])
expect(item.contact.m_nsNickName).toBe('未命名会话')
})
})
describe('formatAskWechatStats', () => {
it('query_messages:读取条数 + 证据条数 + 模型调用 + 耗时', () => {
const chips = formatAskWechatStats(
stats({
tools: ['query_messages'],
reads: { messageCount: 20, matchedCount: 0, overviewSourceCount: 0, evidenceCount: 20 }
})
)
expect(chips).toContain('所有聊天记录内查询')
expect(chips).toContain('读取 20 条消息')
expect(chips).toContain('使用 20 条证据')
expect(chips).toContain('2 次模型调用')
expect(chips).toContain('3.3 s')
})
it('overview:覆盖源消息 + 代表证据;search 无证据时说明命中为 0 的事实', () => {
const overview = formatAskWechatStats(
stats({
tools: ['conversation_overview'],
scope: { kind: 'current', label: '当前会话:TraceMemo 交流群' },
reads: { messageCount: 0, matchedCount: 0, overviewSourceCount: 1200, evidenceCount: 60 }
})
)
expect(overview).toContain('当前会话:TraceMemo 交流群内查询')
expect(overview).toContain('覆盖 1200 条消息')
expect(overview).toContain('使用 60 条证据')
const search = formatAskWechatStats(
stats({
tools: ['search_messages'],
scope: { kind: 'groups', label: '群聊专属' },
reads: { messageCount: 0, matchedCount: 12, overviewSourceCount: 0, evidenceCount: 0 }
})
)
expect(search).toContain('命中 12 条相关消息')
expect(search.some((chip) => chip.includes('知识库已收录'))).toBe(false)
})
})
describe('askWechatToolLabels', () => {
it('把 Tool 名映射成中文能力标签并去重', () => {
expect(askWechatToolLabels(['query_messages', 'search_messages', 'search_messages'])).toEqual([
'精确读取',
'关键词检索'
])
})
})
+490
View File
@@ -0,0 +1,490 @@
import { describe, expect, it, vi } from 'vitest'
import { AskWechatService, type AskWechatLogRecord } from '../../src/main/services/ask-wechat-service'
import { QueryAgentService, type QueryAgentProvider } from '../../src/main/services/query-agent-service'
import type { AiSearchPipelineResult } from '../../src/shared/ai-search'
import type { AskWechatQueryRequest, AskWechatQueryResult } from '../../src/shared/query-agent'
type ChatResponse = Awaited<ReturnType<QueryAgentProvider['chatWithTools']>>
type ToolExecutor = (name: string, input: Record<string, unknown>) => Promise<Record<string, unknown>>
const request = (
text: string,
legacy?: AskWechatQueryRequest['legacy']
): AskWechatQueryRequest => ({ requestId: 'req-1', text, legacy })
function providerFactory(responses: ChatResponse[], configured = true): {
provider: QueryAgentProvider
calls: Array<{ messages: Array<Record<string, unknown>>; tools: string[] }>
} {
const calls: Array<{ messages: Array<Record<string, unknown>>; tools: string[] }> = []
const provider: QueryAgentProvider = {
getRuntimeConfig: () => ({
configured,
providerName: 'Fixture Provider',
model: 'fixture-model',
modelName: 'Fixture Model'
}),
chatWithTools: vi.fn(async (messages, tools) => {
calls.push({ messages, tools: tools.map((tool) => tool.function.name) })
return responses.shift() || { success: true, data: 'done' }
})
}
return { provider, calls }
}
const toolCall = (name: string, args: Record<string, unknown>): ChatResponse => ({
success: true,
data: '',
toolCalls: [{ id: `call-${name}`, name, arguments: JSON.stringify(args) }]
})
const answer = (data: string): ChatResponse => ({ success: true, data })
const toolMessages = (messages: Array<Record<string, unknown>>): string =>
JSON.stringify(messages.filter((message) => message.role === 'tool'))
const legacyResult = (patch: Record<string, unknown> = {}): AiSearchPipelineResult =>
({
requestId: 'req-1',
status: 'completed',
answer: 'legacy answer',
...patch
}) as unknown as AiSearchPipelineResult
const answered = (result: AskWechatQueryResult): Extract<AskWechatQueryResult, { status: 'answered' }> => {
if (result.status !== 'answered') throw new Error(`expected answered, got ${result.status}`)
return result
}
describe('AskWechatService — 桌面问问微信主路径', () => {
it('A. 「我和 BOBO 第一次聊了什么」走 query_messages 并返回回答', async () => {
const execute = vi.fn(async () => ({
status: 'completed',
returnedCount: 1,
messages: [{ messageRef: 'ref-1', text: '你好' }]
}))
const { provider } = providerFactory([
toolCall('query_messages', {
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
temporalBasis: { kind: 'none' },
order: 'asc',
limit: 1
}),
answer('你们的第一次聊天是一条问候。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
const result = answered(await service.ask(request('我和 BOBO 第一次聊了什么')))
expect(result.engine).toBe('query-agent')
expect(result.answer).toBe('你们的第一次聊天是一条问候。')
expect(result.diagnostics).toMatchObject({
entry: 'desktop',
provider: 'Fixture Provider',
model: 'Fixture Model',
modelCallCount: 2,
toolCallCount: 1,
tools: ['query_messages'],
outcome: 'answered'
})
expect(execute).toHaveBeenCalledTimes(1)
// temporalBasis 是 LLM-facing 元数据,必须在到达 Query API 前被剥离。
expect(execute.mock.calls[0][1]).not.toHaveProperty('temporalBasis')
expect(execute.mock.calls[0][1].timeRange).toEqual({ kind: 'all' })
})
it('B. 明确时间(constraint)不会被 retry 扩大,ISO-8601 被换算成 epoch seconds', async () => {
const execute = vi.fn(async () => ({
status: 'completed',
returnedCount: 0,
coverage: { state: 'complete' }
}))
const constraintBasis = { kind: 'constraint', sourceText: '八月份' }
const { provider, calls } = providerFactory([
toolCall('query_messages', {
target: { query: 'BOBO' },
timeRange: {
kind: 'absolute',
startTime: '2026-08-01T00:00:00+08:00',
endTime: '2026-09-01T00:00:00+08:00'
},
temporalBasis: constraintBasis
}),
toolCall('query_messages', {
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
temporalBasis: constraintBasis
}),
answer('八月份没有找到相关记录。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
answered(await service.ask(request('八月份 BOBO 有没有给我发过文件')))
// 第二次改时间范围被 Host 结构性拒绝,没有到达 Query API。
expect(execute).toHaveBeenCalledTimes(1)
expect(execute.mock.calls[0][1].timeRange).toEqual({
kind: 'absolute',
startTime: Math.floor(Date.parse('2026-08-01T00:00:00+08:00') / 1000),
endTime: Math.floor(Date.parse('2026-09-01T00:00:00+08:00') / 1000)
})
expect(toolMessages(calls[2].messages)).toContain('constraint_time_range_immutable')
})
it('C. 模糊时间(recall_hint)首次 0 条时由 Host 自动补查 all,且不占 toolCallCount', async () => {
const execute = vi
.fn<ToolExecutor>()
.mockResolvedValueOnce({ status: 'completed', returnedCount: 0, coverage: { state: 'complete' } })
.mockResolvedValueOnce({
status: 'completed',
returnedCount: 2,
coverage: { state: 'complete' },
messages: [{ messageRef: 'ref-2' }]
})
const { provider } = providerFactory([
toolCall('query_messages', {
target: { query: 'BOBO' },
timeRange: { kind: 'last_7_days' },
temporalBasis: { kind: 'recall_hint', sourceText: '前阵子' }
}),
answer('原范围没有,全部历史里有两条。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
const result = answered(await service.ask(request('前阵子 BOBO 好像发过东西给我')))
expect(execute).toHaveBeenCalledTimes(2)
expect(execute.mock.calls[1][1].timeRange).toEqual({ kind: 'all' })
expect(result.diagnostics.toolCallCount).toBe(1)
expect(result.diagnostics.modelCallCount).toBe(2)
})
it('D. 语义检索走 search_messages,queries 映射为 query + variants', async () => {
const execute = vi.fn(async () => ({ status: 'completed', evidenceCount: 2, evidence: [] }))
const { provider } = providerFactory([
toolCall('search_messages', {
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
queries: ['房租', '押金']
}),
answer('找到两条相关记录。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
const result = answered(await service.ask(request('BOBO 提过房租或者押金吗')))
expect(result.diagnostics.tools).toEqual(['search_messages'])
expect(execute.mock.calls[0][1]).toMatchObject({ query: '房租', variants: ['押金'] })
})
it('E. 宽泛总结走 conversation_overview', async () => {
const execute = vi.fn(async () => ({ status: 'completed', evidenceCount: 12, evidence: [] }))
const { provider } = providerFactory([
toolCall('conversation_overview', {
target: { query: 'TraceMemo交流群' },
timeRange: { kind: 'last_7_days' }
}),
answer('最近一周主要讨论了两件事。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
const result = answered(await service.ask(request('TraceMemo交流群最近聊了什么')))
expect(result.diagnostics.tools).toEqual(['conversation_overview'])
})
it('F. 模型追问(clarification)按普通回答返回,并进入下一轮上下文', async () => {
const { provider, calls } = providerFactory([
answer('你想问的是哪位联系人?'),
answer('好的,是 BOBO。')
])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { entry: 'desktop' })
const first = answered(await service.ask(request('我们第一次聊了什么')))
expect(first.answer).toBe('你想问的是哪位联系人?')
await service.ask(request('BOBO'))
expect(calls[1].messages.map((message) => message.role)).toEqual([
'system',
'user',
'assistant',
'user'
])
expect(calls[1].messages[1]).toMatchObject({ content: '我们第一次聊了什么' })
expect(calls[1].messages[2]).toMatchObject({ content: '你想问的是哪位联系人?' })
})
it('G. Provider 失败 → 安全文案,且不回退 Legacy', async () => {
const runLegacy = vi.fn(async () => legacyResult())
const { provider } = providerFactory([{ success: false, error: 'AI 请求超时', timedOut: true }])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), {
entry: 'desktop',
runLegacy
})
const result = await service.ask(request('BOBO 说过什么'))
expect(result.status).toBe('provider_unavailable')
if (result.status !== 'provider_unavailable') throw new Error('unreachable')
expect(result.message).toBe('当前 AI 查询服务暂时不可用,请稍后再试。')
expect(result.diagnostics.outcome).toBe('provider_failure')
expect(runLegacy).not.toHaveBeenCalled()
})
it('G2. Provider 未配置 → 安全文案,且不触发任何模型 / 工具调用', async () => {
const execute = vi.fn(async () => ({ status: 'completed' }))
const { provider } = providerFactory([], false)
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
const result = await service.ask(request('BOBO 说过什么'))
expect(result.status).toBe('provider_unavailable')
expect(execute).not.toHaveBeenCalled()
expect(provider.chatWithTools).not.toHaveBeenCalled()
})
it('H. Query Agent 查 0 条 → 仍然是它回答,不调用 Legacy', async () => {
const execute = vi.fn(async () => ({
status: 'completed',
returnedCount: 0,
coverage: { state: 'complete' }
}))
const runLegacy = vi.fn(async () => legacyResult())
const { provider } = providerFactory([
toolCall('query_messages', {
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
temporalBasis: { kind: 'none' }
}),
answer('当前可读取的完整范围里没有找到相关记录。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), {
entry: 'desktop',
runLegacy
})
const result = answered(await service.ask(request('BOBO 给我发过文件吗')))
expect(result.answer).toContain('没有找到')
expect(result.diagnostics.outcome).toBe('answered')
expect(runLegacy).not.toHaveBeenCalled()
})
it('Runtime 抛出未分类异常 → 允许回退 Legacy,并沿用 UI 范围', async () => {
const runLegacy = vi.fn(async () => legacyResult({ status: 'completed' }))
const provider: QueryAgentProvider = {
getRuntimeConfig: () => ({
configured: true,
providerName: 'P',
model: 'm',
modelName: 'M'
}),
chatWithTools: vi.fn(async () => {
throw new Error('unexpected internal failure')
})
}
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), {
entry: 'desktop',
runLegacy
})
const result = await service.ask(
request('BOBO 说过什么', {
scope: 'conversation',
range: '30d',
conversationId: 'conversation-1'
})
)
expect(result.engine).toBe('legacy')
if (result.engine !== 'legacy') throw new Error('unreachable')
expect(result.reason).toBe('runtime_error')
expect(runLegacy).toHaveBeenCalledTimes(1)
expect(runLegacy.mock.calls[0][0]).toMatchObject({
requestId: 'req-1',
text: 'BOBO 说过什么',
scope: 'conversation',
range: '30d',
conversationId: 'conversation-1'
})
})
it('Runtime 异常且没有 Legacy 通道(Agent Hub)→ 明确文案,不抛异常', async () => {
const provider: QueryAgentProvider = {
getRuntimeConfig: () => ({ configured: true, providerName: 'P', model: 'm', modelName: 'M' }),
chatWithTools: vi.fn(async () => {
throw new Error('unexpected internal failure')
})
}
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), {
entry: 'agent-hub'
})
const result = await service.ask(request('BOBO 说过什么'))
expect(result.status).toBe('error')
if (result.status !== 'error') throw new Error('unreachable')
expect(result.message).toBe('本次查询没有完成,请稍后再试或换一种问法。')
expect(result.diagnostics.entry).toBe('agent-hub')
})
it('超过工具调用上限 → 判为 Runtime 失败(可回退),不伪装成正常回答', async () => {
let counter = 0
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 0 }))
const provider: QueryAgentProvider = {
getRuntimeConfig: () => ({ configured: true, providerName: 'P', model: 'm', modelName: 'M' }),
chatWithTools: vi.fn(async () => {
counter += 1
return {
success: true,
data: '',
toolCalls: [
{
id: `call-${counter}`,
name: 'query_messages',
arguments: JSON.stringify({
target: { query: `BOBO${counter}` },
timeRange: { kind: 'all' },
temporalBasis: { kind: 'none' }
})
}
]
}
})
}
const service = new AskWechatService(new QueryAgentService(provider, execute), {
entry: 'agent-hub'
})
const result = await service.ask(request('一直查不完的问题'))
expect(result.status).toBe('error')
if (result.status !== 'error') throw new Error('unreachable')
expect(result.diagnostics.outcome).toBe('tool_limit')
})
it('空问题 → 明确的用户文案', async () => {
const { provider } = providerFactory([])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { entry: 'desktop' })
const result = await service.ask(request(' '))
expect(result.status).toBe('error')
if (result.status !== 'error') throw new Error('unreachable')
expect(result.message).toBe('请先输入想了解的问题。')
expect(result.diagnostics.outcome).toBe('invalid_question')
})
it('生产日志只记录形态字段,不含回答内容', async () => {
const logs: AskWechatLogRecord[] = []
const { provider } = providerFactory([answer('BOBO 最近在准备搬家,提到了房租和押金。')])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), {
entry: 'desktop',
log: (record) => logs.push(record)
})
await service.ask(request('BOBO 最近在忙什么'))
expect(logs).toHaveLength(1)
expect(Object.keys(logs[0].details ?? {}).sort()).toEqual([
'entry',
'model',
'modelCallCount',
'outcome',
'provider',
'toolCallCount',
'tools',
'totalMs'
])
expect(JSON.stringify(logs)).not.toContain('准备搬家')
})
it('forgetConversation 清掉指定会话的澄清上下文', async () => {
const { provider, calls } = providerFactory([
answer('哪一位?'),
answer('好的。'),
answer('好的。')
])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { entry: 'desktop' })
await service.ask(request('我们第一次聊了什么'))
service.forgetConversation()
await service.ask(request('BOBO'))
expect(calls[1].messages.map((message) => message.role)).toEqual(['system', 'user'])
})
})
describe('AskWechatService — 搜索范围(conversationScope)', () => {
it('把 UI 的 scope 透传到 Tool 执行上下文,并让范围说明进入模型上下文', async () => {
const seen: Array<Record<string, unknown> | undefined> = []
const execute = vi.fn(async (_name: string, _input: Record<string, unknown>, context?: { conversationScope?: unknown }) => {
seen.push(context?.conversationScope as Record<string, unknown> | undefined)
return { status: 'completed', returnedCount: 0 }
})
const { provider, calls } = providerFactory([
toolCall('search_messages', { timeRange: { kind: 'all' }, queries: ['健身'] }),
answer('范围内没有找到。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
await service.ask({
requestId: 'req-1',
text: '最近谁聊过健身',
scope: { scope: { kind: 'groups' }, label: '群聊专属' }
})
expect(seen[0]).toEqual({ kind: 'groups' })
// 范围说明只描述边界;强制由 Engine 完成(越界 target 会被结构化拒绝)。
expect(String(calls[0].messages[1]?.content)).toContain('群聊专属')
expect(String(calls[0].messages[1]?.content)).toContain('群成员实际发送的消息')
})
it('没有 scope 时不注入范围说明(保持毕业版本的 messages 形状)', async () => {
const { provider, calls } = providerFactory([answer('好的')])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), { entry: 'agent-hub' })
await service.ask(request('你好'))
expect(calls[0].messages.map((message) => message.role)).toEqual(['system', 'user'])
})
it('回答结果带真实统计与证据(供 UI 顶部与右侧面板使用)', async () => {
const execute = vi.fn(async () => ({
status: 'completed',
evidenceCount: 2,
evidence: [
{
messageRef: 'ref-1',
conversationName: 'TraceMemo 交流群',
conversationType: 'group',
sender: '张三',
timestamp: 1_787_650_302_000,
sourceKind: 'text',
text: '最近开始健身了'
}
]
}))
const { provider } = providerFactory([
toolCall('search_messages', { timeRange: { kind: 'all' }, queries: ['健身'] }),
answer('张三提过。')
])
const service = new AskWechatService(new QueryAgentService(provider, execute), { entry: 'desktop' })
const result = answered(
await service.ask({ requestId: 'req-1', text: '谁聊过健身', scope: { scope: { kind: 'groups' } } })
)
expect(result.evidence).toHaveLength(1)
expect(result.evidence[0]).toMatchObject({
conversationName: 'TraceMemo 交流群',
conversationType: 'group',
sender: '张三',
source: 'search_messages'
})
expect(result.stats.tools).toEqual(['search_messages'])
expect(result.stats.reads.evidenceCount).toBe(1)
expect(result.stats.scope).toEqual({ kind: 'groups' })
})
})
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import {
KNOWLEDGE_FRESHNESS_TOLERANCE_MS,
isKnowledgeFresh,
type KnowledgePassProgress,
type KnowledgeRuntimeStatus
} from '../../src/shared/knowledge'
import { formatIndexDate, knowledgeIsStale, knowledgeStateLabel } from '../../src/renderer/src/components/search/searchFormatters'
type StatusPatch = Partial<
Pick<KnowledgeRuntimeStatus, 'state' | 'indexLatestAt' | 'sourceLatestAt' | 'pass'>
>
const makePass = (phase: KnowledgePassProgress['phase']): KnowledgePassProgress => ({
phase,
cancellable: phase !== 'idle',
startedAt: 0,
scannedMessages: 0,
indexedMessages: 0,
processedConversations: 0,
totalConversations: 0,
skippedConversations: 0,
mainLoopLagMs: 0
})
const status = (patch: StatusPatch): KnowledgeRuntimeStatus =>
({
accountId: 'fixture',
state: 'ready',
indexedMessageCount: 1,
indexedChunkCount: 1,
sourceMessageCount: 1,
processedMessages: 1,
totalMessages: 1,
estimatedRemainingMs: null,
databaseBytes: 0,
walBytes: 0,
shmBytes: 0,
indexLatestAt: null,
sourceLatestAt: null,
...patch
})
describe('isKnowledgeFresh(READY 与 FRESH 是两个概念)', () => {
it('索引追平源数据时算 fresh', () => {
expect(isKnowledgeFresh({ indexLatestAt: 1000, sourceLatestAt: 1000 })).toBe(true)
})
it('容差内算 fresh:吸收 Session 元数据晚于消息落库的漂移', () => {
expect(
isKnowledgeFresh({ indexLatestAt: 1000, sourceLatestAt: 1000 + KNOWLEDGE_FRESHNESS_TOLERANCE_MS })
).toBe(true)
})
it('明显落后时算 stale', () => {
expect(
isKnowledgeFresh({ indexLatestAt: 1000, sourceLatestAt: 1000 + KNOWLEDGE_FRESHNESS_TOLERANCE_MS + 1 })
).toBe(false)
})
it('任一侧口径缺失时返回 null(无法判定,不冒充 fresh)', () => {
expect(isKnowledgeFresh({ indexLatestAt: null, sourceLatestAt: 1000 })).toBeNull()
expect(isKnowledgeFresh({ indexLatestAt: 1000, sourceLatestAt: null })).toBeNull()
})
})
describe('Knowledge UI 状态文案', () => {
const indexLatestAt = new Date('2026-08-26T11:37:24+08:00').getTime()
const sourceLatestAt = new Date('2026-09-11T11:57:24+08:00').getTime()
it('索引落后时显示「可用 · 待追新」,不得显示「已同步」', () => {
const label = knowledgeStateLabel(status({ indexLatestAt, sourceLatestAt }))
// READY ≠ FRESH:落后时既要说"还能用",又要如实说"还没追平"。
expect(label).toBe('可用 · 待追新')
expect(label).not.toBe('已同步')
expect(label).not.toContain('已追至最新')
expect(knowledgeIsStale(status({ indexLatestAt, sourceLatestAt }))).toBe(true)
// 卡片必须能给出真实的覆盖边界。
expect(formatIndexDate(indexLatestAt)).toBe('8/26')
})
it('索引真正追平源数据最新时才显示「可用 · 已追至最新」', () => {
expect(knowledgeStateLabel(status({ indexLatestAt: sourceLatestAt, sourceLatestAt }))).toBe(
'可用 · 已追至最新'
)
expect(knowledgeIsStale(status({ indexLatestAt: sourceLatestAt, sourceLatestAt }))).toBe(false)
})
it('无法判定 freshness 时不能把"不确定"说成已经追平', () => {
// 安全侧:无法确认就按"可能还没追平"表达,绝不说「已追至最新」。
expect(knowledgeStateLabel(status({ indexLatestAt, sourceLatestAt: null }))).toBe(
'可用 · 待追新'
)
expect(knowledgeIsStale(status({ indexLatestAt, sourceLatestAt: null }))).toBe(false)
})
it('同步中必须区分「正在追新」与「正在补齐历史」', () => {
expect(knowledgeStateLabel(status({ state: 'syncing', indexLatestAt, sourceLatestAt }))).toBe(
'可用 · 正在追新'
)
// 首轮全量建立历史时不能谎称"只是在追新"。
expect(
knowledgeStateLabel(
status({
state: 'syncing',
indexLatestAt,
sourceLatestAt,
pass: makePass('full')
})
)
).toBe('可用 · 正在补齐历史')
})
it('取消与失败都不能丢掉「可用」前缀(已建立的分片仍然能查)', () => {
expect(knowledgeStateLabel(status({ state: 'cancelled' }))).toBe('可用 · 同步已取消')
expect(knowledgeStateLabel(status({ state: 'error' }))).toBe('可用 · 更新失败')
})
})
File diff suppressed because it is too large Load Diff
+119
View File
@@ -136,6 +136,82 @@ describe('knowledge sqlite', () => {
expect(existsSync(databasePath)).toBe(false)
})
/**
* 取消之后 `run_state` 不允许留下一个假的 `indexing`,而且已提交的分片必须保留
* (取消 ≠ 回滚),下一次索引从断点继续。
*/
it('records a cancelled pass as cancelled and keeps the committed chunks queryable', async () => {
const root = makeRoot()
// 两个会话:取消发生在第二个会话内部,于是第一个会话必须已经提交。
// (生产里索引是 per-conversation 事务,取消 ≠ 回滚。)
const committed = createSyntheticConversation(
FIXTURE_ACCOUNT_A,
'conversation-a',
0,
300,
'mixed'
)
const interrupted = createSyntheticConversation(
FIXTURE_ACCOUNT_A,
'conversation-b',
10_000,
2_000,
'mixed'
)
const controller = new AbortController()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
const cancelled = await store.index(
{ conversations: [committed, interrupted], chunker: DEFAULT_KNOWLEDGE_CHUNKER },
controller.signal,
(progress) => {
if (progress.conversationId === 'conversation-b' && progress.processedMessages >= 900) {
controller.abort()
}
}
)
expect(cancelled.cancelled).toBe(true)
// 取消是真实的终态,不能被当成"还在跑"。
const status = store.getRuntimeStatus()
expect(status.state).toBe('cancelled')
expect(status.state).not.toBe('indexing')
// 已经提交的会话仍然可查 —— 取消不等于回滚。
const evidence = store.search({ accountId: FIXTURE_ACCOUNT_A, text: '本地知识库', limit: 5 })
expect(evidence.length).toBeGreaterThan(0)
expect(new Set(evidence.map((item) => item.conversationId))).toEqual(
new Set(['conversation-a'])
)
store.close()
})
it('does not report an interrupted pass as an unusable index', async () => {
const root = makeRoot()
const source = createSyntheticConversation(
FIXTURE_ACCOUNT_A,
'conversation-a',
0,
200,
'mixed'
)
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
await store.index({ conversations: [source], chunker: DEFAULT_KNOWLEDGE_CHUNKER })
expect(store.getRuntimeStatus().state).toBe('ready')
store.close()
// 模拟进程被杀:派生库里分片齐全,但 run_state 残留 'indexing'。
const databasePath = getKnowledgeDatabasePath(root, FIXTURE_ACCOUNT_A)
const raw = new DatabaseSync(databasePath)
raw
.prepare(`UPDATE knowledge_meta SET value = 'indexing' WHERE key = 'run_state'`)
.run()
raw.close()
const reopened = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
// 「可查询」不能因为一次中断残留就变成「不可用」。
expect(reopened.getRuntimeStatus().state).toBe('ready')
reopened.close()
})
it('uses a bounded exact fallback for two-character Chinese queries with the trigram profile', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
@@ -352,6 +428,49 @@ describe('knowledge sqlite', () => {
store.close()
})
it('把 per-conversation 的源侧覆盖边界当作索引覆盖口径(meta / high_water_time 只作回退)', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
const conversation = createSyntheticConversation(FIXTURE_ACCOUNT_A, 'coverage', 0, 4, 'mixed')
const lastCreateTime = Math.max(...conversation.messages.map((message) => message.createTime))
await store.index({ conversations: [conversation], chunker: DEFAULT_KNOWLEDGE_CHUNKER })
// 既没有 per-conversation 源侧边界、也没有完整 pass 口径时,退化为
// "最新被索引的消息时间"(只会偏旧、不会冒充更新)。
expect(store.getRuntimeStatus().indexLatestAt).toBe(lastCreateTime)
await store.index({
conversations: [conversation],
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
sourceMessageCount: conversation.messages.length,
// 源数据边界可以比"被索引建模的最新消息"更新(例如最新一条是不可建模的图片)。
sourceLatestAt: lastCreateTime + 60_000
})
const status = store.getRuntimeStatus()
expect(status.indexLatestAt).toBe(lastCreateTime + 60_000)
// 派生库自己看不到源数据,sourceLatestAt 由 KnowledgeSearchService 填。
expect(status.sourceLatestAt).toBeNull()
// per-conversation 的源侧边界一旦存在就是**权威口径**:它是每个成功处理的会话
// 立刻持久化的聚合值,增量 pass 也能推进它。而 `source_latest_at` meta 只在
// 「整遍零跳过」时才写 —— 增量世界里这让它永久冻结(冻结值落后真实 checkpoint,
// 导致 isKnowledgeFresh() 恒为 false)。
await store.index({
conversations: [{ ...conversation, sourceHighWaterTime: lastCreateTime + 180_000 }],
chunker: DEFAULT_KNOWLEDGE_CHUNKER
})
expect(store.getRuntimeStatus().indexLatestAt).toBe(lastCreateTime + 180_000)
// checkpoint 单调:一个"看起来更旧"的源侧边界不得让覆盖率回退,
// 否则已经追到最新的会话会被重新打回"有新消息",每遍都白读。
await store.index({
conversations: [{ ...conversation, sourceHighWaterTime: lastCreateTime + 1_000 }],
chunker: DEFAULT_KNOWLEDGE_CHUNKER
})
expect(store.getRuntimeStatus().indexLatestAt).toBe(lastCreateTime + 180_000)
store.close()
})
it('keeps conversation, sender and time filters when a participant question has no topic terms', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
+350 -11
View File
@@ -19,9 +19,37 @@ vi.mock('../../src/main/services/chat-service', () => ({
import { LocalQueryApiService } from '../../src/main/services/local-query-api-service'
describe('LocalQueryApiService', () => {
const knowledge = { search: vi.fn(async () => ({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })) } as any
const knowledge = {
search: vi.fn(async () => ({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })),
requestCatchUp: vi.fn(() => ({ triggered: false, inProgress: false })),
waitForIndexingComplete: vi.fn(async () => false),
lastPassDurationMs: vi.fn(() => 0),
beginInteractiveQuery: vi.fn(),
endInteractiveQuery: vi.fn()
} as any
/** 固定时钟:requestedEnd 由它决定。 */
const NOW = new Date('2026-09-09T12:00:00+08:00').getTime()
const DAY = 24 * 60 * 60 * 1000
let service: LocalQueryApiService
beforeEach(() => { vi.clearAllMocks(); if (!fixture.contacts.some((contact) => contact.md5 === 'md5-bobo-2')) fixture.contacts.push({ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' }); service = new LocalQueryApiService(knowledge, () => new Date('2026-09-09T12:00:00+08:00')) })
beforeEach(() => {
vi.clearAllMocks()
// mockReset 会清掉 once 队列与实现:避免上一个用例残留的队列被下一个用例消费。
knowledge.search.mockReset()
knowledge.search.mockImplementation(async () => ({
state: 'ready',
evidence: [{ conversationId: 'md5-bobo', messageId: 'm1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }],
conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true },
voiceCoverage: undefined
}))
knowledge.requestCatchUp.mockReset()
knowledge.requestCatchUp.mockReturnValue({ triggered: false, inProgress: false })
knowledge.waitForIndexingComplete.mockReset()
knowledge.waitForIndexingComplete.mockResolvedValue(false)
knowledge.lastPassDurationMs.mockReset()
knowledge.lastPassDurationMs.mockReturnValue(0)
if (!fixture.contacts.some((contact) => contact.md5 === 'md5-bobo-2')) fixture.contacts.push({ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' })
service = new LocalQueryApiService(knowledge, () => new Date('2026-09-09T12:00:00+08:00'))
})
it('publishes capability contract and resolves previous month', async () => {
expect(service.capabilities().tools.query_messages.messageTypes).toContain('file')
@@ -63,22 +91,333 @@ describe('LocalQueryApiService', () => {
expect(searchedRef).toBe(queriedRef)
await expect(service.context({ messageRef: searchedRef!, before: 0, after: 0 })).resolves.toMatchObject({ status: 'completed' })
knowledge.search.mockResolvedValueOnce({ state: 'ready', evidence: [{ conversationId: 'md5-bobo', messageId: 'local:m1', timestamp: 1, sender: 'BOBO', sourceKind: 'text', text: '你好' }], conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true }, voiceCoverage: undefined })
// 概览直读 WCDB,不再消费派生索引 → 这里不再排队 knowledge mock(残留队列会污染后续用例)。
const overview = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } })
const overviewRef = overview.evidence?.[0]?.messageRef
expect(overviewRef).toBe(queriedRef)
await expect(service.context({ messageRef: overviewRef!, before: 0, after: 0 })).resolves.toMatchObject({ status: 'completed' })
})
it('sorts overview evidence while preserving the selected set and exposes sampling', async () => {
it('会话概览以 WCDB 为事实来源,并在派生索引为空时不返回「0 + complete」(P0 回归)', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValueOnce({ state: 'ready', evidence: [
{ conversationId: 'md5-bobo', messageId: 'late', timestamp: 300, sender: 'BOBO', sourceKind: 'text', text: 'late' },
{ conversationId: 'md5-bobo', messageId: 'early', timestamp: 100, sender: 'BOBO', sourceKind: 'text', text: 'early' }
], conversationRetrieval: { totalMessages: 82, chunkCount: 4, complete: true }, voiceCoverage: undefined })
// 陈旧索引的真实形态:索引对这一会话在范围内 0 行,且自称 complete。
knowledge.search.mockResolvedValue({
state: 'ready',
evidence: [],
conversationRetrieval: { totalMessages: 0, chunkCount: 0, complete: true },
voiceCoverage: undefined
})
const result = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' } })
expect(result.sourceCoverage).toEqual({ state: 'complete', sourceMessageCount: 82 })
expect(result.selection).toEqual({ mode: 'temporal_coverage', selectedEvidenceCount: 2, sampled: true })
expect(result.evidence?.map((item) => item.timestamp)).toEqual([100, 300])
expect(result).toMatchObject({ status: 'completed', origin: 'wcdb', sourceMessageCount: 2, evidenceCount: 2 })
expect(result.coverage).toEqual({ state: 'complete' })
expect(result.evidence?.map((item) => item.messageRef)).toHaveLength(2)
// 概览不再依赖派生索引(索引可能滞后于 WCDB)。
expect(knowledge.search).not.toHaveBeenCalled()
})
it('会话概览保留时间片代表证据,并如实标注覆盖状态', async () => {
fixture.contacts.splice(1)
const result = await service.overview({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' } })
expect(result.sourceCoverage).toEqual({ state: 'complete', sourceMessageCount: 2 })
expect(result.evidence?.map((item) => item.timestamp)).toEqual(
[...fixture.messages].map((message) => message.createTime * 1000)
)
expect(result.selection?.mode).toBe('temporal_coverage')
})
it('scope=groups 只检索群会话,并把群名与成员写进证据', async () => {
fixture.contacts.splice(1)
fixture.contacts.push({ m_nsUsrName: 'wxid-group@chatroom', m_nsNickName: 'TraceMemo 交流群', md5: 'md5-group', type: 'group' } as never)
knowledge.search.mockResolvedValue({
state: 'ready',
evidence: [{ conversationId: 'md5-group', messageId: 'g1', timestamp: 5, sender: '张三', sourceKind: 'text', text: '最近开始健身了' }],
voiceCoverage: undefined
})
const result = await service.search({ timeRange: { kind: 'all' }, query: '健身', scope: { kind: 'groups' } })
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({ conversationIds: ['md5-group'] }))
expect(result.scope).toEqual({ kind: 'groups', conversationCount: 1 })
expect(result.evidence?.[0]).toMatchObject({
conversationName: 'TraceMemo 交流群',
conversationType: 'group',
sender: '张三'
})
})
it('scope=all 省略 target 时按全局检索(不传 conversationIds)', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ state: 'ready', evidence: [], voiceCoverage: undefined })
const result = await service.search({ timeRange: { kind: 'all' }, query: '健身', scope: { kind: 'all' } })
expect(knowledge.search).toHaveBeenCalledWith(expect.objectContaining({ conversationIds: undefined }))
// 跨会话检索不宣称"完整":派生索引不是完整性权威。
expect(result.coverage).toEqual({ state: 'partial' })
})
it('scope 内的 target 越界会被结构化拒绝(而不是悄悄扩大范围)', async () => {
fixture.contacts.splice(1)
const result = await service.search({
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
query: '健身',
scope: { kind: 'groups' }
})
expect(result).toMatchObject({
status: 'invalid_tool_arguments',
field: 'target',
constraint: 'target_outside_scope',
actual: 'BOBO'
})
expect(knowledge.search).not.toHaveBeenCalled()
})
it('scope=contact 只允许该单聊会话,未知会话会被拒绝', async () => {
fixture.contacts.splice(1)
const allowed = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, scope: { kind: 'contact', conversationId: 'md5-bobo' } })
expect(allowed.status).toBe('completed')
const rejected = await service.messages({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, scope: { kind: 'contact', conversationId: 'md5-unknown' } })
expect(rejected.status).toBe('scope_conversation_not_found')
})
it('会话概览在范围内有多个会话且未指定 target 时要求指定 target', async () => {
fixture.contacts.splice(1)
const result = await service.overview({ timeRange: { kind: 'all' }, scope: { kind: 'groups' } })
expect(result).toMatchObject({ status: 'invalid_tool_arguments', constraint: 'target_required_for_scope' })
})
describe('freshness contract(索引落后时不得出现 stale complete negative)', () => {
it('交互检索全程标记为前台:后台索引必须让路(否则查询会被 pass 拖慢)', async () => {
fixture.contacts.splice(1)
const order: string[] = []
knowledge.beginInteractiveQuery.mockImplementation(() => order.push('begin'))
knowledge.endInteractiveQuery.mockImplementation(() => order.push('end'))
knowledge.search.mockImplementation(async () => {
order.push('search')
return { state: 'ready', evidence: [], indexLatestAt: NOW - 30 * 1000, sourceLatestAt: NOW - 1000, voiceCoverage: undefined }
})
await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(order).toEqual(['begin', 'search', 'end'])
expect(knowledge.beginInteractiveQuery).toHaveBeenCalledTimes(1)
expect(knowledge.endInteractiveQuery).toHaveBeenCalledTimes(1)
})
it('同一次查询的多个 probe 共用 retrieval session(避免重复读群成员快照)', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ state: 'ready', indexLatestAt: NOW - 30 * 1000, sourceLatestAt: NOW - 1000, evidence: [], voiceCoverage: undefined })
await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身', variants: ['锻炼', '跑步'] })
expect(knowledge.search).toHaveBeenCalledTimes(3)
const sessions = new Set(knowledge.search.mock.calls.map((call: unknown[]) => (call[0] as { retrievalSessionId?: string }).retrievalSessionId))
expect(sessions.size).toBe(1)
expect([...sessions][0]).toEqual(expect.any(String))
})
const staleIndex = (): Record<string, unknown> => ({
state: 'ready',
evidence: [],
indexLatestAt: NOW - 10 * DAY,
voiceCoverage: undefined
})
const freshIndex = (evidence: unknown[] = []): Record<string, unknown> => ({
state: 'ready',
evidence,
indexLatestAt: NOW - 30 * 1000,
voiceCoverage: undefined
})
it('索引明显落后 → coverage=partial、请求追赶,且 0 结果不被当成“没有”', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ ...staleIndex(), sourceLatestAt: NOW - 1000 })
knowledge.requestCatchUp.mockReturnValue({ triggered: true, inProgress: true })
knowledge.waitForIndexingComplete.mockResolvedValue(false)
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(result.coverage).toEqual({ state: 'partial' })
expect(result.evidenceCount).toBe(0)
expect(result.indexLatestAt).toBe(NOW - 10 * DAY)
expect(result.sourceLatestAt).toBe(NOW - 1000)
expect(result.freshness).toEqual({ catchUp: 'pending' })
expect(knowledge.requestCatchUp).toHaveBeenCalledTimes(1)
expect(knowledge.waitForIndexingComplete).toHaveBeenCalledWith(2000)
})
it('追赶在预算内完成 → 用新索引重新检索(不能拿同步前的结果回答)', async () => {
fixture.contacts.splice(1)
knowledge.search
.mockResolvedValueOnce({ ...staleIndex(), sourceLatestAt: NOW - 1000 })
.mockResolvedValueOnce({
state: 'ready',
indexLatestAt: NOW - 30 * 1000,
sourceLatestAt: NOW - 1000,
voiceCoverage: undefined,
evidence: [{ conversationId: 'md5-bobo', messageId: 'new-1', timestamp: NOW - 60_000, sender: 'BOBO', sourceKind: 'text', text: '最近开始健身了' }]
})
knowledge.requestCatchUp.mockReturnValue({ triggered: true, inProgress: true })
knowledge.waitForIndexingComplete.mockResolvedValue(true)
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(knowledge.search).toHaveBeenCalledTimes(2)
expect(result.coverage).toEqual({ state: 'complete' })
expect(result.freshness).toEqual({ catchUp: 'completed' })
expect(result.evidenceCount).toBe(1)
expect(result.evidence?.[0]).toMatchObject({ text: '最近开始健身了', sender: 'BOBO' })
})
it('索引已追平 → 不触发同步(fresh path 不增加开销)', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ ...freshIndex(), sourceLatestAt: NOW - 1000 })
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(result.freshness).toEqual({ catchUp: 'none' })
expect(result.coverage).toEqual({ state: 'complete' })
expect(knowledge.requestCatchUp).not.toHaveBeenCalled()
expect(knowledge.waitForIndexingComplete).not.toHaveBeenCalled()
})
it('请求范围落在索引覆盖窗口内 → 不触发同步', async () => {
fixture.contacts.splice(1)
// 索引追到"昨天",而问的是"上个月"(早于索引覆盖边界)→ 完全覆盖。
knowledge.search.mockResolvedValue({
state: 'ready',
evidence: [],
indexLatestAt: NOW - DAY,
sourceLatestAt: NOW - 1000,
voiceCoverage: undefined
})
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'previous_month' }, query: '健身' })
expect(knowledge.requestCatchUp).not.toHaveBeenCalled()
expect(result.coverage).toEqual({ state: 'complete' })
expect(result.indexCoverage?.covered).toBe(true)
})
it('有界时间范围落后于索引时必须 partial(resolvedTimeRange 是秒,freshness 口径是毫秒)', async () => {
fixture.contacts.splice(1)
// 回归:`resolvedTimeRange.endTime` 是 epoch 秒,而 indexLatestAt 是 epoch 毫秒。
// 混单位会让请求边界被算成 1970 年,从而把"落后"误判成"已覆盖"。
knowledge.search.mockResolvedValue({
state: 'ready',
evidence: [],
indexLatestAt: NOW - 4 * 60 * 1000,
sourceLatestAt: NOW,
voiceCoverage: undefined
})
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'today' }, query: '健身' })
expect(result.resolvedTimeRange?.endTime).toEqual(expect.any(Number))
// 请求边界(今天)晚于索引覆盖边界 4 分钟 → 只能 partial。
expect(result.coverage).toEqual({ state: 'partial' })
expect(result.indexCoverage?.covered).toBe(false)
expect(result.indexCoverage?.summary).toContain('暂时无法确认')
})
it('已有索引任务在跑 → 复用而不阻塞本次查询', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ ...staleIndex(), sourceLatestAt: NOW - 1000 })
knowledge.requestCatchUp.mockReturnValue({ triggered: false, inProgress: true })
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(result.freshness).toEqual({ catchUp: 'reused' })
expect(knowledge.waitForIndexingComplete).not.toHaveBeenCalled()
expect(result.coverage).toEqual({ state: 'partial' })
})
it('追平后 0 结果才允许 complete negative;落后时同一条件只能是 partial', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ ...staleIndex(), sourceLatestAt: NOW - 1000 })
const stale = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '不存在的词' })
expect(stale.coverage).toEqual({ state: 'partial' })
knowledge.search.mockResolvedValue({ ...freshIndex(), sourceLatestAt: NOW - 1000 })
const fresh = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '不存在的词' })
expect(fresh.coverage).toEqual({ state: 'complete' })
expect(fresh.evidenceCount).toBe(0)
})
it('派生索引不可用时保持 unknown/partial,不冒充 complete', async () => {
fixture.contacts.splice(1)
knowledge.search.mockResolvedValue({ state: 'unavailable', evidence: [], indexLatestAt: null, sourceLatestAt: null, voiceCoverage: undefined })
const empty = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(empty.coverage).toEqual({ state: 'unknown' })
expect(knowledge.requestCatchUp).not.toHaveBeenCalled()
})
it('落后量低于门槛(max(下限, 上一遍耗时))时不触发追赶,只如实 partial', async () => {
fixture.contacts.splice(1)
// 上一遍索引耗时 15 分钟 → 15 分钟的落后不值得立刻再跑一遍(否则会连续索引)。
knowledge.lastPassDurationMs.mockReturnValue(15 * 60 * 1000)
knowledge.search.mockResolvedValue({
state: 'ready',
evidence: [],
indexLatestAt: NOW - 15 * 60 * 1000,
sourceLatestAt: NOW - 1000,
voiceCoverage: undefined
})
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(result.freshness).toEqual({ catchUp: 'skipped' })
expect(result.coverage).toEqual({ state: 'partial' })
expect(knowledge.requestCatchUp).not.toHaveBeenCalled()
})
it('落后量超过上一遍耗时 → 值得再追一遍', async () => {
fixture.contacts.splice(1)
knowledge.lastPassDurationMs.mockReturnValue(60 * 1000)
knowledge.search.mockResolvedValue({ ...staleIndex(), sourceLatestAt: NOW - 1000 })
knowledge.requestCatchUp.mockReturnValue({ triggered: true, inProgress: true })
const result = await service.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(knowledge.requestCatchUp).toHaveBeenCalledTimes(1)
expect(result.freshness).toEqual({ catchUp: 'pending' })
})
})
})
describe('LocalQueryApiService — indexCoverage 结论句', () => {
const NOW2 = new Date('2026-09-09T12:00:00+08:00').getTime()
let service2: LocalQueryApiService
beforeEach(() => {
vi.clearAllMocks()
if (!fixture.contacts.some((contact) => contact.md5 === 'md5-bobo-2')) fixture.contacts.push({ m_nsUsrName: 'wxid-bobo-2', m_nsNickName: 'BOBO', md5: 'md5-bobo-2', type: 'user' })
service2 = new LocalQueryApiService(
{
search: vi.fn(async () => ({
state: 'ready',
evidence: [],
indexLatestAt: NOW2 - 10 * 24 * 60 * 60 * 1000,
sourceLatestAt: NOW2 - 1000,
voiceCoverage: undefined
})),
requestCatchUp: vi.fn(() => ({ triggered: false, inProgress: false })),
waitForIndexingComplete: vi.fn(async () => false),
lastPassDurationMs: vi.fn(() => 0),
beginInteractiveQuery: vi.fn(),
endInteractiveQuery: vi.fn()
} as never,
() => new Date('2026-09-09T12:00:00+08:00')
)
fixture.contacts.splice(1)
})
it('落后时给出可直接引用的本地时间结论,且明确"暂时无法确认"', async () => {
const result = await service2.search({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '健身' })
expect(result.coverage).toEqual({ state: 'partial' })
// 8/30 12:00 的本地时间标签(固定时钟下可断言)
expect(result.indexCoverage?.covered).toBe(false)
expect(result.indexCoverage?.indexLatestAtLabel).toBe('08-30 12:00')
expect(result.indexCoverage?.summary).toContain('还没进索引')
expect(result.indexCoverage?.summary).toContain('暂时无法确认')
// 不要把 epoch 数字交给模型去猜
expect(result.indexCoverage?.summary).not.toMatch(/\d{12,}/)
})
})
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest'
import { createLocalQueryToolExecutor } from '../../src/main/services/local-query-tool-executor'
import type { LocalQueryApiService } from '../../src/main/services/local-query-api-service'
function fakeQueryApi(): LocalQueryApiService {
return {
messages: vi.fn(async () => ({ status: 'completed', returnedCount: 0 })),
search: vi.fn(async () => ({ status: 'completed', evidenceCount: 0 })),
context: vi.fn(async () => ({ status: 'completed' })),
overview: vi.fn(async () => ({ status: 'completed' }))
} as unknown as LocalQueryApiService
}
describe('createLocalQueryToolExecutor', () => {
it('把四个 Tool 映射到对应的 Local Query API 能力', async () => {
const api = fakeQueryApi()
const execute = createLocalQueryToolExecutor(api)
await execute('query_messages', { target: { query: 'BOBO' }, timeRange: { kind: 'all' } })
await execute('search_messages', { query: '房租' })
await execute('message_context', { messageRef: 'ref' })
await execute('conversation_overview', { target: { query: '群' }, timeRange: { kind: 'all' } })
expect(api.messages).toHaveBeenCalledWith({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } })
expect(api.search).toHaveBeenCalledWith({ query: '房租' })
expect(api.context).toHaveBeenCalledWith({ messageRef: 'ref' })
expect(api.overview).toHaveBeenCalledWith({
target: { query: '群' },
timeRange: { kind: 'all' }
})
})
it('拒绝未知工具名(不静默落到任意查询)', async () => {
const api = fakeQueryApi()
const execute = createLocalQueryToolExecutor(api)
await expect(execute('raw_sql', {})).rejects.toThrow('不允许的工具')
expect(api.messages).not.toHaveBeenCalled()
})
})
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import type { Message } from '../../src/shared/types'
import { buildMessageGroups } from '../../src/renderer/src/components/chat/messageGrouping'
import {
normalizeJumpMessageId,
resolveMessageJumpTarget
} from '../../src/renderer/src/components/chat/messageJump'
const message = (id: string, createTime: number, content = id): Message =>
({
id,
from: 'user',
type: '普通文本',
datetime: '',
content,
isSender: false,
createTime
}) as Message
/**
* 「跳转到原聊天」的定位契约。
*
* 必须**真的**落在那一条消息上,而不是"路由切过去了"。
* 秒级时间戳在群聊里对应多条消息,所以精确 id 必须优先。
*/
describe('resolveMessageJumpTarget', () => {
it('prefers the exact stable identity over the timestamp', () => {
const messages = [
message('m1', 1_700_000_000),
message('m2', 1_700_000_000),
message('m3', 1_700_000_000)
]
const groups = buildMessageGroups(messages)
// 三条消息同一秒:只有 id 能定位到正确的那一条。
const target = resolveMessageJumpTarget(groups, 1_700_000_000, 'm3')
expect(target).toMatchObject({ messageId: 'm3', exact: true })
})
it('normalizes the synthetic local: prefix on both sides', () => {
// 归档侧存的是 `local:42`,证据侧的 messageRef 解出来是 `42` —— 必须视为同一条。
const groups = buildMessageGroups([message('local:42', 1_700_000_000)])
expect(normalizeJumpMessageId('local:42')).toBe('42')
expect(resolveMessageJumpTarget(groups, undefined, '42')).toMatchObject({
messageId: 'local:42',
exact: true
})
// 反过来也一样(证据侧带前缀、归档侧不带)。
const plain = buildMessageGroups([message('42', 1_700_000_000)])
expect(resolveMessageJumpTarget(plain, undefined, 'local:42')).toMatchObject({
messageId: '42',
exact: true
})
})
it('returns the archive-side id so the highlight comparison cannot silently mismatch', () => {
const groups = buildMessageGroups([message('local:17078', 1_700_000_000)])
const target = resolveMessageJumpTarget(groups, undefined, '17078')
// 传进去的是归一化后的 `17078`,但返回的必须是归档侧真实出现的 `local:17078`:
// MessageGroup 用 message.id 直接比对高亮。
expect(target?.messageId).toBe('local:17078')
})
it('falls back to the first message at or after the timestamp for legacy evidence', () => {
const groups = buildMessageGroups([
message('old', 1_690_000_000),
message('hit', 1_700_000_100),
message('newer', 1_700_000_200)
])
expect(resolveMessageJumpTarget(groups, 1_700_000_000, undefined)).toMatchObject({
messageId: 'hit',
exact: false
})
})
it('reports nothing to jump to when the message no longer exists', () => {
const groups = buildMessageGroups([message('m1', 1_700_000_000)])
// 目标已被删除 / 清理,且时间戳晚于所有消息 → 调用方必须走诚实降级文案。
expect(resolveMessageJumpTarget(groups, 1_900_000_000, 'gone')).toBeNull()
expect(resolveMessageJumpTarget(groups, undefined, 'gone')).toBeNull()
expect(resolveMessageJumpTarget(groups, null, null)).toBeNull()
})
it('still resolves by id when the evidence carries no usable timestamp', () => {
const groups = buildMessageGroups([message('m1', 1_700_000_000), message('m2', 1_700_000_600)])
// 没有 createTime 的证据在真实数据里确实存在(老缓存),不能因此放弃定位。
expect(resolveMessageJumpTarget(groups, undefined, 'm1')).toMatchObject({
messageId: 'm1',
exact: true,
groupIndex: 0
})
})
})
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import {
decodeMessageRef,
encodeMessageRef,
normalizeMessageIdentity
} from '../../src/shared/local-query-api'
import {
MESSAGES_AROUND_MAX_WINDOW,
messagesAroundRadii,
normalizeRadiusSeconds,
sliceMessagesAroundWindow,
widenRadiusSeconds
} from '../../src/main/services/messages-around'
/**
* 「跳到原聊天」的稳定身份。
*
* 只靠「会话 + 秒级时间戳」定位不到**这一条**消息:同一秒可能有多条,
* 时间戳也只能定位到"附近"。messageRef 是唯一跨层可用的稳定身份,
* 所以它的编解码必须与 Buffer / Node API 解耦(renderer 在 contextIsolation 下没有 Buffer)。
*/
describe('messageRef codec', () => {
it('round-trips conversation and message identity', () => {
const ref = encodeMessageRef('conversation-md5', '17078')
expect(decodeMessageRef(ref)).toEqual({
conversationId: 'conversation-md5',
messageId: '17078'
})
})
it('strips the synthetic local: prefix so both sides normalize to the same identity', () => {
const ref = encodeMessageRef('conversation-md5', 'local:17078')
expect(decodeMessageRef(ref)).toEqual({
conversationId: 'conversation-md5',
messageId: '17078'
})
// 两条不同的入参(带/不带前缀)必须归一到同一个身份。
expect(encodeMessageRef('conversation-md5', '17078')).toBe(ref)
})
it('produces an opaque, URL-safe token that does not leak raw ids', () => {
const ref = encodeMessageRef('conversation-md5', '17078')
expect(ref).not.toContain('conversation-md5')
expect(ref).not.toContain('17078')
expect(ref).toMatch(/^[A-Za-z0-9_-]+$/)
expect(ref).not.toMatch(/[+/=]/)
})
it('returns null instead of throwing for garbage, tampered or empty references', () => {
expect(decodeMessageRef('')).toBeNull()
expect(decodeMessageRef('not-base64!!')).toBeNull()
expect(decodeMessageRef(encodeMessageRef('conversation-md5', '17078') + 'x')).toBeNull()
expect(decodeMessageRef(undefined)).toBeNull()
expect(decodeMessageRef(42)).toBeNull()
expect(decodeMessageRef({ c: 'a', m: 'b' })).toBeNull()
// 合法 base64 但不是我们的结构。
expect(decodeMessageRef(btoa('{"x":1}').replace(/=+$/, ''))).toBeNull()
})
it('rejects identities missing either half', () => {
expect(normalizeMessageIdentity('', '17078')).toBeNull()
expect(normalizeMessageIdentity('conversation-md5', ' ')).toBeNull()
expect(normalizeMessageIdentity('conversation-md5', '17078')).toEqual({
conversationId: 'conversation-md5',
messageId: '17078'
})
})
})
/**
* 锚点窗口规则:加载必须围绕这条消息,**不能**退化成整段历史。
*/
describe('messages around anchor window', () => {
it('never plans an unbounded read when the anchor time is missing', () => {
const base = normalizeRadiusSeconds(undefined)
// 没有锚点 = 没有半径可试:调用方必须走「已打开会话但无法定位原消息」的降级文案。
expect(messagesAroundRadii(undefined, base, widenRadiusSeconds(base))).toEqual([])
expect(messagesAroundRadii(0, base, widenRadiusSeconds(base))).toEqual([])
expect(messagesAroundRadii(-5, base, widenRadiusSeconds(base))).toEqual([])
})
it('tries the narrow window first, then one bounded widening', () => {
const base = normalizeRadiusSeconds(undefined)
const widened = widenRadiusSeconds(base)
expect(messagesAroundRadii(1_785_900_000, base, widened)).toEqual([base, widened])
// 放宽有上限:一次点击不许变成全库扫描。
expect(widened).toBeLessThanOrEqual(3 * 24 * 3600)
expect(widened).toBeGreaterThanOrEqual(base)
})
it('clamps the requested radius into a sane band', () => {
expect(normalizeRadiusSeconds(1)).toBe(60)
expect(normalizeRadiusSeconds(6 * 3600)).toBe(6 * 3600)
expect(normalizeRadiusSeconds(365 * 24 * 3600)).toBe(24 * 3600)
})
it('caps the returned window so a huge conversation cannot flood the IPC payload', () => {
const messages = Array.from({ length: 5000 }, (_, index) => ({ id: `local:${index}` }))
const { window, index, truncated } = sliceMessagesAroundWindow(
messages,
'conversation-md5',
'4999'
)
expect(truncated).toBe(true)
expect(window).toHaveLength(MESSAGES_AROUND_MAX_WINDOW)
// 目标落在被截断的部分之外 → 如实报告"没找到",而不是伪装成找到。
expect(index).toBe(-1)
})
it('locates the exact message by normalized identity, not by position', () => {
const messages = [{ id: 'local:17076' }, { id: '17077' }, { id: 'local:17078' }]
const { window, index, truncated } = sliceMessagesAroundWindow(
messages,
'conversation-md5',
'17078'
)
expect(truncated).toBe(false)
expect(index).toBe(2)
expect(window[index]).toEqual({ id: 'local:17078' })
})
})
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import {
CONVERSATION_MEMORY_ANSWER_MAX_CHARS,
CONVERSATION_MEMORY_MAX_TURNS,
CONVERSATION_MEMORY_QUESTION_MAX_CHARS,
CONVERSATION_MEMORY_TTL_MS,
QueryAgentConversationMemory
} from '../../src/main/services/query-agent-conversation-memory'
describe('QueryAgentConversationMemory', () => {
it('只保留最近 N 轮,且有明确长度边界', () => {
const memory = new QueryAgentConversationMemory()
memory.record('k', 'q1', 'a1')
memory.record('k', 'q2', 'a2')
memory.record('k', 'q3', 'a3')
const history = memory.history('k')
expect(history).toHaveLength(CONVERSATION_MEMORY_MAX_TURNS)
expect(history.map((turn) => turn.question)).toEqual(['q2', 'q3'])
})
it('超长问题与回答会被截断', () => {
const memory = new QueryAgentConversationMemory()
memory.record('k', 'q'.repeat(500), 'a'.repeat(2000))
const [turn] = memory.history('k')
expect(turn.question.length).toBe(CONVERSATION_MEMORY_QUESTION_MAX_CHARS + 1)
expect(turn.answer.length).toBe(CONVERSATION_MEMORY_ANSWER_MAX_CHARS + 1)
})
it('超过 TTL 后自动失效', () => {
let now = 1_000
const memory = new QueryAgentConversationMemory(() => now)
memory.record('k', 'q', 'a')
expect(memory.history('k')).toHaveLength(1)
now += CONVERSATION_MEMORY_TTL_MS + 1
expect(memory.history('k')).toEqual([])
})
it('按会话隔离,forget 只清目标会话', () => {
const memory = new QueryAgentConversationMemory()
memory.record('a', 'q-a', 'a-a')
memory.record('b', 'q-b', 'a-b')
memory.forget('a')
expect(memory.history('a')).toEqual([])
expect(memory.history('b')).toHaveLength(1)
memory.forget()
expect(memory.history('b')).toEqual([])
})
it('空白内容不进入记忆', () => {
const memory = new QueryAgentConversationMemory()
memory.record('k', ' ', 'a')
memory.record('k', 'q', ' ')
expect(memory.history('k')).toEqual([])
})
})
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest'
import { QueryAgentService, type QueryAgentProvider } from '../../src/main/services/query-agent-service'
function capturingProvider(configured = true): {
provider: QueryAgentProvider
calls: Array<{ messages: Array<Record<string, unknown>>; tools: string[] }>
} {
const calls: Array<{ messages: Array<Record<string, unknown>>; tools: string[] }> = []
const provider: QueryAgentProvider = {
getRuntimeConfig: () => ({
configured,
providerName: 'Fixture Provider',
model: 'fixture-model',
modelName: 'Fixture Model'
}),
chatWithTools: vi.fn(async (messages, tools) => {
calls.push({ messages, tools: tools.map((tool) => tool.function.name) })
return { success: true, data: 'ok' }
})
}
return { provider, calls }
}
describe('QueryAgentService — 历史上下文不改变毕业语义', () => {
it('不传 history 时 messages 仍然只有 system + user(与毕业版本一致)', async () => {
const { provider, calls } = capturingProvider()
const service = new QueryAgentService(provider, vi.fn())
await service.run('我和 BOBO 第一次聊了什么')
expect(calls[0].messages.map((message) => message.role)).toEqual(['system', 'user'])
expect(calls[0].messages[1]).toMatchObject({ content: '我和 BOBO 第一次聊了什么' })
expect(calls[0].tools).toEqual([
'query_messages',
'search_messages',
'message_context',
'conversation_overview'
])
})
it('传 history 时按 问 / 答 顺序插在 system 之后', async () => {
const { provider, calls } = capturingProvider()
const service = new QueryAgentService(provider, vi.fn())
await service.run('是 BOBO', {
history: [{ question: '我们第一次聊了什么', answer: '你说的是哪位联系人?' }]
})
expect(calls[0].messages.map((message) => message.role)).toEqual([
'system',
'user',
'assistant',
'user'
])
})
it('失败分类是 additive 字段:成功时不存在', async () => {
const { provider } = capturingProvider()
const service = new QueryAgentService(provider, vi.fn())
const result = await service.run('你好')
expect(result.errorKind).toBeUndefined()
expect(result.answer).toBe('ok')
})
it('Provider 未配置 / 空问题仍返回可区分的失败分类', async () => {
const notConfigured = new QueryAgentService(capturingProvider(false).provider, vi.fn())
const unconfigured = await notConfigured.run('你好')
expect(unconfigured.errorKind).toBe('provider_unavailable')
expect(unconfigured.modelCallCount).toBe(0)
const configured = new QueryAgentService(capturingProvider().provider, vi.fn())
const empty = await configured.run(' ')
expect(empty.errorKind).toBe('invalid_question')
})
})
+38 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { parsePocQuestion } from '../../src/main/query-agent-poc-cli'
import { parsePocInvocation, parsePocQuestion } from '../../src/main/query-agent-poc-cli'
describe('Query Agent POC CLI 参数解析', () => {
it('移除开头的分隔符 --', () => {
@@ -34,3 +34,40 @@ describe('Query Agent POC CLI 参数解析', () => {
expect(argv).toEqual(['--', '问题'])
})
})
describe('parsePocInvocation — 诊断模式与搜索范围', () => {
it('无 --tool 时是自然语言模式,并把 --scope 从问题里剥掉', () => {
expect(parsePocInvocation(['我和 BOBO 第一次聊了什么'])).toMatchObject({
kind: 'question',
question: '我和 BOBO 第一次聊了什么'
})
const scoped = parsePocInvocation([
'--scope',
'{"kind":"groups"}',
'最近谁聊过健身'
])
expect(scoped).toMatchObject({ kind: 'question', question: '最近谁聊过健身' })
if (scoped.kind !== 'question') throw new Error('unreachable')
expect(scoped.scope).toEqual({ kind: 'groups' })
})
it('裸工具模式把 scope 合并进工具参数(Host 注入,LLM 提供不了)', () => {
const invocation = parsePocInvocation([
'--tool',
'search_messages',
'--args',
'{"timeRange":{"kind":"all"},"query":"健身"}',
'--scope',
'{"kind":"groups"}'
])
if (invocation.kind !== 'tool') throw new Error('unreachable')
expect(invocation.toolName).toBe('search_messages')
expect(invocation.args).toMatchObject({ query: '健身', scope: { kind: 'groups' } })
})
it('非法 scope / 缺少 conversationId 会明确报错', () => {
expect(() => parsePocInvocation(['--scope', '{"kind":"nope"}', 'x'])).toThrow(/scope.kind/)
expect(() => parsePocInvocation(['--scope', '{"kind":"contact"}', 'x'])).toThrow(/conversationId/)
expect(() => parsePocInvocation(['--scope', 'not-json', 'x'])).toThrow(/合法 JSON/)
})
})
+2 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { formatProviderDiagnostics, formatTiming } from '../../src/main/query-agent-poc-report'
import type { QueryAgentPocResult } from '../../src/main/services/query-agent-poc-service'
import type { QueryAgentResult } from '../../src/main/services/query-agent-service'
function result(patch: Partial<QueryAgentPocResult> = {}): QueryAgentPocResult {
function result(patch: Partial<QueryAgentResult> = {}): QueryAgentResult {
return {
question: 'q',
provider: 'Fixture Provider',
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import {
QUERY_AGENT_PROGRESS_STEPS,
queryAgentProgressLabel,
queryAgentProgressStepIndex,
type QueryAgentProgressState
} from '../../src/renderer/src/components/search/hooks/useQueryAgentProgress'
const state = (patch: Partial<QueryAgentProgressState> = {}): QueryAgentProgressState => ({
stage: 'understanding',
elapsedMs: 0,
modelCallCount: 1,
toolCallCount: 0,
...patch
})
/**
* 进度文案必须来自**真实 Runtime 生命周期**,而且要说清"卡在哪一步":
* 静态 N 步文案加一个计时器,用户什么都判断不出来。
*/
describe('queryAgentProgressLabel', () => {
it('maps every real lifecycle stage to a user-readable sentence', () => {
expect(queryAgentProgressLabel(null, true)).toBe('正在准备查询')
expect(queryAgentProgressLabel(state({ stage: 'understanding' }), true)).toBe(
'正在理解你的问题'
)
expect(
queryAgentProgressLabel(state({ stage: 'understanding', modelCallCount: 2 }), true)
).toBe('正在重新理解你的问题')
expect(queryAgentProgressLabel(state({ stage: 'organizing_evidence' }), true)).toBe(
'正在整理找到的聊天记录'
)
expect(queryAgentProgressLabel(state({ stage: 'generating_answer' }), true)).toBe(
'正在生成回答'
)
expect(queryAgentProgressLabel(state({ stage: 'completed' }), true)).toBe('已完成')
})
it('explains the wider scope during the Tool phase instead of silently hanging', () => {
// 跨会话检索是另一个量级的耗时;提前说清楚范围,用户才不会被"突然要好几十秒"劝退。
expect(queryAgentProgressLabel(state({ stage: 'searching' }), true)).toBe(
'正在搜索较大范围的聊天记录…'
)
expect(queryAgentProgressLabel(state({ stage: 'searching' }), false)).toBe(
'正在搜索聊天记录…'
)
})
it('never leaks internal tool names, SQL, FTS or ids', () => {
const stages: QueryAgentProgressState['stage'][] = [
'understanding',
'searching',
'organizing_evidence',
'generating_answer',
'completed'
]
for (const stage of stages) {
for (const wide of [true, false]) {
const label = queryAgentProgressLabel(state({ stage, toolName: 'search_messages' }), wide)
expect(label).not.toMatch(/query_messages|search_messages|message_context|overview/)
expect(label).not.toMatch(/SELECT|FTS|fts|knowledge_|local:|[0-9a-f]{16,}/)
}
}
})
it('keeps the declared step list aligned with the runtime lifecycle', () => {
expect(QUERY_AGENT_PROGRESS_STEPS.map((step) => step.stage)).toEqual([
'understanding',
'searching',
'organizing_evidence',
'generating_answer'
])
})
})
describe('queryAgentProgressStepIndex', () => {
it('advances monotonically with the lifecycle and completes at the end', () => {
expect(queryAgentProgressStepIndex(null)).toBe(0)
expect(queryAgentProgressStepIndex(state({ stage: 'understanding' }))).toBe(0)
expect(queryAgentProgressStepIndex(state({ stage: 'searching' }))).toBe(1)
expect(queryAgentProgressStepIndex(state({ stage: 'organizing_evidence' }))).toBe(2)
expect(queryAgentProgressStepIndex(state({ stage: 'generating_answer' }))).toBe(3)
expect(queryAgentProgressStepIndex(state({ stage: 'completed' }))).toBe(
QUERY_AGENT_PROGRESS_STEPS.length
)
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { QueryAgentPocService, type QueryAgentProvider, validateToolArguments } from '../../src/main/services/query-agent-poc-service'
import { QueryAgentService, type QueryAgentProvider, validateToolArguments } from '../../src/main/services/query-agent-service'
function provider(responses: Array<Awaited<ReturnType<QueryAgentProvider['chatWithTools']>>>, configured = true): QueryAgentProvider {
return {
@@ -12,10 +12,10 @@ function provider(responses: Array<Awaited<ReturnType<QueryAgentProvider['chatWi
const MONTH_TEXT = '上个月'
const MONTH_QUESTION = '上个月 BOBO 有没有给我发过文件'
describe('QueryAgentPocService', () => {
describe('QueryAgentService', () => {
it('runs a bounded model -> tool -> model loop and records sanitized trace', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'secret-ref' }] }))
const service = new QueryAgentPocService(provider([
const service = new QueryAgentService(provider([
{ success: true, toolCalls: [{ id: 'call-1', name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, temporalBasis: { kind: 'none' }, limit: 1 }) }] },
{ success: true, data: '第一条消息是图片。' }
]), execute)
@@ -24,7 +24,13 @@ describe('QueryAgentPocService', () => {
expect(result.modelCallCount).toBe(2)
expect(result.toolCallCount).toBe(1)
expect(result.traces[0]).toMatchObject({ toolName: 'query_messages', status: 'completed', resultCount: 1 })
expect(JSON.stringify(result)).not.toContain('secret-ref')
// trace / input 必须保持脱敏(opaque messageRef 不出现在 trace 里)。
expect(JSON.stringify(result.traces)).not.toContain('secret-ref')
// 证据是 ADDITIVE 的展示契约:只有 evidence[] 携带 opaque messageRef,且不含会话身份。
expect(result.evidence).toEqual([
expect.objectContaining({ messageRef: 'secret-ref', source: 'query_messages' })
])
expect(JSON.stringify(result.evidence)).not.toMatch(/wxid|md5|conversationId/)
})
it('presents planning boundaries and remaining budget with tool results', async () => {
@@ -39,7 +45,7 @@ describe('QueryAgentPocService', () => {
{ success: true, toolCalls: [{ id: 'call-1', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['topic'] }) }] },
{ success: true, data: '根据这条证据可以回答。' }
])
await new QueryAgentPocService(configuredProvider, execute).run('查找相关记录')
await new QueryAgentService(configuredProvider, execute).run('查找相关记录')
const calls = vi.mocked(configuredProvider.chatWithTools).mock.calls
const firstMessages = calls[0]?.[0] || []
expect(String(firstMessages[0]?.content)).toContain('Evidence 是否已经足以')
@@ -56,7 +62,7 @@ describe('QueryAgentPocService', () => {
{ success: true, toolCalls: [{ id: 'call-1', name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, temporalBasis: { kind: 'none' }, limit: 1 }) }] },
{ success: true, data: '完成' }
])
await new QueryAgentPocService(configuredProvider, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('第一条消息')
await new QueryAgentService(configuredProvider, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('第一条消息')
expect(vi.mocked(configuredProvider.chatWithTools).mock.calls[1]?.[1]).toEqual([])
})
@@ -67,7 +73,7 @@ describe('QueryAgentPocService', () => {
{ success: true, toolCalls: [{ id: 'call-2', name: 'conversation_overview', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } }) }] },
{ success: true, data: '完成' }
])
const result = await new QueryAgentPocService(configuredProvider, execute).run('第一条消息')
const result = await new QueryAgentService(configuredProvider, execute).run('第一条消息')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1]).toMatchObject({ toolName: 'conversation_overview', status: 'invalid_tool_arguments' })
const thirdCallMessages = vi.mocked(configuredProvider.chatWithTools).mock.calls[2]?.[0] || []
@@ -78,7 +84,7 @@ describe('QueryAgentPocService', () => {
it('rejects unknown tools and stops after five calls', async () => {
const execute = vi.fn(async () => ({ status: 'completed' }))
const responses = Array.from({ length: 6 }, () => ({ success: true, toolCalls: [{ id: 'x', name: 'unknown', arguments: '{}' }] }))
const result = await new QueryAgentPocService(provider(responses), execute).run('test')
const result = await new QueryAgentService(provider(responses), execute).run('test')
expect(result.toolCallCount).toBe(5)
expect(result.traces.every((trace) => trace.status === 'invalid_tool_arguments')).toBe(true)
expect(result.error).toContain('最大工具调用次数')
@@ -87,14 +93,14 @@ describe('QueryAgentPocService', () => {
it('clarifies unavailable configuration without making a model call', async () => {
const configuredProvider = provider([], false)
const result = await new QueryAgentPocService(configuredProvider, vi.fn()).run('test')
const result = await new QueryAgentService(configuredProvider, vi.fn()).run('test')
expect(result.error).toContain('尚未配置')
expect(configuredProvider.chatWithTools).not.toHaveBeenCalled()
})
it('maps LLM queries[] to the Local Query API query + variants contract', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 0 }))
const valid = await new QueryAgentPocService(provider([
const valid = await new QueryAgentService(provider([
{ success: true, toolCalls: [{ id: 'valid', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['答应', '承诺', '保证', '说好'] }) }] },
{ success: true, data: 'done' }
]), execute).run('test')
@@ -105,7 +111,7 @@ describe('QueryAgentPocService', () => {
expect(execute.mock.calls[0][1]).not.toHaveProperty('queries')
execute.mockClear()
const invalid = await new QueryAgentPocService(provider([
const invalid = await new QueryAgentService(provider([
{ success: true, toolCalls: [{ id: 'invalid', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['1', '2', '3', '4', '5'] }) }] },
{ success: true, data: '修正后完成' }
]), execute).run('test')
@@ -233,7 +239,7 @@ describe('QueryAgent zero-result limited retry', () => {
it('keeps search_messages available once after a zero-evidence search', async () => {
const execute = vi.fn(emptySearch)
const configured = provider([searchCall('c1', ['答应']), { success: true, data: '没有找到相关证据。' }])
await new QueryAgentPocService(configured, execute).run('找承诺')
await new QueryAgentService(configured, execute).run('找承诺')
expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1].map((tool) => tool.function.name)).toEqual(['search_messages'])
const toolMessage = vi.mocked(configured.chatWithTools).mock.calls[1]?.[0].find((message) => message.role === 'tool')
const presented = JSON.parse(String(toolMessage?.content)) as Record<string, any>
@@ -244,7 +250,7 @@ describe('QueryAgent zero-result limited retry', () => {
it('allows exactly one substantively different second search and then closes tools', async () => {
const execute = vi.fn(emptySearch)
const configured = provider([searchCall('c1', ['答应']), searchCall('c2', ['公积金']), { success: true, data: '仍然没有。' }])
const result = await new QueryAgentPocService(configured, execute).run('找承诺')
const result = await new QueryAgentService(configured, execute).run('找承诺')
expect(execute).toHaveBeenCalledTimes(2)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed'])
expect(vi.mocked(configured.chatWithTools).mock.calls[2]?.[1]).toEqual([])
@@ -254,7 +260,7 @@ describe('QueryAgent zero-result limited retry', () => {
it('rejects an identical search retry instead of spending the budget again', async () => {
const execute = vi.fn(emptySearch)
const configured = provider([searchCall('c1', ['答应']), searchCall('c2', ['答应']), { success: true, data: '没有找到。' }])
const result = await new QueryAgentPocService(configured, execute).run('找承诺')
const result = await new QueryAgentService(configured, execute).run('找承诺')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(vi.mocked(configured.chatWithTools).mock.calls[2]?.[1]).toEqual([])
@@ -265,7 +271,7 @@ describe('QueryAgent zero-result limited retry', () => {
it('treats a reordered identical probe set as an identical retry', async () => {
const execute = vi.fn(emptySearch)
const configured = provider([searchCall('c1', ['答应', '承诺']), searchCall('c2', ['承诺', '答应']), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run('找承诺')
const result = await new QueryAgentService(configured, execute).run('找承诺')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1].status).toBe('invalid_tool_arguments')
})
@@ -273,14 +279,14 @@ describe('QueryAgent zero-result limited retry', () => {
it('keeps the efficient path when the first search already returned evidence', async () => {
const execute = vi.fn(async () => ({ status: 'completed', evidenceCount: 2, evidence: [{ messageRef: 'ref', text: 't' }] }))
const configured = provider([searchCall('c1', ['答应']), { success: true, data: 'ok' }])
await new QueryAgentPocService(configured, execute).run('找承诺')
await new QueryAgentService(configured, execute).run('找承诺')
expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1].map((tool) => tool.function.name)).toEqual(['message_context'])
})
it('rejects an identical query_messages retry', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }), queryCall('c2', { kind: 'previous_month' }), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run(MONTH_QUESTION)
const result = await new QueryAgentService(configured, execute).run(MONTH_QUESTION)
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1].status).toBe('invalid_tool_arguments')
})
@@ -288,7 +294,7 @@ describe('QueryAgent zero-result limited retry', () => {
it('keeps the efficient stop when the exact query already returned messages', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 3, messages: [] }))
const configured = provider([queryCall('c1'), { success: true, data: 'ok' }])
await new QueryAgentPocService(configured, execute).run(MONTH_QUESTION)
await new QueryAgentService(configured, execute).run(MONTH_QUESTION)
expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1]).toEqual([])
})
@@ -302,7 +308,7 @@ describe('QueryAgent zero-result limited retry', () => {
queryCall('c5', { kind: 'this_year' }),
queryCall('c6', { kind: 'this_month' })
]
const result = await new QueryAgentPocService(provider(responses), execute).run(MONTH_QUESTION)
const result = await new QueryAgentService(provider(responses), execute).run(MONTH_QUESTION)
expect(result.toolCallCount).toBe(5)
expect(result.error).toContain('最大工具调用次数')
})
@@ -317,7 +323,7 @@ describe('QueryAgent zero-result limited retry', () => {
toolCalls: [{ id, name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: range, temporalBasis: { kind: 'constraint', sourceText: '8 月' }, messageTypes }) }]
})
const configured = provider([call('c1', ['file']), call('c2', ['text']), { success: true, data: '找到。' }])
const result = await new QueryAgentPocService(configured, execute).run('今年 8 月有没有给我发过文件')
const result = await new QueryAgentService(configured, execute).run('今年 8 月有没有给我发过文件')
expect(execute).toHaveBeenCalledTimes(2)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed'])
// the explicit range is preserved on the retry — only the non-temporal condition changed
@@ -346,7 +352,7 @@ describe('QueryAgent temporal basis policy', () => {
it('requires temporalBasis on query_messages', async () => {
const execute = vi.fn(emptyQuery)
const call = { success: true as const, toolCalls: [{ id: 'c1', name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' } }) }] }
const result = await new QueryAgentPocService(provider([call, { success: true, data: 'x' }]), execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(provider([call, { success: true, data: 'x' }]), execute).run(CONSTRAINT_Q)
expect(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
})
@@ -354,7 +360,7 @@ describe('QueryAgent temporal basis policy', () => {
it('exposes the declared temporalBasis on the trace and never forwards it to the Local Query API', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 1, messages: [] }))
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: 'ok' }])
const result = await new QueryAgentPocService(configured, execute).run(HINT_Q)
const result = await new QueryAgentService(configured, execute).run(HINT_Q)
expect(result.traces[0].temporalBasis).toEqual({ kind: 'recall_hint', sourceText: '前阵子' })
expect(result.traces[0].input).not.toHaveProperty('temporalBasis')
expect(execute.mock.calls[0][1]).not.toHaveProperty('temporalBasis')
@@ -363,7 +369,7 @@ describe('QueryAgent temporal basis policy', () => {
it('rejects a sourceText that is not literally in the user question', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }, { kind: 'constraint', sourceText: '去年冬天' }), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q)
expect(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('source_not_in_question')
@@ -372,7 +378,7 @@ describe('QueryAgent temporal basis policy', () => {
it('requires sourceText for constraint and recall_hint', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }, { kind: 'constraint' }), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q)
expect(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
})
@@ -380,7 +386,7 @@ describe('QueryAgent temporal basis policy', () => {
it('rejects sourceText when kind is none', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'all' }, { kind: 'none', sourceText: '上个月' }), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q)
expect(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('forbidden_for_none')
@@ -392,7 +398,7 @@ describe('QueryAgent temporal basis policy', () => {
const sourceText = '8 月 1 日到 9 月 1 日'
const range = { kind: 'absolute', startTime: '2026-08-01T00:00:00+08:00', endTime: '2026-09-01T00:00:00+08:00' }
const configured = provider([queryCall('c1', range, { kind: 'constraint', sourceText }), { success: true, data: 'ok' }])
const result = await new QueryAgentPocService(configured, execute).run(question)
const result = await new QueryAgentService(configured, execute).run(question)
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].temporalBasis).toEqual({ kind: 'constraint', sourceText })
})
@@ -400,7 +406,7 @@ describe('QueryAgent temporal basis policy', () => {
it('never broadens a constraint relative range to all when it returns zero', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }, CONSTRAINT), { success: true, data: '上个月没有。' }])
const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q)
expect(execute).toHaveBeenCalledTimes(1)
expect(execute.mock.calls[0][1]).toMatchObject({ timeRange: { kind: 'previous_month' } })
expect(result.traces[0].autoFallback).toBeUndefined()
@@ -410,7 +416,7 @@ describe('QueryAgent temporal basis policy', () => {
const execute = vi.fn(emptyQuery)
const range = { kind: 'absolute', startTime: '2026-08-01T00:00:00+08:00', endTime: '2026-08-31T23:59:59+08:00' }
const configured = provider([queryCall('c1', range, { kind: 'constraint', sourceText: '8 月' }), { success: true, data: '没有。' }])
const result = await new QueryAgentPocService(configured, execute).run('2026 年 8 月有没有给我发过文件')
const result = await new QueryAgentService(configured, execute).run('2026 年 8 月有没有给我发过文件')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].autoFallback).toBeUndefined()
expect(result.traces[0].input.timeRange).toMatchObject({ kind: 'absolute' })
@@ -419,7 +425,7 @@ describe('QueryAgent temporal basis policy', () => {
it('rejects a constraint retry that replaces the user time range', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'previous_month' }, CONSTRAINT), queryCall('c2', { kind: 'all' }, CONSTRAINT), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q)
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(JSON.stringify(toolMessageAt(configured, 2))).toContain('constraint_time_range_immutable')
@@ -433,7 +439,7 @@ describe('QueryAgent temporal basis policy', () => {
queryCall('c3', { kind: 'all' }, CONSTRAINT),
{ success: true, data: 'x' }
]
const result = await new QueryAgentPocService(provider(responses), execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(provider(responses), execute).run(CONSTRAINT_Q)
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'invalid_tool_arguments', 'invalid_tool_arguments'])
})
@@ -447,7 +453,7 @@ describe('QueryAgent temporal basis policy', () => {
queryCall('c2', { kind: 'previous_month' }, CONSTRAINT, { messageTypes: ['text'] }),
{ success: true, data: '找到。' }
])
const result = await new QueryAgentPocService(configured, execute).run(CONSTRAINT_Q)
const result = await new QueryAgentService(configured, execute).run(CONSTRAINT_Q)
expect(execute).toHaveBeenCalledTimes(2)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed'])
})
@@ -455,7 +461,7 @@ describe('QueryAgent temporal basis policy', () => {
it('does not broaden when a recall_hint range already returned messages', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 3, messages: [] }))
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: 'ok' }])
const result = await new QueryAgentPocService(configured, execute).run(HINT_Q)
const result = await new QueryAgentService(configured, execute).run(HINT_Q)
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].autoFallback).toBeUndefined()
expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1]).toEqual([])
@@ -466,7 +472,7 @@ describe('QueryAgent temporal basis policy', () => {
.mockResolvedValueOnce({ status: 'completed', returnedCount: 0, messages: [] })
.mockResolvedValueOnce({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'ref', sourceKind: 'file' }], resolvedTimeRange: { kind: 'all', label: '全部历史' } })
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: '找到了。' }])
const result = await new QueryAgentPocService(configured, execute).run(HINT_Q)
const result = await new QueryAgentService(configured, execute).run(HINT_Q)
expect(execute).toHaveBeenCalledTimes(2)
expect(execute.mock.calls[1][1]).toMatchObject({ timeRange: { kind: 'all' } })
// the corrective lookup is Host orchestration, not a model tool call
@@ -477,7 +483,7 @@ describe('QueryAgent temporal basis policy', () => {
it('does not repeat an all-history lookup when a recall_hint query already used all', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'all' }, HINT), queryCall('c2', { kind: 'all' }, HINT), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run(HINT_Q)
const result = await new QueryAgentService(configured, execute).run(HINT_Q)
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].autoFallback).toBeUndefined()
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' })
@@ -488,7 +494,7 @@ describe('QueryAgent temporal basis policy', () => {
.mockResolvedValueOnce({ status: 'completed', returnedCount: 0, messages: [], query: { resolvedTimeRange: { kind: 'last_7_days', label: '近 7 天' } } })
.mockResolvedValueOnce({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'ref', text: 'x' }], resolvedTimeRange: { kind: 'all', label: '全部历史' } })
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), { success: true, data: 'ok' }])
await new QueryAgentPocService(configured, execute).run(HINT_Q)
await new QueryAgentService(configured, execute).run(HINT_Q)
const presented = toolMessageAt(configured, 1)
expect(presented.returnedCount).toBe(0)
expect(presented.fallbackLookup).toMatchObject({ reason: 'soft_temporal_hint_zero_result', timeRange: { kind: 'all' }, returnedCount: 1 })
@@ -498,7 +504,7 @@ describe('QueryAgent temporal basis policy', () => {
it('stops after a corrective lookup that also finds nothing', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, HINT), queryCall('c2', { kind: 'this_month' }, HINT), { success: true, data: '都没找到。' }])
const result = await new QueryAgentPocService(configured, execute).run(HINT_Q)
const result = await new QueryAgentService(configured, execute).run(HINT_Q)
// primary + one corrective lookup only; the third attempt is refused because tools are closed
expect(execute).toHaveBeenCalledTimes(2)
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' })
@@ -507,7 +513,7 @@ describe('QueryAgent temporal basis policy', () => {
it('accepts none with timeRange all', async () => {
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 2, messages: [] }))
const configured = provider([queryCall('c1', { kind: 'all' }, NONE), { success: true, data: 'ok' }])
const result = await new QueryAgentPocService(configured, execute).run('BOBO 给我发过文件吗')
const result = await new QueryAgentService(configured, execute).run('BOBO 给我发过文件吗')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[0].temporalBasis).toEqual({ kind: 'none' })
})
@@ -515,7 +521,7 @@ describe('QueryAgent temporal basis policy', () => {
it('rejects none combined with a bounded time range', async () => {
const execute = vi.fn(emptyQuery)
const configured = provider([queryCall('c1', { kind: 'last_7_days' }, NONE), { success: true, data: 'x' }])
const result = await new QueryAgentPocService(configured, execute).run('BOBO 给我发过文件吗')
const result = await new QueryAgentService(configured, execute).run('BOBO 给我发过文件吗')
expect(execute).not.toHaveBeenCalled()
expect(result.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(JSON.stringify(toolMessageAt(configured, 1))).toContain('temporal_basis_mismatch')
@@ -531,7 +537,7 @@ describe('QueryAgent 耗时与请求级诊断记录', () => {
const configured = provider([
{ success: false, error: '模型服务返回了网页而不是 JSON(HTTP 502 Bad Gateway)', errorStatus: 502, errorContentType: 'text/html', htmlInsteadOfJson: true, elapsedMs: 99419 }
])
const result = await new QueryAgentPocService(configured, vi.fn()).run('测试')
const result = await new QueryAgentService(configured, vi.fn()).run('测试')
expect(result.modelCallCount).toBe(1)
expect(result.modelDurationsMs).toHaveLength(1)
expect(result.modelDiagnostics).toEqual([
@@ -545,7 +551,7 @@ describe('QueryAgent 耗时与请求级诊断记录', () => {
it('2 次模型调用 + 1 次工具:耗时数组与调用次数一致', async () => {
const configured = provider([queryCall('c1'), { success: true, data: '完成' }])
const result = await new QueryAgentPocService(configured, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('测试')
const result = await new QueryAgentService(configured, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('测试')
expect(result.modelCallCount).toBe(2)
expect(result.modelDurationsMs).toHaveLength(2)
expect(result.modelDurationsMs.every((value) => typeof value === 'number' && value >= 0)).toBe(true)
@@ -555,7 +561,7 @@ describe('QueryAgent 耗时与请求级诊断记录', () => {
it('1 次模型调用 + 0 次工具:无工具诊断噪声', async () => {
const configured = provider([{ success: true, data: '直接回答' }])
const result = await new QueryAgentPocService(configured, vi.fn()).run('测试')
const result = await new QueryAgentService(configured, vi.fn()).run('测试')
expect(result.modelCallCount).toBe(1)
expect(result.toolCallCount).toBe(0)
expect(result.modelDurationsMs).toHaveLength(1)
@@ -567,10 +573,101 @@ describe('QueryAgent 耗时与请求级诊断记录', () => {
queryCall('c1'),
{ success: false, error: 'AI 请求超时', timedOut: true }
])
const result = await new QueryAgentPocService(configured, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('测试')
const result = await new QueryAgentService(configured, vi.fn(async () => ({ status: 'completed', returnedCount: 1 }))).run('测试')
expect(result.modelDurationsMs).toHaveLength(2)
expect(result.modelDiagnostics[1]).toMatchObject({ index: 2, timedOut: true })
expect(result.error).toBe('AI 请求超时')
expect(result.answer).toBeUndefined()
})
})
/**
* 生产查询进度。
*
* 进度必须来自**真实 Runtime 生命周期边界**,不允许定时器伪进度 ——
* 只有一个静态步骤界面加一个计时器时,用户看不到是在等模型、等本地检索,还是等知识库追新。
*/
describe('QueryAgentService progress lifecycle', () => {
it('emits the real understanding -> searching -> organizing -> generating lifecycle', async () => {
const stages: Array<{ stage: string; toolName?: string }> = []
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'r1' }] }))
const service = new QueryAgentService(
provider([
{
success: true,
toolCalls: [
{
id: 'call-1',
name: 'query_messages',
arguments: JSON.stringify({
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
temporalBasis: { kind: 'none' },
limit: 1
})
}
]
},
{ success: true, data: '第一条消息是图片。' }
]),
execute
)
await service.run('我和 BOBO 最开始聊了什么', {
onProgress: (event) => stages.push({ stage: event.stage, toolName: event.toolName })
})
expect(stages.map((item) => item.stage)).toEqual([
'understanding',
'searching',
'organizing_evidence',
'generating_answer',
'completed'
])
// toolName 只在 Host 侧用于诊断片段,UI 文案不会把它暴露给用户。
expect(stages[1].toolName).toBe('query_messages')
})
it('reports understanding again when the model re-plans after a bad call', async () => {
const stages: string[] = []
const execute = vi.fn(async () => ({ status: 'completed', returnedCount: 0, messages: [] }))
await new QueryAgentService(
provider([
{ success: true, toolCalls: [{ id: 'c1', name: 'not_a_tool', arguments: '{}' }] },
{ success: true, data: '无法完成。' }
]),
execute
).run('测试', { onProgress: (event) => stages.push(event.stage) })
// 第一个 tool call 被拒绝(没有真正执行工具)→ 仍然算"还没有 Tool 结果",
// 所以第二次模型调用依然是理解阶段,而不是凭空跳到"生成回答"。
expect(stages[0]).toBe('understanding')
expect(stages).toContain('generating_answer')
expect(stages[stages.length - 1]).toBe('completed')
})
it('always finishes the stream even when the provider fails', async () => {
const stages: string[] = []
const result = await new QueryAgentService(
provider([{ success: false, error: 'AI 请求超时' }]),
vi.fn()
).run('测试', { onProgress: (event) => stages.push(event.stage) })
expect(result.errorKind).toBe('provider_failure')
// 不允许把 UI 永久停在某个中间阶段。
expect(stages[0]).toBe('understanding')
expect(stages[stages.length - 1]).toBe('completed')
})
it('never lets a throwing progress listener break the query', async () => {
const result = await new QueryAgentService(
provider([{ success: true, data: '直接回答' }]),
vi.fn()
).run('测试', {
onProgress: () => {
throw new Error('listener 崩了')
}
})
expect(result.answer).toBe('直接回答')
})
})