"""Message rows: the `Message` <-> table-column mapping and session hydration.
Covers the column mapping, the archive-aware loading of sessions, and the
one-shot repair of tool-call batches torn apart by a crash (or by a page
boundary). Nothing here touches the `sessions` row itself — `store` does that.
"""
import json
import asyncpg
from navi.llm.base import Message, ToolCallRequest
from ..session import Session
def _serialize(messages: list[Message]) -> str:
return json.dumps(
[m.model_dump(mode="json", exclude_none=True) for m in messages],
ensure_ascii=False,
)
def _deserialize(raw: str) -> list[Message]:
if not raw:
return []
return [Message.model_validate(m) for m in json.loads(raw)]
def repair_dangling_tool_calls(messages: list[Message]) -> int:
"""Synthesize placeholder results for assistant tool_calls that lost their
role=tool pair (a crash mid-batch, e.g. a parallel batch killed by a server
restart). Returns the number of placeholders added.
Placeholders are inserted right after the last tool message of the affected
batch (before the next non-tool message) and marked ``is_context=False`` —
display history stays coherent, the LLM context is untouched.
"""
answered = {
m.tool_call_id for m in messages if m.role == "tool" and m.tool_call_id
}
out: list[Message] = []
pending: list[Message] = []
for m in messages:
if m.role != "tool" and pending:
out.extend(pending)
pending = []
out.append(m)
if m.role == "assistant" and m.tool_calls:
for tc in m.tool_calls:
if not tc.id or tc.id in answered:
continue
pending.append(
Message(
role="tool",
content="[Interrupted before result]",
tool_call_id=tc.id,
name=tc.name,
metadata={},
is_context=False,
)
)
out.extend(pending)
if len(out) != len(messages):
added = len(out) - len(messages)
messages[:] = out
return added
return 0
def _message_key(m: Message) -> tuple:
"""Stable key for matching a message between messages[] and context[].
Used only by the one-shot boot migration for legacy JSON rows.
"""
return (
m.role,
m.content,
m.tool_call_id,
m.name,
m.is_summary,
m.is_plan,
m.is_compression,
m.is_recall,
m.thinking,
m.created_at.isoformat() if m.created_at else None,
json.dumps([tc.model_dump(mode="json") for tc in m.tool_calls], ensure_ascii=False) if m.tool_calls else None,
json.dumps(m.files, ensure_ascii=False) if m.files else None,
json.dumps(m.metadata, ensure_ascii=False) if m.metadata else None,
)
def _row_to_message(row: asyncpg.Record) -> Message:
images = json.loads(row["images"]) if row.get("images") else None
tool_calls = None
raw_tool_calls = row.get("tool_calls")
if raw_tool_calls:
tool_calls = [ToolCallRequest.model_validate(tc) for tc in json.loads(raw_tool_calls)]
files = json.loads(row["files"]) if row.get("files") else None
metadata = json.loads(row["metadata"]) if row.get("metadata") else {}
return Message(
role=row["role"],
content=row["content"],
images=images,
tool_calls=tool_calls,
tool_call_id=row.get("tool_call_id"),
name=row.get("name"),
created_at=row.get("created_at"),
is_summary=bool(row.get("is_summary", False)),
thinking=row.get("thinking"),
is_plan=bool(row.get("is_plan", False)),
is_compression=bool(row.get("is_compression", False)),
is_context=bool(row.get("is_context", True)),
is_display=bool(row.get("is_display", True)),
elapsed_seconds=row.get("elapsed_seconds"),
tool_call_count=row.get("tool_call_count"),
token_count=row.get("token_count"),
files=files,
metadata=metadata,
is_recall=bool(row.get("is_recall", False)),
sequence_number=row.get("sequence_number", 0),
)
async def _load_messages_map(conn: asyncpg.Connection, session_ids: list[str]) -> dict[str, list[Message]]:
"""Batch-load all messages for the given session IDs."""
if not session_ids:
return {}
rows = await conn.fetch(
"SELECT * FROM session_messages WHERE session_id = ANY($1) ORDER BY sequence_number",
session_ids,
)
result: dict[str, list[Message]] = {sid: [] for sid in session_ids}
for row in rows:
sid = row["session_id"]
result[sid].append(_row_to_message(row))
return result
async def _build_sessions(
conn: asyncpg.Connection,
rows: list[asyncpg.Record],
) -> list[Session]:
"""Hydrate session rows with hot (non-archived) messages."""
session_ids = [r["id"] for r in rows]
messages_map = await _load_messages_map(conn, session_ids)
sessions: list[Session] = []
for row in rows:
all_msgs = messages_map.get(row["id"], [])
archive_threshold = row.get("archive_threshold", 0) or 0
hot_msgs = [m for m in all_msgs if m.sequence_number >= archive_threshold]
messages = [m for m in hot_msgs if m.is_display]
context = [m for m in hot_msgs if m.is_context]
planning_logs_raw = row["planning_logs"]
planning_logs = json.loads(planning_logs_raw) if planning_logs_raw else []
next_seq = row.get("next_sequence", 0) or 0
max_seq = max((m.sequence_number for m in hot_msgs), default=-1)
if next_seq == 0:
next_seq = max_seq + 1
s = Session(
id=row["id"],
profile_id=row["profile_id"],
user_id=row["user_id"],
messages=messages,
context=context,
pinned=bool(row["pinned"]),
special=bool(row.get("special", False)),
name=row["name"],
created_at=row["created_at"],
last_active=row["last_active"],
context_token_count=row["context_token_count"] or 0,
planning_logs=planning_logs,
)
session_metadata = json.loads(row.get("session_metadata") or "{}") if row.get("session_metadata") else {}
s.db_message_count = len(hot_msgs)
s.db_next_sequence = next_seq
s.archive_threshold = archive_threshold
s.session_metadata = session_metadata
sessions.append(s)
return sessions
def _special_frag(special: bool | None) -> str:
"""SQL fragment for the special filter; '' when unfiltered."""
if special is None:
return ""
return f"special = {str(bool(special)).upper()}"
def _special_and(special: bool | None) -> str:
frag = _special_frag(special)
return f" AND {frag}" if frag else ""
def _special_where(special: bool | None) -> str:
frag = _special_frag(special)
return f" WHERE {frag}" if frag else ""