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,
|
exitSubAgentView,
|
||||||
navigateToSubAgentLevel,
|
navigateToSubAgentLevel,
|
||||||
handleStop,
|
handleStop,
|
||||||
|
finishStreaming,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
|
|
||||||
const { status, sendMessage } = useWebSocket({
|
const { status, sendMessage } = useWebSocket({
|
||||||
url: wsUrl,
|
url: wsUrl,
|
||||||
onMessage: handleServerMessage,
|
onMessage: handleServerMessage,
|
||||||
|
onDisconnect: () => finishStreaming(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 将 sendMessage 注入到 useChat,供 handleServerMessage 内部发送命令
|
// 将 sendMessage 注入到 useChat,供 handleServerMessage 内部发送命令
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export interface UseMessagesReturn {
|
|||||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||||
handleMessage: (content: string, attachments?: Attachment[]) => void;
|
handleMessage: (content: string, attachments?: Attachment[]) => void;
|
||||||
clearMessages: () => void;
|
clearMessages: () => void;
|
||||||
|
finishStreaming: () => void;
|
||||||
handleStop: () => Command;
|
handleStop: () => Command;
|
||||||
/** 处理主视图的消息类 case(task_started, stream_*, tool_*, execution_*, error),返回是否已处理 */
|
/** 处理主视图的消息类 case(task_started, stream_*, tool_*, execution_*, error),返回是否已处理 */
|
||||||
handleMainViewMessage: (message: WsOutbound) => boolean;
|
handleMainViewMessage: (message: WsOutbound) => boolean;
|
||||||
@ -426,6 +427,7 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
setIsLoading,
|
setIsLoading,
|
||||||
handleMessage,
|
handleMessage,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
|
finishStreaming,
|
||||||
handleStop,
|
handleStop,
|
||||||
handleMainViewMessage,
|
handleMainViewMessage,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -498,4 +498,29 @@ describe('useChat - handleServerMessage characterization', () => {
|
|||||||
expect(result.current.messages[0].content).toBe('final text');
|
expect(result.current.messages[0].content).toBe('final text');
|
||||||
expect(result.current.messages[0].reasoningContent).toBe('final reasoning');
|
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;
|
handleMessage: (content: string, attachments?: Attachment[]) => void;
|
||||||
handleCommand: (command: Command) => void;
|
handleCommand: (command: Command) => void;
|
||||||
clearMessages: () => void;
|
clearMessages: () => void;
|
||||||
|
finishStreaming: () => void;
|
||||||
handleServerMessage: (message: WsOutbound) => void;
|
handleServerMessage: (message: WsOutbound) => void;
|
||||||
setSendMessage: (fn: (msg: WsInbound) => boolean) => void;
|
setSendMessage: (fn: (msg: WsInbound) => boolean) => void;
|
||||||
|
|
||||||
@ -360,6 +361,7 @@ export function useChat(): UseChatReturn {
|
|||||||
handleMessage: messages.handleMessage,
|
handleMessage: messages.handleMessage,
|
||||||
handleCommand,
|
handleCommand,
|
||||||
clearMessages: messages.clearMessages,
|
clearMessages: messages.clearMessages,
|
||||||
|
finishStreaming: messages.finishStreaming,
|
||||||
handleServerMessage,
|
handleServerMessage,
|
||||||
setSendMessage: conn.setSendMessage,
|
setSendMessage: conn.setSendMessage,
|
||||||
selectTopic,
|
selectTopic,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user