From ceb537f7bdf94dbd0e24e5810dbdf38aabb002d5 Mon Sep 17 00:00:00 2001 From: Kagura Date: Sat, 25 Apr 2026 15:51:09 +0800 Subject: [PATCH] fix(chat): filter internal messages (NO_REPLY) in SSE final handler (#904) (#915) --- src/stores/chat.ts | 18 +++++++++++ src/stores/chat/runtime-event-handlers.ts | 19 ++++++++++++ .../unit/chat-runtime-event-handlers.test.ts | 31 +++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 4d6e6d7..63ae843 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -2082,6 +2082,24 @@ export const useChatStore = create((set, get) => ({ if (finalMsg) { const normalizedFinalMessage = normalizeStreamingMessage(finalMsg) as RawMessage; const updates = collectToolUpdates(normalizedFinalMessage, resolvedState); + // Filter out internal-only final responses (NO_REPLY, HEARTBEAT_OK, etc.) + // before adding to messages. Without this guard, the internal token appears + // 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. + if (isInternalMessage(normalizedFinalMessage)) { + set({ + streamingText: '', + streamingMessage: null, + sending: false, + activeRunId: null, + pendingFinal: false, + streamingTools: [], + pendingToolImages: [], + }); + clearHistoryPoll(); + void get().loadHistory(true); + break; + } if (isToolResultRole(normalizedFinalMessage.role)) { // Resolve file path from the streaming assistant message's matching tool call const currentStreamForPath = get().streamingMessage as RawMessage | null; diff --git a/src/stores/chat/runtime-event-handlers.ts b/src/stores/chat/runtime-event-handlers.ts index 6d27845..24f7aa5 100644 --- a/src/stores/chat/runtime-event-handlers.ts +++ b/src/stores/chat/runtime-event-handlers.ts @@ -9,6 +9,7 @@ import { getToolCallFilePath, hasErrorRecoveryTimer, hasNonToolAssistantContent, + isInternalMessage, isToolOnlyMessage, isToolResultRole, makeAttachedFile, @@ -81,6 +82,24 @@ export function handleRuntimeEventState( if (finalMsg) { const normalizedFinalMessage = normalizeStreamingMessage(finalMsg) as RawMessage; const updates = collectToolUpdates(normalizedFinalMessage, resolvedState); + // Filter out internal-only final responses (NO_REPLY, HEARTBEAT_OK, etc.) + // before adding to messages. Without this guard, the internal token appears + // 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. + if (isInternalMessage(normalizedFinalMessage)) { + set({ + streamingText: '', + streamingMessage: null, + sending: false, + activeRunId: null, + pendingFinal: false, + streamingTools: [], + pendingToolImages: [], + }); + clearHistoryPoll(); + void get().loadHistory(true); + break; + } if (isToolResultRole(normalizedFinalMessage.role)) { // Resolve file path from the streaming assistant message's matching tool call const currentStreamForPath = get().streamingMessage as RawMessage | null; diff --git a/tests/unit/chat-runtime-event-handlers.test.ts b/tests/unit/chat-runtime-event-handlers.test.ts index 9921af3..23e2092 100644 --- a/tests/unit/chat-runtime-event-handlers.test.ts +++ b/tests/unit/chat-runtime-event-handlers.test.ts @@ -10,6 +10,7 @@ const getMessageText = vi.fn(() => ''); const getToolCallFilePath = vi.fn(() => undefined); const hasErrorRecoveryTimer = vi.fn(() => false); const hasNonToolAssistantContent = vi.fn(() => true); +const isInternalMessage = vi.fn(() => false); const isToolOnlyMessage = vi.fn(() => false); const isToolResultRole = vi.fn((role: unknown) => role === 'toolresult' || role === 'toolResult' || role === 'tool_result'); const makeAttachedFile = vi.fn((ref: { filePath: string; mimeType: string }, source?: 'user-upload' | 'tool-result' | 'message-ref') => ({ @@ -36,6 +37,7 @@ vi.mock('@/stores/chat/helpers', () => ({ getToolCallFilePath: (...args: unknown[]) => getToolCallFilePath(...args), hasErrorRecoveryTimer: (...args: unknown[]) => hasErrorRecoveryTimer(...args), hasNonToolAssistantContent: (...args: unknown[]) => hasNonToolAssistantContent(...args), + isInternalMessage: (...args: unknown[]) => isInternalMessage(...args), isToolOnlyMessage: (...args: unknown[]) => isToolOnlyMessage(...args), isToolResultRole: (...args: unknown[]) => isToolResultRole(...args), makeAttachedFile: (...args: unknown[]) => makeAttachedFile(...args), @@ -348,4 +350,33 @@ describe('chat runtime event handlers', () => { expect(next.lastUserMessageAt).toBeNull(); expect(next.pendingToolImages).toEqual([]); }); + + it('filters out NO_REPLY internal message in final event without adding to messages', async () => { + isInternalMessage.mockReturnValueOnce(true); + const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers'); + const h = makeHarness({ + sending: true, + activeRunId: 'r3', + messages: [{ role: 'user', content: 'hello', id: 'u1' }], + }); + + handleRuntimeEventState( + h.set as never, + h.get as never, + { message: { role: 'assistant', content: 'NO_REPLY', id: 'a1' } }, + 'final', + 'r3', + ); + const next = h.read(); + // NO_REPLY must not appear in messages + expect(next.messages).toEqual([{ role: 'user', content: 'hello', id: 'u1' }]); + expect(next.sending).toBe(false); + expect(next.activeRunId).toBeNull(); + expect(next.pendingFinal).toBe(false); + expect(next.streamingText).toBe(''); + expect(next.streamingMessage).toBeNull(); + // Should trigger history reload + expect(clearHistoryPoll).toHaveBeenCalled(); + expect(next.loadHistory).toHaveBeenCalledWith(true); + }); });