Fix switch session (#1054)

This commit is contained in:
paisley
2026-05-21 14:17:34 +08:00
committed by GitHub
parent 3178f96c55
commit 94de6fb08e
6 changed files with 420 additions and 31 deletions

View File

@@ -9,6 +9,7 @@ import { useGatewayStore } from './gateway';
import { useAgentsStore } from './agents'; import { useAgentsStore } from './agents';
import { buildBaselineRunKey, captureBaseline, clearBaselines } from './baseline-cache'; import { buildBaselineRunKey, captureBaseline, clearBaselines } from './baseline-cache';
import { buildCronSessionHistoryPath, isCronSessionKey } from './chat/cron-session-utils'; import { buildCronSessionHistoryPath, isCronSessionKey } from './chat/cron-session-utils';
import { pickStartupSessionFallback } from './chat/session-selection';
import { import {
CHAT_HISTORY_DISK_FALLBACK_TIMEOUT_MS, CHAT_HISTORY_DISK_FALLBACK_TIMEOUT_MS,
CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS, CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS,
@@ -1463,6 +1464,13 @@ function buildSessionSwitchPatch(
nextSessionKey: string, nextSessionKey: string,
): Partial<ChatState> { ): Partial<ChatState> {
captureSessionRunState(state.currentSessionKey, state); 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. // Only treat sessions with no history records and no activity timestamp as empty.
// Relying solely on messages.length is unreliable because switchSession clears // Relying solely on messages.length is unreliable because switchSession clears
// the current messages before loadHistory runs, creating a race condition that // the current messages before loadHistory runs, creating a race condition that
@@ -1970,6 +1978,31 @@ function postUserSegmentMessages(filteredMessages: RawMessage[]): RawMessage[] {
return []; 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 { function segmentHasOpenToolRun(segmentMessages: RawMessage[]): boolean {
if (segmentMessages.length === 0) return false; if (segmentMessages.length === 0) return false;
const hasToolActivity = segmentMessages.some( 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. // default ghost key (`agent:main:main`) should yield to real history.
const hasLocalPendingSession = localSessions.some((session) => session.key === nextSessionKey); const hasLocalPendingSession = localSessions.some((session) => session.key === nextSessionKey);
if (!hasLocalPendingSession) { 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!]), .map((session) => [session.key, session.updatedAt!]),
); );
set((state) => ({ const previousSessionKey = currentSessionKey;
sessions: sessionsWithCurrent, if (previousSessionKey !== nextSessionKey) {
currentSessionKey: nextSessionKey, // Mirror switchSession: stop in-flight history polls and swap cached
currentAgentId: getAgentIdFromSessionKey(nextSessionKey), // history/run state immediately. Without this, a background loadSessions
sessionLastActivity: { // can retarget currentSessionKey (e.g. to a cron heartbeat session)
...state.sessionLastActivity, // while messages[] still holds the prior conversation until
...discoveredActivity, // 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); applySessionBackendLabels(set, sessionsWithCurrent);
// Background: fetch first user message for every non-main session to populate labels upfront. // 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(); void get().loadHistory();
} }
} }
@@ -2455,11 +2509,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
const applyLoadFailure = (errorMessage: string | null) => { const applyLoadFailure = (errorMessage: string | null) => {
if (!isCurrentSession()) return; if (!isCurrentSession()) return;
set((state) => { set((state) => {
const hasMessages = state.messages.length > 0; const mergedMessages = mergePendingOptimisticUserMessages(currentSessionKey, state.messages);
return { return {
loading: false, loading: false,
error: shouldShowForegroundLoading && errorMessage ? errorMessage : state.error, 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; return filteredMessages;
})(); })();
const lastAssistantAfterBoundary = [...postBoundaryMessages].reverse().find((msg) => msg.role === 'assistant'); const lastAssistantAfterBoundary = [...postBoundaryMessages].reverse().find((msg) => msg.role === 'assistant');
const latestTerminalAssistantErrorMessage = lastAssistantAfterBoundary const latestTerminalAssistantErrorMessage = lastAssistantAfterBoundary
&& getMessageStopReason(lastAssistantAfterBoundary) === 'error' && (getMessageStopReason(lastAssistantAfterBoundary) === 'error'
? getMessageErrorMessage(lastAssistantAfterBoundary) || isFailedAssistantTurnMessage(lastAssistantAfterBoundary))
: null; ? (getMessageErrorMessage(lastAssistantAfterBoundary)
?? (isFailedAssistantTurnMessage(lastAssistantAfterBoundary)
? getMessageText(lastAssistantAfterBoundary.content)
: null))
: null;
const historyErrorIsTransient = Boolean( const historyErrorIsTransient = Boolean(
latestTerminalAssistantErrorMessage latestTerminalAssistantErrorMessage
&& isSendingNow && isSendingNow
@@ -2632,11 +2690,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
} }
} }
// After session switch (or cold load) the renderer may have reset run // After session switch the renderer may have reset run lifecycle flags even
// lifecycle flags even though the Gateway is still executing tools. // though the Gateway is still executing a user-initiated turn. Re-arm only
// Re-arm from authoritative history when the latest user turn has tool // when this session had an active cached run (e.g. user switched away
// activity but no final reply yet. // mid-send). Do not re-arm from stale :main heartbeat/tool history alone.
if (!get().sending && !latestTerminalAssistantErrorMessage) { if (!get().sending && !latestTerminalAssistantErrorMessage && hasCachedActiveUserRun(currentSessionKey)) {
const openSegment = postUserSegmentMessages(filteredMessages); const openSegment = postUserSegmentMessages(filteredMessages);
if (segmentHasOpenToolRun(openSegment)) { if (segmentHasOpenToolRun(openSegment)) {
const lastUser = findLastRealUserMessage(filteredMessages); const lastUser = findLastRealUserMessage(filteredMessages);
@@ -2649,6 +2707,20 @@ export const useChatStore = create<ChatState>((set, get) => ({
captureSessionRunState(currentSessionKey, get()); captureSessionRunState(currentSessionKey, get());
} }
} }
if (
get().sending
&& !latestTerminalAssistantErrorMessage
&& !shouldTrackInboundRunLifecycle(get(), currentSessionKey)
) {
clearHistoryPoll();
set({
sending: false,
activeRunId: null,
pendingFinal: false,
lastUserMessageAt: null,
});
}
return true; return true;
}; };
@@ -3107,19 +3179,18 @@ export const useChatStore = create<ChatState>((set, get) => ({
|| resolvedState === 'error' || resolvedState === 'aborted'; || resolvedState === 'error' || resolvedState === 'aborted';
if (hasUsefulData) { if (hasUsefulData) {
clearHistoryPoll(); clearHistoryPoll();
// Adopt run started from another client (e.g. console at 127.0.0.1:18789): // Adopt run started from another client only for user-initiated turns.
// show loading/streaming in the app when this session has an active run. // Background :main heartbeat runs must not surface "Thinking..." in the UI.
const { sending } = get(); const { sending } = get();
if (!sending && runId) { if (!sending && runId && shouldTrackInboundRunLifecycle(get(), currentSessionKey)) {
set({ sending: true, activeRunId: runId, error: null, runError: null }); set({ sending: true, activeRunId: runId, error: null, runError: null });
} }
} }
switch (resolvedState) { switch (resolvedState) {
case 'started': { case 'started': {
// Run just started (e.g. from console); show loading immediately.
const { sending: currentSending } = get(); const { sending: currentSending } = get();
if (!currentSending && runId) { if (!currentSending && runId && shouldTrackInboundRunLifecycle(get(), currentSessionKey)) {
set({ sending: true, activeRunId: runId, error: null, runError: null }); set({ sending: true, activeRunId: runId, error: null, runError: null });
} }
break; break;

View File

@@ -1,5 +1,6 @@
import { invokeIpc } from '@/lib/api-client'; import { invokeIpc } from '@/lib/api-client';
import { clearPendingOptimisticUserMessages, getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers'; 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 { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api'; 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) { if (!dedupedSessions.find((s) => s.key === nextSessionKey) && dedupedSessions.length > 0) {
// Current session not found in the backend list
const isNewEmptySession = get().messages.length === 0; const isNewEmptySession = get().messages.length === 0;
if (!isNewEmptySession) { if (!isNewEmptySession) {
nextSessionKey = dedupedSessions[0].key; const fallbackKey = pickStartupSessionFallback(nextSessionKey, dedupedSessions);
if (fallbackKey) {
nextSessionKey = fallbackKey;
}
} }
} }

View 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;
}

View 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);
});
});

View 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');
});
});

View File

@@ -287,6 +287,85 @@ describe('useChatStore startup history retry', () => {
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['cached history']); 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 () => { it('switchSession restores in-flight run state so Thinking indicator survives navigation', async () => {
const { useChatStore } = await import('@/stores/chat'); const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({ useChatStore.setState({