Skip to content
Merged
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
14 changes: 7 additions & 7 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -3887,14 +3887,14 @@ async def _prepare_inbound_message_text(
message_text = f"{context_note}\n\n{message_text}"

if getattr(event, "reply_to_text", None) and event.reply_to_message_id:
# Always inject the reply-to pointer — even when the quoted text
# already appears in history. The prefix isn't deduplication, it's
# disambiguation: it tells the agent *which* prior message the user
# is referencing. History can contain the same or similar text
# multiple times, and without an explicit pointer the agent has to
# guess (or answer for both subjects). Token overhead is minimal.
reply_snippet = event.reply_to_text[:500]
found_in_history = any(
reply_snippet[:200] in (msg.get("content") or "")
for msg in history
if msg.get("role") in ("assistant", "user", "tool")
)
if not found_in_history:
message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}'
message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}'

if "@" in message_text:
try:
Expand Down
159 changes: 159 additions & 0 deletions tests/gateway/test_reply_to_injection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Tests for reply-to pointer injection in _prepare_inbound_message_text.

The `[Replying to: "..."]` prefix is a *disambiguation pointer*, not
deduplication. It must always be injected when the user explicitly replies
to a prior message — even when the quoted text already exists somewhere
in the conversation history. History can contain the same or similar text
multiple times, and without an explicit pointer the agent has to guess
which prior message the user is referencing.
"""
import pytest

from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.run import GatewayRunner
from gateway.session import SessionSource


def _make_runner() -> GatewayRunner:
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake")},
)
runner.adapters = {}
runner._model = "openai/gpt-4.1-mini"
runner._base_url = None
return runner


def _source() -> SessionSource:
return SessionSource(
platform=Platform.TELEGRAM,
chat_id="123",
chat_name="DM",
chat_type="private",
user_name="Alice",
)


@pytest.mark.asyncio
async def test_reply_prefix_injected_when_text_absent_from_history():
runner = _make_runner()
source = _source()
event = MessageEvent(
text="What's the best time to go?",
source=source,
reply_to_message_id="42",
reply_to_text="Japan is great for culture, food, and efficiency.",
)

result = await runner._prepare_inbound_message_text(
event=event,
source=source,
history=[{"role": "user", "content": "unrelated"}],
)

assert result is not None
assert result.startswith(
'[Replying to: "Japan is great for culture, food, and efficiency."]'
)
assert result.endswith("What's the best time to go?")


@pytest.mark.asyncio
async def test_reply_prefix_still_injected_when_text_in_history():
"""Regression test: the pointer must survive even when the quoted text
already appears in history. Previously a `found_in_history` guard
silently dropped the prefix, leaving the agent to guess which prior
message the user was referencing."""
runner = _make_runner()
source = _source()
quoted = "Japan is great for culture, food, and efficiency."
event = MessageEvent(
text="What's the best time to go?",
source=source,
reply_to_message_id="42",
reply_to_text=quoted,
)

history = [
{"role": "user", "content": "I'm thinking of going to Japan or Italy."},
{
"role": "assistant",
"content": (
f"{quoted} Italy is better if you prefer a relaxed pace."
),
},
{"role": "user", "content": "How long should I stay?"},
{"role": "assistant", "content": "For Japan, 10-14 days is ideal."},
]

result = await runner._prepare_inbound_message_text(
event=event,
source=source,
history=history,
)

assert result is not None
assert result.startswith(f'[Replying to: "{quoted}"]')
assert result.endswith("What's the best time to go?")


@pytest.mark.asyncio
async def test_no_prefix_without_reply_context():
runner = _make_runner()
source = _source()
event = MessageEvent(text="hello", source=source)

result = await runner._prepare_inbound_message_text(
event=event,
source=source,
history=[],
)

assert result == "hello"


@pytest.mark.asyncio
async def test_no_prefix_when_reply_to_text_is_empty():
"""reply_to_message_id alone without text (e.g. a reply to a media-only
message) should not produce an empty `[Replying to: ""]` prefix."""
runner = _make_runner()
source = _source()
event = MessageEvent(
text="hi",
source=source,
reply_to_message_id="42",
reply_to_text=None,
)

result = await runner._prepare_inbound_message_text(
event=event,
source=source,
history=[],
)

assert result == "hi"


@pytest.mark.asyncio
async def test_reply_snippet_truncated_to_500_chars():
runner = _make_runner()
source = _source()
long_text = "x" * 800
event = MessageEvent(
text="follow-up",
source=source,
reply_to_message_id="42",
reply_to_text=long_text,
)

result = await runner._prepare_inbound_message_text(
event=event,
source=source,
history=[],
)

assert result is not None
assert result.startswith('[Replying to: "' + "x" * 500 + '"]')
assert "x" * 501 not in result
Loading