diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py index f5a1011d..e50bcad0 100644 --- a/sdks/python/src/honcho/aio.py +++ b/sdks/python/src/honcho/aio.py @@ -1256,10 +1256,12 @@ class SessionAio(AsyncMetadataConfigMixin): routes.messages(self._session.workspace_id, self._session.id), body={"messages": messages_data}, ) - return [ + created_messages = [ Message.from_api_response(MessageResponse.model_validate(msg)) for msg in data ] + self._session._update_last_message_at_from_messages(created_messages) + return created_messages async def messages( self, @@ -1559,10 +1561,12 @@ class SessionAio(AsyncMetadataConfigMixin): data=data_dict, ) - return [ + created_messages = [ Message.from_api_response(MessageResponse.model_validate(msg)) for msg in response ] + self._session._update_last_message_at_from_messages(created_messages) + return created_messages @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) async def representation( diff --git a/sdks/python/src/honcho/session.py b/sdks/python/src/honcho/session.py index d94972a1..28415329 100644 --- a/sdks/python/src/honcho/session.py +++ b/sdks/python/src/honcho/session.py @@ -127,6 +127,17 @@ class Session(SessionBase, MetadataConfigMixin): self._last_message_at = session.last_message_at self._is_active = session.is_active + def _update_last_message_at_from_messages( + self, messages: Sequence[Message] + ) -> None: + """Advance the cached activity timestamp from locally written messages.""" + if not messages: + return + + newest_message_at = max(message.created_at for message in messages) + if self._last_message_at is None or newest_message_at > self._last_message_at: + self._last_message_at = newest_message_at + def get_metadata(self) -> dict[str, object]: """ Get metadata from the server and update the cache. @@ -451,10 +462,12 @@ class Session(SessionBase, MetadataConfigMixin): routes.messages(self.workspace_id, self.id), body={"messages": messages_data}, ) - return [ + created_messages = [ Message.from_api_response(MessageResponse.model_validate(msg)) for msg in data ] + self._update_last_message_at_from_messages(created_messages) + return created_messages @validate_call def messages( @@ -906,10 +919,12 @@ class Session(SessionBase, MetadataConfigMixin): data=data_dict, ) - return [ + created_messages = [ Message.from_api_response(MessageResponse.model_validate(msg)) for msg in response ] + self._update_last_message_at_from_messages(created_messages) + return created_messages @validate_call(config=ConfigDict(arbitrary_types_allowed=True)) def representation( diff --git a/sdks/typescript/__tests__/peer.test.ts b/sdks/typescript/__tests__/peer.test.ts index 5d0c89da..e754dd83 100644 --- a/sdks/typescript/__tests__/peer.test.ts +++ b/sdks/typescript/__tests__/peer.test.ts @@ -222,7 +222,7 @@ describe('Peer', () => { const sessionIds = sessions.items.map((s) => s.id) expect(sessionIds).toContain('peer-sessions-test') const returned = sessions.items.find((item) => item.id === session.id) - expect(returned?.lastMessageAt).toBeDefined() + expect(typeof returned?.lastMessageAt).toBe('string') }) test('sessions returns empty for peer in no sessions', async () => { diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts index c473ef69..3f004f88 100644 --- a/sdks/typescript/src/types/api.ts +++ b/sdks/typescript/src/types/api.ts @@ -126,7 +126,7 @@ export interface SessionResponse { metadata: Record configuration: SessionConfigApi created_at: string - last_message_at?: string | null + last_message_at: string | null } export interface SessionCreateParams { diff --git a/tests/sdk/test_file_uploads.py b/tests/sdk/test_file_uploads.py index 777abaef..391ac86c 100644 --- a/tests/sdk/test_file_uploads.py +++ b/tests/sdk/test_file_uploads.py @@ -48,6 +48,7 @@ async def test_session_upload_file( assert text_content in messages[0].content assert messages[0].peer_id == user.id assert messages[0].session_id == session.id + assert session.last_message_at == max(message.created_at for message in messages) @pytest.mark.asyncio diff --git a/tests/sdk/test_session.py b/tests/sdk/test_session.py index 4269e2dd..cc9de362 100644 --- a/tests/sdk/test_session.py +++ b/tests/sdk/test_session.py @@ -1,15 +1,104 @@ import datetime +from types import SimpleNamespace from typing import cast +from unittest.mock import AsyncMock, MagicMock import pytest -from sdks.python.src.honcho.api_types import QueueStatusResponse +from sdks.python.src.honcho.api_types import MessageCreateParams, QueueStatusResponse from sdks.python.src.honcho.client import Honcho from sdks.python.src.honcho.message import Message from sdks.python.src.honcho.peer import Peer from sdks.python.src.honcho.session import Session, SessionPeerConfig +def _message_response( + message_id: str, created_at: datetime.datetime +) -> dict[str, object]: + """Build a complete message response fixture for SDK boundary tests.""" + return { + "id": message_id, + "content": message_id, + "peer_id": "peer-1", + "session_id": "session-1", + "workspace_id": "workspace-1", + "metadata": {}, + "created_at": created_at, + "token_count": 1, + } + + +def _session_write_honcho() -> ( + tuple[SimpleNamespace, datetime.datetime, datetime.datetime] +): + """Build sync and async HTTP stubs for local session write tests.""" + newest_time = datetime.datetime(2026, 1, 10, 12, 0, tzinfo=datetime.timezone.utc) + older_time = datetime.datetime(2026, 1, 5, 12, 0, tzinfo=datetime.timezone.utc) + older_response = [_message_response("backdated activity", older_time)] + newest_response = [_message_response("newest activity", newest_time)] + sync_http = SimpleNamespace( + post=MagicMock(side_effect=[older_response, older_response]), + upload=MagicMock(side_effect=[newest_response, older_response]), + ) + async_http = SimpleNamespace( + post=AsyncMock(side_effect=[older_response, older_response]), + upload=AsyncMock(side_effect=[newest_response, older_response]), + ) + honcho = SimpleNamespace( + workspace_id="workspace-1", + _http=sync_http, + _async_http_client=async_http, + _ensure_workspace=MagicMock(return_value=None), + _ensure_workspace_async=AsyncMock(return_value=None), + ) + return honcho, older_time, newest_time + + +def test_session_sync_writes_update_last_message_at_monotonically() -> None: + """Sync message and file writes keep the newest cached activity timestamp.""" + honcho, older_time, newest_time = _session_write_honcho() + session = Session("session-1", honcho) + + session.add_messages(MessageCreateParams(content="activity", peer_id="peer-1")) + assert session.last_message_at == older_time + + session.upload_file(("activity.txt", b"activity", "text/plain"), peer="peer-1") + assert session.last_message_at == newest_time + + session.add_messages(MessageCreateParams(content="activity", peer_id="peer-1")) + assert session.last_message_at == newest_time + + session.upload_file(("activity.txt", b"activity", "text/plain"), peer="peer-1") + assert session.last_message_at == newest_time + + +@pytest.mark.asyncio +async def test_session_async_writes_update_last_message_at_monotonically() -> None: + """Async message and file writes keep the newest cached activity timestamp.""" + honcho, older_time, newest_time = _session_write_honcho() + session = Session("session-1", honcho) + + await session.aio.add_messages( + MessageCreateParams(content="activity", peer_id="peer-1") + ) + assert session.last_message_at == older_time + + await session.aio.upload_file( + ("activity.txt", b"activity", "text/plain"), peer="peer-1" + ) + assert session.last_message_at == newest_time + + await session.aio.add_messages( + MessageCreateParams(content="activity", peer_id="peer-1") + ) + assert session.last_message_at == newest_time + + await session.aio.upload_file( + ("activity.txt", b"activity", "text/plain"), peer="peer-1" + ) + assert session.last_message_at == newest_time + + @pytest.mark.asyncio async def test_session_metadata(client_fixture: tuple[Honcho, str]): """ @@ -729,12 +818,14 @@ async def test_session_clone(client_fixture: tuple[Honcho, str]): cloned = await session.aio.clone() assert isinstance(cloned, Session) assert cloned.id != session.id # Should have a different ID - assert cloned.last_message_at is not None # Verify cloned session has the same messages cloned_messages_page = await cloned.aio.messages() cloned_messages = cloned_messages_page.items assert len(cloned_messages) == 2 + assert cloned.last_message_at == max( + message.created_at for message in cloned_messages + ) # Verify original session still has messages original_messages_page = await session.aio.messages() @@ -758,12 +849,14 @@ async def test_session_clone(client_fixture: tuple[Honcho, str]): cloned = session.clone() assert isinstance(cloned, Session) assert cloned.id != session.id # Should have a different ID - assert cloned.last_message_at is not None # Verify cloned session has the same messages cloned_messages_page = cloned.messages() cloned_messages = list(cloned_messages_page) assert len(cloned_messages) == 2 + assert cloned.last_message_at == max( + message.created_at for message in cloned_messages + ) # Verify original session still has messages original_messages_page = session.messages()