diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 9c455c9..033e0fc 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -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 { 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, + 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((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((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((set, get) => ({ })(); } - if (currentSessionKey !== nextSessionKey) { + if (previousSessionKey !== nextSessionKey) { void get().loadHistory(); } } @@ -2455,11 +2509,11 @@ export const useChatStore = create((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((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((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((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((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; diff --git a/src/stores/chat/session-actions.ts b/src/stores/chat/session-actions.ts index 0f04bea..c9b5081 100644 --- a/src/stores/chat/session-actions.ts +++ b/src/stores/chat/session-actions.ts @@ -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; + } } } diff --git a/src/stores/chat/session-selection.ts b/src/stores/chat/session-selection.ts new file mode 100644 index 0000000..346e723 --- /dev/null +++ b/src/stores/chat/session-selection.ts @@ -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; +} diff --git a/tests/unit/chat-load-sessions-startup.test.ts b/tests/unit/chat-load-sessions-startup.test.ts new file mode 100644 index 0000000..872ee83 --- /dev/null +++ b/tests/unit/chat-load-sessions-startup.test.ts @@ -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); + }); +}); diff --git a/tests/unit/chat-session-selection.test.ts b/tests/unit/chat-session-selection.test.ts new file mode 100644 index 0000000..33fe431 --- /dev/null +++ b/tests/unit/chat-session-selection.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/chat-store-history-retry.test.ts b/tests/unit/chat-store-history-retry.test.ts index da14603..6df544f 100644 --- a/tests/unit/chat-store-history-retry.test.ts +++ b/tests/unit/chat-store-history-retry.test.ts @@ -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({