From d9f42930d76e1556d3513eab92a0a0080ed87471 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Wed, 20 May 2026 10:11:14 +0800 Subject: [PATCH] Fix false "no response" error during tool-call chains when history poll shows progress (#1047) --- src/stores/chat.ts | 197 +++++++++++++++++++- src/stores/chat/helpers.ts | 32 ++++ src/stores/chat/history-actions.ts | 14 +- src/stores/chat/runtime-send-actions.ts | 9 + src/stores/gateway.ts | 8 +- tests/unit/chat-store-history-retry.test.ts | 158 ++++++++++++++++ 6 files changed, 402 insertions(+), 16 deletions(-) diff --git a/src/stores/chat.ts b/src/stores/chat.ts index dcbca0b..623c4db 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -75,6 +75,31 @@ const _lastHistoryLoadAtBySession = new Map(); const _forceNextHistoryLoadBySession = new Set(); const _foregroundHistoryLoadSeen = new Set(); const _sessionHistoryCache = new Map(); + +type SessionRunState = Pick< + ChatState, + | 'sending' + | 'activeRunId' + | 'pendingFinal' + | 'lastUserMessageAt' + | 'streamingText' + | 'streamingMessage' + | 'streamingTools' + | 'pendingToolImages' +>; + +const DEFAULT_SESSION_RUN_STATE: SessionRunState = { + sending: false, + activeRunId: null, + pendingFinal: false, + lastUserMessageAt: null, + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingToolImages: [], +}; + +const _sessionRunStateCache = new Map(); const SESSION_LOAD_MIN_INTERVAL_MS = 1_200; const HISTORY_LOAD_MIN_INTERVAL_MS = 800; const HISTORY_POLL_SILENCE_WINDOW_MS = 2_500; @@ -334,6 +359,38 @@ function clearCachedSessionHistory(sessionKey: string): void { _sessionHistoryCache.delete(sessionKey); } +function captureSessionRunState(sessionKey: string, state: SessionRunState): void { + _sessionRunStateCache.set(sessionKey, { + sending: state.sending, + activeRunId: state.activeRunId, + pendingFinal: state.pendingFinal, + lastUserMessageAt: state.lastUserMessageAt, + streamingText: state.streamingText, + streamingMessage: state.streamingMessage, + streamingTools: [...state.streamingTools], + pendingToolImages: state.pendingToolImages.map((file) => ({ ...file })), + }); +} + +function getCachedSessionRunState(sessionKey: string): SessionRunState { + const cached = _sessionRunStateCache.get(sessionKey); + if (!cached) return DEFAULT_SESSION_RUN_STATE; + return { + sending: cached.sending, + activeRunId: cached.activeRunId, + pendingFinal: cached.pendingFinal, + lastUserMessageAt: cached.lastUserMessageAt, + streamingText: cached.streamingText, + streamingMessage: cached.streamingMessage, + streamingTools: [...cached.streamingTools], + pendingToolImages: cached.pendingToolImages.map((file) => ({ ...file })), + }; +} + +function clearCachedSessionRunState(sessionKey: string): void { + _sessionRunStateCache.delete(sessionKey); +} + function getHistoryForegroundLoadKey(sessionKey: string): string { const gatewayState = useGatewayStore.getState?.() as { status?: { pid?: number; connectedAt?: number; port?: number } } | undefined; const gatewayStatus = gatewayState?.status; @@ -1388,10 +1445,24 @@ function clearSessionEntryFromMap>(entries: T, function buildSessionSwitchPatch( state: Pick< ChatState, - 'currentSessionKey' | 'messages' | 'sessions' | 'sessionLabels' | 'sessionLastActivity' | 'thinkingLevel' + | 'currentSessionKey' + | 'messages' + | 'sessions' + | 'sessionLabels' + | 'sessionLastActivity' + | 'thinkingLevel' + | 'sending' + | 'activeRunId' + | 'pendingFinal' + | 'lastUserMessageAt' + | 'streamingText' + | 'streamingMessage' + | 'streamingTools' + | 'pendingToolImages' >, nextSessionKey: string, ): Partial { + captureSessionRunState(state.currentSessionKey, state); // Only treat sessions with no history records and no activity timestamp as empty. // Relying solely on messages.length is unreliable because switchSession clears // the current messages before loadHistory runs, creating a race condition that @@ -1405,6 +1476,7 @@ function buildSessionSwitchPatch( ? state.sessions.filter((session) => session.key !== state.currentSessionKey) : state.sessions; const cachedNextSession = getCachedSessionHistory(nextSessionKey); + const cachedRunState = getCachedSessionRunState(nextSessionKey); return { currentSessionKey: nextSessionKey, @@ -1420,14 +1492,9 @@ function buildSessionSwitchPatch( hasMoreHistory: cachedNextSession ? cachedNextSession.messages.length >= HISTORY_PAGE_SIZE : false, loadingMoreHistory: false, thinkingLevel: cachedNextSession?.thinkingLevel ?? state.thinkingLevel ?? null, - streamingText: '', - streamingMessage: null, - streamingTools: [], - activeRunId: null, + ...cachedRunState, error: null, - pendingFinal: false, - lastUserMessageAt: null, - pendingToolImages: [], + runError: null, }; } @@ -1862,6 +1929,78 @@ function hasPendingToolUse(message: RawMessage | undefined): boolean { return false; } +function isRealUserBoundaryMessage(msg: RawMessage): boolean { + if (msg.role !== 'user') return false; + if (!Array.isArray(msg.content)) return true; + const blocks = msg.content as Array<{ type?: string }>; + return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult'); +} + +function hasAssistantAfterLastRealUser(messages: RawMessage[]): boolean { + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (isRealUserBoundaryMessage(messages[i])) { + return messages.slice(i + 1).some((m) => m.role === 'assistant'); + } + } + return false; +} + +function hasAssistantProgressSinceSend(messages: RawMessage[], lastUserMessageAt: number | null): boolean { + if (!lastUserMessageAt) return false; + const normalized = [...messages]; + while (normalized.length > 0) { + const last = normalized[normalized.length - 1]; + if (last.role === 'user' && !last.timestamp) { + normalized.pop(); + continue; + } + break; + } + return hasAssistantAfterLastRealUser(normalized); +} + +function postUserSegmentMessages(filteredMessages: RawMessage[]): RawMessage[] { + for (let i = filteredMessages.length - 1; i >= 0; i -= 1) { + if (isRealUserBoundaryMessage(filteredMessages[i])) { + return filteredMessages.slice(i + 1); + } + } + return []; +} + +function segmentHasOpenToolRun(segmentMessages: RawMessage[]): boolean { + if (segmentMessages.length === 0) return false; + const hasToolActivity = segmentMessages.some( + (message) => message.role === 'assistant' && (hasPendingToolUse(message) || isToolOnlyMessage(message)), + ); + if (!hasToolActivity) return false; + + let lastToolUseOffset = -1; + for (let i = segmentMessages.length - 1; i >= 0; i -= 1) { + const message = segmentMessages[i]; + if (message.role === 'assistant' && (hasPendingToolUse(message) || isToolOnlyMessage(message))) { + lastToolUseOffset = i; + break; + } + } + + return !segmentMessages.some((message, index) => { + if (index <= lastToolUseOffset) return false; + if (message.role !== 'assistant') return false; + if (hasPendingToolUse(message)) return false; + return hasNonToolAssistantContent(message); + }); +} + +function findLastRealUserMessage(messages: RawMessage[]): RawMessage | null { + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (isRealUserBoundaryMessage(messages[i])) { + return messages[i]; + } + } + return null; +} + // ── Store ──────────────────────────────────────────────────────── export const useChatStore = create((set, get) => ({ @@ -2082,6 +2221,7 @@ export const useChatStore = create((set, get) => ({ deleteSession: async (key: string) => { clearCachedSessionHistory(key); + clearCachedSessionRunState(key); clearSessionLabelHydrationTracking(key); clearPendingOptimisticUserMessages(key); // Hard-delete the session's JSONL transcript on disk. @@ -2441,6 +2581,17 @@ export const useChatStore = create((set, get) => ({ return true; } + // History poll is the fallback when Gateway streaming events are missing + // (WS disconnect, console-only runs, etc.). Any assistant turn after the + // user's message counts as progress so the safety timeout does not emit a + // false "No response received" error while tool chains are still running. + if (isSendingNow && hasAssistantAfterLastRealUser(filteredMessages)) { + _lastChatEventAt = Date.now(); + if (get().error) { + set({ error: null }); + } + } + // Promote pendingFinal only when there's a *final-looking* assistant // message after the user — i.e. one that has actual user-visible output // (text/image) AND is not still waiting on a tool result. This used to @@ -2478,6 +2629,24 @@ export const useChatStore = create((set, get) => ({ set({ sending: false, activeRunId: null, pendingFinal: false }); } } + + // After session switch (or cold load) the renderer may have reset run + // lifecycle flags even though the Gateway is still executing tools. + // Re-arm from authoritative history when the latest user turn has tool + // activity but no final reply yet. + if (!get().sending && !latestTerminalAssistantErrorMessage) { + const openSegment = postUserSegmentMessages(filteredMessages); + if (segmentHasOpenToolRun(openSegment)) { + const lastUser = findLastRealUserMessage(filteredMessages); + const inferredUserAt = lastUser?.timestamp ? toMs(lastUser.timestamp) : Date.now(); + set({ + sending: true, + pendingFinal: true, + lastUserMessageAt: inferredUserAt, + }); + captureSessionRunState(currentSessionKey, get()); + } + } return true; }; @@ -2766,6 +2935,14 @@ export const useChatStore = create((set, get) => ({ setTimeout(checkStuck, 10_000); return; } + if (hasAssistantProgressSinceSend(state.messages, state.lastUserMessageAt)) { + _lastChatEventAt = Date.now(); + if (state.error) { + set({ error: null }); + } + setTimeout(checkStuck, 10_000); + return; + } if (Date.now() - _lastChatEventAt < SAFETY_TIMEOUT_MS) { setTimeout(checkStuck, 10_000); return; @@ -3297,3 +3474,7 @@ export const useChatStore = create((set, get) => ({ clearError: () => set({ error: null, runError: null }), })); + +export function syncCachedSessionRunIdle(sessionKey: string): void { + captureSessionRunState(sessionKey, DEFAULT_SESSION_RUN_STATE); +} diff --git a/src/stores/chat/helpers.ts b/src/stores/chat/helpers.ts index 81a9b1c..c3d8b34 100644 --- a/src/stores/chat/helpers.ts +++ b/src/stores/chat/helpers.ts @@ -1389,6 +1389,36 @@ function queueBlockedRunEvent(runId: string, event: Record): vo _blockedRunEvents.set(runId, events); } +function isRealUserBoundaryMessage(msg: RawMessage): boolean { + if (msg.role !== 'user') return false; + if (!Array.isArray(msg.content)) return true; + const blocks = msg.content as ContentBlock[]; + return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult'); +} + +function hasAssistantAfterLastRealUser(messages: RawMessage[]): boolean { + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (isRealUserBoundaryMessage(messages[i])) { + return messages.slice(i + 1).some((m) => m.role === 'assistant'); + } + } + return false; +} + +function hasAssistantProgressSinceSend(messages: RawMessage[], lastUserMessageAt: number | null): boolean { + if (!lastUserMessageAt) return false; + const normalized = [...messages]; + while (normalized.length > 0) { + const last = normalized[normalized.length - 1]; + if (last.role === 'user' && !last.timestamp) { + normalized.pop(); + continue; + } + break; + } + return hasAssistantAfterLastRealUser(normalized); +} + function takeBlockedRunEvents(runId: string): Record[] { const events = _blockedRunEvents.get(runId) ?? []; _blockedRunEvents.delete(runId); @@ -1419,6 +1449,8 @@ export { upsertToolStatuses, hasNonToolAssistantContent, hasPendingToolUse, + hasAssistantAfterLastRealUser, + hasAssistantProgressSinceSend, isToolOnlyMessage, normalizeStreamingMessage, matchesOptimisticUserMessage, diff --git a/src/stores/chat/history-actions.ts b/src/stores/chat/history-actions.ts index aef56d5..d2628c6 100644 --- a/src/stores/chat/history-actions.ts +++ b/src/stores/chat/history-actions.ts @@ -14,8 +14,10 @@ import { loadMissingPreviews, mergePendingOptimisticUserMessages, dropRedundantOptimisticUserMessages, + hasAssistantAfterLastRealUser, hasOptimisticServerEcho, isRecoverableRuntimeError, + setLastChatEventAt, toMs, } from './helpers'; import { buildCronSessionHistoryPath, isCronSessionKey } from './cron-session-utils'; @@ -224,14 +226,12 @@ export function createHistoryActions( return true; } - if (isSendingNow && !pendingFinal) { - const hasRecentAssistantActivity = [...filteredMessages].reverse().some((msg) => { - if (msg.role !== 'assistant') return false; - return isAfterUserMsg(msg); - }); - if (hasRecentAssistantActivity) { - set({ pendingFinal: true }); + if (isSendingNow && !pendingFinal && hasAssistantAfterLastRealUser(filteredMessages)) { + setLastChatEventAt(Date.now()); + if (get().error) { + set({ error: null }); } + set({ pendingFinal: true }); } return true; }; diff --git a/src/stores/chat/runtime-send-actions.ts b/src/stores/chat/runtime-send-actions.ts index f7c8fa0..d176f88 100644 --- a/src/stores/chat/runtime-send-actions.ts +++ b/src/stores/chat/runtime-send-actions.ts @@ -5,6 +5,7 @@ import { clearHistoryPoll, getLastAbortedRunId, getLastChatEventAt, + hasAssistantProgressSinceSend, setHistoryPollTimer, setLastChatEventAt, setLastAbortedRunId, @@ -162,6 +163,14 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick { + .then(({ useChatStore, syncCachedSessionRunIdle }) => { const state = useChatStore.getState(); const resolvedSessionKey = sessionKey != null ? String(sessionKey) : null; const shouldRefreshSessions = resolvedSessionKey != null && ( @@ -233,6 +233,9 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec if (matchesCurrentSession || matchesActiveRun) { maybeLoadHistory(state, isRunCompletion); } + if (isRunCompletion && resolvedSessionKey && !matchesCurrentSession) { + syncCachedSessionRunIdle(resolvedSessionKey); + } if (isRunCompletion && (matchesCurrentSession || matchesActiveRun) && state.sending) { useChatStore.setState({ sending: false, @@ -241,6 +244,9 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec lastUserMessageAt: null, error: null, }); + if (resolvedSessionKey) { + syncCachedSessionRunIdle(resolvedSessionKey); + } } }) .catch(() => {}); diff --git a/tests/unit/chat-store-history-retry.test.ts b/tests/unit/chat-store-history-retry.test.ts index b2cfdc5..da14603 100644 --- a/tests/unit/chat-store-history-retry.test.ts +++ b/tests/unit/chat-store-history-retry.test.ts @@ -287,6 +287,51 @@ describe('useChatStore startup history retry', () => { expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['cached history']); }); + 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'); @@ -924,4 +969,117 @@ describe('useChatStore startup history retry', () => { 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; + }); });