eigent/test/unit/store/projectStore.test.ts

890 lines
28 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { PROJECT_CACHE_SCHEMA_VERSION } from '@/lib/projectCache';
import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
import {
getProjectEventStore,
resetProjectEventStoresForTests,
} from '@/store/projectEventStore';
import { useProjectStore } from '@/store/projectStore';
import { SPACE_SCHEMA_VERSION, useSpaceStore } from '@/store/spaceStore';
import { normalizeThinkingEffort, ThinkingEffort } from '@/types/constants';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
deleteCachedProjectMock,
fetchGetMock,
getCachedProjectMock,
hasActiveSSEConnectionMock,
putCachedProjectMock,
proxyFetchGetMock,
replayMock,
} = vi.hoisted(() => ({
deleteCachedProjectMock: vi.fn(),
fetchGetMock: vi.fn(),
getCachedProjectMock: vi.fn(),
hasActiveSSEConnectionMock: vi.fn(),
putCachedProjectMock: vi.fn(),
proxyFetchGetMock: vi.fn(),
replayMock: vi.fn(),
}));
vi.mock('@/api/http', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/http')>();
return {
...actual,
fetchGet: fetchGetMock,
proxyFetchGet: proxyFetchGetMock,
};
});
vi.mock('@/lib/projectCache', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/projectCache')>();
return {
...actual,
deleteCachedProject: deleteCachedProjectMock,
getCachedProject: getCachedProjectMock,
putCachedProject: putCachedProjectMock,
};
});
vi.mock('@/service/spaceApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/spaceApi')>();
return {
...actual,
proxyUpdateSpaceProject: vi.fn().mockResolvedValue({}),
};
});
vi.mock('@/store/chatStore', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/store/chatStore')>();
return {
...actual,
createChatStoreInstance: (
...args: Parameters<typeof actual.createChatStoreInstance>
) => {
const store = actual.createChatStoreInstance(...args);
store.setState({ replay: replayMock } as any);
return store;
},
hasActiveSSEConnection: hasActiveSSEConnectionMock,
};
});
describe('projectStore runtime shape', () => {
it('invalidates cache snapshots predating durable approval resolution projection', () => {
expect(PROJECT_CACHE_SCHEMA_VERSION).toBe(7);
});
beforeEach(() => {
vi.clearAllMocks();
resetProjectEventStoresForTests();
deleteCachedProjectMock.mockResolvedValue(undefined);
getCachedProjectMock.mockResolvedValue(null);
putCachedProjectMock.mockResolvedValue(undefined);
hasActiveSSEConnectionMock.mockReturnValue(false);
fetchGetMock.mockResolvedValue({ runs: [] });
proxyFetchGetMock.mockResolvedValue({ tasks: [] });
replayMock.mockResolvedValue(undefined);
useProjectStore.setState({
activeProjectId: null,
projects: {},
navLeadByProjectId: {},
historyLoadingProjectIds: {},
staleProjectIds: new Set(),
});
globalThis.electronAPI = {
...globalThis.electronAPI,
terminalDispose: vi.fn().mockResolvedValue({ success: true }),
};
usePageTabStore.setState({
sessionPreviewProjectId: null,
sessionPreviewByProject: {},
});
useSpaceStore.setState({
activeSpaceId: 'space_test',
spaces: {
space_test: {
id: 'space_test',
name: 'Test Space',
sourceType: 'blank',
status: 'active',
schemaVersion: SPACE_SCHEMA_VERSION,
createdAt: 1,
updatedAt: 1,
},
},
lastVisitedProjectBySpace: {},
projectsBySpaceId: {},
projectIdIndex: {},
projectsSyncedAt: {},
});
});
it('disposes the preview shell when its project is removed', () => {
const projectId = useProjectStore
.getState()
.createProject('Terminal Project', undefined, 'project_terminal');
const pageTabs = usePageTabStore.getState();
pageTabs.setSessionPreviewProject(projectId);
pageTabs.toggleSessionPreview();
pageTabs.choosePreviewTabType(
getSessionPreviewSlice(usePageTabStore.getState()).activeTabId!,
'terminal'
);
const terminal = getSessionPreviewSlice(usePageTabStore.getState()).tabs[0];
const shellId = terminal.type === 'terminal' ? terminal.shellId : undefined;
useProjectStore.getState().removeProject(projectId);
expect(globalThis.electronAPI.terminalDispose).toHaveBeenCalledWith(
shellId
);
expect(
usePageTabStore.getState().sessionPreviewByProject[projectId]
).toBeUndefined();
});
it('preserves the event-store instance when replay overwrites the same Project id', () => {
const projectId = useProjectStore
.getState()
.createProject('Original', undefined, 'project_same_id');
const eventStore = getProjectEventStore(projectId);
const initialIncarnation = eventStore.getIncarnation();
const listener = vi.fn();
eventStore.subscribe(listener);
useProjectStore
.getState()
.replayProject(['task-replay'], 'Replay', projectId);
expect(getProjectEventStore(projectId)).toBe(eventStore);
expect(eventStore.getIncarnation()).toBe(initialIncarnation + 1);
expect(listener).toHaveBeenCalled();
});
it('appends project runs into the same primary chat store', () => {
const projectId = useProjectStore
.getState()
.createProject('Test Project', undefined, 'project_test');
const initialProject = useProjectStore.getState().projects[projectId];
const initialChatId = initialProject.activeChatId;
const firstRun = useProjectStore
.getState()
.appendInitChatStore(projectId, 'task_a');
const secondRun = useProjectStore
.getState()
.appendInitChatStore(projectId, 'task_b');
const project = useProjectStore.getState().projects[projectId];
expect(Object.keys(project.chatStores)).toEqual([initialChatId]);
expect(project.activeChatId).toBe(initialChatId);
expect(firstRun?.chatStore).toBe(secondRun?.chatStore);
const tasks = firstRun?.chatStore.getState().tasks ?? {};
expect(tasks.task_a).toBeDefined();
expect(tasks.task_b).toBeDefined();
expect(firstRun?.chatStore.getState().activeTaskId).toBe('task_b');
});
it('keeps Send now exclusive without changing trigger queue entries', () => {
const projectId = useProjectStore
.getState()
.createProject('Queue Project', undefined, 'project_queue');
const store = useProjectStore.getState();
store.addQueuedMessage(projectId, 'First', [], 'follow-1');
store.addQueuedMessage(projectId, 'Second', [], 'follow-2');
store.addQueuedMessage(
projectId,
'Scheduled',
[],
'trigger-1',
'execution-1'
);
store.prioritizeQueuedMessage(projectId, 'follow-1');
store.prioritizeQueuedMessage(projectId, 'follow-2');
const queued =
useProjectStore.getState().projects[projectId].queuedMessages;
expect(queued.map(({ task_id, sendNow }) => [task_id, sendNow])).toEqual([
['follow-1', false],
['follow-2', true],
['trigger-1', undefined],
]);
});
it('reuses an optimistically seeded follow-up run without erasing it', () => {
const projectId = useProjectStore
.getState()
.createProject('Test Project', undefined, 'project_seeded_followup');
const seeded = useProjectStore
.getState()
.appendInitChatStore(projectId, 'run_followup');
expect(seeded).not.toBeNull();
seeded?.chatStore.getState().addMessages('run_followup', {
id: 'followup-message',
role: 'user',
content: 'Use the CSV format',
});
seeded?.chatStore.getState().setIsPending('run_followup', true);
const confirmed = useProjectStore
.getState()
.appendInitChatStore(projectId, 'run_followup');
expect(confirmed?.taskId).toBe('run_followup');
expect(confirmed?.chatStore).toBe(seeded?.chatStore);
expect(confirmed?.chatStore.getState().tasks.run_followup).toMatchObject({
isPending: true,
messages: [
expect.objectContaining({
id: 'followup-message',
content: 'Use the CSV format',
}),
],
});
expect(
Object.keys(confirmed?.chatStore.getState().tasks ?? {}).filter(
(taskId) => taskId === 'run_followup'
)
).toHaveLength(1);
});
it('stores and returns the per-project model selection', () => {
const projectId = useProjectStore
.getState()
.createProject('Test Project', undefined, 'project_model_test');
expect(useProjectStore.getState().getProjectModel(projectId)).toBeNull();
useProjectStore.getState().setProjectModel(projectId, {
modelType: 'cloud',
cloud_model_type: 'model_a',
model_platform: 'platform_a',
model_type: 'model_a',
});
const selection = useProjectStore.getState().getProjectModel(projectId);
expect(selection).toEqual({
modelType: 'cloud',
cloud_model_type: 'model_a',
model_platform: 'platform_a',
model_type: 'model_a',
});
// The pin must also reach the persisted space meta so it survives an
// app restart (the runtime project store is not persisted).
const meta = useSpaceStore.getState().getProjectMeta(projectId);
expect(meta?.metadata?.modelSelection).toEqual(selection);
});
it('falls back to the space meta when the runtime project is gone', () => {
const projectId = useProjectStore
.getState()
.createProject('Test Project', undefined, 'project_meta_fallback');
useProjectStore.getState().setProjectModel(projectId, {
modelType: 'custom',
provider_id: 7,
model_platform: 'platform_b',
model_type: 'model_b',
});
// Simulate a restart: runtime projects are wiped, space meta persists.
useProjectStore.setState({ activeProjectId: null, projects: {} });
const selection = useProjectStore.getState().getProjectModel(projectId);
expect(selection).toEqual({
modelType: 'custom',
provider_id: 7,
model_platform: 'platform_b',
model_type: 'model_b',
});
});
it('persists the requested thinking effort per project', () => {
const projectId = useProjectStore
.getState()
.createProject('Effort Project', undefined, 'project_effort_test');
expect(useProjectStore.getState().getProjectThinkingEffort(projectId)).toBe(
ThinkingEffort.MEDIUM
);
expect(
useProjectStore.getState().getProjectThinkingEffortOverride(projectId)
).toBeUndefined();
useProjectStore
.getState()
.setProjectThinkingEffort(projectId, ThinkingEffort.MAX);
expect(useProjectStore.getState().getProjectThinkingEffort(projectId)).toBe(
ThinkingEffort.MAX
);
expect(
useProjectStore.getState().getProjectThinkingEffortOverride(projectId)
).toBe(ThinkingEffort.MAX);
expect(
useSpaceStore.getState().getProjectMeta(projectId)?.metadata
?.thinkingEffort
).toBe(ThinkingEffort.MAX);
});
it('normalizes persisted legacy thinking effort aliases', () => {
expect(normalizeThinkingEffort('light')).toBe(ThinkingEffort.LOW);
expect(normalizeThinkingEffort('extra_high')).toBe(ThinkingEffort.XHIGH);
expect(normalizeThinkingEffort('ultra')).toBe(ThinkingEffort.MAX);
expect(normalizeThinkingEffort('unsupported')).toBe(ThinkingEffort.MEDIUM);
});
it('keeps stale project runtime while one of its tasks has an active SSE stream', () => {
const projectId = useProjectStore
.getState()
.createProject('Stale Project', undefined, 'project_stale_live');
useProjectStore.getState().appendInitChatStore(projectId, 'task_live');
useProjectStore.setState({
staleProjectIds: new Set([projectId]),
});
hasActiveSSEConnectionMock.mockImplementation((taskIds: string[]) =>
taskIds.includes('task_live')
);
useProjectStore.getState()._evictStaleOnTransition('project_next');
expect(useProjectStore.getState().projects[projectId]).toBeDefined();
expect(useProjectStore.getState().staleProjectIds.has(projectId)).toBe(
true
);
expect(hasActiveSSEConnectionMock).toHaveBeenCalledWith(
expect.arrayContaining(['task_live'])
);
});
it('evicts a stale project runtime on a later transition after active SSE is gone', () => {
const projectId = useProjectStore
.getState()
.createProject('Stale Project', undefined, 'project_stale_safe');
useProjectStore.getState().appendInitChatStore(projectId, 'task_finished');
useProjectStore.setState({
staleProjectIds: new Set([projectId]),
});
hasActiveSSEConnectionMock.mockReturnValue(false);
useProjectStore.getState()._evictStaleOnTransition('project_next');
expect(useProjectStore.getState().projects[projectId]).toBeUndefined();
expect(useProjectStore.getState().staleProjectIds.has(projectId)).toBe(
false
);
expect(useSpaceStore.getState().getProjectMeta(projectId)).toBeDefined();
expect(hasActiveSSEConnectionMock).toHaveBeenCalledWith(
expect.arrayContaining(['task_finished'])
);
});
it('replays stale cached history during the same project open', async () => {
const { useAuthStore } = await import('@/store/authStore');
const previousUserId = useAuthStore.getState().user_id;
useAuthStore.setState({ user_id: 10 });
try {
getCachedProjectMock.mockResolvedValue({
schemaVersion: 1,
cachedAt: 100,
serverUpdatedAt: 100,
taskIds: ['task_stale'],
tasks: {
task_stale: {
taskState: {
status: 'running',
messages: [],
taskInfo: [],
taskRunning: [],
taskAssigning: [],
},
},
},
});
await useProjectStore
.getState()
.loadProjectFromHistory(
['task_stale'],
'long-running prompt',
'project_stale_cache',
'history_stale',
'Stale cache project',
'space_test',
{ task_stale: 'long-running prompt' },
200
);
expect(deleteCachedProjectMock).toHaveBeenCalledWith({
userId: 10,
projectId: 'project_stale_cache',
});
expect(replayMock).toHaveBeenCalledWith(
'task_stale',
'long-running prompt',
0,
'project_stale_cache'
);
expect(
useProjectStore.getState().staleProjectIds.has('project_stale_cache')
).toBe(false);
} finally {
useAuthStore.setState({ user_id: previousUserId });
}
});
it('replays canonical local Run history when its cache anchor is stale', async () => {
const { useAuthStore } = await import('@/store/authStore');
const previousUserId = useAuthStore.getState().user_id;
useAuthStore.setState({ user_id: 10 });
try {
fetchGetMock.mockResolvedValue({
runs: [
{
run_id: 'task_local',
status: 'completed',
created_at: 100,
updated_at: 300,
total_attempt_elapsed_ms: 613_328,
},
],
});
replayMock.mockImplementationOnce(async (taskId: string) => {
const project = useProjectStore.getState().projects.project_local;
const chatStore = project.chatStores[project.activeChatId];
chatStore.getState().create(taskId, 'replay');
});
getCachedProjectMock.mockResolvedValue({
schemaVersion: 2,
cachedAt: 200,
serverUpdatedAt: 200,
localCanonicalUpdatedAt: 250,
taskIds: ['task_local'],
tasks: {
task_local: {
taskState: {
status: 'running',
messages: [],
taskInfo: [],
taskRunning: [],
taskAssigning: [],
},
},
},
});
await useProjectStore
.getState()
.loadProjectFromHistory(
['task_local'],
'long-running prompt',
'project_local',
'history_local',
'Local canonical project',
'space_test',
{ task_local: 'long-running prompt' },
200
);
expect(fetchGetMock).toHaveBeenCalledWith('/runs', {
project_id: 'project_local',
limit: 100,
});
expect(getCachedProjectMock).toHaveBeenCalledWith({
userId: 10,
projectId: 'project_local',
});
expect(deleteCachedProjectMock).toHaveBeenCalledWith({
userId: 10,
projectId: 'project_local',
});
expect(replayMock).toHaveBeenCalledWith(
'task_local',
'long-running prompt',
0,
'project_local',
'local_durable'
);
const project = useProjectStore.getState().projects.project_local;
const task =
project.chatStores[project.activeChatId].getState().tasks.task_local;
expect(task.elapsed).toBe(613_328);
expect(task.durableRunStatus).toBe('completed');
expect(putCachedProjectMock).toHaveBeenCalledWith(
{ userId: 10, projectId: 'project_local' },
expect.objectContaining({
serverUpdatedAt: 200,
localCanonicalUpdatedAt: 300,
taskIds: ['task_local'],
})
);
} finally {
useAuthStore.setState({ user_id: previousUserId });
}
});
it('projects waiting_for_user before an attached durable replay stream settles', async () => {
fetchGetMock.mockResolvedValue({
runs: [
{
run_id: 'task_waiting_approval',
status: 'waiting_for_user',
created_at: 100,
updated_at: 300,
total_attempt_elapsed_ms: null,
},
],
});
let releaseReplay: (() => void) | undefined;
replayMock.mockImplementationOnce((taskId: string) => {
const project = useProjectStore.getState().projects.project_waiting;
const chatStore = project.chatStores[project.activeChatId];
chatStore.getState().create(taskId, 'replay');
chatStore.getState().setStatus(taskId, 'finished');
return new Promise<void>((resolve) => {
releaseReplay = resolve;
});
});
const loadPromise = useProjectStore
.getState()
.loadProjectFromHistory(
['task_waiting_approval'],
'approval prompt',
'project_waiting',
'history_waiting',
'Waiting project',
'space_test',
{ task_waiting_approval: 'approval prompt' },
200
);
await vi.waitFor(() => expect(releaseReplay).toBeDefined());
const project = useProjectStore.getState().projects.project_waiting;
const task =
project.chatStores[project.activeChatId].getState().tasks
.task_waiting_approval;
expect(task.type).toBe('replay');
expect(task.status).toBe('finished');
expect(task.durableRunStatus).toBe('waiting_for_user');
releaseReplay?.();
await loadPromise;
});
it('projects cloud-restored duration and interrupted status without attempt rows', async () => {
fetchGetMock.mockResolvedValue({
runs: [
{
run_id: 'task_cloud_interrupted',
status: 'interrupted',
origin: 'cloud_restore',
created_at: 1_786_101_992.187,
updated_at: 1_786_102_022.109,
total_attempt_elapsed_ms: null,
},
],
});
replayMock.mockImplementationOnce(async (taskId: string) => {
const project = useProjectStore.getState().projects.project_cloud;
const chatStore = project.chatStores[project.activeChatId];
chatStore.getState().create(taskId, 'replay');
chatStore.getState().setStatus(taskId, 'finished');
});
await useProjectStore
.getState()
.loadProjectFromHistory(
['task_cloud_interrupted'],
'cloud prompt',
'project_cloud',
'history_cloud',
'Cloud history',
'space_test',
{ task_cloud_interrupted: 'cloud prompt' },
200
);
const project = useProjectStore.getState().projects.project_cloud;
const task =
project.chatStores[project.activeChatId].getState().tasks
.task_cloud_interrupted;
expect(task.elapsed).toBeCloseTo(29_922, 0);
expect(task.durableRunStatus).toBe('interrupted');
});
it('hydrates a canonical local snapshot when its SQLite anchor matches', async () => {
const { useAuthStore } = await import('@/store/authStore');
const previousUserId = useAuthStore.getState().user_id;
useAuthStore.setState({ user_id: 10 });
try {
fetchGetMock.mockResolvedValue({
runs: [
{
run_id: 'task_cached_local',
status: 'completed',
updated_at: 300,
total_attempt_elapsed_ms: 613_328,
},
],
});
getCachedProjectMock.mockResolvedValue({
schemaVersion: 2,
cachedAt: 400,
serverUpdatedAt: 200,
localCanonicalUpdatedAt: 300,
taskIds: ['task_cached_local'],
tasks: {
task_cached_local: {
taskState: {
status: 'finished',
elapsed: 613_328,
taskTime: 0,
hasMessages: true,
messages: [
{ id: 'user-1', role: 'user', content: 'cached prompt' },
],
taskInfo: [],
taskRunning: [],
taskAssigning: [],
},
},
},
});
await useProjectStore
.getState()
.loadProjectFromHistory(
['task_cached_local'],
'cached prompt',
'project_cached_local',
'history_cached_local',
'Cached local project',
'space_test',
{ task_cached_local: 'cached prompt' },
200
);
expect(deleteCachedProjectMock).not.toHaveBeenCalled();
expect(replayMock).not.toHaveBeenCalled();
const project = useProjectStore.getState().projects.project_cached_local;
const task =
project.chatStores[project.activeChatId].getState().tasks
.task_cached_local;
expect(task.elapsed).toBe(613_328);
expect(task.messages).toHaveLength(1);
} finally {
useAuthStore.setState({ user_id: previousUserId });
}
});
it('repairs zero duration in a current cache from the canonical local Run', async () => {
const { useAuthStore } = await import('@/store/authStore');
const previousUserId = useAuthStore.getState().user_id;
useAuthStore.setState({ user_id: 10 });
try {
fetchGetMock.mockResolvedValue({
runs: [
{
run_id: 'task_cached_zero_duration',
status: 'completed',
created_at: 100,
updated_at: 300,
total_attempt_elapsed_ms: 570_577,
},
],
});
getCachedProjectMock.mockResolvedValue({
schemaVersion: 5,
cachedAt: 400,
serverUpdatedAt: 200,
localCanonicalUpdatedAt: 300,
taskIds: ['task_cached_zero_duration'],
tasks: {
task_cached_zero_duration: {
taskState: {
status: 'finished',
elapsed: 0,
taskTime: 123,
messages: [],
taskInfo: [],
taskRunning: [],
taskAssigning: [],
},
},
},
});
await useProjectStore
.getState()
.loadProjectFromHistory(
['task_cached_zero_duration'],
'cached prompt',
'project_cached_zero_duration',
'history_cached_zero_duration',
'Cached zero-duration project',
'space_test',
{ task_cached_zero_duration: 'cached prompt' },
200
);
expect(deleteCachedProjectMock).not.toHaveBeenCalled();
expect(replayMock).not.toHaveBeenCalled();
const project =
useProjectStore.getState().projects.project_cached_zero_duration;
const task =
project.chatStores[project.activeChatId].getState().tasks
.task_cached_zero_duration;
expect(task.elapsed).toBe(570_577);
expect(task.taskTime).toBe(0);
expect(task.durableRunStatus).toBe('completed');
expect(putCachedProjectMock).toHaveBeenCalledWith(
{ userId: 10, projectId: 'project_cached_zero_duration' },
expect.objectContaining({
serverUpdatedAt: 200,
localCanonicalUpdatedAt: 300,
tasks: expect.objectContaining({
task_cached_zero_duration: {
taskState: expect.objectContaining({
elapsed: 570_577,
taskTime: 0,
durableRunStatus: 'completed',
}),
},
}),
})
);
} finally {
useAuthStore.setState({ user_id: previousUserId });
}
});
it('merges missing history into a background remote Project without stealing focus', async () => {
const activeProjectId = useProjectStore
.getState()
.createProject('Active Project', undefined, 'project_active');
const remoteProjectId = useProjectStore
.getState()
.createProject(
'Remote Project',
undefined,
'project_remote',
undefined,
'history_remote',
false,
{
spaceId: 'space_test',
metadata: { remoteHistoryHydrationPending: true },
}
);
const remoteRun = useProjectStore
.getState()
.appendInitChatStore(remoteProjectId, 'task_remote');
const chatStore = remoteRun?.chatStore;
expect(chatStore).toBeDefined();
chatStore?.getState().addMessages('task_remote', {
id: 'msg_remote',
role: 'user',
content: 'remote prompt',
});
const replay = vi.fn(async (taskId: string, question: string) => {
const state = chatStore!.getState();
state.create(taskId, 'replay');
state.addMessages(taskId, {
id: `msg_${taskId}`,
role: 'user',
content: question,
});
state.setActiveTaskId(taskId);
});
chatStore?.setState({ replay } as any);
await useProjectStore.getState().mergeProjectHistory(
remoteProjectId,
[
{ task_id: 'task_old', question: 'old prompt' },
{ task_id: 'task_remote', question: 'remote prompt' },
],
'fallback prompt'
);
expect(useProjectStore.getState().activeProjectId).toBe(activeProjectId);
expect(replay).toHaveBeenCalledTimes(1);
expect(replay).toHaveBeenCalledWith(
'task_old',
'old prompt',
0,
remoteProjectId
);
expect(chatStore?.getState().tasks.task_old).toBeDefined();
expect(chatStore?.getState().tasks.task_remote).toBeDefined();
expect(Object.keys(chatStore?.getState().tasks ?? {}).slice(0, 2)).toEqual([
'task_old',
'task_remote',
]);
expect(chatStore?.getState().activeTaskId).toBe('task_remote');
expect(
useProjectStore.getState().projects[remoteProjectId].metadata
?.remoteHistoryHydrationPending
).toBe(false);
});
it('does not start a second remote history merge while one is already loading', async () => {
const remoteProjectId = useProjectStore
.getState()
.createProject(
'Remote Project',
undefined,
'project_remote_loading',
undefined,
'history_remote',
false,
{
spaceId: 'space_test',
metadata: { remoteHistoryHydrationPending: true },
}
);
const chatStore = useProjectStore.getState().getChatStore(remoteProjectId);
const replay = vi.fn(async () => undefined);
chatStore?.setState({ replay } as any);
useProjectStore.getState().setHistoryLoadingProject(remoteProjectId, true);
await useProjectStore
.getState()
.mergeProjectHistory(
remoteProjectId,
[{ task_id: 'task_old', question: 'old prompt' }],
'fallback prompt'
);
expect(replay).not.toHaveBeenCalled();
expect(
useProjectStore.getState().projects[remoteProjectId].metadata
?.remoteHistoryHydrationPending
).toBe(true);
expect(
useProjectStore.getState().historyLoadingProjectIds[remoteProjectId]
).toBe(true);
});
});