mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: 修复问股和首页进行中状态残留 (#1461)
* fix: clear stale active task state * fix: guard active task pruning against stale snapshots --------- Co-authored-by: mumu <42829555+ZhuLinsen@users.noreply.github.com>
This commit is contained in:
@@ -30,11 +30,13 @@ describe('useDashboardLifecycle', () => {
|
||||
it('loads history, refreshes on interval, and reacts to visibility changes', () => {
|
||||
const loadInitialHistory = vi.fn().mockResolvedValue(undefined);
|
||||
const refreshHistory = vi.fn().mockResolvedValue(undefined);
|
||||
const refreshActiveTasks = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
renderHook(() =>
|
||||
useDashboardLifecycle({
|
||||
loadInitialHistory,
|
||||
refreshHistory,
|
||||
refreshActiveTasks,
|
||||
syncTaskCreated: vi.fn(),
|
||||
syncTaskUpdated: vi.fn(),
|
||||
syncTaskFailed: vi.fn(),
|
||||
@@ -43,11 +45,13 @@ describe('useDashboardLifecycle', () => {
|
||||
);
|
||||
|
||||
expect(loadInitialHistory).toHaveBeenCalledTimes(1);
|
||||
expect(refreshActiveTasks).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
});
|
||||
expect(refreshHistory).toHaveBeenCalledWith(true);
|
||||
expect(refreshActiveTasks).toHaveBeenCalledTimes(2);
|
||||
|
||||
act(() => {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
@@ -58,6 +62,7 @@ describe('useDashboardLifecycle', () => {
|
||||
});
|
||||
|
||||
expect(refreshHistory).toHaveBeenCalledTimes(2);
|
||||
expect(refreshActiveTasks).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('cleans pending task removal timers on unmount', () => {
|
||||
@@ -67,6 +72,7 @@ describe('useDashboardLifecycle', () => {
|
||||
useDashboardLifecycle({
|
||||
loadInitialHistory: vi.fn().mockResolvedValue(undefined),
|
||||
refreshHistory: vi.fn().mockResolvedValue(undefined),
|
||||
refreshActiveTasks: vi.fn().mockResolvedValue(undefined),
|
||||
syncTaskCreated: vi.fn(),
|
||||
syncTaskUpdated: vi.fn(),
|
||||
syncTaskFailed: vi.fn(),
|
||||
@@ -99,6 +105,7 @@ describe('useDashboardLifecycle', () => {
|
||||
useDashboardLifecycle({
|
||||
loadInitialHistory: vi.fn().mockResolvedValue(undefined),
|
||||
refreshHistory,
|
||||
refreshActiveTasks: vi.fn().mockResolvedValue(undefined),
|
||||
syncTaskCreated: vi.fn(),
|
||||
syncTaskUpdated,
|
||||
syncTaskFailed: vi.fn(),
|
||||
@@ -130,6 +137,7 @@ describe('useDashboardLifecycle', () => {
|
||||
useDashboardLifecycle({
|
||||
loadInitialHistory: vi.fn().mockResolvedValue(undefined),
|
||||
refreshHistory: vi.fn().mockResolvedValue(undefined),
|
||||
refreshActiveTasks: vi.fn().mockResolvedValue(undefined),
|
||||
syncTaskCreated: vi.fn(),
|
||||
syncTaskUpdated,
|
||||
syncTaskFailed: vi.fn(),
|
||||
@@ -160,6 +168,7 @@ describe('useDashboardLifecycle', () => {
|
||||
useDashboardLifecycle({
|
||||
loadInitialHistory: vi.fn().mockResolvedValue(undefined),
|
||||
refreshHistory: vi.fn().mockResolvedValue(undefined),
|
||||
refreshActiveTasks: vi.fn().mockResolvedValue(undefined),
|
||||
syncTaskCreated: vi.fn(),
|
||||
syncTaskUpdated: vi.fn(),
|
||||
syncTaskFailed,
|
||||
@@ -186,4 +195,28 @@ describe('useDashboardLifecycle', () => {
|
||||
|
||||
expect(removeTask).toHaveBeenCalledWith(failedTask.taskId);
|
||||
});
|
||||
|
||||
it('reconciles active tasks when the SSE stream connects', () => {
|
||||
const refreshActiveTasks = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
renderHook(() =>
|
||||
useDashboardLifecycle({
|
||||
loadInitialHistory: vi.fn().mockResolvedValue(undefined),
|
||||
refreshHistory: vi.fn().mockResolvedValue(undefined),
|
||||
refreshActiveTasks,
|
||||
syncTaskCreated: vi.fn(),
|
||||
syncTaskUpdated: vi.fn(),
|
||||
syncTaskFailed: vi.fn(),
|
||||
removeTask: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const taskStreamOptions = vi.mocked(useTaskStream).mock.calls[0]?.[0];
|
||||
|
||||
act(() => {
|
||||
taskStreamOptions?.onConnected?.();
|
||||
});
|
||||
|
||||
expect(refreshActiveTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTaskStream } from './useTaskStream';
|
||||
type UseDashboardLifecycleOptions = {
|
||||
loadInitialHistory: () => Promise<void>;
|
||||
refreshHistory: (silent?: boolean) => Promise<void>;
|
||||
refreshActiveTasks: () => Promise<void>;
|
||||
syncTaskCreated: (task: TaskInfo) => void;
|
||||
syncTaskUpdated: (task: TaskInfo) => void;
|
||||
syncTaskFailed: (task: TaskInfo) => void;
|
||||
@@ -15,6 +16,7 @@ type UseDashboardLifecycleOptions = {
|
||||
export function useDashboardLifecycle({
|
||||
loadInitialHistory,
|
||||
refreshHistory,
|
||||
refreshActiveTasks,
|
||||
syncTaskCreated,
|
||||
syncTaskUpdated,
|
||||
syncTaskFailed,
|
||||
@@ -29,7 +31,8 @@ export function useDashboardLifecycle({
|
||||
}
|
||||
|
||||
void loadInitialHistory();
|
||||
}, [enabled, loadInitialHistory]);
|
||||
void refreshActiveTasks();
|
||||
}, [enabled, loadInitialHistory, refreshActiveTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
@@ -38,10 +41,11 @@ export function useDashboardLifecycle({
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void refreshHistory(true);
|
||||
void refreshActiveTasks();
|
||||
}, 30_000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [enabled, refreshHistory]);
|
||||
}, [enabled, refreshHistory, refreshActiveTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
@@ -51,12 +55,13 @@ export function useDashboardLifecycle({
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void refreshHistory(true);
|
||||
void refreshActiveTasks();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, [enabled, refreshHistory]);
|
||||
}, [enabled, refreshHistory, refreshActiveTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -78,6 +83,9 @@ export function useDashboardLifecycle({
|
||||
onTaskCreated: syncTaskCreated,
|
||||
onTaskStarted: syncTaskUpdated,
|
||||
onTaskProgress: syncTaskUpdated,
|
||||
onConnected: () => {
|
||||
void refreshActiveTasks();
|
||||
},
|
||||
onTaskCompleted: (task) => {
|
||||
syncTaskUpdated(task);
|
||||
void refreshHistory(true);
|
||||
|
||||
@@ -39,6 +39,7 @@ export function useHomeDashboardState() {
|
||||
syncTaskCreated: state.syncTaskCreated,
|
||||
syncTaskUpdated: state.syncTaskUpdated,
|
||||
syncTaskFailed: state.syncTaskFailed,
|
||||
refreshActiveTasks: state.refreshActiveTasks,
|
||||
removeTask: state.removeTask,
|
||||
openMarkdownDrawer: state.openMarkdownDrawer,
|
||||
closeMarkdownDrawer: state.closeMarkdownDrawer,
|
||||
|
||||
@@ -97,6 +97,7 @@ const HomePage: React.FC = () => {
|
||||
syncTaskCreated,
|
||||
syncTaskUpdated,
|
||||
syncTaskFailed,
|
||||
refreshActiveTasks,
|
||||
removeTask,
|
||||
openMarkdownDrawer,
|
||||
closeMarkdownDrawer,
|
||||
@@ -291,6 +292,7 @@ const HomePage: React.FC = () => {
|
||||
syncTaskCreated,
|
||||
syncTaskUpdated,
|
||||
syncTaskFailed,
|
||||
refreshActiveTasks,
|
||||
removeTask,
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock('../../api/analysis', async () => {
|
||||
analyzeAsync: vi.fn(),
|
||||
triggerMarketReview: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
getTasks: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -117,6 +118,12 @@ describe('HomePage', () => {
|
||||
vi.clearAllMocks();
|
||||
navigateMock.mockReset();
|
||||
useStockPoolStore.getState().resetDashboardState();
|
||||
vi.mocked(analysisApi.getTasks).mockResolvedValue({
|
||||
total: 0,
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
tasks: [],
|
||||
});
|
||||
vi.mocked(agentApi.getSkills).mockResolvedValue({ skills: [], default_skill_id: '' });
|
||||
vi.mocked(systemConfigApi.getSetupStatus).mockResolvedValue({
|
||||
isComplete: true,
|
||||
@@ -487,26 +494,31 @@ describe('HomePage', () => {
|
||||
});
|
||||
|
||||
it('renders active task panel content from dashboard state', async () => {
|
||||
const activeTask = {
|
||||
taskId: 'task-1',
|
||||
stockCode: '600519',
|
||||
stockName: '贵州茅台',
|
||||
status: 'processing' as const,
|
||||
progress: 45,
|
||||
message: '正在抓取最新行情',
|
||||
reportType: 'detailed',
|
||||
createdAt: '2026-03-18T08:00:00Z',
|
||||
};
|
||||
vi.mocked(historyApi.getList).mockResolvedValue({
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(analysisApi.getTasks).mockResolvedValue({
|
||||
total: 1,
|
||||
pending: 0,
|
||||
processing: 1,
|
||||
tasks: [activeTask],
|
||||
});
|
||||
|
||||
useStockPoolStore.setState({
|
||||
activeTasks: [
|
||||
{
|
||||
taskId: 'task-1',
|
||||
stockCode: '600519',
|
||||
stockName: '贵州茅台',
|
||||
status: 'processing',
|
||||
progress: 45,
|
||||
message: '正在抓取最新行情',
|
||||
reportType: 'detailed',
|
||||
createdAt: '2026-03-18T08:00:00Z',
|
||||
},
|
||||
],
|
||||
activeTasks: [activeTask],
|
||||
});
|
||||
|
||||
render(
|
||||
|
||||
@@ -28,25 +28,35 @@ function createStreamResponse(lines: string[]) {
|
||||
);
|
||||
}
|
||||
|
||||
describe('agentChatStore.startStream', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAgentChatStore.setState({
|
||||
messages: [],
|
||||
loading: false,
|
||||
progressSteps: [],
|
||||
sessionId: 'session-test',
|
||||
sessions: [],
|
||||
sessionsLoading: false,
|
||||
chatError: null,
|
||||
currentRoute: '/chat',
|
||||
completionBadge: false,
|
||||
hasInitialLoad: true,
|
||||
abortController: null,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAgentChatStore.setState({
|
||||
messages: [],
|
||||
loading: false,
|
||||
progressSteps: [],
|
||||
sessionId: 'session-test',
|
||||
sessions: [],
|
||||
sessionsLoading: false,
|
||||
chatError: null,
|
||||
currentRoute: '/chat',
|
||||
completionBadge: false,
|
||||
hasInitialLoad: true,
|
||||
abortController: null,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('agentChatStore.startStream', () => {
|
||||
it('appends the user message and final assistant message from the SSE stream', async () => {
|
||||
vi.mocked(agentApi.chatStream).mockResolvedValue(
|
||||
createStreamResponse([
|
||||
@@ -183,3 +193,66 @@ describe('agentChatStore.startStream', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentChatStore.switchSession', () => {
|
||||
|
||||
it('clears transient loading state when switching sessions during a stream', async () => {
|
||||
const ac = new AbortController();
|
||||
vi.mocked(agentApi.getChatSessionMessages).mockResolvedValue([
|
||||
{ id: 'msg-2', role: 'assistant', content: '历史回复', created_at: null },
|
||||
]);
|
||||
useAgentChatStore.setState({
|
||||
loading: true,
|
||||
progressSteps: [{ type: 'thinking', message: '正在制定分析路径...' }],
|
||||
abortController: ac,
|
||||
chatError: {
|
||||
title: '请求失败',
|
||||
message: '旧错误',
|
||||
category: 'unknown',
|
||||
rawMessage: '旧错误',
|
||||
},
|
||||
});
|
||||
|
||||
await useAgentChatStore.getState().switchSession('session-2');
|
||||
|
||||
const state = useAgentChatStore.getState();
|
||||
expect(ac.signal.aborted).toBe(true);
|
||||
expect(state.sessionId).toBe('session-2');
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.progressSteps).toEqual([]);
|
||||
expect(state.abortController).toBeNull();
|
||||
expect(state.chatError).toBeNull();
|
||||
expect(state.messages).toEqual([
|
||||
{ id: 'msg-2', role: 'assistant', content: '历史回复' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not let a late session history response overwrite the current session', async () => {
|
||||
const sessionA = createDeferred<
|
||||
Array<{ id: string; role: 'user' | 'assistant'; content: string; created_at: string | null }>
|
||||
>();
|
||||
const sessionB = createDeferred<
|
||||
Array<{ id: string; role: 'user' | 'assistant'; content: string; created_at: string | null }>
|
||||
>();
|
||||
vi.mocked(agentApi.getChatSessionMessages).mockImplementation((targetSessionId: string) => {
|
||||
if (targetSessionId === 'session-a') return sessionA.promise;
|
||||
if (targetSessionId === 'session-b') return sessionB.promise;
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
const switchToA = useAgentChatStore.getState().switchSession('session-a');
|
||||
const switchToB = useAgentChatStore.getState().switchSession('session-b');
|
||||
|
||||
sessionB.resolve([{ id: 'msg-b', role: 'assistant', content: 'B 回复', created_at: null }]);
|
||||
await switchToB;
|
||||
|
||||
sessionA.resolve([{ id: 'msg-a', role: 'assistant', content: 'A 回复', created_at: null }]);
|
||||
await switchToA;
|
||||
|
||||
const state = useAgentChatStore.getState();
|
||||
expect(state.sessionId).toBe('session-b');
|
||||
expect(state.messages).toEqual([
|
||||
{ id: 'msg-b', role: 'assistant', content: 'B 回复' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { analysisApi, DuplicateTaskError } from '../../api/analysis';
|
||||
import { historyApi } from '../../api/history';
|
||||
import type { TaskInfo, TaskListResponse } from '../../types/analysis';
|
||||
import { useStockPoolStore } from '../stockPoolStore';
|
||||
|
||||
vi.mock('../../api/history', () => ({
|
||||
@@ -17,6 +18,7 @@ vi.mock('../../api/analysis', async () => {
|
||||
...actual,
|
||||
analysisApi: {
|
||||
analyzeAsync: vi.fn(),
|
||||
getTasks: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -48,6 +50,33 @@ const historyReport = {
|
||||
},
|
||||
};
|
||||
|
||||
function createTask(overrides: Partial<TaskInfo> = {}): TaskInfo {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
stockCode: '600519',
|
||||
stockName: '贵州茅台',
|
||||
status: 'processing',
|
||||
progress: 50,
|
||||
reportType: 'detailed',
|
||||
createdAt: '2026-03-18T08:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTaskListResponse(
|
||||
tasks: TaskInfo[],
|
||||
counts: Partial<Pick<TaskListResponse, 'pending' | 'processing' | 'total'>> = {},
|
||||
): TaskListResponse {
|
||||
const pending = counts.pending ?? tasks.filter((task) => task.status === 'pending').length;
|
||||
const processing = counts.processing ?? tasks.filter((task) => task.status === 'processing').length;
|
||||
return {
|
||||
total: counts.total ?? tasks.length,
|
||||
pending,
|
||||
processing,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -62,6 +91,7 @@ describe('stockPoolStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useStockPoolStore.getState().resetDashboardState();
|
||||
vi.mocked(analysisApi.getTasks).mockResolvedValue(createTaskListResponse([]));
|
||||
});
|
||||
|
||||
it('loads initial history and auto-selects the first report', async () => {
|
||||
@@ -367,6 +397,126 @@ describe('stockPoolStore', () => {
|
||||
expect(state.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reconciles active tasks from a complete empty backend snapshot without dismissing them', async () => {
|
||||
const staleTask = createTask();
|
||||
useStockPoolStore.getState().syncTaskCreated(staleTask);
|
||||
vi.mocked(analysisApi.getTasks).mockResolvedValue(createTaskListResponse([]));
|
||||
|
||||
await useStockPoolStore.getState().refreshActiveTasks();
|
||||
|
||||
expect(analysisApi.getTasks).toHaveBeenCalledWith({
|
||||
status: 'pending,processing',
|
||||
limit: 100,
|
||||
});
|
||||
expect(useStockPoolStore.getState().activeTasks).toHaveLength(0);
|
||||
|
||||
useStockPoolStore.getState().syncTaskCreated(staleTask);
|
||||
expect(useStockPoolStore.getState().activeTasks).toEqual([staleTask]);
|
||||
});
|
||||
|
||||
it('does not prune tasks created after an active-task refresh request started', async () => {
|
||||
const emptySnapshot = createDeferred<TaskListResponse>();
|
||||
const createdTask = createTask({
|
||||
taskId: 'task-created-after-request',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
});
|
||||
const updatedTask = {
|
||||
...createdTask,
|
||||
status: 'processing' as const,
|
||||
progress: 35,
|
||||
};
|
||||
vi.mocked(analysisApi.getTasks).mockReturnValue(emptySnapshot.promise);
|
||||
|
||||
const refreshPromise = useStockPoolStore.getState().refreshActiveTasks();
|
||||
useStockPoolStore.getState().syncTaskCreated(createdTask);
|
||||
|
||||
emptySnapshot.resolve(createTaskListResponse([]));
|
||||
await refreshPromise;
|
||||
|
||||
expect(useStockPoolStore.getState().activeTasks).toEqual([createdTask]);
|
||||
|
||||
useStockPoolStore.getState().syncTaskUpdated(updatedTask);
|
||||
expect(useStockPoolStore.getState().activeTasks).toEqual([updatedTask]);
|
||||
});
|
||||
|
||||
it('upserts pending and processing tasks from the backend snapshot', async () => {
|
||||
const existingTask = createTask({ taskId: 'task-existing', progress: 30 });
|
||||
const updatedTask = createTask({ taskId: 'task-existing', progress: 80, message: 'LLM 正在生成分析结果' });
|
||||
const newTask = createTask({
|
||||
taskId: 'task-new',
|
||||
stockCode: '000001',
|
||||
stockName: '平安银行',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
});
|
||||
useStockPoolStore.getState().syncTaskCreated(existingTask);
|
||||
vi.mocked(analysisApi.getTasks).mockResolvedValue(
|
||||
createTaskListResponse([updatedTask, newTask]),
|
||||
);
|
||||
|
||||
await useStockPoolStore.getState().refreshActiveTasks();
|
||||
|
||||
expect(useStockPoolStore.getState().activeTasks).toEqual([updatedTask, newTask]);
|
||||
});
|
||||
|
||||
it('does not re-add dismissed tasks from backend reconciliation', async () => {
|
||||
const dismissedTask = createTask();
|
||||
useStockPoolStore.getState().syncTaskCreated(dismissedTask);
|
||||
useStockPoolStore.getState().removeTask(dismissedTask.taskId);
|
||||
vi.mocked(analysisApi.getTasks).mockResolvedValue(
|
||||
createTaskListResponse([dismissedTask]),
|
||||
);
|
||||
|
||||
await useStockPoolStore.getState().refreshActiveTasks();
|
||||
|
||||
expect(useStockPoolStore.getState().activeTasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores late active-task snapshots from older refreshes', async () => {
|
||||
const staleSnapshot = createDeferred<TaskListResponse>();
|
||||
const freshSnapshot = createDeferred<TaskListResponse>();
|
||||
const staleTask = createTask({ taskId: 'task-stale' });
|
||||
const freshTask = createTask({ taskId: 'task-fresh', stockCode: '000001', stockName: '平安银行' });
|
||||
vi.mocked(analysisApi.getTasks)
|
||||
.mockReturnValueOnce(staleSnapshot.promise)
|
||||
.mockReturnValueOnce(freshSnapshot.promise);
|
||||
|
||||
const staleRefresh = useStockPoolStore.getState().refreshActiveTasks();
|
||||
const freshRefresh = useStockPoolStore.getState().refreshActiveTasks();
|
||||
|
||||
freshSnapshot.resolve(createTaskListResponse([freshTask]));
|
||||
await freshRefresh;
|
||||
expect(useStockPoolStore.getState().activeTasks).toEqual([freshTask]);
|
||||
|
||||
staleSnapshot.resolve(createTaskListResponse([staleTask]));
|
||||
await staleRefresh;
|
||||
expect(useStockPoolStore.getState().activeTasks).toEqual([freshTask]);
|
||||
});
|
||||
|
||||
it('does not prune local tasks when the backend active-task snapshot is incomplete', async () => {
|
||||
const localTask = createTask({ taskId: 'task-local' });
|
||||
const remoteTask = createTask({ taskId: 'task-remote', stockCode: '000001', stockName: '平安银行' });
|
||||
useStockPoolStore.getState().syncTaskCreated(localTask);
|
||||
vi.mocked(analysisApi.getTasks).mockResolvedValue(
|
||||
createTaskListResponse([remoteTask], { processing: 2, total: 2 }),
|
||||
);
|
||||
|
||||
await useStockPoolStore.getState().refreshActiveTasks();
|
||||
|
||||
expect(useStockPoolStore.getState().activeTasks).toEqual([localTask, remoteTask]);
|
||||
});
|
||||
|
||||
it('keeps active tasks unchanged when backend reconciliation fails', async () => {
|
||||
const activeTask = createTask();
|
||||
useStockPoolStore.getState().syncTaskCreated(activeTask);
|
||||
vi.mocked(analysisApi.getTasks).mockRejectedValue(new Error('network failed'));
|
||||
|
||||
await useStockPoolStore.getState().refreshActiveTasks();
|
||||
|
||||
expect(useStockPoolStore.getState().activeTasks).toEqual([activeTask]);
|
||||
});
|
||||
|
||||
it('triggers an analysis with the forceRefresh flag', async () => {
|
||||
vi.mocked(analysisApi.analyzeAsync).mockResolvedValue({
|
||||
taskId: 'task-force-1',
|
||||
|
||||
@@ -178,13 +178,21 @@ export const useAgentChatStore = create<AgentChatState & AgentChatActions>((set,
|
||||
if (targetSessionId === sessionId && messages.length > 0) return;
|
||||
|
||||
abortController?.abort();
|
||||
set({ abortController: null });
|
||||
|
||||
set({ messages: [], sessionId: targetSessionId });
|
||||
set({
|
||||
messages: [],
|
||||
sessionId: targetSessionId,
|
||||
loading: false,
|
||||
progressSteps: [],
|
||||
chatError: null,
|
||||
abortController: null,
|
||||
});
|
||||
localStorage.setItem(STORAGE_KEY_SESSION, targetSessionId);
|
||||
|
||||
try {
|
||||
const msgs = await agentApi.getChatSessionMessages(targetSessionId);
|
||||
if (get().sessionId !== targetSessionId) {
|
||||
return;
|
||||
}
|
||||
set({
|
||||
messages: msgs.map((m) => ({
|
||||
id: m.id,
|
||||
|
||||
@@ -30,6 +30,8 @@ type SubmitAnalysisOptions = {
|
||||
let reportRequestSeq = 0;
|
||||
let analyzeRequestSeq = 0;
|
||||
let historyRequestSeq = 0;
|
||||
let activeTaskRequestSeq = 0;
|
||||
let activeTaskLocalRevision = 0;
|
||||
const dismissedTaskIds = new Set<string>();
|
||||
|
||||
export interface StockPoolState {
|
||||
@@ -68,6 +70,7 @@ export interface StockPoolState {
|
||||
syncTaskCreated: (task: TaskInfo) => void;
|
||||
syncTaskUpdated: (task: TaskInfo) => void;
|
||||
syncTaskFailed: (task: TaskInfo) => void;
|
||||
refreshActiveTasks: () => Promise<void>;
|
||||
removeTask: (taskId: string) => void;
|
||||
resetDashboardState: () => void;
|
||||
}
|
||||
@@ -388,6 +391,7 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
|
||||
if (get().activeTasks.some((item) => item.taskId === task.taskId)) {
|
||||
return;
|
||||
}
|
||||
activeTaskLocalRevision += 1;
|
||||
set({ activeTasks: [...get().activeTasks, task] });
|
||||
},
|
||||
|
||||
@@ -399,6 +403,7 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
|
||||
const index = nextTasks.findIndex((item) => item.taskId === task.taskId);
|
||||
if (index >= 0) {
|
||||
nextTasks[index] = task;
|
||||
activeTaskLocalRevision += 1;
|
||||
set({ activeTasks: nextTasks });
|
||||
}
|
||||
},
|
||||
@@ -408,15 +413,66 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
|
||||
set({ error: getParsedApiError(task.error || '分析失败') });
|
||||
},
|
||||
|
||||
refreshActiveTasks: async () => {
|
||||
const requestId = ++activeTaskRequestSeq;
|
||||
const localRevisionAtRequest = activeTaskLocalRevision;
|
||||
try {
|
||||
const response = await analysisApi.getTasks({
|
||||
status: 'pending,processing',
|
||||
limit: 100,
|
||||
});
|
||||
if (requestId !== activeTaskRequestSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteTasks = response.tasks.filter(
|
||||
(task) => !dismissedTaskIds.has(task.taskId),
|
||||
);
|
||||
const remoteTaskIds = new Set(remoteTasks.map((task) => task.taskId));
|
||||
const remoteTaskById = new Map(remoteTasks.map((task) => [task.taskId, task]));
|
||||
const isCompleteSnapshot = response.tasks.length === response.pending + response.processing;
|
||||
const canPruneLocalTasks = isCompleteSnapshot && activeTaskLocalRevision === localRevisionAtRequest;
|
||||
|
||||
const currentTasks = get().activeTasks;
|
||||
const nextTasks = currentTasks
|
||||
.filter((task) => !dismissedTaskIds.has(task.taskId))
|
||||
.filter((task) => !canPruneLocalTasks || remoteTaskIds.has(task.taskId))
|
||||
.map((task) => remoteTaskById.get(task.taskId) ?? task);
|
||||
|
||||
const localTaskIds = new Set(nextTasks.map((task) => task.taskId));
|
||||
for (const task of remoteTasks) {
|
||||
if (!localTaskIds.has(task.taskId)) {
|
||||
nextTasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
const hasActiveTaskChanges = nextTasks.length !== currentTasks.length
|
||||
|| nextTasks.some((task, index) => task !== currentTasks[index]);
|
||||
if (hasActiveTaskChanges) {
|
||||
activeTaskLocalRevision += 1;
|
||||
set({ activeTasks: nextTasks });
|
||||
}
|
||||
} catch {
|
||||
// Keep the current task panel when reconciliation cannot reach the API.
|
||||
}
|
||||
},
|
||||
|
||||
removeTask: (taskId) => {
|
||||
dismissedTaskIds.add(taskId);
|
||||
set({ activeTasks: get().activeTasks.filter((task) => task.taskId !== taskId) });
|
||||
const currentTasks = get().activeTasks;
|
||||
const nextTasks = currentTasks.filter((task) => task.taskId !== taskId);
|
||||
if (nextTasks.length !== currentTasks.length) {
|
||||
activeTaskLocalRevision += 1;
|
||||
}
|
||||
set({ activeTasks: nextTasks });
|
||||
},
|
||||
|
||||
resetDashboardState: () => {
|
||||
historyRequestSeq += 1;
|
||||
reportRequestSeq = 0;
|
||||
analyzeRequestSeq = 0;
|
||||
activeTaskRequestSeq += 1;
|
||||
activeTaskLocalRevision += 1;
|
||||
dismissedTaskIds.clear();
|
||||
set({ ...initialState });
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [文档] 新增 AnalysisContextPack P0 上下文盘点,明确字段质量状态、现有状态映射和首版 pack 边界。
|
||||
- [新功能] 新增 AnalysisContextPack P1 内部契约与脱敏序列化测试。
|
||||
- [修复] 恢复 Agent/历史兼容快照中的关联板块与板块联动字段提取,修复新版首页报告缺少“板块联动”的回归问题。
|
||||
- [修复] 修复问股会话切换和首页任务重连后可能残留 Agent/分析任务进行中状态的问题。
|
||||
- [新功能] 问股新增默认关闭的可见对话上下文压缩,支持 Web 开关、Agent 高级 preset、滚动摘要和最近轮次原文保护,降低长会话 token 消耗。
|
||||
- [改进] P2-min:LLM Prompt 注入市场阶段上下文。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user