fix: 完善查询agent时间与检索契约

增加调试与诊断
This commit is contained in:
Wxw-Gu
2026-09-10 17:45:12 +08:00
parent 376ea79ff6
commit fe8619d56d
18 changed files with 1210 additions and 84 deletions
+143
View File
@@ -0,0 +1,143 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { AIProviderConfig } from '../../src/shared/ai-provider'
const root = mkdtempSync(join(tmpdir(), 'wxe-ai-provider-diagnostics-'))
vi.mock('electron', () => ({
app: { getPath: () => root },
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (value: string) => Buffer.from(value),
decryptString: (value: Buffer) => value.toString('utf8')
}
}))
import { AIProviderService } from '../../src/main/services/ai-provider-service'
function provider(baseUrl: string, timeoutMs = 120_000): AIProviderConfig {
return {
id: 'fixture-provider',
name: 'Fixture Provider',
type: 'openai-compatible' as const,
baseUrl,
auth: { type: 'none' as const },
models: [
{ id: 'fixture-model', name: 'Fixture Model', capabilities: { chat: true, vision: false, ocr: false, longContext: false } }
],
defaultModel: 'fixture-model',
advanced: { timeoutMs, extraHeaders: {} }
}
}
const TOOLS = [{ type: 'function' as const, function: { name: 'query_messages', description: 'read', parameters: { type: 'object' } } }]
describe('AI Provider 请求级诊断', () => {
afterAll(() => rmSync(root, { recursive: true, force: true }))
it('200 JSON:成功并带请求耗时,无错误诊断字段', async () => {
const service = new AIProviderService()
service.save(provider('https://diag.example.test/v1'))
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ choices: [{ message: { content: 'ok', tool_calls: [] } }], usage: {} }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
))
try {
const result = await service.chatWithTools([{ role: 'user', content: 'q' }], TOOLS)
expect(result.success).toBe(true)
expect(typeof result.elapsedMs).toBe('number')
expect(result).not.toHaveProperty('errorStatus')
expect(result).not.toHaveProperty('htmlInsteadOfJson')
} finally {
vi.unstubAllGlobals()
}
})
it('502 text/html:保留清晰错误,并记录 status / content-type / elapsed,且不泄漏 HTML 正文', async () => {
const service = new AIProviderService()
service.save(provider('https://diag.example.test/v1'))
const html = '<!DOCTYPE html><html><head><title>502 Bad Gateway</title></head><body>relay error</body></html>'
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(html, { status: 502, statusText: 'Bad Gateway', headers: { 'content-type': 'text/html' } })
))
try {
const result = await service.chatWithTools([{ role: 'user', content: 'q' }], TOOLS)
expect(result.success).toBe(false)
expect(result.errorStatus).toBe(502)
expect(result.errorContentType).toBe('text/html')
expect(result.htmlInsteadOfJson).toBe(true)
expect(typeof result.elapsedMs).toBe('number')
expect(result.error).toContain('网页而不是 JSON')
expect(result.error).toContain('502')
// HTML 正文不得出现在错误信息里
expect(result.error).not.toContain('relay error')
expect(result.error).not.toContain('<html')
} finally {
vi.unstubAllGlobals()
}
})
it('502 application/json:使用上游 error.message,并保留状态码', async () => {
const service = new AIProviderService()
service.save(provider('https://diag.example.test/v1'))
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: { message: 'upstream overloaded', code: 'overloaded', type: 'server_error' } }), {
status: 502,
headers: { 'content-type': 'application/json' }
})
))
try {
const result = await service.chatWithTools([{ role: 'user', content: 'q' }], TOOLS)
expect(result.success).toBe(false)
expect(result.error).toBe('upstream overloaded')
expect(result.errorStatus).toBe(502)
expect(result.errorCode).toBe('overloaded')
expect(result.errorType).toBe('server_error')
expect(result.htmlInsteadOfJson).toBe(false)
} finally {
vi.unstubAllGlobals()
}
})
it('200 但 body 不是 JSON:明确报格式异常并带状态码', async () => {
const service = new AIProviderService()
service.save(provider('https://diag.example.test/v1'))
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
new Response('plain text body', { status: 200, headers: { 'content-type': 'text/plain' } })
))
try {
const result = await service.chatWithTools([{ role: 'user', content: 'q' }], TOOLS)
expect(result.success).toBe(false)
expect(result.error).toContain('格式异常')
expect(result.htmlInsteadOfJson).toBe(false)
} finally {
vi.unstubAllGlobals()
}
})
it('超时:标记 timedOut 且错误为 AI 请求超时', async () => {
const service = new AIProviderService()
service.save(provider('https://diag.example.test/v1', 1))
vi.stubGlobal('fetch', vi.fn((_url: string, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true })
})))
try {
const result = await service.chatWithTools([{ role: 'user', content: 'q' }], TOOLS)
expect(result.success).toBe(false)
expect(result.timedOut).toBe(true)
expect(result.error).toBe('AI 请求超时')
} finally {
vi.unstubAllGlobals()
}
}, 5_000)
it('只输出 host 作为 endpoint 诊断,不泄漏路径与 query', () => {
const service = new AIProviderService()
service.save(provider('https://relay.example.test/v1?token=secret-value'))
expect(service.getRuntimeEndpointHost()).toBe('relay.example.test')
})
})
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { parsePocQuestion } from '../../src/main/query-agent-poc-cli'
describe('Query Agent POC CLI 参数解析', () => {
it('移除开头的分隔符 --', () => {
expect(parsePocQuestion(['--', '我和BOBO第一次聊了什么'])).toBe('我和BOBO第一次聊了什么')
})
it('没有分隔符时保持原样', () => {
expect(parsePocQuestion(['我和BOBO第一次聊了什么'])).toBe('我和BOBO第一次聊了什么')
})
it('不删除问题正文中间的合法 --', () => {
expect(parsePocQuestion(['测试', '--', '内容'])).toBe('测试 -- 内容')
})
it('开头分隔符只移除一个,后续 -- 仍是正文', () => {
expect(parsePocQuestion(['--', '--', '内容'])).toBe('-- 内容')
expect(parsePocQuestion(['--', 'a', '--', 'b'])).toBe('a -- b')
})
it('多段参数按空格拼接,并去掉首尾空白', () => {
expect(parsePocQuestion([' ', 'BOBO', '上个月', '有没有给我发过文件 '])).toBe('BOBO 上个月 有没有给我发过文件')
})
it('只有分隔符或空参数时返回空字符串', () => {
expect(parsePocQuestion(['--'])).toBe('')
expect(parsePocQuestion([])).toBe('')
})
it('不修改传入的数组', () => {
const argv = ['--', '问题']
parsePocQuestion(argv)
expect(argv).toEqual(['--', '问题'])
})
})
+120
View File
@@ -0,0 +1,120 @@
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'
function result(patch: Partial<QueryAgentPocResult> = {}): QueryAgentPocResult {
return {
question: 'q',
provider: 'Fixture Provider',
model: 'fixture-model',
modelCallCount: 0,
toolCallCount: 0,
toolTotalMs: 0,
totalMs: 0,
traces: [],
modelDurationsMs: [],
modelDiagnostics: [],
...patch
}
}
describe('formatTiming', () => {
it('处理 0 次模型调用', () => {
const text = formatTiming(result({ error: '当前 AI Provider 尚未配置', totalMs: 3 }))
expect(text).toContain('(未发生模型调用)')
expect(text).toContain('Total')
expect(text).toContain('error: 当前 AI Provider 尚未配置')
})
it('处理 1 次模型调用 + 0 次工具', () => {
const text = formatTiming(result({ modelCallCount: 1, modelDurationsMs: [1234], totalMs: 1234 }))
expect(text).toContain('Model #1')
expect(text).toContain('1234 ms')
expect(text).not.toContain('TM Tools')
expect(text).toContain('Model total')
expect(text).toContain('100.0%')
})
it('处理 2 次模型调用 + 1 次工具,并还原交错顺序', () => {
const text = formatTiming(
result({ modelCallCount: 2, toolCallCount: 1, modelDurationsMs: [18784, 19690], toolTotalMs: 551, totalMs: 39025 })
)
const model1 = text.indexOf('Model #1')
const tools = text.indexOf('TM Tools')
const model2 = text.indexOf('Model #2')
expect(model1).toBeGreaterThanOrEqual(0)
expect(tools).toBeGreaterThan(model1)
expect(model2).toBeGreaterThan(tools)
expect(text).toContain('38474 ms (98.6%)')
expect(text).toContain('551 ms (1.4%)')
})
it('处理首次模型调用失败', () => {
const text = formatTiming(
result({ modelCallCount: 1, modelDurationsMs: [99419], totalMs: 99419, error: '模型服务返回了网页而不是 JSON(HTTP 502 Bad Gateway)' })
)
expect(text).toContain('Model #1')
expect(text).toContain('99419 ms')
expect(text).toContain('502')
})
it('处理末尾模型调用失败(有首次耗时、无 finalModelMs)', () => {
const text = formatTiming(
result({ modelCallCount: 2, toolCallCount: 1, modelDurationsMs: [800, 1200], toolTotalMs: 300, totalMs: 2300, error: 'AI 请求超时' })
)
expect(text).toContain('Model #2')
expect(text).toContain('error: AI 请求超时')
})
it('3 次以上模型调用标注为聚合展示', () => {
const text = formatTiming(result({ modelCallCount: 3, toolCallCount: 2, modelDurationsMs: [10, 20, 30], toolTotalMs: 5, totalMs: 65 }))
expect(text).toContain('Model #3')
expect(text).toContain('工具耗时按总计展示')
})
it('totalMs 为 0 时不产生 NaN 百分比', () => {
const text = formatTiming(result({ modelCallCount: 1, modelDurationsMs: [5], totalMs: 0 }))
expect(text).not.toContain('NaN')
expect(text).toContain('n/a')
})
})
describe('formatProviderDiagnostics', () => {
it('输出 provider / model / host 与每次尝试的请求级诊断', () => {
const text = formatProviderDiagnostics(
result({
provider: 'OpenAI',
model: 'gpt-5.6-sol',
modelCallCount: 1,
modelDiagnostics: [{ index: 1, elapsedMs: 99419, status: 502, contentType: 'text/html', htmlInsteadOfJson: true, error: '模型服务返回了网页而不是 JSON(HTTP 502 Bad Gateway)' }]
}),
'relay.example.test'
)
expect(text).toContain('provider OpenAI')
expect(text).toContain('model gpt-5.6-sol')
expect(text).toContain('host relay.example.test')
expect(text).toContain('status=502')
expect(text).toContain('contentType=text/html')
expect(text).toContain('htmlInsteadOfJson=true')
expect(text).toContain('upstreamGateway=true')
expect(text).toContain('elapsedMs=99419')
})
it('标记超时', () => {
const text = formatProviderDiagnostics(
result({ modelCallCount: 1, modelDiagnostics: [{ index: 1, elapsedMs: 240000, timedOut: true, error: 'AI 请求超时' }] })
)
expect(text).toContain('timedOut=true')
expect(text).toContain('(unknown)')
})
it('不泄漏凭据:输出中不出现 token / Authorization', () => {
const text = formatProviderDiagnostics(
result({ modelDiagnostics: [{ index: 1, elapsedMs: 1, status: 200, contentType: 'application/json' }] }),
'relay.example.test'
)
expect(text).not.toMatch(/authorization/i)
expect(text).not.toMatch(/bearer/i)
expect(text).not.toMatch(/sk-/)
})
})
+246 -7
View File
@@ -32,7 +32,7 @@ describe('QueryAgentPocService', () => {
evidence: [{ messageRef: 'opaque', timestamp: 1, sender: 'BOBO', sourceKind: 'image', text: 'caption' }]
}))
const configuredProvider = provider([
{ success: true, toolCalls: [{ id: 'call-1', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: 'topic' }) }] },
{ 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('查找相关记录')
@@ -88,18 +88,21 @@ describe('QueryAgentPocService', () => {
expect(configuredProvider.chatWithTools).not.toHaveBeenCalled()
})
it('accepts schema-valid variants and rejects invalid arguments before executing a tool', async () => {
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([
{ success: true, toolCalls: [{ id: 'valid', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '答应', variants: ['承诺', '保证', '说好', '一定'] }) }] },
{ 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')
expect(valid.traces[0]).toMatchObject({ status: 'completed', toolName: 'search_messages' })
expect(execute).toHaveBeenCalledTimes(1)
// Local Query API 仍然只认 query + variants,Host 负责映射。
expect(execute.mock.calls[0][1]).toMatchObject({ query: '答应', variants: ['承诺', '保证', '说好'] })
expect(execute.mock.calls[0][1]).not.toHaveProperty('queries')
execute.mockClear()
const invalid = await new QueryAgentPocService(provider([
{ success: true, toolCalls: [{ id: 'invalid', name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: '答应', variants: ['1', '2', '3', '4', '5'] }) }] },
{ 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')
expect(invalid.traces[0]).toMatchObject({ status: 'invalid_tool_arguments' })
@@ -108,13 +111,249 @@ describe('QueryAgentPocService', () => {
})
it('enforces generic nested tool schema constraints', () => {
const base = { target: { query: 'BOBO' }, timeRange: { kind: 'all' }, query: 'x' }
expect(validateToolArguments('search_messages', { ...base, variants: ['1', '2', '3', '4'] }).error).toBeUndefined()
expect(validateToolArguments('search_messages', { ...base, variants: ['1', '2', '3', '4', '5'] }).error).toMatchObject({ field: 'variants', constraint: 'maxItems', expected: 4, actual: 5 })
const base = { target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: ['x'] }
expect(validateToolArguments('search_messages', { ...base, queries: ['1', '2', '3', '4'] }).error).toBeUndefined()
expect(validateToolArguments('search_messages', { ...base, queries: ['1', '2', '3', '4', '5'] }).error).toMatchObject({ field: 'queries', constraint: 'maxItems', expected: 4, actual: 5 })
expect(validateToolArguments('search_messages', { ...base, queries: [] }).error).toMatchObject({ field: 'queries', constraint: 'minItems', expected: 1, actual: 0 })
expect(validateToolArguments('search_messages', { ...base, extra: true }).error).toMatchObject({ field: 'extra', constraint: 'additionalProperties' })
// 旧的 query 字段已不再是合法属性,必须被拒绝而不是静默忽略。
expect(validateToolArguments('search_messages', { ...base, query: 'x' }).error).toMatchObject({ field: 'query', constraint: 'additionalProperties' })
// 漏掉 queries 时按 required 拒绝。
expect(validateToolArguments('search_messages', { target: { query: 'BOBO' }, timeRange: { kind: 'all' } }).error).toMatchObject({ field: 'queries', constraint: 'required' })
expect(validateToolArguments('query_messages', { target: { query: 'BOBO' }, timeRange: { kind: 'all' }, direction: 'sideways' }).error).toMatchObject({ field: 'direction', constraint: 'enum' })
expect(validateToolArguments('query_messages', { target: { query: '' }, timeRange: { kind: 'all' } }).error).toMatchObject({ field: 'target.query', constraint: 'minLength' })
expect(validateToolArguments('query_messages', { target: { query: 'BOBO' }, timeRange: { kind: 'all' }, limit: 0 }).error).toMatchObject({ field: 'limit', constraint: 'minimum' })
expect(validateToolArguments('message_context', { messageRef: 'opaque', before: 51 }).error).toMatchObject({ field: 'before', constraint: 'maximum' })
})
it('rejects a whitespace-only search probe instead of sending an empty query', () => {
const error = validateToolArguments('search_messages', { target: { query: 'BOBO' }, timeRange: { kind: 'all' }, queries: [' '] }).error
expect(error).toMatchObject({ field: 'queries', constraint: 'required' })
})
})
describe('QueryAgent absolute time contract', () => {
const now = new Date('2026-09-10T00:00:00Z')
const target = { query: 'BOBO' }
function canonical(timeRange: Record<string, unknown>) {
return validateToolArguments('query_messages', { target, timeRange }, now)
}
it('canonicalizes an ISO-8601 absolute range with offset into Local Query API epoch seconds', () => {
const result = canonical({ kind: 'absolute', startTime: '2025-08-01T00:00:00+08:00', endTime: '2025-09-01T00:00:00+08:00' })
expect(result.error).toBeUndefined()
expect(result.input?.timeRange).toEqual({
kind: 'absolute',
startTime: Math.floor(Date.parse('2025-07-31T16:00:00Z') / 1000),
endTime: Math.floor(Date.parse('2025-08-31T16:00:00Z') / 1000)
})
})
it('round-trips seconds and ISO to the same instant', () => {
const result = canonical({ kind: 'absolute', startTime: '2025-08-13T03:27:34Z', endTime: '2025-08-13T04:00:00Z' })
const startSeconds = (result.input?.timeRange as { startTime: number }).startTime
expect(startSeconds).toBe(Math.floor(1755055654000 / 1000))
expect(new Date(startSeconds * 1000).toISOString()).toBe('2025-08-13T03:27:34.000Z')
})
it('treats equivalent offsets as the same instant', () => {
const beijing = canonical({ kind: 'absolute', startTime: '2025-08-01T00:00:00+08:00', endTime: '2025-08-02T00:00:00+08:00' })
const utc = canonical({ kind: 'absolute', startTime: '2025-07-31T16:00:00Z', endTime: '2025-08-01T16:00:00Z' })
expect(beijing.input?.timeRange).toEqual(utc.input?.timeRange)
})
it('supports cross-year ranges', () => {
const result = canonical({ kind: 'absolute', startTime: '2025-12-31T20:00:00+08:00', endTime: '2026-01-01T04:00:00+08:00' })
const range = result.input?.timeRange as { startTime: number; endTime: number }
expect(result.error).toBeUndefined()
expect(range.endTime).toBeGreaterThan(range.startTime)
expect(new Date(range.startTime * 1000).toISOString()).toBe('2025-12-31T12:00:00.000Z')
expect(new Date(range.endTime * 1000).toISOString()).toBe('2025-12-31T20:00:00.000Z')
})
it('never lets millisecond epoch numbers reach the executor silently', () => {
const error = canonical({ kind: 'absolute', startTime: 1753977600000, endTime: 1756656000000 }).error
expect(error).toMatchObject({ field: 'timeRange.startTime', constraint: 'type', expected: 'string', actual: 'number' })
})
it('rejects naive local datetimes without a timezone', () => {
expect(canonical({ kind: 'absolute', startTime: '2025-08-01T00:00:00', endTime: '2025-09-01T00:00:00' }).error).toMatchObject({ field: 'timeRange.startTime', constraint: 'format' })
})
it('rejects invalid calendar days and out-of-range clock values', () => {
expect(canonical({ kind: 'absolute', startTime: '2026-02-31T00:00:00Z', endTime: '2026-03-01T00:00:00Z' }).error).toMatchObject({ field: 'timeRange.startTime', constraint: 'format' })
expect(canonical({ kind: 'absolute', startTime: '2026-03-01T25:00:00Z', endTime: '2026-03-02T00:00:00Z' }).error).toMatchObject({ field: 'timeRange.startTime', constraint: 'format' })
})
it('rejects start after end', () => {
expect(canonical({ kind: 'absolute', startTime: '2026-03-02T00:00:00Z', endTime: '2026-03-01T00:00:00Z' }).error).toMatchObject({ field: 'timeRange.endTime', constraint: 'range_order' })
})
it('rejects absurd future and past ranges instead of returning complete-zero', () => {
expect(canonical({ kind: 'absolute', startTime: '2999-01-01T00:00:00Z', endTime: '2999-02-01T00:00:00Z' }).error).toMatchObject({ field: 'timeRange.startTime', constraint: 'range_sanity' })
expect(canonical({ kind: 'absolute', startTime: '1999-01-01T00:00:00Z', endTime: '1999-02-01T00:00:00Z' }).error).toMatchObject({ field: 'timeRange.startTime', constraint: 'range_sanity' })
expect(canonical({ kind: 'absolute', startTime: '57526-01-01T00:00:00Z', endTime: '57526-02-01T00:00:00Z' }).error).toMatchObject({ field: 'timeRange.startTime', constraint: 'format' })
})
it('drops stray startTime/endTime for non-absolute kinds', () => {
expect(canonical({ kind: 'all', startTime: '2026-01-01T00:00:00Z', endTime: '2026-02-01T00:00:00Z' }).input?.timeRange).toEqual({ kind: 'all' })
expect(canonical({ kind: 'previous_month' }).input?.timeRange).toEqual({ kind: 'previous_month' })
})
it('requires both bounds for absolute ranges', () => {
expect(canonical({ kind: 'absolute', startTime: '2026-01-01T00:00:00Z' }).error).toMatchObject({ field: 'timeRange.startTime', constraint: 'required' })
})
})
describe('QueryAgent zero-result limited retry', () => {
function searchCall(id: string, queries: string[], timeRange: Record<string, unknown> = { kind: 'all' }) {
return { success: true as const, toolCalls: [{ id, name: 'search_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange, queries }) }] }
}
function queryCall(id: string, timeRange: Record<string, unknown> = { kind: 'all' }) {
return { success: true as const, toolCalls: [{ id, name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange }) }] }
}
const emptySearch = async () => ({ status: 'completed', evidenceCount: 0, evidence: [] })
const emptyQuery = async () => ({ status: 'completed', returnedCount: 0, messages: [] })
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('找承诺')
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>
expect(presented._agent).toMatchObject({ toolCallsUsed: 1, availableNextTools: ['search_messages'] })
expect(String(presented._agent.note)).toContain('实质不同')
})
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('找承诺')
expect(execute).toHaveBeenCalledTimes(2)
expect(result.traces.map((trace) => trace.status)).toEqual(['completed', 'completed'])
expect(vi.mocked(configured.chatWithTools).mock.calls[2]?.[1]).toEqual([])
expect(result.error).toBeUndefined()
})
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('找承诺')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1]).toMatchObject({ status: 'invalid_tool_arguments' })
expect(vi.mocked(configured.chatWithTools).mock.calls[2]?.[1]).toEqual([])
const toolMessage = vi.mocked(configured.chatWithTools).mock.calls[2]?.[0].findLast((message) => message.role === 'tool')
expect(String(toolMessage?.content)).toContain('duplicate_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('找承诺')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1].status).toBe('invalid_tool_arguments')
})
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('找承诺')
expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1].map((tool) => tool.function.name)).toEqual(['message_context'])
})
it('allows one query_messages retry after a zero-result exact query', async () => {
const execute = vi.fn()
.mockResolvedValueOnce({ status: 'completed', returnedCount: 0, messages: [] })
.mockResolvedValueOnce({ status: 'completed', returnedCount: 1, messages: [{ messageRef: 'ref' }] })
const configured = provider([queryCall('c1', { kind: 'last_7_days' }), queryCall('c2', { kind: 'all' }), { success: true, data: '找到。' }])
const result = await new QueryAgentPocService(configured, execute).run('找文件')
expect(execute).toHaveBeenCalledTimes(2)
expect(execute.mock.calls[1][1]).toMatchObject({ timeRange: { kind: 'all' } })
expect(result.traces[1].status).toBe('completed')
})
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('找文件')
expect(execute).toHaveBeenCalledTimes(1)
expect(result.traces[1].status).toBe('invalid_tool_arguments')
})
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('找')
expect(vi.mocked(configured.chatWithTools).mock.calls[1]?.[1]).toEqual([])
})
it('still caps total tool calls at five', async () => {
const execute = vi.fn(emptyQuery)
const responses = [
queryCall('c1', { kind: 'last_7_days' }),
queryCall('c2', { kind: 'all' }),
queryCall('c3', { kind: 'today' }),
queryCall('c4', { kind: 'yesterday' }),
queryCall('c5', { kind: 'this_year' }),
queryCall('c6', { kind: 'this_month' })
]
const result = await new QueryAgentPocService(provider(responses), execute).run('找')
expect(result.toolCallCount).toBe(5)
expect(result.error).toContain('最大工具调用次数')
})
})
describe('QueryAgent 耗时与请求级诊断记录', () => {
function queryCall(id: string) {
return { success: true as const, toolCalls: [{ id, name: 'query_messages', arguments: JSON.stringify({ target: { query: 'BOBO' }, timeRange: { kind: 'all' }, limit: 1 }) }] }
}
it('记录每次模型调用耗时,包括失败的那次(首次调用失败)', async () => {
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('测试')
expect(result.modelCallCount).toBe(1)
expect(result.modelDurationsMs).toHaveLength(1)
expect(result.modelDiagnostics).toEqual([
expect.objectContaining({ index: 1, status: 502, contentType: 'text/html', htmlInsteadOfJson: true, elapsedMs: 99419 })
])
expect(result.error).toContain('502')
// 首次模型调用就失败,不应产生任何工具调用
expect(result.toolCallCount).toBe(0)
expect(result.finalModelMs).toBeUndefined()
})
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('测试')
expect(result.modelCallCount).toBe(2)
expect(result.modelDurationsMs).toHaveLength(2)
expect(result.modelDurationsMs.every((value) => typeof value === 'number' && value >= 0)).toBe(true)
expect(result.modelDiagnostics.map((entry) => entry.index)).toEqual([1, 2])
expect(result.toolCallCount).toBe(1)
})
it('1 次模型调用 + 0 次工具:无工具诊断噪声', async () => {
const configured = provider([{ success: true, data: '直接回答' }])
const result = await new QueryAgentPocService(configured, vi.fn()).run('测试')
expect(result.modelCallCount).toBe(1)
expect(result.toolCallCount).toBe(0)
expect(result.modelDurationsMs).toHaveLength(1)
expect(result.modelDiagnostics[0]).not.toHaveProperty('error')
})
it('末尾模型调用失败时两条记录都在,且第二条带错误', async () => {
const configured = provider([
queryCall('c1'),
{ success: false, error: 'AI 请求超时', timedOut: true }
])
const result = await new QueryAgentPocService(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()
})
})
+63
View File
@@ -0,0 +1,63 @@
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { spawnSync } from 'child_process'
import { afterAll, describe, expect, it } from 'vitest'
const repoRoot = process.cwd()
const sandboxes: string[] = []
/** 搭一个最小"仓库":只放 runner 脚本,可选放假的构建产物。 */
function sandbox(withBuild: boolean): string {
const dir = mkdtempSync(join(tmpdir(), 'wxe-poc-run-'))
sandboxes.push(dir)
mkdirSync(join(dir, 'scripts'), { recursive: true })
copyFileSync(join(repoRoot, 'scripts', 'run-query-agent-poc.cjs'), join(dir, 'scripts', 'run-query-agent-poc.cjs'))
if (withBuild) {
mkdirSync(join(dir, 'out', 'main'), { recursive: true })
writeFileSync(
join(dir, 'out', 'main', 'queryAgentPoc.js'),
'process.stdout.write(JSON.stringify({ ranEntry: true, argv: process.argv.slice(2) }) + "\\n")\n'
)
}
return dir
}
function run(dir: string, args: string[], env: Record<string, string> = {}) {
return spawnSync(process.execPath, [join(dir, 'scripts', 'run-query-agent-poc.cjs'), ...args], {
encoding: 'utf8',
cwd: dir,
env: { ...process.env, ...env }
})
}
describe('poc:query-agent:run 快速入口', () => {
afterAll(() => {
for (const dir of sandboxes) rmSync(dir, { recursive: true, force: true })
})
it('构建缺失时给出明确提示并退出码 1,不触发构建', () => {
const result = run(sandbox(false), ['问题'])
expect(result.status).toBe(1)
expect(result.stderr).toContain('POC build 不存在')
expect(result.stderr).toContain('pnpm poc:query-agent')
expect(result.stderr).toContain('不自动构建')
expect(result.stdout).toBe('')
})
it('构建存在时直接执行,且原样转发参数(分隔符交给 entry 处理)', () => {
const dir = sandbox(true)
const result = run(dir, ['--', '我和BOBO第一次聊了什么'], { TRACEMEMO_POC_ELECTRON: process.execPath })
expect(result.status).toBe(0)
const payload = JSON.parse(result.stdout.trim()) as { ranEntry: boolean; argv: string[] }
expect(payload.ranEntry).toBe(true)
// runner 不修改参数:leading `--` 保留,由 POC entry 决定是否移除。
expect(payload.argv).toEqual(['--', '我和BOBO第一次聊了什么'])
})
it('构建存在但启动器失败时返回非 0 退出码并给出提示', () => {
const result = run(sandbox(true), ['问题'], { TRACEMEMO_POC_ELECTRON: '/nonexistent/electron-binary' })
expect(result.status).toBe(1)
expect(result.stderr).toContain('启动 Electron 失败')
})
})