1165 lines
38 KiB
TypeScript
1165 lines
38 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const { gatewayRpcMock, agentsState, hostApiFetchMock } = vi.hoisted(() => ({
|
|
gatewayRpcMock: vi.fn(),
|
|
agentsState: {
|
|
agents: [] as Array<Record<string, unknown>>,
|
|
},
|
|
hostApiFetchMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/stores/gateway', () => ({
|
|
useGatewayStore: {
|
|
getState: () => ({
|
|
status: { state: 'running', port: 18789, connectedAt: Date.now() },
|
|
rpc: gatewayRpcMock,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/stores/agents', () => ({
|
|
useAgentsStore: {
|
|
getState: () => agentsState,
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/lib/host-api', () => ({
|
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
|
}));
|
|
|
|
describe('useChatStore startup history retry', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.useFakeTimers();
|
|
window.localStorage.clear();
|
|
agentsState.agents = [];
|
|
gatewayRpcMock.mockReset();
|
|
hostApiFetchMock.mockReset();
|
|
hostApiFetchMock.mockResolvedValue({ messages: [] });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('uses the longer timeout only for the initial foreground history load', async () => {
|
|
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
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,
|
|
});
|
|
|
|
gatewayRpcMock
|
|
.mockResolvedValueOnce({
|
|
messages: [{ role: 'assistant', content: 'first load', timestamp: 1000 }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
messages: [{ role: 'assistant', content: 'quiet refresh', timestamp: 1001 }],
|
|
});
|
|
|
|
await useChatStore.getState().loadHistory(false);
|
|
vi.advanceTimersByTime(1_000);
|
|
await useChatStore.getState().loadHistory(true);
|
|
|
|
expect(gatewayRpcMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'chat.history',
|
|
{ sessionKey: 'agent:main:main', limit: 200 },
|
|
35_000,
|
|
);
|
|
expect(gatewayRpcMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'chat.history',
|
|
{ sessionKey: 'agent:main:main', limit: 200 },
|
|
undefined,
|
|
);
|
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 191_800);
|
|
setTimeoutSpy.mockRestore();
|
|
});
|
|
|
|
it('forces the internal final-message reload through the quiet history cooldown', async () => {
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
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,
|
|
});
|
|
|
|
gatewayRpcMock
|
|
.mockResolvedValueOnce({
|
|
messages: [{ role: 'user', content: 'hello', id: 'u1', timestamp: 1000 }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
messages: [
|
|
{ role: 'user', content: 'hello', id: 'u1', timestamp: 1000 },
|
|
{ role: 'assistant', content: 'Real answer', id: 'a2', timestamp: 1001 },
|
|
],
|
|
});
|
|
|
|
await useChatStore.getState().loadHistory(true);
|
|
useChatStore.setState({
|
|
sending: true,
|
|
activeRunId: 'run-internal',
|
|
streamingText: 'NO_REPLY',
|
|
streamingMessage: { role: 'assistant', content: 'NO_REPLY' },
|
|
});
|
|
|
|
useChatStore.getState().handleChatEvent({
|
|
state: 'final',
|
|
runId: 'run-internal',
|
|
sessionKey: 'agent:main:main',
|
|
message: { role: 'assistant', content: 'NO_REPLY', id: 'a1' },
|
|
});
|
|
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(gatewayRpcMock).toHaveBeenCalledTimes(2);
|
|
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([
|
|
'hello',
|
|
'Real answer',
|
|
]);
|
|
expect(useChatStore.getState().sending).toBe(false);
|
|
expect(useChatStore.getState().activeRunId).toBeNull();
|
|
});
|
|
|
|
it('keeps non-startup foreground loading safety timeout at 15 seconds', async () => {
|
|
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
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,
|
|
});
|
|
|
|
gatewayRpcMock
|
|
.mockResolvedValueOnce({
|
|
messages: [{ role: 'assistant', content: 'first load', timestamp: 1000 }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
messages: [{ role: 'assistant', content: 'second foreground load', timestamp: 1001 }],
|
|
});
|
|
|
|
await useChatStore.getState().loadHistory(false);
|
|
setTimeoutSpy.mockClear();
|
|
await useChatStore.getState().loadHistory(false);
|
|
|
|
expect(gatewayRpcMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'chat.history',
|
|
{ sessionKey: 'agent:main:main', limit: 200 },
|
|
undefined,
|
|
);
|
|
expect(setTimeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 15_000);
|
|
setTimeoutSpy.mockRestore();
|
|
});
|
|
|
|
it('keeps cached session messages visible without foreground loading overlay during refresh', async () => {
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:main',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }],
|
|
messages: [],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: false,
|
|
activeRunId: null,
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: false,
|
|
lastUserMessageAt: null,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
gatewayRpcMock
|
|
.mockResolvedValueOnce({
|
|
messages: [{ role: 'assistant', content: 'cached history', timestamp: 1000 }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
messages: [{ role: 'assistant', content: 'main history', timestamp: 1001 }],
|
|
});
|
|
|
|
useChatStore.setState({ currentSessionKey: 'agent:main:other' });
|
|
await useChatStore.getState().loadHistory(false);
|
|
|
|
gatewayRpcMock.mockImplementationOnce(() => new Promise((resolve) => {
|
|
setTimeout(() => {
|
|
resolve({ messages: [{ role: 'assistant', content: 'refreshed cached history', timestamp: 1002 }] });
|
|
}, 10);
|
|
}));
|
|
|
|
useChatStore.getState().switchSession('agent:main:other');
|
|
|
|
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['cached history']);
|
|
expect(useChatStore.getState().loading).toBe(false);
|
|
});
|
|
|
|
it('switchSession restores cached session messages immediately while refreshing in background', async () => {
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:main',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }],
|
|
messages: [],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: false,
|
|
activeRunId: null,
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: false,
|
|
lastUserMessageAt: null,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
gatewayRpcMock
|
|
.mockResolvedValueOnce({
|
|
messages: [{ role: 'assistant', content: 'cached history', timestamp: 1000 }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
messages: [{ role: 'assistant', content: 'main history', timestamp: 1001 }],
|
|
});
|
|
|
|
useChatStore.setState({ currentSessionKey: 'agent:main:other' });
|
|
await useChatStore.getState().loadHistory(false);
|
|
|
|
gatewayRpcMock.mockResolvedValueOnce({
|
|
messages: [{ role: 'assistant', content: 'refreshed cached history', timestamp: 1002 }],
|
|
});
|
|
|
|
useChatStore.getState().switchSession('agent:main:other');
|
|
|
|
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({
|
|
currentSessionKey: 'agent:main:session-run',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:session-run' }, { key: 'agent:main:other' }],
|
|
messages: [
|
|
{ id: 'user-run', role: 'user', content: 'browse the page', timestamp: 1000 },
|
|
{
|
|
id: 'assistant-tool-run',
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'toolCall', id: 'tool-run', name: 'browser', input: { action: 'snapshot' } },
|
|
],
|
|
stopReason: 'toolUse',
|
|
timestamp: 1500,
|
|
},
|
|
],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: true,
|
|
activeRunId: 'run-nav-test',
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: true,
|
|
lastUserMessageAt: 1000,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
useChatStore.getState().switchSession('agent:main:other');
|
|
expect(useChatStore.getState().sending).toBe(false);
|
|
|
|
useChatStore.getState().switchSession('agent:main:session-run');
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.sending).toBe(true);
|
|
expect(state.activeRunId).toBe('run-nav-test');
|
|
expect(state.pendingFinal).toBe(true);
|
|
expect(state.lastUserMessageAt).toBe(1000);
|
|
});
|
|
|
|
it('treats the same session as a fresh foreground load after gateway runtime changes', async () => {
|
|
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
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,
|
|
});
|
|
|
|
gatewayRpcMock
|
|
.mockResolvedValueOnce({
|
|
messages: [{ role: 'assistant', content: 'first runtime', timestamp: 1000 }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
messages: [{ role: 'assistant', content: 'second runtime', timestamp: 1001 }],
|
|
});
|
|
|
|
await useChatStore.getState().loadHistory(false);
|
|
|
|
vi.resetModules();
|
|
vi.doMock('@/stores/gateway', () => ({
|
|
useGatewayStore: {
|
|
getState: () => ({
|
|
status: { state: 'running', port: 18789, connectedAt: Date.now() + 5_000 },
|
|
rpc: gatewayRpcMock,
|
|
}),
|
|
},
|
|
}));
|
|
const { useChatStore: useChatStoreReloaded } = await import('@/stores/chat');
|
|
useChatStoreReloaded.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,
|
|
});
|
|
|
|
setTimeoutSpy.mockClear();
|
|
await useChatStoreReloaded.getState().loadHistory(false);
|
|
|
|
expect(gatewayRpcMock).toHaveBeenLastCalledWith(
|
|
'chat.history',
|
|
{ sessionKey: 'agent:main:main', limit: 200 },
|
|
35_000,
|
|
);
|
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 191_800);
|
|
setTimeoutSpy.mockRestore();
|
|
});
|
|
|
|
it('does not burn the first-load retry path when the first attempt becomes stale', async () => {
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:main',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }],
|
|
messages: [],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: false,
|
|
activeRunId: null,
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: false,
|
|
lastUserMessageAt: null,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
let resolveFirstAttempt: ((value: { messages: Array<{ role: string; content: string; timestamp: number }> }) => void) | null = null;
|
|
gatewayRpcMock
|
|
.mockImplementationOnce(() => new Promise((resolve) => {
|
|
resolveFirstAttempt = resolve;
|
|
}))
|
|
.mockRejectedValueOnce(new Error('RPC timeout: chat.history'))
|
|
.mockResolvedValueOnce({
|
|
messages: [{ role: 'assistant', content: 'restored after retry', timestamp: 1002 }],
|
|
});
|
|
|
|
const firstLoad = useChatStore.getState().loadHistory(false);
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:other',
|
|
messages: [{ role: 'assistant', content: 'other session', timestamp: 1001 }],
|
|
});
|
|
resolveFirstAttempt?.({
|
|
messages: [{ role: 'assistant', content: 'stale original payload', timestamp: 1000 }],
|
|
});
|
|
await firstLoad;
|
|
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:main',
|
|
messages: [],
|
|
});
|
|
const secondLoad = useChatStore.getState().loadHistory(false);
|
|
await vi.runAllTimersAsync();
|
|
await secondLoad;
|
|
|
|
expect(gatewayRpcMock).toHaveBeenCalledTimes(3);
|
|
expect(gatewayRpcMock.mock.calls[0]).toEqual([
|
|
'chat.history',
|
|
{ sessionKey: 'agent:main:main', limit: 200 },
|
|
35_000,
|
|
]);
|
|
expect(gatewayRpcMock.mock.calls[1]).toEqual([
|
|
'chat.history',
|
|
{ sessionKey: 'agent:main:main', limit: 200 },
|
|
35_000,
|
|
]);
|
|
expect(gatewayRpcMock.mock.calls[2]).toEqual([
|
|
'chat.history',
|
|
{ sessionKey: 'agent:main:main', limit: 200 },
|
|
35_000,
|
|
]);
|
|
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['restored after retry']);
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'[chat.history] startup retry scheduled',
|
|
expect.objectContaining({
|
|
sessionKey: 'agent:main:main',
|
|
attempt: 1,
|
|
}),
|
|
);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('stops retrying once the user switches sessions mid-load', async () => {
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:main',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }],
|
|
messages: [],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: false,
|
|
activeRunId: null,
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: false,
|
|
lastUserMessageAt: null,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
gatewayRpcMock.mockImplementationOnce(async () => {
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:other',
|
|
messages: [{ role: 'assistant', content: 'other session', timestamp: 1001 }],
|
|
loading: false,
|
|
});
|
|
throw new Error('RPC timeout: chat.history');
|
|
});
|
|
|
|
await useChatStore.getState().loadHistory(false);
|
|
|
|
expect(gatewayRpcMock).toHaveBeenCalledTimes(1);
|
|
expect(useChatStore.getState().currentSessionKey).toBe('agent:main:other');
|
|
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['other session']);
|
|
expect(useChatStore.getState().error).toBeNull();
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('keeps the optimistic user message when completion refresh wins the transcript write race', async () => {
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
let historyMessages: Array<Record<string, unknown>> = [];
|
|
let resolveSend: ((value: { runId: string }) => void) | null = null;
|
|
|
|
gatewayRpcMock.mockImplementation((method: string) => {
|
|
if (method === 'chat.send') {
|
|
return new Promise((resolve) => {
|
|
resolveSend = resolve as (value: { runId: string }) => void;
|
|
});
|
|
}
|
|
if (method === 'chat.history') {
|
|
return Promise.resolve({ messages: historyMessages });
|
|
}
|
|
return Promise.resolve({});
|
|
});
|
|
|
|
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,
|
|
});
|
|
|
|
const sendPromise = useChatStore.getState().sendMessage('hello from app');
|
|
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['hello from app']);
|
|
|
|
// Simulate Gateway phase=end clearing send state before chat.history has
|
|
// persisted the user turn.
|
|
useChatStore.setState({
|
|
sending: false,
|
|
activeRunId: null,
|
|
pendingFinal: false,
|
|
lastUserMessageAt: null,
|
|
});
|
|
|
|
await useChatStore.getState().loadHistory(true);
|
|
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['hello from app']);
|
|
|
|
historyMessages = [{
|
|
role: 'user',
|
|
content: 'hello from app',
|
|
timestamp: Date.now() / 1000,
|
|
id: 'server-user',
|
|
}];
|
|
vi.advanceTimersByTime(1_000);
|
|
await useChatStore.getState().loadHistory(true);
|
|
|
|
expect(useChatStore.getState().messages).toHaveLength(1);
|
|
expect(useChatStore.getState().messages[0]).toMatchObject({
|
|
id: 'server-user',
|
|
role: 'user',
|
|
content: 'hello from app',
|
|
});
|
|
|
|
resolveSend?.({ runId: 'run-1' });
|
|
await sendPromise;
|
|
});
|
|
|
|
it('does not restore a pending optimistic message after deleting the session', async () => {
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
let resolveSend: ((value: { runId: string }) => void) | null = null;
|
|
|
|
gatewayRpcMock.mockImplementation((method: string) => {
|
|
if (method === 'chat.send') {
|
|
return new Promise((resolve) => {
|
|
resolveSend = resolve as (value: { runId: string }) => void;
|
|
});
|
|
}
|
|
if (method === 'chat.history') {
|
|
return Promise.resolve({ messages: [] });
|
|
}
|
|
return Promise.resolve({});
|
|
});
|
|
hostApiFetchMock.mockImplementation((url: string) => {
|
|
if (url === '/api/sessions/delete') {
|
|
return Promise.resolve({ success: true });
|
|
}
|
|
return Promise.resolve({ messages: [] });
|
|
});
|
|
|
|
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,
|
|
});
|
|
|
|
const sendPromise = useChatStore.getState().sendMessage('message that will be deleted');
|
|
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([
|
|
'message that will be deleted',
|
|
]);
|
|
|
|
await useChatStore.getState().deleteSession('agent:main:main');
|
|
expect(useChatStore.getState().messages).toEqual([]);
|
|
|
|
await useChatStore.getState().loadHistory(true);
|
|
expect(useChatStore.getState().messages).toEqual([]);
|
|
|
|
resolveSend?.({ runId: 'run-deleted' });
|
|
await sendPromise;
|
|
});
|
|
|
|
// Regression for the "thinking disappears mid-tool-chain" bug:
|
|
// when the history-poll loads an intermediate `[thinking, toolCall]` assistant
|
|
// message (stop_reason=tool_use) the closer half of applyLoadedMessages used
|
|
// to match it as a "final reply" — because `hasNonToolAssistantContent` once
|
|
// counted thinking blocks as user-visible content — and clear sending /
|
|
// activeRunId / pendingFinal. That caused the Execution Graph card to flip to
|
|
// inactive, the Thinking… dot to vanish, and ChatInput's stop button to
|
|
// revert to a send button while the agent was still running tools.
|
|
it('keeps the run open across intermediate [thinking, toolCall] history snapshots', async () => {
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:session-1',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:session-1' }],
|
|
messages: [
|
|
{ id: 'user-1', role: 'user', content: '帮我查一下昆明未来七天的天气', timestamp: 1000 },
|
|
],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: true,
|
|
activeRunId: 'run-keep-open',
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: false,
|
|
lastUserMessageAt: 1000,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
gatewayRpcMock.mockResolvedValueOnce({
|
|
messages: [
|
|
{ id: 'user-1', role: 'user', content: '帮我查一下昆明未来七天的天气', timestamp: 1000 },
|
|
{
|
|
id: 'assistant-tool-1',
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'thinking', thinking: 'Let me search for the weather.' },
|
|
{ type: 'toolCall', id: 'tool-1', name: 'web_search', input: { query: 'Kunming weather' } },
|
|
],
|
|
stopReason: 'toolUse',
|
|
timestamp: 1500,
|
|
},
|
|
],
|
|
});
|
|
|
|
await useChatStore.getState().loadHistory(true);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.sending).toBe(true);
|
|
expect(state.activeRunId).toBe('run-keep-open');
|
|
expect(state.pendingFinal).toBe(false);
|
|
});
|
|
|
|
// Regression for the mixed `[thinking, text, toolCall]` shape some models
|
|
// emit. Even though it carries user-visible text, stop_reason=tool_use means
|
|
// the assistant is still pending a tool result and the lifecycle must stay
|
|
// armed. Without `hasPendingToolUse`, the closer would match this on the
|
|
// text block and clear sending.
|
|
it('keeps the run open for mixed [thinking, text, toolCall] turns with stopReason=toolUse', async () => {
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:session-2',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:session-2' }],
|
|
messages: [
|
|
{ id: 'user-2', role: 'user', content: 'mixed turn test', timestamp: 2000 },
|
|
],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: true,
|
|
activeRunId: 'run-mixed',
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: true,
|
|
lastUserMessageAt: 2000,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
gatewayRpcMock.mockResolvedValueOnce({
|
|
messages: [
|
|
{ id: 'user-2', role: 'user', content: 'mixed turn test', timestamp: 2000 },
|
|
{
|
|
id: 'assistant-mixed-1',
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'thinking', thinking: 'I should search before answering.' },
|
|
{ type: 'text', text: 'Let me search for that.' },
|
|
{ type: 'toolCall', id: 'tool-2', name: 'web_search', input: { query: 'foo' } },
|
|
],
|
|
stopReason: 'toolUse',
|
|
timestamp: 2500,
|
|
},
|
|
],
|
|
});
|
|
|
|
await useChatStore.getState().loadHistory(true);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.sending).toBe(true);
|
|
expect(state.activeRunId).toBe('run-mixed');
|
|
expect(state.pendingFinal).toBe(true);
|
|
});
|
|
|
|
// Positive case: a real final reply (text/image, no pending tool) SHOULD
|
|
// close the run when applyLoadedMessages observes it via history poll.
|
|
it('closes the run when a final assistant reply (text, stopReason=endTurn) appears', async () => {
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:session-3',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:session-3' }],
|
|
messages: [
|
|
{ id: 'user-3', role: 'user', content: 'final reply test', timestamp: 3000 },
|
|
],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: true,
|
|
activeRunId: 'run-final',
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: true,
|
|
lastUserMessageAt: 3000,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
gatewayRpcMock.mockResolvedValueOnce({
|
|
messages: [
|
|
{ id: 'user-3', role: 'user', content: 'final reply test', timestamp: 3000 },
|
|
{
|
|
id: 'assistant-final-1',
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'thinking', thinking: 'I have all the info.' },
|
|
{ type: 'text', text: 'Here is the answer.' },
|
|
],
|
|
stopReason: 'endTurn',
|
|
timestamp: 3500,
|
|
},
|
|
],
|
|
});
|
|
|
|
await useChatStore.getState().loadHistory(true);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.sending).toBe(false);
|
|
expect(state.activeRunId).toBeNull();
|
|
expect(state.pendingFinal).toBe(false);
|
|
});
|
|
|
|
// Cross-protocol coverage: Anthropic Messages API native shape (snake_case).
|
|
// OpenClaw's gateway normally normalizes to camelCase, but some paths can
|
|
// pass Anthropic responses through unchanged. `hasPendingToolUse` must still
|
|
// detect the intermediate turn via `stop_reason: "tool_use"` plus
|
|
// `content[].type === "tool_use"`.
|
|
it('keeps the run open for Anthropic-native [thinking, tool_use] (snake_case stop_reason)', async () => {
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:session-anthropic',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:session-anthropic' }],
|
|
messages: [
|
|
{ id: 'user-a', role: 'user', content: 'anthropic protocol', timestamp: 4000 },
|
|
],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: true,
|
|
activeRunId: 'run-anthropic',
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: false,
|
|
lastUserMessageAt: 4000,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
gatewayRpcMock.mockResolvedValueOnce({
|
|
messages: [
|
|
{ id: 'user-a', role: 'user', content: 'anthropic protocol', timestamp: 4000 },
|
|
{
|
|
id: 'assistant-anthropic-1',
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'thinking', thinking: 'Should I use a tool?' },
|
|
{ type: 'tool_use', id: 'toolu_01', name: 'web_search', input: { query: 'foo' } },
|
|
],
|
|
stop_reason: 'tool_use',
|
|
timestamp: 4500,
|
|
},
|
|
],
|
|
});
|
|
|
|
await useChatStore.getState().loadHistory(true);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.sending).toBe(true);
|
|
expect(state.activeRunId).toBe('run-anthropic');
|
|
});
|
|
|
|
// Cross-protocol coverage: OpenAI Chat Completions native shape. The
|
|
// tool-call signal is the top-level `tool_calls` array on the message, with
|
|
// no `stop_reason` / `stopReason` field (OpenAI uses `finish_reason` at the
|
|
// choice level which doesn't reach the message object). `hasPendingToolUse`
|
|
// must still flag this via the `tool_calls` array check.
|
|
it('keeps the run open for OpenAI ChatCompletions message with tool_calls array', async () => {
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:session-openai-cc',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:session-openai-cc' }],
|
|
messages: [
|
|
{ id: 'user-occ', role: 'user', content: 'openai chat completions', timestamp: 5000 },
|
|
],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: true,
|
|
activeRunId: 'run-openai-cc',
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: false,
|
|
lastUserMessageAt: 5000,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
gatewayRpcMock.mockResolvedValueOnce({
|
|
messages: [
|
|
{ id: 'user-occ', role: 'user', content: 'openai chat completions', timestamp: 5000 },
|
|
{
|
|
id: 'assistant-openai-cc-1',
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [
|
|
{
|
|
id: 'call_abc123',
|
|
type: 'function',
|
|
function: { name: 'web_search', arguments: '{"query":"foo"}' },
|
|
},
|
|
],
|
|
timestamp: 5500,
|
|
},
|
|
],
|
|
});
|
|
|
|
await useChatStore.getState().loadHistory(true);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.sending).toBe(true);
|
|
expect(state.activeRunId).toBe('run-openai-cc');
|
|
});
|
|
|
|
// Cross-protocol coverage: OpenAI Chat Completions FINAL reply. No
|
|
// tool_calls, plain text content. Must close the run normally.
|
|
it('closes the run for OpenAI ChatCompletions plain-text final reply', async () => {
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:session-openai-cc-final',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:session-openai-cc-final' }],
|
|
messages: [
|
|
{ id: 'user-occf', role: 'user', content: 'openai final', timestamp: 6000 },
|
|
],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: true,
|
|
activeRunId: 'run-openai-cc-final',
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: true,
|
|
lastUserMessageAt: 6000,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
gatewayRpcMock.mockResolvedValueOnce({
|
|
messages: [
|
|
{ id: 'user-occf', role: 'user', content: 'openai final', timestamp: 6000 },
|
|
{
|
|
id: 'assistant-openai-cc-final-1',
|
|
role: 'assistant',
|
|
content: 'Here is the final answer.',
|
|
timestamp: 6500,
|
|
},
|
|
],
|
|
});
|
|
|
|
await useChatStore.getState().loadHistory(true);
|
|
|
|
const state = useChatStore.getState();
|
|
expect(state.sending).toBe(false);
|
|
expect(state.activeRunId).toBeNull();
|
|
});
|
|
|
|
// Regression: the 90s safety timeout used to fire "No response received"
|
|
// while the model was still working via tool chains. Gateway streaming can be
|
|
// absent (WS drop, long tool execution) but chat.history still surfaces
|
|
// intermediate assistant turns — those must count as progress.
|
|
it('clears a stale no-response error when history poll shows tool progress', async () => {
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:session-stuck',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:session-stuck' }],
|
|
messages: [{ id: 'user-stuck', role: 'user', content: 'weather check' }],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: true,
|
|
activeRunId: 'run-stuck-test',
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: false,
|
|
lastUserMessageAt: Date.now(),
|
|
pendingToolImages: [],
|
|
error: 'No response received from the model. The provider may be unavailable or the API key may have insufficient quota. Please check your provider settings.',
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
gatewayRpcMock.mockResolvedValueOnce({
|
|
messages: [
|
|
{ id: 'user-stuck', role: 'user', content: 'weather check', timestamp: 1000 },
|
|
{
|
|
id: 'assistant-tool-stuck',
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'thinking', thinking: 'Searching...' },
|
|
{ type: 'toolCall', id: 'tool-stuck', name: 'web_search', input: { q: 'weather' } },
|
|
],
|
|
stopReason: 'toolUse',
|
|
timestamp: 1500,
|
|
},
|
|
],
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
await useChatStore.getState().loadHistory(true);
|
|
|
|
expect(useChatStore.getState().error).toBeNull();
|
|
expect(useChatStore.getState().sending).toBe(true);
|
|
expect(useChatStore.getState().messages.some((msg) => msg.role === 'assistant')).toBe(true);
|
|
});
|
|
|
|
it('does not emit a false no-response error when history poll shows tool progress', async () => {
|
|
let resolveSend: ((value: { runId: string }) => void) | undefined;
|
|
gatewayRpcMock.mockImplementation((method: string) => {
|
|
if (method === 'chat.send') {
|
|
return new Promise((resolve) => {
|
|
resolveSend = resolve;
|
|
});
|
|
}
|
|
if (method === 'chat.history') {
|
|
return {
|
|
messages: [
|
|
{ id: 'user-stuck', role: 'user', content: 'weather check', timestamp: 1000 },
|
|
{
|
|
id: 'assistant-tool-stuck',
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'thinking', thinking: 'Searching...' },
|
|
{ type: 'toolCall', id: 'tool-stuck', name: 'web_search', input: { q: 'weather' } },
|
|
],
|
|
stopReason: 'toolUse',
|
|
timestamp: 1500,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
return { messages: [] };
|
|
});
|
|
|
|
const { useChatStore } = await import('@/stores/chat');
|
|
useChatStore.setState({
|
|
currentSessionKey: 'agent:main:session-stuck',
|
|
currentAgentId: 'main',
|
|
sessions: [{ key: 'agent:main:session-stuck' }],
|
|
messages: [],
|
|
sessionLabels: {},
|
|
sessionLastActivity: {},
|
|
sending: false,
|
|
activeRunId: null,
|
|
streamingText: '',
|
|
streamingMessage: null,
|
|
streamingTools: [],
|
|
pendingFinal: false,
|
|
lastUserMessageAt: null,
|
|
pendingToolImages: [],
|
|
error: null,
|
|
loading: false,
|
|
thinkingLevel: null,
|
|
});
|
|
|
|
const sendPromise = useChatStore.getState().sendMessage('weather check');
|
|
|
|
await vi.advanceTimersByTimeAsync(7_000);
|
|
await useChatStore.getState().loadHistory(true);
|
|
await vi.advanceTimersByTimeAsync(100_000);
|
|
|
|
expect(useChatStore.getState().error).toBeNull();
|
|
expect(useChatStore.getState().sending).toBe(true);
|
|
expect(useChatStore.getState().messages.some((msg) => msg.role === 'assistant')).toBe(true);
|
|
|
|
resolveSend?.({ runId: 'run-stuck-test' });
|
|
await sendPromise;
|
|
});
|
|
});
|