Fix switch session (#1054)
This commit is contained in:
@@ -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,6 +2147,23 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
.map((session) => [session.key, session.updatedAt!]),
|
.map((session) => [session.key, session.updatedAt!]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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) => ({
|
set((state) => ({
|
||||||
sessions: sessionsWithCurrent,
|
sessions: sessionsWithCurrent,
|
||||||
currentSessionKey: nextSessionKey,
|
currentSessionKey: nextSessionKey,
|
||||||
@@ -2120,6 +2173,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
...discoveredActivity,
|
...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[] }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -2517,8 +2571,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
})();
|
})();
|
||||||
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))
|
||||||
|
? (getMessageErrorMessage(lastAssistantAfterBoundary)
|
||||||
|
?? (isFailedAssistantTurnMessage(lastAssistantAfterBoundary)
|
||||||
|
? getMessageText(lastAssistantAfterBoundary.content)
|
||||||
|
: null))
|
||||||
: null;
|
: null;
|
||||||
const historyErrorIsTransient = Boolean(
|
const historyErrorIsTransient = Boolean(
|
||||||
latestTerminalAssistantErrorMessage
|
latestTerminalAssistantErrorMessage
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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']);
|
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({
|
||||||
|
|||||||
Reference in New Issue
Block a user