Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8102,14 +8102,23 @@ async def get_session_latest_descendant(session_id: str):
}

@app.get("/api/sessions/{session_id}/messages")
async def get_session_messages(session_id: str, profile: Optional[str] = None):
async def get_session_messages(
session_id: str,
profile: Optional[str] = None,
include_compacted: bool = False,
include_inactive: bool = False,
):
db = _open_session_db_for_profile(profile)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main's endpoint now preserves limit/offset, clamps a requested limit to 500, and returns pagination metadata. Carry those fields forward when adding these history flags.

try:
sid = db.resolve_session_id(session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
sid = db.resolve_resume_session_id(sid)
messages = db.get_messages(sid)
messages = db.get_messages(
sid,
include_inactive=include_inactive,
include_compacted=include_compacted,
)
return {"session_id": sid, "messages": messages}
finally:
db.close()
Expand Down
18 changes: 14 additions & 4 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -3701,19 +3701,29 @@ def _do(conn):


def get_messages(
self, session_id: str, include_inactive: bool = False
self,
session_id: str,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main has added limit and offset parameters to this method for the session-message pagination contract. During salvage, retain them and make include_compacted compose with pagination rather than replacing that newer signature.

include_inactive: bool = False,
include_compacted: bool = False,
) -> List[Dict[str, Any]]:
"""Load messages for a session in insertion order.

By default only active messages are returned. Pass
``include_inactive=True`` to load soft-deleted rows (e.g. for
audit / debug views of rewound history). See
``include_compacted=True`` to include durable compaction-archived rows
(``active=0, compacted=1``) while still excluding user-rewound/undone
rows (``active=0, compacted=0``). Pass ``include_inactive=True`` to load
every soft-deleted row (e.g. for low-level audit / debug views). See
:meth:`rewind_to_message` for the soft-delete mechanic.

Ordered by AUTOINCREMENT id (true insertion order) rather than
timestamp — see c03acca50 for the WSL2 clock-regression rationale.
"""
active_clause = "" if include_inactive else " AND active = 1"
if include_inactive:
active_clause = ""
elif include_compacted:
active_clause = " AND (active = 1 OR compacted = 1)"
else:
active_clause = " AND active = 1"
with self._lock:
cursor = self._conn.execute(
"SELECT * FROM messages WHERE session_id = ?"
Expand Down
86 changes: 86 additions & 0 deletions tests/hermes_cli/test_session_messages_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import asyncio

from hermes_cli import web_server
from hermes_state import SessionDB


def test_get_messages_include_compacted_keeps_compression_history_but_not_rewound(tmp_path):
db = SessionDB(tmp_path / "state.db")
db.create_session("telegram_session", source="telegram")
db.append_message("telegram_session", role="user", content="original prompt")
db.append_message("telegram_session", role="assistant", content="original answer")

db.archive_and_compact(
"telegram_session",
[
{"role": "user", "content": "current prompt"},
{"role": "assistant", "content": "current answer"},
],
)
rewound_id = db.append_message("telegram_session", role="user", content="undone prompt")
db._execute_write(
lambda conn: conn.execute(
"UPDATE messages SET active = 0, compacted = 0 WHERE id = ?",
(rewound_id,),
)
)

live = [m["content"] for m in db.get_messages("telegram_session")]
history = [
m["content"]
for m in db.get_messages("telegram_session", include_compacted=True)
if m["role"] in {"user", "assistant"}
]
audit = [m["content"] for m in db.get_messages("telegram_session", include_inactive=True)]

assert live == ["current prompt", "current answer"]
assert history == [
"original prompt",
"original answer",
"current prompt",
"current answer",
]
assert "undone prompt" in audit
assert "undone prompt" not in history

db.close()


class _FakeDB:
def __init__(self):
self.calls = []
self.closed = False

def resolve_session_id(self, session_id):
return f"resolved-{session_id}"

def resolve_resume_session_id(self, session_id):
return f"tip-{session_id}"

def get_messages(self, session_id, **kwargs):
self.calls.append((session_id, kwargs))
return [{"role": "user", "content": "hello"}]

def close(self):
self.closed = True


def test_web_session_messages_endpoint_accepts_compacted_history_flag(monkeypatch):
fake = _FakeDB()
monkeypatch.setattr(web_server, "_open_session_db_for_profile", lambda profile=None: fake)

response = asyncio.run(
web_server.get_session_messages("abc", include_compacted=True)
)

assert response == {
"session_id": "tip-resolved-abc",
"messages": [{"role": "user", "content": "hello"}],
}
assert fake.calls == [
(
"tip-resolved-abc",
{"include_inactive": False, "include_compacted": True},
)
]
assert fake.closed is True
3 changes: 2 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ import ConfigPage from "@/pages/ConfigPage";
import DocsPage from "@/pages/DocsPage";
import EnvPage from "@/pages/EnvPage";
import FilesPage from "@/pages/FilesPage";
import SessionsPage from "@/pages/SessionsPage";
import SessionsPage, { SessionDetailPage } from "@/pages/SessionsPage";
import LogsPage from "@/pages/LogsPage";
import AnalyticsPage from "@/pages/AnalyticsPage";
import ModelsPage from "@/pages/ModelsPage";
Expand Down Expand Up @@ -133,6 +133,7 @@ const CHAT_NAV_ITEM: NavItem = {
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/": RootRedirect,
"/sessions": SessionsPage,
"/sessions/:sessionId": SessionDetailPage,
"/files": FilesPage,
"/analytics": AnalyticsPage,
"/models": ModelsPage,
Expand Down
25 changes: 20 additions & 5 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,10 +361,22 @@ export const api = {
profile,
),
),
getSessionMessages: (id: string, profile = getManagementProfile()) =>
fetchJSON<SessionMessagesResponse>(
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/messages`, profile),
),
getSessionMessages: (
id: string,
profile = getManagementProfile(),
options?: { includeCompacted?: boolean; includeInactive?: boolean },
) => {
const params = new URLSearchParams();
if (options?.includeCompacted) params.set("include_compacted", "true");
if (options?.includeInactive) params.set("include_inactive", "true");
const query = params.toString();
return fetchJSON<SessionMessagesResponse>(
appendProfileParam(
`/api/sessions/${encodeURIComponent(id)}/messages${query ? `?${query}` : ""}`,
profile,
),
);
},
getSessionDetail: (id: string, profile = getManagementProfile()) =>
fetchJSON<SessionInfo>(
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile),
Expand Down Expand Up @@ -1734,7 +1746,10 @@ export interface TelegramOnboardingApplyResponse {

export interface SessionMessage {
role: "user" | "assistant" | "system" | "tool";
content: string | null;
content: string | null | Array<
| string
| { type?: string; text?: string; image_url?: { url?: string } | string; [key: string]: unknown }
>;
tool_calls?: Array<{
id: string;
function: { name: string; arguments: string };
Expand Down
Loading