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
16 changes: 16 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -18974,6 +18974,21 @@ async def _rename_discord_auto_thread_for_session_title(
else getattr(source, "auto_thread_initial_name", None)
)
thread_name = self._sanitize_discord_thread_title(title)
# Relay lane only: the connector's egress guard resolves the owning
# tenant from the outbound metadata's scope_id (guild) / user_id
# (author). Those discriminator caches are keyed by the PARENT channel
# chat_id (learned at inbound), NOT the thread id. rename_thread
# defaults chat_id to the thread id when no parent is given, so the
# scope/author lookup misses and the connector declines the op
# ("target not routed to an onboarded tenant" — the live failure on
# staging 2026-08-01). Pass the parent channel id (the relay source's
# chat_id IS the parent channel; the thread came from send-result
# feedback) so the discriminators resolve. Native lane needs nothing:
# its source IS the thread and it renames via the direct Discord API,
# not the relay egress guard.
parent_chat_id = (
str(source.chat_id) if use_connector_guard and source.chat_id else None
)
logger.info(
"discord auto-thread rename: thread=%s lane=%s new_title=%r",
target_thread_id,
Expand All @@ -18986,6 +19001,7 @@ async def _rename_discord_auto_thread_for_session_title(
thread_name,
prefer_connector_created=use_connector_guard,
only_if_current_name=guard_name,
parent_chat_id=parent_chat_id,
)
logger.info(
"discord auto-thread rename result: thread=%s applied=%s",
Expand Down
46 changes: 43 additions & 3 deletions tests/gateway/relay/test_relay_threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,40 @@ async def test_rename_thread_connector_guard_takes_precedence_over_string():
assert "only_if_current_name" not in action


@pytest.mark.asyncio
async def test_rename_thread_resolves_scope_from_parent_chat_not_thread():
"""The connector's egress guard resolves the owning tenant from the
outbound metadata's scope_id / user_id, and the adapter's discriminator
caches are keyed by the PARENT channel chat_id (learned at inbound), never
the thread id. A rename that passes parent_chat_id must carry that
discriminator; a rename keyed only on the thread id must not — reproducing
the live decline ("target not routed to an onboarded tenant") and its fix.
"""
adapter, stub = _adapter()
# Simulate the inbound-learned scope for the PARENT channel only.
adapter._scope_by_chat["chan-parent"] = "guild-123"

# Fix: pass the parent chat id -> scope_id resolves.
await adapter.rename_thread(
"th-9",
"Real Title",
prefer_connector_created=True,
parent_chat_id="chan-parent",
)
fixed = stub.sent[-1]
assert fixed["metadata"].get("scope_id") == "guild-123"

# Regression shape: keyed on the thread id alone (no parent) -> no scope_id,
# which is exactly what made the connector decline the op.
await adapter.rename_thread(
"th-9",
"Real Title",
prefer_connector_created=True,
)
unscoped = stub.sent[-1]
assert "scope_id" not in unscoped["metadata"]


# ── the relay semantic-rename lane (marker parity) ───────────────────────


Expand Down Expand Up @@ -331,7 +365,7 @@ async def rename_thread(
prefer_connector_created=False,
parent_chat_id=None,
):
renames.append((thread_id, name, prefer_connector_created))
renames.append((thread_id, name, prefer_connector_created, parent_chat_id))
return True

adapter.rename_thread = rename_thread # type: ignore[method-assign]
Expand All @@ -348,8 +382,14 @@ async def land_feedback_late():
)
await task
# Relay lane uses the connector-owned guard (prefer_connector_created=True),
# not the fragile cross-repo initial-name string.
assert renames == [("th-9", "Debugging the flux capacitor", True)]
# not the fragile cross-repo initial-name string. It MUST pass the PARENT
# channel chat_id so the connector's egress guard can resolve the tenant
# (the discriminator caches are keyed by the parent channel, not the thread;
# omitting it made the connector decline "target not routed to an onboarded
# tenant" — the live failure on staging 2026-08-01).
assert renames == [
("th-9", "Debugging the flux capacitor", True, "chan-parent")
]


@pytest.mark.asyncio
Expand Down
Loading