Skip to content

Commit 510d710

Browse files
fix: address code review feedback for after_timestamp feature
- Add _make_timestamp_filter() helper to eliminate code duplication - Convert datetime filters to ISO strings once (O(1) vs O(n) parsing) - Add unit tests for normalize_datetime_to_server_timezone() - Add unit tests for _make_timestamp_filter() helper - Add behavioral tests that verify actual filtering (not just mock calls) - Add warning log when after_timestamp provided without resend_all - Add Query description for after_timestamp in OpenAPI docs Co-authored-by: openhands <openhands@all-hands.dev>
1 parent c0175b8 commit 510d710

5 files changed

Lines changed: 359 additions & 21 deletions

File tree

openhands-agent-server/openhands/agent_server/event_service.py

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
from collections.abc import Callable
23
from dataclasses import dataclass, field
34
from datetime import datetime
45
from pathlib import Path
@@ -32,6 +33,41 @@
3233
logger = get_logger(__name__)
3334

3435

36+
def _make_timestamp_filter(
37+
timestamp__gte: datetime | None,
38+
timestamp__lt: datetime | None,
39+
) -> Callable[[str], bool] | None:
40+
"""Create a filter function for timestamp comparisons.
41+
42+
Converts datetime filters to ISO strings once, then returns a function
43+
that performs string comparisons. This avoids parsing event.timestamp
44+
on every iteration.
45+
46+
Args:
47+
timestamp__gte: Filter for events with timestamp >= this value
48+
timestamp__lt: Filter for events with timestamp < this value
49+
50+
Returns:
51+
A function that takes an event timestamp (ISO string) and returns
52+
True if it passes the filter, or None if no filtering is needed.
53+
"""
54+
if timestamp__gte is None and timestamp__lt is None:
55+
return None
56+
57+
# Convert datetime filters to ISO strings once (outside the loop)
58+
gte_str = timestamp__gte.isoformat() if timestamp__gte else None
59+
lt_str = timestamp__lt.isoformat() if timestamp__lt else None
60+
61+
def passes_filter(event_timestamp: str) -> bool:
62+
if gte_str is not None and event_timestamp < gte_str:
63+
return False
64+
if lt_str is not None and event_timestamp >= lt_str:
65+
return False
66+
return True
67+
68+
return passes_filter
69+
70+
3571
@dataclass
3672
class EventService:
3773
"""
@@ -107,6 +143,9 @@ def _search_events_sync(
107143
if not self._conversation:
108144
raise ValueError("inactive_service")
109145

146+
# Create timestamp filter once (converts datetimes to ISO strings)
147+
timestamp_filter = _make_timestamp_filter(timestamp__gte, timestamp__lt)
148+
110149
# Collect all events
111150
all_events = []
112151
with self._conversation._state as state:
@@ -128,15 +167,11 @@ def _search_events_sync(
128167
if not self._event_matches_body(event, body):
129168
continue
130169

131-
# Apply timestamp filters using proper datetime comparison.
132-
# Event timestamps are naive (server local time), so filter
133-
# datetimes should also be naive or normalized to server timezone.
134-
if timestamp__gte is not None or timestamp__lt is not None:
135-
event_dt = datetime.fromisoformat(event.timestamp)
136-
if timestamp__gte is not None and event_dt < timestamp__gte:
137-
continue
138-
if timestamp__lt is not None and event_dt >= timestamp__lt:
139-
continue
170+
# Apply timestamp filter (uses string comparison for efficiency)
171+
if timestamp_filter is not None and not timestamp_filter(
172+
event.timestamp
173+
):
174+
continue
140175

141176
all_events.append(event)
142177

@@ -208,6 +243,9 @@ def _count_events_sync(
208243
if not self._conversation:
209244
raise ValueError("inactive_service")
210245

246+
# Create timestamp filter once (converts datetimes to ISO strings)
247+
timestamp_filter = _make_timestamp_filter(timestamp__gte, timestamp__lt)
248+
211249
count = 0
212250
with self._conversation._state as state:
213251
for event in state.events:
@@ -228,15 +266,11 @@ def _count_events_sync(
228266
if not self._event_matches_body(event, body):
229267
continue
230268

231-
# Apply timestamp filters using proper datetime comparison.
232-
# Event timestamps are naive (server local time), so filter
233-
# datetimes should also be naive or normalized to server timezone.
234-
if timestamp__gte is not None or timestamp__lt is not None:
235-
event_dt = datetime.fromisoformat(event.timestamp)
236-
if timestamp__gte is not None and event_dt < timestamp__gte:
237-
continue
238-
if timestamp__lt is not None and event_dt >= timestamp__lt:
239-
continue
269+
# Apply timestamp filter (uses string comparison for efficiency)
270+
if timestamp_filter is not None and not timestamp_filter(
271+
event.timestamp
272+
):
273+
continue
240274

241275
count += 1
242276

openhands-agent-server/openhands/agent_server/sockets.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,16 @@ async def events_socket(
4343
websocket: WebSocket,
4444
session_api_key: Annotated[str | None, Query(alias="session_api_key")] = None,
4545
resend_all: Annotated[bool, Query()] = False,
46-
after_timestamp: Annotated[datetime | None, Query()] = None,
46+
after_timestamp: Annotated[
47+
datetime | None,
48+
Query(
49+
description=(
50+
"Filter events to timestamps >= this value when resend_all=True. "
51+
"Accepts ISO 8601 format. Timezone-aware datetimes are converted "
52+
"to server local time; naive datetimes assumed in server timezone."
53+
)
54+
),
55+
] = None,
4756
):
4857
"""WebSocket endpoint for conversation events.
4958
@@ -84,6 +93,13 @@ async def events_socket(
8493
else None
8594
)
8695

96+
# Warn if after_timestamp is provided without resend_all
97+
if after_timestamp and not resend_all:
98+
logger.warning(
99+
f"after_timestamp provided without resend_all=True, "
100+
f"will be ignored: {conversation_id}"
101+
)
102+
87103
try:
88104
# Resend existing events if requested
89105
if resend_all:

tests/agent_server/test_event_router.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for event_router.py endpoints."""
22

3+
from datetime import UTC, datetime, timezone
34
from pathlib import Path
45
from typing import cast
56
from unittest.mock import AsyncMock, MagicMock
@@ -10,14 +11,76 @@
1011
from fastapi.testclient import TestClient
1112

1213
from openhands.agent_server.dependencies import get_event_service
13-
from openhands.agent_server.event_router import event_router
14+
from openhands.agent_server.event_router import (
15+
event_router,
16+
normalize_datetime_to_server_timezone,
17+
)
1418
from openhands.agent_server.event_service import EventService
1519
from openhands.agent_server.models import SendMessageRequest
1620
from openhands.sdk import Message
1721
from openhands.sdk.event.llm_convertible.message import MessageEvent
1822
from openhands.sdk.llm.message import ImageContent, TextContent
1923

2024

25+
class TestNormalizeDatetimeToServerTimezone:
26+
"""Unit tests for normalize_datetime_to_server_timezone function."""
27+
28+
def test_naive_datetime_passthrough(self):
29+
"""Naive datetimes should be returned unchanged."""
30+
naive_dt = datetime(2025, 1, 15, 10, 30, 0)
31+
result = normalize_datetime_to_server_timezone(naive_dt)
32+
33+
assert result == naive_dt
34+
assert result.tzinfo is None
35+
36+
def test_utc_datetime_converted_to_naive(self):
37+
"""UTC datetime should be converted to server local time and made naive."""
38+
utc_dt = datetime(2025, 1, 15, 10, 30, 0, tzinfo=UTC)
39+
result = normalize_datetime_to_server_timezone(utc_dt)
40+
41+
# Result should be naive
42+
assert result.tzinfo is None
43+
# Result should represent the same instant converted to local time
44+
expected = utc_dt.astimezone(None).replace(tzinfo=None)
45+
assert result == expected
46+
47+
def test_non_utc_timezone_converted_to_naive(self):
48+
"""Non-UTC timezone-aware datetime should be converted to server local time."""
49+
# Create a timezone-aware datetime in UTC
50+
aware_dt = datetime(2025, 6, 15, 18, 30, 0, tzinfo=UTC)
51+
52+
result = normalize_datetime_to_server_timezone(aware_dt)
53+
54+
# Result should be naive
55+
assert result.tzinfo is None
56+
# Result should represent the same instant in server local time
57+
expected = aware_dt.astimezone(None).replace(tzinfo=None)
58+
assert result == expected
59+
60+
def test_preserves_microseconds(self):
61+
"""Microseconds should be preserved through conversion."""
62+
utc_dt = datetime(2025, 1, 15, 10, 30, 0, 123456, tzinfo=UTC)
63+
result = normalize_datetime_to_server_timezone(utc_dt)
64+
65+
assert result.microsecond == 123456
66+
67+
def test_different_fixed_offset_timezone(self):
68+
"""Test with a specific fixed offset timezone."""
69+
# UTC+5:30 (like India Standard Time)
70+
from datetime import timedelta
71+
72+
ist = timezone(timedelta(hours=5, minutes=30))
73+
ist_dt = datetime(2025, 1, 15, 16, 0, 0, tzinfo=ist) # 4:00 PM IST
74+
75+
result = normalize_datetime_to_server_timezone(ist_dt)
76+
77+
# Should be naive
78+
assert result.tzinfo is None
79+
# Should be the same instant converted to server local time
80+
expected = ist_dt.astimezone(None).replace(tzinfo=None)
81+
assert result == expected
82+
83+
2184
@pytest.fixture
2285
def client():
2386
"""Create a test client for the FastAPI app without authentication."""

tests/agent_server/test_event_router_websocket.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,3 +647,162 @@ async def test_resend_all_without_after_timestamp(
647647
mock_event_service.search_events.assert_called_once_with(
648648
page_id=None, timestamp__gte=None
649649
)
650+
651+
652+
class TestAfterTimestampFilteringBehavioral:
653+
"""Behavioral tests that verify actual filtering works with real events.
654+
655+
These tests use real EventService instances with mock conversations
656+
to verify that timestamp filtering actually produces correct results,
657+
not just that the right methods were called.
658+
"""
659+
660+
@pytest.fixture
661+
def event_service_with_timestamped_events(self):
662+
"""Create a real EventService with timestamped events for testing."""
663+
from pathlib import Path
664+
665+
from openhands.agent_server.event_service import EventService
666+
from openhands.agent_server.models import StoredConversation
667+
from openhands.sdk import LLM, Agent
668+
from openhands.sdk.security.confirmation_policy import NeverConfirm
669+
from openhands.sdk.workspace import LocalWorkspace
670+
671+
stored = StoredConversation(
672+
id=uuid4(),
673+
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
674+
workspace=LocalWorkspace(working_dir="workspace/project"),
675+
confirmation_policy=NeverConfirm(),
676+
initial_message=None,
677+
metrics=None,
678+
)
679+
service = EventService(
680+
stored=stored, conversations_dir=Path("test_conversation_dir")
681+
)
682+
683+
# Create mock conversation with timestamped events
684+
from unittest.mock import MagicMock
685+
686+
from openhands.sdk import Conversation
687+
from openhands.sdk.conversation.state import ConversationState
688+
689+
conversation = MagicMock(spec=Conversation)
690+
state = MagicMock(spec=ConversationState)
691+
692+
# Events with specific timestamps spanning 10:00 to 14:00
693+
timestamps = [
694+
"2025-01-01T10:00:00.000000",
695+
"2025-01-01T11:00:00.000000",
696+
"2025-01-01T12:00:00.000000",
697+
"2025-01-01T13:00:00.000000",
698+
"2025-01-01T14:00:00.000000",
699+
]
700+
701+
events = []
702+
for index, timestamp in enumerate(timestamps, 1):
703+
event = MessageEvent(
704+
id=f"event{index}",
705+
source="user",
706+
llm_message=Message(
707+
role="user", content=[TextContent(text=f"Message {index}")]
708+
),
709+
timestamp=timestamp,
710+
)
711+
events.append(event)
712+
713+
state.events = events
714+
state.__enter__ = MagicMock(return_value=state)
715+
state.__exit__ = MagicMock(return_value=None)
716+
conversation._state = state
717+
718+
service._conversation = conversation
719+
return service
720+
721+
@pytest.mark.asyncio
722+
async def test_timestamp_filter_returns_correct_events(
723+
self, event_service_with_timestamped_events
724+
):
725+
"""Test that timestamp filtering returns only events >= the filter time."""
726+
from datetime import datetime
727+
728+
service = event_service_with_timestamped_events
729+
730+
# Filter for events >= 12:00:00 (should return events 3, 4, 5)
731+
filter_time = datetime(2025, 1, 1, 12, 0, 0)
732+
result = await service.search_events(timestamp__gte=filter_time)
733+
734+
# Should return exactly 3 events
735+
assert len(result.items) == 3
736+
737+
# Verify the correct events were returned
738+
returned_ids = [event.id for event in result.items]
739+
assert "event3" in returned_ids
740+
assert "event4" in returned_ids
741+
assert "event5" in returned_ids
742+
743+
# Events 1 and 2 should NOT be returned
744+
assert "event1" not in returned_ids
745+
assert "event2" not in returned_ids
746+
747+
@pytest.mark.asyncio
748+
async def test_timestamp_filter_boundary_condition(
749+
self, event_service_with_timestamped_events
750+
):
751+
"""Test that filter boundary is inclusive (>=)."""
752+
from datetime import datetime
753+
754+
service = event_service_with_timestamped_events
755+
756+
# Filter for events >= exactly 12:00:00 (event3's timestamp)
757+
filter_time = datetime(2025, 1, 1, 12, 0, 0)
758+
result = await service.search_events(timestamp__gte=filter_time)
759+
760+
# Event3 at exactly 12:00:00 should be included
761+
returned_ids = [event.id for event in result.items]
762+
assert "event3" in returned_ids
763+
764+
@pytest.mark.asyncio
765+
async def test_timestamp_filter_no_matches(
766+
self, event_service_with_timestamped_events
767+
):
768+
"""Test that filter returns empty when no events match."""
769+
from datetime import datetime
770+
771+
service = event_service_with_timestamped_events
772+
773+
# Filter for events >= 15:00:00 (no events exist after 14:00)
774+
filter_time = datetime(2025, 1, 1, 15, 0, 0)
775+
result = await service.search_events(timestamp__gte=filter_time)
776+
777+
assert len(result.items) == 0
778+
779+
@pytest.mark.asyncio
780+
async def test_timestamp_filter_all_events_match(
781+
self, event_service_with_timestamped_events
782+
):
783+
"""Test that all events are returned when filter is before all events."""
784+
from datetime import datetime
785+
786+
service = event_service_with_timestamped_events
787+
788+
# Filter for events >= 09:00:00 (before all events)
789+
filter_time = datetime(2025, 1, 1, 9, 0, 0)
790+
result = await service.search_events(timestamp__gte=filter_time)
791+
792+
# Should return all 5 events
793+
assert len(result.items) == 5
794+
795+
@pytest.mark.asyncio
796+
async def test_count_events_with_timestamp_filter(
797+
self, event_service_with_timestamped_events
798+
):
799+
"""Test that count_events also respects timestamp filtering."""
800+
from datetime import datetime
801+
802+
service = event_service_with_timestamped_events
803+
804+
# Count events >= 12:00:00 (should be 3)
805+
filter_time = datetime(2025, 1, 1, 12, 0, 0)
806+
count = await service.count_events(timestamp__gte=filter_time)
807+
808+
assert count == 3

0 commit comments

Comments
 (0)