Skip to content

Commit 6762ac3

Browse files
Fix event ordering in RemoteEventsList by inserting events sorted by timestamp (#1737)
Co-authored-by: openhands <[email protected]>
1 parent 5734ec0 commit 6762ac3

File tree

2 files changed

+144
-3
lines changed

2 files changed

+144
-3
lines changed

openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import bisect
23
import json
34
import os
45
import threading
@@ -219,13 +220,25 @@ def _do_full_sync(self) -> None:
219220
logger.debug(f"Full sync completed, {len(events)} events cached")
220221

221222
def add_event(self, event: Event) -> None:
222-
"""Add a new event to the local cache (called by WebSocket callback)."""
223+
"""Add a new event to the local cache (called by WebSocket callback).
224+
225+
Events are inserted in sorted order by timestamp to maintain correct
226+
temporal ordering regardless of WebSocket delivery order.
227+
"""
223228
with self._lock:
224229
# Check if event already exists to avoid duplicates
225230
if event.id not in self._cached_event_ids:
226-
self._cached_events.append(event)
231+
# Use bisect with key function for O(log N) insertion
232+
# This ensures events are always ordered correctly even if
233+
# WebSocket delivers them out of order
234+
insert_pos = bisect.bisect_right(
235+
self._cached_events, event.timestamp, key=lambda e: e.timestamp
236+
)
237+
self._cached_events.insert(insert_pos, event)
227238
self._cached_event_ids.add(event.id)
228-
logger.debug(f"Added event {event.id} to local cache")
239+
logger.debug(
240+
f"Added event {event.id} to local cache at position {insert_pos}"
241+
)
229242

230243
def append(self, event: Event) -> None:
231244
"""Add a new event to the list (for compatibility with EventLog interface)."""

tests/sdk/conversation/remote/test_remote_events_list.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,131 @@ def test_remote_events_list_empty(mock_client, conversation_id):
177177

178178
with pytest.raises(IndexError):
179179
_ = events_list[0]
180+
181+
182+
def test_remote_events_list_maintains_timestamp_order(mock_client, conversation_id):
183+
"""Test that events are inserted in sorted order by timestamp.
184+
185+
This tests the fix for the race condition where WebSocket might deliver
186+
events out of order (e.g., ActionEvent arriving before MessageEvent).
187+
"""
188+
mock_response = create_mock_api_response([])
189+
mock_client.request.return_value = mock_response
190+
191+
events_list = RemoteEventsList(mock_client, conversation_id)
192+
193+
# Create events with specific timestamps (out of order)
194+
event1 = MessageEvent(
195+
id="event-1",
196+
timestamp="2024-01-01T10:00:00", # First chronologically
197+
source="user",
198+
llm_message=Message(role="user", content=[TextContent(text="Hello")]),
199+
)
200+
event2 = MessageEvent(
201+
id="event-2",
202+
timestamp="2024-01-01T10:00:02", # Third chronologically
203+
source="agent",
204+
llm_message=Message(role="assistant", content=[TextContent(text="Response")]),
205+
)
206+
event3 = MessageEvent(
207+
id="event-3",
208+
timestamp="2024-01-01T10:00:01", # Second chronologically
209+
source="agent",
210+
llm_message=Message(role="assistant", content=[TextContent(text="Action")]),
211+
)
212+
213+
# Add events in wrong order (simulating WebSocket out-of-order delivery)
214+
events_list.add_event(event2) # Add third event first
215+
events_list.add_event(event1) # Add first event second
216+
events_list.add_event(event3) # Add second event last
217+
218+
# Events should be sorted by timestamp regardless of insertion order
219+
assert len(events_list) == 3
220+
assert events_list[0].id == "event-1" # 10:00:00
221+
assert events_list[1].id == "event-3" # 10:00:01
222+
assert events_list[2].id == "event-2" # 10:00:02
223+
224+
225+
def test_remote_events_list_timestamp_order_with_existing_events(
226+
mock_client, conversation_id
227+
):
228+
"""Test that new events are inserted in correct position among existing events."""
229+
# Start with some events already loaded
230+
initial_events: list[Event] = [
231+
MessageEvent(
232+
id="initial-1",
233+
timestamp="2024-01-01T10:00:00",
234+
source="user",
235+
llm_message=Message(role="user", content=[TextContent(text="First")]),
236+
),
237+
MessageEvent(
238+
id="initial-2",
239+
timestamp="2024-01-01T10:00:02",
240+
source="agent",
241+
llm_message=Message(role="assistant", content=[TextContent(text="Third")]),
242+
),
243+
]
244+
245+
mock_response = create_mock_api_response(initial_events)
246+
mock_client.request.return_value = mock_response
247+
248+
events_list = RemoteEventsList(mock_client, conversation_id)
249+
assert len(events_list) == 2
250+
251+
# Add an event that should be inserted in the middle
252+
middle_event = MessageEvent(
253+
id="middle",
254+
timestamp="2024-01-01T10:00:01", # Between initial-1 and initial-2
255+
source="agent",
256+
llm_message=Message(role="assistant", content=[TextContent(text="Middle")]),
257+
)
258+
events_list.add_event(middle_event)
259+
260+
assert len(events_list) == 3
261+
assert events_list[0].id == "initial-1"
262+
assert events_list[1].id == "middle"
263+
assert events_list[2].id == "initial-2"
264+
265+
266+
def test_remote_events_list_identical_timestamps_stable_order(
267+
mock_client, conversation_id
268+
):
269+
"""Test that events with identical timestamps maintain insertion order."""
270+
mock_response = create_mock_api_response([])
271+
mock_client.request.return_value = mock_response
272+
273+
events_list = RemoteEventsList(mock_client, conversation_id)
274+
275+
# Create events with identical timestamps
276+
same_timestamp = "2024-01-01T10:00:00"
277+
event1 = MessageEvent(
278+
id="event-1",
279+
timestamp=same_timestamp,
280+
source="user",
281+
llm_message=Message(role="user", content=[TextContent(text="First")]),
282+
)
283+
event2 = MessageEvent(
284+
id="event-2",
285+
timestamp=same_timestamp,
286+
source="agent",
287+
llm_message=Message(role="assistant", content=[TextContent(text="Second")]),
288+
)
289+
event3 = MessageEvent(
290+
id="event-3",
291+
timestamp=same_timestamp,
292+
source="agent",
293+
llm_message=Message(role="assistant", content=[TextContent(text="Third")]),
294+
)
295+
296+
# Add events in order
297+
events_list.add_event(event1)
298+
events_list.add_event(event2)
299+
events_list.add_event(event3)
300+
301+
# Events with identical timestamps should maintain insertion order.
302+
# bisect_right ensures new events are inserted after existing ones
303+
# with the same timestamp.
304+
assert len(events_list) == 3
305+
assert events_list[0].id == "event-1"
306+
assert events_list[1].id == "event-2"
307+
assert events_list[2].id == "event-3"

0 commit comments

Comments
 (0)