fix(web): WebSocket 断连时清理流式状态,防止重连后消息串流

流式优化引入的回归风险:当 WebSocket 在流式输出中途断连时,
streamingRef(contentChunks/index)不会被重置。重连后新消息的
delta 会通过 s.index >= 0 分支追加到旧消息的错误位置。

修复链路:useWebSocket 的 onDisconnect 回调 → useChat 的
finishStreaming → useMessages 的 finishStreaming(重置 ref +
flush pending chunks)。

- useMessages.ts: 在 UseMessagesReturn 接口和 return 对象暴露
  finishStreaming(原为内部函数)
- useChat.ts: 在返回对象透传 finishStreaming
- App.tsx: useWebSocket 调用新增 onDisconnect 回调触发 finishStreaming
- useChat.test.ts: 新增用例 17 验证断连后新 delta 不污染旧消息
This commit is contained in:
oudecheng 2026-08-07 06:51:00 +08:00
parent 00735395ca
commit c749df4e0a
4 changed files with 31 additions and 0 deletions

View File

@ -115,11 +115,13 @@ function App() {
exitSubAgentView,
navigateToSubAgentLevel,
handleStop,
finishStreaming,
} = useChat();
const { status, sendMessage } = useWebSocket({
url: wsUrl,
onMessage: handleServerMessage,
onDisconnect: () => finishStreaming(),
});
// 将 sendMessage 注入到 useChat供 handleServerMessage 内部发送命令

View File

@ -37,6 +37,7 @@ export interface UseMessagesReturn {
setIsLoading: Dispatch<SetStateAction<boolean>>;
handleMessage: (content: string, attachments?: Attachment[]) => void;
clearMessages: () => void;
finishStreaming: () => void;
handleStop: () => Command;
/** 处理主视图的消息类 casetask_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,
};

View File

@ -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');
});
});

View File

@ -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,