fix(chat): force history sync after internal NO_REPLY final (#919)
This commit is contained in:
@@ -60,6 +60,7 @@ let _loadSessionsInFlight: Promise<void> | null = null;
|
|||||||
let _lastLoadSessionsAt = 0;
|
let _lastLoadSessionsAt = 0;
|
||||||
const _historyLoadInFlight = new Map<string, Promise<void>>();
|
const _historyLoadInFlight = new Map<string, Promise<void>>();
|
||||||
const _lastHistoryLoadAtBySession = new Map<string, number>();
|
const _lastHistoryLoadAtBySession = new Map<string, number>();
|
||||||
|
const _forceNextHistoryLoadBySession = new Set<string>();
|
||||||
const _foregroundHistoryLoadSeen = new Set<string>();
|
const _foregroundHistoryLoadSeen = new Set<string>();
|
||||||
const SESSION_LOAD_MIN_INTERVAL_MS = 1_200;
|
const SESSION_LOAD_MIN_INTERVAL_MS = 1_200;
|
||||||
const HISTORY_LOAD_MIN_INTERVAL_MS = 800;
|
const HISTORY_LOAD_MIN_INTERVAL_MS = 800;
|
||||||
@@ -81,6 +82,10 @@ function clearHistoryPoll(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forceNextHistoryLoad(sessionKey: string): void {
|
||||||
|
_forceNextHistoryLoadBySession.add(sessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
function pruneChatEventDedupe(now: number): void {
|
function pruneChatEventDedupe(now: number): void {
|
||||||
for (const [key, ts] of _chatEventDedupe.entries()) {
|
for (const [key, ts] of _chatEventDedupe.entries()) {
|
||||||
if (now - ts > CHAT_EVENT_DEDUPE_TTL_MS) {
|
if (now - ts > CHAT_EVENT_DEDUPE_TTL_MS) {
|
||||||
@@ -1523,14 +1528,20 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const { currentSessionKey } = get();
|
const { currentSessionKey } = get();
|
||||||
const isInitialForegroundLoad = !quiet && !_foregroundHistoryLoadSeen.has(currentSessionKey);
|
const isInitialForegroundLoad = !quiet && !_foregroundHistoryLoadSeen.has(currentSessionKey);
|
||||||
const historyTimeoutOverride = getStartupHistoryTimeoutOverride(isInitialForegroundLoad);
|
const historyTimeoutOverride = getStartupHistoryTimeoutOverride(isInitialForegroundLoad);
|
||||||
|
const forceLoad = _forceNextHistoryLoadBySession.delete(currentSessionKey);
|
||||||
const existingLoad = _historyLoadInFlight.get(currentSessionKey);
|
const existingLoad = _historyLoadInFlight.get(currentSessionKey);
|
||||||
if (existingLoad) {
|
if (existingLoad) {
|
||||||
await existingLoad;
|
await existingLoad;
|
||||||
return;
|
if (!forceLoad) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (get().currentSessionKey !== currentSessionKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastLoadAt = _lastHistoryLoadAtBySession.get(currentSessionKey) || 0;
|
const lastLoadAt = _lastHistoryLoadAtBySession.get(currentSessionKey) || 0;
|
||||||
if (quiet && Date.now() - lastLoadAt < HISTORY_LOAD_MIN_INTERVAL_MS) {
|
if (!forceLoad && quiet && Date.now() - lastLoadAt < HISTORY_LOAD_MIN_INTERVAL_MS) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2087,6 +2098,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
// briefly in the UI until loadHistory replaces the message list — and if the
|
// briefly in the UI until loadHistory replaces the message list — and if the
|
||||||
// quiet-mode reload is debounced away, the token can stay visible permanently.
|
// quiet-mode reload is debounced away, the token can stay visible permanently.
|
||||||
if (isInternalMessage(normalizedFinalMessage)) {
|
if (isInternalMessage(normalizedFinalMessage)) {
|
||||||
|
const sessionKeyForReload = get().currentSessionKey;
|
||||||
set({
|
set({
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
streamingMessage: null,
|
streamingMessage: null,
|
||||||
@@ -2097,6 +2109,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
pendingToolImages: [],
|
pendingToolImages: [],
|
||||||
});
|
});
|
||||||
clearHistoryPoll();
|
clearHistoryPoll();
|
||||||
|
forceNextHistoryLoad(sessionKeyForReload);
|
||||||
void get().loadHistory(true);
|
void get().loadHistory(true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,66 @@ describe('useChatStore startup history retry', () => {
|
|||||||
setTimeoutSpy.mockRestore();
|
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 () => {
|
it('keeps non-startup foreground loading safety timeout at 15 seconds', async () => {
|
||||||
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||||||
const { useChatStore } = await import('@/stores/chat');
|
const { useChatStore } = await import('@/stores/chat');
|
||||||
|
|||||||
Reference in New Issue
Block a user