Files
hermes-agent/tests/gateway/test_auto_skill_title_input.py
Ritvik Gautam 6776a429b5 fix: use original gateway text for automatic session titles
Gateway turns reach the agent enriched with channel-bound skill payloads,
sender metadata and other model context, so the first user message the
titler saw could be thousands of characters of skill text ahead of the
actual question, and new sessions were named after the skill instead of
the request.

The gateway now captures the inbound event text before that enrichment and
passes it as a turn-local title_user_message through TurnContext, the turn
runner, AIAgent.run_conversation and the conversation prologue. Only the
titler reads it: the main model, persisted history and relay metadata still
receive the full enriched message. An explicit empty or whitespace override
suppresses titling for that turn, None keeps the existing model-facing
fallback (attachment-only turns, paste previews), and the override cannot
enable model titling for cron or subagent runs. The value is a per-call
argument, so a cached agent never reuses a previous turn's override.

Rebased onto current main: the gateway call keeps main's pinned channel
prompt for internal events, and the integration fixture's context-prompt
stub accepts main's internal= keyword.

Ritvik Gautam made this change with Doryani AI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:57:52 -05:00

142 lines
7.2 KiB
Python

"""Title input is user intent, not the gateway's model-facing skill payload."""
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from agent import title_generator
from gateway.config import GatewayConfig, Platform
from gateway.platforms.event import MessageEvent
from gateway.run import GatewayRunner
from gateway.session import SessionSource, SessionEntry
from gateway.turn_context import TurnContext
from run_agent import AIAgent
class _TurnObserved(BaseException):
"""Stop after the real prologue, before any main-model network request."""
@pytest.mark.asyncio
@pytest.mark.parametrize("skills", [[], "alpha", ["alpha"], ["alpha", "beta"]])
async def test_gateway_titles_original_request_without_changing_model_input(tmp_path, monkeypatch, skills):
question = "Why does my connection pool exhaust after a deployment?"
names = [skills] if isinstance(skills, str) else skills
payloads = {name: f"# {name}\n" + (f"Follow {name} procedures.\n" * 100) for name in names}
monkeypatch.setattr("agent.skill_commands._load_skill_payload", lambda name, **kw: (
{"name": name, "content": payloads[name]}, tmp_path / name, name,
))
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {})
monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {})
source = SessionSource(platform=Platform.DISCORD, chat_id="channel", user_id="user", user_name="Example")
event = MessageEvent(text=question, source=source, auto_skill=skills,
channel_context="[Discord channel context: synthetic metadata]")
entry = SessionEntry(session_id="title-test", session_key="discord:title-test",
created_at=datetime(2026, 1, 1), updated_at=datetime(2026, 1, 1))
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig()
runner.adapters = {}
runner._get_proxy_url = lambda: None
runner.hooks = SimpleNamespace(emit=AsyncMock())
runner._hmwa_resolve_session = AsyncMock(return_value=(source, entry, entry.session_key))
runner._hmwa_open_session = AsyncMock(return_value=(False, True))
runner._set_session_env = lambda context: {}
runner._clear_session_env = lambda tokens: None
runner._pinned_session_context_prompt = lambda *args, **kwargs: ""
runner._hmwa_acquire_turn_lease = AsyncMock()
runner._mark_durable_active_turn = AsyncMock()
runner.session_store = object()
runner._async_session_store = SimpleNamespace(_store=runner.session_store, load_transcript=AsyncMock(return_value=[]))
runner._hmwa_run_session_hygiene = AsyncMock(return_value=[])
runner._hmwa_first_contact_notes = AsyncMock()
runner._voice_channel_sidecar_note = lambda *args: None
runner._consume_pending_native_image_paths = lambda key: []
runner._adapter_for_source = lambda source: None
runner._bind_adapter_run_generation = lambda *args: None
async def propagate_error(exc, *args):
raise exc
runner._hmwa_agent_error_reply = propagate_error
from hermes_state import SessionDB
db = SessionDB(tmp_path / "state.db")
agent = AIAgent(session_db=db, model="test-model", api_key="test-key", base_url="http://127.0.0.1:1/v1",
platform="discord", session_id=entry.session_id, enabled_toolsets=[],
quiet_mode=True, skip_memory=True, skip_context_files=True)
agent.compression_enabled = False
title_inputs, title_requests, model_inputs = [], [], []
def title_request(**kwargs):
title_requests.append(kwargs["messages"][-1]["content"])
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content='{"title": "Diagnose connection pool exhaustion"}'))])
def title_at_turn_start(db, sid, text, **kwargs):
title_inputs.append(text)
title_generator.generate_title(text)
monkeypatch.setattr(title_generator, "maybe_auto_title", title_at_turn_start)
monkeypatch.setattr(title_generator, "call_llm", title_request)
def observe_model(agent, messages):
model_inputs.append(messages[-1]["content"])
raise _TurnObserved
monkeypatch.setattr("agent.conversation_loop.begin_iteration", observe_model)
defaults = TurnContext()
display = SimpleNamespace(**{name: getattr(defaults, name) for name in runner._DISPLAY_TO_TURN_CTX})
display.platform_key = "discord"
display.resolve_display_setting = lambda *args: False
runner._run_agent_display_settings = lambda source: display
def run_without_delivery(ctx, worker, *args):
# Execute at the existing wiring seam: real _run_agent_inner/context/agent
# forwarding, without starting the executor or platform delivery tasks.
worker._native_image_run_message = lambda: ctx.message
worker._run_conversation_with_approval(agent, [], None, ctx.persist_user_message, ctx.persist_user_timestamp)
runner._run_agent_bind_turn_wiring = run_without_delivery
runner._run_agent = runner._run_agent_inner
try:
with pytest.raises(_TurnObserved):
await runner._handle_message_with_agent(event, source, entry.session_key, 1)
assert title_inputs == [question]
assert title_requests == [question]
# Both independent per-turn carriers must survive the gateway/facade/loop
# handoff: title text must not displace upstream memory author attribution.
assert agent._turn_author == {"id": "user", "name": "Example", "is_bot": False}
assert question in model_inputs[0]
for payload in payloads.values():
assert payload in model_inputs[0]
if skills:
assert model_inputs[0].index(question) > title_generator.MAX_TITLE_INPUT_CHARS
stored = [row["content"] for row in db.get_messages(entry.session_id) if row["role"] == "user"]
assert stored and question in stored[0]
for payload in payloads.values():
assert payload in stored[0]
# The ordinary Discord metadata is still model-facing, too.
assert model_inputs[0] != question
# The cached agent must not reuse this title override on a later caller
# that does not supply one.
with pytest.raises(_TurnObserved):
agent.run_conversation("Explain transaction isolation")
assert title_inputs[-1] == "Explain transaction isolation"
assert title_requests[-1] == "Explain transaction isolation"
# Relay metadata belongs to upstream's facade, while title input belongs
# to the conversation prologue. Both must survive the same admission.
from agent import relay_runtime
from unittest.mock import Mock
begin_turn = Mock(wraps=relay_runtime.SESSION_COORDINATOR.begin_turn)
monkeypatch.setattr(relay_runtime.SESSION_COORDINATOR, "begin_turn", begin_turn)
metadata = {"source": "title-regression"}
with pytest.raises(_TurnObserved):
agent.run_conversation("Enriched relay request", relay_metadata=metadata,
title_user_message="Original relay request")
assert begin_turn.call_args.kwargs["metadata"] is metadata
assert title_inputs[-1] == "Original relay request"
assert model_inputs[-1] == "Enriched relay request"
finally:
agent.close()
db.close()