Fix switch session (#1054)
This commit is contained in:
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