feat(webhook): mirror cross-platform deliveries into the target chat session

A webhook route that delivers to telegram/discord/... runs in an ephemeral
webhook:<route>:<delivery_id> session. The delivered text never reaches
the target chat's own session, so when the user replies there ("so he's
out?") the agent has no idea it just sent them anything and asks what
they mean.

After a successful cross-platform send, mirror the delivered text into the
target chat's transcript as a labelled user turn ("[Webhook delivery:
<route>]\n..."), the same path and role convention cron briefs use
(cron.scheduler_delivery._maybe_mirror_cron_delivery, #2221). Best-effort:
never fails the delivery, skipped when the chat has no session yet.
Route opt-out via mirror_to_session: false. delivery_info now carries the
route name and the flag for both agent-run and deliver_only routes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZ15jNgPUHdPsMw4wWon6v
This commit is contained in:
Patrick BlaisandClaude Fable 5.1 authored and Teknium committed 2026-09-23 17:58:45 -07:00
1 parent 76aebb415d
commit 11fb429f49
3 files changed
+118 -4

No files matched your search

+36 -4
View File
@@ -3,7 +3,9 @@ Svix, Linear, generic), renders payloads into agent prompts, and routes response
or any gateway platform). Routes live under platforms.webhook.extra.routes: events (header filter),
secret (REQUIRED; "INSECURE_NO_AUTH" skips validation, loopback only), prompt template, skills,
deliver/deliver_extra, deliver_only (rendered prompt IS the message), cron_job (fire an existing cron
job per event; the rendered prompt is transient per-run context; exclusive with deliver_only). Per-route rate limiting,
job per event; the rendered prompt is transient per-run context; exclusive with deliver_only), mirror_to_session
(default on: a delivered response is also written into the target chat's transcript so follow-ups there have
context). Per-route rate limiting,
idempotency cache, body-size caps checked before reading. Generic HMAC V2 binds a timestamp for
replay protection; body-only V1 is deprecated but accepted with a warning."""
@@ -481,7 +483,9 @@ class WebhookAdapter(BasePlatformAdapter):
"""deliver_only: the rendered prompt IS the message — skip the agent, reuse the same
auth/rate-limit/idempotency/template pipeline."""
delivery = {"deliver": route_config.get("deliver", "log"), "payload": payload, "profile": profile,
"deliver_extra": self._render_delivery_extra(route_config.get("deliver_extra", {}), payload)}
"deliver_extra": self._render_delivery_extra(route_config.get("deliver_extra", {}), payload),
"route": route_name,
"mirror": route_config.get("mirror_to_session", True) is not False}
logger.info("[webhook] direct-deliver event=%s route=%s target=%s msg_len=%d delivery=%s", event_type,
route_name, delivery["deliver"], len(prompt), delivery_id)
failed = {"status": "error", "error": "Delivery failed", "delivery_id": delivery_id}
@@ -650,7 +654,9 @@ class WebhookAdapter(BasePlatformAdapter):
# THIS profile's adapter, home channel and secrets — not the first profile that has the platform.
self._delivery_info[session_chat_id] = {
"deliver": route_config.get("deliver", "log"), "profile": profile,
"deliver_extra": self._render_delivery_extra(route_config.get("deliver_extra", {}), payload)}
"deliver_extra": self._render_delivery_extra(route_config.get("deliver_extra", {}), payload),
"route": route_name,
"mirror": route_config.get("mirror_to_session", True) is not False}
self._delivery_info_created[session_chat_id] = now
self._delivery_info_order.append((now, session_chat_id))
self._prune_delivery_info(now)
@@ -873,7 +879,10 @@ class WebhookAdapter(BasePlatformAdapter):
return SendResult(success=False, error=f"No chat_id or home channel for {platform_name}")
chat_id = home.chat_id
thread_id = extra.get("message_thread_id") or extra.get("thread_id") # Telegram forum topics
return await adapter.send(chat_id, content, metadata={"thread_id": thread_id} if thread_id else None)
result = await adapter.send(chat_id, content, metadata={"thread_id": thread_id} if thread_id else None)
if result.success:
self._mirror_delivery(platform_name, str(chat_id), content, delivery, thread_id)
return result
def _delivery_config(self, profile: Optional[str]):
"""Gateway config of the profile a delivery is bound to (call inside ``_profile_scope``)."""
@@ -881,3 +890,26 @@ class WebhookAdapter(BasePlatformAdapter):
return self.gateway_runner.config
from gateway.config import load_gateway_config
return load_gateway_config()
def _mirror_delivery(self, platform_name: str, chat_id: str, content: str, delivery: dict,
thread_id: Optional[str]) -> None:
"""Best-effort mirror of a delivered response into the TARGET chat's session transcript, so a
follow-up there ("so he's out?") sees what the webhook run just told the user. Without this the
text only lives in the ephemeral ``webhook:<route>:<delivery_id>`` session and the target chat's
agent has no idea it sent anything. Same path and USER-role convention as cron briefs
(``cron.scheduler_delivery._maybe_mirror_cron_delivery``, #2221): the text is not the target
session's agent speaking, and a labelled user turn merges safely on strict-alternation providers.
Route opt-out: ``mirror_to_session: false``. Never raises — a delivered message must not be
reported failed because the mirror broke."""
if not delivery.get("mirror", True):
return
route = delivery.get("route") or "webhook"
try:
from gateway.mirror import mirror_to_session
ok = mirror_to_session(platform_name, chat_id, f"[Webhook delivery: {route}]\n{content}",
source_label="webhook", thread_id=thread_id, role="user")
logger.log(logging.INFO if ok else logging.DEBUG,
"[webhook] Route '%s' delivery %smirrored into %s:%s session", route, "" if ok else "not ",
platform_name, chat_id)
except Exception as e:
logger.debug("[webhook] Route '%s' delivery mirror into %s:%s failed: %s", route, platform_name, chat_id, e)
+79
View File
@@ -819,6 +819,85 @@ class TestDeliverCrossPlatformThreadId:
)
class TestCrossPlatformDeliveryMirror:
"""A delivered response is mirrored into the TARGET chat's session so a follow-up reply there has
context (the text otherwise only lives in the ephemeral webhook:<route>:<id> session)."""
def _setup(self, send_result=None):
adapter = _make_adapter()
mock_target = AsyncMock()
mock_target.send = AsyncMock(return_value=send_result or SendResult(success=True))
mock_runner = MagicMock()
mock_runner.adapters = {Platform("telegram"): mock_target}
mock_runner.config.get_home_channel.return_value = None
adapter.gateway_runner = mock_runner
return adapter, mock_target
@pytest.mark.asyncio
async def test_successful_delivery_is_mirrored_as_labelled_user_turn(self):
adapter, _ = self._setup()
delivery = {"route": "ambush-nfl", "deliver_extra": {"chat_id": "5135545282", "thread_id": "7"}}
with patch("gateway.mirror.mirror_to_session", return_value=True) as mirror:
result = await adapter._deliver_cross_platform("telegram", "Henderson OUT Wednesday", delivery)
assert result.success is True
mirror.assert_called_once_with(
"telegram", "5135545282", "[Webhook delivery: ambush-nfl]\nHenderson OUT Wednesday",
source_label="webhook", thread_id="7", role="user")
@pytest.mark.asyncio
async def test_home_channel_fallback_is_mirrored_to_that_chat(self):
adapter, _ = self._setup()
adapter.gateway_runner.config.get_home_channel.return_value = MagicMock(chat_id="home-1")
with patch("gateway.mirror.mirror_to_session", return_value=True) as mirror:
await adapter._deliver_cross_platform("telegram", "hi", {"route": "r", "deliver_extra": {}})
assert mirror.call_args.args[:2] == ("telegram", "home-1")
@pytest.mark.asyncio
async def test_failed_delivery_is_not_mirrored(self):
adapter, _ = self._setup(SendResult(success=False, error="boom"))
with patch("gateway.mirror.mirror_to_session") as mirror:
result = await adapter._deliver_cross_platform(
"telegram", "hi", {"route": "r", "deliver_extra": {"chat_id": "1"}})
assert result.success is False
mirror.assert_not_called()
@pytest.mark.asyncio
async def test_route_can_opt_out(self):
adapter, _ = self._setup()
with patch("gateway.mirror.mirror_to_session") as mirror:
await adapter._deliver_cross_platform(
"telegram", "hi", {"route": "r", "mirror": False, "deliver_extra": {"chat_id": "1"}})
mirror.assert_not_called()
@pytest.mark.asyncio
async def test_mirror_failure_never_fails_the_delivery(self):
adapter, mock_target = self._setup()
with patch("gateway.mirror.mirror_to_session", side_effect=RuntimeError("db locked")):
result = await adapter._deliver_cross_platform(
"telegram", "hi", {"route": "r", "deliver_extra": {"chat_id": "1"}})
assert result.success is True
mock_target.send.assert_awaited_once()
@pytest.mark.asyncio
async def test_delivery_info_records_route_and_mirror_flag(self):
"""The route name and opt-out reach send() through delivery_info for both agent-run and
deliver_only routes."""
routes = {
"agent": {"secret": _INSECURE_NO_AUTH, "prompt": "p"},
"quiet": {"secret": _INSECURE_NO_AUTH, "prompt": "p", "mirror_to_session": False},
}
adapter = _make_adapter(routes=routes)
adapter.handle_message = AsyncMock()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
r1 = await cli.post("/webhooks/agent", json={"a": 1}, headers={"X-Request-ID": "d1"})
r2 = await cli.post("/webhooks/quiet", json={"a": 1}, headers={"X-Request-ID": "d2"})
assert r1.status == 202 and r2.status == 202
assert adapter._delivery_info["webhook:agent:d1"]["route"] == "agent"
assert adapter._delivery_info["webhook:agent:d1"]["mirror"] is True
assert adapter._delivery_info["webhook:quiet:d2"]["mirror"] is False
class TestInsecureNoAuthSafetyRail:
"""connect() refuses to start when INSECURE_NO_AUTH is combined with a
non-loopback bind. Guards against accidentally exposing an unauthenticated
@@ -91,6 +91,7 @@ Routes define how different webhook sources are handled. Each route is a named e
| `deliver_only` | No | If `true`, skip the agent entirely — the rendered `prompt` template becomes the literal message that gets delivered. Zero LLM cost, sub-second delivery. See [Direct Delivery Mode](#direct-delivery-mode) for use cases. Requires `deliver` to be a real target (not `log`). |
| `cron_job` | No | Fire an existing cron job (by ID or name) on each event instead of starting a fresh webhook agent session. The rendered `prompt` becomes transient per-run context; the job's own prompt, skills, model, and delivery settings apply. Mutually exclusive with `deliver_only`. See [Event-Triggered Cron Jobs](#event-triggered-cron-jobs). |
| `coalesce` | No | Debounce rapid distinct events on the same logical entity into one agent run. Block with a required `key` (payload field or template identifying the entity, e.g. `pull_request.number`), optional `window_seconds` (quiet window, default 30) and `max_wait_seconds` (dispatch cap, default 300). See [Event Coalescing](#event-coalescing). Mutually exclusive with `deliver_only` and `cron_job`. |
| `mirror_to_session` | No | Default `true`. After a successful delivery to a chat platform, the delivered message is also written into that chat's session transcript (as a labelled user turn, the same way cron briefs are), so when you reply in that chat the agent knows what it just sent you. Set `false` to keep the target chat's history untouched. |
### Full example
@@ -359,6 +360,8 @@ The `deliver` field controls where the agent's response goes after processing th
For cross-platform delivery, the target platform must also be enabled and connected in the gateway. If no `chat_id` is provided in `deliver_extra`, the response is sent to that platform's configured home channel.
A delivered response is mirrored into the target chat's session transcript as `[Webhook delivery: <route>]` followed by the message, so a follow-up reply in that chat ("so he's out?") has the context of what the webhook run said. The mirror is best-effort: it never fails the delivery, and it is skipped when the chat has no gateway session yet (nobody has talked to the agent there). Set `mirror_to_session: false` on the route to disable it.
---
## Direct Delivery Mode {#direct-delivery-mode}