This commit is contained in:
steven-ji 2026-09-04 09:08:55 +08:00 committed by GitHub
commit 29f69c365e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 955 additions and 24 deletions

View File

@ -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

View File

@ -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",

View File

@ -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)

View File

@ -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

View File

@ -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,
)

View File

@ -265,6 +265,7 @@ class SessionResponse(BaseModel):
default_factory=SessionConfigurationResponse
)
created_at: datetime.datetime
last_message_at: datetime.datetime | None = None
class SessionCreateParams(BaseModel):

View File

@ -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,

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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(

View File

@ -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

View File

@ -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)
}
}
/**

View File

@ -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 () => {

View File

@ -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)

View File

@ -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)
})

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)
}

View File

@ -139,6 +139,7 @@ export interface SessionResponse {
metadata: Record<string, unknown>
configuration: SessionConfigApi
created_at: string
last_message_at: string | null
}
export interface SessionCreateParams {

View File

@ -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 }>(

View File

@ -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

View File

@ -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(

View File

@ -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"),

View File

@ -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,
),
)

View File

@ -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

View File

@ -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_",

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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()