Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2311,6 +2311,7 @@ def _own_policy_open_startup_violation(config) -> Optional[str]:
"_session_reasoning_overrides",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main has moved conversation-scoped state into gateway/session_state.py:ConversationState, whose clear() is invoked by every boundary. Please port this pivot state there rather than adding another runner-level dictionary to the legacy registry.

"_session_service_tier_overrides",
"_pending_model_notes",
"_pending_personality_notes",
"_last_resolved_model",
"_queued_events",
# Staged-but-never-consumed sidecar notes (turn aborted between staging
Expand Down Expand Up @@ -22553,6 +22554,13 @@ def _approval_notify_sync(approval_data: dict) -> None:
if _msn:
message = _msn + "\n\n" + message

# Prepend pending personality note so the model pivots its style
# instead of imitating its own prior turns in the transcript.
_pending_pers = getattr(self, '_pending_personality_notes', {})
_psn = _pending_pers.pop(session_key, None) if session_key else None
if _psn:
message = _psn + "\n\n" + message

# Auto-continue: if the loaded history ends with a tool result,
# the previous agent turn was interrupted mid-work (gateway
# restart, crash, SIGTERM). Prepend a system note so the model
Expand Down
16 changes: 16 additions & 0 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2575,6 +2575,13 @@ def _resolve_prompt(value):
except Exception as e:
return t("gateway.personality.save_failed", error=str(e))
self._ephemeral_system_prompt = ""
_sk = self._session_key_for_source(event.source)
if not hasattr(self, "_pending_personality_notes"):
self._pending_personality_notes = {}
self._pending_personality_notes[_sk] = (
"[Note: the personality overlay was just cleared. "
"From this point forward, respond in your normal default style.]"
)
return t("gateway.personality.cleared")
elif args in personalities:
new_prompt = _resolve_prompt(personalities[args])
Expand All @@ -2591,6 +2598,15 @@ def _resolve_prompt(value):
# Update in-memory so it takes effect on the very next message.
self._ephemeral_system_prompt = new_prompt

_sk = self._session_key_for_source(event.source)
if not hasattr(self, "_pending_personality_notes"):
self._pending_personality_notes = {}
self._pending_personality_notes[_sk] = (
f"[Note: the assistant's personality was just changed to '{args}'. "
f"From this point forward, adopt this persona and respond "
f"accordingly, regardless of the style of earlier replies: {new_prompt}]"
)

return t("gateway.personality.set_to", name=args)

available = "`none`, " + ", ".join(f"`{n}`" for n in personalities)
Expand Down
198 changes: 198 additions & 0 deletions tests/gateway/test_personality_pivot_note.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"""Regression tests: /personality stages a pivot note for the next turn.

``/personality <name>`` writes ``config.yaml`` and sets
``_ephemeral_system_prompt``, but with a populated transcript the model
imitates the style of its own prior assistant turns and ignores the new
system prompt for one more turn. The handler therefore also stages a note
under ``_pending_personality_notes[session_key]``, which
``gateway/run.py`` prepends to the next user message (same mechanism as
``_pending_model_notes`` for ``/model``).

These lock in:

* the note is staged, keyed by session, for both set and clear
* ``_ephemeral_system_prompt`` still tracks the selected personality
* a session boundary (/new) drops the staged note for that session only,
via ``_CONVERSATION_SCOPED_STATE``
"""

from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest

from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import SessionEntry, SessionSource, build_session_key


PERSONALITIES = {
"chadwick": "You are a loud gym bro. Call the user Champ.",
"catgirl": "You are a cheerful cat girl. Use nya~ liberally.",
}


def _make_source() -> SessionSource:
return SessionSource(
platform=Platform.TELEGRAM,
user_id="u1",
chat_id="c1",
user_name="tester",
chat_type="dm",
)


def _make_event(text: str) -> MessageEvent:
return MessageEvent(text=text, source=_make_source(), message_id="m1")


def _make_runner():
from gateway.run import GatewayRunner

runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
adapter = MagicMock()
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner._session_model_overrides = {}
runner._session_reasoning_overrides = {}
runner._pending_model_notes = {}
runner._pending_personality_notes = {}
runner._ephemeral_system_prompt = ""
runner._background_tasks = set()

session_key = build_session_key(_make_source())
session_entry = SessionEntry(
session_key=session_key,
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = session_entry
runner.session_store.reset_session.return_value = session_entry
runner.session_store._entries = {session_key: session_entry}
runner.session_store._generate_session_key.return_value = session_key
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = None
runner._agent_cache_lock = None # disables _evict_cached_agent lock path
runner._is_user_authorized = lambda _source: True
runner._format_session_info = lambda: ""
return runner


@pytest.fixture
def personality_config(monkeypatch, tmp_path):
"""Serve a config with personalities and swallow the config.yaml write.

``_handle_personality_command`` imports ``_hermes_home`` and
``_load_gateway_config`` from ``gateway.run`` at call time and writes via
``atomic_config_write``. Without patching all three the test would
overwrite the developer's real ``~/.hermes/config.yaml``.
"""
config = {"agent": {"personalities": dict(PERSONALITIES)}}
written = {}

monkeypatch.setattr("gateway.run._load_gateway_config", lambda: config)
monkeypatch.setattr("gateway.run._hermes_home", tmp_path)
monkeypatch.setattr(
"gateway.slash_commands.atomic_config_write",
lambda path, cfg: written.update({"path": path, "cfg": cfg}),
)
return SimpleNamespace(config=config, written=written)


@pytest.mark.asyncio
async def test_personality_set_stages_pivot_note(personality_config):
"""/personality <name> stages a note for that session's next message."""
runner = _make_runner()
session_key = build_session_key(_make_source())

await runner._handle_personality_command(_make_event("/personality chadwick"))

assert session_key in runner._pending_personality_notes
note = runner._pending_personality_notes[session_key]
assert "chadwick" in note
assert PERSONALITIES["chadwick"] in note
assert runner._ephemeral_system_prompt == PERSONALITIES["chadwick"]


@pytest.mark.asyncio
async def test_second_switch_replaces_the_staged_note(personality_config):
"""Swapping again before the note is consumed must not stack notes.

This is the reported failure: the second and later swaps in one process
were the ones that came back in the previous persona's voice.
"""
runner = _make_runner()
session_key = build_session_key(_make_source())

await runner._handle_personality_command(_make_event("/personality chadwick"))
await runner._handle_personality_command(_make_event("/personality catgirl"))

note = runner._pending_personality_notes[session_key]
assert PERSONALITIES["catgirl"] in note
assert PERSONALITIES["chadwick"] not in note
assert runner._ephemeral_system_prompt == PERSONALITIES["catgirl"]


@pytest.mark.asyncio
async def test_personality_none_stages_a_clear_note(personality_config):
"""/personality none must also pivot — not silently keep the old voice."""
runner = _make_runner()
session_key = build_session_key(_make_source())

await runner._handle_personality_command(_make_event("/personality chadwick"))
await runner._handle_personality_command(_make_event("/personality none"))

assert runner._ephemeral_system_prompt == ""
note = runner._pending_personality_notes[session_key]
assert PERSONALITIES["chadwick"] not in note
assert "default" in note.lower()


@pytest.mark.asyncio
async def test_unknown_personality_stages_nothing(personality_config):
"""An unrecognised name must not leave a note behind."""
runner = _make_runner()
session_key = build_session_key(_make_source())

await runner._handle_personality_command(_make_event("/personality nope"))

assert session_key not in runner._pending_personality_notes


@pytest.mark.asyncio
async def test_new_command_clears_pending_personality_note():
"""A staged-but-unconsumed note must not survive a session boundary."""
runner = _make_runner()
session_key = build_session_key(_make_source())
runner._pending_personality_notes[session_key] = "[Note: now chadwick.]"

await runner._handle_reset_command(_make_event("/new"))

assert session_key not in runner._pending_personality_notes


@pytest.mark.asyncio
async def test_new_command_only_clears_own_session_note():
"""/new must leave other sessions' staged notes alone."""
runner = _make_runner()
session_key = build_session_key(_make_source())
other_key = "other_session_key"
runner._pending_personality_notes[session_key] = "[Note: now chadwick.]"
runner._pending_personality_notes[other_key] = "[Note: now catgirl.]"

await runner._handle_reset_command(_make_event("/new"))

assert session_key not in runner._pending_personality_notes
assert other_key in runner._pending_personality_notes