diff --git a/apps/desktop/src/lib/chat-messages/hydration.ts b/apps/desktop/src/lib/chat-messages/hydration.ts index df3b765df3..45968a3a89 100644 --- a/apps/desktop/src/lib/chat-messages/hydration.ts +++ b/apps/desktop/src/lib/chat-messages/hydration.ts @@ -147,6 +147,12 @@ function timelineTaskCount(metadata: SessionMessage['display_metadata']): number return typeof count === 'number' ? count : undefined } +function timelineDisplayText(metadata: SessionMessage['display_metadata']): string | undefined { + const text = parseDisplayMetadata(metadata)?.display_text + + return typeof text === 'string' && text.trim() ? text : undefined +} + function messageReactions(metadata: SessionMessage['display_metadata']): MessageReaction[] { const reactions = parseDisplayMetadata(metadata)?.reactions @@ -164,7 +170,13 @@ function messageReactions(metadata: SessionMessage['display_metadata']): Message function asyncResultBody(content: string): string | undefined { let bodies = [content] - if (content.startsWith('[ASYNC DELEGATION')) { + if (content.startsWith('[IMPORTANT: ')) { + // Background-process completion: one `[IMPORTANT: …]` block per process, a batch header first. + bodies = content + .split(/\n\n(?=\[IMPORTANT: )/) + .map(block => block.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '')) + .filter(block => !/^\d+ background processes completed\./.test(block)) + } else if (content.startsWith('[ASYNC DELEGATION')) { if (content.startsWith('[ASYNC DELEGATION BATCH COMPLETE')) { // Task goals can span lines; stopping at a newline leaks the next goal and transcript footer. bodies = content.split(/^--- [✓✗⚠] TASK \d+\/\d+(?:: [\s\S]*?)? {2}\(status=[^\n]*\) ---\r?\n/gm).slice(1) @@ -203,9 +215,16 @@ function timelineDisplayContent(message: SessionMessage, content: string): strin if (message.display_kind === 'async_delegation_complete') { const count = timelineTaskCount(message.display_metadata) - return count === undefined - ? 'background agent work finished' - : `${count} background agent${count === 1 ? '' : 's'} finished` + return ( + timelineDisplayText(message.display_metadata) ?? + (count === undefined + ? 'background agent work finished' + : `${count} background agent${count === 1 ? '' : 's'} finished`) + ) + } + + if (message.display_kind === 'process_complete') { + return timelineDisplayText(message.display_metadata) ?? 'background process finished' } return content @@ -298,6 +317,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] { const displayRole = message.display_kind === 'model_switch' || message.display_kind === 'async_delegation_complete' || + message.display_kind === 'process_complete' || message.display_kind === 'auto_continue' || message.display_kind === 'personality_switch' ? 'system' @@ -409,7 +429,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] { id: `${message.timestamp || Date.now()}-${index}-${displayRole}`, role: displayRole, parts, - ...(message.display_kind === 'async_delegation_complete' + ...(message.display_kind === 'async_delegation_complete' || message.display_kind === 'process_complete' ? { asyncResult: asyncResultBody(displayContentForMessage(message.role, message.content || content)) } : {}), timestamp: earliestTimestamp(message.timestamp, ...parts.map(part => part.timestamp)), diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 3ddc3f9ec2..1273c21dc7 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -549,7 +549,9 @@ export type TimelineDisplayMetadata = completed_count?: number failed_count?: number duration_seconds?: number + display_text?: string } + | { display_text: string } | { reactions: MessageReaction[] } /** One emoji reaction on a message. One per author, iOS-Tapback style. */ @@ -580,7 +582,14 @@ export interface SessionMessage { reasoning_content?: null | string reasoning_details?: unknown display_kind?: - 'async_delegation_complete' | 'auto_continue' | 'hidden' | 'model_switch' | 'personality_switch' | 'steer' | string + | 'async_delegation_complete' + | 'auto_continue' + | 'hidden' + | 'model_switch' + | 'personality_switch' + | 'process_complete' + | 'steer' + | string /** * A backend older than this app can still serve this as unparsed JSON text, * so readers must narrow before indexing into it. diff --git a/cli.py b/cli.py index e4680ce9e0..c330455608 100644 --- a/cli.py +++ b/cli.py @@ -3454,11 +3454,11 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix def _tui_process_one_input(self, user_input): """Route one submitted input: file drop, /resume pick, ! shell, slash command, or a chat turn.""" - from tools.process_registry_notifications import SubagentNotification - notification_preview = user_input if isinstance(user_input, SubagentNotification) else None + from tools.process_registry_notifications import TimelineNotification user_input, is_voice_input, is_seeded_query = self._tui_unwrap_input(user_input) if not user_input: return + notification_preview = user_input if isinstance(user_input, TimelineNotification) else None self._status_bar_suppressed_after_resize = False # input ends post-resize suppression submit_images = [] diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index d18a6a3c90..94adf5d4c4 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -97,6 +97,7 @@ def _tool_calls_summary(tool_calls) -> str: _RESUME_EVENT_TEXT = { "model_switch": "model changed", "async_delegation_complete": "background delegation completed", + "process_complete": "background process finished", "auto_continue": "resumed interrupted turn"} def _collect_resume_entries(display_history, disp: dict, clean_assistant): @@ -125,7 +126,7 @@ def _collect_resume_entries(display_history, disp: dict, clean_assistant): continue if display_kind in _RESUME_EVENT_TEXT: metadata = msg.get("display_metadata") or {} - label = metadata.get("display_text") if display_kind == "async_delegation_complete" else None + label = metadata.get("display_text") if display_kind in ("async_delegation_complete", "process_complete") else None entries.append(("event", _sanitize_display_text(label or _RESUME_EVENT_TEXT[display_kind]))) continue if role == "user": diff --git a/hermes_cli/cli_chat_turn_mixin.py b/hermes_cli/cli_chat_turn_mixin.py index c39b9886f3..e5e17e9726 100644 --- a/hermes_cli/cli_chat_turn_mixin.py +++ b/hermes_cli/cli_chat_turn_mixin.py @@ -35,7 +35,7 @@ class CLIChatTurnMixin: the concise voice-response prefix, #65827) """ from cli import ChatConsole, _ChatTurn, _DIM, _RST, _accent_hex, _cprint, set_secret_capture_callback - from tools.process_registry_notifications import SubagentNotification + from tools.process_registry_notifications import TimelineNotification # Single-query and direct chat callers do not go through run(). set_secret_capture_callback(self._secret_capture_callback) # Reset per turn; only a real interrupt flips it, so early returns leave it False. @@ -57,7 +57,7 @@ class CLIChatTurnMixin: return None message = self._chat_route_images(message, images) - if isinstance(message, str) and not isinstance(message, SubagentNotification): + if isinstance(message, str) and not isinstance(message, TimelineNotification): message, blocked = self._chat_expand_context_references(message) if blocked is not None: return blocked @@ -66,7 +66,7 @@ class CLIChatTurnMixin: message = _sanitize_surrogates(message) self._chat_stage_user_message(agent, message) - if isinstance(message, SubagentNotification): + if isinstance(message, TimelineNotification): message = str(message) # UI metadata is on the staged row, never in model content. ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") @@ -207,9 +207,9 @@ class CLIChatTurnMixin: agent._persist_user_message_override = None agent._persist_user_message_timestamp = None staged_user_message = stamp_message_timestamp({"role": "user", "content": message}) - from tools.process_registry_notifications import SubagentNotification - if isinstance(message, SubagentNotification): - staged_user_message.update(content=str(message), display_kind="async_delegation_complete", + from tools.process_registry_notifications import TimelineNotification + if isinstance(message, TimelineNotification): + staged_user_message.update(content=str(message), display_kind=message.display_kind, display_metadata={"display_text": message.display_text}) agent._pending_cli_user_message = staged_user_message self.conversation_history.append(staged_user_message) diff --git a/hermes_cli/cli_process_notifications.py b/hermes_cli/cli_process_notifications.py index f5dd6697b6..0c16b4a2d4 100644 --- a/hermes_cli/cli_process_notifications.py +++ b/hermes_cli/cli_process_notifications.py @@ -23,7 +23,7 @@ class CLIProcessNotificationsMixin: from tools.process_registry import process_registry from tools.async_delegation import claim_event_delivery, complete_event_delivery from tools.process_registry_notifications import ( - ProcessNotificationBatch, SubagentNotification, group_process_notifications) + ProcessNotificationBatch, TimelineNotification, group_process_notifications) claimed = [] for event, text in process_registry.drain_notifications( @@ -39,16 +39,19 @@ class CLIProcessNotificationsMixin: if event.get("type", "completion") == "completion": pending = ProcessNotificationBatch(notifications) else: - pending = SubagentNotification(text, event) if event.get("type") == "async_delegation" else text + pending = TimelineNotification.for_delegation(text, event) if event.get("type") == "async_delegation" else text 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 ProcessNotificationBatch + from tools.process_registry_notifications import ( + PROCESS_COMPLETE_DISPLAY_KIND, ProcessNotificationBatch, TimelineNotification) if isinstance(user_input, ProcessNotificationBatch): - user_input = user_input.render(process_registry) + 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) diff --git a/hermes_cli/cli_stream_mixin.py b/hermes_cli/cli_stream_mixin.py index 8a49ac8ba8..36f651acfb 100644 --- a/hermes_cli/cli_stream_mixin.py +++ b/hermes_cli/cli_stream_mixin.py @@ -210,8 +210,8 @@ class CLIStreamMixin: def _print_user_message_preview(self, user_input: str) -> None: """Render a user message using the normal chat scrollback style.""" from cli import ChatConsole, _accent_hex - from tools.process_registry_notifications import SubagentNotification - if isinstance(user_input, SubagentNotification): + from tools.process_registry_notifications import TimelineNotification + if isinstance(user_input, TimelineNotification): ChatConsole().print(f"[dim]◈ {_escape(user_input.display_text)}[/dim]") return ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]") diff --git a/tests/hermes_cli/test_process_notification_display.py b/tests/hermes_cli/test_process_notification_display.py new file mode 100644 index 0000000000..21d5dd0969 --- /dev/null +++ b/tests/hermes_cli/test_process_notification_display.py @@ -0,0 +1,83 @@ +"""Background-process completions paint a compact title while the model keeps the raw wall.""" +import queue +import threading +from types import SimpleNamespace +from unittest.mock import Mock + +from cli import HermesCLI +from tools.process_registry_notifications import ( + PROCESS_COMPLETE_DISPLAY_KIND, format_process_notification, process_completion_display_text) +from tui_gateway import server + + +def _registry(events): + return SimpleNamespace( + drain_notifications=lambda **kw: [(e, format_process_notification(e)) for e in events], + completion_queue=queue.Queue(), is_completion_consumed=lambda sid: False) + + +def _event(sid, exit_code, command="cd /tmp && bash long-build.sh"): + return {"type": "completion", "session_id": sid, "session_key": "display-session", "command": command, + "exit_code": exit_code, "completion_reason": "exited", "output": "web tsc=0\nSECRET_OUTPUT_LINE"} + + +def test_process_completion_display_keeps_payload_separate_across_surfaces(monkeypatch, capsys, tmp_path): + events = [_event("proc_1", 0)] + payload = format_process_notification(events[0]) + cli = HermesCLI.__new__(HermesCLI) + cli.session_id = "display-session" + cli._pending_input = queue.Queue() + registry = _registry(events) + monkeypatch.setattr("tools.process_registry.process_registry", registry) + monkeypatch.setattr("tools.async_delegation.claim_event_delivery", lambda *a: "claimed") + monkeypatch.setattr("tools.async_delegation.complete_event_delivery", lambda *a: None) + cli._drain_process_notifications("cli-idle") + for attr, value in {"_pending_resume_sessions": [], "_typed_voice_stop": lambda t: False, + "handle_bang_shell": lambda t: False, "_turn_summary_begin": lambda: None, + "_tui_after_turn": lambda: None, "_app": SimpleNamespace(invalidate=lambda: None), + "chat": Mock()}.items(): + setattr(cli, attr, value) + cli._tui_process_one_input(cli._pending_input.get_nowait()) + visible = capsys.readouterr().out + expected = "Background Process Finished: cd /tmp && bash long-build.sh" + assert expected in visible + assert "[IMPORTANT" not in visible and "SECRET_OUTPUT_LINE" not in visible + queued = cli.chat.call_args.args[0] + assert queued == payload # the model still receives the full notification + + cli.conversation_history = [] + cli.agent = SimpleNamespace(run_conversation=Mock(return_value={})) + cli._chat_stage_user_message(cli.agent, queued) + staged = cli.conversation_history[-1] + assert staged["content"] == payload and type(staged["content"]) is str + assert staged["display_kind"] == PROCESS_COMPLETE_DISPLAY_KIND + assert staged["display_metadata"]["display_text"] == expected + + from hermes_cli.cli_agent_setup_mixin import _collect_resume_entries + entries, _, _ = _collect_resume_entries(cli.conversation_history, {}, lambda text: text) + assert entries == [("event", expected)] + + # TUI gateway: the status line and the persisted turn carry the same compact title. + emitted, submitted = [], [] + monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args)) + monkeypatch.setattr(server, "_notif_submit", lambda *args, **kw: submitted.append((args, kw))) + monkeypatch.setattr(server, "_notif_claim_turn", lambda session: True) + session = {"session_key": "display-session", "history_lock": threading.RLock()} + server._notif_handle_ready("ui-session", session, events, set(), registry, format_process_notification, None, + owned=True) + assert emitted[0][2] == {"kind": "process", "text": expected} + (_rid, _sid, _session, text, _what), kwargs = submitted[0] + assert text == payload + assert kwargs["display_kind"] == PROCESS_COMPLETE_DISPLAY_KIND + assert kwargs["display_metadata"] == {"display_text": expected} + + +def test_process_completion_titles_reflect_outcome_and_batch(): + assert process_completion_display_text([_event("p", 1)]) == ( + "Background Process Failed (exit 1): cd /tmp && bash long-build.sh") + killed = {**_event("p", -15), "completion_reason": "killed"} + assert process_completion_display_text([killed]).startswith("Background Process Terminated: ") + assert process_completion_display_text([_event("a", 0), _event("b", 2)]) == "2 Background Processes Finished" + long_cmd = "x" * 200 + title = process_completion_display_text([_event("p", 0, command=long_cmd)]) + assert title.endswith("...") and len(title) < 120 diff --git a/tests/tui_gateway/test_tui_gateway_server.py b/tests/tui_gateway/test_tui_gateway_server.py index 2784b1a99e..161b07767c 100644 --- a/tests/tui_gateway/test_tui_gateway_server.py +++ b/tests/tui_gateway/test_tui_gateway_server.py @@ -7233,7 +7233,7 @@ def test_notification_poller_live_loop_requeues_foreign_completion_for_owner( monkeypatch.setattr(server, "_get_db", lambda: None) monkeypatch.setattr(server, "_emit", lambda *args, **_kwargs: emitted.append(args)) - def _deliver(_rid, sid, session, text): + def _deliver(_rid, sid, session, text, **_kw): delivered["a" if sid == "sid-a-live-handoff" else "b"].append(text) session["running"] = False @@ -7342,7 +7342,7 @@ def test_notification_poller_live_loop_drops_addressed_orphan( monkeypatch.setattr( server, "_run_prompt_submit", - lambda _rid, _sid, _session, text: delivered.append(text), + lambda _rid, _sid, _session, text, **_kw: delivered.append(text), ) server._sessions["sid-live-orphan"] = session process_registry._completion_consumed.discard(event["session_id"]) @@ -7383,7 +7383,7 @@ def test_notification_poller_drops_orphaned_events(monkeypatch, routing): monkeypatch.setattr( server, "_run_prompt_submit", - lambda _rid, _sid, _session, text: delivered.append(text), + lambda _rid, _sid, _session, text, **_kw: delivered.append(text), ) monkeypatch.setattr(server, "_get_db", lambda: None) @@ -7449,7 +7449,7 @@ def test_notification_poller_delivers_owned_events( monkeypatch.setattr( server, "_run_prompt_submit", - lambda _rid, _sid, _session, text: delivered.append(text), + lambda _rid, _sid, _session, text, **_kw: delivered.append(text), ) monkeypatch.setattr(server, "_get_db", lambda: _CompressionDB()) diff --git a/tools/process_registry_notifications.py b/tools/process_registry_notifications.py index 6f012e3089..cf9576b7b8 100644 --- a/tools/process_registry_notifications.py +++ b/tools/process_registry_notifications.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from contextlib import suppress _DONE = ("completed", "success") +_REASON_STATUS = {"lost": "marked lost because the process backend disappeared", "failed_start": "failed to start"} @dataclass(frozen=True, slots=True) @@ -16,9 +17,12 @@ class ProcessNotificationBatch: notifications: tuple[tuple[dict, str], ...] + def _live(self, registry) -> list: + return [(event, text) for event, text in self.notifications + if not registry.is_completion_consumed(event.get("session_id", ""))] + def render(self, registry) -> str | None: - messages = [text for event, text in self.notifications - if not registry.is_completion_consumed(event.get("session_id", ""))] + messages = [text for _event, text in self._live(registry)] if not messages: return None if len(messages) == 1: @@ -28,6 +32,9 @@ class ProcessNotificationBatch: "preserve failures and actionable results.]") return "\n\n".join((header, *messages)) + def display_text(self, registry) -> str: + return process_completion_display_text([event for event, _text in self._live(registry)]) + def group_process_notifications(notifications): """Group consecutive completions only; watches and delegations are barriers.""" @@ -298,16 +305,48 @@ def async_delegation_display_text(evt: dict) -> str: return f"Subagent Tasks {outcome}: {title} ({len(results)} tasks)" -class SubagentNotification(str): - """Keep queued model text string-compatible, with a separate human preview.""" +PROCESS_COMPLETE_DISPLAY_KIND = "process_complete" + + +def _short_command(command) -> str: + cmd = " ".join(str(command or "").split()) + return cmd[:77] + "..." if len(cmd) > 80 else cmd + + +def process_completion_display_text(events: list) -> str: + """Compact UI title for one or more process completions; the model text keeps the full output.""" + if len(events) != 1: + return f"{len(events)} Background Processes Finished" + evt = events[0] + reason, exit_code = evt.get("completion_reason") or "exited", evt.get("exit_code", "?") + if reason == "killed": + outcome = "Terminated" + elif reason in _REASON_STATUS: + outcome = "Lost" if reason == "lost" else "Failed to Start" + else: + outcome = "Finished" if exit_code == 0 else "Failed" + cmd = _short_command(evt.get("command")) + detail = f" (exit {exit_code})" if reason not in ("killed", *_REASON_STATUS) and exit_code != 0 else "" + return f"Background Process {outcome}{detail}: {cmd}" if cmd else f"Background Process {outcome}{detail}" + + +class TimelineNotification(str): + """Queued model text that stays string-compatible, plus the display kind and compact human + title the surface paints instead of the raw notification wall.""" display_text: str + display_kind: str - def __new__(cls, text: str, event: dict): + def __new__(cls, text: str, display_text: str, display_kind: str): instance = super().__new__(cls, text) - instance.display_text = async_delegation_display_text(event) + instance.display_text = display_text + instance.display_kind = display_kind return instance + @classmethod + def for_delegation(cls, text: str, event: dict) -> "TimelineNotification": + return cls(text, async_delegation_display_text(event), "async_delegation_complete") + def _delegation_attribution_line(evt: dict) -> "str | None": """One-line provenance for a subagent-owned process event, else None. Such a process @@ -329,9 +368,6 @@ def _delegation_attribution_line(evt: dict) -> "str | None": + (f' Task: "{goal}"' if goal else "")) -_REASON_STATUS = {"lost": "marked lost because the process backend disappeared", "failed_start": "failed to start"} - - def _completion_status(evt: dict) -> str: reason = evt.get("completion_reason") or "exited" if reason == "killed": diff --git a/tui_gateway/session_notifications.py b/tui_gateway/session_notifications.py index 5d16abf471..1e80a97696 100644 --- a/tui_gateway/session_notifications.py +++ b/tui_gateway/session_notifications.py @@ -431,8 +431,9 @@ def _notif_handle_event(sid, session, evt, emitted, registry, fmt, deferred, com # while distinct watch_match events from one process must stay visible. dedup_key = _notification_event_dedup_key(evt) if dedup_key not in emitted: - from tools.process_registry_notifications import async_delegation_display_text - display_text = async_delegation_display_text(evt) if is_delegation else text + from tools.process_registry_notifications import async_delegation_display_text, process_completion_display_text + display_text = (async_delegation_display_text(evt) if is_delegation + else process_completion_display_text([evt]) if evt_type == "completion" else text) _emit("status.update", sid, {"kind": "process", "text": display_text}) emitted.add(dedup_key) if evt_type == "completion" and completions is not None: @@ -449,7 +450,7 @@ def _notif_handle_event(sid, session, evt, emitted, registry, fmt, deferred, com def _notif_dispatch_completions(sid, session, notifications, registry, deferred): - from tools.process_registry_notifications import ProcessNotificationBatch + from tools.process_registry_notifications import PROCESS_COMPLETE_DISPLAY_KIND, ProcessNotificationBatch from tools.async_delegation import claim_event_delivery, complete_event_delivery, release_event_delivery if not notifications: @@ -462,13 +463,15 @@ def _notif_dispatch_completions(sid, session, notifications, registry, deferred) return claimed = [(event, text, claim) for event, text in notifications if (claim := claim_event_delivery(event, "tui-completion-batch")) is not None] - text = ProcessNotificationBatch(tuple((event, text) for event, text, _claim in claimed)).render(registry) + batch = ProcessNotificationBatch(tuple((event, text) for event, text, _claim in claimed)) + text = batch.render(registry) if text is None: _notif_release_turn(session) try: if text is not None: _notif_submit(f"__notif__{int(time.time() * 1000)}", sid, session, text, - "completion batch dispatch failed") + "completion batch dispatch failed", display_kind=PROCESS_COMPLETE_DISPLAY_KIND, + display_metadata={"display_text": batch.display_text(registry)}) except Exception: for event, _text, claim in claimed: release_event_delivery(event, claim) diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts index 7806f4e572..04c9c0f6fd 100644 --- a/ui-tui/src/domain/messages.ts +++ b/ui-tui/src/domain/messages.ts @@ -71,14 +71,16 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { continue } - if (display_kind === 'async_delegation_complete') { + if (display_kind === 'async_delegation_complete' || display_kind === 'process_complete') { const meta = (row as TranscriptRow).display_metadata const count = meta && typeof meta.task_count === 'number' ? meta.task_count : undefined const label = - count === undefined - ? 'background agent work finished' - : `${count} background agent${count === 1 ? '' : 's'} finished` + display_kind === 'process_complete' + ? 'background process finished' + : count === undefined + ? 'background agent work finished' + : `${count} background agent${count === 1 ? '' : 's'} finished` out.push({ kind: 'event',