mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 重构问问微信并完善知识库增量检索
统一问问微信与 Agent Hub 的查询链路 完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论 基于会话实现真正的增量追新与历史补齐 支持后台同步、取消恢复、重启续传以及同步期间继续查询 优化知识库跨会话检索、同步状态、进度展示和侧栏布局
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([
|
||||
'精确读取',
|
||||
'关键词检索'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
+139
-42
@@ -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('直接回答')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user