mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-21 21:47:00 +08:00
refactor: 抽离 AI 搜索运行生命周期
This commit is contained in:
@@ -1,15 +1,10 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
import * as Popover from '@radix-ui/react-popover'
|
||||
import { aiSearchIntentLabel, aiSearchRangeStart } from '../../../../shared/ai-search'
|
||||
import type {
|
||||
AiSearchAgentRun,
|
||||
AiSearchProgressEvent,
|
||||
AiSearchTimeRange
|
||||
} from '../../../../shared/ai-search'
|
||||
import type { AiSearchProgressEvent, AiSearchTimeRange } from '../../../../shared/ai-search'
|
||||
|
||||
import type {
|
||||
AISearchWorkspaceProps,
|
||||
SearchProgressByStage,
|
||||
SearchRange,
|
||||
SearchScope,
|
||||
SearchStage,
|
||||
@@ -42,6 +37,7 @@ import { useSearchHistory } from './hooks/useSearchHistory'
|
||||
import { useKnowledgeStatus } from './hooks/useKnowledgeStatus'
|
||||
import { useExternalProviderConsent } from './hooks/useExternalProviderConsent'
|
||||
import { EVIDENCE_PAGE_SIZE, useEvidenceCollection } from './hooks/useEvidenceCollection'
|
||||
import { useAiSearchRun } from './hooks/useAiSearchRun'
|
||||
|
||||
export function AISearchWorkspace({
|
||||
contacts,
|
||||
@@ -67,15 +63,12 @@ export function AISearchWorkspace({
|
||||
const [senderNames, setSenderNames] = useState<Record<string, string>>({})
|
||||
const [cachedAt, setCachedAt] = useState(0)
|
||||
const [searchTrace, setSearchTrace] = useState<SearchTrace | null>(null)
|
||||
const [searchProgress, setSearchProgress] = useState<SearchProgressByStage>({})
|
||||
const [agentTrace, setAgentTrace] = useState<AiSearchAgentRun['trace']>([])
|
||||
const [searchDetailsOpen, setSearchDetailsOpen] = useState(false)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [debugEnabled, setDebugEnabled] = useState(false)
|
||||
const [debugPanelOpen, setDebugPanelOpen] = useState(false)
|
||||
const [debugEntries, setDebugEntries] = useState<string[]>([])
|
||||
const [appLogPath, setAppLogPath] = useState('')
|
||||
const searchRequestIdRef = useRef('')
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null)
|
||||
const {
|
||||
evidence,
|
||||
@@ -150,6 +143,15 @@ export function AISearchWorkspace({
|
||||
settleExternalProviderConsent,
|
||||
clearExternalProviderConsent
|
||||
} = useExternalProviderConsent()
|
||||
const {
|
||||
requestId: searchRunRequestId,
|
||||
progress: searchProgress,
|
||||
agentTrace,
|
||||
createRequestId,
|
||||
startSearch,
|
||||
cancelSearch,
|
||||
resetSearchRun
|
||||
} = useAiSearchRun()
|
||||
|
||||
const resetSearchResult = (): void => {
|
||||
const reset = createSearchResultResetState()
|
||||
@@ -158,8 +160,7 @@ export function AISearchWorkspace({
|
||||
clearEvidenceCollection()
|
||||
setCachedAt(reset.cachedAt)
|
||||
setSearchTrace(reset.searchTrace)
|
||||
setSearchProgress(reset.searchProgress)
|
||||
setAgentTrace(reset.agentTrace)
|
||||
resetSearchRun()
|
||||
setSearchDetailsOpen(reset.searchDetailsOpen)
|
||||
}
|
||||
|
||||
@@ -172,22 +173,6 @@ export function AISearchWorkspace({
|
||||
)
|
||||
}, [])
|
||||
|
||||
React.useEffect(
|
||||
() =>
|
||||
window.api.onAiSearchProgress((progress) => {
|
||||
if (progress.requestId !== searchRequestIdRef.current) return
|
||||
setSearchProgress((current) => ({ ...current, [progress.stage]: progress }))
|
||||
if (progress.agentTrace) {
|
||||
setAgentTrace((current) =>
|
||||
current.some((item) => item.sequence === progress.agentTrace?.sequence)
|
||||
? current
|
||||
: [...current, progress.agentTrace as AiSearchAgentRun['trace'][number]]
|
||||
)
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const addDebugEntry = (message: string, details: Record<string, unknown> = {}): void => {
|
||||
const entry = `${new Date().toLocaleTimeString('zh-CN')} ${message} ${JSON.stringify(details)}`
|
||||
setDebugEntries((current) => [entry, ...current].slice(0, 80))
|
||||
@@ -235,18 +220,15 @@ export function AISearchWorkspace({
|
||||
|
||||
const cancelAnalysis = async (): Promise<void> => {
|
||||
clearExternalProviderConsent()
|
||||
const requestId = searchRequestIdRef.current
|
||||
const requestId = searchRunRequestId
|
||||
if (!requestId) return
|
||||
searchRequestIdRef.current = ''
|
||||
setStage('idle')
|
||||
setAnalysisError('')
|
||||
setSearchProgress({})
|
||||
setAgentTrace([])
|
||||
setSearchDetailsOpen(false)
|
||||
onNotice('已取消本次分析')
|
||||
composerRef.current?.focus()
|
||||
try {
|
||||
await window.api.cancelAiSearch(requestId)
|
||||
await cancelSearch()
|
||||
} catch (error) {
|
||||
addDebugEntry('取消检索请求失败', {
|
||||
requestId,
|
||||
@@ -284,7 +266,6 @@ export function AISearchWorkspace({
|
||||
effectiveRange,
|
||||
normalizedQuery
|
||||
)
|
||||
let requestId = ''
|
||||
try {
|
||||
const cached = consumeCacheBypass() ? null : readCachedResult(cacheKey)
|
||||
if (cached) {
|
||||
@@ -298,7 +279,7 @@ export function AISearchWorkspace({
|
||||
onNotice('已使用最近的检索缓存,可点击刷新数据读取最新消息')
|
||||
return
|
||||
}
|
||||
requestId = globalThis.crypto?.randomUUID?.() || `search-${Date.now()}`
|
||||
const requestId = createRequestId()
|
||||
try {
|
||||
if (!(await ensureAiSearchDataConsent(requestId))) {
|
||||
onNotice('已取消本次 AI Search,未执行检索,也未向远程 AI 服务发送聊天内容')
|
||||
@@ -314,8 +295,7 @@ export function AISearchWorkspace({
|
||||
}
|
||||
setStage('loading')
|
||||
resetSearchResult()
|
||||
searchRequestIdRef.current = requestId
|
||||
const searchResult = await window.api.runAiSearch({
|
||||
const outcome = await startSearch({
|
||||
requestId,
|
||||
text: normalizedQuery,
|
||||
scope,
|
||||
@@ -323,7 +303,19 @@ export function AISearchWorkspace({
|
||||
conversationId: scope === 'conversation' ? activeContact?.md5 : undefined,
|
||||
timeRangeOverride: effectiveTimeRangeOverride
|
||||
})
|
||||
if (searchRequestIdRef.current !== requestId) return
|
||||
if (outcome.kind === 'stale') return
|
||||
if (outcome.kind === 'cancelled') {
|
||||
onNotice('已取消本次分析')
|
||||
setStage('idle')
|
||||
return
|
||||
}
|
||||
if (outcome.kind === 'failed') {
|
||||
addDebugEntry('检索失败', { error: outcome.error })
|
||||
setAnalysisError(outcome.error)
|
||||
setStage('insufficient')
|
||||
return
|
||||
}
|
||||
const searchResult = outcome.result
|
||||
addDebugEntry('主进程搜索任务完成', {
|
||||
status: searchResult.status,
|
||||
candidateEvidenceCount: searchResult.candidateEvidenceCount,
|
||||
@@ -331,18 +323,12 @@ export function AISearchWorkspace({
|
||||
elapsedMs: searchResult.elapsedMs,
|
||||
errorStage: searchResult.errorStage
|
||||
})
|
||||
if (searchResult.status === 'cancelled') {
|
||||
onNotice('已取消本次分析')
|
||||
setStage('idle')
|
||||
return
|
||||
}
|
||||
const evidenceItems = mapPipelineEvidence(searchResult.evidence, allContacts)
|
||||
const collectionItems = mapPipelineEvidence(
|
||||
searchResult.evidenceCollection || searchResult.evidence,
|
||||
allContacts
|
||||
)
|
||||
setSearchTrace(mapSearchResultToTrace(searchResult, evidenceItems.length))
|
||||
setAgentTrace(searchResult.agent.trace)
|
||||
setEvidenceResult(evidenceItems, collectionItems)
|
||||
const nextSenderNames = mapEvidenceSenderNames(evidenceItems)
|
||||
setSenderNames(nextSenderNames)
|
||||
@@ -381,13 +367,10 @@ export function AISearchWorkspace({
|
||||
})
|
||||
setStage('result')
|
||||
} catch (error) {
|
||||
if (requestId && searchRequestIdRef.current !== requestId) return
|
||||
const errorMessage = error instanceof Error ? error.message : '读取聊天记录失败'
|
||||
addDebugEntry('检索失败', { error: errorMessage })
|
||||
setAnalysisError(errorMessage)
|
||||
setStage('insufficient')
|
||||
} finally {
|
||||
if (requestId && searchRequestIdRef.current === requestId) searchRequestIdRef.current = ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
AiSearchAgentRun,
|
||||
AiSearchPipelineRequest,
|
||||
AiSearchPipelineResult,
|
||||
AiSearchProgressEvent
|
||||
} from '../../../../../shared/ai-search'
|
||||
import type { SearchProgressByStage } from '../searchTypes'
|
||||
|
||||
export type AiSearchRunStatus =
|
||||
| 'idle'
|
||||
| 'starting'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
|
||||
export type AiSearchRunOutcome =
|
||||
| { kind: 'completed'; requestId: string; result: AiSearchPipelineResult }
|
||||
| { kind: 'cancelled'; requestId: string; result?: AiSearchPipelineResult }
|
||||
| { kind: 'failed'; requestId: string; error: string }
|
||||
| { kind: 'stale'; requestId: string }
|
||||
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : '读取聊天记录失败'
|
||||
|
||||
export function useAiSearchRun(): {
|
||||
status: AiSearchRunStatus
|
||||
requestId: string
|
||||
result: AiSearchPipelineResult | null
|
||||
error: string
|
||||
progress: SearchProgressByStage
|
||||
agentTrace: AiSearchAgentRun['trace']
|
||||
createRequestId: () => string
|
||||
startSearch: (request: AiSearchPipelineRequest) => Promise<AiSearchRunOutcome>
|
||||
cancelSearch: () => Promise<void>
|
||||
resetSearchRun: () => void
|
||||
isCurrentRequest: (requestId: string) => boolean
|
||||
} {
|
||||
const [status, setStatus] = useState<AiSearchRunStatus>('idle')
|
||||
const [requestId, setRequestId] = useState('')
|
||||
const [result, setResult] = useState<AiSearchPipelineResult | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [progress, setProgress] = useState<SearchProgressByStage>({})
|
||||
const [agentTrace, setAgentTrace] = useState<AiSearchAgentRun['trace']>([])
|
||||
const requestIdRef = useRef('')
|
||||
|
||||
const createRequestId = (): string => globalThis.crypto?.randomUUID?.() || `search-${Date.now()}`
|
||||
|
||||
const isCurrentRequest = (currentRequestId: string): boolean =>
|
||||
Boolean(currentRequestId) && requestIdRef.current === currentRequestId
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.api.onAiSearchProgress((event: AiSearchProgressEvent) => {
|
||||
if (!isCurrentRequest(event.requestId)) return
|
||||
setProgress((current) => ({ ...current, [event.stage]: event }))
|
||||
if (event.agentTrace) {
|
||||
setAgentTrace((current) =>
|
||||
current.some((item) => item.sequence === event.agentTrace?.sequence)
|
||||
? current
|
||||
: [...current, event.agentTrace as AiSearchAgentRun['trace'][number]]
|
||||
)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
requestIdRef.current = ''
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startSearch = async (request: AiSearchPipelineRequest): Promise<AiSearchRunOutcome> => {
|
||||
requestIdRef.current = request.requestId
|
||||
setRequestId(request.requestId)
|
||||
setStatus('starting')
|
||||
setError('')
|
||||
setResult(null)
|
||||
setProgress({})
|
||||
setAgentTrace([])
|
||||
setStatus('running')
|
||||
try {
|
||||
const searchResult = await window.api.runAiSearch(request)
|
||||
if (!isCurrentRequest(request.requestId))
|
||||
return { kind: 'stale', requestId: request.requestId }
|
||||
requestIdRef.current = ''
|
||||
setRequestId('')
|
||||
setResult(searchResult)
|
||||
setAgentTrace(searchResult.agent.trace)
|
||||
if (searchResult.status === 'cancelled') {
|
||||
setStatus('cancelled')
|
||||
return { kind: 'cancelled', requestId: request.requestId, result: searchResult }
|
||||
}
|
||||
if (searchResult.status === 'failed' || searchResult.status === 'ai_failed') {
|
||||
setStatus('failed')
|
||||
} else {
|
||||
setStatus('completed')
|
||||
}
|
||||
return { kind: 'completed', requestId: request.requestId, result: searchResult }
|
||||
} catch (caughtError) {
|
||||
if (!isCurrentRequest(request.requestId))
|
||||
return { kind: 'stale', requestId: request.requestId }
|
||||
requestIdRef.current = ''
|
||||
setRequestId('')
|
||||
const message = errorMessage(caughtError)
|
||||
setError(message)
|
||||
setStatus('failed')
|
||||
return { kind: 'failed', requestId: request.requestId, error: message }
|
||||
}
|
||||
}
|
||||
|
||||
const cancelSearch = async (): Promise<void> => {
|
||||
const currentRequestId = requestIdRef.current
|
||||
if (!currentRequestId) return
|
||||
requestIdRef.current = ''
|
||||
setRequestId('')
|
||||
setStatus('cancelled')
|
||||
setError('')
|
||||
setProgress({})
|
||||
setAgentTrace([])
|
||||
await window.api.cancelAiSearch(currentRequestId)
|
||||
}
|
||||
|
||||
const resetSearchRun = (): void => {
|
||||
requestIdRef.current = ''
|
||||
setRequestId('')
|
||||
setStatus('idle')
|
||||
setResult(null)
|
||||
setError('')
|
||||
setProgress({})
|
||||
setAgentTrace([])
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
requestId,
|
||||
result,
|
||||
error,
|
||||
progress,
|
||||
agentTrace,
|
||||
createRequestId,
|
||||
startSearch,
|
||||
cancelSearch,
|
||||
resetSearchRun,
|
||||
isCurrentRequest
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AiSearchAgentTraceItem,
|
||||
AiSearchPipelineRequest,
|
||||
AiSearchPipelineResult,
|
||||
AiSearchProgressEvent,
|
||||
AiSearchProgressStage
|
||||
} from '../../src/shared/ai-search'
|
||||
import { useAiSearchRun } from '../../src/renderer/src/components/search/hooks/useAiSearchRun'
|
||||
import { makeSearchResult } from './support/ai-search-fixtures'
|
||||
|
||||
const api = {
|
||||
runAiSearch: vi.fn(),
|
||||
cancelAiSearch: vi.fn(),
|
||||
onAiSearchProgress: vi.fn()
|
||||
}
|
||||
|
||||
const request = (requestId: string, text = '测试问题'): AiSearchPipelineRequest => ({
|
||||
requestId,
|
||||
text,
|
||||
scope: 'global',
|
||||
range: '30d'
|
||||
})
|
||||
|
||||
const resultFor = (
|
||||
requestId: string,
|
||||
status: 'completed' | 'failed' | 'ai_failed' | 'cancelled' = 'completed'
|
||||
): AiSearchPipelineResult => makeSearchResult({ requestId, status })
|
||||
|
||||
const progressFor = (
|
||||
requestId: string,
|
||||
stage: AiSearchProgressStage,
|
||||
trace?: AiSearchAgentTraceItem
|
||||
): AiSearchProgressEvent => ({
|
||||
requestId,
|
||||
stage,
|
||||
status: 'running',
|
||||
message: `${stage} ${requestId}`,
|
||||
agentTrace: trace
|
||||
})
|
||||
|
||||
const traceFor = (sequence: number): AiSearchAgentTraceItem => ({
|
||||
sequence,
|
||||
event: 'agentDecision',
|
||||
label: `decision-${sequence}`
|
||||
})
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
let progressListener: ((event: AiSearchProgressEvent) => void) | undefined
|
||||
let unsubscribe: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
progressListener = undefined
|
||||
unsubscribe = vi.fn()
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: api })
|
||||
api.onAiSearchProgress.mockImplementation((listener) => {
|
||||
progressListener = listener
|
||||
return unsubscribe
|
||||
})
|
||||
api.cancelAiSearch.mockResolvedValue({ cancelled: true })
|
||||
})
|
||||
|
||||
describe('useAiSearchRun', () => {
|
||||
it('starts idle and registers exactly one progress listener', () => {
|
||||
const { result, rerender } = renderHook(() => useAiSearchRun())
|
||||
|
||||
expect(result.current.status).toBe('idle')
|
||||
expect(result.current.requestId).toBe('')
|
||||
expect(result.current.progress).toEqual({})
|
||||
expect(result.current.agentTrace).toEqual([])
|
||||
rerender()
|
||||
expect(api.onAiSearchProgress).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('starts a run with the exact request payload and exposes the running requestId', async () => {
|
||||
const run = deferred<AiSearchPipelineResult>()
|
||||
api.runAiSearch.mockReturnValue(run.promise)
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
|
||||
let startPromise!: ReturnType<typeof result.current.startSearch>
|
||||
act(() => {
|
||||
startPromise = result.current.startSearch(request('request-a', '原始问题'))
|
||||
})
|
||||
|
||||
expect(result.current.status).toBe('running')
|
||||
expect(result.current.requestId).toBe('request-a')
|
||||
expect(api.runAiSearch).toHaveBeenCalledWith(request('request-a', '原始问题'))
|
||||
|
||||
await act(async () => {
|
||||
run.resolve(resultFor('request-a'))
|
||||
await startPromise
|
||||
})
|
||||
})
|
||||
|
||||
it('applies the current request result and converges to completed', async () => {
|
||||
api.runAiSearch.mockResolvedValue(resultFor('request-a'))
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
|
||||
let outcome!: Awaited<ReturnType<typeof result.current.startSearch>>
|
||||
await act(async () => {
|
||||
outcome = await result.current.startSearch(request('request-a'))
|
||||
})
|
||||
|
||||
expect(outcome).toMatchObject({ kind: 'completed', requestId: 'request-a' })
|
||||
expect(result.current.status).toBe('completed')
|
||||
expect(result.current.requestId).toBe('')
|
||||
expect(result.current.result?.requestId).toBe('request-a')
|
||||
})
|
||||
|
||||
it('converges to failed when the pipeline returns a failed status', async () => {
|
||||
api.runAiSearch.mockResolvedValue(resultFor('request-failed', 'failed'))
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
|
||||
let outcome!: Awaited<ReturnType<typeof result.current.startSearch>>
|
||||
await act(async () => {
|
||||
outcome = await result.current.startSearch(request('request-failed'))
|
||||
})
|
||||
|
||||
expect(outcome.kind).toBe('completed')
|
||||
expect(result.current.status).toBe('failed')
|
||||
expect(result.current.result?.status).toBe('failed')
|
||||
})
|
||||
|
||||
it('converges to failed with an error when runAiSearch rejects', async () => {
|
||||
api.runAiSearch.mockRejectedValue(new Error('Worker failed'))
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
|
||||
let outcome!: Awaited<ReturnType<typeof result.current.startSearch>>
|
||||
await act(async () => {
|
||||
outcome = await result.current.startSearch(request('request-error'))
|
||||
})
|
||||
|
||||
expect(outcome).toEqual({ kind: 'failed', requestId: 'request-error', error: 'Worker failed' })
|
||||
expect(result.current.status).toBe('failed')
|
||||
expect(result.current.error).toBe('Worker failed')
|
||||
expect(result.current.requestId).toBe('')
|
||||
})
|
||||
|
||||
it('applies current progress and deduplicates current Agent Trace events', async () => {
|
||||
const run = deferred<AiSearchPipelineResult>()
|
||||
api.runAiSearch.mockReturnValue(run.promise)
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
act(() => {
|
||||
void result.current.startSearch(request('request-a'))
|
||||
})
|
||||
|
||||
act(() => progressListener?.(progressFor('request-a', 'agent_tool', traceFor(1))))
|
||||
act(() => progressListener?.(progressFor('request-a', 'agent_tool', traceFor(1))))
|
||||
|
||||
expect(result.current.progress.agent_tool?.requestId).toBe('request-a')
|
||||
expect(result.current.agentTrace).toHaveLength(1)
|
||||
expect(result.current.agentTrace[0].sequence).toBe(1)
|
||||
await act(async () => {
|
||||
run.resolve(resultFor('request-a'))
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores stale progress and Agent Trace after a newer request starts', async () => {
|
||||
const runA = deferred<AiSearchPipelineResult>()
|
||||
const runB = deferred<AiSearchPipelineResult>()
|
||||
api.runAiSearch.mockReturnValueOnce(runA.promise).mockReturnValueOnce(runB.promise)
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
let promiseA!: ReturnType<typeof result.current.startSearch>
|
||||
let promiseB!: ReturnType<typeof result.current.startSearch>
|
||||
act(() => {
|
||||
promiseA = result.current.startSearch(request('request-a'))
|
||||
promiseB = result.current.startSearch(request('request-b'))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
progressListener?.(progressFor('request-a', 'query_understanding', traceFor(1)))
|
||||
progressListener?.(progressFor('request-b', 'agent_tool', traceFor(2)))
|
||||
})
|
||||
|
||||
expect(result.current.progress.query_understanding).toBeUndefined()
|
||||
expect(result.current.progress.agent_tool?.requestId).toBe('request-b')
|
||||
expect(result.current.agentTrace.map((item) => item.sequence)).toEqual([2])
|
||||
|
||||
await act(async () => {
|
||||
runA.resolve(resultFor('request-a'))
|
||||
expect(await promiseA).toEqual({ kind: 'stale', requestId: 'request-a' })
|
||||
runB.resolve(resultFor('request-b'))
|
||||
expect((await promiseB).kind).toBe('completed')
|
||||
})
|
||||
expect(result.current.result?.requestId).toBe('request-b')
|
||||
})
|
||||
|
||||
it('ignores a stale result so it cannot overwrite the current request', async () => {
|
||||
const runA = deferred<AiSearchPipelineResult>()
|
||||
const runB = deferred<AiSearchPipelineResult>()
|
||||
api.runAiSearch.mockReturnValueOnce(runA.promise).mockReturnValueOnce(runB.promise)
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
let promiseA!: ReturnType<typeof result.current.startSearch>
|
||||
let promiseB!: ReturnType<typeof result.current.startSearch>
|
||||
act(() => {
|
||||
promiseA = result.current.startSearch(request('request-a'))
|
||||
promiseB = result.current.startSearch(request('request-b'))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
runA.resolve(resultFor('request-a'))
|
||||
expect(await promiseA).toEqual({ kind: 'stale', requestId: 'request-a' })
|
||||
expect(result.current.result).toBeNull()
|
||||
runB.resolve(resultFor('request-b'))
|
||||
await promiseB
|
||||
})
|
||||
|
||||
expect(result.current.result?.requestId).toBe('request-b')
|
||||
expect(result.current.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('ignores a stale error so it cannot overwrite the current request', async () => {
|
||||
const runA = deferred<AiSearchPipelineResult>()
|
||||
const runB = deferred<AiSearchPipelineResult>()
|
||||
api.runAiSearch.mockReturnValueOnce(runA.promise).mockReturnValueOnce(runB.promise)
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
let promiseA!: ReturnType<typeof result.current.startSearch>
|
||||
let promiseB!: ReturnType<typeof result.current.startSearch>
|
||||
act(() => {
|
||||
promiseA = result.current.startSearch(request('request-a'))
|
||||
promiseB = result.current.startSearch(request('request-b'))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
runA.reject(new Error('late A error'))
|
||||
expect(await promiseA).toEqual({ kind: 'stale', requestId: 'request-a' })
|
||||
expect(result.current.error).toBe('')
|
||||
runB.resolve(resultFor('request-b'))
|
||||
await promiseB
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('')
|
||||
expect(result.current.result?.requestId).toBe('request-b')
|
||||
})
|
||||
|
||||
it('cancels the current request through the existing IPC and converges state', async () => {
|
||||
const run = deferred<AiSearchPipelineResult>()
|
||||
api.runAiSearch.mockReturnValue(run.promise)
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
act(() => {
|
||||
void result.current.startSearch(request('request-cancel'))
|
||||
})
|
||||
|
||||
await act(async () => result.current.cancelSearch())
|
||||
|
||||
expect(api.cancelAiSearch).toHaveBeenCalledWith('request-cancel')
|
||||
expect(result.current.status).toBe('cancelled')
|
||||
expect(result.current.requestId).toBe('')
|
||||
expect(result.current.progress).toEqual({})
|
||||
expect(result.current.agentTrace).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a late result after cancellation', async () => {
|
||||
const run = deferred<AiSearchPipelineResult>()
|
||||
api.runAiSearch.mockReturnValue(run.promise)
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
let startPromise!: ReturnType<typeof result.current.startSearch>
|
||||
act(() => {
|
||||
startPromise = result.current.startSearch(request('request-cancel'))
|
||||
})
|
||||
await act(async () => result.current.cancelSearch())
|
||||
|
||||
await act(async () => {
|
||||
run.resolve(resultFor('request-cancel'))
|
||||
expect(await startPromise).toEqual({ kind: 'stale', requestId: 'request-cancel' })
|
||||
})
|
||||
expect(result.current.result).toBeNull()
|
||||
expect(result.current.status).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('does not call cancel IPC when no request is active', async () => {
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
|
||||
await act(async () => result.current.cancelSearch())
|
||||
|
||||
expect(api.cancelAiSearch).not.toHaveBeenCalled()
|
||||
expect(result.current.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('ignores progress after the request has ended', async () => {
|
||||
api.runAiSearch.mockResolvedValue(resultFor('request-ended'))
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
await act(async () => result.current.startSearch(request('request-ended')))
|
||||
const progressBefore = result.current.progress
|
||||
|
||||
act(() => progressListener?.(progressFor('request-ended', 'error', traceFor(9))))
|
||||
|
||||
expect(result.current.progress).toBe(progressBefore)
|
||||
expect(result.current.agentTrace).toEqual([])
|
||||
})
|
||||
|
||||
it('resets the run state without cancelling an IPC request', async () => {
|
||||
const run = deferred<AiSearchPipelineResult>()
|
||||
api.runAiSearch.mockReturnValue(run.promise)
|
||||
const { result } = renderHook(() => useAiSearchRun())
|
||||
act(() => {
|
||||
void result.current.startSearch(request('request-reset'))
|
||||
})
|
||||
act(() => result.current.resetSearchRun())
|
||||
|
||||
expect(result.current.status).toBe('idle')
|
||||
expect(result.current.requestId).toBe('')
|
||||
expect(result.current.result).toBeNull()
|
||||
expect(result.current.progress).toEqual({})
|
||||
expect(result.current.agentTrace).toEqual([])
|
||||
expect(api.cancelAiSearch).not.toHaveBeenCalled()
|
||||
await act(async () => {
|
||||
run.resolve(resultFor('request-reset'))
|
||||
})
|
||||
})
|
||||
|
||||
it('cleans up the progress listener and invalidates an active request on unmount', async () => {
|
||||
const run = deferred<AiSearchPipelineResult>()
|
||||
api.runAiSearch.mockReturnValue(run.promise)
|
||||
const { result, unmount } = renderHook(() => useAiSearchRun())
|
||||
let startPromise!: ReturnType<typeof result.current.startSearch>
|
||||
act(() => {
|
||||
startPromise = result.current.startSearch(request('request-unmount'))
|
||||
})
|
||||
|
||||
unmount()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
await act(async () => {
|
||||
run.resolve(resultFor('request-unmount'))
|
||||
expect(await startPromise).toEqual({ kind: 'stale', requestId: 'request-unmount' })
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user