Skip to content

Commit 0e68bfb

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 98df5a6 commit 0e68bfb

File tree

4 files changed

+307
-3
lines changed

4 files changed

+307
-3
lines changed

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,16 @@ async def events_socket(
9494
websocket: WebSocket,
9595
session_api_key: Annotated[str | None, Query(alias="session_api_key")] = None,
9696
resend_all: Annotated[bool, Query()] = False,
97-
after_timestamp: Annotated[datetime | None, Query()] = None,
97+
after_timestamp: Annotated[
98+
datetime | None,
99+
Query(
100+
description=(
101+
"Filter events to timestamps >= this value when resend_all=True. "
102+
"Accepts ISO 8601 format. Timezone-aware datetimes are converted "
103+
"to server local time; naive datetimes assumed in server timezone."
104+
)
105+
),
106+
] = None,
98107
):
99108
"""WebSocket endpoint for conversation events.
100109
@@ -130,6 +139,13 @@ async def events_socket(
130139
else None
131140
)
132141

142+
# Warn if after_timestamp is provided without resend_all
143+
if after_timestamp and not resend_all:
144+
logger.warning(
145+
f"after_timestamp provided without resend_all=True, "
146+
f"will be ignored: {conversation_id}"
147+
)
148+
133149
try:
134150
# Resend existing events if requested
135151
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

tests/agent_server/test_event_service.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import pytest
1010

11-
from openhands.agent_server.event_service import EventService
11+
from openhands.agent_server.event_service import EventService, _make_timestamp_filter
1212
from openhands.agent_server.models import (
1313
ConfirmationResponseRequest,
1414
EventPage,
@@ -29,6 +29,72 @@
2929
from openhands.sdk.workspace import LocalWorkspace
3030

3131

32+
class TestMakeTimestampFilter:
33+
"""Unit tests for _make_timestamp_filter helper function."""
34+
35+
def test_no_filters_returns_none(self):
36+
"""When no filters provided, should return None."""
37+
result = _make_timestamp_filter(None, None)
38+
assert result is None
39+
40+
def test_gte_filter_only(self):
41+
"""Test filtering with only timestamp__gte."""
42+
filter_time = datetime(2025, 1, 1, 12, 0, 0)
43+
filter_fn = _make_timestamp_filter(filter_time, None)
44+
45+
assert filter_fn is not None
46+
# Event at 11:00 should be excluded
47+
assert filter_fn("2025-01-01T11:00:00.000000") is False
48+
# Event at exactly 12:00 should be included
49+
assert filter_fn("2025-01-01T12:00:00.000000") is True
50+
# Event at 13:00 should be included
51+
assert filter_fn("2025-01-01T13:00:00.000000") is True
52+
53+
def test_lt_filter_only(self):
54+
"""Test filtering with only timestamp__lt."""
55+
filter_time = datetime(2025, 1, 1, 12, 0, 0)
56+
filter_fn = _make_timestamp_filter(None, filter_time)
57+
58+
assert filter_fn is not None
59+
# Event at 11:00 should be included
60+
assert filter_fn("2025-01-01T11:00:00.000000") is True
61+
# Event at exactly 12:00 should be excluded
62+
assert filter_fn("2025-01-01T12:00:00.000000") is False
63+
# Event at 13:00 should be excluded
64+
assert filter_fn("2025-01-01T13:00:00.000000") is False
65+
66+
def test_range_filter(self):
67+
"""Test filtering with both gte and lt (range filter)."""
68+
gte_time = datetime(2025, 1, 1, 11, 0, 0)
69+
lt_time = datetime(2025, 1, 1, 13, 0, 0)
70+
filter_fn = _make_timestamp_filter(gte_time, lt_time)
71+
72+
assert filter_fn is not None
73+
# Event at 10:00 should be excluded (before range)
74+
assert filter_fn("2025-01-01T10:00:00.000000") is False
75+
# Event at 11:00 should be included (start of range, inclusive)
76+
assert filter_fn("2025-01-01T11:00:00.000000") is True
77+
# Event at 12:00 should be included (middle of range)
78+
assert filter_fn("2025-01-01T12:00:00.000000") is True
79+
# Event at 13:00 should be excluded (end of range, exclusive)
80+
assert filter_fn("2025-01-01T13:00:00.000000") is False
81+
# Event at 14:00 should be excluded (after range)
82+
assert filter_fn("2025-01-01T14:00:00.000000") is False
83+
84+
def test_microseconds_handled_correctly(self):
85+
"""Test that microseconds are handled in string comparison."""
86+
filter_time = datetime(2025, 1, 1, 12, 0, 0, 500000) # 12:00:00.500000
87+
filter_fn = _make_timestamp_filter(filter_time, None)
88+
89+
assert filter_fn is not None
90+
# Event at 12:00:00.400000 should be excluded
91+
assert filter_fn("2025-01-01T12:00:00.400000") is False
92+
# Event at 12:00:00.500000 should be included (exact match)
93+
assert filter_fn("2025-01-01T12:00:00.500000") is True
94+
# Event at 12:00:00.600000 should be included
95+
assert filter_fn("2025-01-01T12:00:00.600000") is True
96+
97+
3298
@pytest.fixture
3399
def sample_stored_conversation():
34100
"""Create a sample StoredConversation for testing."""

0 commit comments

Comments
 (0)