Files
hermes-agent/hermes_cli/cli_process_notifications.py
T
Teknium 5f39eb5221 fix(cli): adopt a started /queue admission by its id, never by its text
The idle tick queued the started admission's text, and run_conversation
pumped it only when the text it got still matched. The input loop and
chat() clean the text first (leaked CPR or mouse reports, paste markers,
lone surrogates). A queued prompt with any of those no longer matched, so
run_conversation sent a new prompt.submit while the owner was already
running the queued one, and the prompt ran twice.

The seeded item now carries the admission id. When the input loop takes
it, that id is armed on the session state, and run_conversation pumps
exactly that admission. A typed input with the same text is its own
submit.

Refs #134889 (N5)
2026-10-19 07:02:06 -07:00

98 lines
5.3 KiB
Python

"""CLI notification ownership, structured queueing and last-moment consumption."""
class CLIProcessNotificationsMixin:
def _owns_process_notification(self, event: dict) -> bool:
"""Whether this session owns a delegation event (pre-compression keys resolve to their continuation; fail closed)."""
event_key = str(event.get("session_key") or "")
current_key = str(getattr(self, "session_id", "") or "")
if not event_key or not current_key:
return False
if event_key == current_key:
return True
try:
session_db = getattr(self, "_session_db", None)
resolved_key = (
session_db.resolve_resume_session_id(event_key) if session_db is not None else event_key
) or event_key
except Exception:
resolved_key = event_key
return str(resolved_key) == current_key
def _background_notifications_suppressed(self) -> bool:
"""Whether ``display.background_process_notifications`` is ``off`` for this CLI session.
The key gates the gateway's completion injection (#9290) but the CLI drain never consulted
it, so the documented ``off`` escape hatch silently did nothing here (#123114). Mirrors the
gateway semantics: events are still drained, claimed and acknowledged — only the
turn-starting injection is suppressed."""
try:
from cli import CLI_CONFIG
mode = str((CLI_CONFIG.get("display") or {}).get("background_process_notifications") or "").strip().lower()
except Exception:
return False
return mode == "off"
def _drain_process_notifications(self, consumer: str) -> None:
from hermes_cli.gateway_tools_notify import gateway_store, queue_admitted_turn
store = gateway_store(self)
if store is not None:
# The process registry and its delivery claims live in the gateway, which admits the
# notification turn itself; this viewer only paints it (IPS-086).
queue_admitted_turn(self, store)
return
from tools.process_registry import process_registry
from tools.async_delegation import claim_event_delivery, complete_event_delivery
from tools.process_registry_notifications import (
HEARTBEAT_DISPLAY_KIND, ProcessNotificationBatch, TimelineNotification, group_process_notifications,
heartbeat_display_text)
claimed = []
for event, text in process_registry.drain_notifications(
session_key=getattr(self, "session_id", "") or "", owns_event=self._owns_process_notification,
):
claim = claim_event_delivery(event, consumer)
if claim is None:
continue
claimed.append((event, text))
complete_event_delivery(event, claim)
if self._background_notifications_suppressed():
# Subagent results are not process notifications: they still land.
claimed = [(event, text) for event, text in claimed if event.get("type") == "async_delegation"]
for notifications in group_process_notifications(claimed):
event, text = notifications[0]
evt_type = event.get("type", "completion")
if evt_type == "completion":
pending = ProcessNotificationBatch(notifications)
elif evt_type == "heartbeat":
pending = TimelineNotification(text, heartbeat_display_text(event), HEARTBEAT_DISPLAY_KIND)
else:
pending = TimelineNotification.for_delegation(text, event) if evt_type == "async_delegation" else text
from agent.notification_presentation import diagnostic_process_event
if diagnostic_process_event(event) and not isinstance(pending, TimelineNotification):
pending = TimelineNotification(text, text, "internal_notification", "diagnostic")
self._pending_input.put(pending)
def _tui_unwrap_input(self, user_input):
"""Unwrap ``_VoiceInputMessage`` / ``_SeededQueryMessage`` -> ``(text_or_tuple, is_voice_input, is_seeded_query)``."""
from cli import _VoiceInputMessage, _SeededQueryMessage
from tools.process_registry import process_registry
from tools.process_registry_notifications import (
PROCESS_COMPLETE_DISPLAY_KIND, ProcessNotificationBatch, TimelineNotification)
if isinstance(user_input, ProcessNotificationBatch):
rendered = user_input.render(process_registry)
user_input = rendered and TimelineNotification(
rendered, user_input.display_text(process_registry), PROCESS_COMPLETE_DISPLAY_KIND)
# Voice-transcribed messages arrive wrapped in a sentinel so only genuine STT output gets the voice
# prefix (#65827).
is_voice_input = isinstance(user_input, _VoiceInputMessage)
if is_voice_input:
user_input = user_input.text
is_seeded_query = isinstance(user_input, _SeededQueryMessage)
if is_seeded_query:
from hermes_cli.gateway_tools_notify import arm_adopted
arm_adopted(self, user_input)
user_input = (user_input.text, user_input.images) if user_input.images else user_input.text
return user_input, is_voice_input, is_seeded_query