Skip to content

Commit 2f534f7

Browse files
Merge pull request microsoft#1086 from microsoft/feat/model-changes
fix: Intermittent UI freeze and infinite reasoning
2 parents bcb8656 + 04cbc66 commit 2f534f7

5 files changed

Lines changed: 116 additions & 6 deletions

File tree

src/App/src/hooks/usePlanWebSocket.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ export function usePlanWebSocket({
105105
const streamingMessageBuffer = useAppSelector(selectStreamingMessageBuffer);
106106
const processingStartedAtRef = React.useRef<number | null>(null);
107107

108+
// Coalesce high-frequency streaming tokens into one flush per animation frame
109+
// to avoid a synchronous re-render per token freezing the UI on fast streams.
110+
const streamingChunkQueueRef = React.useRef<string[]>([]);
111+
const streamingFlushHandleRef = React.useRef<number | null>(null);
112+
108113
useEffect(() => {
109114
if (showProcessingPlanSpinner) {
110115
if (processingStartedAtRef.current === null) {
@@ -144,15 +149,38 @@ export function usePlanWebSocket({
144149

145150
// ── AGENT_MESSAGE_STREAMING ───────────────────────────────────
146151
useEffect(() => {
152+
const flushStreamingChunks = () => {
153+
streamingFlushHandleRef.current = null;
154+
const chunks = streamingChunkQueueRef.current;
155+
if (chunks.length === 0) return;
156+
streamingChunkQueueRef.current = [];
157+
dispatch(setShowBufferingText(true));
158+
dispatch(appendToStreamingBuffer(chunks.join('')));
159+
};
160+
147161
const unsub = webSocketService.on(
148162
WebsocketMessageType.AGENT_MESSAGE_STREAMING,
149163
(msg: any) => {
150164
const line = PlanDataService.simplifyHumanClarification(msg.data?.content || msg.content || '');
151-
dispatch(setShowBufferingText(true));
152-
dispatch(appendToStreamingBuffer(line));
165+
streamingChunkQueueRef.current.push(line);
166+
if (streamingFlushHandleRef.current === null) {
167+
streamingFlushHandleRef.current = requestAnimationFrame(flushStreamingChunks);
168+
}
153169
},
154170
);
155-
return unsub;
171+
return () => {
172+
unsub();
173+
// Cancel pending frame and flush leftovers so no streamed text is lost
174+
if (streamingFlushHandleRef.current !== null) {
175+
cancelAnimationFrame(streamingFlushHandleRef.current);
176+
streamingFlushHandleRef.current = null;
177+
}
178+
if (streamingChunkQueueRef.current.length > 0) {
179+
const remaining = streamingChunkQueueRef.current.join('');
180+
streamingChunkQueueRef.current = [];
181+
dispatch(appendToStreamingBuffer(remaining));
182+
}
183+
};
156184
}, [dispatch]);
157185

158186
// ── USER_CLARIFICATION_REQUEST ────────────────────────────────
@@ -241,6 +269,27 @@ export function usePlanWebSocket({
241269
scrollToBottom();
242270
showToast(errorContent, 'error');
243271
webSocketService.disconnect();
272+
} else {
273+
// Any other terminal status (e.g. "terminated"): clear the spinner
274+
// so the UI doesn't hang after the answer has already arrived.
275+
const content = finalMessage.data?.content;
276+
if (content) {
277+
const terminalMessage: AgentMessageData = {
278+
agent: AgentType.GROUP_CHAT_MANAGER,
279+
agent_type: AgentMessageType.AI_AGENT,
280+
timestamp: Date.now(),
281+
steps: [],
282+
next_steps: [],
283+
content,
284+
raw_data: finalMessage,
285+
};
286+
dispatch(addAgentMessage(terminalMessage));
287+
}
288+
dispatch(setShowBufferingText(false));
289+
dispatch(setShowProcessingPlanSpinner(false));
290+
processingStartedAtRef.current = null;
291+
scrollToBottom();
292+
webSocketService.disconnect();
244293
}
245294
},
246295
);

src/App/src/models/enums.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,8 @@ export enum WebsocketMessageType {
253253
USER_CLARIFICATION_REQUEST = "user_clarification_request",
254254
USER_CLARIFICATION_RESPONSE = "user_clarification_response",
255255
FINAL_RESULT_MESSAGE = "final_result_message",
256-
ERROR_MESSAGE = 'error_message'
256+
ERROR_MESSAGE = 'error_message',
257+
PING = "ping"
257258
}
258259

259260
export enum AgentMessageType {

src/App/src/store/WebSocketService.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ class WebSocketService {
1515
private intentionalDisconnect = false;
1616
private lastPlanId: string | undefined;
1717
private lastProcessId: string | undefined;
18+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
19+
private heartbeatIntervalMs = 20000; // 20s client keepalive ping
1820

1921

2022
private buildSocketUrl(processId?: string, planId?: string): string {
@@ -59,6 +61,7 @@ class WebSocketService {
5961
clearTimeout(this.reconnectTimer);
6062
this.reconnectTimer = null;
6163
}
64+
this.startHeartbeat();
6265
this.emit('connection_status', { connected: true });
6366
resolve();
6467
};
@@ -75,6 +78,7 @@ class WebSocketService {
7578
this.ws.onclose = (event) => {
7679
this.isConnecting = false;
7780
this.ws = null;
81+
this.stopHeartbeat();
7882
this.emit('connection_status', { connected: false });
7983
/* P1: Only auto-reconnect if not intentional and not a clean close */
8084
if (!this.intentionalDisconnect && event.code !== 1000 &&
@@ -99,6 +103,7 @@ class WebSocketService {
99103

100104
disconnect(): void {
101105
this.intentionalDisconnect = true;
106+
this.stopHeartbeat();
102107
if (this.reconnectTimer) {
103108
clearTimeout(this.reconnectTimer);
104109
this.reconnectTimer = null;
@@ -130,6 +135,26 @@ class WebSocketService {
130135
}
131136

132137

138+
private startHeartbeat(): void {
139+
this.stopHeartbeat();
140+
this.heartbeatTimer = setInterval(() => {
141+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
142+
try {
143+
this.ws.send(JSON.stringify({ type: WebsocketMessageType.PING }));
144+
} catch {
145+
/* onclose handles real drops */
146+
}
147+
}
148+
}, this.heartbeatIntervalMs);
149+
}
150+
151+
private stopHeartbeat(): void {
152+
if (this.heartbeatTimer) {
153+
clearInterval(this.heartbeatTimer);
154+
this.heartbeatTimer = null;
155+
}
156+
}
157+
133158
on(eventType: string, callback: (message: StreamMessage) => void): () => void {
134159
if (!this.listeners.has(eventType)) {
135160
this.listeners.set(eventType, new Set());
@@ -255,6 +280,10 @@ class WebSocketService {
255280
}
256281
break;
257282
}
283+
case WebsocketMessageType.PING: {
284+
// Server keepalive heartbeat — ignore.
285+
break;
286+
}
258287
case WebsocketMessageType.ERROR_MESSAGE: {
259288
this.emit(WebsocketMessageType.ERROR_MESSAGE, message.data); // Emit the data
260289
break;

src/backend/api/router.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,33 @@ async def start_comms(
7272
ws_props["session_id"] = session_id
7373
track_event_if_configured("WebSocket_Connected", ws_props)
7474

75+
# Keepalive: reasoning models (gpt-5.4/-mini) stream nothing during long
76+
# thinking gaps; a periodic frame stops the ingress proxy idle-timing-out
77+
# and dropping the socket (which would lose the final-result message).
78+
HEARTBEAT_INTERVAL_SECONDS = 20
79+
80+
async def _heartbeat() -> None:
81+
while True:
82+
await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
83+
try:
84+
await websocket.send_text(
85+
json.dumps(
86+
{
87+
"type": WebsocketMessageType.PING,
88+
"data": {"ts": asyncio.get_event_loop().time()},
89+
},
90+
default=str,
91+
)
92+
)
93+
except Exception as hb_exc:
94+
logging.debug(
95+
"Heartbeat stopped for user %s, process %s: %s",
96+
user_id, process_id, hb_exc,
97+
)
98+
break
99+
100+
heartbeat_task = asyncio.create_task(_heartbeat())
101+
75102
# Keep the connection open - FastAPI will close the connection if this returns
76103
try:
77104
# Keep the connection open - FastAPI will close the connection if this returns
@@ -95,10 +122,13 @@ async def start_comms(
95122
logging.info(f"Client disconnected from batch {process_id}")
96123
break
97124
except Exception as e:
98-
# Fixed logging syntax - removed the error= parameter
99125
logging.error(f"Error in WebSocket connection: {str(e)}")
100126
finally:
101-
# Always clean up the connection
127+
heartbeat_task.cancel()
128+
try:
129+
await heartbeat_task
130+
except (asyncio.CancelledError, Exception):
131+
pass
102132
await connection_config.close_connection(process_id=process_id)
103133

104134

src/backend/models/messages.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ class WebsocketMessageType(str, Enum):
152152
FINAL_RESULT_MESSAGE = "final_result_message"
153153
TIMEOUT_NOTIFICATION = "timeout_notification"
154154
ERROR_MESSAGE = "error_message"
155+
PING = "ping"
155156

156157

157158
@dataclass(slots=True)

0 commit comments

Comments
 (0)