fix(chat): filter internal messages (NO_REPLY) in SSE final handler (#904) (#915)

This commit is contained in:
Kagura
2026-04-25 15:51:09 +08:00
committed by GitHub
parent 04e234bbde
commit ceb537f7bd
3 changed files with 68 additions and 0 deletions

View File

@@ -2082,6 +2082,24 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (finalMsg) { if (finalMsg) {
const normalizedFinalMessage = normalizeStreamingMessage(finalMsg) as RawMessage; const normalizedFinalMessage = normalizeStreamingMessage(finalMsg) as RawMessage;
const updates = collectToolUpdates(normalizedFinalMessage, resolvedState); 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)) { if (isToolResultRole(normalizedFinalMessage.role)) {
// Resolve file path from the streaming assistant message's matching tool call // Resolve file path from the streaming assistant message's matching tool call
const currentStreamForPath = get().streamingMessage as RawMessage | null; const currentStreamForPath = get().streamingMessage as RawMessage | null;

View File

@@ -9,6 +9,7 @@ import {
getToolCallFilePath, getToolCallFilePath,
hasErrorRecoveryTimer, hasErrorRecoveryTimer,
hasNonToolAssistantContent, hasNonToolAssistantContent,
isInternalMessage,
isToolOnlyMessage, isToolOnlyMessage,
isToolResultRole, isToolResultRole,
makeAttachedFile, makeAttachedFile,
@@ -81,6 +82,24 @@ export function handleRuntimeEventState(
if (finalMsg) { if (finalMsg) {
const normalizedFinalMessage = normalizeStreamingMessage(finalMsg) as RawMessage; const normalizedFinalMessage = normalizeStreamingMessage(finalMsg) as RawMessage;
const updates = collectToolUpdates(normalizedFinalMessage, resolvedState); 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)) { if (isToolResultRole(normalizedFinalMessage.role)) {
// Resolve file path from the streaming assistant message's matching tool call // Resolve file path from the streaming assistant message's matching tool call
const currentStreamForPath = get().streamingMessage as RawMessage | null; const currentStreamForPath = get().streamingMessage as RawMessage | null;

View File

@@ -10,6 +10,7 @@ const getMessageText = vi.fn(() => '');
const getToolCallFilePath = vi.fn(() => undefined); const getToolCallFilePath = vi.fn(() => undefined);
const hasErrorRecoveryTimer = vi.fn(() => false); const hasErrorRecoveryTimer = vi.fn(() => false);
const hasNonToolAssistantContent = vi.fn(() => true); const hasNonToolAssistantContent = vi.fn(() => true);
const isInternalMessage = vi.fn(() => false);
const isToolOnlyMessage = vi.fn(() => false); const isToolOnlyMessage = vi.fn(() => false);
const isToolResultRole = vi.fn((role: unknown) => role === 'toolresult' || role === 'toolResult' || role === 'tool_result'); 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') => ({ 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), getToolCallFilePath: (...args: unknown[]) => getToolCallFilePath(...args),
hasErrorRecoveryTimer: (...args: unknown[]) => hasErrorRecoveryTimer(...args), hasErrorRecoveryTimer: (...args: unknown[]) => hasErrorRecoveryTimer(...args),
hasNonToolAssistantContent: (...args: unknown[]) => hasNonToolAssistantContent(...args), hasNonToolAssistantContent: (...args: unknown[]) => hasNonToolAssistantContent(...args),
isInternalMessage: (...args: unknown[]) => isInternalMessage(...args),
isToolOnlyMessage: (...args: unknown[]) => isToolOnlyMessage(...args), isToolOnlyMessage: (...args: unknown[]) => isToolOnlyMessage(...args),
isToolResultRole: (...args: unknown[]) => isToolResultRole(...args), isToolResultRole: (...args: unknown[]) => isToolResultRole(...args),
makeAttachedFile: (...args: unknown[]) => makeAttachedFile(...args), makeAttachedFile: (...args: unknown[]) => makeAttachedFile(...args),
@@ -348,4 +350,33 @@ describe('chat runtime event handlers', () => {
expect(next.lastUserMessageAt).toBeNull(); expect(next.lastUserMessageAt).toBeNull();
expect(next.pendingToolImages).toEqual([]); 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);
});
}); });