feat: add generic gateway status phrases

This commit is contained in:
devatnull 2026-06-28 21:00:59 +03:00 committed by Teknium
parent b0f2bdbe8b
commit 4bf5b563bd
13 changed files with 764 additions and 45 deletions

View file

@ -7,5 +7,7 @@ graft locales
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
# below covers the wheel; this covers the sdist. See #34034 / #28149.
recursive-include plugins plugin.yaml plugin.yml
# Gateway assets include images plus YAML catalogs such as status_phrases.yaml.
recursive-include gateway/assets *
global-exclude __pycache__
global-exclude *.py[cod]

5
cli.py
View file

@ -3698,7 +3698,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self.console = Console()
self.config = CLI_CONFIG
self.compact = compact if compact is not None else CLI_CONFIG["display"].get("compact", False)
# tool_progress: "off", "new", "all", "verbose" (from config.yaml display section)
# tool_progress: "off", "generic", "new", "all", "verbose" (from config.yaml display section)
# YAML 1.1 parses bare `off` as boolean False — normalise to string.
_raw_tp = CLI_CONFIG["display"].get("tool_progress", "all")
self.tool_progress_mode = "off" if _raw_tp is False else str(_raw_tp)
@ -9191,7 +9191,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
explicit ``-v``/``--verbose`` flag and the ``/verbose-logging``
toggle. See PR #6a1aa420e for the history that decoupled them.
"""
cycle = ["off", "new", "all", "verbose"]
cycle = ["off", "generic", "new", "all", "verbose"]
try:
idx = cycle.index(self.tool_progress_mode)
except ValueError:
@ -9212,6 +9212,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
from hermes_cli.colors import Colors as _Colors
labels = {
"off": f"{_Colors.DIM}Tool progress: OFF{_Colors.RESET} — silent mode, just the final response.",
"generic": f"{_Colors.YELLOW}Tool progress: GENERIC{_Colors.RESET} — show friendly placeholders instead of tool details.",
"new": f"{_Colors.YELLOW}Tool progress: NEW{_Colors.RESET} — show each new tool (skip repeats).",
"all": f"{_Colors.GREEN}Tool progress: ALL{_Colors.RESET} — show every tool call.",
"verbose": f"{_Colors.BOLD}{_Colors.GREEN}Tool progress: VERBOSE{_Colors.RESET} — full args, results, and think blocks.",

View file

@ -0,0 +1,198 @@
# Default phrases for generic gateway visibility surfaces.
#
# These are Hermes UI/status surfaces, not app/vendor/domain buckets. Keep them
# generic: do not mention Jira, Confluence, Gmail, local paths, model names,
# secrets, or tool arguments. User config can append/replace these via:
#
# display:
# status_phrases:
# mode: append # append | replace
# thinking:
# - one sec, thinking this through
# platforms:
# whatsapp:
# status_phrases:
# status:
# - still on it
thinking:
- one sec, thinking it through
- thinking this through
- hmm, working through it
- one sec, lining it up
- checking the shape of it
- following the thread
- sorting the pieces
- thinking through the tradeoffs
- one sec, making sure this fits
- working out the clean version
- holding the thought for a second
- checking the logic
- one sec, theres a detail here
- turning it over quickly
- making sure Im not missing a bit
- mapping the moving pieces
- letting the idea settle for a sec
- checking the edge cases
- narrowing the answer down
- one sec, this has a wrinkle
- tracing the dependency chain
- sorting signal from noise
- making the answer less wrong
- checking the assumption
- building the mental model
- sanity-checking it
- working through the awkward part
- finding the clean path
- one sec, connecting the dots
- checking whether that actually follows
tool:
- checking that now
- looking into it
- one sec, checking the details
- working through the lookup
- checking the actual thing
- pulling the thread
- one sec, doing the lookup
- checking the source
- looking at the live data
- grabbing the relevant bit
- checking whats there
- one sec, verifying it
- reading through it
- checking the current state
- looking for the signal
- checking the record
- digging through the source
- fetching the useful bit
- seeing what it says
- looking under the hood
- checking the real state
- pulling current context
- scanning the relevant output
- checking the available info
- one sec, comparing the pieces
- checking the latest version
- confirming against the source
- looking for the exact bit
- checking where this lands
- one sec, validating it
command:
- running it now
- checking what the machine says
- one sec, running the command
- running the check
- asking the machine directly
- letting it run for a sec
- checking the output
- running the useful bit
- one sec, getting a real result
- testing it locally
- checking the logs
- running the verification
- waiting on the command
- checking the system state
- letting the process finish
- running the test
- asking the shell
- waiting for the output
- checking the result
- letting the command complete
- running the boring bit
- checking the terminal output
- one sec, the machine is thinking
- running the local check
- waiting on the process
- checking whether it passes
- letting the job finish
- watching the output
- verifying it directly
- checking the exit status
interim:
- one sec, theres a bit more here
- still shaping the answer
- stitching this together
- almost got the useful version
- one sec, connecting the pieces
- working this into a clean answer
- still narrowing it down
- pulling this into shape
- one sec, checking the last part
- getting the answer into a useful form
- almost there
- one more bit to check
- turning this into something readable
- still pulling it together
- one sec, making this less messy
- checking the last thread
- shaping the final answer
- joining the dots
- just tightening the answer
- one sec, cleaning this up
- still organizing it
- almost got the shape
- one more pass
- making the useful part clearer
- still stitching
- checking the last detail
- making sure this lands cleanly
- one sec, summarizing the useful bit
- pulling the answer together
- final bit of cleanup
status:
- still on it
- still working through it
- this is taking a bit, still running
- still checking
- not stuck, just still running
- waiting on the slow part
- still alive, still working
- letting it finish
- still processing this
- one sec, this is still going
- still making progress
- waiting for the result
- still here, checking
- the slow bit is still running
- still working on the useful answer
- no result yet, still waiting
- still running the check
- waiting on the backend bit
- this one needs a minute
- still moving
- still going, not frozen
- waiting for the useful output
- still chewing through it
- one sec, long bit is still running
- still waiting on the result
- working through the slow step
- this is taking longer than usual
- still checking, no panic
- still in progress
- letting the slow thing finish
generic:
- on it
- one sec
- checking that now
- give me a sec
- looking into it
- working through it
- checking
- still working
- one moment
- taking a look
- got it, checking
- give me a moment
- looking now
- sorting it
- checking the useful bit
- one sec, working on it
- taking a proper look
- checking the details
- working it out
- still looking

View file

@ -233,16 +233,25 @@ def _normalise(setting: str, value: Any) -> Any:
return "off"
if value is True:
return "all"
return str(value).lower()
val = str(value).lower()
return val if val in {"off", "generic", "new", "all", "verbose"} else "all"
if setting in {
"show_reasoning",
"streaming",
"interim_assistant_messages",
"long_running_notifications",
"busy_ack_detail",
"thinking_progress",
}:
if isinstance(value, str):
return value.lower() in {"true", "1", "yes", "on"}
val = value.strip().lower()
if val == "generic" and setting in {
"thinking_progress",
"interim_assistant_messages",
"long_running_notifications",
}:
return "generic"
return val in {"true", "1", "yes", "on", "raw", "verbose"}
return bool(value)
if setting == "cleanup_progress":
if isinstance(value, str):

View file

@ -16490,6 +16490,46 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
# Tool progress grouping: "accumulate" (edit one bubble) or "separate" (one msg per tool)
progress_grouping = resolve_display_setting(user_config, platform_key, "tool_progress_grouping") or "accumulate"
from gateway.status_phrases import choose_status_phrase, resolve_status_phrase_catalog
_generic_status_recent: List[str] = []
_generic_status_catalog = resolve_status_phrase_catalog(user_config, platform_key)
def _display_surface_mode(
setting: str,
*,
default: bool = False,
require_platform_override_for: set[Any] | None = None,
) -> str:
"""Return off|raw|generic for a gateway visibility surface."""
if require_platform_override_for:
current_platform = _gateway_platform_value(source.platform)
platform_only = {
_gateway_platform_value(item)
for item in require_platform_override_for
}
if (
current_platform in platform_only
and not _has_platform_display_override(user_config, platform_key, setting)
):
return "off"
value = resolve_display_setting(user_config, platform_key, setting, default)
if isinstance(value, str) and value.strip().lower() == "generic":
return "generic"
return "raw" if bool(value) else "off"
def _generic_status_phrase(kind: str, *, tool_name: str | None = None, preview: str | None = None, args: Any = None) -> str:
try:
return choose_status_phrase(
kind,
tool_name=tool_name,
preview=preview,
args=args,
recent=_generic_status_recent,
catalog=_generic_status_catalog,
)
except Exception as _phrase_err:
logger.debug("generic status phrase selection failed: %s", _phrase_err)
return "still on it" if kind in {"heartbeat", "waiting", "long_running", "status"} else "one sec"
# Disable tool progress for webhooks - they don't support message editing,
# so each progress line would be sent as a separate message.
from gateway.config import Platform
@ -16501,29 +16541,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Natural assistant status messages are intentionally independent from
# tool progress and token streaming. Users can keep tool_progress quiet
# in chat platforms while opting into concise mid-turn updates.
interim_assistant_messages_mode = _display_surface_mode(
"interim_assistant_messages",
default=True,
require_platform_override_for={Platform.MATTERMOST},
)
interim_assistant_messages_enabled = (
source.platform != Platform.WEBHOOK
and _resolve_gateway_display_bool(
user_config,
platform_key,
"interim_assistant_messages",
default=True,
platform=source.platform,
require_platform_override_for={Platform.MATTERMOST},
)
and interim_assistant_messages_mode != "off"
)
# thinking_progress is independent — if enabled, we need the progress
# queue even when tool_progress is off (thinking relay uses same infra).
# Mattermost requires a per-platform opt-in: global scratch-text display
# is too easy to leak into busy public threads.
_thinking_enabled = _resolve_gateway_display_bool(
user_config,
platform_key,
_thinking_mode = _display_surface_mode(
"thinking_progress",
default=False,
platform=source.platform,
require_platform_override_for={Platform.MATTERMOST},
)
_thinking_enabled = _thinking_mode != "off"
needs_progress_queue = tool_progress_enabled or _thinking_enabled
@ -16653,7 +16689,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if not _thinking_enabled:
return
thinking_text = preview if tool_name == "_thinking" else tool_name
msg = f"💬 {thinking_text}" if thinking_text else None
if _thinking_mode == "generic":
msg = _generic_status_phrase("thinking", preview=str(thinking_text or ""))
else:
msg = f"💬 {thinking_text}" if thinking_text else None
if msg:
progress_queue.put(msg)
return
@ -16687,6 +16726,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if progress_mode == "new" and tool_name == last_tool[0]:
return
last_tool[0] = tool_name
if tool_progress_generic:
progress_queue.put(
_generic_status_phrase(
"tool",
tool_name=str(tool_name or ""),
preview=str(preview or ""),
args=args,
)
)
return
# Build progress message with primary argument preview
from agent.display import get_tool_emoji
@ -17487,18 +17537,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:
if not _run_still_current():
return
display_text = (
_generic_status_phrase("interim", preview=str(text or ""))
if interim_assistant_messages_mode == "generic"
else text
)
if _stream_consumer is not None:
if already_streamed:
_stream_consumer.on_segment_break()
else:
_stream_consumer.on_commentary(text)
_stream_consumer.on_commentary(display_text)
return
if already_streamed or not _status_adapter or not str(text or "").strip():
if already_streamed or not _status_adapter or not str(display_text or "").strip():
return
safe_schedule_threadsafe(
_status_adapter.send(
_status_chat_id,
text,
display_text,
metadata=_status_thread_metadata,
),
_loop_for_step,
@ -18669,14 +18724,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# 0 = disable notifications.
_NOTIFY_INTERVAL_RAW = _float_env("HERMES_AGENT_NOTIFY_INTERVAL", 180)
_NOTIFY_INTERVAL = _NOTIFY_INTERVAL_RAW if _NOTIFY_INTERVAL_RAW > 0 else None
if not bool(
resolve_display_setting(
user_config,
platform_key,
"long_running_notifications",
True,
)
):
_long_running_mode = _display_surface_mode(
"long_running_notifications",
default=True,
)
if _long_running_mode == "off":
_NOTIFY_INTERVAL = None
_notify_start = time.time()
@ -18738,7 +18790,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_status_detail = "" + ", ".join(_parts)
except Exception:
pass
_heartbeat_text = f"⏳ Working — {_elapsed_mins} min{_status_detail}"
_heartbeat_text = (
_generic_status_phrase("status")
if _long_running_mode == "generic"
else f"⏳ Working — {_elapsed_mins} min{_status_detail}"
)
try:
_notify_res = None
if _heartbeat_msg_id:

View file

@ -2936,6 +2936,7 @@ class GatewaySlashCommandsMixin:
cycle = ["off", "new", "all", "verbose", "log"]
descriptions = {
"off": t("gateway.verbose.mode_off"),
"generic": "GENERIC — show human-friendly placeholders instead of tool details.",
"new": t("gateway.verbose.mode_new"),
"all": t("gateway.verbose.mode_all"),
"verbose": t("gateway.verbose.mode_verbose"),

241
gateway/status_phrases.py Normal file
View file

@ -0,0 +1,241 @@
"""Human-friendly generic gateway status phrases.
These helpers deliberately avoid relaying raw model scratch text. They turn
Hermes' own gateway visibility surfaces (thinking/tool/command/interim/status)
into short status lines suitable for chat surfaces.
Built-in defaults live in ``gateway/assets/status_phrases.yaml``. Users can add
portable, profile-relative phrase catalogs under ``HERMES_HOME`` either by using
conventional paths::
~/.hermes/status_phrases.yaml
~/.hermes/status_phrases/*.yaml
or by pointing config at a relative file/directory::
display:
status_phrases:
path: status_phrases/whatsapp.yaml # relative to HERMES_HOME
mode: append # append (default) or replace
Absolute paths and ``..`` escapes are ignored on purpose so config stays
profile-portable and does not accidentally read arbitrary files.
Only configured phrase strings are used; raw tool args, commands, previews, and
reasoning text are never interpolated into the returned phrase.
"""
from __future__ import annotations
import random as _random
from collections.abc import Mapping, MutableSequence
from pathlib import Path
from typing import Any
import yaml
from hermes_constants import get_hermes_home
# These are Hermes UI surfaces, not app/vendor/domain buckets. Do not add
# Jira/Confluence/Gmail/etc. categories here; tool names are only used to choose
# between the built-in "tool" and "command" surfaces.
_STATUS_SURFACES = ("thinking", "tool", "command", "interim", "status", "generic")
_COMMAND_TOOL_NAMES = {"terminal", "execute_code", "process"}
_MAX_CUSTOM_PHRASES_PER_SURFACE = 80
_MAX_PHRASE_CHARS = 160
_CONVENTIONAL_RELATIVE_PATHS = ("status_phrases.yaml", "status_phrases")
_FALLBACK_PHRASES: dict[str, list[str]] = {
"thinking": ["one sec, thinking it through", "thinking this through", "checking the logic"],
"tool": ["checking that now", "looking into it", "checking the source"],
"command": ["running it now", "checking what the machine says", "checking the output"],
"interim": ["still shaping the answer", "stitching this together", "almost there"],
"status": ["still on it", "still working through it", "waiting for the result"],
"generic": ["on it", "one sec", "checking that now"],
}
def _clean_phrase_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
cleaned: list[str] = []
seen: set[str] = set()
for item in value[:_MAX_CUSTOM_PHRASES_PER_SURFACE]:
phrase = str(item or "").strip()
if not phrase or len(phrase) > _MAX_PHRASE_CHARS or phrase in seen:
continue
cleaned.append(phrase)
seen.add(phrase)
return cleaned
def _merge_phrase_mapping(catalog: dict[str, list[str]], section: Mapping[str, Any], *, inherited_mode: str | None = None) -> None:
mode = str(section.get("mode") or inherited_mode or "append").strip().lower()
replace = mode == "replace"
phrase_map = section.get("phrases") if isinstance(section.get("phrases"), Mapping) else section
for surface in _STATUS_SURFACES:
phrases = _clean_phrase_list(phrase_map.get(surface) if isinstance(phrase_map, Mapping) else None)
if not phrases:
continue
catalog[surface] = phrases if replace else [*catalog.get(surface, []), *phrases]
def _merge_phrase_file(catalog: dict[str, list[str]], path: Path, *, inherited_mode: str | None = None) -> None:
try:
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
except Exception:
return
if isinstance(loaded, Mapping):
_merge_phrase_mapping(catalog, loaded, inherited_mode=inherited_mode)
def _relative_path_under(base_dir: Path, raw_path: Any) -> Path | None:
raw = str(raw_path or "").strip()
if not raw:
return None
candidate = Path(raw).expanduser()
if candidate.is_absolute() or ".." in candidate.parts:
return None
base = base_dir.resolve()
resolved = (base / candidate).resolve()
try:
resolved.relative_to(base)
except ValueError:
return None
return resolved
def _iter_phrase_files(path: Path) -> list[Path]:
if path.is_file() and path.suffix.lower() in {".yaml", ".yml"}:
return [path]
if path.is_dir():
return sorted(
child for child in path.iterdir()
if child.is_file() and child.suffix.lower() in {".yaml", ".yml"}
)
return []
def _merge_phrase_paths(
catalog: dict[str, list[str]],
paths: Any,
*,
base_dir: Path,
inherited_mode: str | None = None,
) -> None:
if paths is None:
return
raw_paths = paths if isinstance(paths, list) else [paths]
for raw_path in raw_paths:
resolved = _relative_path_under(base_dir, raw_path)
if resolved is None:
continue
for phrase_file in _iter_phrase_files(resolved):
_merge_phrase_file(catalog, phrase_file, inherited_mode=inherited_mode)
def _load_builtin_catalog() -> dict[str, list[str]]:
catalog = {surface: list(phrases) for surface, phrases in _FALLBACK_PHRASES.items()}
catalog_path = Path(__file__).resolve().parent / "assets" / "status_phrases.yaml"
_merge_phrase_file(catalog, catalog_path, inherited_mode="replace")
return catalog
_DEFAULT_PHRASES: dict[str, list[str]] = _load_builtin_catalog()
def _copy_default_catalog() -> dict[str, list[str]]:
return {surface: list(phrases) for surface, phrases in _DEFAULT_PHRASES.items()}
def _merge_phrase_config(catalog: dict[str, list[str]], section: Any, *, base_dir: Path | None = None) -> None:
"""Merge one display.status_phrases-style section into ``catalog``."""
if not isinstance(section, Mapping):
return
mode = str(section.get("mode") or "append").strip().lower()
if base_dir is not None:
_merge_phrase_paths(catalog, section.get("path"), base_dir=base_dir, inherited_mode=mode)
_merge_phrase_paths(catalog, section.get("paths"), base_dir=base_dir, inherited_mode=mode)
_merge_phrase_mapping(catalog, section)
def resolve_status_phrase_catalog(user_config: Mapping[str, Any] | None, platform_key: str | None = None) -> dict[str, list[str]]:
"""Resolve built-in + user-configured generic status phrases.
Resolution order mirrors gateway display settings: built-ins, conventional
profile-relative user files, global ``display.status_phrases`` (or legacy
alias ``generic_status_phrases``), then
``display.platforms.<platform>.status_phrases``.
"""
catalog = _copy_default_catalog()
hermes_home = get_hermes_home()
_merge_phrase_paths(catalog, list(_CONVENTIONAL_RELATIVE_PATHS), base_dir=hermes_home)
display = (user_config or {}).get("display") if isinstance(user_config, Mapping) else None
if not isinstance(display, Mapping):
return catalog
_merge_phrase_config(catalog, display.get("generic_status_phrases"), base_dir=hermes_home)
_merge_phrase_config(catalog, display.get("status_phrases"), base_dir=hermes_home)
platforms = display.get("platforms")
if platform_key and isinstance(platforms, Mapping):
platform_display = platforms.get(platform_key)
if isinstance(platform_display, Mapping):
_merge_phrase_config(catalog, platform_display.get("generic_status_phrases"), base_dir=hermes_home)
_merge_phrase_config(catalog, platform_display.get("status_phrases"), base_dir=hermes_home)
return catalog
def classify_status_context(
kind: str,
*,
tool_name: str | None = None,
preview: str | None = None,
args: Any = None,
) -> str:
"""Classify an internal gateway event into a Hermes UI-surface bucket."""
normalized = str(kind or "").strip().lower()
if normalized in {"heartbeat", "waiting", "long_running", "status"}:
return "status"
if normalized in {"thinking", "_thinking", "reasoning"}:
return "thinking"
if normalized in {"interim", "interim_assistant"}:
return "interim"
if normalized in {"command", "terminal"}:
return "command"
if normalized == "tool":
name = (tool_name or "").strip().lower()
return "command" if name in _COMMAND_TOOL_NAMES else "tool"
return "generic"
def choose_status_phrase(
kind: str,
*,
tool_name: str | None = None,
preview: str | None = None,
args: Any = None,
recent: MutableSequence[str] | None = None,
rng: Any = None,
catalog: Mapping[str, list[str]] | None = None,
) -> str:
"""Pick a short generic status phrase, avoiding recent repeats.
``preview`` and ``args`` are accepted for callback compatibility, but their
raw contents are never embedded in the returned phrase.
"""
phrase_catalog = catalog or _DEFAULT_PHRASES
category = classify_status_context(kind, tool_name=tool_name, preview=preview, args=args)
candidates = list(phrase_catalog.get(category) or phrase_catalog.get("generic") or _DEFAULT_PHRASES["generic"])
if recent:
recent_set = set(recent)
fresh = [phrase for phrase in candidates if phrase not in recent_set]
if fresh:
candidates = fresh
picker = rng or _random
phrase = picker.choice(candidates)
if recent is not None:
recent.append(phrase)
del recent[:-6]
return phrase

View file

@ -1773,6 +1773,15 @@ DEFAULT_CONFIG = {
# applies where tool_progress is already enabled. Per-platform override
# via display.platforms.<platform>.tool_progress_grouping.
"tool_progress_grouping": "accumulate",
# Optional custom phrases for display.* generic visibility modes.
# Built-in defaults live in gateway/assets/status_phrases.yaml. Users
# can set `path`/`paths` to HERMES_HOME-relative YAML files/directories
# (or rely on conventional status_phrases.yaml / status_phrases/*.yaml).
# Keys: thinking, tool, command, interim, status, generic. Use
# mode: "append" (default) to add phrases, or "replace" to fully
# replace configured surfaces. Per-platform overrides live under
# display.platforms.<platform>.status_phrases.
"status_phrases": {},
# How a reasoning/thinking summary renders when show_reasoning is on.
# "code" (default) = 💭 fenced code block; "blockquote" = "> " lines;
# "subtext" = "-# " lines (Discord small grey metadata text). Discord
@ -5337,7 +5346,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
if old_enabled and old_enabled.lower() in {"false", "0", "no"}:
display["tool_progress"] = "off"
results["config_added"].append("display.tool_progress=off (from HERMES_TOOL_PROGRESS=false)")
elif old_mode and old_mode.lower() in {"new", "all"}:
elif old_mode and old_mode.lower() in {"generic", "new", "all", "verbose"}:
display["tool_progress"] = old_mode.lower()
results["config_added"].append(f"display.tool_progress={old_mode.lower()} (from HERMES_TOOL_PROGRESS_MODE)")
else:

View file

@ -99,7 +99,7 @@ class TestVerboseAndToolProgress:
def test_tool_progress_mode_is_string(self):
cli = _make_cli()
assert isinstance(cli.tool_progress_mode, str)
assert cli.tool_progress_mode in {"off", "new", "all", "verbose"}
assert cli.tool_progress_mode in {"off", "generic", "new", "all", "verbose"}
class TestFallbackChainInit:

View file

@ -149,6 +149,36 @@ class TestYAMLNormalisation:
config = {"display": {"tool_progress": True}}
assert resolve_display_setting(config, "telegram", "tool_progress") == "all"
def test_tool_progress_generic_is_first_class_mode(self):
from gateway.display_config import resolve_display_setting
config = {"display": {"platforms": {"whatsapp": {"tool_progress": "generic"}}}}
assert resolve_display_setting(config, "whatsapp", "tool_progress") == "generic"
def test_chatter_visibility_surfaces_accept_generic_mode(self):
from gateway.display_config import resolve_display_setting
config = {
"display": {
"platforms": {
"whatsapp": {
"thinking_progress": "generic",
"interim_assistant_messages": "generic",
"long_running_notifications": "generic",
}
}
}
}
assert resolve_display_setting(config, "whatsapp", "thinking_progress") == "generic"
assert resolve_display_setting(config, "whatsapp", "interim_assistant_messages") == "generic"
assert resolve_display_setting(config, "whatsapp", "long_running_notifications") == "generic"
def test_thinking_progress_string_false_normalised_to_false(self):
from gateway.display_config import resolve_display_setting
config = {"display": {"platforms": {"whatsapp": {"thinking_progress": "false"}}}}
assert resolve_display_setting(config, "whatsapp", "thinking_progress") is False
def test_show_reasoning_string_true(self):
"""String 'true' is normalised to bool True."""
from gateway.display_config import resolve_display_setting

View file

@ -0,0 +1,173 @@
import random
from gateway.status_phrases import (
classify_status_context,
choose_status_phrase,
resolve_status_phrase_catalog,
)
def test_terminal_tool_uses_command_surface_bucket():
assert classify_status_context("tool", tool_name="terminal") == "command"
assert classify_status_context("command") == "command"
def test_regular_tool_uses_tool_surface_bucket_not_domain_bucket():
assert classify_status_context("tool", tool_name="web_search", preview="weather") == "tool"
assert classify_status_context("tool", tool_name="mcp_atlassian_infohealth_getJiraIssue") == "tool"
def test_interim_uses_interim_surface_bucket():
assert classify_status_context("interim_assistant") == "interim"
def test_generic_phrase_does_not_leak_raw_thinking_text():
msg = choose_status_phrase(
"thinking",
preview="actual private scratch text should not be sent",
rng=random.Random(4),
)
assert "actual private scratch" not in msg
assert msg
def test_generic_tool_phrase_does_not_leak_vendor_or_args():
msg = choose_status_phrase(
"tool",
tool_name="mcp_atlassian_infohealth_getJiraIssue",
args={"issueIdOrKey": "SECRET-123"},
rng=random.Random(1),
)
assert "jira" not in msg.lower()
assert "confluence" not in msg.lower()
assert "atlassian" not in msg.lower()
assert "SECRET-123" not in msg
def test_generic_phrase_avoids_recent_repetition():
recent: list[str] = []
first = choose_status_phrase("tool", tool_name="web_search", rng=random.Random(2), recent=recent)
second = choose_status_phrase("tool", tool_name="web_search", rng=random.Random(2), recent=recent)
assert first != second
assert recent[-2:] == [first, second]
def test_builtin_catalog_is_loaded_from_external_asset_and_is_not_tiny():
catalog = resolve_status_phrase_catalog({}, "whatsapp")
for surface in ("thinking", "tool", "command", "interim", "status"):
assert len(catalog[surface]) >= 25, surface
def test_relative_status_phrase_path_loads_from_hermes_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
phrase_file = tmp_path / "phrases.yaml"
phrase_file.write_text("mode: replace\ntool:\n - relative safe tool text\n", encoding="utf-8")
catalog = resolve_status_phrase_catalog(
{"display": {"status_phrases": {"path": "phrases.yaml"}}},
"whatsapp",
)
assert catalog["tool"] == ["relative safe tool text"]
def test_status_phrase_path_can_load_relative_directory(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
phrase_dir = tmp_path / "phrase-catalog"
phrase_dir.mkdir()
(phrase_dir / "01-status.yaml").write_text("status:\n - relative dir status text\n", encoding="utf-8")
catalog = resolve_status_phrase_catalog(
{"display": {"status_phrases": {"path": "phrase-catalog"}}},
"whatsapp",
)
assert "relative dir status text" in catalog["status"]
def test_absolute_or_parent_phrase_paths_are_ignored(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
outside = tmp_path.parent / "outside-phrases.yaml"
outside.write_text("mode: replace\ntool:\n - should not load\n", encoding="utf-8")
catalog = resolve_status_phrase_catalog(
{"display": {"status_phrases": {"path": str(outside)}}},
"whatsapp",
)
escaped = resolve_status_phrase_catalog(
{"display": {"status_phrases": {"path": "../outside-phrases.yaml"}}},
"whatsapp",
)
assert catalog["tool"] != ["should not load"]
assert escaped["tool"] != ["should not load"]
def test_conventional_relative_status_phrase_file_is_loaded(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "status_phrases.yaml").write_text(
"mode: replace\nstatus:\n - conventional status text\n",
encoding="utf-8",
)
catalog = resolve_status_phrase_catalog({}, "whatsapp")
assert catalog["status"] == ["conventional status text"]
def test_global_custom_phrase_catalog_appends_to_builtin():
catalog = resolve_status_phrase_catalog(
{
"display": {
"status_phrases": {
"thinking": ["custom thinking placeholder"],
}
}
},
"whatsapp",
)
assert "custom thinking placeholder" in catalog["thinking"]
assert len(catalog["thinking"]) > 1
def test_platform_custom_phrase_catalog_can_replace_surface():
catalog = resolve_status_phrase_catalog(
{
"display": {
"platforms": {
"whatsapp": {
"status_phrases": {
"mode": "replace",
"tool": ["custom tool placeholder"],
}
}
}
}
},
"whatsapp",
)
assert catalog["tool"] == ["custom tool placeholder"]
assert len(catalog["thinking"]) > 1
def test_choose_status_phrase_uses_custom_catalog_without_leaking_args():
catalog = resolve_status_phrase_catalog(
{"display": {"status_phrases": {"mode": "replace", "tool": ["custom safe tool text"]}}},
"whatsapp",
)
msg = choose_status_phrase(
"tool",
tool_name="web_search",
args={"query": "SECRET SEARCH"},
catalog=catalog,
)
assert msg == "custom safe tool text"
assert "SECRET" not in msg

View file

@ -105,7 +105,7 @@ class TestVerboseCommand:
@pytest.mark.asyncio
async def test_cycles_through_all_modes(self, tmp_path, monkeypatch):
"""Calling /verbose repeatedly cycles through all four modes."""
"""Calling /verbose repeatedly cycles through all tool-progress visibility modes."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
@ -132,8 +132,7 @@ class TestVerboseCommand:
Telegram's tier-1 preset overrides ``tool_progress`` to ``"off"`` so the
platform stays final-answer-first by default on mobile inboxes. The
first ``/verbose`` invocation therefore cycles ``off new``, not
``all ...``.
first ``/verbose`` invocation therefore cycles ``off generic``.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
@ -148,10 +147,10 @@ class TestVerboseCommand:
runner = _make_runner()
result = await runner._handle_verbose_command(_make_event())
# Telegram platform default is "off" → cycles to "new"
assert "NEW" in result
# Telegram platform default is "off" → cycles to "generic"
assert "GENERIC" in result
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "new"
assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "generic"
@pytest.mark.asyncio
async def test_per_platform_isolation(self, tmp_path, monkeypatch):
@ -159,7 +158,7 @@ class TestVerboseCommand:
Without a global tool_progress, each platform uses its built-in
default Telegram = 'off' (tier-1 inbox override), Slack = 'off'
(quiet Slack default). Both cycle to 'new' on first /verbose.
(quiet Slack default). Both cycle to 'generic' on first /verbose.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
@ -184,10 +183,10 @@ class TestVerboseCommand:
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
platforms = saved["display"]["platforms"]
# Telegram: off -> new (platform default = off, tier-1 inbox override)
assert platforms["telegram"]["tool_progress"] == "new"
# Slack: off -> new (first /verbose cycle from quiet default)
assert platforms["slack"]["tool_progress"] == "new"
# Telegram: off -> generic (platform default = off, tier-1 inbox override)
assert platforms["telegram"]["tool_progress"] == "generic"
# Slack: off -> generic (first /verbose cycle from quiet default)
assert platforms["slack"]["tool_progress"] == "generic"
@pytest.mark.asyncio
async def test_no_config_file_returns_disabled(self, tmp_path, monkeypatch):

View file

@ -2385,7 +2385,7 @@ def _load_memory_notifications() -> str:
def _load_tool_progress_mode() -> str:
env = os.environ.get("HERMES_TUI_TOOL_PROGRESS", "").strip().lower()
if env in {"off", "new", "all", "verbose"}:
if env in {"off", "generic", "new", "all", "verbose"}:
return env
raw = (_load_cfg().get("display") or {}).get("tool_progress", "all")
if raw is False:
@ -2393,7 +2393,7 @@ def _load_tool_progress_mode() -> str:
if raw is True:
return "all"
mode = str(raw or "all").strip().lower()
return mode if mode in {"off", "new", "all", "verbose"} else "all"
return mode if mode in {"off", "generic", "new", "all", "verbose"} else "all"
def _load_enabled_toolsets() -> list[str] | None:
@ -9930,7 +9930,7 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"key": key, "value": raw})
if key == "verbose":
cycle = ["off", "new", "all", "verbose"]
cycle = ["off", "generic", "new", "all", "verbose"]
cur = (
session.get("tool_progress_mode", _load_tool_progress_mode())
if session