Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
272aa92
test(matrix): give DM fixtures complete joined membership
iainlane Sep 27, 2026
0831d7f
fix(matrix): adopt the homeserver's user ID after password login
iainlane Sep 29, 2026
155571b
test(matrix): give the handoff fixture complete joined membership
iainlane Sep 29, 2026
704e722
docs(matrix): describe the two-joined-users rule in the env reference
iainlane Sep 29, 2026
221252f
fix(matrix): keep the E2EE store keyed by the configured user ID
iainlane Sep 29, 2026
6686da4
feat(matrix): name unnamed rooms after their members
iainlane Sep 27, 2026
7ef24f5
feat(matrix): surface room state changes to the agent
iainlane Sep 27, 2026
bc806ed
fix(matrix): attach room notes when preparing model input
iainlane Sep 27, 2026
11e35e3
fix(matrix): pin initial room metadata in conversation prompts
iainlane Sep 27, 2026
91ba17a
test(matrix): exercise live gateway with separate client
iainlane Sep 27, 2026
aa0abae
fix(matrix): clean up live test resources after worker exit
iainlane Sep 27, 2026
41b7321
fix(matrix): isolate live gateway image builds
iainlane Sep 27, 2026
9649752
fix(matrix): reap interrupted live gateway images
iainlane Sep 27, 2026
c7ed0e4
test(matrix): use a cached gateway image for live tests
iainlane Sep 27, 2026
284b4da
test(matrix): bound plain exchange wait
iainlane Sep 27, 2026
3620919
test(matrix): set finite event rates for disposable Synapse
iainlane Sep 28, 2026
a012532
test(matrix): keep the selected Docker context for live tests
iainlane Sep 29, 2026
0cd6347
test(matrix): drop the gateway image's crypto probe container
iainlane Sep 29, 2026
651134b
ci(matrix): build the live gateway image before the tests
iainlane Sep 29, 2026
336d81f
test(matrix): run the live gateway as the host user
iainlane Sep 29, 2026
4b42e8e
test(matrix): listen for the live gateway only on its route to this host
iainlane Sep 29, 2026
0804a39
test(matrix): wait for the live gateway to finish startup restore
iainlane Sep 29, 2026
22e056f
test(matrix): fix the Windows gateway user and widen the live CI filter
iainlane Sep 29, 2026
01341e8
fix(matrix): restrict private bot chats to two joined users
iainlane Sep 27, 2026
79845e3
fix(matrix): read the author from an emote reply fallback
iainlane Sep 30, 2026
52a254f
fix(telegram): preserve reply context across message batches
the3asic Jul 16, 2026
14a5c1f
fix(gateway): keep reply context when busy follow-ups merge
iainlane Sep 30, 2026
98bbee9
fix(gateway): preserve inline flags in busy text merges
iainlane Oct 1, 2026
2094d18
fix(matrix): distinguish thread fallback from explicit replies
iainlane Sep 27, 2026
54de7fb
feat(matrix): resolve quoted messages from room events
iainlane Sep 27, 2026
5b69344
feat(matrix): restore quoted media and new thread context
iainlane Sep 27, 2026
86c46c5
test(matrix): cover media reply failure and separate thread context
iainlane Sep 27, 2026
874e1d2
fix(matrix): preserve thread reply context across event changes
iainlane Sep 27, 2026
01ff8f3
test(matrix): verify thread context with a separate client
iainlane Sep 27, 2026
7674b0e
fix(matrix): exclude redacted context from in-flight reads
iainlane Sep 27, 2026
9941414
fix(matrix): keep reply pointers unattributed and quotes whole
iainlane Sep 29, 2026
fe7e96b
fix(matrix): strip only a real reply fallback from thread messages
iainlane Sep 29, 2026
c640040
fix(matrix): ignore edits whose target is not cached
iainlane Sep 29, 2026
022f176
fix(matrix): keep reply context when a busy session merges a message
iainlane Sep 29, 2026
ad9c5bd
fix(matrix): leave every chunk of a batched turn out of thread backfill
iainlane Sep 29, 2026
dd4500c
test(matrix): cover decryption of reply targets and thread events
iainlane Sep 29, 2026
48afd67
fix(matrix): fetch thread history through the turn-context hook
iainlane Sep 29, 2026
57ac219
fix(matrix): use the reply-fallback check for thread messages
iainlane Sep 30, 2026
de47f16
fix(matrix): share one <mx-reply> check between reply helpers
iainlane Sep 30, 2026
625d024
fix(matrix): exclude every busy-debounced trigger from thread history
iainlane Oct 1, 2026
bfe01c8
fix(matrix): append thread fields after existing event fields
iainlane Oct 1, 2026
7df8ed7
fix(matrix): preserve user quotations in resolved context
iainlane Oct 1, 2026
5abf2a4
fix(matrix): exclude batched thread roots from backfill
iainlane Oct 1, 2026
ce762ac
docs(matrix): describe reply fallback and merged event identities
iainlane Oct 1, 2026
221c1ae
fix(matrix): keep live reads on the gateway event loop
iainlane Sep 27, 2026
2a7463a
refactor(matrix): share history decoding with the thread backfill
iainlane Sep 29, 2026
ba2dce8
refactor(matrix): bind the gateway loop for Matrix reads explicitly
iainlane Sep 29, 2026
f0c6bcf
fix(matrix): refuse Matrix reads through a non-Matrix transport
iainlane Sep 29, 2026
878e49e
fix(matrix): list Matrix reads oldest first
iainlane Sep 29, 2026
cb0f4eb
fix(matrix): count only messages towards the Matrix read limit
iainlane Sep 29, 2026
06dc266
test(matrix): cover read policy, argument bounds and the deadline
iainlane Sep 29, 2026
9fe2867
feat(matrix): configure the Matrix read toolset through hermes tools
iainlane Sep 29, 2026
acd712b
test(matrix): wait for the final reply in the live read-tools test
iainlane Sep 30, 2026
f781032
test(matrix): start the read deadline only once the stalled read runs
iainlane Sep 30, 2026
c8a0fb4
fix(matrix): require a target for thread and event reads
iainlane Oct 1, 2026
19e379d
refactor(matrix): decode encrypted history with its concrete event type
iainlane Oct 1, 2026
3d95679
fix(matrix): refresh room origin on reset and fan out state notes
iainlane Sep 27, 2026
55a9104
fix(matrix): reconcile room state after gateway restart
iainlane Sep 27, 2026
7ac5783
fix(matrix): acknowledge room state with the transcript turn
iainlane Sep 27, 2026
fb2ff7f
fix(matrix): keep one history backfill when text batches merge
iainlane Sep 29, 2026
7e74371
fix(matrix): report room changes through a generic adapter turn hook
iainlane Sep 29, 2026
56f1a5d
fix(matrix): keep the room baseline when state reads fail
iainlane Sep 29, 2026
2bcca40
fix(matrix): acknowledge room security changes with the saved turn
iainlane Sep 29, 2026
39d8144
test(matrix): expect /new to pass the chat source to reset_session
iainlane Sep 29, 2026
0724867
fix(matrix): keep origin names when an internal wake exhausts compres…
iainlane Sep 29, 2026
c3322e4
fix(matrix): look up the turn-context session off the event loop
iainlane Sep 29, 2026
01089ed
refactor(matrix): derive the frozen session prompt from the turn hook
iainlane Sep 29, 2026
dffdd5d
fix(matrix): keep the last room names when a state read fails
iainlane Sep 29, 2026
6648555
fix(matrix): keep the room baseline across context compaction
iainlane Sep 30, 2026
e4bdd56
test(matrix): cover the channel state on a summary merged into the tail
iainlane Sep 30, 2026
0dc0b04
docs(gateway): describe channel state from any transcript role
iainlane Oct 1, 2026
d80e8ff
fix(matrix): keep a user's leading quote in an explicit reply
iainlane Sep 29, 2026
81d9c21
test(matrix): expose incorrect thread fallback chains
iainlane Sep 27, 2026
f1c6d55
fix(matrix): preserve genuine thread reply semantics
mijanx Jun 22, 2026
11b6b7c
fix(matrix): chain thread fallbacks through known events
iainlane Sep 27, 2026
41bfee4
test(matrix): prove genuine thread replies with a live client
iainlane Sep 27, 2026
037e5a4
fix(matrix): quote the triggering event once per threaded response
iainlane Sep 29, 2026
f004048
refactor(matrix): drop the unused thread fallback alias
iainlane Sep 29, 2026
e3dde2a
fix(matrix): remember recent answered and sent events per thread
iainlane Sep 29, 2026
44a00e2
feat(matrix): read bounded history through the live session
iainlane Sep 27, 2026
989b6aa
fix(matrix): close real sessions in adapter test fixtures
iainlane Oct 9, 2026
7ec84d5
refactor(matrix): separate room identity and HTML rendering
iainlane Oct 2, 2026
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
61 changes: 61 additions & 0 deletions .github/workflows/matrix-live.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Matrix live client

on:
pull_request:
paths:
- '.github/workflows/matrix-live.yml'
- '.github/actions/setup-pm/**'
- '.dockerignore'
- '*.py'
- 'agent/**'
- 'cron/**'
- 'gateway/**'
- 'hermes_cli/**'
- 'hermes_platform/**'
- 'locales/**'
- 'plugins/**'
- 'pm/**'
- 'providers/**'
- 'scripts/bundles/payload.py'
- 'scripts/ci/list_os_marked_tests.py'
- 'scripts/ci/setup_toolchain.py'
- 'scripts/run_tests.sh'
- 'scripts/run_tests_parallel.py'
- 'skills/**'
- 'tests/conftest.py'
- 'tests/fakes/fake_llm_provider.py'
- 'tests/integration/matrix_live/**'
- 'tools/**'
- 'tui_gateway/**'
- 'pyproject.toml'
- 'uv.lock'
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
plain-exchange:
name: Separate client and Linux gateway
runs-on: ubuntu-latest-32-core
timeout-minutes: 45
env:
HERMES_TEST_MATRIX_GATEWAY_IMAGE: hermes-matrix-live:ci
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up locked Python and Matrix dependencies
uses: ./.github/actions/setup-pm
with:
extras: '["matrix"]'
test-environment: 'true'
# Build here so that the per-file test timeout does not have to cover a cold image build.
- name: Build the Linux gateway image
run: '"$HERMES_PYTHON" -m tests.integration.matrix_live.image_build "$HERMES_TEST_MATRIX_GATEWAY_IMAGE"'
- name: Run Matrix live client tests
run: scripts/run_tests.sh --include-integration tests/integration/matrix_live/
env:
HERMES_TEST_WORKERS: '1'
HERMES_TEST_FILE_TIMEOUT: '300'
HERMES_TEST_FILE_RETRIES: '0'
10 changes: 8 additions & 2 deletions agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from agent.context_compressor_prellm import PreLlmSkipMixin
from agent.context_compressor_summary import SummaryDispatchMixin
from agent.error_classifier import FailoverReason, classify_api_error
from agent.message_metadata import keep_newest_channel_state
from agent.micro_compaction import MicroCompactionMixin
from agent.prompt_builder import STEER_DISPLAY_KIND
from agent.model_metadata import (
Expand Down Expand Up @@ -5745,14 +5746,16 @@ def _assemble_compressed(
summary_role, merge_into_tail, force_user_leading, first_tail_visible_idx = (
self._summary_placement(compressed, tail_messages, compress_start)
)
summary_row = None
if not merge_into_tail:
# End marker stops weak models treating the quoted summary as fresh input (#11475) or
# regurgitating it (#33256).
compressed.append({
summary_row = {
"role": summary_role, "content": summary + "\n\n" + _SUMMARY_END_MARKER,
COMPRESSED_SUMMARY_METADATA_KEY: True,
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY: bool(self._summary_has_user_turn),
})
}
compressed.append(summary_row)
# Default carrier is tail[0]: an exempt row absorbs the summary invisibly. The forced repair
# path needs a non-empty role=user row, so it targets the template-visible row.
merge_target_idx = first_tail_visible_idx if force_user_leading and first_tail_visible_idx is not None else 0
Expand All @@ -5763,7 +5766,10 @@ def _assemble_compressed(
msg[_COMPACTION_TAIL_MARKER] = True
if merge_into_tail and tail_idx == merge_target_idx:
self._merge_summary_into_tail_row(msg, summary, summary_role, force_user_leading)
summary_row = msg
compressed.append(msg)
if summary_row is not None:
keep_newest_channel_state(messages, compressed, summary_row)
return compressed


Expand Down
32 changes: 31 additions & 1 deletion agent/message_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,35 @@ def message_identity(msg: MutableMapping[str, Any], *, with_tool_uids: bool = Fa
return identity


# The ``display_metadata`` entry in which the gateway saves a snapshot of the chat (its name, topic and
# similar state) on a user row. The newest row with one is the snapshot that the conversation has
# acknowledged, so a rewrite that removes that row must record the snapshot on a row that it keeps.
CHANNEL_STATE_METADATA_KEY = "channel_state"


def _channel_state(msg: Any) -> Optional[dict]:
metadata = msg.get("display_metadata") if isinstance(msg, Mapping) else None
state = metadata.get(CHANNEL_STATE_METADATA_KEY) if isinstance(metadata, Mapping) else None
return state if isinstance(state, dict) else None


def newest_channel_state(messages: List[Any]) -> Optional[dict]:
"""The channel state saved on the newest row of *messages* that has one."""
return next((state for msg in reversed(messages) if (state := _channel_state(msg)) is not None), None)


def keep_newest_channel_state(
original: List[Any], rewritten: List[Any], carrier: MutableMapping[str, Any],
) -> None:
"""Record the newest channel state of *original* on *carrier* when *rewritten* no longer has it as
its newest. *carrier* is the row of *rewritten* that replaces the removed rows (a compaction summary
or a merge survivor), and every row after it in *rewritten* must be a row of *original*."""
state = newest_channel_state(original)
if state is None or newest_channel_state(rewritten) == state:
return
carrier["display_metadata"] = {**(carrier.get("display_metadata") or {}), CHANNEL_STATE_METADATA_KEY: state}


def record_absorbed_message(
survivor: MutableMapping[str, Any], dropped: Mapping[str, Any], *, dropped_leads: bool = False,
) -> None:
Expand All @@ -123,8 +152,9 @@ def record_absorbed_message(
text leads; with *dropped_leads* the dropped dict's text was put first (the real user anchor folded
into a scaffolding turn), so its uid becomes the survivor's and the survivor's former uid is recorded.
A dict without a uid (unflushed, scaffolding, engine-authored) contributes nothing; an empty result
leaves the survivor untouched.
leaves the survivor untouched. The survivor also keeps the newest channel state of the two constituents.
"""
keep_newest_channel_state([dropped, survivor] if dropped_leads else [survivor, dropped], [survivor], survivor)
survivor_uid = message_uid_or_none(survivor)
dropped_uid = message_uid_or_none(dropped)
own = uid_list(survivor.get(ABSORBED_MESSAGE_UIDS))
Expand Down
3 changes: 3 additions & 0 deletions gateway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,6 @@ gateway under the backend, and do NOT "fix" update locks by widening the tree-ki
assert on hardcoded platform lists or command counts (root: no change-detectors). Session-key and
guard behaviour are invariants worth a test; platform API quirks belong in connector comments +
tests, not in prose.

Matrix room identity reads are defined in `plugins/platforms/matrix/context_mixin.py`;
formatted HTML helpers are defined in `plugins/platforms/matrix/rendering.py`.
60 changes: 55 additions & 5 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non
from gateway.platforms.base_exec_approval import (
approval_timeout_seconds, ea_action_labels, ea_default_reason_text, ea_header_text,
ea_reason_label_text, ea_smart_deny_line_text, format_approval_deadline_line)
from gateway.platforms.event import MessageEvent, MessageType, ProcessingOutcome
from gateway.platforms.event import MessageEvent, MessageType, ProcessingOutcome, TurnContextUpdate
from gateway.warning_notifications import diagnostic_wake_muted
from hermes_cli.observability.shared_metrics_gateway import records_delivery, stop_reply_clock
from gateway.session import SessionSource, build_session_key
Expand Down Expand Up @@ -1829,6 +1829,8 @@ def _padded_inline_flags(msg: MessageEvent) -> list[Optional[bool]]:
existing.media_text_inlined.extend(incoming_inline_flags)
if event.text:
existing.text = BasePlatformAdapter._merge_caption(existing.text, event.text)
existing.absorb_message_ids(event)
existing.absorb_reply_context(event)
existing.absorb_reply_expected(event)
if existing_is_photo or incoming_is_photo:
existing.message_type = MessageType.PHOTO
Expand All @@ -1844,6 +1846,8 @@ def _padded_inline_flags(msg: MessageEvent) -> list[Optional[bool]]:
if merge_text and both_text:
if event.text:
existing.text = _append_text(existing.text, event.text)
existing.absorb_message_ids(event)
existing.absorb_reply_context(event)
existing.absorb_reply_expected(event)
return
pending_messages[session_key] = event
Expand Down Expand Up @@ -2584,6 +2588,8 @@ def _enqueue_text_event(self, event: "MessageEvent") -> None:
if event.media_urls:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
existing.absorb_message_ids(event)
existing.absorb_reply_context(event)
existing.absorb_reply_expected(event)
existing._last_chunk_len = len(event.text or "") # type: ignore[attr-defined]
prior_task = self._pending_text_batch_tasks.get(key)
Expand Down Expand Up @@ -3528,6 +3534,25 @@ def pop_post_delivery_callback(
_OK_EMOJI: Optional[str] = None
_FAIL_EMOJI: Optional[str] = None

async def prepare_turn_context(
self, event: MessageEvent, *, origin: Optional[SessionSource],
acknowledged_state: Optional[Dict[str, Any]], first_turn: bool,
) -> Optional[TurnContextUpdate]:
"""Report context for this turn: changes to the chat since the conversation last
acknowledged its state, and earlier messages that a new session has not seen.

The gateway calls this while it prepares every inbound turn. ``origin`` is the session's
origin source, or ``None`` before the session exists. ``acknowledged_state`` is the
``channel_state`` saved with the most recent transcript row that has one.
``first_turn`` is true when the session transcript is empty. Return ``None`` to add no note
and leave the saved state unchanged.

For an adapter that overrides this hook, the session-context prompt keeps the chat name,
topic and user name from the session origin, so a rename does not rewrite the system prompt
of a running conversation. An override must therefore report name and topic changes in its
note."""
return None

async def on_processing_start(self, event: MessageEvent) -> None:
"""Hook called when background processing begins."""

Expand Down Expand Up @@ -3823,6 +3848,13 @@ def _is_queue_text_debounce_candidate(self, event: MessageEvent) -> bool:
return result

def _can_merge_text_debounce_events(self, existing: MessageEvent, event: MessageEvent) -> bool:
"""Return True when two text debounce events came from the same sender and do not reply
to different messages."""
return (self._same_text_debounce_sender(existing, event)
and not existing.reply_context_conflicts(event))

@staticmethod
def _same_text_debounce_sender(existing: MessageEvent, event: MessageEvent) -> bool:
"""Return True when two text debounce events came from the same sender."""

def _identity(candidate: MessageEvent) -> tuple[str, ...] | None:
Expand Down Expand Up @@ -3861,21 +3893,39 @@ async def _queue_text_debounce(self, session_key: str, event: MessageEvent) -> N
existing_pending = self._pending_messages.get(session_key)
if existing_pending is not None and self._can_merge_text_debounce_events(existing_pending, event):
merge_pending_message_event(self._pending_messages, session_key, event, merge_text=True)
return
return
if not self._same_text_debounce_sender(state.event, event):
return
logger.debug("[%s] Busy text for %s replies to a third message; merging it into the "
"debounce buffer, which keeps its own reply context", self.name, session_key)
now = time.monotonic()
if state is None:
state = TextDebounceState(event=event, task=None, first_ts=now, last_ts=now)
store[session_key] = state
else:
if event.text:
state.event.text = _append_text(state.event.text, event.text)
if event.media_urls:
state.event.media_text_inlined.extend(
[None] * (len(state.event.media_urls) - len(state.event.media_text_inlined))
)
state.event.media_urls.extend(event.media_urls)
state.event.media_types.extend(event.media_types)
state.event.media_text_inlined.extend(event.media_text_inlined)
state.event.media_text_inlined.extend(
[None] * (len(state.event.media_urls) - len(state.event.media_text_inlined))
)
state.event.absorb_reply_context(event)
state.event.absorb_reply_expected(event)
latest_message_id = getattr(event, "message_id", None)
latest_anchor = latest_message_id or getattr(event, "reply_to_message_id", None)
if latest_message_id is not None:
state.event.merged_message_ids.extend(
message_id for message_id in (state.event.message_id, *event.merged_message_ids)
if message_id
)
state.event.message_id = str(latest_message_id)
if latest_anchor is not None and hasattr(state.event, "reply_to_message_id"):
state.event.reply_to_message_id = str(latest_anchor)
else:
state.event.absorb_message_ids(event)
state.last_ts = now
state.cancel_timer()
delay = self._text_debounce_delay(session_key)
Expand Down
46 changes: 46 additions & 0 deletions gateway/platforms/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ class ProcessingOutcome(Enum):
CANCELLED = "cancelled"


@dataclass(frozen=True)
class TurnContextUpdate:
"""What ``BasePlatformAdapter.prepare_turn_context`` reports for one turn.

``note`` is prepended to the user message. ``channel_state`` is saved with the user transcript
row, so the change is acknowledged only when the turn that reported it is saved. It is ``None``
when the adapter could not read the chat state, and the saved state then stays unchanged.
"""
note: Optional[str]
channel_state: Optional[Dict[str, Any]]


@dataclass
class MessageEvent:
"""Incoming message from a platform — the normalized shape all adapters produce."""
Expand Down Expand Up @@ -101,6 +113,14 @@ class MessageEvent:
# knows the message was meant for someone else); None means unknown and keeps the visible
# fallback, like True.
reply_expected: Optional[bool] = None
# Snapshot from ``BasePlatformAdapter.prepare_turn_context``. The user transcript row saves it,
# and the saved snapshot is the baseline for the adapter's next comparison.
channel_state: Optional[Dict[str, Any]] = None
# Whether the quoted author passed the adapter's authorisation check; None when the adapter
# did not check. The reply pointer identifies the author only when this is set.
reply_to_author_authorized: Optional[bool] = None
# IDs of other events represented in this turn.
merged_message_ids: List[str] = field(default_factory=list)

# Process-local admission receipt, never routing metadata or execution acknowledgement.
_gateway_accepted: bool = field(default=False, init=False, repr=False, compare=False)
Expand All @@ -112,6 +132,11 @@ def absorb_reply_expected(self, other: "MessageEvent") -> None:
if self.reply_expected is not True and other.reply_expected is not False:
self.reply_expected = other.reply_expected

def absorb_message_ids(self, other: "MessageEvent") -> None:
self.merged_message_ids.extend(
message_id for message_id in (other.message_id, *other.merged_message_ids) if message_id
)

def _command_text(self) -> str:
"""Return the message text with leading Desktop attachment refs stripped.

Expand All @@ -122,6 +147,27 @@ def _command_text(self) -> str:
"""
return _ATTACHMENT_REF_RE.sub("", (self.text or "").lstrip()).lstrip()

def _replies_to_message(self) -> bool:
return bool(self.reply_to_message_id or self.reply_to_text)

def _reply_context(self) -> tuple:
return (self.reply_to_message_id, self.reply_to_text, self.reply_to_author_id,
self.reply_to_author_name, bool(self.reply_to_is_own_message), self.reply_to_author_authorized)

def reply_context_conflicts(self, other: "MessageEvent") -> bool:
"""True when both events reply to a message and their reply contexts differ. A merged
event has room for only one reply context."""
return (self._replies_to_message() and other._replies_to_message()
and self._reply_context() != other._reply_context())

def absorb_reply_context(self, other: "MessageEvent") -> None:
"""One turn now answers *other* too: take its reply context if this event has none."""
if self._replies_to_message() or not other._replies_to_message():
return
(self.reply_to_message_id, self.reply_to_text, self.reply_to_author_id,
self.reply_to_author_name, self.reply_to_is_own_message,
self.reply_to_author_authorized) = other._reply_context()

def is_command(self) -> bool:
"""Check if this is a command message (e.g., /new, /reset)."""
return self.allow_gateway_control and self._command_text().startswith("/")
Expand Down
4 changes: 3 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4207,7 +4207,7 @@ def _set_session_env(self, context: SessionContext) -> list:
from gateway.session_context import set_session_vars
# Async-delivery capability tells async tools whether this channel can wake a later turn. Default
# True keeps CLI/unknown paths working; stateless adapters (api_server) declare False.
_adapter = (getattr(self, "adapters", None) or {}).get(context.source.platform)
_adapter = self._delivery_adapter_for(context.source)
_async_delivery = getattr(_adapter, "supports_async_delivery", True)
return set_session_vars(
platform=context.source.platform.value,
Expand All @@ -4224,6 +4224,8 @@ def _set_session_env(self, context: SessionContext) -> list:
message_id=str(context.source.message_id) if context.source.message_id else "",
profile=getattr(context.source, "profile", "") or "",
async_delivery=_async_delivery,
transport_adapter=_adapter,
transport_loop=getattr(self, "_gateway_loop", None),
cron_session="")

def _clear_session_env(self, tokens: list) -> None:
Expand Down
Loading