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
@@ -6,6 +6,7 @@ import {
SEARCH_CACHE_KEY,
buildSearchCacheKey
} from '../../src/renderer/src/components/search/searchUtils'
import { makeSearchResult } from './support/ai-search-fixtures'
const api = {
getSettings: vi.fn(),
@@ -352,12 +353,18 @@ describe('AISearchWorkspace cache privacy boundary', () => {
expect(input).toHaveValue('')
})
it('disables and guards analysis while the knowledge base is synchronizing', async () => {
it('keeps analysis available while the knowledge base is synchronizing (partial result + freshness warning)', async () => {
api.getKnowledgeStatus.mockResolvedValue({
state: 'syncing',
indexedMessageCount: 20,
indexedChunkCount: 4,
processedMessages: 20,
totalMessages: 100
})
api.runAiSearch.mockResolvedValue(makeSearchResult())
// 本地 Provider:本用例测的是"同步中能否提问",不是远程授权边界
// (远程授权边界由本文件另外的用例覆盖)。
api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false })
const onNotice = vi.fn()
render(
<AISearchWorkspace
@@ -378,18 +385,24 @@ describe('AISearchWorkspace cache privacy boundary', () => {
/>
)
await userEvent.type(screen.getByRole('textbox'), '同步时不能分析')
const button = await screen.findByRole('button', { name: /同步中,暂不可分析/ })
expect(button).toBeDisabled()
const form = screen.getByRole('textbox').closest('form')
expect(form).not.toBeNull()
fireEvent.submit(form as HTMLFormElement)
await userEvent.type(screen.getByRole('textbox'), '同步时也要能分析')
// 等状态真的落地(同步中)再点,否则测的是"状态还没到"的竞态而不是产品行为。
await screen.findByText('Knowledge 可用 · 正在追新')
// 同步中**不允许**禁用提问:按钮必须可用,文案也不能变成"暂不可分析"。
const button = screen.getByRole('button', { name: /开始分析/ })
expect(button).toBeEnabled()
expect(screen.queryByRole('button', { name: /暂不可分析/ })).not.toBeInTheDocument()
await userEvent.click(button)
// 查询真的走到了检索(走完 consent 才会到这里),而不是被"请等待同步完成"挡回。
await waitFor(() => expect(api.getAiSearchProviderStatus).toHaveBeenCalled())
await waitFor(() =>
expect(onNotice).toHaveBeenCalledWith('知识库正在同步,请等待同步完成后再开始分析')
expect(onNotice).toHaveBeenCalledWith('知识库正在后台同步,本次结果可能未覆盖最新消息')
)
expect(api.getAiSearchProviderStatus).not.toHaveBeenCalled()
expect(api.runAiSearch).not.toHaveBeenCalled()
expect(onNotice).not.toHaveBeenCalledWith('知识库正在同步,请等待同步完成后再开始分析')
await waitFor(() => expect(api.runAiSearch).toHaveBeenCalledOnce())
expect(await screen.findByText('测试搜索答案')).toBeInTheDocument()
})
it('submits with Enter and keeps Shift+Enter available for a new line', async () => {
+9 -2
View File
@@ -71,8 +71,15 @@ describe('AISearchComposer', () => {
expect(props.onHistoryOpenChange).toHaveBeenCalledWith(false)
})
it('disables submission while Knowledge is syncing', () => {
it('keeps submission available while Knowledge is syncing and only warns about coverage', () => {
// 知识库同步是后台 / 可取消 / 可断点续传的,**不允许**因此禁止提问。
// 索引没追平时应由 coverage/freshness 契约如实标注覆盖范围。
renderComposer({ knowledgeSyncing: true })
expect(screen.getByRole('button', { name: /同步中,暂不可分析/ })).toBeDisabled()
const submit = screen.getByRole('button', { name: /开始分析/ })
expect(submit).toBeEnabled()
expect(screen.queryByRole('button', { name: /暂不可分析/ })).not.toBeInTheDocument()
expect(
screen.getByText('知识库后台同步中 · 仍可提问,答案会标注覆盖范围')
).toBeInTheDocument()
})
})
@@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useEvidenceCollection } from '../../src/renderer/src/components/search/hooks/useEvidenceCollection'
import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes'
import { decodeMessageRef, encodeMessageRef } from '../../src/shared/local-query-api'
import {
aiSearchContact,
makeCacheRecord,
@@ -16,6 +17,9 @@ const makeEvidence = (count: number, offset = 0): EvidenceItem[] =>
return {
evidenceId: item.id,
contact: aiSearchContact,
// 稳定引用是"跳转到这一条"的唯一可靠身份:只给会话 + 秒级时间戳,
// 同一秒多条消息时根本定位不到目标。
messageRef: encodeMessageRef(aiSearchContact.md5, item.messageId),
message: {
id: item.messageId,
from: item.senderId,
@@ -168,14 +172,22 @@ describe('useEvidenceCollection', () => {
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' })
})
it('passes the selected Evidence contact and timestamp to the jump callback', () => {
it('passes the whole Evidence item — including its stable messageRef — to the jump callback', () => {
const { result } = renderHook(() => useEvidenceHarness())
const collection = makeEvidence(1)
act(() => result.current.setEvidenceResult(collection, collection))
act(() => result.current.jumpToEvidence(0))
expect(onOpenEvidence).toHaveBeenCalledWith(aiSearchContact, collection[0].message.createTime)
// 传整条证据而不是 `(contact, createTime)`:后者丢掉了稳定身份,跳转只能靠
// "会话 + 秒级时间戳"猜。引用必须能被还原成真实的 conversationId / messageId。
expect(onOpenEvidence).toHaveBeenCalledTimes(1)
const passed = onOpenEvidence.mock.calls[0][0] as EvidenceItem
expect(passed).toBe(collection[0])
expect(decodeMessageRef(passed.messageRef)).toEqual({
conversationId: aiSearchContact.md5,
messageId: collection[0].message.id
})
})
it('clears the previous request Evidence and selection before the next result is applied', () => {
@@ -0,0 +1,461 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AISearchWorkspace } from '../../src/renderer/src/components/search/AISearchWorkspace'
import { aiSearchContact, aiSearchGroup, makeSearchResult } from './support/ai-search-fixtures'
import type { AskWechatQueryResult, AskWechatStats, QueryAgentProgressEvent } from '../../src/shared/query-agent'
type AnsweredResult = Extract<AskWechatQueryResult, { status: 'answered' }>
const api = {
getSettings: vi.fn(),
getAppLogPath: vi.fn(),
getKnowledgeStatus: vi.fn(),
onKnowledgeStatus: vi.fn(),
onAiSearchProgress: vi.fn(),
getAiSearchProviderStatus: vi.fn(),
authorizeAiSearchExternalProvider: vi.fn(),
runAiSearch: vi.fn(),
cancelAiSearch: vi.fn(),
startKnowledgeIndex: vi.fn(),
writeAppLog: vi.fn(),
revealAppLog: vi.fn(),
copyText: vi.fn(),
getAskWechatConfig: vi.fn(),
runAskWechatQuery: vi.fn(),
forgetAskWechatConversation: vi.fn(),
onAskWechatProgress: vi.fn(),
cancelKnowledgeIndex: vi.fn()
}
const indexLatestAt = new Date('2026-09-11T11:57:24+08:00').getTime()
/** 主进程推来的**真实**进度事件(不是定时器伪进度)。 */
let askWechatProgressListener:
| ((requestId: string, event: QueryAgentProgressEvent) => void)
| undefined
const readyKnowledgeStatus = {
accountId: 'fixture-account',
state: 'ready' as const,
indexedMessageCount: 20,
indexedChunkCount: 4,
sourceMessageCount: 20,
processedMessages: 20,
totalMessages: 20,
estimatedRemainingMs: null,
databaseBytes: 128,
walBytes: 64,
shmBytes: 32,
indexLatestAt,
sourceLatestAt: indexLatestAt
}
const diagnostics = {
entry: 'desktop' as const,
provider: 'Fixture Provider',
model: 'Fixture Model',
modelCallCount: 2,
toolCallCount: 1,
tools: ['query_messages'],
totalMs: 1200,
outcome: 'answered' as const
}
const makeProps = (): Record<string, unknown> => ({
contacts: [aiSearchContact, aiSearchGroup],
selectedContact: aiSearchContact,
dbReady: true,
aiModelConfig: {
configured: true,
providerName: 'Fixture Provider',
model: 'fixture-model',
modelName: 'Fixture Model',
status: 'connected' as const
},
onSelectContact: vi.fn(),
onOpenEvidence: vi.fn(),
onOpenAISettings: vi.fn(),
onNotice: vi.fn()
})
const submitQuery = async (query: string): Promise<void> => {
const user = userEvent.setup()
await user.type(screen.getByRole('textbox'), query)
await user.click(screen.getByRole('button', { name: /开始分析/ }))
}
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.clearAllMocks()
Object.defineProperty(window, 'api', { configurable: true, value: api })
api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } })
api.getAppLogPath.mockResolvedValue('')
api.getKnowledgeStatus.mockResolvedValue(readyKnowledgeStatus)
api.onKnowledgeStatus.mockImplementation(() => vi.fn())
api.onAiSearchProgress.mockImplementation(() => vi.fn())
api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false })
api.authorizeAiSearchExternalProvider.mockResolvedValue({ success: true })
api.cancelAiSearch.mockResolvedValue({ cancelled: true })
api.startKnowledgeIndex.mockResolvedValue(readyKnowledgeStatus)
api.writeAppLog.mockResolvedValue(undefined)
api.revealAppLog.mockResolvedValue(undefined)
api.copyText.mockResolvedValue({ success: true })
api.forgetAskWechatConversation.mockResolvedValue(undefined)
api.runAiSearch.mockResolvedValue(makeSearchResult())
api.getAskWechatConfig.mockResolvedValue({ queryAgentEnabled: true })
api.runAskWechatQuery.mockResolvedValue(answeredResult())
api.onAskWechatProgress.mockImplementation(
(listener: (requestId: string, event: QueryAgentProgressEvent) => void) => {
askWechatProgressListener = listener
return () => {
askWechatProgressListener = undefined
}
}
)
api.cancelKnowledgeIndex.mockResolvedValue({ cancellable: false, cancelled: false })
})
const askStats = (): AskWechatStats => ({
tools: ['query_messages'],
reads: { messageCount: 10, matchedCount: 0, overviewSourceCount: 0, evidenceCount: 0 },
scope: { kind: 'all' as const, label: '所有聊天记录' },
modelCallCount: 2,
toolCallCount: 1,
totalMs: 3343
})
const answeredResult = (patch: Partial<AnsweredResult> = {}): AnsweredResult => ({
engine: 'query-agent' as const,
status: 'answered' as const,
answer: '你们的第一次聊天是一条问候。',
evidence: [],
stats: askStats(),
diagnostics,
...patch
})
describe('AISearchWorkspace — Query Agent 主路径', () => {
it('Query Agent 开启时用它回答,不再调用 Legacy 检索', async () => {
render(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('我和 BOBO 第一次聊了什么')
expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy()
expect(api.runAskWechatQuery).toHaveBeenCalledWith(
expect.objectContaining({ text: '我和 BOBO 第一次聊了什么' })
)
expect(api.runAiSearch).not.toHaveBeenCalled()
})
it('Query Agent 查 0 条时仍然是它回答,不触发 Legacy 二次检索', async () => {
api.runAskWechatQuery.mockResolvedValue(
answeredResult({ answer: '当前可读取的完整范围里没有找到相关记录。' })
)
render(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('BOBO 给我发过文件吗')
expect(await screen.findByText('当前可读取的完整范围里没有找到相关记录。')).toBeTruthy()
expect(api.runAiSearch).not.toHaveBeenCalled()
})
it('Provider 不可用时给出明确文案,不静默回退成另一次检索', async () => {
api.runAskWechatQuery.mockResolvedValue({
engine: 'query-agent',
status: 'provider_unavailable',
message: '当前 AI 查询服务暂时不可用,请稍后再试。',
diagnostics: { ...diagnostics, outcome: 'provider_failure' }
})
render(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('BOBO 最近说过什么')
expect(await screen.findByText('当前 AI 查询服务暂时不可用,请稍后再试。')).toBeTruthy()
expect(api.runAiSearch).not.toHaveBeenCalled()
})
it('Runtime 不可恢复错误时接住主进程回退的 Legacy 结果', async () => {
api.runAskWechatQuery.mockResolvedValue({
engine: 'legacy',
status: 'legacy',
reason: 'runtime_error',
result: makeSearchResult({ answer: 'Legacy 兜底答案' })
})
render(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('BOBO 最近说过什么')
expect(await screen.findByText('Legacy 兜底答案')).toBeTruthy()
})
it('Query Agent 关闭时仍走 Legacy 检索(可回退)', async () => {
api.getAskWechatConfig.mockResolvedValue({ queryAgentEnabled: false })
render(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('我和测试会话最近聊了什么')
expect(await screen.findByText('测试搜索答案')).toBeTruthy()
expect(api.runAskWechatQuery).not.toHaveBeenCalled()
expect(api.runAiSearch).toHaveBeenCalled()
})
it('preload 契约缺失(测试 / 旧版本)时安全回退 Legacy', async () => {
Object.defineProperty(window, 'api', {
configurable: true,
value: { ...api, getAskWechatConfig: undefined, runAskWechatQuery: undefined }
})
render(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('我和测试会话最近聊了什么')
expect(await screen.findByText('测试搜索答案')).toBeTruthy()
expect(api.runAiSearch).toHaveBeenCalled()
})
})
describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据', () => {
it('Query Agent 开启时保留搜索范围、隐藏时间范围,并提示时间写在问题里', async () => {
render(<AISearchWorkspace {...(makeProps() as never)} />)
expect(await screen.findByText('搜索范围')).toBeTruthy()
// 开关是异步解析的:等提示出现,说明 Query Agent 已生效
expect(await screen.findByText(/时间直接写在问题里/)).toBeTruthy()
expect(screen.getAllByText('所有聊天记录').length).toBeGreaterThan(0)
expect(screen.getByText('群聊专属')).toBeTruthy()
expect(screen.getByText('单聊专属')).toBeTruthy()
// 时间范围控件在 Query Agent 主路径下移除
expect(screen.queryByText('时间范围')).toBeNull()
expect(screen.queryByText('不限时间')).toBeNull()
})
it('Query Agent 关闭时恢复 Legacy 的时间范围控件', async () => {
api.getAskWechatConfig.mockResolvedValue({ queryAgentEnabled: false })
render(<AISearchWorkspace {...(makeProps() as never)} />)
expect(await screen.findByText('时间范围')).toBeTruthy()
expect(screen.getByText('不限时间')).toBeTruthy()
expect(screen.queryByText(/时间直接写在问题里/)).toBeNull()
})
it('把界面选择的搜索范围传给 Query Agent', async () => {
render(<AISearchWorkspace {...(makeProps() as never)} />)
const user = userEvent.setup()
await user.click(await screen.findByText('群聊专属'))
await submitQuery('最近谁聊过健身')
expect(api.runAskWechatQuery).toHaveBeenCalledWith(
expect.objectContaining({
text: '最近谁聊过健身',
scope: expect.objectContaining({ scope: { kind: 'groups' } })
})
)
})
it('展示真实统计与真实证据,不再显示「知识库已收录 0」', async () => {
api.runAskWechatQuery.mockResolvedValue(
answeredResult({
answer: '张三最近提过健身。',
evidence: [
{
messageRef: 'ref-1',
conversationName: 'TraceMemo 交流群',
conversationType: 'group',
sender: '张三',
timestamp: 1_787_650_302_000,
messageType: 'text',
text: '最近重新开始健身了',
source: 'search_messages'
}
],
stats: {
tools: ['search_messages'],
reads: { messageCount: 0, matchedCount: 12, overviewSourceCount: 0, evidenceCount: 1 },
scope: { kind: 'groups', label: '群聊专属' },
modelCallCount: 2,
toolCallCount: 1,
totalMs: 4034
}
})
)
render(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('最近谁聊过健身')
expect(await screen.findByText('张三最近提过健身。')).toBeTruthy()
// 群消息证据必须能归属到具体群 + 成员
expect(screen.getAllByText(/TraceMemo 交流群/).length).toBeGreaterThan(0)
// 证据卡片把"编号 · 发送者"渲染在同一行内,用正则匹配文本内容
expect(screen.getAllByText(/张三/).length).toBeGreaterThan(0)
expect(screen.getByText('最近重新开始健身了')).toBeTruthy()
// 真实统计(不使用 Legacy 的"知识库已收录")
expect(screen.getByText('群聊专属内查询')).toBeTruthy()
expect(screen.getByText('使用 1 条证据')).toBeTruthy()
expect(screen.queryByText(/知识库已收录/)).toBeNull()
})
})
describe('AISearchWorkspace — 知识库新鲜度状态', () => {
it('索引落后时显示「可用 · 待追新」并说明覆盖范围,而不是「已同步」', async () => {
api.getKnowledgeStatus.mockResolvedValue({
...readyKnowledgeStatus,
indexLatestAt: new Date('2026-08-26T11:37:24+08:00').getTime(),
sourceLatestAt: new Date('2026-09-11T11:57:24+08:00').getTime()
})
render(<AISearchWorkspace {...(makeProps() as never)} />)
// ready ≠ fresh:落后时必须说「可用 · 待追新」,并在描述里给出真实覆盖边界,
// 绝不能用笼统的「已同步」把两件事混为一谈。
expect(await screen.findByText('可用 · 待追新')).toBeTruthy()
expect(screen.queryByText('已同步')).toBeNull()
expect(screen.queryByText('可用 · 已追至最新')).toBeNull()
expect(screen.getByText(/跨会话搜索目前只覆盖到 8\/26/)).toBeTruthy()
expect(screen.getByText('最新索引')).toBeTruthy()
expect(screen.getByText('8/26')).toBeTruthy()
})
it('索引已追平源数据最新时才显示「可用 · 已追至最新」', async () => {
render(<AISearchWorkspace {...(makeProps() as never)} />)
expect(await screen.findByText('可用 · 已追至最新')).toBeTruthy()
expect(screen.queryByText('已同步')).toBeNull()
expect(screen.queryByText(/跨会话搜索目前只覆盖到/)).toBeNull()
})
})
const emitProgress = async (
requestId: string,
event: Partial<QueryAgentProgressEvent> & { stage: QueryAgentProgressEvent['stage'] }
): Promise<void> => {
await act(async () => {
askWechatProgressListener?.(requestId, {
elapsedMs: 0,
at: Date.now(),
modelCallCount: 1,
toolCallCount: 0,
...event
} as QueryAgentProgressEvent)
})
}
describe('AISearchWorkspace — 查询进度与耗时拆解', () => {
it('advances the visible stage from real Runtime progress instead of a static 4-step list', async () => {
let release: ((value: unknown) => void) | undefined
api.runAskWechatQuery.mockImplementation(
() =>
new Promise((resolve) => {
release = resolve
})
)
render(<AISearchWorkspace {...(makeProps() as never)} />)
await userEvent.type(screen.getByRole('textbox'), '最近谁聊过健身')
await userEvent.click(screen.getByRole('button', { name: /开始分析/ }))
const requestId = api.runAskWechatQuery.mock.calls[0][0].requestId as string
await emitProgress(requestId, { stage: 'understanding' })
expect(screen.getByRole('heading', { name: '正在理解你的问题' })).toBeTruthy()
// 跨会话范围(所有聊天记录)必须提前说明"范围较大",否则用户会以为卡住了。
await emitProgress(requestId, { stage: 'searching', toolCallCount: 1 })
expect(screen.getByRole('heading', { name: '正在搜索较大范围的聊天记录…' })).toBeTruthy()
await emitProgress(requestId, { stage: 'organizing_evidence', toolCallCount: 1 })
expect(screen.getByRole('heading', { name: '正在整理找到的聊天记录' })).toBeTruthy()
await emitProgress(requestId, { stage: 'generating_answer', toolCallCount: 1 })
expect(screen.getByRole('heading', { name: '正在生成回答' })).toBeTruthy()
// 进度里绝不能出现内部工具名 / SQL / 内部 id。
expect(screen.queryByText(/search_messages|query_messages|SELECT|FTS/)).toBeNull()
expect(screen.queryByText(/local:/)).toBeNull()
release?.(answeredResult())
expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy()
})
it('uses the narrower wording for a single-conversation scope', async () => {
let release: ((value: unknown) => void) | undefined
api.runAskWechatQuery.mockImplementation(
() =>
new Promise((resolve) => {
release = resolve
})
)
render(<AISearchWorkspace {...(makeProps() as never)} />)
await userEvent.click(screen.getByRole('button', { name: /当前会话/ }))
await userEvent.type(screen.getByRole('textbox'), '最近聊了什么')
await userEvent.click(screen.getByRole('button', { name: /开始分析/ }))
const requestId = api.runAskWechatQuery.mock.calls[0][0].requestId as string
await emitProgress(requestId, { stage: 'searching', toolCallCount: 1 })
expect(screen.getByRole('heading', { name: '正在搜索聊天记录…' })).toBeTruthy()
expect(screen.queryByRole('heading', { name: '正在搜索较大范围的聊天记录…' })).toBeNull()
release?.(answeredResult())
expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy()
})
it('ignores progress that belongs to a different request', async () => {
let release: ((value: unknown) => void) | undefined
api.runAskWechatQuery.mockImplementation(
() =>
new Promise((resolve) => {
release = resolve
})
)
render(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('最近谁聊过健身')
await emitProgress('stale-request-id', { stage: 'generating_answer' })
// 串台的进度会让用户看到一个根本不属于本次查询的阶段。
expect(screen.queryByRole('heading', { name: '正在生成回答' })).toBeNull()
expect(screen.getByRole('heading', { name: '正在准备查询' })).toBeTruthy()
release?.(answeredResult())
expect(await screen.findByText('你们的第一次聊天是一条问候。')).toBeTruthy()
})
it('breaks the total duration down into AI and local query instead of a bare 48.0s', async () => {
api.runAskWechatQuery.mockResolvedValue(
answeredResult({
stats: {
...askStats(),
totalMs: 19_548,
timings: {
totalMs: 19_548,
modelMs: 3_892,
localQueryMs: 15_648,
modelDurationsMs: [1059, 2833],
toolDurationsMs: [15_648]
}
}
})
)
render(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('最近谁聊过健身')
await screen.findByText('你们的第一次聊天是一条问候。')
const breakdown = screen.getByLabelText('本次查询耗时拆解')
expect(breakdown.textContent).toContain('总耗时 19.5s')
expect(breakdown.textContent).toContain('AI 3.9s')
expect(breakdown.textContent).toContain('本地查询 15.6s')
// 普通 UI 只出现用户能理解的名字,不出现 firstModelMs / toolTotalMs 这类工程字段。
expect(breakdown.textContent).not.toMatch(/firstModelMs|toolTotalMs|finalModelMs/)
})
it('does not render a timing breakdown when the runtime reported none', async () => {
render(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('最近谁聊过健身')
await screen.findByText('你们的第一次聊天是一条问候。')
expect(screen.queryByLabelText('本次查询耗时拆解')).toBeNull()
expect(screen.queryByText(/总耗时/)).toBeNull()
})
it('cancelling a query never touches the Knowledge sync abort scope', async () => {
api.runAskWechatQuery.mockImplementation(() => new Promise(() => undefined))
render(<AISearchWorkspace {...(makeProps() as never)} />)
await submitQuery('最近谁聊过健身')
await userEvent.click(await screen.findByRole('button', { name: /取消分析/ }))
// 查询取消与索引取消必须是两套独立的 Abort scope,共用一个会互相误杀。
expect(api.cancelKnowledgeIndex).not.toHaveBeenCalled()
})
})
@@ -6,6 +6,7 @@ import {
SEARCH_CACHE_KEY,
SEARCH_HISTORY_KEY
} from '../../src/renderer/src/components/search/searchUtils'
import { decodeMessageRef } from '../../src/shared/local-query-api'
import {
aiSearchContact,
aiSearchGroup,
@@ -321,7 +322,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(api.runAiSearch).toHaveBeenCalledOnce()
})
it('passes the selected Evidence contact and timestamp to the conversation jump callback', async () => {
it('passes the whole Evidence item with a decodable stable reference to the conversation jump callback', async () => {
const onOpenEvidence = vi.fn()
const item = makePipelineEvidence(1, aiSearchContact)
api.runAiSearch.mockResolvedValue(makeSearchResult({ evidence: [item] }))
@@ -330,7 +331,19 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
await screen.findByText('E1 · 发送者 1')
await userEvent.click(screen.getByRole('button', { name: '跳转到原聊天 ↗' }))
expect(onOpenEvidence).toHaveBeenCalledWith(aiSearchContact, Math.floor(item.timestamp / 1000))
// 跳转必须拿到**稳定身份**(messageRef),而不是只靠"会话 + 秒级时间戳"猜位置:
// 秒级时间无法定位同秒多条消息,也会因为 contact.md5 是合成 key 而跳错会话。
expect(onOpenEvidence).toHaveBeenCalledTimes(1)
const passed = onOpenEvidence.mock.calls[0][0] as {
messageRef?: string
contact: { md5: string }
}
expect(passed.contact.md5).toBe(aiSearchContact.md5)
expect(decodeMessageRef(passed.messageRef)).toEqual({
conversationId: aiSearchContact.md5,
messageId: item.messageId
})
})
it('clears the previous request Evidence before a refreshed request completes', async () => {
@@ -579,15 +592,52 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(api.runAiSearch).toHaveBeenCalledTimes(2)
})
/**
* Knowledge 状态语义矩阵。
*
* 必须把 FRESHNESS(追到源数据最新)与 COMPLETENESS(这一遍 pass 的进度)分开表达,
* **不得**在落后时出现「已同步」。
*/
const STALE_INDEX_AT = new Date('2026-08-26T11:37:24+08:00').getTime()
const SOURCE_LATEST_AT = new Date('2026-09-11T11:57:24+08:00').getTime()
const makePass = (phase: 'full' | 'catchup' | 'backfill' | 'idle') => ({
phase,
cancellable: phase !== 'idle',
startedAt: SOURCE_LATEST_AT,
scannedMessages: 120,
indexedMessages: 20,
processedConversations: 3,
totalConversations: 10,
skippedConversations: 1,
catchupConversations: 4,
backfillConversations: 6,
backfillCompletedConversations: 2,
mainLoopLagMs: 4
})
it.each([
['unavailable', '未建立'],
['building', '建立中'],
['syncing', '增量同步'],
['ready', '已同步'],
['error', '异常']
] as const)('renders Knowledge state %s as %s', async (state, label) => {
['unavailable', '未建立', {}],
['building', '可用 · 正在补齐历史', {}],
['syncing', '可用 · 正在追新', { pass: makePass('catchup') }],
['syncing', '可用 · 正在补齐历史', { pass: makePass('full') }],
// backfill(补历史缺口)与 full(首次建库)对用户是同一件事:都不是"追最新"。
['syncing', '可用 · 正在补齐历史', { pass: makePass('backfill') }],
[
'ready',
'可用 · 待追新',
{ indexLatestAt: STALE_INDEX_AT, sourceLatestAt: SOURCE_LATEST_AT }
],
[
'ready',
'可用 · 已追至最新',
{ indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT }
],
['cancelled', '可用 · 同步已取消', {}],
['error', '可用 · 更新失败', {}]
] as const)('renders Knowledge state %s as %s', async (state, label, overrides) => {
api.getKnowledgeStatus.mockResolvedValue({
...readyKnowledgeStatus,
...overrides,
state,
lastError: state === 'error' ? 'Worker 异常' : undefined
})
@@ -595,11 +645,51 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(await screen.findAllByText(new RegExp(`Knowledge ${label}`))).not.toHaveLength(0)
if (state === 'error') expect(screen.getByText('Worker 异常')).toBeInTheDocument()
// 无论哪种状态,「已同步」这种把两件事混为一谈的笼统说法都不允许出现。
expect(screen.queryByText('已同步')).toBeNull()
})
it('never claims the index is up to date when freshness cannot be confirmed', async () => {
// 两个边界时间缺失(老库 / 运行态未上报)时无法判断是否追平。
// 安全侧是「待追新」——**不能**把"无法确认"说成「已追至最新」。
api.getKnowledgeStatus.mockResolvedValue({
...readyKnowledgeStatus,
indexLatestAt: null,
sourceLatestAt: null
})
renderWorkspace()
expect(await screen.findByText('Knowledge 可用 · 待追新')).toBeInTheDocument()
expect(screen.queryByText(/可用 · 已追至最新/)).toBeNull()
expect(screen.queryByText('已同步')).toBeNull()
})
it('does not claim usability when the derived index is unavailable', async () => {
// 没有分片却残留了历史计数时,不能说「可用 · …」——那是两句真话拼成的假话。
api.getKnowledgeStatus.mockResolvedValue({
...readyKnowledgeStatus,
state: 'unavailable',
indexedMessageCount: 0,
indexedChunkCount: 0
})
renderWorkspace()
expect(await screen.findByText('Knowledge 未建立')).toBeInTheDocument()
expect(screen.queryByText(/可用 · /)).toBeNull()
})
it('starts Knowledge indexing and reflects the returned status', async () => {
api.getKnowledgeStatus.mockResolvedValue({ ...readyKnowledgeStatus, state: 'unavailable' })
api.startKnowledgeIndex.mockResolvedValue({ ...readyKnowledgeStatus, state: 'syncing' })
api.getKnowledgeStatus.mockResolvedValue({
...readyKnowledgeStatus,
state: 'unavailable',
indexedMessageCount: 0,
indexedChunkCount: 0
})
api.startKnowledgeIndex.mockResolvedValue({
...readyKnowledgeStatus,
state: 'syncing',
pass: makePass('full')
})
const onNotice = vi.fn()
renderWorkspace({ onNotice })
await screen.findByText('Knowledge 未建立')
@@ -607,7 +697,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(api.startKnowledgeIndex).toHaveBeenCalledOnce()
expect(onNotice).toHaveBeenCalledWith(expect.stringContaining('开始同步'))
expect(await screen.findByText('Knowledge 增量同步')).toBeInTheDocument()
expect(await screen.findByText('Knowledge 可用 · 正在补齐历史')).toBeInTheDocument()
})
it('unsubscribes Knowledge and Progress listeners on unmount', () => {
@@ -617,3 +707,213 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(progressUnsubscribe).toHaveBeenCalledOnce()
})
})
/**
* Knowledge 卡片的布局契约(侧栏宽度不变,靠栅格而不是靠缩字号解决拥挤)。
*
* jsdom 不做布局计算,拿不到真实宽度,所以这里锁的是**布局所依赖的 DOM 契约**:
* - 五段结构(HEADER / CURRENT PASS / DATABASE STATUS / CURRENT / ACTION)各自的可见性;
* - 状态文案保持**单一文本节点**,换行只可能发生在「 · 」之后;
* - 数值行是「label + value」成对结构,nowrap 挂在 value 上;
* - 「取消同步」只在这一遍真的在跑时出现,且带不会被压扁的类。
*
* 刻意不写整份 CSS 快照:那种断言在调样式时全是噪声,也证明不了布局真的对。
*/
describe('Knowledge card layout contract', () => {
const STALE_INDEX_AT = new Date('2026-08-26T11:37:24+08:00').getTime()
const SOURCE_LATEST_AT = new Date('2026-09-11T11:57:24+08:00').getTime()
const makePass = (phase: 'full' | 'catchup' | 'backfill' | 'idle') => ({
phase,
cancellable: phase !== 'idle',
startedAt: SOURCE_LATEST_AT,
scannedMessages: 2_400,
indexedMessages: 1_200,
processedConversations: 400,
totalConversations: 4_800,
skippedConversations: 200,
catchupConversations: 4,
backfillConversations: 6,
backfillCompletedConversations: 2,
mainLoopLagMs: 0
})
const status = (state: string, extra: Record<string, unknown> = {}): unknown => ({
...readyKnowledgeStatus,
state,
...extra
})
const findStateLabel = async (): Promise<HTMLElement> =>
await screen.findByTestId('knowledge-state-label')
const queryPassSection = (): HTMLElement | null => screen.queryByTestId('knowledge-pass-progress')
it('keeps the state label a single string that only wraps at 「 · 」', async () => {
api.getKnowledgeStatus.mockResolvedValue(
status('ready', { indexLatestAt: STALE_INDEX_AT, sourceLatestAt: SOURCE_LATEST_AT })
)
renderWorkspace()
const label = await findStateLabel()
// 卡片上的字符串必须与 label 函数逐字一致(侧栏外的详情行也用同一个函数)。
// 折行位置由 CSS 的 word-break: keep-all 决定,DOM 里不做切分 —— 一旦被拆成
// 多个元素,页面上就会出现「可用 · 正在」/「补齐历史」这种碎句。
expect(label.textContent).toBe('可用 · 待追新')
expect(label.childNodes).toHaveLength(1)
expect(label).toHaveClass('ai-search-knowledge-state')
})
it('keeps the header as one grid row with kicker, state and status dot', async () => {
api.getKnowledgeStatus.mockResolvedValue(
status('ready', { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT })
)
renderWorkspace()
const label = await findStateLabel()
const heading = label.closest('.ai-search-knowledge-heading')
expect(heading).not.toBeNull()
expect(heading!.querySelector('.ai-search-knowledge-kicker')?.textContent).toBe('KNOWLEDGE BASE')
expect(heading!.querySelector('.ai-search-knowledge-dot')).not.toBeNull()
})
it('shows the CURRENT PASS section only while a pass is really running', async () => {
api.getKnowledgeStatus.mockResolvedValue(
status('unavailable', { indexedMessageCount: 0, indexedChunkCount: 0 })
)
const view = renderWorkspace()
await screen.findByText('Knowledge 未建立')
expect(queryPassSection()).toBeNull()
view.unmount()
api.getKnowledgeStatus.mockResolvedValue(status('syncing', { pass: makePass('catchup') }))
renderWorkspace()
expect(await screen.findByTestId('knowledge-pass-progress')).toBeInTheDocument()
})
it('renders the real two counters instead of a denominator-less fake progress', async () => {
api.getKnowledgeStatus.mockResolvedValue(status('syncing', { pass: makePass('catchup') }))
renderWorkspace()
const progress = await screen.findByTestId('knowledge-pass-progress')
expect(progress.textContent).toBe(
`本轮新增索引 ${(1_200).toLocaleString()} 条 · 本轮已扫描 ${(2_400).toLocaleString()} 条`
)
// 同样是一个文本节点 + CSS 折行:不能被拆成多个元素(否则「 · 」会甩到行首)。
expect(progress.childNodes).toHaveLength(1)
})
it.each([
['catchup', '正在追最新消息:4 个会话有新内容'],
['backfill', '后台补齐历史:2 / 6 个久未更新的会话'],
['full', '正在建立索引:4,800 个会话']
] as const)('describes the %s pass inside CURRENT PASS', async (phase, expected) => {
api.getKnowledgeStatus.mockResolvedValue(status('syncing', { pass: makePass(phase) }))
renderWorkspace()
expect(await screen.findByTestId('knowledge-pass-scope')).toHaveTextContent(expected)
})
it('keeps every database status row a label/value pair with a non-wrapping value', async () => {
api.getKnowledgeStatus.mockResolvedValue(
status('ready', { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT })
)
renderWorkspace()
await findStateLabel()
for (const text of ['已索引消息', '知识片段', '最新索引', '磁盘占用']) {
const label = screen.getByText(text)
expect(label).toHaveClass('ai-search-knowledge-label')
const row = label.closest('.ai-search-knowledge-row')
expect(row).not.toBeNull()
// value 是 nowrap 的载体:数字一旦折行,这一行就不可读了。
expect(row!.querySelector('.ai-search-knowledge-value')).not.toBeNull()
}
})
it('gives the current conversation its own row and truncates the long name', async () => {
api.getKnowledgeStatus.mockResolvedValue(
status('syncing', { pass: makePass('catchup'), currentConversationId: aiSearchGroup.md5 })
)
renderWorkspace()
await findStateLabel()
const row = screen.getByText('当前会话').closest('.ai-search-knowledge-row')
expect(row).not.toBeNull()
// 群名 / 备注可以很长:必须省略而不是撑破侧栏。
expect(row!.querySelector('.ai-search-knowledge-value')).toHaveClass(
'ai-search-knowledge-value--truncate'
)
})
it('drops the current-conversation row once nothing is running', async () => {
api.getKnowledgeStatus.mockResolvedValue(
status('ready', {
indexLatestAt: SOURCE_LATEST_AT,
sourceLatestAt: SOURCE_LATEST_AT,
currentConversationId: aiSearchGroup.md5
})
)
renderWorkspace()
await findStateLabel()
expect(screen.queryByText('当前会话')).toBeNull()
})
it('keeps the main-loop lag in its own labelled row when it is measured', async () => {
api.getKnowledgeStatus.mockResolvedValue(
status('syncing', { pass: { ...makePass('catchup'), mainLoopLagMs: 42 } })
)
renderWorkspace()
await findStateLabel()
const row = screen.getByText('界面卡顿峰值').closest('.ai-search-knowledge-row')
expect(row).not.toBeNull()
expect(row!.querySelector('.ai-search-knowledge-value')?.textContent).toBe('42ms')
})
it('keeps the cancel action on one line and only while running', async () => {
api.getKnowledgeStatus.mockResolvedValue(
status('ready', { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT })
)
const view = renderWorkspace()
await findStateLabel()
expect(screen.queryByTestId('knowledge-cancel-sync')).toBeNull()
view.unmount()
api.getKnowledgeStatus.mockResolvedValue(status('syncing', { pass: makePass('catchup') }))
renderWorkspace()
const cancel = await screen.findByTestId('knowledge-cancel-sync')
expect(cancel).toHaveTextContent('取消同步')
// 带 min-width 的类:按钮不会被主按钮挤到换行成「取消同」/「步」。
expect(cancel).toHaveClass('ai-search-knowledge-cancel')
expect(cancel.closest('.ai-search-knowledge-actions')).not.toBeNull()
})
it('keeps the cancelled state honest and free of a stale progress block', async () => {
api.getKnowledgeStatus.mockResolvedValue(status('cancelled', { pass: makePass('idle') }))
renderWorkspace()
expect(await screen.findByText('Knowledge 可用 · 同步已取消')).toBeInTheDocument()
expect(
screen.getByText('上一遍同步被取消,已建立的索引仍然可用;下次同步会从断点继续。')
).toBeInTheDocument()
// 取消之后不能残留一个假的"正在同步"。
expect(queryPassSection()).toBeNull()
expect(screen.queryByTestId('knowledge-cancel-sync')).toBeNull()
})
it('keeps the FRESH state free of the stale warning and the progress block', async () => {
api.getKnowledgeStatus.mockResolvedValue(
status('ready', { indexLatestAt: SOURCE_LATEST_AT, sourceLatestAt: SOURCE_LATEST_AT })
)
renderWorkspace()
expect(await screen.findByText('Knowledge 可用 · 已追至最新')).toBeInTheDocument()
expect(
screen.getByText('后台增量同步不会影响原始微信聊天记录,也不会阻塞提问。')
).toBeInTheDocument()
expect(screen.queryByText(/索引还没追上最新聊天/)).toBeNull()
expect(queryPassSection()).toBeNull()
})
})
+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('直接回答')
})
})