* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales
* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter
ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.
* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs
* chore(tui): split en catalog siblings by lane (slash sibling)
* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports
* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts
* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list
* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes
* feat(plugins): report language-pack layers in the mid-run activation summary
* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n
StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.
* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter
- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
layers partial packs (nested or flat dotted) over bundled/en via
mergeTranslations; a string over a function-valued en entry becomes a
positional {0}/{1} formatter; $appLocaleVersion bumps so translators
re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
registry as source 'backend' (method-not-found is silent); re-synced on
socket open, display.language change and profile switch. A saved pack-only
language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).
* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)
Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.
* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()
- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces
* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)
* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()
Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.
locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).
* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()
- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions
* i18n(platforms): route Google Chat and Teams user-facing text through t()
Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.
Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.
* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()
LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.
* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)
Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.
* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)
_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.
* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)
RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.
* i18n(cli): live-work dock, subagent monitor and render copy through t()
cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.
* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()
* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)
get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.
* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output
* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time
Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).
* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)
cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.
* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()
- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
at call time; category labels via slash.category.*; help/alias/usage suffixes
via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
keeps its English constant; callers use history_unreadable() ->
gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.
* i18n(telegram): route adapter chat copy through t()
Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.
Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.
* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()
- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor
* i18n(discord): route adapter chat copy through t()
Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().
* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)
* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()
- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
_DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
Updating/Generating) are one full template per variant; plurals use
<key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).
* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English
* test(i18n): pin Telegram/Discord adapter catalog wiring
Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.
* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge
* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint
* i18n(tr): translate bundled catalog + tui pack
* i18n(ja): translate bundled catalog + tui pack
* i18n(ko): translate bundled catalog + tui pack
* i18n(zh): translate bundled catalog + tui pack
* i18n(fr): translate bundled catalog + tui pack
* i18n(af): translate bundled catalog + tui pack
* i18n(uk): translate bundled catalog + tui pack
* i18n(ar): translate bundled catalog + tui pack
* i18n(pt): translate bundled catalog + tui pack
* i18n(it): translate bundled catalog + tui pack
* i18n(es): translate bundled catalog + tui pack
* i18n(zh-hant): translate bundled catalog + tui pack
* i18n(ru): translate bundled catalog + tui pack
* i18n(hu): translate bundled catalog + tui pack
* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)
* i18n(de): translate bundled catalog + tui pack
* i18n(ga): translate bundled catalog + tui pack
* test(i18n): fixture matches _normalize_lang(lang, home) signature
* i18n(tui): scaffold userMessages/slashCmd en siblings
* i18n(tui): wire secure prompts + content tables
* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays
Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.
* i18n(tui): wire slash ops/wake replies
* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)
* i18n(tui): wire slash core/debug/setup replies
* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)
* i18n(tui): wire slash session/topup/subscription replies
* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)
* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys
* i18n(tui): wire userMessages copy through the userMessages namespace
* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json
* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)
Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).
* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)
* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)
* tui: i18n-export-en script (English templates for pack translators)
* docs(i18n): bundled TUI packs are the bottom layer of the tui surface
* i18n(ru): translate TUI pack
* i18n(ar): translate TUI pack
* i18n(es): translate TUI pack
* i18n(pt): translate TUI pack
* i18n(ko): translate TUI pack
* i18n(de): translate TUI pack
* i18n(ja): translate TUI pack
* i18n(fr): translate TUI pack
* i18n(tr): translate TUI pack
* i18n(it): translate TUI pack
* i18n(zh): translate TUI pack
* i18n(zh-hant): translate TUI pack
* i18n(hu): translate TUI pack
* i18n(uk): translate TUI pack
1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.
Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).
* i18n(ga): translate TUI pack
* i18n(af): translate TUI pack
* plugin_guard: locale catalogs in language packs step down the agent-config family
A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.
* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)
* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header
Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.
* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel
- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)
* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})
* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property
* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal
* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export
The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.
* test: unbreak two main-red timing tests the PR merge-ref inherits
- test_local_runtime racing fake publishes the modern state record (legacy pid-only
records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
a fixed 0.35s, which a loaded CI runner does not always meet
* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)
* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak
SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.
* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings
* chore(i18n): regenerate desktop key catalog after main sync
---------
Co-authored-by: Teknium <teknium@nousresearch.com>
2180 lines
79 KiB
Python
2180 lines
79 KiB
Python
"""Tests for topic-aware gateway progress updates."""
|
|
|
|
import asyncio
|
|
import importlib
|
|
import sys
|
|
import threading
|
|
import time
|
|
import types
|
|
from types import SimpleNamespace
|
|
from urllib.parse import quote
|
|
|
|
import pytest
|
|
|
|
import gateway.platforms.base as base_platform
|
|
from gateway.config import Platform, PlatformConfig, StreamingConfig
|
|
from gateway.platforms.base import BasePlatformAdapter, SendResult
|
|
from gateway.platforms.event import MessageEvent, MessageType
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
class ProgressCaptureAdapter(BasePlatformAdapter):
|
|
def __init__(self, platform=Platform.TELEGRAM):
|
|
super().__init__(PlatformConfig(enabled=True, token="***"), platform)
|
|
self.sent = []
|
|
self.edits = []
|
|
self.typing = []
|
|
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
return True
|
|
|
|
async def disconnect(self) -> None:
|
|
return None
|
|
|
|
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
|
|
self.sent.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"content": content,
|
|
"reply_to": reply_to,
|
|
"metadata": metadata,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id="progress-1")
|
|
|
|
async def edit_message(self, chat_id, message_id, content) -> SendResult:
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
async def send_typing(self, chat_id, metadata=None) -> None:
|
|
self.typing.append({"chat_id": chat_id, "metadata": metadata})
|
|
|
|
async def stop_typing(self, chat_id) -> None:
|
|
self.typing.append({"chat_id": chat_id, "metadata": {"stopped": True}})
|
|
|
|
async def get_chat_info(self, chat_id: str):
|
|
return {"id": chat_id}
|
|
|
|
|
|
class DiscordProgressCaptureAdapter(ProgressCaptureAdapter):
|
|
"""Capture sends while exercising Discord's real preview formatter."""
|
|
|
|
def __init__(self):
|
|
super().__init__(platform=Platform.DISCORD)
|
|
|
|
def format_tool_preview(self, preview, **kwargs):
|
|
from plugins.platforms.discord.adapter import DiscordAdapter
|
|
|
|
return DiscordAdapter.format_tool_preview(self, preview, **kwargs)
|
|
|
|
|
|
class MediaCaptureProgressAdapter(ProgressCaptureAdapter):
|
|
"""Capture native image batches without contacting a platform API."""
|
|
|
|
def __init__(self, platform=Platform.TELEGRAM):
|
|
super().__init__(platform=platform)
|
|
self.image_batches = []
|
|
|
|
async def send_multiple_images(
|
|
self, chat_id, images, metadata=None, human_delay=0.0
|
|
) -> None:
|
|
self.image_batches.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"images": images,
|
|
"metadata": metadata,
|
|
}
|
|
)
|
|
|
|
|
|
class SmallLimitProgressAdapter(ProgressCaptureAdapter):
|
|
"""Adapter with a tiny platform limit to exercise progress rollover."""
|
|
|
|
MAX_MESSAGE_LENGTH = 180
|
|
|
|
def __init__(self, platform=Platform.TELEGRAM):
|
|
super().__init__(platform=platform)
|
|
self._next_id = 0
|
|
self.oversized_edits = []
|
|
self.oversized_sends = []
|
|
|
|
def _mint_id(self):
|
|
self._next_id += 1
|
|
return f"progress-{self._next_id}"
|
|
|
|
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
|
|
if len(content) > self.MAX_MESSAGE_LENGTH:
|
|
self.oversized_sends.append(content)
|
|
self.sent.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"content": content,
|
|
"reply_to": reply_to,
|
|
"metadata": metadata,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id=self._mint_id())
|
|
|
|
async def edit_message(self, chat_id, message_id, content) -> SendResult:
|
|
if len(content) > self.MAX_MESSAGE_LENGTH:
|
|
self.oversized_edits.append(content)
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
|
|
class MetadataEditProgressCaptureAdapter(ProgressCaptureAdapter):
|
|
async def edit_message(
|
|
self, chat_id, message_id, content, *, finalize: bool = False, metadata=None
|
|
) -> SendResult:
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
"metadata": metadata,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
|
|
class MatrixStreamingProgressCaptureAdapter(ProgressCaptureAdapter):
|
|
"""Records whether Matrix edits are progressive or finalizing."""
|
|
|
|
initial_send_seen = threading.Event()
|
|
progressive_edit_seen = threading.Event()
|
|
|
|
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
|
|
result = await super().send(chat_id, content, reply_to=reply_to, metadata=metadata)
|
|
self.initial_send_seen.set()
|
|
return result
|
|
|
|
async def edit_message(
|
|
self, chat_id, message_id, content, *, finalize: bool = False, metadata=None
|
|
) -> SendResult:
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
"finalize": finalize,
|
|
}
|
|
)
|
|
if not finalize:
|
|
self.progressive_edit_seen.set()
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
|
|
class UnsupportedEditProgressCaptureAdapter(MetadataEditProgressCaptureAdapter):
|
|
"""Chat adapter whose message edits fail (e.g. edit unsupported or rejected)."""
|
|
|
|
async def edit_message(
|
|
self, chat_id, message_id, content, *, finalize: bool = False, metadata=None
|
|
) -> SendResult:
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
"metadata": metadata,
|
|
}
|
|
)
|
|
return SendResult(success=False, error="Not supported")
|
|
|
|
|
|
class RetryableOverflowEditProgressAdapter(SmallLimitProgressAdapter):
|
|
"""Fail the first split edit transiently, then keep editing."""
|
|
|
|
def __init__(self, platform=Platform.TELEGRAM):
|
|
super().__init__(platform=platform)
|
|
self.retryable_edit_failures = 0
|
|
|
|
async def edit_message(self, chat_id, message_id, content) -> SendResult:
|
|
if self.retryable_edit_failures == 0:
|
|
self.retryable_edit_failures += 1
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
}
|
|
)
|
|
return SendResult(
|
|
success=False,
|
|
error="temporary network failure",
|
|
retryable=True,
|
|
error_kind="transient",
|
|
)
|
|
return await super().edit_message(chat_id, message_id, content)
|
|
|
|
|
|
class NonEditingProgressCaptureAdapter(ProgressCaptureAdapter):
|
|
SUPPORTS_MESSAGE_EDITING = False
|
|
|
|
async def edit_message(self, chat_id, message_id, content) -> SendResult:
|
|
raise AssertionError("non-editable adapters should not receive edit_message calls")
|
|
|
|
|
|
class FakeAgent:
|
|
def __init__(self, **kwargs):
|
|
# Capture anything passed via kwargs (older code path) but don't
|
|
# freeze it — production now assigns tool_progress_callback after
|
|
# construction (see gateway/run.py around the agent-cache hit),
|
|
# so we must read it at call time, not at init.
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
cb = self.tool_progress_callback
|
|
if cb is not None:
|
|
cb("tool.started", "terminal", "pwd", {})
|
|
time.sleep(0.35)
|
|
cb("tool.started", "browser_navigate", "https://example.com", {})
|
|
time.sleep(0.35)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class SilentHeartbeatAgent(FakeAgent):
|
|
"""Heartbeat work can call tools yet intentionally deliver no final text."""
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
cb = self.tool_progress_callback
|
|
if cb is not None:
|
|
cb("tool.started", "terminal", "date", {})
|
|
time.sleep(0.35)
|
|
return {"final_response": "[SILENT]", "messages": [], "api_calls": 1}
|
|
|
|
|
|
class NativeTaskCardAdapter(ProgressCaptureAdapter):
|
|
def __init__(self, platform=Platform.SLACK):
|
|
super().__init__(platform=platform)
|
|
self.native_updates = []
|
|
self.native_stops = 0
|
|
|
|
def native_task_cards_enabled(self):
|
|
return True
|
|
|
|
async def send_native_task_card_progress(
|
|
self,
|
|
chat_id,
|
|
tasks,
|
|
*,
|
|
title,
|
|
reply_to=None,
|
|
metadata=None,
|
|
fallback_text=None,
|
|
) -> SendResult:
|
|
self.native_updates.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"tasks": [dict(task) for task in tasks],
|
|
"metadata": dict(metadata or {}),
|
|
"fallback_text": fallback_text,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id="native-stream-1")
|
|
|
|
async def stop_native_task_card_progress(
|
|
self, chat_id, *, reply_to=None, metadata=None
|
|
):
|
|
self.native_stops += 1
|
|
|
|
async def edit_message(
|
|
self, chat_id, message_id, content, *, finalize=False, metadata=None
|
|
) -> SendResult:
|
|
self.edits.append(
|
|
{
|
|
"chat_id": chat_id,
|
|
"message_id": message_id,
|
|
"content": content,
|
|
"metadata": metadata,
|
|
}
|
|
)
|
|
return SendResult(success=True, message_id=message_id)
|
|
|
|
|
|
class FailingNativeTaskCardAdapter(NativeTaskCardAdapter):
|
|
async def send_native_task_card_progress(self, *args, **kwargs) -> SendResult:
|
|
await super().send_native_task_card_progress(*args, **kwargs)
|
|
return SendResult(success=False, error="native stream unavailable", retryable=True)
|
|
|
|
|
|
class DuplicateNativeToolsAgent:
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tool_start_callback = kwargs.get("tool_start_callback")
|
|
self.tool_complete_callback = kwargs.get("tool_complete_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
# Production (agent/tool_executor.py) fires these through _safe_callback, which skips a
|
|
# None callback; the card lane leaves them unset when cards are disabled for the turn.
|
|
start = self.tool_start_callback or (lambda *a, **k: None)
|
|
complete = self.tool_complete_callback or (lambda *a, **k: None)
|
|
start("call-a", "web_search", {"query": "alpha"})
|
|
time.sleep(0.15)
|
|
start("call-b", "web_search", {"query": "beta"})
|
|
time.sleep(0.15)
|
|
# Complete the second same-name call first. Correlation by tool name
|
|
# would incorrectly mark call-a as failed here.
|
|
complete("call-b", "web_search", {"query": "beta"}, '{"error": "boom"}')
|
|
time.sleep(0.15)
|
|
complete("call-a", "web_search", {"query": "alpha"}, '{"success": true}')
|
|
time.sleep(0.15)
|
|
return {"final_response": "done", "messages": [], "api_calls": 1}
|
|
|
|
|
|
class LongPreviewAgent:
|
|
"""Agent that emits a tool call with a very long preview string."""
|
|
LONG_CMD = "cd /home/teknium/.hermes/hermes-agent/.worktrees/hermes-d8860339 && source .venv/bin/activate && python -m pytest tests/gateway/test_run_progress_topics.py -n0 -q"
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.tool_progress_callback("tool.started", "terminal", self.LONG_CMD, {})
|
|
time.sleep(0.35)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class UrlPreviewAgent:
|
|
URL = "https://hermes-agent.nousresearch.com/docs/gateway/discord/tool-progress"
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.tool_progress_callback(
|
|
"tool.started",
|
|
"web_extract",
|
|
self.URL,
|
|
{"urls": [self.URL]},
|
|
)
|
|
time.sleep(0.35)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class DelayedProgressAgent:
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.tool_progress_callback("tool.started", "terminal", "first command", {})
|
|
time.sleep(0.45)
|
|
self.tool_progress_callback("tool.started", "terminal", "second command", {})
|
|
time.sleep(0.1)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
_ACTIVE_ADAPTER: dict = {} # the adapter of the run in flight, for agents that pace on its sends
|
|
|
|
|
|
class ManyProgressLinesAgent:
|
|
"""Emits enough tool-progress lines to exceed a single platform bubble."""
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
cb = self.tool_progress_callback
|
|
assert cb is not None
|
|
cb("tool.started", "terminal", "first-short", {})
|
|
# Let the progress task create the first editable bubble, then enqueue
|
|
# the rest quickly. The cancellation drain must roll them into fresh
|
|
# editable bubbles instead of trying to edit the first one past limit.
|
|
# Wait for the bubble itself, not a fixed interval: on a loaded CI runner
|
|
# 0.35s is not always enough and every line then lands before the first
|
|
# send, so nothing is ever edited.
|
|
adapter = _ACTIVE_ADAPTER.get("adapter")
|
|
deadline = time.monotonic() + 5.0
|
|
while adapter is not None and not adapter.sent and time.monotonic() < deadline:
|
|
time.sleep(0.02)
|
|
time.sleep(0.05)
|
|
for idx in range(1, 8):
|
|
cb("tool.started", "terminal", f"overflow-line-{idx}-" + "x" * 45, {})
|
|
time.sleep(0.1)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class DelayedInterimAgent:
|
|
def __init__(self, **kwargs):
|
|
self.interim_assistant_callback = kwargs.get("interim_assistant_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.interim_assistant_callback("first interim")
|
|
time.sleep(0.45)
|
|
self.interim_assistant_callback("second interim")
|
|
time.sleep(0.1)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
def _make_runner(adapter):
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
GatewayRunner = gateway_run.GatewayRunner
|
|
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.adapters = {adapter.platform: adapter}
|
|
runner._voice_mode = {}
|
|
runner._prefill_messages = []
|
|
runner._ephemeral_system_prompt = ""
|
|
runner._reasoning_config = None
|
|
runner._provider_routing = {}
|
|
runner._fallback_model = None
|
|
runner._session_db = None
|
|
runner._running_agents = {}
|
|
runner._session_run_generation = {}
|
|
runner.session_store = SimpleNamespace(_entries={}, _save=lambda: None)
|
|
runner.hooks = SimpleNamespace(loaded_hooks=False)
|
|
runner.config = SimpleNamespace(
|
|
thread_sessions_per_user=False,
|
|
group_sessions_per_user=False,
|
|
stt_enabled=False,
|
|
)
|
|
return runner
|
|
|
|
|
|
def test_tool_progress_mode_reads_profile_scope_not_process_environ(monkeypatch, tmp_path):
|
|
"""HERMES_TOOL_PROGRESS_MODE must resolve through the active profile's secret scope, not
|
|
process-wide ``os.environ``. Under gateway multiplexing ``os.environ`` carries whichever
|
|
profile's ``.env`` loaded last, so a raw ``os.getenv`` here would leak that profile's setting
|
|
into every other profile's turns (#116898)."""
|
|
from agent import secret_scope
|
|
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
|
|
# Simulates a leaked env var from whichever profile's process env loaded last.
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "off")
|
|
# This profile's OWN scoped value, which must win over the leaked process env.
|
|
token = secret_scope.set_secret_scope({"HERMES_TOOL_PROGRESS_MODE": "all"})
|
|
try:
|
|
adapter = ProgressCaptureAdapter(platform=Platform.SLACK)
|
|
runner = _make_runner(adapter)
|
|
source = SessionSource(platform=Platform.SLACK, chat_id="D1", chat_type="dm", thread_id=None)
|
|
disp = runner._run_agent_display_settings(source)
|
|
finally:
|
|
secret_scope.reset_secret_scope(token)
|
|
|
|
assert disp.progress_mode == "all"
|
|
|
|
|
|
def test_tool_progress_mode_follows_profile_through_the_real_scoping_seam(monkeypatch, tmp_path):
|
|
"""An A -> B -> A profile cycle driven through ``_profile_scope_for_source`` itself (the seam
|
|
``_run_agent``/``_run_agent_inner`` actually enter for every turn), not a manually pre-installed
|
|
secret scope: binds the fix to profile ownership, so a future scoping regression that hands
|
|
profile B's turn profile A's scope cannot stay hidden behind an isolated ``get_secret`` test
|
|
(#116898)."""
|
|
from agent import secret_scope
|
|
|
|
root = tmp_path / "hermes"
|
|
beta = root / "profiles" / "beta"
|
|
beta.mkdir(parents=True)
|
|
# Leaked value from whichever profile's process env loaded last under multiplexing.
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "off")
|
|
(root / ".env").write_text("HERMES_TOOL_PROGRESS_MODE=log\n")
|
|
(beta / ".env").write_text("HERMES_TOOL_PROGRESS_MODE=verbose\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(root))
|
|
monkeypatch.setattr("hermes_constants.get_default_hermes_root", lambda: root)
|
|
|
|
prev_multiplex = secret_scope.is_multiplex_active()
|
|
secret_scope.set_multiplex_active(True)
|
|
try:
|
|
adapter = ProgressCaptureAdapter(platform=Platform.SLACK)
|
|
runner = _make_runner(adapter)
|
|
runner.config.multiplex_profiles = True
|
|
source_a = SessionSource(
|
|
platform=Platform.SLACK, chat_id="D1", chat_type="dm", thread_id=None, profile="default")
|
|
source_b = SessionSource(
|
|
platform=Platform.SLACK, chat_id="D2", chat_type="dm", thread_id=None, profile="beta")
|
|
|
|
with runner._profile_scope_for_source(source_a):
|
|
assert runner._run_agent_display_settings(source_a).progress_mode == "log"
|
|
with runner._profile_scope_for_source(source_b):
|
|
assert runner._run_agent_display_settings(source_b).progress_mode == "verbose"
|
|
with runner._profile_scope_for_source(source_a):
|
|
assert runner._run_agent_display_settings(source_a).progress_mode == "log"
|
|
finally:
|
|
secret_scope.set_multiplex_active(prev_multiplex)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_progress_uses_event_message_id_for_slack_dm(monkeypatch, tmp_path):
|
|
"""Slack DM progress should keep event ts fallback threading."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
# Since PR #8006, Slack's built-in display tier sets tool_progress="off"
|
|
# by default. Override via config so this test still exercises the
|
|
# progress-callback path the Slack DM event_message_id threading depends on.
|
|
import hermes_yaml as yaml
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.safe_dump({"display": {"platforms": {"slack": {"tool_progress": "all"}}}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = FakeAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
adapter = ProgressCaptureAdapter(platform=Platform.SLACK)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.SLACK,
|
|
chat_id="D123",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-3",
|
|
session_key="agent:main:slack:dm:D123",
|
|
event_message_id="1234567890.000001",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.sent
|
|
expected_metadata = {
|
|
"thread_id": "1234567890.000001",
|
|
"message_id": "1234567890.000001",
|
|
}
|
|
assert adapter.sent[0]["metadata"] == expected_metadata
|
|
assert all(call["metadata"] == expected_metadata for call in adapter.typing)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scheduled_heartbeat_suppresses_routine_progress_and_typing(monkeypatch, tmp_path):
|
|
"""A silent scheduled heartbeat must not create a visible progress surface."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = SilentHeartbeatAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
adapter = ProgressCaptureAdapter()
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="123",
|
|
chat_type="dm",
|
|
thread_id="topic-7",
|
|
message_id="stale-user-message",
|
|
)
|
|
result = await runner._run_agent(
|
|
message="scheduled heartbeat",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="heartbeat-session",
|
|
session_key="agent:main:telegram:dm:123:topic-7",
|
|
scheduled_heartbeat=True,
|
|
)
|
|
|
|
assert result["final_response"] == "[SILENT]"
|
|
assert adapter.sent == []
|
|
assert adapter.typing == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_progress_carries_anchor_for_relay_discord_auto_thread(monkeypatch, tmp_path):
|
|
"""Relay Discord channel-initiate: the thread doesn't exist at ingest, so
|
|
the connector auto-threads on the reply anchor and stamps
|
|
prospective_thread_id. The tool-progress / status bubbles must carry that
|
|
anchor (reply_to + metadata.reply_to_message_id) so they route into the
|
|
SAME auto-thread as the final reply — otherwise the search-status updates
|
|
leak into the parent channel (staging repro 2026-08-02)."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
import hermes_yaml as yaml
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.safe_dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = FakeAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
adapter = ProgressCaptureAdapter(platform=Platform.RELAY)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
# Channel-initiating message: no thread_id yet, but the connector stamped
|
|
# the prospective thread id (== the triggering message id). Relay ingress
|
|
# keeps the underlying platform (discord) on the source for display policy,
|
|
# but delivery/progress route through the one live RelayAdapter.
|
|
source = SessionSource(
|
|
platform=Platform.DISCORD,
|
|
chat_id="chan-parent",
|
|
chat_type="group",
|
|
thread_id=None,
|
|
prospective_thread_id="msg-anchor-1",
|
|
delivered_via_upstream_relay=True,
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="find me a gift",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-relay-thread",
|
|
session_key="agent:main:discord:thread:chan-parent:msg-anchor-1",
|
|
event_message_id="msg-anchor-1",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.sent, "expected at least one progress send"
|
|
# Every progress send must carry the anchor so the connector threads it.
|
|
for call in adapter.sent:
|
|
assert call["reply_to"] == "msg-anchor-1", call
|
|
assert (call["metadata"] or {}).get("reply_to_message_id") == "msg-anchor-1", call
|
|
# Discord lifecycle/status sends are marked non-conversational.
|
|
assert (call["metadata"] or {}).get("non_conversational") is True, call
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_progress_no_anchor_for_native_discord_thread_event(monkeypatch, tmp_path):
|
|
"""A message ARRIVING in an existing Discord thread (not the relay
|
|
auto-thread lane) must NOT get the synthetic prospective anchor — it already
|
|
routes by its real thread. Guards against over-broadening the relay fix."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
import hermes_yaml as yaml
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.safe_dump({"display": {"platforms": {"discord": {"tool_progress": "all"}}}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = FakeAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
adapter = ProgressCaptureAdapter(platform=Platform.RELAY)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
# No prospective_thread_id (event is IN a real thread already).
|
|
source = SessionSource(
|
|
platform=Platform.DISCORD,
|
|
chat_id="real-thread-9",
|
|
chat_type="thread",
|
|
thread_id="real-thread-9",
|
|
delivered_via_upstream_relay=True,
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="continue",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-in-thread",
|
|
session_key="agent:main:discord:thread:real-thread-9:real-thread-9",
|
|
event_message_id="msg-2",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
# The relay-prospective synthetic anchor path must NOT engage; progress
|
|
# routes by the real thread's own metadata, not a forced reply_to anchor.
|
|
for call in adapter.sent:
|
|
meta = call["metadata"] or {}
|
|
# The real thread id drives routing; we did not inject the anchor
|
|
# reply_to that the prospective lane uses.
|
|
assert meta.get("thread_id") == "real-thread-9" or call["reply_to"] != "msg-2", call
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preview truncation tests (all/new mode respects tool_preview_length)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _extract_progress_preview(content: str) -> str | None:
|
|
"""Extract the argument-preview portion from a tool-progress message.
|
|
|
|
Handles both render styles:
|
|
- Legacy / custom tools: ``🔧 tool_name: "<preview>"`` (quoted)
|
|
- Friendly built-in verb: ``💻 Running <preview>`` (verb prefix, no quotes)
|
|
"""
|
|
import re
|
|
|
|
# Legacy quoted form takes precedence when present.
|
|
match = re.search(r'"(.+)"', content)
|
|
if match:
|
|
return match.group(1)
|
|
# Friendly form: "<emoji> <verb> <preview>". The terminal verb is "Running".
|
|
marker = " Running "
|
|
idx = content.find(marker)
|
|
if idx != -1:
|
|
return content[idx + len(marker):].strip()
|
|
return None
|
|
|
|
|
|
def _run_long_preview_helper(monkeypatch, tmp_path, preview_length=0):
|
|
"""Shared setup for long-preview truncation tests.
|
|
|
|
Returns (adapter, result) after running the agent with LongPreviewAgent.
|
|
``preview_length`` controls display.tool_preview_length in the config file
|
|
that _run_agent reads — so the gateway picks it up the same way production does.
|
|
"""
|
|
import asyncio
|
|
import hermes_yaml as yaml
|
|
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = LongPreviewAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
# Write config.yaml so _run_agent picks up tool_preview_length
|
|
config = {"display": {"tool_preview_length": preview_length}}
|
|
(tmp_path / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8")
|
|
|
|
adapter = ProgressCaptureAdapter()
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="12345",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
|
|
result = asyncio.get_event_loop().run_until_complete(
|
|
runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-trunc",
|
|
session_key="agent:main:telegram:dm:12345",
|
|
)
|
|
)
|
|
return adapter, result
|
|
|
|
|
|
def test_all_mode_respects_custom_preview_length(monkeypatch, tmp_path):
|
|
"""When tool_preview_length is explicitly set (e.g. 120), all/new mode uses that."""
|
|
adapter, result = _run_long_preview_helper(monkeypatch, tmp_path, preview_length=120)
|
|
assert result["final_response"] == "done"
|
|
assert adapter.sent
|
|
content = adapter.sent[0]["content"]
|
|
# With 120-char cap, the command (165 chars) should still be truncated but longer.
|
|
preview_text = _extract_progress_preview(content)
|
|
assert preview_text is not None, f"No preview found in: {content}"
|
|
# Should be longer than the 40-char default
|
|
assert len(preview_text) > 40, f"Preview suspiciously short ({len(preview_text)}): {preview_text}"
|
|
# But still capped at 120
|
|
assert len(preview_text) <= 120, f"Preview too long ({len(preview_text)}): {preview_text}"
|
|
|
|
|
|
def test_discord_truncated_tool_url_links_to_full_destination(monkeypatch, tmp_path):
|
|
"""The real gateway path must retain the URL beyond its visible cap."""
|
|
import hermes_yaml as yaml
|
|
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = UrlPreviewAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.safe_dump({"display": {"tool_preview_length": 0}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
adapter = DiscordProgressCaptureAdapter()
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(
|
|
gateway_run,
|
|
"_resolve_runtime_agent_kwargs",
|
|
lambda: {"api_key": "***"},
|
|
)
|
|
|
|
source = SessionSource(
|
|
platform=Platform.DISCORD,
|
|
chat_id="12345",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
result = asyncio.get_event_loop().run_until_complete(
|
|
runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-discord-url",
|
|
session_key="agent:main:discord:dm:12345",
|
|
)
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.sent
|
|
visible = UrlPreviewAgent.URL[:37] + "..."
|
|
label = visible.removeprefix("https://")
|
|
assert f"[{label}](<{UrlPreviewAgent.URL}>)" in adapter.sent[0]["content"]
|
|
|
|
|
|
class CommentaryAgent:
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.interim_assistant_callback = kwargs.get("interim_assistant_callback")
|
|
self.stream_delta_callback = kwargs.get("stream_delta_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
if self.interim_assistant_callback:
|
|
self.interim_assistant_callback("I'll inspect the repo first.", already_streamed=False)
|
|
time.sleep(0.1)
|
|
if self.stream_delta_callback:
|
|
self.stream_delta_callback("done")
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class FinalAsInterimAgent:
|
|
"""Model bridge that reports its completed final through the interim callback."""
|
|
|
|
def __init__(self, **kwargs):
|
|
self.interim_assistant_callback = kwargs.get("interim_assistant_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
final = "A completed answer from the model bridge."
|
|
if self.interim_assistant_callback:
|
|
self.interim_assistant_callback(final, already_streamed=False)
|
|
return {
|
|
"final_response": final,
|
|
"response_previewed": True,
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class StreamingRefineAgent:
|
|
def __init__(self, **kwargs):
|
|
self.stream_delta_callback = kwargs.get("stream_delta_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
if self.stream_delta_callback:
|
|
self.stream_delta_callback("Continuing to refine:")
|
|
MatrixStreamingProgressCaptureAdapter.initial_send_seen.wait(timeout=2.0)
|
|
if self.stream_delta_callback:
|
|
self.stream_delta_callback(" Final answer.")
|
|
MatrixStreamingProgressCaptureAdapter.progressive_edit_seen.wait(timeout=2.0)
|
|
return {
|
|
"final_response": "Continuing to refine: Final answer.",
|
|
"response_previewed": True,
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class QueuedCommentaryAgent:
|
|
calls = 0
|
|
|
|
def __init__(self, **kwargs):
|
|
self.interim_assistant_callback = kwargs.get("interim_assistant_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
type(self).calls += 1
|
|
if type(self).calls == 1 and self.interim_assistant_callback:
|
|
self.interim_assistant_callback("I'll inspect the repo first.", already_streamed=False)
|
|
return {
|
|
"final_response": f"final response {type(self).calls}",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class QueuedMediaAgent:
|
|
"""Return an explicit image attachment before a queued follow-up."""
|
|
|
|
calls = 0
|
|
media_path = None
|
|
|
|
def __init__(self, **kwargs):
|
|
self.stream_delta_callback = kwargs.get("stream_delta_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
type(self).calls += 1
|
|
if type(self).calls == 1:
|
|
final_response = f"first response\nMEDIA:{type(self).media_path}"
|
|
if self.stream_delta_callback:
|
|
self.stream_delta_callback("first response")
|
|
else:
|
|
final_response = "follow-up processed"
|
|
if self.stream_delta_callback:
|
|
self.stream_delta_callback(final_response)
|
|
return {
|
|
"final_response": final_response,
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class QueuedSilenceAgent:
|
|
"""First turn is intentionally silent; queued follow-up still runs."""
|
|
|
|
calls = 0
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
type(self).calls += 1
|
|
return {
|
|
"final_response": "NO_REPLY" if type(self).calls == 1 else "follow-up processed",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class QueuedFailedEmptyAgent:
|
|
"""First turn fails empty; its normalized error must send before follow-up."""
|
|
|
|
calls = 0
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
type(self).calls += 1
|
|
if type(self).calls == 1:
|
|
return {
|
|
"final_response": "",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
"failed": True,
|
|
"error": "provider exploded",
|
|
}
|
|
return {
|
|
"final_response": "follow-up processed",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class BackgroundReviewAgent:
|
|
def __init__(self, **kwargs):
|
|
self.background_review_callback = kwargs.get("background_review_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
if self.background_review_callback:
|
|
self.background_review_callback("💾 Skill 'prospect-scanner' created.")
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
class VerboseAgent:
|
|
"""Agent that emits a tool call with args whose JSON exceeds 200 chars."""
|
|
LONG_CODE = "x" * 300
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.tool_progress_callback(
|
|
"tool.started", "execute_code", None,
|
|
{"code": self.LONG_CODE},
|
|
)
|
|
time.sleep(0.35)
|
|
return {
|
|
"final_response": "done",
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
async def _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
agent_cls,
|
|
*,
|
|
session_id,
|
|
pending_text=None,
|
|
config_data=None,
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="-1001",
|
|
chat_type="group",
|
|
thread_id="17585",
|
|
adapter_cls=ProgressCaptureAdapter,
|
|
user_id=None,
|
|
scope_id=None,
|
|
):
|
|
if config_data:
|
|
import hermes_yaml as yaml
|
|
|
|
(tmp_path / "config.yaml").write_text(yaml.safe_dump(config_data), encoding="utf-8")
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = agent_cls
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
adapter = adapter_cls(platform=platform)
|
|
_ACTIVE_ADAPTER["adapter"] = adapter
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
if config_data and "streaming" in config_data:
|
|
runner.config.streaming = StreamingConfig.from_dict(config_data["streaming"])
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
source = SessionSource(
|
|
platform=platform,
|
|
chat_id=chat_id,
|
|
chat_type=chat_type,
|
|
thread_id=thread_id,
|
|
user_id=user_id,
|
|
scope_id=scope_id,
|
|
)
|
|
session_key = f"agent:main:{platform.value}:{chat_type}:{chat_id}"
|
|
if thread_id:
|
|
session_key = f"{session_key}:{thread_id}"
|
|
if pending_text is not None:
|
|
adapter._pending_messages[session_key] = MessageEvent(
|
|
text=pending_text,
|
|
message_type=MessageType.TEXT,
|
|
source=source,
|
|
message_id="queued-1",
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id=session_id,
|
|
session_key=session_key,
|
|
)
|
|
return adapter, result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slack_native_progress_correlates_concurrent_duplicate_tools_by_id(
|
|
monkeypatch, tmp_path
|
|
):
|
|
# No display config: Slack's tier default (tool_progress off) keeps the TEXT lane quiet while
|
|
# the card lane stays on. An operator-written ``off`` is a different thing (see below).
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
DuplicateNativeToolsAgent,
|
|
session_id="sess-native-ids",
|
|
platform=Platform.SLACK,
|
|
chat_id="C1",
|
|
thread_id="thread-1",
|
|
adapter_cls=NativeTaskCardAdapter,
|
|
user_id="U1",
|
|
scope_id="T1",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.native_updates
|
|
second_completed = next(
|
|
update
|
|
for update in adapter.native_updates
|
|
if {task["id"]: task["status"] for task in update["tasks"]}
|
|
== {"call-a": "in_progress", "call-b": "error"}
|
|
)
|
|
assert second_completed["metadata"]["recipient_team_id"] == "T1"
|
|
assert second_completed["metadata"]["recipient_user_id"] == "U1"
|
|
assert adapter.native_updates[-1]["tasks"] == [
|
|
{
|
|
"id": "call-a",
|
|
"title": "web_search - alpha",
|
|
"status": "complete",
|
|
},
|
|
{
|
|
"id": "call-b",
|
|
"title": "web_search - beta",
|
|
"status": "error",
|
|
},
|
|
]
|
|
assert adapter.sent == []
|
|
assert adapter.native_stops == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slack_native_failure_keeps_editing_one_live_text_fallback(
|
|
monkeypatch, tmp_path
|
|
):
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
DuplicateNativeToolsAgent,
|
|
session_id="sess-native-fallback",
|
|
platform=Platform.SLACK,
|
|
chat_id="C1",
|
|
thread_id="thread-1",
|
|
adapter_cls=FailingNativeTaskCardAdapter,
|
|
user_id="U1",
|
|
scope_id="T1",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert len(adapter.native_updates) == 1
|
|
assert len(adapter.sent) == 1
|
|
assert adapter.sent[0]["content"].endswith("web_search - alpha - running")
|
|
assert len(adapter.edits) >= 2
|
|
assert {edit["message_id"] for edit in adapter.edits} == {"progress-1"}
|
|
assert adapter.edits[-1]["content"].endswith("web_search - beta - error")
|
|
assert "web_search - alpha - complete" in adapter.edits[-1]["content"]
|
|
assert adapter.native_stops == 1
|
|
|
|
|
|
class UnsupportedDestinationTaskCardAdapter(NativeTaskCardAdapter):
|
|
"""Relay connector shape for a flat Slack DM: cards need a thread anchor, so the
|
|
connector rejects every card frame with a deterministic unsupported-destination error."""
|
|
|
|
async def send_native_task_card_progress(self, *args, **kwargs) -> SendResult:
|
|
await super().send_native_task_card_progress(*args, **kwargs)
|
|
return SendResult(success=False, error="slack task_card requires a thread anchor")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"display_cfg",
|
|
[
|
|
{"platforms": {"slack": {"tool_progress": "off"}}},
|
|
{"tool_progress": "off"},
|
|
{"tool_progress_overrides": {"slack": "off"}},
|
|
],
|
|
ids=["platform-override", "global", "legacy-overrides"],
|
|
)
|
|
async def test_slack_operator_tool_progress_off_disables_task_cards(monkeypatch, tmp_path, display_cfg):
|
|
# Task cards ARE tool progress rendered natively. When the operator writes ``off`` (not the
|
|
# tier default), neither the card lane nor its text fallback may publish anything.
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
DuplicateNativeToolsAgent,
|
|
session_id="sess-native-operator-off",
|
|
config_data={"display": display_cfg},
|
|
platform=Platform.SLACK,
|
|
chat_id="D1",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
adapter_cls=NativeTaskCardAdapter,
|
|
user_id="U1",
|
|
scope_id="T1",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.native_updates == []
|
|
assert adapter.native_stops == 0
|
|
assert adapter.sent == []
|
|
assert adapter.edits == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"display_cfg",
|
|
[
|
|
{"platforms": {"slack": {"tool_progress": None}}},
|
|
{"tool_progress": None},
|
|
{"tool_progress_overrides": {"slack": None}},
|
|
# null at the platform level over a global "all": the platform inherits "all", cards stay.
|
|
{"tool_progress": "all", "platforms": {"slack": {"tool_progress": None}}},
|
|
],
|
|
ids=["platform-null", "global-null", "legacy-null", "platform-null-over-global-all"],
|
|
)
|
|
@pytest.mark.parametrize("env_mode", [None, "new"])
|
|
async def test_slack_null_tool_progress_is_inheritance_not_explicit_off(monkeypatch, tmp_path, display_cfg, env_mode):
|
|
# A bare key with ``null`` inherits (the resolver skips None); it is not an operator saying "off".
|
|
monkeypatch.delenv("HERMES_TOOL_PROGRESS_MODE", raising=False)
|
|
if env_mode is not None:
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", env_mode)
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
DuplicateNativeToolsAgent,
|
|
session_id="sess-native-null",
|
|
config_data={"display": display_cfg},
|
|
platform=Platform.SLACK,
|
|
chat_id="C1",
|
|
thread_id="thread-1",
|
|
adapter_cls=NativeTaskCardAdapter,
|
|
user_id="U1",
|
|
scope_id="T1",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.native_updates
|
|
assert adapter.native_stops == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slack_operator_tool_progress_new_keeps_task_cards(monkeypatch, tmp_path):
|
|
# An explicit non-off mode keeps the native card lane engaged (text lane stays swallowed by it).
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
DuplicateNativeToolsAgent,
|
|
session_id="sess-native-operator-new",
|
|
config_data={"display": {"platforms": {"slack": {"tool_progress": "new"}}}},
|
|
platform=Platform.SLACK,
|
|
chat_id="C1",
|
|
thread_id="thread-1",
|
|
adapter_cls=NativeTaskCardAdapter,
|
|
user_id="U1",
|
|
scope_id="T1",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.native_updates
|
|
assert adapter.sent == []
|
|
assert adapter.native_stops == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slack_unsupported_card_destination_does_not_degrade_to_text_progress(
|
|
monkeypatch, tmp_path
|
|
):
|
|
# Flat Slack DM, no operator config (tier default: tool_progress off). The connector cannot
|
|
# render a card without a thread anchor; that is a property of the destination, not a
|
|
# transient native failure, so the lane must NOT fall back to text tool progress the operator
|
|
# never asked for. Transient failures keep the fallback (see the test above).
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
DuplicateNativeToolsAgent,
|
|
session_id="sess-native-unsupported-dest",
|
|
platform=Platform.SLACK,
|
|
chat_id="D1",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
adapter_cls=UnsupportedDestinationTaskCardAdapter,
|
|
user_id="U1",
|
|
scope_id="T1",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert len(adapter.native_updates) == 1
|
|
assert adapter.sent == []
|
|
assert adapter.edits == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slack_explicit_all_in_unsupported_card_destination_falls_back_to_text(
|
|
monkeypatch, tmp_path
|
|
):
|
|
# Same flat DM, but the operator WROTE ``tool_progress: all``: they asked for text progress,
|
|
# so an un-cardable chat carries it through the editable fallback instead of going silent.
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
DuplicateNativeToolsAgent,
|
|
session_id="sess-native-unsupported-dest-all",
|
|
config_data={"display": {"platforms": {"slack": {"tool_progress": "all"}}}},
|
|
platform=Platform.SLACK,
|
|
chat_id="D1",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
adapter_cls=UnsupportedDestinationTaskCardAdapter,
|
|
user_id="U1",
|
|
scope_id="T1",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert len(adapter.native_updates) == 1
|
|
assert len(adapter.sent) == 1
|
|
assert adapter.edits
|
|
assert "web_search" in adapter.edits[-1]["content"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retryable_overflow_edit_keeps_editable_bubble_identity(monkeypatch, tmp_path):
|
|
"""A transient split edit must retain can_edit and the current message ID."""
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
ManyProgressLinesAgent,
|
|
session_id="sess-progress-retry-overflow-same-message",
|
|
config_data={
|
|
"display": {
|
|
"tool_progress": "all",
|
|
"interim_assistant_messages": False,
|
|
}
|
|
},
|
|
platform=Platform.SLACK,
|
|
chat_id="C123",
|
|
chat_type="direct",
|
|
thread_id="1700000000.000100",
|
|
adapter_cls=RetryableOverflowEditProgressAdapter,
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert isinstance(adapter, RetryableOverflowEditProgressAdapter)
|
|
assert adapter.retryable_edit_failures == 1
|
|
assert len(adapter.sent) >= 2
|
|
assert adapter.edits[0]["message_id"] == "progress-1"
|
|
assert any(call["message_id"] == "progress-1" for call in adapter.edits[1:])
|
|
assert adapter.oversized_sends == []
|
|
assert adapter.oversized_edits == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_display_streaming_does_not_enable_gateway_streaming(monkeypatch, tmp_path):
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
CommentaryAgent,
|
|
session_id="sess-display-streaming-cli-only",
|
|
config_data={
|
|
"display": {
|
|
"streaming": True,
|
|
"interim_assistant_messages": True,
|
|
},
|
|
"streaming": {"enabled": False},
|
|
},
|
|
)
|
|
|
|
assert result.get("already_sent") is not True
|
|
assert adapter.edits == []
|
|
assert [call["content"] for call in adapter.sent] == ["I'll inspect the repo first."]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_editable_interim_final_is_recorded_for_final_send_dedup(monkeypatch, tmp_path):
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
FinalAsInterimAgent,
|
|
session_id="sess-non-editable-interim-final",
|
|
config_data={
|
|
"display": {"interim_assistant_messages": True},
|
|
"streaming": {"enabled": False},
|
|
},
|
|
adapter_cls=NonEditingProgressCaptureAdapter,
|
|
)
|
|
|
|
assert result["already_sent"] is True
|
|
assert [call["content"] for call in adapter.sent] == [result["final_response"]]
|
|
assert adapter.edits == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_matrix_streaming_omits_cursor(monkeypatch, tmp_path):
|
|
MatrixStreamingProgressCaptureAdapter.initial_send_seen.clear()
|
|
MatrixStreamingProgressCaptureAdapter.progressive_edit_seen.clear()
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
StreamingRefineAgent,
|
|
session_id="sess-matrix-streaming",
|
|
config_data={
|
|
"display": {"tool_progress": "off", "interim_assistant_messages": False},
|
|
"streaming": {"enabled": True, "edit_interval": 0.01, "buffer_threshold": 1},
|
|
},
|
|
platform=Platform.MATRIX,
|
|
chat_id="!room:matrix.example.org",
|
|
chat_type="group",
|
|
thread_id="$thread",
|
|
adapter_cls=MatrixStreamingProgressCaptureAdapter,
|
|
)
|
|
|
|
assert result.get("already_sent") is True
|
|
all_text = [call["content"] for call in adapter.sent] + [call["content"] for call in adapter.edits]
|
|
assert all_text, "expected streamed Matrix content to be sent or edited"
|
|
assert any(not call["finalize"] for call in adapter.edits), (
|
|
"Matrix should progressively edit the active message before finalization"
|
|
)
|
|
assert all("▉" not in text for text in all_text)
|
|
assert any("Continuing to refine:" in text for text in all_text)
|
|
|
|
|
|
class TransformedStreamAgent:
|
|
"""Streams a response, then signals the gateway that a plugin hook
|
|
(``transform_llm_output``) modified the final text after streaming
|
|
finished. ``run_conversation`` returns ``response_transformed=True``
|
|
plus a ``final_response`` that diverges from what was streamed.
|
|
"""
|
|
|
|
def __init__(self, **kwargs):
|
|
self.stream_delta_callback = kwargs.get("stream_delta_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
if self.stream_delta_callback:
|
|
self.stream_delta_callback("original answer")
|
|
return {
|
|
"final_response": "original answer\n\n[plugin appended this]",
|
|
"response_previewed": True,
|
|
"response_transformed": True,
|
|
"messages": [],
|
|
"api_calls": 1,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transformed_response_edits_streamed_message_in_place(monkeypatch, tmp_path):
|
|
"""When a transform_llm_output hook modifies the response after streaming,
|
|
the gateway must edit the existing streamed message in place with the full
|
|
transformed content (so plugins like content filters / appenders reach the
|
|
user) and still mark already_sent=True (no duplicate send).
|
|
"""
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
TransformedStreamAgent,
|
|
session_id="sess-transformed-stream",
|
|
config_data={
|
|
"display": {"tool_progress": "off", "interim_assistant_messages": False},
|
|
"streaming": {"enabled": True, "edit_interval": 0.01, "buffer_threshold": 1},
|
|
},
|
|
platform=Platform.MATRIX,
|
|
chat_id="!room:matrix.example.org",
|
|
chat_type="group",
|
|
thread_id="$thread",
|
|
adapter_cls=MetadataEditProgressCaptureAdapter,
|
|
)
|
|
|
|
# Final delivery happened (no duplicate send fallback).
|
|
assert result.get("already_sent") is True
|
|
# The transformed final text reached the user — appended portion is present
|
|
# in an edit_message call (not just in the streamed sends).
|
|
edited_texts = [e["content"] for e in adapter.edits]
|
|
assert any("[plugin appended this]" in text for text in edited_texts), (
|
|
f"expected transformed text in adapter.edits, got: {edited_texts!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transformed_final_is_not_marked_sent_when_edit_fails(monkeypatch, tmp_path):
|
|
"""#119323: when the in-place edit carrying a transformed final fails on a chat platform,
|
|
the response must not be marked already_sent, or the transformed text is never delivered."""
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
TransformedStreamAgent,
|
|
session_id="sess-transformed-edit-fails",
|
|
config_data={
|
|
"display": {"tool_progress": "off", "interim_assistant_messages": False},
|
|
"streaming": {"enabled": True, "edit_interval": 0.01, "buffer_threshold": 1},
|
|
},
|
|
platform=Platform.TELEGRAM,
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
adapter_cls=UnsupportedEditProgressCaptureAdapter,
|
|
)
|
|
|
|
assert result["final_response"].endswith("[plugin appended this]")
|
|
assert result.get("already_sent") is not True
|
|
assert len(adapter.edits) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_queued_message_does_not_treat_commentary_as_final(monkeypatch, tmp_path):
|
|
QueuedCommentaryAgent.calls = 0
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
QueuedCommentaryAgent,
|
|
session_id="sess-queued-commentary",
|
|
pending_text="queued follow-up",
|
|
config_data={"display": {"interim_assistant_messages": True}},
|
|
)
|
|
|
|
sent_texts = [call["content"] for call in adapter.sent]
|
|
assert result["final_response"] == "final response 2"
|
|
assert "I'll inspect the repo first." in sent_texts
|
|
assert "final response 1" in sent_texts
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_queued_message_delivers_first_response_media(monkeypatch, tmp_path):
|
|
"""Queued follow-ups must preserve explicit attachments from the first turn."""
|
|
media_path = tmp_path / "queued-first-response.png"
|
|
media_path.write_bytes(b"not-a-real-png-but-a-real-file")
|
|
QueuedMediaAgent.calls = 0
|
|
QueuedMediaAgent.media_path = media_path
|
|
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
QueuedMediaAgent,
|
|
session_id="sess-queued-media",
|
|
pending_text="queued follow-up",
|
|
platform=Platform.DISCORD,
|
|
chat_id="discord-thread",
|
|
chat_type="group",
|
|
thread_id="discord-thread",
|
|
adapter_cls=MediaCaptureProgressAdapter,
|
|
)
|
|
|
|
assert result["final_response"] == "follow-up processed"
|
|
assert isinstance(adapter, MediaCaptureProgressAdapter)
|
|
assert {
|
|
"sent_texts": [call["content"] for call in adapter.sent],
|
|
"image_batches": adapter.image_batches,
|
|
} == {
|
|
"sent_texts": ["first response"],
|
|
"image_batches": [
|
|
{
|
|
"chat_id": "discord-thread",
|
|
"images": [(f"file://{quote(str(media_path))}", "")],
|
|
"metadata": {"thread_id": "discord-thread"},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_queued_message_delivers_streamed_first_response_media(
|
|
monkeypatch, tmp_path,
|
|
):
|
|
"""Streaming first-turn text must not suppress its explicit attachment."""
|
|
media_path = tmp_path / "queued-streamed-first-response.png"
|
|
media_path.write_bytes(b"not-a-real-png-but-a-real-file")
|
|
QueuedMediaAgent.calls = 0
|
|
QueuedMediaAgent.media_path = media_path
|
|
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
QueuedMediaAgent,
|
|
session_id="sess-queued-streamed-media",
|
|
pending_text="queued follow-up",
|
|
config_data={
|
|
"display": {"tool_progress": "off", "interim_assistant_messages": False},
|
|
"streaming": {"enabled": True, "edit_interval": 0.01, "buffer_threshold": 1},
|
|
},
|
|
platform=Platform.DISCORD,
|
|
chat_id="discord-thread",
|
|
chat_type="group",
|
|
thread_id="discord-thread",
|
|
adapter_cls=MediaCaptureProgressAdapter,
|
|
)
|
|
|
|
assert result["final_response"] == "follow-up processed"
|
|
assert isinstance(adapter, MediaCaptureProgressAdapter)
|
|
all_text = [call["content"] for call in adapter.sent + adapter.edits]
|
|
assert all("MEDIA:" not in text for text in all_text)
|
|
assert adapter.image_batches == [
|
|
{
|
|
"chat_id": "discord-thread",
|
|
"images": [(f"file://{quote(str(media_path))}", "")],
|
|
"metadata": {"thread_id": "discord-thread"},
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_suppresses_silent_first_turn_and_processes_queued_followup(
|
|
monkeypatch, tmp_path,
|
|
):
|
|
"""Regression: queued direct-send must not leak NO_REPLY to the channel."""
|
|
QueuedSilenceAgent.calls = 0
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
QueuedSilenceAgent,
|
|
session_id="sess-queued-silence",
|
|
pending_text="queued follow-up",
|
|
platform=Platform.SLACK,
|
|
chat_id="C123",
|
|
thread_id="1712345678.000100",
|
|
)
|
|
|
|
sent_texts = [call["content"] for call in adapter.sent]
|
|
assert QueuedSilenceAgent.calls == 2
|
|
assert result["final_response"] == "follow-up processed"
|
|
assert "NO_REPLY" not in sent_texts
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_sends_normalized_failure_before_queued_followup(
|
|
monkeypatch, tmp_path,
|
|
):
|
|
"""Queued delivery uses finalized output, not the raw empty agent result."""
|
|
QueuedFailedEmptyAgent.calls = 0
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
QueuedFailedEmptyAgent,
|
|
session_id="sess-queued-failed-empty",
|
|
pending_text="queued follow-up",
|
|
platform=Platform.SLACK,
|
|
chat_id="C123",
|
|
thread_id="1712345678.000100",
|
|
)
|
|
|
|
sent_texts = [call["content"] for call in adapter.sent]
|
|
assert QueuedFailedEmptyAgent.calls == 2
|
|
assert result["final_response"] == "follow-up processed"
|
|
# Sanitized failure copy (raw "provider exploded" stays in the log), then the queued follow-up.
|
|
assert any("couldn't finish this reply" in text and "/retry" in text for text in sent_texts)
|
|
assert not any("provider exploded" in text for text in sent_texts)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_defers_background_review_notification_until_release(monkeypatch, tmp_path):
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
BackgroundReviewAgent,
|
|
session_id="sess-bg-review-order",
|
|
config_data={"display": {"interim_assistant_messages": True}},
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
assert adapter.sent == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_processing_releases_post_delivery_callback_after_main_send():
|
|
"""Post-delivery callbacks on the adapter fire after the main response."""
|
|
adapter = ProgressCaptureAdapter()
|
|
|
|
async def _handler(event):
|
|
return "done"
|
|
|
|
adapter.set_message_handler(_handler)
|
|
|
|
released = []
|
|
|
|
def _post_delivery_cb():
|
|
released.append(True)
|
|
adapter.sent.append(
|
|
{
|
|
"chat_id": "bg-review",
|
|
"content": "💾 Skill 'prospect-scanner' created.",
|
|
"reply_to": None,
|
|
"metadata": None,
|
|
}
|
|
)
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="-1001",
|
|
chat_type="group",
|
|
thread_id="17585",
|
|
)
|
|
event = MessageEvent(
|
|
text="hello",
|
|
message_type=MessageType.TEXT,
|
|
source=source,
|
|
message_id="msg-1",
|
|
)
|
|
session_key = "agent:main:telegram:group:-1001:17585"
|
|
adapter._active_sessions[session_key] = asyncio.Event()
|
|
adapter._post_delivery_callbacks[session_key] = _post_delivery_cb
|
|
|
|
await adapter._process_message_background(event, session_key)
|
|
|
|
sent_texts = [call["content"] for call in adapter.sent]
|
|
assert sent_texts == ["done", "💾 Skill 'prospect-scanner' created."]
|
|
assert released == [True]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_processing_stops_typing_before_hung_post_delivery_callback(
|
|
monkeypatch,
|
|
):
|
|
"""A stuck post-delivery callback must not keep the typing task alive."""
|
|
monkeypatch.setattr(base_platform, "_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS", 0.01)
|
|
adapter = ProgressCaptureAdapter()
|
|
events = []
|
|
|
|
async def _handler(event):
|
|
return "done"
|
|
|
|
async def _post_delivery_cb():
|
|
events.append("callback-start")
|
|
await asyncio.Event().wait()
|
|
|
|
async def _stop_typing(chat_id):
|
|
events.append("typing-stopped")
|
|
await ProgressCaptureAdapter.stop_typing(adapter, chat_id)
|
|
|
|
adapter.set_message_handler(_handler)
|
|
adapter.stop_typing = _stop_typing
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="-1001",
|
|
chat_type="group",
|
|
thread_id="17585",
|
|
)
|
|
event = MessageEvent(
|
|
text="hello",
|
|
message_type=MessageType.TEXT,
|
|
source=source,
|
|
message_id="msg-1",
|
|
)
|
|
session_key = "agent:main:telegram:group:-1001:17585"
|
|
adapter._active_sessions[session_key] = asyncio.Event()
|
|
adapter._post_delivery_callbacks[session_key] = _post_delivery_cb
|
|
|
|
await asyncio.wait_for(
|
|
adapter._process_message_background(event, session_key), timeout=1.0
|
|
)
|
|
|
|
assert [call["content"] for call in adapter.sent] == ["done"]
|
|
# Invariant: typing must stop before the (hung) post-delivery callback
|
|
# starts. Don't pin the exact stop_typing call count — the shared
|
|
# cleanup path may make more than one bounded stop attempt.
|
|
assert "typing-stopped" in events
|
|
assert "callback-start" in events
|
|
assert events.index("typing-stopped") < events.index("callback-start")
|
|
assert events[: events.index("callback-start")] == (
|
|
["typing-stopped"] * events.index("callback-start")
|
|
)
|
|
assert any(call["metadata"] == {"stopped": True} for call in adapter.typing)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_drops_tool_progress_after_generation_invalidation(monkeypatch, tmp_path):
|
|
import hermes_yaml as yaml
|
|
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.safe_dump({"display": {"tool_progress": "all"}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = DelayedProgressAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
import tools.terminal_tool # noqa: F401 - register terminal tool metadata
|
|
|
|
adapter = ProgressCaptureAdapter(platform=Platform.DISCORD)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.DISCORD,
|
|
chat_id="dm-1",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
session_key = "agent:main:discord:dm:dm-1"
|
|
runner._session_run_generation[session_key] = 1
|
|
|
|
original_send = adapter.send
|
|
invalidated = {"done": False}
|
|
|
|
async def send_and_invalidate(chat_id, content, reply_to=None, metadata=None):
|
|
result = await original_send(chat_id, content, reply_to=reply_to, metadata=metadata)
|
|
if "first command" in content and not invalidated["done"]:
|
|
invalidated["done"] = True
|
|
runner._invalidate_session_run_generation(session_key, reason="test_stop")
|
|
return result
|
|
|
|
adapter.send = send_and_invalidate
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-progress-stop",
|
|
session_key=session_key,
|
|
run_generation=1,
|
|
)
|
|
|
|
all_progress_text = " ".join(call["content"] for call in adapter.sent)
|
|
all_progress_text += " ".join(call["content"] for call in adapter.edits)
|
|
assert result["final_response"] == "done"
|
|
assert 'first command' in all_progress_text
|
|
assert 'second command' not in all_progress_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_agent_drops_interim_commentary_after_generation_invalidation(monkeypatch, tmp_path):
|
|
import hermes_yaml as yaml
|
|
|
|
(tmp_path / "config.yaml").write_text(
|
|
yaml.safe_dump({"display": {"tool_progress": "off", "interim_assistant_messages": True}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = DelayedInterimAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
adapter = ProgressCaptureAdapter(platform=Platform.DISCORD)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.DISCORD,
|
|
chat_id="dm-2",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
session_key = "agent:main:discord:dm:dm-2"
|
|
runner._session_run_generation[session_key] = 1
|
|
|
|
original_send = adapter.send
|
|
invalidated = {"done": False}
|
|
|
|
async def send_and_invalidate(chat_id, content, reply_to=None, metadata=None):
|
|
result = await original_send(chat_id, content, reply_to=reply_to, metadata=metadata)
|
|
if content == "first interim" and not invalidated["done"]:
|
|
invalidated["done"] = True
|
|
runner._invalidate_session_run_generation(session_key, reason="test_stop")
|
|
return result
|
|
|
|
adapter.send = send_and_invalidate
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-commentary-stop",
|
|
session_key=session_key,
|
|
run_generation=1,
|
|
)
|
|
|
|
sent_texts = [call["content"] for call in adapter.sent]
|
|
assert result["final_response"] == "done"
|
|
assert "first interim" in sent_texts
|
|
assert "second interim" not in sent_texts
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_keep_typing_stops_immediately_when_interrupt_event_is_set():
|
|
adapter = ProgressCaptureAdapter(platform=Platform.DISCORD)
|
|
stop_event = asyncio.Event()
|
|
|
|
task = asyncio.create_task(
|
|
adapter._keep_typing(
|
|
"dm-typing-stop",
|
|
interval=30.0,
|
|
stop_event=stop_event,
|
|
)
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
stop_event.set()
|
|
await asyncio.wait_for(task, timeout=0.5)
|
|
|
|
normal_typing_calls = [
|
|
call for call in adapter.typing if call.get("metadata") != {"stopped": True}
|
|
]
|
|
stopped_calls = [
|
|
call for call in adapter.typing if call.get("metadata") == {"stopped": True}
|
|
]
|
|
assert len(normal_typing_calls) == 1
|
|
assert len(stopped_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verbose_mode_does_not_truncate_args_by_default(monkeypatch, tmp_path):
|
|
"""Verbose mode with default tool_preview_length (0) should NOT truncate args.
|
|
|
|
Previously, verbose mode capped args at 200 chars when tool_preview_length
|
|
was 0 (default). The user explicitly opted into verbose — show full detail.
|
|
"""
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
VerboseAgent,
|
|
session_id="sess-verbose-no-truncate",
|
|
config_data={"display": {"tool_progress": "verbose", "tool_preview_length": 0}},
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
# The full 300-char 'x' string should be present, not truncated to 200
|
|
all_content = " ".join(call["content"] for call in adapter.sent)
|
|
all_content += " ".join(call["content"] for call in adapter.edits)
|
|
assert VerboseAgent.LONG_CODE in all_content
|
|
|
|
|
|
class CodeBlockProgressAdapter(ProgressCaptureAdapter):
|
|
"""A markdown-capable progress adapter (declares supports_code_blocks)."""
|
|
|
|
supports_code_blocks = True
|
|
|
|
|
|
class TerminalCommandAgent:
|
|
"""Emits a terminal tool.started with a real, multi-line command arg."""
|
|
|
|
CMD = (
|
|
"set -euo pipefail\n"
|
|
"printf 'node: '; node --version\n"
|
|
"npm install -g hyperframes@latest"
|
|
)
|
|
|
|
def __init__(self, **kwargs):
|
|
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
|
self.tools = []
|
|
|
|
def run_conversation(self, message, conversation_history=None, task_id=None, **kwargs):
|
|
self.tool_progress_callback(
|
|
"tool.started", "terminal", self.CMD, {"command": self.CMD}
|
|
)
|
|
# Let the async progress task drain the queue and send before returning.
|
|
time.sleep(0.35)
|
|
return {"final_response": "done", "messages": [], "api_calls": 1}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_progress_renders_fenced_code_block(monkeypatch, tmp_path):
|
|
"""Terminal progress on a markdown-capable (supports_code_blocks) gateway
|
|
renders a bare fenced code block — no language tag (Slack mrkdwn would print
|
|
'bash' as a literal first code line). In non-verbose ("all"/"new") mode the
|
|
command is collapsed to a single line capped at tool_preview_length so a long
|
|
or multi-line command doesn't render as a huge block (#42634)."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = TerminalCommandAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
|
|
|
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="12345",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-terminal-code-block",
|
|
session_key="agent:main:telegram:dm:12345",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
all_content = " ".join(call["content"] for call in adapter.sent)
|
|
all_content += " ".join(call["content"] for call in adapter.edits)
|
|
# Bare fenced block, no language tag (no '```bash').
|
|
assert "```" in all_content
|
|
assert "```bash" not in all_content
|
|
# Non-verbose collapses to the first line + truncation marker — the later
|
|
# command lines must NOT appear (this was the "huge block" regression).
|
|
assert "set -euo pipefail" in all_content
|
|
assert "npm install -g hyperframes@latest" not in all_content
|
|
assert "node --version" not in all_content
|
|
# No truncated quoted preview for the terminal command.
|
|
assert 'terminal: "' not in all_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_progress_verbose_shows_full_command(monkeypatch, tmp_path):
|
|
"""Verbose mode on a markdown-capable gateway renders the FULL multi-line
|
|
command in a bare fenced block (no truncation, no 'bash' tag). This is the
|
|
parity guarantee for #42634: verbose keeps full detail, non-verbose caps."""
|
|
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "verbose")
|
|
|
|
fake_dotenv = types.ModuleType("dotenv")
|
|
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
|
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = TerminalCommandAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
|
|
|
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
|
runner = _make_runner(adapter)
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
|
|
|
source = SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="12345",
|
|
chat_type="dm",
|
|
thread_id=None,
|
|
)
|
|
|
|
result = await runner._run_agent(
|
|
message="hello",
|
|
context_prompt="",
|
|
history=[],
|
|
source=source,
|
|
session_id="sess-terminal-code-block-verbose",
|
|
session_key="agent:main:telegram:dm:12345",
|
|
)
|
|
|
|
assert result["final_response"] == "done"
|
|
all_content = " ".join(call["content"] for call in adapter.sent)
|
|
all_content += " ".join(call["content"] for call in adapter.edits)
|
|
assert "```" in all_content
|
|
assert "```bash" not in all_content
|
|
# Full command body present — verbose is uncapped.
|
|
assert "npm install -g hyperframes@latest" in all_content
|
|
assert "node --version" in all_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("path", ["turn_runner", "proxy"])
|
|
async def test_per_platform_streaming_does_not_override_global_disabled(monkeypatch, tmp_path, path):
|
|
"""Regression for #53697: display.platforms.telegram.streaming=True must not
|
|
re-enable gateway streaming when streaming.enabled=False (global master switch).
|
|
|
|
Covers both gate sites: TurnRunner.want_stream_deltas (normal agent path) and
|
|
GatewayTurnMixin._proxy_stream_consumer (proxy / native-streaming path)."""
|
|
config_data = {
|
|
"display": {
|
|
"platforms": {
|
|
"telegram": {"streaming": True},
|
|
},
|
|
"interim_assistant_messages": False,
|
|
},
|
|
"streaming": {"enabled": False},
|
|
}
|
|
if path == "proxy":
|
|
adapter = ProgressCaptureAdapter(platform=Platform.TELEGRAM)
|
|
runner = _make_runner(adapter)
|
|
runner.config.streaming = StreamingConfig.from_dict(config_data["streaming"])
|
|
gateway_run = importlib.import_module("gateway.run")
|
|
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: config_data)
|
|
source = SessionSource(platform=Platform.TELEGRAM, chat_id="-1001", chat_type="dm")
|
|
assert runner._proxy_stream_consumer(source, None, None, lambda: True) is None
|
|
return
|
|
|
|
adapter, result = await _run_with_agent(
|
|
monkeypatch,
|
|
tmp_path,
|
|
CommentaryAgent,
|
|
session_id="sess-per-platform-streaming-global-off",
|
|
config_data=config_data,
|
|
platform=Platform.TELEGRAM,
|
|
)
|
|
|
|
assert result.get("already_sent") is not True
|
|
assert adapter.edits == []
|
|
|
|
|
|
class TestSlackReplyInThreadProgressRouting:
|
|
"""#18859: reply_in_thread=false must stop progress from creating threads."""
|
|
|
|
def test_slack_reply_in_thread_false_drops_synthetic_thread(self):
|
|
from gateway.run import _resolve_progress_thread_id
|
|
|
|
# source.thread_id == event ts is the adapter's synthetic
|
|
# session-keying thread for top-level messages — not a real thread.
|
|
assert _resolve_progress_thread_id(
|
|
Platform.SLACK,
|
|
source_thread_id="1700000000.000100",
|
|
event_message_id="1700000000.000100",
|
|
reply_in_thread=False,
|
|
) is None
|
|
|
|
def test_buzz_uses_event_message_id_as_progress_thread(self):
|
|
"""Buzz has no native thread_id; progress must reply-to the trigger."""
|
|
from gateway.run import _resolve_progress_thread_id
|
|
|
|
assert _resolve_progress_thread_id(
|
|
"buzz",
|
|
source_thread_id=None,
|
|
event_message_id="evt-trigger-001",
|
|
reply_in_thread=True,
|
|
) == "evt-trigger-001"
|