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:
parent
00735395ca
commit
c749df4e0a
@ -115,11 +115,13 @@ function App() {
|
||||
exitSubAgentView,
|
||||
navigateToSubAgentLevel,
|
||||
handleStop,
|
||||
finishStreaming,
|
||||
} = useChat();
|
||||
|
||||
const { status, sendMessage } = useWebSocket({
|
||||
url: wsUrl,
|
||||
onMessage: handleServerMessage,
|
||||
onDisconnect: () => finishStreaming(),
|
||||
});
|
||||
|
||||
// 将 sendMessage 注入到 useChat,供 handleServerMessage 内部发送命令
|
||||
|
||||
@ -37,6 +37,7 @@ export interface UseMessagesReturn {
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
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,
|
||||
};
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user