diff --git a/web/src/App.tsx b/web/src/App.tsx index 8a2c9ad..54097f3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -115,11 +115,13 @@ function App() { exitSubAgentView, navigateToSubAgentLevel, handleStop, + finishStreaming, } = useChat(); const { status, sendMessage } = useWebSocket({ url: wsUrl, onMessage: handleServerMessage, + onDisconnect: () => finishStreaming(), }); // 将 sendMessage 注入到 useChat,供 handleServerMessage 内部发送命令 diff --git a/web/src/hooks/chat/useMessages.ts b/web/src/hooks/chat/useMessages.ts index 8fad370..79f5f77 100644 --- a/web/src/hooks/chat/useMessages.ts +++ b/web/src/hooks/chat/useMessages.ts @@ -37,6 +37,7 @@ export interface UseMessagesReturn { setIsLoading: Dispatch>; handleMessage: (content: string, attachments?: Attachment[]) => void; clearMessages: () => void; + finishStreaming: () => void; handleStop: () => Command; /** 处理主视图的消息类 case(task_started, stream_*, tool_*, execution_*, error),返回是否已处理 */ handleMainViewMessage: (message: WsOutbound) => boolean; @@ -426,6 +427,7 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn { setIsLoading, handleMessage, clearMessages, + finishStreaming, handleStop, handleMainViewMessage, }; diff --git a/web/src/hooks/useChat.test.ts b/web/src/hooks/useChat.test.ts index 515fffa..3384bf3 100644 --- a/web/src/hooks/useChat.test.ts +++ b/web/src/hooks/useChat.test.ts @@ -498,4 +498,29 @@ describe('useChat - handleServerMessage characterization', () => { expect(result.current.messages[0].content).toBe('final text'); expect(result.current.messages[0].reasoningContent).toBe('final reasoning'); }); + + it('17. finishStreaming (onDisconnect) mid-stream resets streaming state, preventing post-reconnect leakage', () => { + const { result } = renderUseChat(); + // 流式进行中 + act(() => { + result.current.handleServerMessage({ type: 'stream_delta', id: 's1', delta: 'part1' }); + result.current.handleServerMessage({ type: 'stream_delta', id: 's1', delta: 'part2' }); + }); + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].content).toBe('part1part2'); + + // 模拟 WebSocket 断连:onDisconnect 回调触发 finishStreaming + act(() => result.current.finishStreaming()); + + // 重连后,新 delta(不同 id)应作为新消息出现,不应追加到已断连的旧消息 + act(() => + result.current.handleServerMessage({ type: 'stream_delta', id: 's2', delta: 'fresh' }), + ); + const s2 = result.current.messages.find((m) => m.id === 's2'); + expect(s2).toBeDefined(); + expect(s2?.content).toBe('fresh'); + // 旧消息内容保持不变(未被新 delta 污染) + const s1 = result.current.messages.find((m) => m.id === 's1'); + expect(s1?.content).toBe('part1part2'); + }); }); diff --git a/web/src/hooks/useChat.ts b/web/src/hooks/useChat.ts index 8118156..077bd2a 100644 --- a/web/src/hooks/useChat.ts +++ b/web/src/hooks/useChat.ts @@ -59,6 +59,7 @@ interface UseChatReturn { handleMessage: (content: string, attachments?: Attachment[]) => void; handleCommand: (command: Command) => void; clearMessages: () => void; + finishStreaming: () => void; handleServerMessage: (message: WsOutbound) => void; setSendMessage: (fn: (msg: WsInbound) => boolean) => void; @@ -360,6 +361,7 @@ export function useChat(): UseChatReturn { handleMessage: messages.handleMessage, handleCommand, clearMessages: messages.clearMessages, + finishStreaming: messages.finishStreaming, handleServerMessage, setSendMessage: conn.setSendMessage, selectTopic,