Fix switch session (#1054)
This commit is contained in:
@@ -9,6 +9,7 @@ import { useGatewayStore } from './gateway';
|
||||
import { useAgentsStore } from './agents';
|
||||
import { buildBaselineRunKey, captureBaseline, clearBaselines } from './baseline-cache';
|
||||
import { buildCronSessionHistoryPath, isCronSessionKey } from './chat/cron-session-utils';
|
||||
import { pickStartupSessionFallback } from './chat/session-selection';
|
||||
import {
|
||||
CHAT_HISTORY_DISK_FALLBACK_TIMEOUT_MS,
|
||||
CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS,
|
||||
@@ -1463,6 +1464,13 @@ function buildSessionSwitchPatch(
|
||||
nextSessionKey: string,
|
||||
): Partial<ChatState> {
|
||||
captureSessionRunState(state.currentSessionKey, state);
|
||||
if (state.messages.length > 0) {
|
||||
cacheSessionHistory(
|
||||
state.currentSessionKey,
|
||||
cloneHistoryMessages(state.messages),
|
||||
state.thinkingLevel ?? null,
|
||||
);
|
||||
}
|
||||
// Only treat sessions with no history records and no activity timestamp as empty.
|
||||
// Relying solely on messages.length is unreliable because switchSession clears
|
||||
// the current messages before loadHistory runs, creating a race condition that
|
||||
@@ -1970,6 +1978,31 @@ function postUserSegmentMessages(filteredMessages: RawMessage[]): RawMessage[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Only treat inbound runs as user-visible for this long after the last user send. */
|
||||
const USER_INITIATED_RUN_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
|
||||
function hasCachedActiveUserRun(sessionKey: string): boolean {
|
||||
const cached = getCachedSessionRunState(sessionKey);
|
||||
return cached.sending || cached.activeRunId != null || cached.pendingFinal;
|
||||
}
|
||||
|
||||
function shouldTrackInboundRunLifecycle(
|
||||
state: Pick<ChatState, 'lastUserMessageAt' | 'sending' | 'activeRunId' | 'pendingFinal'>,
|
||||
sessionKey?: string,
|
||||
): boolean {
|
||||
if (state.sending || state.activeRunId != null || state.pendingFinal) return true;
|
||||
if (sessionKey && hasCachedActiveUserRun(sessionKey)) return true;
|
||||
if (!state.lastUserMessageAt) return false;
|
||||
return Date.now() - toMs(state.lastUserMessageAt) <= USER_INITIATED_RUN_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
function isFailedAssistantTurnMessage(message: RawMessage | unknown): boolean {
|
||||
if (!message || typeof message !== 'object') return false;
|
||||
const msg = message as RawMessage;
|
||||
if (msg.role !== 'assistant') return false;
|
||||
return /\[assistant turn failed/i.test(getMessageText(msg.content));
|
||||
}
|
||||
|
||||
function segmentHasOpenToolRun(segmentMessages: RawMessage[]): boolean {
|
||||
if (segmentMessages.length === 0) return false;
|
||||
const hasToolActivity = segmentMessages.some(
|
||||
@@ -2094,7 +2127,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// default ghost key (`agent:main:main`) should yield to real history.
|
||||
const hasLocalPendingSession = localSessions.some((session) => session.key === nextSessionKey);
|
||||
if (!hasLocalPendingSession) {
|
||||
nextSessionKey = dedupedSessions[0].key;
|
||||
const fallbackKey = pickStartupSessionFallback(nextSessionKey, dedupedSessions);
|
||||
if (fallbackKey) {
|
||||
nextSessionKey = fallbackKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2111,15 +2147,33 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
.map((session) => [session.key, session.updatedAt!]),
|
||||
);
|
||||
|
||||
set((state) => ({
|
||||
sessions: sessionsWithCurrent,
|
||||
currentSessionKey: nextSessionKey,
|
||||
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
||||
sessionLastActivity: {
|
||||
...state.sessionLastActivity,
|
||||
...discoveredActivity,
|
||||
},
|
||||
}));
|
||||
const previousSessionKey = currentSessionKey;
|
||||
if (previousSessionKey !== nextSessionKey) {
|
||||
// Mirror switchSession: stop in-flight history polls and swap cached
|
||||
// history/run state immediately. Without this, a background loadSessions
|
||||
// can retarget currentSessionKey (e.g. to a cron heartbeat session)
|
||||
// while messages[] still holds the prior conversation until
|
||||
// chat.history returns — which looks like cross-session contamination.
|
||||
clearHistoryPoll();
|
||||
set((state) => ({
|
||||
...buildSessionSwitchPatch(state, nextSessionKey),
|
||||
sessions: sessionsWithCurrent,
|
||||
sessionLastActivity: {
|
||||
...state.sessionLastActivity,
|
||||
...discoveredActivity,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
set((state) => ({
|
||||
sessions: sessionsWithCurrent,
|
||||
currentSessionKey: nextSessionKey,
|
||||
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
||||
sessionLastActivity: {
|
||||
...state.sessionLastActivity,
|
||||
...discoveredActivity,
|
||||
},
|
||||
}));
|
||||
}
|
||||
applySessionBackendLabels(set, sessionsWithCurrent);
|
||||
|
||||
// Background: fetch first user message for every non-main session to populate labels upfront.
|
||||
@@ -2181,7 +2235,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
})();
|
||||
}
|
||||
|
||||
if (currentSessionKey !== nextSessionKey) {
|
||||
if (previousSessionKey !== nextSessionKey) {
|
||||
void get().loadHistory();
|
||||
}
|
||||
}
|
||||
@@ -2455,11 +2509,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const applyLoadFailure = (errorMessage: string | null) => {
|
||||
if (!isCurrentSession()) return;
|
||||
set((state) => {
|
||||
const hasMessages = state.messages.length > 0;
|
||||
const mergedMessages = mergePendingOptimisticUserMessages(currentSessionKey, state.messages);
|
||||
return {
|
||||
loading: false,
|
||||
error: shouldShowForegroundLoading && errorMessage ? errorMessage : state.error,
|
||||
...(hasMessages ? {} : { messages: [] as RawMessage[] }),
|
||||
...(mergedMessages.length > 0 ? { messages: mergedMessages } : { messages: [] as RawMessage[] }),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -2515,11 +2569,15 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
}
|
||||
return filteredMessages;
|
||||
})();
|
||||
const lastAssistantAfterBoundary = [...postBoundaryMessages].reverse().find((msg) => msg.role === 'assistant');
|
||||
const latestTerminalAssistantErrorMessage = lastAssistantAfterBoundary
|
||||
&& getMessageStopReason(lastAssistantAfterBoundary) === 'error'
|
||||
? getMessageErrorMessage(lastAssistantAfterBoundary)
|
||||
: null;
|
||||
const lastAssistantAfterBoundary = [...postBoundaryMessages].reverse().find((msg) => msg.role === 'assistant');
|
||||
const latestTerminalAssistantErrorMessage = lastAssistantAfterBoundary
|
||||
&& (getMessageStopReason(lastAssistantAfterBoundary) === 'error'
|
||||
|| isFailedAssistantTurnMessage(lastAssistantAfterBoundary))
|
||||
? (getMessageErrorMessage(lastAssistantAfterBoundary)
|
||||
?? (isFailedAssistantTurnMessage(lastAssistantAfterBoundary)
|
||||
? getMessageText(lastAssistantAfterBoundary.content)
|
||||
: null))
|
||||
: null;
|
||||
const historyErrorIsTransient = Boolean(
|
||||
latestTerminalAssistantErrorMessage
|
||||
&& isSendingNow
|
||||
@@ -2632,11 +2690,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
|
||||
// After session switch (or cold load) the renderer may have reset run
|
||||
// lifecycle flags even though the Gateway is still executing tools.
|
||||
// Re-arm from authoritative history when the latest user turn has tool
|
||||
// activity but no final reply yet.
|
||||
if (!get().sending && !latestTerminalAssistantErrorMessage) {
|
||||
// After session switch the renderer may have reset run lifecycle flags even
|
||||
// though the Gateway is still executing a user-initiated turn. Re-arm only
|
||||
// when this session had an active cached run (e.g. user switched away
|
||||
// mid-send). Do not re-arm from stale :main heartbeat/tool history alone.
|
||||
if (!get().sending && !latestTerminalAssistantErrorMessage && hasCachedActiveUserRun(currentSessionKey)) {
|
||||
const openSegment = postUserSegmentMessages(filteredMessages);
|
||||
if (segmentHasOpenToolRun(openSegment)) {
|
||||
const lastUser = findLastRealUserMessage(filteredMessages);
|
||||
@@ -2649,6 +2707,20 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
captureSessionRunState(currentSessionKey, get());
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
get().sending
|
||||
&& !latestTerminalAssistantErrorMessage
|
||||
&& !shouldTrackInboundRunLifecycle(get(), currentSessionKey)
|
||||
) {
|
||||
clearHistoryPoll();
|
||||
set({
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -3107,19 +3179,18 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|| resolvedState === 'error' || resolvedState === 'aborted';
|
||||
if (hasUsefulData) {
|
||||
clearHistoryPoll();
|
||||
// Adopt run started from another client (e.g. console at 127.0.0.1:18789):
|
||||
// show loading/streaming in the app when this session has an active run.
|
||||
// Adopt run started from another client only for user-initiated turns.
|
||||
// Background :main heartbeat runs must not surface "Thinking..." in the UI.
|
||||
const { sending } = get();
|
||||
if (!sending && runId) {
|
||||
set({ sending: true, activeRunId: runId, error: null, runError: null });
|
||||
if (!sending && runId && shouldTrackInboundRunLifecycle(get(), currentSessionKey)) {
|
||||
set({ sending: true, activeRunId: runId, error: null, runError: null });
|
||||
}
|
||||
}
|
||||
|
||||
switch (resolvedState) {
|
||||
case 'started': {
|
||||
// Run just started (e.g. from console); show loading immediately.
|
||||
const { sending: currentSending } = get();
|
||||
if (!currentSending && runId) {
|
||||
if (!currentSending && runId && shouldTrackInboundRunLifecycle(get(), currentSessionKey)) {
|
||||
set({ sending: true, activeRunId: runId, error: null, runError: null });
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { clearPendingOptimisticUserMessages, getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
|
||||
import { pickStartupSessionFallback } from './session-selection';
|
||||
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
|
||||
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
||||
|
||||
@@ -115,10 +116,12 @@ export function createSessionActions(
|
||||
}
|
||||
}
|
||||
if (!dedupedSessions.find((s) => s.key === nextSessionKey) && dedupedSessions.length > 0) {
|
||||
// Current session not found in the backend list
|
||||
const isNewEmptySession = get().messages.length === 0;
|
||||
if (!isNewEmptySession) {
|
||||
nextSessionKey = dedupedSessions[0].key;
|
||||
const fallbackKey = pickStartupSessionFallback(nextSessionKey, dedupedSessions);
|
||||
if (fallbackKey) {
|
||||
nextSessionKey = fallbackKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
src/stores/chat/session-selection.ts
Normal file
39
src/stores/chat/session-selection.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { isCronSessionKey } from './cron-session-utils';
|
||||
import type { ChatSession } from './types';
|
||||
|
||||
function getAgentIdFromSessionKey(sessionKey: string): string {
|
||||
if (!sessionKey.startsWith('agent:')) return 'main';
|
||||
const [, agentId] = sessionKey.split(':');
|
||||
return agentId || 'main';
|
||||
}
|
||||
|
||||
function sortByUpdatedAtDesc(sessions: ChatSession[]): ChatSession[] {
|
||||
return [...sessions].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* When the current session key is missing from `sessions.list`, pick a safer
|
||||
* replacement than `sessions[0]`. Cron/heartbeat sessions must never become
|
||||
* the implicit startup target just because they sort first in the gateway list.
|
||||
*/
|
||||
export function pickStartupSessionFallback(
|
||||
currentSessionKey: string,
|
||||
sessions: ChatSession[],
|
||||
): string | null {
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
const agentId = getAgentIdFromSessionKey(currentSessionKey);
|
||||
const agentMainKey = `agent:${agentId}:main`;
|
||||
const agentMain = sessions.find((session) => session.key === agentMainKey);
|
||||
if (agentMain) return agentMain.key;
|
||||
|
||||
const agentNonCron = sortByUpdatedAtDesc(
|
||||
sessions.filter((session) => session.key.startsWith(`agent:${agentId}:`) && !isCronSessionKey(session.key)),
|
||||
);
|
||||
if (agentNonCron.length > 0) return agentNonCron[0]!.key;
|
||||
|
||||
const nonCron = sortByUpdatedAtDesc(sessions.filter((session) => !isCronSessionKey(session.key)));
|
||||
if (nonCron.length > 0) return nonCron[0]!.key;
|
||||
|
||||
return null;
|
||||
}
|
||||
156
tests/unit/chat-load-sessions-startup.test.ts
Normal file
156
tests/unit/chat-load-sessions-startup.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const runtimeStatus = {
|
||||
state: 'running',
|
||||
port: 18789,
|
||||
connectedAt: 0,
|
||||
};
|
||||
|
||||
const { gatewayRpcMock } = vi.hoisted(() => ({
|
||||
gatewayRpcMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/gateway', () => ({
|
||||
useGatewayStore: {
|
||||
getState: () => ({
|
||||
status: runtimeStatus,
|
||||
rpc: gatewayRpcMock,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/agents', () => ({
|
||||
useAgentsStore: {
|
||||
getState: () => ({ agents: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: vi.fn().mockResolvedValue({ success: true, summaries: [] }),
|
||||
}));
|
||||
|
||||
describe('chat store loadSessions startup selection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
gatewayRpcMock.mockReset();
|
||||
runtimeStatus.connectedAt = Date.now();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens the latest non-cron session instead of a cron heartbeat session', async () => {
|
||||
gatewayRpcMock.mockImplementation(async (method: string) => {
|
||||
if (method === 'sessions.list') {
|
||||
return {
|
||||
sessions: [
|
||||
{
|
||||
key: 'agent:main:cron:heartbeat',
|
||||
label: 'Main Agent heartbeat',
|
||||
updatedAt: 9_000,
|
||||
},
|
||||
{
|
||||
key: 'agent:main:session-a',
|
||||
displayName: 'PDF summary',
|
||||
updatedAt: 5_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === 'chat.history') {
|
||||
return { messages: [] };
|
||||
}
|
||||
throw new Error(`Unexpected gateway RPC: ${method}`);
|
||||
});
|
||||
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessions: [],
|
||||
messages: [],
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
});
|
||||
|
||||
await useChatStore.getState().loadSessions();
|
||||
|
||||
expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-a');
|
||||
});
|
||||
|
||||
it('clears the prior conversation when loadSessions retargets to another session', async () => {
|
||||
gatewayRpcMock.mockImplementation(async (method: string) => {
|
||||
if (method === 'sessions.list') {
|
||||
return {
|
||||
sessions: [
|
||||
{
|
||||
key: 'agent:main:cron:heartbeat',
|
||||
label: 'Main Agent heartbeat',
|
||||
updatedAt: 9_000,
|
||||
},
|
||||
{
|
||||
key: 'agent:main:session-b',
|
||||
displayName: 'Other chat',
|
||||
updatedAt: 5_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === 'chat.history') {
|
||||
return { messages: [] };
|
||||
}
|
||||
throw new Error(`Unexpected gateway RPC: ${method}`);
|
||||
});
|
||||
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessions: [],
|
||||
messages: [{ role: 'user', content: 'question from another chat' }],
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
});
|
||||
|
||||
await useChatStore.getState().loadSessions();
|
||||
|
||||
expect(useChatStore.getState().currentSessionKey).toBe('agent:main:session-b');
|
||||
expect(useChatStore.getState().messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the default main ghost session when only cron sessions exist', async () => {
|
||||
gatewayRpcMock.mockImplementation(async (method: string) => {
|
||||
if (method === 'sessions.list') {
|
||||
return {
|
||||
sessions: [
|
||||
{
|
||||
key: 'agent:main:cron:heartbeat',
|
||||
label: 'Main Agent heartbeat',
|
||||
updatedAt: 9_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === 'chat.history') {
|
||||
return { messages: [] };
|
||||
}
|
||||
throw new Error(`Unexpected gateway RPC: ${method}`);
|
||||
});
|
||||
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessions: [],
|
||||
messages: [],
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
});
|
||||
|
||||
await useChatStore.getState().loadSessions();
|
||||
|
||||
expect(useChatStore.getState().currentSessionKey).toBe('agent:main:main');
|
||||
expect(useChatStore.getState().sessions.some((session) => session.key === 'agent:main:main')).toBe(true);
|
||||
});
|
||||
});
|
||||
41
tests/unit/chat-session-selection.test.ts
Normal file
41
tests/unit/chat-session-selection.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pickStartupSessionFallback } from '@/stores/chat/session-selection';
|
||||
import type { ChatSession } from '@/stores/chat/types';
|
||||
|
||||
describe('pickStartupSessionFallback', () => {
|
||||
it('prefers the agent main session when present', () => {
|
||||
const sessions: ChatSession[] = [
|
||||
{ key: 'agent:main:cron:heartbeat', label: 'heartbeat', updatedAt: 9_000 },
|
||||
{ key: 'agent:main:main', displayName: 'Main', updatedAt: 1_000 },
|
||||
];
|
||||
|
||||
expect(pickStartupSessionFallback('agent:main:main', sessions)).toBe('agent:main:main');
|
||||
});
|
||||
|
||||
it('prefers the most recently updated non-cron session for the agent', () => {
|
||||
const sessions: ChatSession[] = [
|
||||
{ key: 'agent:main:cron:heartbeat', label: 'heartbeat', updatedAt: 9_000 },
|
||||
{ key: 'agent:main:session-old', updatedAt: 2_000 },
|
||||
{ key: 'agent:main:session-new', updatedAt: 5_000 },
|
||||
];
|
||||
|
||||
expect(pickStartupSessionFallback('agent:main:main', sessions)).toBe('agent:main:session-new');
|
||||
});
|
||||
|
||||
it('does not auto-select cron sessions when only cron entries exist', () => {
|
||||
const sessions: ChatSession[] = [
|
||||
{ key: 'agent:main:cron:heartbeat', label: 'heartbeat', updatedAt: 9_000 },
|
||||
];
|
||||
|
||||
expect(pickStartupSessionFallback('agent:main:main', sessions)).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to non-cron sessions from other agents before cron', () => {
|
||||
const sessions: ChatSession[] = [
|
||||
{ key: 'agent:main:cron:heartbeat', updatedAt: 9_000 },
|
||||
{ key: 'agent:research:desk', updatedAt: 3_000 },
|
||||
];
|
||||
|
||||
expect(pickStartupSessionFallback('agent:main:main', sessions)).toBe('agent:research:desk');
|
||||
});
|
||||
});
|
||||
@@ -287,6 +287,85 @@ describe('useChatStore startup history retry', () => {
|
||||
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['cached history']);
|
||||
});
|
||||
|
||||
it('does not re-arm Thinking state for stale main-session heartbeat tool history', async () => {
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
|
||||
gatewayRpcMock.mockResolvedValue({
|
||||
messages: [
|
||||
{ id: 'user-old', role: 'user', content: 'old question', timestamp: 1_000 },
|
||||
{
|
||||
id: 'assistant-tool',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'toolCall', id: 'tool-1', name: 'read', arguments: { path: '~/.openclaw/workspace/HEARTBEAT.md' } }],
|
||||
stopReason: 'toolUse',
|
||||
timestamp: 1_100,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessions: [{ key: 'agent:main:main' }],
|
||||
messages: [],
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
pendingToolImages: [],
|
||||
error: null,
|
||||
loading: false,
|
||||
thinkingLevel: null,
|
||||
});
|
||||
|
||||
await useChatStore.getState().loadHistory(false);
|
||||
|
||||
expect(useChatStore.getState().sending).toBe(false);
|
||||
expect(useChatStore.getState().activeRunId).toBeNull();
|
||||
});
|
||||
|
||||
it('switchSession preserves unsynced optimistic user messages when switching back', async () => {
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
const optimisticHello = {
|
||||
id: 'user-hello-opt',
|
||||
role: 'user' as const,
|
||||
content: '你好',
|
||||
timestamp: 1_700_000_000,
|
||||
};
|
||||
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:session-a',
|
||||
currentAgentId: 'main',
|
||||
sessions: [{ key: 'agent:main:session-a' }, { key: 'agent:main:other' }],
|
||||
messages: [optimisticHello],
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: { 'agent:main:session-a': Date.now() },
|
||||
sending: true,
|
||||
activeRunId: 'run-hello',
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: Date.now(),
|
||||
pendingToolImages: [],
|
||||
error: null,
|
||||
loading: false,
|
||||
thinkingLevel: null,
|
||||
});
|
||||
|
||||
gatewayRpcMock.mockResolvedValue({ messages: [] });
|
||||
|
||||
useChatStore.getState().switchSession('agent:main:other');
|
||||
useChatStore.getState().switchSession('agent:main:session-a');
|
||||
|
||||
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['你好']);
|
||||
});
|
||||
|
||||
it('switchSession restores in-flight run state so Thinking indicator survives navigation', async () => {
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
|
||||
Reference in New Issue
Block a user