@@ -790,6 +790,13 @@ async def template_delete(db: aiosqlite.Connection, template_id: str) -> None:
790790_VALID_PRIORITIES = {"normal" , "urgent" , "system" }
791791
792792
793+ async def msg_get (db : aiosqlite .Connection , message_id : str ) -> Optional [Message ]:
794+ """Fetch a single message by ID. Returns None if not found."""
795+ async with db .execute ("SELECT * FROM messages WHERE id = ?" , (message_id ,)) as cur :
796+ row = await cur .fetchone ()
797+ return _row_to_message (row ) if row else None
798+
799+
793800async def msg_post (
794801 db : aiosqlite .Connection ,
795802 thread_id : str ,
@@ -800,11 +807,25 @@ async def msg_post(
800807 role : str = "user" ,
801808 metadata : Optional [dict ] = None ,
802809 priority : str = "normal" ,
810+ reply_to_msg_id : Optional [str ] = None ,
803811) -> Message :
804812 # Validate priority (UP-16)
805813 if priority not in _VALID_PRIORITIES :
806814 raise ValueError (f"Invalid priority '{ priority } '. Must be one of: { ', ' .join (sorted (_VALID_PRIORITIES ))} " )
807815
816+ # Validate reply_to_msg_id (UP-14): must exist and belong to the same thread
817+ if reply_to_msg_id is not None :
818+ async with db .execute (
819+ "SELECT thread_id FROM messages WHERE id = ?" , (reply_to_msg_id ,)
820+ ) as cur :
821+ parent_row = await cur .fetchone ()
822+ if parent_row is None :
823+ raise ValueError (f"reply_to_msg_id '{ reply_to_msg_id } ' does not exist." )
824+ if parent_row ["thread_id" ] != thread_id :
825+ raise ValueError (
826+ f"reply_to_msg_id '{ reply_to_msg_id } ' belongs to a different thread."
827+ )
828+
808829 # Content filter: block known secret patterns before any DB interaction
809830 if CONTENT_FILTER_ENABLED :
810831 blocked , pattern_name = check_content (content )
@@ -890,8 +911,8 @@ async def msg_post(
890911 seq = await next_seq (db )
891912 meta_json = json .dumps (metadata ) if metadata else None
892913 await db .execute (
893- "INSERT INTO messages (id, thread_id, author, role, content, seq, created_at, metadata, author_id, author_name, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ,
894- (mid , thread_id , actual_author , role , content , seq , now , meta_json , author_id , author_name , priority ),
914+ "INSERT INTO messages (id, thread_id, author, role, content, seq, created_at, metadata, author_id, author_name, priority, reply_to_msg_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ,
915+ (mid , thread_id , actual_author , role , content , seq , now , meta_json , author_id , author_name , priority , reply_to_msg_id ),
895916 )
896917 await db .execute (
897918 "UPDATE threads SET updated_at = ? WHERE id = ?" , (now , thread_id )
@@ -940,11 +961,19 @@ async def msg_post(
940961 "msg_id" : mid , "thread_id" : thread_id ,
941962 "agent" : author_name , "reason" : stop_reason ,
942963 })
943- logger .debug (f"Message posted: seq={ seq } author={ author_name } thread={ thread_id } priority={ priority } " )
964+ # SSE event for reply-to threading (UP-14)
965+ if reply_to_msg_id is not None :
966+ await _emit_event (db , "msg.reply" , thread_id , {
967+ "msg_id" : mid , "reply_to_msg_id" : reply_to_msg_id ,
968+ "thread_id" : thread_id , "author" : author_name , "seq" : seq ,
969+ })
970+
971+ logger .debug (f"Message posted: seq={ seq } author={ author_name } thread={ thread_id } priority={ priority } reply_to={ reply_to_msg_id } " )
944972 return Message (
945973 id = mid , thread_id = thread_id , author = actual_author , role = role ,
946974 content = content , seq = seq , created_at = _parse_dt (now ), metadata = meta_json ,
947- author_id = author_id , author_name = author_name , priority = priority
975+ author_id = author_id , author_name = author_name , priority = priority ,
976+ reply_to_msg_id = reply_to_msg_id ,
948977 )
949978
950979
@@ -1072,6 +1101,7 @@ def _row_to_message(row: aiosqlite.Row) -> Message:
10721101 if not author_name :
10731102 author_name = row ["author" ]
10741103 priority = row ["priority" ] if "priority" in keys else "normal"
1104+ reply_to_msg_id = row ["reply_to_msg_id" ] if "reply_to_msg_id" in keys else None
10751105
10761106 return Message (
10771107 id = row ["id" ],
@@ -1085,6 +1115,7 @@ def _row_to_message(row: aiosqlite.Row) -> Message:
10851115 author_id = author_id ,
10861116 author_name = author_name ,
10871117 priority = priority ,
1118+ reply_to_msg_id = reply_to_msg_id ,
10881119 )
10891120
10901121
@@ -1636,9 +1667,9 @@ async def thread_export_markdown(db: aiosqlite.Connection, thread_id: str) -> Op
16361667 return "\n " .join (lines )
16371668
16381669
1639- # ─────────────────────────────────────────────
1640- # Metrics ( UP-22)
1641- # ─────────────────────────────────────────────
1670+ # ──────────────────────────────────────────────────────────────────────────────
1671+ # UP-22: Bus-level observability metrics
1672+ # ──────────────────────────────────────────────────────────────────────────────
16421673
16431674async def get_bus_metrics (db : aiosqlite .Connection ) -> dict :
16441675 """Return a snapshot of bus-level observability metrics.
@@ -1664,21 +1695,20 @@ async def get_bus_metrics(db: aiosqlite.Connection) -> dict:
16641695 AGENT_HEARTBEAT_TIMEOUT window
16651696 """
16661697 now = datetime .now (timezone .utc )
1667- now_iso = now .isoformat ()
16681698
1669- # ── Thread counts ────────────────────────────────────────────────────────
1699+ # ── Thread counts ──────────────────────────────────────────────────────────
16701700 threads_by_status : dict [str , int ] = {}
16711701 async with db .execute ("SELECT status, COUNT(*) AS cnt FROM threads GROUP BY status" ) as cur :
16721702 async for row in cur :
16731703 threads_by_status [row ["status" ]] = row ["cnt" ]
16741704 threads_total = sum (threads_by_status .values ())
16751705
1676- # ── Message total ────────────────────────────────────────────────────────
1706+ # ── Message total ──────────────────────────────────────────────────────────
16771707 async with db .execute ("SELECT COUNT(*) AS cnt FROM messages" ) as cur :
16781708 row = await cur .fetchone ()
16791709 messages_total = row ["cnt" ] if row else 0
16801710
1681- # ── Message rates (1m / 5m / 15m) ───────────────────────────────────────
1711+ # ── Message rates (1m / 5m / 15m) ─────────────────────────────────────────
16821712 cutoffs = {
16831713 "last_1m" : (now - timedelta (minutes = 1 )).isoformat (),
16841714 "last_5m" : (now - timedelta (minutes = 5 )).isoformat (),
@@ -1692,11 +1722,9 @@ async def get_bus_metrics(db: aiosqlite.Connection) -> dict:
16921722 row = await cur .fetchone ()
16931723 message_rate [key ] = row ["cnt" ] if row else 0
16941724
1695- # ── Inter-message latency (avg ms, threads active in last 15 min) ────────
1725+ # ── Inter-message latency (avg ms, threads active in last 15 min) ─────────
16961726 # Uses LAG() window function (SQLite >= 3.25.0) to compute time gaps
16971727 # between consecutive messages within each thread, then averages them.
1698- # Only considers threads that had activity in the last 15 minutes to keep
1699- # the metric relevant to current bus load.
17001728 cutoff_15m = cutoffs ["last_15m" ]
17011729 avg_latency_ms : Optional [float ] = None
17021730 try :
@@ -1718,10 +1746,9 @@ async def get_bus_metrics(db: aiosqlite.Connection) -> dict:
17181746 if row and row ["avg_gap" ] is not None :
17191747 avg_latency_ms = round (row ["avg_gap" ], 1 )
17201748 except Exception :
1721- # Defensive: if the window query fails for any reason, degrade gracefully
17221749 avg_latency_ms = None
17231750
1724- # ── stop_reason distribution (UP-17) ─────────────────────────────────────
1751+ # ── stop_reason distribution (UP-17) ──────────────────────────────────────
17251752 canonical_reasons = ("convergence" , "timeout" , "complete" , "error" , "impasse" )
17261753 stop_reasons : dict [str , int ] = {r : 0 for r in canonical_reasons }
17271754 async with db .execute (
@@ -1736,7 +1763,7 @@ async def get_bus_metrics(db: aiosqlite.Connection) -> dict:
17361763 reason = row ["reason" ]
17371764 stop_reasons [reason ] = stop_reasons .get (reason , 0 ) + row ["cnt" ]
17381765
1739- # ── Agent counts ─────────────────────────────────────────────────────────
1766+ # ── Agent counts ───────────────────────────────────────────────────────────
17401767 agents_total = 0
17411768 agents_online = 0
17421769 heartbeat_cutoff = (
0 commit comments