This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user