Merge c8f52e1f9e into 699c99368d
This commit is contained in:
commit
29f69c365e
|
|
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Session responses now expose nullable `last_message_at`, backfilled and maintained from the newest message timestamp. `POST /v3/workspaces/{workspace_id}/sessions/list` accepts `sort_by=created_at|last_message_at` alongside the existing `reverse` parameter, with stable ID tie-breaking and sessions without messages placed last in either direction (#965).
|
||||
|
||||
## [3.1.1] - 2026-09-02
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -1095,6 +1095,19 @@
|
|||
},
|
||||
"description": "Whether to reverse the order of results"
|
||||
},
|
||||
{
|
||||
"name": "sort_by",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"enum": ["created_at", "last_message_at"],
|
||||
"type": "string",
|
||||
"description": "Session timestamp used to order results",
|
||||
"default": "created_at",
|
||||
"title": "Sort By"
|
||||
},
|
||||
"description": "Session timestamp used to order results"
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
|
|
@ -4016,6 +4029,13 @@
|
|||
"type": "string",
|
||||
"format": "date-time",
|
||||
"title": "Created At"
|
||||
},
|
||||
"last_message_at": {
|
||||
"anyOf": [
|
||||
{ "type": "string", "format": "date-time" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"title": "Last Message At"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
"""add session last_message_at
|
||||
|
||||
Revision ID: cfaff339d519
|
||||
Revises: e4eba9cfaa6f
|
||||
Create Date: 2026-08-22
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from migrations.utils import get_schema
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "cfaff339d519"
|
||||
down_revision: str | None = "e4eba9cfaa6f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
schema = get_schema()
|
||||
INDEX_NAME = "ix_sessions_workspace_last_message_at"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add the nullable session activity timestamp."""
|
||||
op.add_column(
|
||||
"sessions",
|
||||
sa.Column("last_message_at", sa.DateTime(timezone=True), nullable=True),
|
||||
schema=schema,
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"""
|
||||
UPDATE "{schema}"."sessions" AS session
|
||||
SET last_message_at = activity.last_message_at
|
||||
FROM (
|
||||
SELECT workspace_name, session_name, MAX(created_at) AS last_message_at
|
||||
FROM "{schema}"."messages"
|
||||
GROUP BY workspace_name, session_name
|
||||
) AS activity
|
||||
WHERE session.workspace_name = activity.workspace_name
|
||||
AND session.name = activity.session_name
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.create_index(
|
||||
INDEX_NAME,
|
||||
"sessions",
|
||||
[
|
||||
"workspace_name",
|
||||
sa.text("last_message_at DESC NULLS LAST"),
|
||||
sa.text("id DESC"),
|
||||
],
|
||||
unique=False,
|
||||
schema=schema,
|
||||
postgresql_where=sa.text("is_active"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove the session activity timestamp."""
|
||||
op.drop_index(INDEX_NAME, table_name="sessions", schema=schema)
|
||||
op.drop_column("sessions", "last_message_at", schema=schema)
|
||||
|
|
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
### Added
|
||||
|
||||
- `Session.last_message_at` exposes the newest message timestamp, and sync/async `Honcho.sessions()` accept `sort_by="created_at" | "last_message_at"` while preserving `reverse` across pagination. Requires a Honcho server with the matching API support.
|
||||
- Optional per-call `timeout` on synchronous and asynchronous `Peer.chat()`. It overrides the timeout for each HTTP attempt; when omitted or set to `None`, the client-wide timeout configured on `Honcho` remains in effect.
|
||||
|
||||
## [2.4.0] - 2026-08-25
|
||||
|
|
|
|||
|
|
@ -321,6 +321,7 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
session_data.configuration.model_dump()
|
||||
),
|
||||
created_at=session_data.created_at,
|
||||
last_message_at=session_data.last_message_at,
|
||||
is_active=session_data.is_active,
|
||||
)
|
||||
|
||||
|
|
@ -331,6 +332,7 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
page: int = 1,
|
||||
size: int = 50,
|
||||
reverse: bool = False,
|
||||
sort_by: Literal["created_at", "last_message_at"] = "created_at",
|
||||
) -> AsyncPage[SessionResponse, Session]:
|
||||
"""
|
||||
Get all sessions in the current workspace asynchronously.
|
||||
|
|
@ -340,11 +342,14 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
page: Page number (1-indexed). Default: 1.
|
||||
size: Number of items per page. Default: 50.
|
||||
reverse: If True, reverses the default ordering. Default: False.
|
||||
sort_by: Session timestamp used for ordering.
|
||||
"""
|
||||
await self._honcho._ensure_workspace_async()
|
||||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
if sort_by != "created_at":
|
||||
query["sort_by"] = sort_by
|
||||
data = await self._honcho._async_http_client.post(
|
||||
routes.sessions_list(self._honcho.workspace_id),
|
||||
body={"filters": filters} if filters else None,
|
||||
|
|
@ -359,6 +364,7 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
metadata=session.metadata,
|
||||
configuration=session.configuration,
|
||||
created_at=session.created_at,
|
||||
last_message_at=session.last_message_at,
|
||||
is_active=session.is_active,
|
||||
)
|
||||
|
||||
|
|
@ -367,6 +373,8 @@ class HonchoAio(AsyncMetadataConfigMixin):
|
|||
next_query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
next_query["reverse"] = "true"
|
||||
if sort_by != "created_at":
|
||||
next_query["sort_by"] = sort_by
|
||||
next_data = await self._honcho._async_http_client.post(
|
||||
routes.sessions_list(self._honcho.workspace_id),
|
||||
body={"filters": filters} if filters else None,
|
||||
|
|
@ -930,6 +938,7 @@ class PeerAio(AsyncMetadataConfigMixin):
|
|||
session.configuration.model_dump()
|
||||
),
|
||||
created_at=session.created_at,
|
||||
last_message_at=session.last_message_at,
|
||||
is_active=session.is_active,
|
||||
)
|
||||
|
||||
|
|
@ -1172,6 +1181,7 @@ class SessionAio(AsyncMetadataConfigMixin):
|
|||
session.configuration.model_dump()
|
||||
)
|
||||
self._session._created_at = session.created_at
|
||||
self._session._last_message_at = session.last_message_at
|
||||
self._session._is_active = session.is_active
|
||||
|
||||
async def get_metadata(self) -> dict[str, object]:
|
||||
|
|
@ -1327,10 +1337,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,
|
||||
|
|
@ -1392,6 +1404,7 @@ class SessionAio(AsyncMetadataConfigMixin):
|
|||
metadata=cloned.metadata,
|
||||
configuration=cloned.configuration,
|
||||
created_at=cloned.created_at,
|
||||
last_message_at=cloned.last_message_at,
|
||||
is_active=cloned.is_active,
|
||||
)
|
||||
|
||||
|
|
@ -1629,10 +1642,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(
|
||||
|
|
@ -1985,6 +2000,7 @@ class ScopeAio:
|
|||
metadata=response.metadata,
|
||||
configuration=response.configuration,
|
||||
created_at=response.created_at,
|
||||
last_message_at=response.last_message_at,
|
||||
is_active=response.is_active,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -265,6 +265,7 @@ class SessionResponse(BaseModel):
|
|||
default_factory=SessionConfigurationResponse
|
||||
)
|
||||
created_at: datetime.datetime
|
||||
last_message_at: datetime.datetime | None = None
|
||||
|
||||
|
||||
class SessionCreateParams(BaseModel):
|
||||
|
|
|
|||
|
|
@ -475,6 +475,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
session_data.configuration.model_dump()
|
||||
),
|
||||
created_at=session_data.created_at,
|
||||
last_message_at=session_data.last_message_at,
|
||||
is_active=session_data.is_active,
|
||||
)
|
||||
|
||||
|
|
@ -485,6 +486,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
page: int = 1,
|
||||
size: int = 50,
|
||||
reverse: bool = False,
|
||||
sort_by: Literal["created_at", "last_message_at"] = "created_at",
|
||||
) -> SyncPage[SessionResponse, Session]:
|
||||
"""
|
||||
Get all sessions in the current workspace.
|
||||
|
|
@ -494,6 +496,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
page: Page number (1-indexed). Default: 1.
|
||||
size: Number of items per page. Default: 50.
|
||||
reverse: If True, reverses the default ordering. Default: False.
|
||||
sort_by: Session timestamp used for ordering.
|
||||
|
||||
Returns:
|
||||
A SyncPage of Session objects representing all sessions in the workspace.
|
||||
|
|
@ -502,6 +505,8 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
query: dict[str, Any] = {"page": page, "size": size}
|
||||
if reverse:
|
||||
query["reverse"] = "true"
|
||||
if sort_by != "created_at":
|
||||
query["sort_by"] = sort_by
|
||||
data = self._http.post(
|
||||
routes.sessions_list(self.workspace_id),
|
||||
body={"filters": filters} if filters else None,
|
||||
|
|
@ -516,6 +521,7 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
metadata=session.metadata,
|
||||
configuration=session.configuration,
|
||||
created_at=session.created_at,
|
||||
last_message_at=session.last_message_at,
|
||||
is_active=session.is_active,
|
||||
)
|
||||
|
||||
|
|
@ -524,6 +530,8 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
|
|||
next_query: dict[str, Any] = {"page": next_page, "size": size}
|
||||
if reverse:
|
||||
next_query["reverse"] = "true"
|
||||
if sort_by != "created_at":
|
||||
next_query["sort_by"] = sort_by
|
||||
next_data = self._http.post(
|
||||
routes.sessions_list(self.workspace_id),
|
||||
body={"filters": filters} if filters else None,
|
||||
|
|
|
|||
|
|
@ -478,6 +478,7 @@ class Peer(PeerBase, MetadataConfigMixin):
|
|||
session.configuration.model_dump()
|
||||
),
|
||||
created_at=session.created_at,
|
||||
last_message_at=session.last_message_at,
|
||||
is_active=session.is_active,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ class Scope(ScopeBase):
|
|||
metadata=response.metadata,
|
||||
configuration=response.configuration,
|
||||
created_at=response.created_at,
|
||||
last_message_at=response.last_message_at,
|
||||
is_active=response.is_active,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -60,11 +60,13 @@ class Session(SessionBase, MetadataConfigMixin):
|
|||
fetched. Call get_metadata() for fresh data.
|
||||
configuration: Cached configuration for this session. May be stale if not
|
||||
recently fetched. Call get_configuration() for fresh data.
|
||||
last_message_at: Cached timestamp of the newest message, or None when empty.
|
||||
"""
|
||||
|
||||
_metadata: dict[str, object] | None = PrivateAttr(default=None)
|
||||
_configuration: SessionConfiguration | None = PrivateAttr(default=None)
|
||||
_created_at: datetime | None = PrivateAttr(default=None)
|
||||
_last_message_at: datetime | None = PrivateAttr(default=None)
|
||||
_is_active: bool | None = PrivateAttr(default=None)
|
||||
_honcho: "Honcho" = PrivateAttr()
|
||||
|
||||
|
|
@ -83,6 +85,11 @@ class Session(SessionBase, MetadataConfigMixin):
|
|||
"""Timestamp when this session was created. Only available if fetched from the API."""
|
||||
return self._created_at
|
||||
|
||||
@property
|
||||
def last_message_at(self) -> datetime | None:
|
||||
"""Timestamp of the newest message, or None when the session is empty."""
|
||||
return self._last_message_at
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool | None:
|
||||
"""Whether this session is active. Only available if fetched from the API."""
|
||||
|
|
@ -117,8 +124,20 @@ class Session(SessionBase, MetadataConfigMixin):
|
|||
session.configuration.model_dump()
|
||||
)
|
||||
self._created_at = session.created_at
|
||||
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.
|
||||
|
|
@ -223,6 +242,10 @@ class Session(SessionBase, MetadataConfigMixin):
|
|||
None,
|
||||
description="Timestamp when this session was created.",
|
||||
),
|
||||
last_message_at: datetime | None = Field(
|
||||
None,
|
||||
description="Timestamp of the newest message in this session.",
|
||||
),
|
||||
is_active: bool | None = Field(
|
||||
None,
|
||||
description="Whether this session is active.",
|
||||
|
|
@ -241,6 +264,7 @@ class Session(SessionBase, MetadataConfigMixin):
|
|||
If set, will get/create session immediately with metadata.
|
||||
configuration: Optional configuration to set for this session.
|
||||
If set, will get/create session immediately with flags.
|
||||
last_message_at: Timestamp of the newest message, if fetched.
|
||||
"""
|
||||
super().__init__(
|
||||
id=session_id,
|
||||
|
|
@ -250,6 +274,7 @@ class Session(SessionBase, MetadataConfigMixin):
|
|||
self._metadata = metadata
|
||||
self._configuration = configuration # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
self._created_at = created_at
|
||||
self._last_message_at = last_message_at
|
||||
self._is_active = is_active
|
||||
|
||||
def add_peers(
|
||||
|
|
@ -437,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(
|
||||
|
|
@ -553,6 +580,7 @@ class Session(SessionBase, MetadataConfigMixin):
|
|||
metadata=cloned.metadata,
|
||||
configuration=cloned.configuration,
|
||||
created_at=cloned.created_at,
|
||||
last_message_at=cloned.last_message_at,
|
||||
is_active=cloned.is_active,
|
||||
)
|
||||
|
||||
|
|
@ -891,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(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `Session.lastMessageAt` exposes the newest message timestamp, and `Honcho.sessions()` accepts `sortBy: 'created_at' | 'last_message_at'` while preserving `reverse` across pagination. Requires a Honcho server with the matching API support.
|
||||
|
||||
## [2.4.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -63,6 +63,13 @@ export function assertSessionShape(session: SessionResponse): void {
|
|||
expect(typeof session.configuration).toBe('object')
|
||||
expect(typeof session.created_at).toBe('string')
|
||||
expectValidDateString(session.created_at)
|
||||
expect(
|
||||
session.last_message_at === null ||
|
||||
typeof session.last_message_at === 'string'
|
||||
).toBe(true)
|
||||
if (typeof session.last_message_at === 'string') {
|
||||
expectValidDateString(session.last_message_at)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -214,12 +214,15 @@ describe('Peer', () => {
|
|||
const session = await client.session('peer-sessions-test', { metadata: {} })
|
||||
|
||||
await session.addPeers([peer.id])
|
||||
await session.addMessages(peer.message('peer session activity'))
|
||||
|
||||
const sessions = await peer.sessions()
|
||||
|
||||
expect(sessions.items.length).toBeGreaterThanOrEqual(1)
|
||||
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(typeof returned?.lastMessageAt).toBe('string')
|
||||
})
|
||||
|
||||
test('sessions returns empty for peer in no sessions', async () => {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ describe('Session', () => {
|
|||
expect(session.id).toBe('simple-session')
|
||||
expect(session.workspaceId).toBe(client.workspaceId)
|
||||
expect(session.createdAt).toBeDefined()
|
||||
expect(session.lastMessageAt).toBeNull()
|
||||
expect(session.isActive).toBe(true)
|
||||
})
|
||||
|
||||
|
|
@ -171,6 +172,55 @@ describe('Session', () => {
|
|||
expect(page.items.length).toBe(1)
|
||||
expect(page.items[0].metadata.tag).toBe(tag)
|
||||
})
|
||||
|
||||
test('sessions sort by last message activity and keep empty sessions last', async () => {
|
||||
const activityGroup = generateId('last-activity-group')
|
||||
const recentId = generateId('last-activity-recent')
|
||||
const olderId = generateId('last-activity-older')
|
||||
const emptyId = generateId('last-activity-empty')
|
||||
const peer = await client.peer(generateId('last-activity-peer'))
|
||||
const recentTime = new Date('2026-01-10T00:00:00Z')
|
||||
const olderTime = new Date('2026-01-05T00:00:00Z')
|
||||
|
||||
const older = await client.session(olderId, {
|
||||
metadata: { activityGroup },
|
||||
peers: [peer],
|
||||
})
|
||||
const recent = await client.session(recentId, {
|
||||
metadata: { activityGroup },
|
||||
peers: [peer],
|
||||
})
|
||||
await client.session(emptyId, {
|
||||
metadata: { activityGroup },
|
||||
peers: [peer],
|
||||
})
|
||||
await recent.addMessages(
|
||||
peer.message('recent activity', { createdAt: recentTime })
|
||||
)
|
||||
await older.addMessages(
|
||||
peer.message('older activity', { createdAt: olderTime })
|
||||
)
|
||||
|
||||
const page = await client.sessions({
|
||||
filters: { metadata: { activityGroup } },
|
||||
sortBy: 'last_message_at',
|
||||
reverse: true,
|
||||
size: 1,
|
||||
})
|
||||
const sessions = await page.toArray()
|
||||
|
||||
expect(sessions.map((session) => session.id)).toEqual([
|
||||
recentId,
|
||||
olderId,
|
||||
emptyId,
|
||||
])
|
||||
const returnedActivity = sessions[0].lastMessageAt
|
||||
expect(typeof returnedActivity).toBe('string')
|
||||
expect(new Date(returnedActivity ?? '').getTime()).toBe(
|
||||
recentTime.getTime()
|
||||
)
|
||||
expect(sessions[2].lastMessageAt).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -247,6 +297,8 @@ describe('Session', () => {
|
|||
const cloned = await original.clone()
|
||||
|
||||
expect(cloned.id).not.toBe(original.id)
|
||||
expect(cloned.lastMessageAt).toBeDefined()
|
||||
expect(cloned.lastMessageAt).not.toBeNull()
|
||||
// Cloned session should have same messages
|
||||
const messages = await cloned.messages()
|
||||
expect(messages.items.length).toBe(1)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ function createSessionResponse(
|
|||
metadata: {},
|
||||
configuration: {},
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
last_message_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +36,7 @@ describe('Session unit behavior', () => {
|
|||
createSessionResponse({
|
||||
metadata: { topic: 'testing' },
|
||||
created_at: '2024-02-01T12:00:00Z',
|
||||
last_message_at: '2024-02-02T12:00:00Z',
|
||||
is_active: true,
|
||||
}),
|
||||
} as unknown as HonchoHTTPClient
|
||||
|
|
@ -48,6 +50,7 @@ describe('Session unit behavior', () => {
|
|||
|
||||
expect(metadata).toEqual({ topic: 'testing' })
|
||||
expect(session.createdAt).toBe('2024-02-01T12:00:00Z')
|
||||
expect(session.lastMessageAt).toBe('2024-02-02T12:00:00Z')
|
||||
expect(session.isActive).toBe(true)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ export class Honcho {
|
|||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
sortBy?: 'created_at' | 'last_message_at'
|
||||
}
|
||||
): Promise<PageResponse<SessionResponse>> {
|
||||
return this._http.post<PageResponse<SessionResponse>>(
|
||||
|
|
@ -376,6 +377,10 @@ export class Honcho {
|
|||
page: params?.page,
|
||||
size: params?.size,
|
||||
reverse: params?.reverse ? 'true' : undefined,
|
||||
sort_by:
|
||||
params?.sortBy && params.sortBy !== 'created_at'
|
||||
? params.sortBy
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -628,7 +633,8 @@ export class Honcho {
|
|||
sessionConfigFromApi(sessionData.configuration) ?? undefined,
|
||||
() => this._ensureWorkspace(),
|
||||
sessionData.created_at,
|
||||
sessionData.is_active
|
||||
sessionData.is_active,
|
||||
sessionData.last_message_at
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -723,7 +729,7 @@ export class Honcho {
|
|||
* the current workspace.
|
||||
*
|
||||
* @param options - Either a legacy raw filter object or an options object with
|
||||
* `filters`, `page`, `size`, and `reverse`. See
|
||||
* `filters`, `page`, `size`, `reverse`, and `sortBy`. See
|
||||
* [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
|
||||
* @returns Promise resolving to a Page of Session objects representing all sessions
|
||||
* in the workspace. Returns an empty page if no sessions exist
|
||||
|
|
@ -736,6 +742,7 @@ export class Honcho {
|
|||
page?: number
|
||||
size?: number
|
||||
reverse?: boolean
|
||||
sortBy?: 'created_at' | 'last_message_at'
|
||||
}
|
||||
): Promise<Page<Session, SessionResponse>> {
|
||||
await this._ensureWorkspace()
|
||||
|
|
@ -744,16 +751,19 @@ export class Honcho {
|
|||
'page',
|
||||
'size',
|
||||
'reverse',
|
||||
'sortBy',
|
||||
])
|
||||
const validatedFilter = normalizedOptions.filters
|
||||
? FilterSchema.parse(normalizedOptions.filters)
|
||||
: undefined
|
||||
const reverse = normalizedOptions.reverse
|
||||
const sortBy = normalizedOptions.sortBy
|
||||
const sessionsPage = await this._listSessions(this.workspaceId, {
|
||||
filters: validatedFilter,
|
||||
page: normalizedOptions.page,
|
||||
size: normalizedOptions.size,
|
||||
reverse,
|
||||
sortBy,
|
||||
})
|
||||
|
||||
const fetchNextPage = async (
|
||||
|
|
@ -765,6 +775,7 @@ export class Honcho {
|
|||
page,
|
||||
size,
|
||||
reverse,
|
||||
sortBy,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -779,7 +790,8 @@ export class Honcho {
|
|||
sessionConfigFromApi(session.configuration) ?? undefined,
|
||||
() => this._ensureWorkspace(),
|
||||
session.created_at,
|
||||
session.is_active
|
||||
session.is_active,
|
||||
session.last_message_at
|
||||
),
|
||||
fetchNextPage
|
||||
)
|
||||
|
|
|
|||
|
|
@ -634,7 +634,8 @@ export class Peer {
|
|||
sessionConfigFromApi(session.configuration) ?? undefined,
|
||||
() => this._ensureWorkspace(),
|
||||
session.created_at,
|
||||
session.is_active
|
||||
session.is_active,
|
||||
session.last_message_at
|
||||
),
|
||||
fetchNextPage
|
||||
)
|
||||
|
|
|
|||
|
|
@ -239,7 +239,8 @@ export class Scope {
|
|||
sessionConfigFromApi(session.configuration) ?? undefined,
|
||||
() => this._ensureWorkspace(),
|
||||
session.created_at,
|
||||
session.is_active
|
||||
session.is_active,
|
||||
session.last_message_at
|
||||
),
|
||||
fetchNextPage
|
||||
)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export class Session {
|
|||
private _metadata?: Record<string, unknown>
|
||||
private _configuration?: SessionConfig
|
||||
private _createdAt?: string
|
||||
private _lastMessageAt?: string | null
|
||||
private _isActive?: boolean
|
||||
private _ensureWorkspace: () => Promise<void>
|
||||
|
||||
|
|
@ -119,6 +120,13 @@ export class Session {
|
|||
return this._createdAt
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp of the newest message. Null when the fetched session is empty.
|
||||
*/
|
||||
get lastMessageAt(): string | null | undefined {
|
||||
return this._lastMessageAt
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this session is active. Only available if fetched from the API.
|
||||
*/
|
||||
|
|
@ -134,6 +142,7 @@ export class Session {
|
|||
* @param http - Reference to the HTTP client instance
|
||||
* @param metadata - Optional metadata to initialize the cached value
|
||||
* @param configuration - Optional configuration to initialize the cached value
|
||||
* @param lastMessageAt - Optional newest-message timestamp from the API
|
||||
*/
|
||||
constructor(
|
||||
id: string,
|
||||
|
|
@ -143,7 +152,8 @@ export class Session {
|
|||
configuration?: SessionConfig,
|
||||
ensureWorkspace: () => Promise<void> = async () => undefined,
|
||||
createdAt?: string,
|
||||
isActive?: boolean
|
||||
isActive?: boolean,
|
||||
lastMessageAt?: string | null
|
||||
) {
|
||||
this.id = id
|
||||
this.workspaceId = workspaceId
|
||||
|
|
@ -153,12 +163,14 @@ export class Session {
|
|||
this._ensureWorkspace = ensureWorkspace
|
||||
this._createdAt = createdAt
|
||||
this._isActive = isActive
|
||||
this._lastMessageAt = lastMessageAt
|
||||
}
|
||||
|
||||
private _applySessionResponse(session: SessionResponse): void {
|
||||
this._metadata = session.metadata || {}
|
||||
this._configuration = sessionConfigFromApi(session.configuration) || {}
|
||||
this._createdAt = session.created_at
|
||||
this._lastMessageAt = session.last_message_at
|
||||
this._isActive = session.is_active
|
||||
}
|
||||
|
||||
|
|
@ -741,7 +753,8 @@ export class Session {
|
|||
sessionConfigFromApi(clonedSessionData.configuration) ?? undefined,
|
||||
() => this._ensureWorkspace(),
|
||||
clonedSessionData.created_at,
|
||||
clonedSessionData.is_active
|
||||
clonedSessionData.is_active,
|
||||
clonedSessionData.last_message_at
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ export interface SessionResponse {
|
|||
metadata: Record<string, unknown>
|
||||
configuration: SessionConfigApi
|
||||
created_at: string
|
||||
last_message_at: string | null
|
||||
}
|
||||
|
||||
export interface SessionCreateParams {
|
||||
|
|
|
|||
|
|
@ -407,7 +407,8 @@ export const FilterSchema = z.record(z.string(), z.unknown()).optional()
|
|||
* shape are both accepted.
|
||||
*
|
||||
* Discriminates on the `filters` key: if the input has a `filters` property or
|
||||
* any of the pagination-only keys (`page`, `size`, `reverse`) it is treated as
|
||||
* any of the supplied option-only keys (for example `page`, `reverse`, or
|
||||
* `sortBy`) it is treated as
|
||||
* the new options object. Otherwise it is treated as a legacy raw filter.
|
||||
*/
|
||||
export function normalizeListOptions<T extends { filters?: Filters }>(
|
||||
|
|
|
|||
|
|
@ -4,11 +4,22 @@ from logging import getLogger
|
|||
from typing import Any
|
||||
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy import ColumnElement, Select, and_, func, or_, select, text
|
||||
from sqlalchemy import (
|
||||
ColumnElement,
|
||||
Select,
|
||||
and_,
|
||||
case,
|
||||
func,
|
||||
or_,
|
||||
select,
|
||||
text,
|
||||
update,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import InstrumentedAttribute
|
||||
|
||||
from src import models, schemas
|
||||
from src.cache.client import safe_cache_delete
|
||||
from src.config import settings
|
||||
from src.dependencies import tracked_db
|
||||
from src.embedding_client import embedding_client
|
||||
|
|
@ -19,7 +30,7 @@ from src.utils.types import embedding_call_purpose
|
|||
from src.vector_store import get_external_vector_store
|
||||
|
||||
from .peer import reject_scope_peers
|
||||
from .session import get_or_create_session
|
||||
from .session import get_or_create_session, session_cache_key
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
|
@ -494,7 +505,30 @@ async def create_messages(
|
|||
if pending_rows:
|
||||
db.add_all(pending_rows)
|
||||
|
||||
await db.flush()
|
||||
latest_message_at = max(message.created_at for message in message_objects)
|
||||
await db.execute(
|
||||
update(models.Session)
|
||||
.where(models.Session.workspace_name == workspace_name)
|
||||
.where(models.Session.name == session_name)
|
||||
.values(
|
||||
last_message_at=case(
|
||||
(
|
||||
models.Session.last_message_at.is_(None),
|
||||
latest_message_at,
|
||||
),
|
||||
(
|
||||
models.Session.last_message_at < latest_message_at,
|
||||
latest_message_at,
|
||||
),
|
||||
else_=models.Session.last_message_at,
|
||||
)
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await safe_cache_delete(session_cache_key(workspace_name, session_name))
|
||||
|
||||
return message_objects
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from dataclasses import dataclass
|
||||
from logging import getLogger
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from typing import cast as typing_cast
|
||||
|
||||
from cashews import NOT_NONE
|
||||
|
|
@ -67,8 +67,8 @@ class SessionDeletionResult:
|
|||
conclusions_deleted: int
|
||||
|
||||
|
||||
SESSION_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}:session:{session_name}"
|
||||
SESSION_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2"
|
||||
SESSION_CACHE_KEY_TEMPLATE = "v3:workspace:{workspace_name}:session:{session_name}"
|
||||
SESSION_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v3"
|
||||
|
||||
|
||||
def session_cache_key(workspace_name: str, session_name: str) -> str:
|
||||
|
|
@ -117,6 +117,7 @@ async def _fetch_session(
|
|||
"internal_metadata": obj.internal_metadata,
|
||||
"configuration": obj.configuration,
|
||||
"created_at": obj.created_at,
|
||||
"last_message_at": obj.last_message_at,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -163,6 +164,7 @@ async def get_sessions(
|
|||
workspace_name: str,
|
||||
filters: dict[str, Any] | None = None,
|
||||
reverse: bool = False,
|
||||
sort_by: Literal["created_at", "last_message_at"] = "created_at",
|
||||
) -> Select[tuple[models.Session]]:
|
||||
"""
|
||||
Get all active sessions in a workspace.
|
||||
|
|
@ -170,7 +172,8 @@ async def get_sessions(
|
|||
Args:
|
||||
workspace_name: Name of the workspace
|
||||
filters: Optional filters to apply to the query
|
||||
reverse: If True, order by created_at descending; if False, ascending
|
||||
reverse: If True, order descending; if False, ascending
|
||||
sort_by: Session timestamp used for ordering
|
||||
|
||||
Returns:
|
||||
Select statement for Session objects
|
||||
|
|
@ -183,9 +186,22 @@ async def get_sessions(
|
|||
|
||||
stmt = apply_filter(stmt, models.Session, filters)
|
||||
|
||||
sort_column = (
|
||||
models.Session.last_message_at
|
||||
if sort_by == "last_message_at"
|
||||
else models.Session.created_at
|
||||
)
|
||||
if reverse:
|
||||
return stmt.order_by(models.Session.created_at.desc(), models.Session.id.desc())
|
||||
return stmt.order_by(models.Session.created_at.asc(), models.Session.id.asc())
|
||||
primary_order = sort_column.desc()
|
||||
id_order = models.Session.id.desc()
|
||||
else:
|
||||
primary_order = sort_column.asc()
|
||||
id_order = models.Session.id.asc()
|
||||
|
||||
if sort_by == "last_message_at":
|
||||
primary_order = primary_order.nulls_last()
|
||||
|
||||
return stmt.order_by(primary_order, id_order)
|
||||
|
||||
|
||||
async def get_or_create_session(
|
||||
|
|
@ -391,6 +407,7 @@ async def get_or_create_session(
|
|||
"internal_metadata": honcho_session.internal_metadata,
|
||||
"configuration": honcho_session.configuration,
|
||||
"created_at": honcho_session.created_at,
|
||||
"last_message_at": honcho_session.last_message_at,
|
||||
},
|
||||
expire=settings.CACHE.DEFAULT_TTL_SECONDS,
|
||||
)
|
||||
|
|
@ -836,6 +853,8 @@ async def clone_session(
|
|||
|
||||
insert_stmt = insert(models.Message).returning(models.Message)
|
||||
result = await db.execute(insert_stmt, new_messages)
|
||||
cloned_messages = result.scalars().all()
|
||||
new_session.last_message_at = max(message.created_at for message in cloned_messages)
|
||||
|
||||
# Clone peers from original session to new session (including their configurations)
|
||||
stmt = select(models.SessionPeer).where(
|
||||
|
|
|
|||
|
|
@ -178,6 +178,9 @@ class Session(Base):
|
|||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
last_message_at: Mapped[datetime.datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
workspace_name: Mapped[str] = mapped_column(
|
||||
ForeignKey("workspaces.name"), nullable=False, index=True
|
||||
)
|
||||
|
|
@ -193,6 +196,13 @@ class Session(Base):
|
|||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("name", "workspace_name"),
|
||||
Index(
|
||||
"ix_sessions_workspace_last_message_at",
|
||||
"workspace_name",
|
||||
text("last_message_at DESC NULLS LAST"),
|
||||
text("id DESC"),
|
||||
postgresql_where=text("is_active"),
|
||||
),
|
||||
CheckConstraint("length(name) <= 512", name="name_length"),
|
||||
CheckConstraint("length(id) = 21", name="id_length"),
|
||||
CheckConstraint("id ~ '^[A-Za-z0-9_-]+$'", name="id_format"),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import logging
|
||||
from contextlib import suppress
|
||||
from time import perf_counter
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Response
|
||||
from fastapi_pagination import Page
|
||||
|
|
@ -275,6 +276,9 @@ async def get_sessions(
|
|||
None, description="Filtering and pagination options for the sessions list"
|
||||
),
|
||||
reverse: bool = Query(False, description="Whether to reverse the order of results"),
|
||||
sort_by: Literal["created_at", "last_message_at"] = Query(
|
||||
"created_at", description="Session timestamp used to order results"
|
||||
),
|
||||
db: AsyncSession = read_db,
|
||||
):
|
||||
"""Get all Sessions for a Workspace, paginated with optional filters."""
|
||||
|
|
@ -291,6 +295,7 @@ async def get_sessions(
|
|||
workspace_name=workspace_id,
|
||||
filters=filter_param,
|
||||
reverse=reverse,
|
||||
sort_by=sort_by,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -339,6 +339,16 @@ class MessageCreate(MessageBase):
|
|||
def sanitize_content(cls, v: str) -> str:
|
||||
return strip_nul(v)
|
||||
|
||||
@field_validator("created_at", mode="after")
|
||||
@classmethod
|
||||
def normalize_created_at_timezone(
|
||||
cls, value: datetime.datetime | None
|
||||
) -> datetime.datetime | None:
|
||||
"""Treat timezone-naive message timestamps as UTC."""
|
||||
if value is not None and value.tzinfo is None:
|
||||
return value.replace(tzinfo=datetime.UTC)
|
||||
return value
|
||||
|
||||
@property
|
||||
def encoded_message(self) -> list[int]:
|
||||
return self._encoded_message
|
||||
|
|
@ -452,6 +462,7 @@ class Session(SessionBase):
|
|||
)
|
||||
configuration: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime.datetime
|
||||
last_message_at: datetime.datetime | None = None
|
||||
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
from_attributes=True, populate_by_name=True
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from . import (
|
|||
test_baa22cad81e2_standardize_constraint_names,
|
||||
test_bb6fb3a7a643_add_message_seq_in_session_column,
|
||||
test_c3828084f472_add_indexes_for_messages_and_,
|
||||
test_cfaff339d519_add_session_last_message_at,
|
||||
test_d429de0e5338_adopt_peer_paradigm,
|
||||
test_e4eba9cfaa6f_make_document_session_name_nullable,
|
||||
test_e9b705f9adf9_add_server_defaults_to_timestamp_,
|
||||
|
|
@ -49,6 +50,7 @@ __all__ = [
|
|||
"test_baa22cad81e2_standardize_constraint_names",
|
||||
"test_bb6fb3a7a643_add_message_seq_in_session_column",
|
||||
"test_c3828084f472_add_indexes_for_messages_and_",
|
||||
"test_cfaff339d519_add_session_last_message_at",
|
||||
"test_d429de0e5338_adopt_peer_paradigm",
|
||||
"test_e4eba9cfaa6f_make_document_session_name_nullable",
|
||||
"test_e9b705f9adf9_add_server_defaults_to_timestamp_",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
"""Hooks for revision cfaff339d519 (add session last_message_at)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy import text
|
||||
|
||||
from tests.alembic.registry import register_after_upgrade, register_before_upgrade
|
||||
from tests.alembic.verifier import MigrationVerifier
|
||||
|
||||
WORKSPACE_NAME = generate_nanoid()
|
||||
PEER_NAME = generate_nanoid()
|
||||
ACTIVE_SESSION_NAME = generate_nanoid()
|
||||
EMPTY_SESSION_NAME = generate_nanoid()
|
||||
LATEST_MESSAGE_AT = datetime.datetime(2026, 1, 3, 12, 0, tzinfo=datetime.UTC)
|
||||
INDEX_NAME = "ix_sessions_workspace_last_message_at"
|
||||
|
||||
|
||||
@register_before_upgrade("cfaff339d519")
|
||||
def prepare_add_session_last_message_at(verifier: MigrationVerifier) -> None:
|
||||
"""Seed sessions and messages before the activity timestamp exists."""
|
||||
verifier.assert_column_exists("sessions", "last_message_at", exists=False)
|
||||
verifier.assert_indexes_not_exist([("sessions", INDEX_NAME)])
|
||||
|
||||
schema = verifier.schema
|
||||
connection = verifier.conn
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
INSERT INTO "{schema}"."workspaces" ("id", "name")
|
||||
VALUES (:id, :name)
|
||||
"""
|
||||
),
|
||||
{"id": generate_nanoid(), "name": WORKSPACE_NAME},
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
INSERT INTO "{schema}"."peers" ("id", "name", "workspace_name")
|
||||
VALUES (:id, :name, :workspace_name)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": generate_nanoid(),
|
||||
"name": PEER_NAME,
|
||||
"workspace_name": WORKSPACE_NAME,
|
||||
},
|
||||
)
|
||||
for session_name in (ACTIVE_SESSION_NAME, EMPTY_SESSION_NAME):
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
INSERT INTO "{schema}"."sessions"
|
||||
("id", "name", "workspace_name", "is_active")
|
||||
VALUES (:id, :name, :workspace_name, true)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": generate_nanoid(),
|
||||
"name": session_name,
|
||||
"workspace_name": WORKSPACE_NAME,
|
||||
},
|
||||
)
|
||||
|
||||
for seq, created_at in enumerate(
|
||||
(
|
||||
datetime.datetime(2026, 1, 1, 12, 0, tzinfo=datetime.UTC),
|
||||
LATEST_MESSAGE_AT,
|
||||
),
|
||||
start=1,
|
||||
):
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
INSERT INTO "{schema}"."messages"
|
||||
("public_id", "session_name", "content", "token_count",
|
||||
"seq_in_session", "created_at", "peer_name", "workspace_name")
|
||||
VALUES
|
||||
(:public_id, :session_name, :content, 1,
|
||||
:seq_in_session, :created_at, :peer_name, :workspace_name)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"public_id": generate_nanoid(),
|
||||
"session_name": ACTIVE_SESSION_NAME,
|
||||
"content": f"message {seq}",
|
||||
"seq_in_session": seq,
|
||||
"created_at": created_at,
|
||||
"peer_name": PEER_NAME,
|
||||
"workspace_name": WORKSPACE_NAME,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@register_after_upgrade("cfaff339d519")
|
||||
def verify_add_session_last_message_at(verifier: MigrationVerifier) -> None:
|
||||
"""Verify the nullable field, historical backfill, and sorting index."""
|
||||
verifier.assert_column_exists("sessions", "last_message_at", nullable=True)
|
||||
verifier.assert_column_type("sessions", "last_message_at", sa.TIMESTAMP)
|
||||
verifier.assert_indexes_exist([("sessions", INDEX_NAME)])
|
||||
|
||||
index_definition = verifier.conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = :schema
|
||||
AND tablename = 'sessions'
|
||||
AND indexname = :index_name
|
||||
"""
|
||||
),
|
||||
{"schema": verifier.schema, "index_name": INDEX_NAME},
|
||||
).scalar_one()
|
||||
normalized_index_definition = " ".join(index_definition.replace('"', "").split())
|
||||
assert "(workspace_name, last_message_at DESC NULLS LAST, id DESC)" in (
|
||||
normalized_index_definition
|
||||
)
|
||||
assert "WHERE is_active" in normalized_index_definition
|
||||
|
||||
rows = verifier.conn.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT name, last_message_at
|
||||
FROM "{verifier.schema}"."sessions"
|
||||
WHERE workspace_name = :workspace_name
|
||||
"""
|
||||
),
|
||||
{"workspace_name": WORKSPACE_NAME},
|
||||
).all()
|
||||
activity_by_session = {row.name: row.last_message_at for row in rows}
|
||||
|
||||
assert activity_by_session[ACTIVE_SESSION_NAME] == LATEST_MESSAGE_AT
|
||||
assert activity_by_session[EMPTY_SESSION_NAME] is None
|
||||
|
|
@ -4,6 +4,7 @@ from typing import Any
|
|||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from nanoid import generate as generate_nanoid
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src import models
|
||||
|
|
@ -214,6 +215,161 @@ def test_get_sessions(client: TestClient, sample_data: tuple[Workspace, Peer]):
|
|||
assert data["items"][0]["workspace_id"] == test_workspace.name
|
||||
|
||||
|
||||
def test_session_response_exposes_null_last_message_at_without_messages(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""A newly created session reports that no message activity exists yet."""
|
||||
test_workspace, test_peer = sample_data
|
||||
session_id = f"last-activity-empty-{generate_nanoid()}"
|
||||
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={
|
||||
"id": session_id,
|
||||
"peer_names": {test_peer.name: {}},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code in [200, 201]
|
||||
assert response.json()["last_message_at"] is None
|
||||
|
||||
|
||||
def test_session_response_tracks_latest_message_timestamp_after_cached_create(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""Appending messages refreshes the cached session activity timestamp."""
|
||||
test_workspace, test_peer = sample_data
|
||||
session_id = f"last-activity-cached-{generate_nanoid()}"
|
||||
older_timestamp = "2026-01-01T12:00:00Z"
|
||||
latest_timestamp = "2026-01-03T12:00:00Z"
|
||||
|
||||
created = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={
|
||||
"id": session_id,
|
||||
"peer_names": {test_peer.name: {}},
|
||||
},
|
||||
)
|
||||
assert created.status_code in [200, 201]
|
||||
assert created.json()["last_message_at"] is None
|
||||
|
||||
messages = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"content": "older activity",
|
||||
"peer_id": test_peer.name,
|
||||
"created_at": older_timestamp,
|
||||
},
|
||||
{
|
||||
"content": "latest activity",
|
||||
"peer_id": test_peer.name,
|
||||
"created_at": latest_timestamp,
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert messages.status_code == 201
|
||||
|
||||
refreshed = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={"id": session_id},
|
||||
)
|
||||
|
||||
assert refreshed.status_code == 200
|
||||
assert datetime.datetime.fromisoformat(
|
||||
refreshed.json()["last_message_at"].replace("Z", "+00:00")
|
||||
) == datetime.datetime(2026, 1, 3, 12, 0, tzinfo=datetime.UTC)
|
||||
|
||||
|
||||
def test_session_last_message_at_does_not_move_backwards_for_backdated_message(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""Appending historical data cannot make a session appear less recent."""
|
||||
test_workspace, test_peer = sample_data
|
||||
session_id = f"last-activity-backfill-{generate_nanoid()}"
|
||||
|
||||
created = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={
|
||||
"id": session_id,
|
||||
"peer_names": {test_peer.name: {}},
|
||||
},
|
||||
)
|
||||
assert created.status_code in [200, 201]
|
||||
|
||||
for content, created_at in (
|
||||
("current activity", "2026-01-03T12:00:00Z"),
|
||||
("historical import", "2026-01-01T12:00:00Z"),
|
||||
):
|
||||
message_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"content": content,
|
||||
"peer_id": test_peer.name,
|
||||
"created_at": created_at,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert message_response.status_code == 201
|
||||
|
||||
refreshed = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={"id": session_id},
|
||||
)
|
||||
|
||||
assert refreshed.status_code == 200
|
||||
assert datetime.datetime.fromisoformat(
|
||||
refreshed.json()["last_message_at"].replace("Z", "+00:00")
|
||||
) == datetime.datetime(2026, 1, 3, 12, 0, tzinfo=datetime.UTC)
|
||||
|
||||
|
||||
def test_message_batch_normalizes_naive_activity_timestamps(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
"""Mixed explicit and server-default timestamps remain comparable."""
|
||||
test_workspace, test_peer = sample_data
|
||||
session_id = f"last-activity-mixed-timezone-{generate_nanoid()}"
|
||||
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"content": "naive activity timestamp",
|
||||
"peer_id": test_peer.name,
|
||||
"created_at": "2024-01-01T10:00:00",
|
||||
},
|
||||
{
|
||||
"content": "server timestamp",
|
||||
"peer_id": test_peer.name,
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
message_timestamps = [
|
||||
datetime.datetime.fromisoformat(item["created_at"].replace("Z", "+00:00"))
|
||||
for item in response.json()
|
||||
]
|
||||
assert all(timestamp.utcoffset() is not None for timestamp in message_timestamps)
|
||||
|
||||
session_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions",
|
||||
json={"id": session_id},
|
||||
)
|
||||
assert session_response.status_code == 200
|
||||
session_activity = datetime.datetime.fromisoformat(
|
||||
session_response.json()["last_message_at"].replace("Z", "+00:00")
|
||||
)
|
||||
assert session_activity == max(message_timestamps)
|
||||
|
||||
|
||||
def test_get_sessions_with_empty_filter(
|
||||
client: TestClient, sample_data: tuple[Workspace, Peer]
|
||||
):
|
||||
|
|
@ -280,6 +436,100 @@ def test_get_sessions_with_reverse(
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sessions_sort_by_last_message_at_reverses_activity_and_keeps_nulls_last(
|
||||
client: TestClient,
|
||||
db_session: AsyncSession,
|
||||
sample_data: tuple[Workspace, Peer],
|
||||
):
|
||||
"""Activity sorting differs from creation order and leaves empty sessions last."""
|
||||
test_workspace, test_peer = sample_data
|
||||
activity_group = f"last-activity-sort-{generate_nanoid()}"
|
||||
most_recent_session = f"activity-recent-{generate_nanoid()}"
|
||||
older_session = f"activity-older-{generate_nanoid()}"
|
||||
empty_session = f"activity-empty-{generate_nanoid()}"
|
||||
|
||||
db_session.add_all(
|
||||
[
|
||||
models.Session(
|
||||
name=most_recent_session,
|
||||
workspace_name=test_workspace.name,
|
||||
created_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
|
||||
h_metadata={"activity_group": activity_group},
|
||||
),
|
||||
models.Session(
|
||||
name=older_session,
|
||||
workspace_name=test_workspace.name,
|
||||
created_at=datetime.datetime(2026, 1, 2, tzinfo=datetime.UTC),
|
||||
h_metadata={"activity_group": activity_group},
|
||||
),
|
||||
models.Session(
|
||||
name=empty_session,
|
||||
workspace_name=test_workspace.name,
|
||||
created_at=datetime.datetime(2026, 1, 3, tzinfo=datetime.UTC),
|
||||
h_metadata={"activity_group": activity_group},
|
||||
),
|
||||
]
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
for session_id, created_at in (
|
||||
(most_recent_session, "2026-01-10T00:00:00Z"),
|
||||
(older_session, "2026-01-05T00:00:00Z"),
|
||||
):
|
||||
message_response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/messages",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"content": f"activity for {session_id}",
|
||||
"peer_id": test_peer.name,
|
||||
"created_at": created_at,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert message_response.status_code == 201
|
||||
|
||||
response = client.post(
|
||||
f"/v3/workspaces/{test_workspace.name}/sessions/list?sort_by=last_message_at&reverse=true",
|
||||
json={"filters": {"metadata": {"activity_group": activity_group}}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert [item["id"] for item in response.json()["items"]] == [
|
||||
most_recent_session,
|
||||
older_session,
|
||||
empty_session,
|
||||
]
|
||||
assert response.json()["items"][-1]["last_message_at"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_activity_index_supports_reverse_ordering(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""The model index matches the primary DESC NULLS LAST query shape."""
|
||||
result = await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = current_schema()
|
||||
AND tablename = 'sessions'
|
||||
AND indexname = 'ix_sessions_workspace_last_message_at'
|
||||
"""
|
||||
)
|
||||
)
|
||||
index_definition = result.scalar_one()
|
||||
normalized_index_definition = " ".join(index_definition.replace('"', "").split())
|
||||
|
||||
assert "(workspace_name, last_message_at DESC NULLS LAST, id DESC)" in (
|
||||
normalized_index_definition
|
||||
)
|
||||
assert "WHERE is_active" in normalized_index_definition
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sessions_reverse_uses_id_tiebreaker(
|
||||
client: TestClient,
|
||||
|
|
@ -289,9 +539,7 @@ async def test_get_sessions_reverse_uses_id_tiebreaker(
|
|||
"""Sessions with identical created_at fall back to ordering by id (nanoid PK)."""
|
||||
test_workspace, _ = sample_data
|
||||
reverse_group = f"tiebreaker-sessions-{generate_nanoid()}"
|
||||
shared_created_at = datetime.datetime(
|
||||
2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
shared_created_at = datetime.datetime(2026, 1, 1, 12, 0, 0, tzinfo=datetime.UTC)
|
||||
|
||||
low_id = "A" * 21
|
||||
high_id = "z" * 21
|
||||
|
|
@ -580,6 +828,10 @@ def test_clone_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
|
|||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["metadata"] == {"test": "key"}
|
||||
assert data["last_message_at"] is not None
|
||||
cloned_last_message_at = datetime.datetime.fromisoformat(
|
||||
data["last_message_at"].replace("Z", "+00:00")
|
||||
)
|
||||
|
||||
# Check messages were cloned
|
||||
response = client.post(
|
||||
|
|
@ -597,6 +849,10 @@ def test_clone_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
|
|||
|
||||
assert data["items"][1]["content"] == "Test message 2"
|
||||
assert data["items"][1]["metadata"] == {"key": "value2"}
|
||||
assert cloned_last_message_at == max(
|
||||
datetime.datetime.fromisoformat(item["created_at"].replace("Z", "+00:00"))
|
||||
for item in data["items"]
|
||||
)
|
||||
|
||||
|
||||
def test_clone_session_with_cutoff(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ async def test_peer_sessions(client_fixture: tuple[Honcho, str]):
|
|||
|
||||
await session1.aio.add_peers(peer)
|
||||
await session2.aio.add_peers(peer)
|
||||
await session1.aio.add_messages(peer.message("peer session activity"))
|
||||
|
||||
sessions_page = await peer.aio.sessions()
|
||||
sessions = sessions_page.items
|
||||
|
|
@ -63,6 +64,9 @@ async def test_peer_sessions(client_fixture: tuple[Honcho, str]):
|
|||
session_ids = {s.id for s in sessions}
|
||||
assert "s1" in session_ids
|
||||
assert "s2" in session_ids
|
||||
sessions_by_id = {session.id: session for session in sessions}
|
||||
assert sessions_by_id["s1"].last_message_at is not None
|
||||
assert sessions_by_id["s2"].last_message_at is None
|
||||
else:
|
||||
peer = honcho_client.peer(id="test-peer-sessions")
|
||||
session1 = honcho_client.session(id="s1")
|
||||
|
|
@ -70,6 +74,7 @@ async def test_peer_sessions(client_fixture: tuple[Honcho, str]):
|
|||
|
||||
session1.add_peers(peer)
|
||||
session2.add_peers(peer)
|
||||
session1.add_messages(peer.message("peer session activity"))
|
||||
|
||||
sessions_page = peer.sessions()
|
||||
sessions = list(sessions_page)
|
||||
|
|
@ -77,6 +82,9 @@ async def test_peer_sessions(client_fixture: tuple[Honcho, str]):
|
|||
session_ids = {s.id for s in sessions}
|
||||
assert "s1" in session_ids
|
||||
assert "s2" in session_ids
|
||||
sessions_by_id = {session.id: session for session in sessions}
|
||||
assert sessions_by_id["s1"].last_message_at is not None
|
||||
assert sessions_by_id["s2"].last_message_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1,12 +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.UTC)
|
||||
older_time = datetime.datetime(2026, 1, 5, 12, 0, tzinfo=datetime.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]):
|
||||
"""
|
||||
|
|
@ -92,6 +184,108 @@ async def test_session_fetch_methods_refresh_cached_status_fields(
|
|||
assert session.is_active is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_refresh_populates_last_message_at(
|
||||
client_fixture: tuple[Honcho, str],
|
||||
):
|
||||
"""Session.refresh exposes the newest message timestamp cached by the SDK."""
|
||||
honcho_client, client_type = client_fixture
|
||||
session_id = f"test-session-last-message-at-{client_type}"
|
||||
peer_id = f"test-peer-last-message-at-{client_type}"
|
||||
message_time = datetime.datetime(2026, 1, 3, 12, 0, tzinfo=datetime.UTC)
|
||||
|
||||
if client_type == "async":
|
||||
peer = await honcho_client.aio.peer(id=peer_id)
|
||||
created = await honcho_client.aio.session(id=session_id, peers=[peer])
|
||||
await created.aio.add_messages(
|
||||
peer.message("session activity", created_at=message_time)
|
||||
)
|
||||
session = Session(session_id, honcho_client)
|
||||
await session.aio.refresh()
|
||||
else:
|
||||
peer = honcho_client.peer(id=peer_id)
|
||||
created = honcho_client.session(id=session_id, peers=[peer])
|
||||
created.add_messages(peer.message("session activity", created_at=message_time))
|
||||
session = Session(session_id, honcho_client)
|
||||
session.refresh()
|
||||
|
||||
assert session.last_message_at == message_time
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_sessions_sort_by_last_message_at(
|
||||
client_fixture: tuple[Honcho, str],
|
||||
):
|
||||
"""Workspace session listing exposes and preserves activity ordering."""
|
||||
honcho_client, client_type = client_fixture
|
||||
group = f"sdk-last-activity-sort-{client_type}"
|
||||
peer_id = f"sdk-last-activity-peer-{client_type}"
|
||||
recent_id = f"sdk-last-activity-recent-{client_type}"
|
||||
older_id = f"sdk-last-activity-older-{client_type}"
|
||||
empty_id = f"sdk-last-activity-empty-{client_type}"
|
||||
recent_time = datetime.datetime(2026, 1, 10, tzinfo=datetime.UTC)
|
||||
older_time = datetime.datetime(2026, 1, 5, tzinfo=datetime.UTC)
|
||||
|
||||
if client_type == "async":
|
||||
peer = await honcho_client.aio.peer(id=peer_id)
|
||||
older = await honcho_client.aio.session(
|
||||
id=older_id, metadata={"activity_group": group}, peers=[peer]
|
||||
)
|
||||
recent = await honcho_client.aio.session(
|
||||
id=recent_id, metadata={"activity_group": group}, peers=[peer]
|
||||
)
|
||||
await honcho_client.aio.session(
|
||||
id=empty_id, metadata={"activity_group": group}, peers=[peer]
|
||||
)
|
||||
await recent.aio.add_messages(
|
||||
peer.message("recent activity", created_at=recent_time)
|
||||
)
|
||||
await older.aio.add_messages(
|
||||
peer.message("older activity", created_at=older_time)
|
||||
)
|
||||
page = await honcho_client.aio.sessions(
|
||||
{"metadata": {"activity_group": group}},
|
||||
sort_by="last_message_at",
|
||||
reverse=True,
|
||||
size=1,
|
||||
)
|
||||
sessions = cast(list[Session], page.items)
|
||||
while page.has_next_page():
|
||||
next_page = await page.get_next_page()
|
||||
assert next_page is not None
|
||||
sessions.extend(cast(list[Session], next_page.items))
|
||||
page = next_page
|
||||
else:
|
||||
peer = honcho_client.peer(id=peer_id)
|
||||
older = honcho_client.session(
|
||||
id=older_id, metadata={"activity_group": group}, peers=[peer]
|
||||
)
|
||||
recent = honcho_client.session(
|
||||
id=recent_id, metadata={"activity_group": group}, peers=[peer]
|
||||
)
|
||||
honcho_client.session(
|
||||
id=empty_id, metadata={"activity_group": group}, peers=[peer]
|
||||
)
|
||||
recent.add_messages(peer.message("recent activity", created_at=recent_time))
|
||||
older.add_messages(peer.message("older activity", created_at=older_time))
|
||||
page = honcho_client.sessions(
|
||||
{"metadata": {"activity_group": group}},
|
||||
sort_by="last_message_at",
|
||||
reverse=True,
|
||||
size=1,
|
||||
)
|
||||
sessions = cast(list[Session], page.items)
|
||||
while page.has_next_page():
|
||||
next_page = page.get_next_page()
|
||||
assert next_page is not None
|
||||
sessions.extend(cast(list[Session], next_page.items))
|
||||
page = next_page
|
||||
|
||||
assert [session.id for session in sessions] == [recent_id, older_id, empty_id]
|
||||
assert sessions[0].last_message_at == recent_time
|
||||
assert sessions[-1].last_message_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_peer_management(
|
||||
client_fixture: tuple[Honcho, str],
|
||||
|
|
@ -629,6 +823,9 @@ async def test_session_clone(client_fixture: tuple[Honcho, str]):
|
|||
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()
|
||||
|
|
@ -657,6 +854,9 @@ async def test_session_clone(client_fixture: tuple[Honcho, str]):
|
|||
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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue