@securecoms/agent-bridge 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1436 @@
1
+ # packages/connect-agent-bridge/connectors/hermes/tests/test_busy_session.py
2
+ #
3
+ # H0 (audit r1 #3, r2 #1, #2): a Hermes tool decision for a (chat_id, user_id)
4
+ # key can never be lifted while an ungranted turn for that key is still
5
+ # running, or still waiting to run, anywhere in its Hermes session.
6
+ #
7
+ # The fake below is COPIED, branch for branch, from Hermes v2026.9.24 as
8
+ # installed on the Mini (~/.hermes/hermes-agent-v2026.9.24, read 2026-11-11;
9
+ # md5 run_busy.py 9b9052966ef5605ac3d8b63f6f602248, platforms/base.py
10
+ # 6d3da1360844e3043361369f4b89a80f, run_inbound.py
11
+ # 4f490dcbfd4349abd649260573af2083, session.py
12
+ # 53677a3689bf100b9f4202fa46656cc8). Text-only subset: no media, no clarify,
13
+ # no topics, no profile routing, no busy-ack replies.
14
+ # - build_session_key: gateway/session.py:673-718, copied VERBATIM below
15
+ # (with _session_key_namespace 644-654 and _canonical_participant
16
+ # 664-670); only Platform.WHATSAPP / SLACK and the WhatsApp canonicaliser
17
+ # are shimmed. A DM is keyed by chat only, plus the thread when one is set.
18
+ # - The session store keeps one transcript per session key (session.py:885,
19
+ # :923, :1040-1143); Runner.transcripts models it.
20
+ # - HermesBase: gateway/platforms/base.py
21
+ # merge_pending_message_event 1739-1781 (text merge only)
22
+ # _event_session_key 2488 -> build_session_key (gateway/session.py
23
+ # 673-713: a DM is keyed by chat only; a group adds the sender when
24
+ # group_sessions_per_user, default True)
25
+ # debounce 3757-3832, guard/task helpers 3836-3888,
26
+ # cancel_session_processing 3890-3914, /stop /new /reset 3916-3948,
27
+ # handle_message 3950-3990, _handle_message_while_active 3992-4065,
28
+ # _finish_session_task 4414-4436, _process_message_background
29
+ # 4438-4557 (on_processing_complete fires ONCE per started task, for
30
+ # the starting event only, BEFORE the debounce flush and the pending
31
+ # pop), _spawn_drain_task 4560-4567, _cleanup_finished_session_task
32
+ # 4573-4590.
33
+ # - Runner.busy_session_handler: gateway/run_busy.py 767-860
34
+ # (_handle_active_session_busy_message): unauthorized drop 782-788,
35
+ # bot-loop drop 790-791, drain drop 795-797, queue-mode text to the
36
+ # base debounce 809-814, steer/redirect 816-819 via
37
+ # _resolve_busy_steer_or_redirect 559-610 (awaits the compression check
38
+ # at 575), FIFO queue 822-823 via _queue_or_replace_pending_event
39
+ # 358-406 (cap drop 398-404, _BUSY_QUEUE_MAX_PENDING run.py:4034) and
40
+ # _enqueue_fifo 88-97, interrupt 836-840.
41
+ # - Runner.__call__ (the gateway message handler): gateway/run_inbound.py
42
+ # 1295-1390: a session the RUNNER holds (turn.agent set) goes to
43
+ # _hm_handle_running_session_message 675-731 even when the base adapter
44
+ # started a task for this event (sentinel -> merged into the adapter's
45
+ # pending slot 690-697; queue / steer / redirect / interrupt); else the
46
+ # slot is claimed with the pending sentinel (1343), the turn runs, and
47
+ # the slot is released in the finally (1387) BEFORE the base hook runs.
48
+ # The durable marker token is set only for the turn's own event
49
+ # (run_turn.py:2090 -> run_inbound.py:1754-1767) and deleted at turn end
50
+ # (1793).
51
+ # - FIFO overflow promotion modelled at turn end (run_busy.py:100-116
52
+ # _promote_queued_event); /new clears the overflow
53
+ # (run_agent_cache.py:369 conversation.clear()) and the running slot
54
+ # (slash_commands_session.py:163).
55
+
56
+ import asyncio
57
+ import importlib.util
58
+ import logging
59
+ import os
60
+ import types
61
+ from collections import defaultdict
62
+ from enum import Enum
63
+ from typing import Optional
64
+
65
+ import pytest
66
+
67
+ _HERE = os.path.dirname(os.path.abspath(__file__))
68
+ _spec = importlib.util.spec_from_file_location(
69
+ "_securecoms_wiring_helpers", os.path.join(_HERE, "test_adapter_wiring.py")
70
+ )
71
+ _w = importlib.util.module_from_spec(_spec)
72
+ _spec.loader.exec_module(_w)
73
+ adapter = _w.adapter
74
+ authz = adapter.securecoms_authz
75
+ MessageType = adapter.MessageType
76
+
77
+ SENTINEL = object() # gateway.run._AGENT_PENDING_SENTINEL
78
+
79
+ # --- gateway/session.py, Hermes v2026.9.24 (shims: Platform members, WhatsApp) ---
80
+ Platform = types.SimpleNamespace(WHATSAPP=object(), SLACK=object())
81
+
82
+
83
+ def canonical_whatsapp_identifier(x):
84
+ return x
85
+
86
+
87
+ def _session_key_namespace(profile: Optional[str]) -> str:
88
+ if not profile or profile == "default":
89
+ return "agent:main"
90
+ return "agent:main~" if profile == "main" else f"agent:{profile}"
91
+
92
+
93
+ def _canonical_participant(source) -> Optional[str]:
94
+ """Sender id for key isolation; WhatsApp JID/LID aliases are canonicalized so alias flips
95
+ cannot split one member into two sessions."""
96
+ participant_id = source.user_id_alt or source.user_id
97
+ if participant_id and source.platform == Platform.WHATSAPP:
98
+ participant_id = canonical_whatsapp_identifier(str(participant_id)) or participant_id
99
+ return participant_id
100
+
101
+
102
+ def build_session_key(
103
+ source, group_sessions_per_user: bool = True,
104
+ thread_sessions_per_user: bool = False, profile: Optional[str] = None,
105
+ ) -> str:
106
+ is_dm = source.chat_type == "dm"
107
+ chat_id = source.chat_id
108
+ if is_dm and source.platform == Platform.WHATSAPP:
109
+ chat_id = canonical_whatsapp_identifier(chat_id)
110
+ # Discord auto-thread continuity: key a channel-initiating message on the thread it WILL be
111
+ # delivered into (prospective_thread_id), and normalize the chat_type slot to "thread" so
112
+ # in-thread follow-ups byte-match. A real thread_id always wins. DMs use thread_id only.
113
+ thread_id = source.thread_id or (None if is_dm else source.prospective_thread_id)
114
+ chat_type_slot = "thread" if thread_id and not source.thread_id else source.chat_type
115
+ if is_dm:
116
+ # No chat_id: fall back to the sender id before the bare per-platform sink, or every
117
+ # chat_id-less DM shares one agent.
118
+ isolate_user = not chat_id
119
+ else:
120
+ # Threads are shared by default; per-user isolation only via thread_sessions_per_user or
121
+ # outside a thread.
122
+ isolate_user = group_sessions_per_user and not (thread_id and not thread_sessions_per_user)
123
+ # Duck-typed sources may lack user_id_alt: read the participant only when it matters.
124
+ participant_id = _canonical_participant(source) if (isolate_user or not is_dm) else None
125
+
126
+ parts = [_session_key_namespace(profile), source.platform.value, chat_type_slot]
127
+ if source.platform == Platform.SLACK and source.scope_id:
128
+ parts.append(str(source.scope_id))
129
+ if chat_id:
130
+ parts.append(chat_id)
131
+ # DMs put the participant before the thread; groups/threads put it after.
132
+ user_part = [str(participant_id)] if isolate_user and participant_id else []
133
+ thread_part = [thread_id] if thread_id else []
134
+ parts += user_part + thread_part if is_dm else thread_part + user_part
135
+ return ":".join(str(part) for part in parts)
136
+ # --- end of the verbatim copy ---
137
+
138
+
139
+ class ProcessingOutcome(Enum):
140
+ SUCCESS = "success"
141
+ FAILURE = "failure"
142
+ CANCELLED = "cancelled"
143
+
144
+
145
+ def merge_pending_message_event(pending, key, event, *, merge_text=False):
146
+ """base.py:1739-1781, text-only events."""
147
+ existing = pending.get(key)
148
+ if existing:
149
+ both_text = existing.message_type == MessageType.TEXT and event.message_type == MessageType.TEXT
150
+ if merge_text and both_text:
151
+ if event.text:
152
+ existing.text = existing.text + "\n" + event.text
153
+ return
154
+ pending[key] = event
155
+
156
+
157
+ class _TurnState:
158
+ def __init__(self):
159
+ self.agent = None
160
+ self.event = None
161
+
162
+
163
+ class _Conversation:
164
+ def __init__(self):
165
+ self.queued_events = []
166
+
167
+
168
+ class SessionState:
169
+ def __init__(self):
170
+ self.turn = _TurnState()
171
+ self.conversation = _Conversation()
172
+
173
+
174
+ class Turn:
175
+ def __init__(self, event, key):
176
+ self.event = event
177
+ self.key = key
178
+ self.text = event.text
179
+ self.injected = [] # (verb, text) steered or redirected into this run
180
+ self.done = False
181
+ self.interrupted = False
182
+
183
+
184
+ class Agent:
185
+ _supports_active_turn_redirect = True
186
+
187
+ def __init__(self, turn, runner):
188
+ self.turn = turn
189
+ self.runner = runner
190
+
191
+ def redirect(self, text):
192
+ self.turn.injected.append(("redirect", text))
193
+ self.runner.transcripts[self.turn.key].append(text)
194
+ return True
195
+
196
+ def steer(self, text):
197
+ self.turn.injected.append(("steer", text))
198
+ self.runner.transcripts[self.turn.key].append(text)
199
+ return True
200
+
201
+ def interrupt(self, text):
202
+ self.turn.interrupted = True
203
+ self.runner.release(self.turn.event.message_id)
204
+
205
+
206
+ def _text_only(event):
207
+ return event.message_type == MessageType.TEXT and not event.media_urls
208
+
209
+
210
+ class Runner:
211
+ """GatewayRunner: message handler, busy-session handler, session state."""
212
+
213
+ BUSY_QUEUE_MAX_PENDING = 32
214
+
215
+ def __init__(self, a):
216
+ self.a = a
217
+ self.sessions = {}
218
+ self.gates = {}
219
+ self.turns = {}
220
+ self.busy_input_mode = "interrupt"
221
+ self.draining = False
222
+ self.unauthorized = set()
223
+ self.dropped = []
224
+ self.compression_check = None # test hook at run_busy.py:575's await
225
+ self.transcripts = defaultdict(list) # session key -> user texts (session.py store)
226
+ self.skip_promotion = False # an early-exit turn that orphans the overflow (#28503)
227
+ self.allowed_users = set()
228
+
229
+ def admits(self, source):
230
+ """authz_mixin.py:614-680: admission reads source.user_id (allowlist subset)."""
231
+ return source.user_id in self.allowed_users
232
+
233
+ # run.py:3455-3470
234
+ def _session_state(self, key):
235
+ return self.sessions.setdefault(key, SessionState())
236
+
237
+ def _peek_session_state(self, key):
238
+ return self.sessions.get(key)
239
+
240
+ def _session_key_for_source(self, source):
241
+ return self.a._source_session_key(source)
242
+
243
+ def gate(self, mid):
244
+ return self.gates.setdefault(mid, asyncio.Event())
245
+
246
+ def release(self, mid):
247
+ self.gate(mid).set()
248
+
249
+ def turn(self, mid):
250
+ return self.turns.get(mid)
251
+
252
+ async def __call__(self, event):
253
+ key = self._session_key_for_source(event.source)
254
+ st = self._peek_session_state(key)
255
+ if st is not None and st.turn.agent is not None: # run_inbound.py:1303-1305
256
+ return await self._running_session_message(event, key)
257
+ st = self._session_state(key)
258
+ st.turn.agent = SENTINEL # :1343
259
+ st.turn.event = event
260
+ try:
261
+ turn = Turn(event, key)
262
+ self.turns[event.message_id] = turn
263
+ self.transcripts[key].append(event.text)
264
+ st.turn.agent = Agent(turn, self)
265
+ event._gateway_active_turn_token = "tok-" + event.message_id # run_turn.py:2090
266
+ try:
267
+ await self.gate(event.message_id).wait()
268
+ finally:
269
+ del event._gateway_active_turn_token # run_inbound.py:1793
270
+ turn.done = True
271
+ # run_busy.py:100-116: the overflow head moves into the drained slot.
272
+ if not self.skip_promotion and st.conversation.queued_events and key not in self.a._pending_messages:
273
+ self.a._pending_messages[key] = st.conversation.queued_events.pop(0)
274
+ finally:
275
+ st.turn.agent = None # :1387 _release_running_agent_state
276
+ st.turn.event = None
277
+ return None
278
+
279
+ async def _running_session_message(self, event, key):
280
+ """run_inbound.py:675-731."""
281
+ running_agent = self._peek_session_state(key).turn.agent
282
+ if running_agent is SENTINEL: # 690-697
283
+ merge_pending_message_event(self.a._pending_messages, key, event, merge_text=True)
284
+ return None
285
+ if self.busy_input_mode == "queue":
286
+ self._queue_or_replace_pending_event(key, event)
287
+ return None
288
+ if self.busy_input_mode == "steer":
289
+ if _text_only(event) and event.text and running_agent.steer(event.text):
290
+ return None
291
+ self._queue_or_replace_pending_event(key, event)
292
+ return None
293
+ if _text_only(event) and running_agent.redirect(event.text): # _hm_busy_interrupt 650-660
294
+ return None
295
+ running_agent.interrupt(event.text)
296
+ merge_pending_message_event(self.a._pending_messages, key, event, merge_text=True)
297
+ return None
298
+
299
+ def _queue_depth(self, key):
300
+ """run_busy.py:118-123."""
301
+ st = self._peek_session_state(key)
302
+ depth = len(st.conversation.queued_events) if st else 0
303
+ return depth + (1 if key in self.a._pending_messages else 0)
304
+
305
+ def _enqueue_fifo(self, key, event):
306
+ """run_busy.py:88-97."""
307
+ if key in self.a._pending_messages:
308
+ self._session_state(key).conversation.queued_events.append(event)
309
+ else:
310
+ self.a._pending_messages[key] = event
311
+ event._gateway_accepted = True
312
+
313
+ def _queue_or_replace_pending_event(self, key, event):
314
+ """run_busy.py:358-406 (no photo bursts here)."""
315
+ if self._queue_depth(key) >= self.BUSY_QUEUE_MAX_PENDING: # 398-404
316
+ self.dropped.append(event.message_id)
317
+ return
318
+ self._enqueue_fifo(key, event)
319
+
320
+ async def busy_session_handler(self, event, key):
321
+ """run_busy.py:767-860 _handle_active_session_busy_message."""
322
+ if event.source.user_id in self.unauthorized: # 782-788
323
+ self.dropped.append(event.message_id)
324
+ return True
325
+ effective_mode = self.busy_input_mode # 794
326
+ if self.draining: # 795-797
327
+ self.dropped.append(event.message_id)
328
+ return True
329
+ if (
330
+ event.message_type == MessageType.TEXT
331
+ and getattr(self.a, "_busy_text_mode", "interrupt") == "queue"
332
+ and effective_mode != "steer"
333
+ ): # 809-814
334
+ return False
335
+ st = self._peek_session_state(key)
336
+ running_agent = st.turn.agent if st else None
337
+ # _resolve_busy_steer_or_redirect 559-610
338
+ if self.compression_check is not None: # the await at 575
339
+ await self.compression_check()
340
+ else:
341
+ await asyncio.sleep(0)
342
+ steered = redirected = False
343
+ agent_live = running_agent is not None and running_agent is not SENTINEL
344
+ if effective_mode == "steer":
345
+ if event.text and _text_only(event) and agent_live:
346
+ steered = running_agent.steer(event.text)
347
+ if not steered:
348
+ effective_mode = "queue"
349
+ elif effective_mode == "interrupt" and _text_only(event) and agent_live:
350
+ redirected = running_agent.redirect(event.text)
351
+ if not steered and not redirected: # 822-823
352
+ self._queue_or_replace_pending_event(key, event)
353
+ if effective_mode == "interrupt" and not redirected and agent_live: # 836-840
354
+ running_agent.interrupt(event.text)
355
+ return True
356
+
357
+ async def inline_command(self, event, cmd):
358
+ key = self._session_key_for_source(event.source)
359
+ if cmd in ("new", "reset"):
360
+ st = self._peek_session_state(key)
361
+ if st is not None:
362
+ st.conversation.queued_events.clear() # run_agent_cache.py:369
363
+ st.turn.agent = None # slash_commands_session.py:163
364
+
365
+
366
+ class HermesBase:
367
+ """gateway/platforms/base.py, the paths this adapter's sessions take."""
368
+
369
+ _busy_text_mode = "interrupt"
370
+
371
+ def _hermes_init(self):
372
+ self._active_sessions = {}
373
+ self._pending_messages = {}
374
+ self._session_tasks = {}
375
+ self._background_tasks = set()
376
+ self._expected_cancelled_tasks = set()
377
+ self._text_debounce = {}
378
+ self._busy_session_handler = None
379
+ self.inline_commands = []
380
+
381
+ def _source_session_key(self, source):
382
+ """base.py:2492-2499."""
383
+ extra = self.config.extra
384
+ return build_session_key(
385
+ source, group_sessions_per_user=extra.get("group_sessions_per_user", True),
386
+ thread_sessions_per_user=extra.get("thread_sessions_per_user", False), profile=None)
387
+
388
+ def _event_session_key(self, event):
389
+ return self._source_session_key(event.source)
390
+
391
+ def _text_debounce_store(self):
392
+ return self._text_debounce
393
+
394
+ async def _run_processing_hook(self, hook_name, *args):
395
+ hook = getattr(self, hook_name, None)
396
+ if not callable(hook):
397
+ return
398
+ try:
399
+ await hook(*args)
400
+ except Exception:
401
+ pass
402
+
403
+ async def on_processing_start(self, event):
404
+ pass
405
+
406
+ # 3757-3832 (the timer is long: tests flush at completion, as 4520 does)
407
+ def _is_queue_text_debounce_candidate(self, event):
408
+ return self._busy_text_mode == "queue" and event.message_type == MessageType.TEXT
409
+
410
+ async def _queue_text_debounce(self, key, event):
411
+ state = self._text_debounce.get(key)
412
+ if state is None:
413
+ self._text_debounce[key] = types.SimpleNamespace(event=event)
414
+ else:
415
+ state.event.text = state.event.text + "\n" + event.text
416
+
417
+ async def _flush_text_debounce_now(self, key):
418
+ state = self._text_debounce.get(key)
419
+ if state is None:
420
+ return False
421
+ pending = self._pending_messages.get(key)
422
+ if pending is not None:
423
+ return False
424
+ self._text_debounce.pop(key, None)
425
+ merge_pending_message_event(self._pending_messages, key, state.event, merge_text=True)
426
+ return True
427
+
428
+ def _discard_text_debounce(self, key):
429
+ self._text_debounce.pop(key, None)
430
+
431
+ # 3836-3888
432
+ def _release_session_guard(self, key, *, guard=None):
433
+ current = self._active_sessions.get(key)
434
+ if current is None or (guard is not None and current is not guard):
435
+ return
436
+ del self._active_sessions[key]
437
+
438
+ def _session_task_is_stale(self, key):
439
+ done = getattr(self._session_tasks.get(key), "done", None)
440
+ return bool(done and done())
441
+
442
+ def _heal_stale_session_lock(self, key):
443
+ if key not in self._active_sessions or not self._session_task_is_stale(key):
444
+ return False
445
+ self._active_sessions.pop(key, None)
446
+ self._pending_messages.pop(key, None)
447
+ self._session_tasks.pop(key, None)
448
+ self._discard_text_debounce(key)
449
+ return True
450
+
451
+ def _start_session_processing(self, event, key):
452
+ guard = asyncio.Event()
453
+ self._active_sessions[key] = guard
454
+ task = asyncio.create_task(self._process_message_background(event, key))
455
+ self._track_session_task(key, task)
456
+ return True
457
+
458
+ def _track_session_task(self, key, task):
459
+ self._session_tasks[key] = task
460
+ self._background_tasks.add(task)
461
+ task.add_done_callback(self._background_tasks.discard)
462
+ task.add_done_callback(self._expected_cancelled_tasks.discard)
463
+ return True
464
+
465
+ # 3890-3914
466
+ async def cancel_session_processing(self, key, *, release_guard=True, discard_pending=True):
467
+ task = self._session_tasks.pop(key, None)
468
+ if task is not None and not task.done():
469
+ self._expected_cancelled_tasks.add(task)
470
+ task.cancel()
471
+ await asyncio.gather(task, return_exceptions=True)
472
+ if discard_pending:
473
+ self._pending_messages.pop(key, None)
474
+ self._discard_text_debounce(key)
475
+ if release_guard:
476
+ self._release_session_guard(key)
477
+
478
+ # 3916-3948
479
+ async def _drain_pending_after_session_command(self, key, command_guard):
480
+ await self._flush_text_debounce_now(key)
481
+ pending_event = self._pending_messages.pop(key, None)
482
+ self._release_session_guard(key, guard=command_guard)
483
+ if pending_event is not None:
484
+ self._start_session_processing(pending_event, key)
485
+
486
+ async def _dispatch_active_session_command(self, event, key, cmd):
487
+ command_guard = asyncio.Event()
488
+ self._active_sessions[key] = command_guard
489
+ self.inline_commands.append(cmd)
490
+ await self.gateway_runner.inline_command(event, cmd)
491
+ await self.cancel_session_processing(key, release_guard=False, discard_pending=False)
492
+ await self._drain_pending_after_session_command(key, command_guard)
493
+
494
+ # 3950-3990
495
+ async def handle_message(self, event):
496
+ event._gateway_accepted = False
497
+ if not self._message_handler:
498
+ return
499
+ key = self._event_session_key(event)
500
+ if key in self._active_sessions:
501
+ self._heal_stale_session_lock(key)
502
+ if key in self._active_sessions:
503
+ await self._handle_message_while_active(event, key)
504
+ return
505
+ event._gateway_accepted = self._start_session_processing(event, key)
506
+
507
+ # 3992-4065
508
+ async def _handle_message_while_active(self, event, key):
509
+ text = event.text or ""
510
+ cmd = text[1:].split(" ")[0] if text.startswith("/") else None
511
+ if cmd in ("stop", "new", "reset"):
512
+ self._discard_text_debounce(key)
513
+ await self._dispatch_active_session_command(event, key, cmd)
514
+ return
515
+ if self._busy_session_handler is not None:
516
+ if await self._busy_session_handler(event, key):
517
+ return
518
+ if self._is_queue_text_debounce_candidate(event):
519
+ await self._queue_text_debounce(key, event)
520
+ return
521
+ merge_pending_message_event(
522
+ self._pending_messages, key, event, merge_text=event.message_type == MessageType.TEXT
523
+ )
524
+ event._gateway_accepted = True
525
+
526
+ # 4414-4436
527
+ def _finish_session_task(self, key, interrupt_event):
528
+ late_pending = self._pending_messages.pop(key, None)
529
+ current = asyncio.current_task()
530
+ if late_pending is not None:
531
+ existing = self._session_tasks.get(key)
532
+ if existing is not None and existing is not current:
533
+ self._pending_messages[key] = late_pending
534
+ else:
535
+ self._spawn_drain_task(late_pending, key)
536
+ elif current is not None and self._session_tasks.get(key) is current:
537
+ self._cleanup_finished_session_task(key, interrupt_event)
538
+
539
+ # 4438-4557
540
+ async def _process_message_background(self, event, key):
541
+ interrupt_event = self._active_sessions.get(key) or asyncio.Event()
542
+ self._active_sessions[key] = interrupt_event
543
+ try:
544
+ await self._run_processing_hook("on_processing_start", event)
545
+ await self._message_handler(event)
546
+ await self._run_processing_hook("on_processing_complete", event, ProcessingOutcome.SUCCESS)
547
+ await self._flush_text_debounce_now(key)
548
+ if key in self._pending_messages:
549
+ pending_event = self._pending_messages.pop(key)
550
+ self._clear_session_guard(key)
551
+ self._spawn_drain_task(pending_event, key)
552
+ return
553
+ except asyncio.CancelledError:
554
+ expected = asyncio.current_task() in self._expected_cancelled_tasks
555
+ await self._run_processing_hook(
556
+ "on_processing_complete", event,
557
+ ProcessingOutcome.CANCELLED if expected else ProcessingOutcome.FAILURE)
558
+ raise
559
+ except BaseException:
560
+ await self._run_processing_hook("on_processing_complete", event, ProcessingOutcome.FAILURE)
561
+ finally:
562
+ await self._flush_text_debounce_now(key)
563
+ self._finish_session_task(key, interrupt_event)
564
+
565
+ def _spawn_drain_task(self, pending_event, key):
566
+ self._clear_session_guard(key)
567
+ self._track_session_task(key, asyncio.create_task(self._process_message_background(pending_event, key)))
568
+
569
+ def _clear_session_guard(self, key):
570
+ guard = self._active_sessions.get(key)
571
+ if guard is not None:
572
+ guard.clear()
573
+
574
+ def _cleanup_finished_session_task(self, key, interrupt_event):
575
+ self._release_session_guard(key, guard=interrupt_event)
576
+ if key not in self._active_sessions:
577
+ self._session_tasks.pop(key, None)
578
+
579
+ async def cancel_background_tasks(self):
580
+ for _ in range(5):
581
+ tasks = [t for t in self._background_tasks if not t.done()]
582
+ if not tasks:
583
+ return
584
+ for t in tasks:
585
+ t.cancel()
586
+ await asyncio.gather(*tasks, return_exceptions=True)
587
+
588
+
589
+ class _RecWS:
590
+ def __init__(self):
591
+ self.sent = []
592
+ self.closed = False
593
+
594
+ async def send_json(self, payload):
595
+ self.sent.append(payload)
596
+
597
+
598
+ # ---------------------------------------------------------------------------
599
+ # Driver
600
+ # ---------------------------------------------------------------------------
601
+
602
+ GRANTS = [{"tool": "current_time", "mode": "read"}]
603
+ NON_OWNER = authz.NON_OWNER_THREAD
604
+
605
+
606
+ def _auth(audience, grants, **kw):
607
+ block = {"version": 1, "audience": audience, "sender_membership": "active", "tool_grants": list(grants)}
608
+ block.update(kw)
609
+ return block
610
+
611
+
612
+ def owner_relay(mid, text, chan="dm"):
613
+ aud = "other_human_dm" if chan == "dm" else "team_room"
614
+ return {"type": "message", "message_id": mid, "channel_id": chan, "sender_id": "relay", "text": text,
615
+ "authorization": _auth(aud, GRANTS, relay_speaker="owner")}
616
+
617
+
618
+ def guest_relay(mid, text, chan="dm"):
619
+ aud = "other_human_dm" if chan == "dm" else "team_room"
620
+ return {"type": "message", "message_id": mid, "channel_id": chan, "sender_id": "relay", "text": text,
621
+ "authorization": _auth(aud, [])}
622
+
623
+
624
+ def owner_direct(mid, text, chan="dm", grants=GRANTS):
625
+ return {"type": "message", "message_id": mid, "channel_id": chan, "sender_id": "owner", "text": text,
626
+ "authorization": _auth("owner_dm", grants)}
627
+
628
+
629
+ def no_block(mid, text, chan="dm", sender="stranger"):
630
+ return {"type": "message", "message_id": mid, "channel_id": chan, "sender_id": sender, "text": text}
631
+
632
+
633
+ async def settle(n=40):
634
+ for _ in range(n):
635
+ await asyncio.sleep(0)
636
+
637
+
638
+ @pytest.fixture
639
+ def gate_state(monkeypatch):
640
+ monkeypatch.setattr(adapter, "_TURN_AUTHZ", authz.TurnAuthzLedger())
641
+ monkeypatch.setattr(adapter, "_ATTACHED", {})
642
+ monkeypatch.setattr(adapter, "_IDLE_INPUTS_WARNED", False)
643
+ monkeypatch.setattr(adapter, "_SHARED_ROOMS_WARNED", False)
644
+ monkeypatch.setattr(adapter, "_TOOL_VETO_ACTIVE", True)
645
+ monkeypatch.setattr(adapter, "_TURN_TRACKING_WARNED", False, raising=False)
646
+ monkeypatch.setattr(adapter, "_ACCEPT_WAIT_S", 30.0)
647
+
648
+
649
+ @pytest.fixture(params=["shared", "threaded"])
650
+ def threaded(request, monkeypatch):
651
+ """shared: H0.2c's non-owner thread switched off (every DM sender shares one
652
+ key and one session, the HEAD condition); threaded: H0.2c on."""
653
+ if request.param == "shared":
654
+ monkeypatch.setattr(authz, "is_owner_turn", lambda frame: True)
655
+ return request.param == "threaded"
656
+
657
+
658
+ def make(extra=None, mode="interrupt"):
659
+ class Faithful(HermesBase, adapter.SecureComsAdapter):
660
+ pass
661
+
662
+ a = Faithful(types.SimpleNamespace(extra=dict(extra or {})))
663
+ a._hermes_init()
664
+ a._bound_identity = "A"
665
+ a._channels = {"room": {"name": "room", "is_dm": False}, "dm": {"name": "dm", "is_dm": True}}
666
+ a._ws = _RecWS()
667
+ r = Runner(a)
668
+ r.busy_input_mode = mode
669
+ a.set_message_handler(r)
670
+ a.gateway_runner = r
671
+ a._busy_session_handler = r.busy_session_handler
672
+ return a, r
673
+
674
+
675
+ async def teardown(a):
676
+ for t in list(a._ack_waiters):
677
+ t.cancel()
678
+ await asyncio.gather(*list(a._ack_waiters), return_exceptions=True)
679
+ await a.cancel_background_tasks()
680
+
681
+
682
+ async def deliver(a, frame):
683
+ await a._receive_message(frame)
684
+ await settle()
685
+
686
+
687
+ def tools(key):
688
+ return adapter._TURN_AUTHZ.tools_allowed(*key)
689
+
690
+
691
+ def inflight(key):
692
+ return adapter._TURN_AUTHZ.ungranted_in_flight(*key)
693
+
694
+
695
+ OWNER_SESSION = "agent:main:securecoms:dm:dm"
696
+ GUEST_SESSION = "agent:main:securecoms:dm:dm:sc-non-owner"
697
+ UNCLASSIFIED_SESSION = "agent:main:securecoms:dm:dm:sc-unclassified"
698
+ UNCLASSIFIED = getattr(authz, "UNCLASSIFIED_THREAD", "sc-unclassified")
699
+ OWNER_RELAY = ("dm", "relay", None)
700
+ ROOM = ("room", "relay", None) # a room guest relay key
701
+ SHARED = ("dm", "relay", None) # a DM with the H0.2c split off: owner and guest relay share key and session
702
+
703
+
704
+ def guest_key(threaded_on):
705
+ return ("dm", "relay", NON_OWNER if threaded_on else None)
706
+
707
+
708
+ def guest_session(threaded_on):
709
+ return GUEST_SESSION if threaded_on else OWNER_SESSION
710
+
711
+
712
+ # ---------------------------------------------------------------------------
713
+ # H0.1c: redirect, steer, merge (each with and without the non-owner thread)
714
+ # ---------------------------------------------------------------------------
715
+
716
+
717
+ class TestRedirect:
718
+ @pytest.mark.asyncio
719
+ async def test_guest_text_redirected_into_a_running_owner_turn(self, gate_state, threaded):
720
+ a, r = make()
721
+ guest = guest_key(threaded)
722
+ await deliver(a, owner_relay("o1", "owner speaks"))
723
+ t_owner = r.turn("o1")
724
+ assert t_owner is not None and tools(OWNER_RELAY)
725
+ await deliver(a, guest_relay("g1", "guest speaks"))
726
+ if not threaded:
727
+ # One key, one session: the guest's text is inside the owner's run.
728
+ assert ("redirect", authz.COMMAND_ESCAPE + "guest speaks") in t_owner.injected
729
+ assert not tools(OWNER_RELAY)
730
+ await deliver(a, owner_relay("o2", "owner again")) # a later grant for the same key
731
+ assert not tools(OWNER_RELAY)
732
+ r.release("o1")
733
+ await settle()
734
+ assert t_owner.done
735
+ assert tools(OWNER_RELAY) and inflight(guest) == 0
736
+ else:
737
+ assert t_owner.injected == []
738
+ t_guest = r.turn("g1")
739
+ assert t_guest is not None and t_guest.key == GUEST_SESSION != t_owner.key
740
+ assert tools(OWNER_RELAY) and not tools(guest)
741
+ r.release("g1")
742
+ await settle()
743
+ assert inflight(guest) == 0 and not tools(guest)
744
+ await teardown(a)
745
+
746
+ @pytest.mark.asyncio
747
+ async def test_owner_text_redirected_into_a_running_guest_turn(self, gate_state, threaded):
748
+ a, r = make()
749
+ guest = guest_key(threaded)
750
+ await deliver(a, guest_relay("g1", "guest speaks"))
751
+ t_guest = r.turn("g1")
752
+ assert t_guest is not None and not tools(guest)
753
+ await deliver(a, owner_relay("o1", "owner speaks"))
754
+ if not threaded:
755
+ assert ("redirect", "owner speaks") in t_guest.injected
756
+ # The latest decision for the key is the owner's grant; the turn
757
+ # still carries the guest's text, so it has no tools.
758
+ assert not tools(OWNER_RELAY)
759
+ r.release("g1")
760
+ await settle()
761
+ assert tools(OWNER_RELAY) and inflight(guest) == 0
762
+ else:
763
+ assert t_guest.injected == []
764
+ assert r.turn("o1").key == OWNER_SESSION
765
+ assert tools(OWNER_RELAY) and not tools(guest)
766
+ r.release("g1")
767
+ await settle()
768
+ assert inflight(guest) == 0
769
+ await teardown(a)
770
+
771
+
772
+ class TestSteer:
773
+ @pytest.mark.asyncio
774
+ async def test_guest_text_steered_into_a_running_owner_turn(self, gate_state, threaded):
775
+ a, r = make(mode="steer")
776
+ guest = guest_key(threaded)
777
+ await deliver(a, owner_relay("o1", "owner speaks"))
778
+ t_owner = r.turn("o1")
779
+ await deliver(a, guest_relay("g1", "guest speaks"))
780
+ if not threaded:
781
+ assert ("steer", authz.COMMAND_ESCAPE + "guest speaks") in t_owner.injected
782
+ assert not tools(OWNER_RELAY)
783
+ r.release("o1")
784
+ await settle()
785
+ assert inflight(guest) == 0
786
+ else:
787
+ assert t_owner.injected == []
788
+ assert tools(OWNER_RELAY) and not tools(guest)
789
+ r.release("g1")
790
+ await settle()
791
+ assert inflight(guest) == 0
792
+ await teardown(a)
793
+
794
+ @pytest.mark.asyncio
795
+ async def test_owner_text_steered_into_a_running_guest_turn(self, gate_state, threaded):
796
+ a, r = make(mode="steer")
797
+ guest = guest_key(threaded)
798
+ await deliver(a, guest_relay("g1", "guest speaks"))
799
+ t_guest = r.turn("g1")
800
+ await deliver(a, owner_relay("o1", "owner speaks"))
801
+ if not threaded:
802
+ assert ("steer", "owner speaks") in t_guest.injected
803
+ assert not tools(OWNER_RELAY)
804
+ else:
805
+ assert t_guest.injected == []
806
+ assert tools(OWNER_RELAY)
807
+ r.release("g1")
808
+ await settle()
809
+ assert inflight(guest) == 0
810
+ await teardown(a)
811
+
812
+
813
+ class TestPendingSentinelMerge:
814
+ @pytest.mark.asyncio
815
+ async def test_a_guest_event_parked_behind_the_sentinel_is_held_until_its_turn_ends_and_idle(
816
+ self, gate_state, threaded
817
+ ):
818
+ a, r = make()
819
+ guest = guest_key(threaded)
820
+ session = guest_session(threaded)
821
+ # The runner holds the guest session's slot with the pending sentinel
822
+ # (a resumed session, run_startup.py:607) while the base adapter is
823
+ # idle for it: the guest event starts its own base task, the runner
824
+ # parks it in the pending slot (run_inbound.py:697), and that base
825
+ # task completes at once.
826
+ r._session_state(session).turn.agent = SENTINEL
827
+ await deliver(a, guest_relay("g1", "guest speaks"))
828
+ assert r.turn("g1") is None
829
+ assert inflight(guest) == 1 and not tools(guest)
830
+ r._session_state(session).turn.agent = None # the resumed turn ends
831
+ await settle()
832
+ t_guest = r.turn("g1")
833
+ assert t_guest is not None and not t_guest.done # the parked text runs now
834
+ assert inflight(guest) == 1
835
+ await deliver(a, owner_relay("o1", "owner speaks"))
836
+ if not threaded:
837
+ assert ("redirect", "owner speaks") in t_guest.injected
838
+ assert not tools(OWNER_RELAY) # owner's grant recorded, guest's text still running
839
+ else:
840
+ assert tools(OWNER_RELAY)
841
+ r.release("g1")
842
+ await settle()
843
+ assert inflight(guest) == 0
844
+ if not threaded:
845
+ assert tools(OWNER_RELAY)
846
+ await teardown(a)
847
+
848
+ @pytest.mark.asyncio
849
+ async def test_a_guest_event_queued_behind_a_sentinel_turn_waits_for_the_pending_turn(self, gate_state):
850
+ # Base-level variant: the running turn holds the sentinel when the
851
+ # guest arrives, so the guest is queued in the pending slot; it is
852
+ # released only after the pending turn that carries it completes,
853
+ # not at the completion of the turn that was running when it arrived.
854
+ a, r = make()
855
+ session = "agent:main:securecoms:group:room:relay"
856
+ await deliver(a, guest_relay("g0", "first guest", chan="room"))
857
+ r.sessions[session].turn.agent = SENTINEL # agent still building
858
+ await deliver(a, guest_relay("g1", "second guest", chan="room"))
859
+ r.sessions[session].turn.agent = Agent(r.turn("g0"), r)
860
+ assert inflight(ROOM) == 2
861
+ r.release("g0")
862
+ await settle()
863
+ assert r.turn("g0").done
864
+ t1 = r.turn("g1")
865
+ assert t1 is not None and not t1.done
866
+ assert inflight(ROOM) == 2 # g0's completion found g1 pending: not idle
867
+ r.release("g1")
868
+ await settle()
869
+ assert inflight(ROOM) == 0
870
+ await teardown(a)
871
+
872
+
873
+ # ---------------------------------------------------------------------------
874
+ # H0.1c: drops, discards, later grants. A DM with the H0.2c split switched
875
+ # off: owner and guest relay share one key and one session (the HEAD
876
+ # condition), the case the count alone must hold. (The daemon marks
877
+ # relay_speaker only in DMs, so no room has a granted relay key.)
878
+ # ---------------------------------------------------------------------------
879
+
880
+
881
+ @pytest.fixture
882
+ def shared_dm(monkeypatch):
883
+ monkeypatch.setattr(authz, "dm_thread", lambda frame: None)
884
+
885
+
886
+ class TestDropsAndDiscards:
887
+ @pytest.mark.asyncio
888
+ async def test_queue_cap_drop_while_the_session_goes_idle_releases_at_once(self, gate_state, shared_dm):
889
+ a, r = make(mode="queue")
890
+ r.BUSY_QUEUE_MAX_PENDING = 0 # the cap drop needs no backlog
891
+
892
+ async def owner_turn_finishes():
893
+ r.release("o1")
894
+ await settle()
895
+
896
+ await deliver(a, owner_relay("o1", "owner speaks"))
897
+ r.compression_check = owner_turn_finishes
898
+ await a._receive_message(guest_relay("g1", "guest speaks"))
899
+ assert r.dropped == ["g1"]
900
+ assert inflight(SHARED) == 0 # not accepted, session idle: released at once
901
+ await teardown(a)
902
+
903
+ @pytest.mark.asyncio
904
+ async def test_queue_cap_drop_while_busy_waits_for_idle(self, gate_state, shared_dm):
905
+ a, r = make(mode="queue")
906
+ r.BUSY_QUEUE_MAX_PENDING = 0
907
+ await deliver(a, owner_relay("o1", "owner speaks"))
908
+ await deliver(a, guest_relay("g1", "guest speaks"))
909
+ assert r.dropped == ["g1"]
910
+ assert inflight(SHARED) == 1
911
+ r.release("o1")
912
+ await settle()
913
+ assert inflight(SHARED) == 0
914
+ await teardown(a)
915
+
916
+ @pytest.mark.asyncio
917
+ async def test_unauthorized_drop_while_busy_waits_for_idle(self, gate_state, shared_dm):
918
+ a, r = make()
919
+ await deliver(a, owner_relay("o1", "owner speaks")) # admitted on the cold path (not modelled)
920
+ r.unauthorized.add("relay")
921
+ await deliver(a, guest_relay("g1", "guest speaks"))
922
+ assert r.dropped == ["g1"]
923
+ assert inflight(SHARED) == 1
924
+ r.release("o1")
925
+ await settle()
926
+ assert inflight(SHARED) == 0
927
+ await teardown(a)
928
+
929
+ @pytest.mark.asyncio
930
+ async def test_stop_then_the_pending_guest_turn_runs_and_releases_at_idle(self, gate_state, shared_dm):
931
+ a, r = make(mode="queue")
932
+ await deliver(a, owner_relay("o1", "owner speaks"))
933
+ await deliver(a, guest_relay("g1", "guest speaks")) # queued in the pending slot
934
+ assert inflight(SHARED) == 1
935
+ await deliver(a, owner_relay("o2", "/stop"))
936
+ assert a.inline_commands == ["stop"]
937
+ assert r.turn("o1").done
938
+ t_guest = r.turn("g1")
939
+ assert t_guest is not None and not t_guest.done
940
+ assert inflight(SHARED) == 1 and not tools(SHARED)
941
+ r.release("g1")
942
+ await settle()
943
+ assert inflight(SHARED) == 0
944
+ await teardown(a)
945
+
946
+ @pytest.mark.asyncio
947
+ async def test_new_discards_a_queued_guest_event_released_at_the_next_idle_completion(self, gate_state, shared_dm):
948
+ a, r = make(mode="queue")
949
+ await deliver(a, owner_relay("o1", "owner speaks"))
950
+ await deliver(a, owner_relay("o2", "owner follow-up")) # pending slot head
951
+ await deliver(a, guest_relay("g1", "guest speaks")) # FIFO overflow
952
+ assert inflight(SHARED) == 1
953
+ await deliver(a, owner_relay("o3", "/new"))
954
+ assert r.turn("o1").done
955
+ assert r.turn("g1") is None # discarded by /new
956
+ assert inflight(SHARED) == 1 # the cancelled turn's completion found o2 pending
957
+ r.release("o2")
958
+ await settle()
959
+ assert inflight(SHARED) == 0
960
+ await teardown(a)
961
+
962
+ @pytest.mark.asyncio
963
+ async def test_a_later_granted_owner_turn_never_releases_an_attached_count(self, gate_state, shared_dm):
964
+ a, r = make(mode="queue")
965
+ await deliver(a, owner_relay("o1", "owner speaks"))
966
+ await deliver(a, guest_relay("g1", "guest speaks"))
967
+ await deliver(a, owner_relay("o2", "owner again")) # queued behind g1, grant recorded
968
+ assert adapter._TURN_AUTHZ._entries[("dm", "relay", "")] is True
969
+ assert inflight(SHARED) == 1 and not tools(SHARED)
970
+ r.release("o1")
971
+ await settle()
972
+ assert inflight(SHARED) == 1 and not tools(SHARED) # g1 runs now
973
+ r.release("g1")
974
+ await settle()
975
+ assert r.turn("o2") is not None and not r.turn("o2").done
976
+ assert inflight(SHARED) == 1 and not tools(SHARED) # o2 still to finish: not idle
977
+ r.release("o2")
978
+ await settle()
979
+ assert inflight(SHARED) == 0 and tools(SHARED)
980
+ await teardown(a)
981
+
982
+
983
+ # ---------------------------------------------------------------------------
984
+ # Audit F3: one test per idle-predicate clause
985
+ # ---------------------------------------------------------------------------
986
+
987
+
988
+ class TestIdleClauses:
989
+ @pytest.mark.asyncio
990
+ async def test_a_runner_held_live_turn_with_no_adapter_task_keeps_the_count(self, gate_state):
991
+ # turn.agent clause: a resumed runner turn is live in the session but
992
+ # the adapter has no task for it. The guest event starts its own base
993
+ # task, the runner redirects its text into the live turn, and the base
994
+ # task completes at once.
995
+ a, r = make()
996
+ session = "agent:main:securecoms:group:room:relay"
997
+ resumed = Turn(types.SimpleNamespace(message_id="resumed", text="resumed turn"), session)
998
+ r._session_state(session).turn.agent = Agent(resumed, r)
999
+ await deliver(a, guest_relay("g1", "guest speaks", chan="room"))
1000
+ assert ("redirect", authz.COMMAND_ESCAPE + "guest speaks") in resumed.injected
1001
+ assert a._session_tasks.get(session) is None # the guest's own base task is done
1002
+ assert inflight(ROOM) == 1 # still inside the resumed turn
1003
+ r._session_state(session).turn.agent = None
1004
+ await deliver(a, guest_relay("g2", "guest again", chan="room"))
1005
+ r.release("g2")
1006
+ await settle()
1007
+ assert inflight(ROOM) == 0 # the next idle completion clears it
1008
+ await teardown(a)
1009
+
1010
+ @pytest.mark.asyncio
1011
+ async def test_an_orphaned_runner_fifo_keeps_the_count(self, gate_state):
1012
+ # queued_events clause: an early-exit turn leaves the overflow behind
1013
+ # with the pending slot empty (#28503).
1014
+ a, r = make(mode="queue")
1015
+ await deliver(a, guest_relay("g0", "zero", chan="room"))
1016
+ await deliver(a, guest_relay("g1", "one", chan="room")) # pending slot
1017
+ await deliver(a, guest_relay("g2", "two", chan="room")) # overflow
1018
+ session = "agent:main:securecoms:group:room:relay"
1019
+ assert [e.message_id for e in r.sessions[session].conversation.queued_events] == ["g2"]
1020
+ r.release("g0")
1021
+ await settle()
1022
+ r.skip_promotion = True
1023
+ r.release("g1")
1024
+ await settle()
1025
+ assert a._pending_messages == {} and r.sessions[session].conversation.queued_events
1026
+ assert inflight(ROOM) == 3 # g2 still waits in the runner FIFO
1027
+ await teardown(a)
1028
+
1029
+ @pytest.mark.asyncio
1030
+ async def test_debounced_text_keeps_the_count(self, gate_state):
1031
+ # debounce clause: busy_text_mode=queue buffers the guest text; the
1032
+ # running turn's completion fires BEFORE the debounce flush.
1033
+ a, r = make()
1034
+ a._busy_text_mode = "queue"
1035
+ await deliver(a, guest_relay("g0", "zero", chan="room"))
1036
+ await deliver(a, guest_relay("g1", "one", chan="room"))
1037
+ session = "agent:main:securecoms:group:room:relay"
1038
+ assert session in a._text_debounce
1039
+ r.release("g0")
1040
+ await settle()
1041
+ t1 = r.turn("g1")
1042
+ assert t1 is not None and not t1.done
1043
+ assert inflight(ROOM) == 2
1044
+ r.release("g1")
1045
+ await settle()
1046
+ assert inflight(ROOM) == 0
1047
+ await teardown(a)
1048
+
1049
+ @pytest.mark.asyncio
1050
+ async def test_a_just_started_task_keeps_the_count(self, gate_state):
1051
+ # live-task clause: right after handle_message returns, the guest's
1052
+ # own task exists but has not run (no runner turn yet).
1053
+ a, r = make()
1054
+ await a._receive_message(guest_relay("g1", "guest speaks", chan="room"))
1055
+ assert inflight(ROOM) == 1
1056
+ await settle()
1057
+ assert inflight(ROOM) == 1
1058
+ r.release("g1")
1059
+ await settle()
1060
+ assert inflight(ROOM) == 0
1061
+ await teardown(a)
1062
+
1063
+ @pytest.mark.asyncio
1064
+ async def test_a_stop_command_guard_keeps_the_count_until_a_later_completion(self, gate_state, shared_dm):
1065
+ # command-guard clause: /stop installs its guard, then cancels the
1066
+ # running turn; that turn's completion sees a guard with no owner task.
1067
+ a, r = make()
1068
+ await deliver(a, owner_relay("o1", "owner speaks"))
1069
+ await deliver(a, guest_relay("g1", "guest speaks")) # redirected into o1
1070
+ assert inflight(SHARED) == 1
1071
+ await deliver(a, owner_relay("o2", "/stop"))
1072
+ assert r.turn("o1").done and a._pending_messages == {}
1073
+ assert inflight(SHARED) == 1 # next-turn-clears case (stated in the PR)
1074
+ await deliver(a, owner_relay("o3", "owner again"))
1075
+ r.release("o3")
1076
+ await settle()
1077
+ assert inflight(SHARED) == 0
1078
+ await teardown(a)
1079
+
1080
+
1081
+ # ---------------------------------------------------------------------------
1082
+ # H0.2d: non-owner text never enters the owner session
1083
+ # ---------------------------------------------------------------------------
1084
+
1085
+
1086
+ class TestOwnerSessionIsolation:
1087
+ @pytest.mark.asyncio
1088
+ @pytest.mark.parametrize("order", ["owner_first", "guest_first"])
1089
+ async def test_classification_of_dm_turns(self, gate_state, order):
1090
+ a, r = make()
1091
+ frames = [owner_direct("o1", "typed"), owner_relay("o2", "voice"), guest_relay("g1", "guest"),
1092
+ no_block("x1", "nobody")] # x1: unclassified (audit r2)
1093
+ if order == "guest_first":
1094
+ frames.reverse()
1095
+ for f in frames: # each turn finishes before the next arrives
1096
+ await deliver(a, f)
1097
+ r.release(f["message_id"])
1098
+ await settle()
1099
+ ev = {mid: r.turn(mid).event for mid in ("o1", "o2", "g1", "x1")}
1100
+ assert ev["o1"].source.thread_id is None and ev["o2"].source.thread_id is None
1101
+ assert r.turn("o1").key == r.turn("o2").key == OWNER_SESSION
1102
+ assert ev["g1"].source.thread_id == NON_OWNER and ev["x1"].source.thread_id == UNCLASSIFIED
1103
+ assert r.turn("g1").key == GUEST_SESSION and r.turn("x1").key == UNCLASSIFIED_SESSION
1104
+ await teardown(a)
1105
+
1106
+ @pytest.mark.asyncio
1107
+ async def test_an_owners_ungranted_turn_stays_in_the_owner_session(self, gate_state):
1108
+ a, r = make()
1109
+ await deliver(a, owner_direct("o1", "typed", grants=[])) # grants kill switch
1110
+ assert r.turn("o1").key == OWNER_SESSION
1111
+ assert inflight(("dm", "owner", None)) == 1
1112
+ await teardown(a)
1113
+
1114
+ @pytest.mark.asyncio
1115
+ @pytest.mark.parametrize("order", ["owner_first", "guest_first"])
1116
+ async def test_a_guest_canary_never_reaches_the_owner_transcript(self, gate_state, order):
1117
+ a, r = make()
1118
+ canary = "CANARY-guest-7f3a"
1119
+ first, second = (owner_direct("o1", "what time is it"), guest_relay("g1", canary))
1120
+ if order == "guest_first":
1121
+ first, second = second, first
1122
+ await deliver(a, first)
1123
+ await deliver(a, second)
1124
+ assert r.turn("o1") is not None and r.turn("g1") is not None
1125
+ assert OWNER_SESSION not in a._pending_messages and OWNER_SESSION not in a._text_debounce
1126
+ st = r._peek_session_state(OWNER_SESSION)
1127
+ assert st is None or st.conversation.queued_events == []
1128
+ r.release("o1")
1129
+ r.release("g1")
1130
+ await settle()
1131
+ assert not any(canary in t for t in r.transcripts[OWNER_SESSION])
1132
+ assert any(canary in t for t in r.transcripts[GUEST_SESSION])
1133
+ await teardown(a)
1134
+
1135
+ @pytest.mark.asyncio
1136
+ async def test_owner_tools_stay_available_while_a_guest_turn_is_in_flight(self, gate_state):
1137
+ a, r = make()
1138
+ await deliver(a, owner_relay("o1", "owner speaks"))
1139
+ await deliver(a, guest_relay("g1", "guest speaks"))
1140
+ assert inflight(("dm", "relay", NON_OWNER)) == 1
1141
+ env = {"HERMES_SESSION_PLATFORM": "securecoms", "HERMES_SESSION_CHAT_ID": "dm",
1142
+ "HERMES_SESSION_USER_ID": "relay", "HERMES_SESSION_THREAD_ID": ""}
1143
+ assert authz.pre_tool_call_decision(lambda n, d="": env[n], adapter._TURN_AUTHZ) is None
1144
+ assert a.toolsets_for_source(r.turn("o1").event.source) is None
1145
+ await teardown(a)
1146
+
1147
+ @pytest.mark.asyncio
1148
+ async def test_a_tool_call_bound_to_the_guest_session_is_blocked(self, gate_state):
1149
+ a, r = make()
1150
+ await deliver(a, guest_relay("g1", "guest speaks"))
1151
+ r.release("g1")
1152
+ await settle() # the guest's count is released: the key itself must refuse
1153
+ await deliver(a, owner_relay("o1", "owner speaks")) # the LATEST record: granted (dm, relay, "")
1154
+ assert tools(OWNER_RELAY)
1155
+ env = {"HERMES_SESSION_PLATFORM": "securecoms", "HERMES_SESSION_CHAT_ID": "dm",
1156
+ "HERMES_SESSION_USER_ID": "relay", "HERMES_SESSION_THREAD_ID": NON_OWNER}
1157
+ assert authz.pre_tool_call_decision(lambda n, d="": env[n], adapter._TURN_AUTHZ)["action"] == "block"
1158
+ assert a.toolsets_for_source(r.turn("g1").event.source) == list(authz.NO_TOOLS_TOOLSETS)
1159
+ await teardown(a)
1160
+
1161
+ @pytest.mark.asyncio
1162
+ async def test_a_granted_frame_recorded_under_the_non_owner_thread_is_forced_off(self, gate_state):
1163
+ adapter._TURN_AUTHZ.record("dm", "relay", True, NON_OWNER)
1164
+ assert not tools(("dm", "relay", NON_OWNER))
1165
+
1166
+ @pytest.mark.asyncio
1167
+ async def test_a_no_block_dm_frame_is_an_unclassified_turn(self, gate_state):
1168
+ a, r = make()
1169
+ await deliver(a, no_block("x1", "hello", sender="owner")) # even the owner's id, without a block
1170
+ assert r.turn("x1").event.source.thread_id == UNCLASSIFIED
1171
+ assert r.turn("x1").key == UNCLASSIFIED_SESSION
1172
+ await teardown(a)
1173
+
1174
+ @pytest.mark.asyncio
1175
+ @pytest.mark.parametrize("order", ["owner_first", "stale_first"])
1176
+ async def test_a_stale_roster_owner_frame_runs_in_the_unclassified_session(self, gate_state, order):
1177
+ # Audit r2: the owner's text during a stale roster (sender_membership
1178
+ # "unknown") lands in neither the guest transcript nor the owner's.
1179
+ a, r = make()
1180
+ stale = owner_direct("s1", "STALE-owner-91c")
1181
+ stale["authorization"]["sender_membership"] = "unknown"
1182
+ frames = [stale, guest_relay("g1", "CANARY-guest-4d2"), owner_direct("o1", "fresh owner")]
1183
+ if order == "stale_first":
1184
+ frames = [frames[0], frames[2], frames[1]]
1185
+ for f in frames:
1186
+ await deliver(a, f)
1187
+ r.release(f["message_id"])
1188
+ await settle()
1189
+ assert r.turn("s1").event.source.thread_id == UNCLASSIFIED
1190
+ assert r.turn("g1").event.source.thread_id == NON_OWNER
1191
+ assert r.turn("o1").event.source.thread_id is None
1192
+ assert any("STALE-owner-91c" in t for t in r.transcripts[UNCLASSIFIED_SESSION])
1193
+ assert not any("STALE-owner-91c" in t for t in r.transcripts[GUEST_SESSION] + r.transcripts[OWNER_SESSION])
1194
+ assert not any("CANARY-guest-4d2" in t for t in r.transcripts[UNCLASSIFIED_SESSION] + r.transcripts[OWNER_SESSION])
1195
+ assert not tools(("dm", "owner", UNCLASSIFIED))
1196
+ await teardown(a)
1197
+
1198
+ @pytest.mark.asyncio
1199
+ @pytest.mark.parametrize("cache", ["empty", "refresh_failed", "stale_says_room"])
1200
+ async def test_a_dm_block_splits_even_when_the_channel_cache_misses(self, gate_state, cache):
1201
+ # Audit r2: the block says DM (owner_dm / other_human_dm / relay
1202
+ # mark), so the split applies whatever the channel cache says.
1203
+ a, r = make()
1204
+ if cache == "empty":
1205
+ a._channels = {}
1206
+ elif cache == "refresh_failed":
1207
+ a._channels = {}
1208
+ a._session = object()
1209
+
1210
+ async def failing_refresh():
1211
+ return None
1212
+
1213
+ a._refresh_channels = failing_refresh
1214
+ else:
1215
+ a._channels = {"dm": {"name": "dm", "is_dm": False}}
1216
+ await deliver(a, owner_relay("o1", "owner speaks"))
1217
+ await deliver(a, guest_relay("g1", "CANARY-guest-cache"))
1218
+ assert r.turn("o1").event.source.chat_type == "dm"
1219
+ assert r.turn("g1").event.source.thread_id == NON_OWNER
1220
+ assert r.turn("g1").key == GUEST_SESSION != r.turn("o1").key
1221
+ r.release("o1")
1222
+ r.release("g1")
1223
+ await settle()
1224
+ assert not any("CANARY-guest-cache" in t for t in r.transcripts[OWNER_SESSION])
1225
+ await teardown(a)
1226
+
1227
+ @pytest.mark.asyncio
1228
+ async def test_send_with_thread_metadata_posts_to_the_channel_only(self, gate_state):
1229
+ a, r = make()
1230
+ sess = _w.FakeSession(post_response=(200, {"message_id": "m9"}))
1231
+ a._session = sess
1232
+ a.bridge_url = "http://127.0.0.1:9"
1233
+ res = await a.send("dm", "reply", metadata={"thread_id": NON_OWNER})
1234
+ assert res.success
1235
+ assert sess.post_calls[0][2] == {"channel_id": "dm", "text": "reply"}
1236
+
1237
+ @pytest.mark.asyncio
1238
+ async def test_admission_is_by_user_id_so_both_relay_turns_are_admitted(self, gate_state):
1239
+ a, r = make()
1240
+ r.allowed_users = {"relay"} # SECURECOMS_ALLOWED_USERS lists only the relay
1241
+ await deliver(a, owner_relay("o1", "owner speaks"))
1242
+ await deliver(a, guest_relay("g1", "guest speaks"))
1243
+ assert r.admits(r.turn("o1").event.source) and r.admits(r.turn("g1").event.source)
1244
+ await teardown(a)
1245
+
1246
+
1247
+ # ---------------------------------------------------------------------------
1248
+ # H0.3, audit F2, F8
1249
+ # ---------------------------------------------------------------------------
1250
+
1251
+
1252
+ class TestReleaseBookkeeping:
1253
+ @pytest.mark.asyncio
1254
+ async def test_a_second_completion_for_one_started_event_releases_once(self, gate_state):
1255
+ a, r = make()
1256
+ await deliver(a, guest_relay("g1", "guest speaks", chan="room"))
1257
+ adapter._TURN_AUTHZ.begin_ungranted(*ROOM) # some other in-flight count
1258
+ assert inflight(ROOM) == 2
1259
+ ev = r.turn("g1").event
1260
+ r.release("g1")
1261
+ await settle()
1262
+ assert inflight(ROOM) == 1
1263
+ await a.on_processing_complete(ev, ProcessingOutcome.SUCCESS)
1264
+ assert inflight(ROOM) == 1
1265
+ await teardown(a)
1266
+
1267
+ @pytest.mark.asyncio
1268
+ async def test_a_dispatch_exception_before_handle_message_releases(self, gate_state):
1269
+ a, r = make()
1270
+
1271
+ def boom(event):
1272
+ raise RuntimeError("session lookup failed")
1273
+
1274
+ a._session_was_busy = boom
1275
+ with pytest.raises(RuntimeError):
1276
+ await a._receive_message(guest_relay("g1", "guest speaks", chan="room"))
1277
+ assert inflight(ROOM) == 0
1278
+ assert adapter._ATTACHED == {}
1279
+ await teardown(a)
1280
+
1281
+ @pytest.mark.asyncio
1282
+ async def test_a_handle_message_exception_keeps_the_count_until_idle(self, gate_state):
1283
+ a, r = make()
1284
+
1285
+ async def broken(event):
1286
+ raise RuntimeError("hermes failed mid-intake")
1287
+
1288
+ await deliver(a, guest_relay("g0", "running", chan="room"))
1289
+ a.handle_message = broken
1290
+ with pytest.raises(RuntimeError):
1291
+ await a._receive_message(guest_relay("g1", "guest speaks", chan="room"))
1292
+ assert inflight(ROOM) == 2 # session busy (g0 runs): attached, not released
1293
+ r.release("g0")
1294
+ await settle()
1295
+ assert inflight(ROOM) == 0
1296
+ await teardown(a)
1297
+
1298
+ @pytest.mark.asyncio
1299
+ async def test_a_granted_turn_alone_keeps_todays_behaviour(self, gate_state):
1300
+ a, r = make()
1301
+ await deliver(a, owner_direct("o1", "hi"))
1302
+ assert tools(("dm", "owner", None))
1303
+ assert inflight(("dm", "owner", None)) == 0
1304
+ assert a.toolsets_for_source(r.turn("o1").event.source) is None
1305
+ r.release("o1")
1306
+ await settle()
1307
+ assert tools(("dm", "owner", None))
1308
+ assert adapter._ATTACHED == {}
1309
+ await teardown(a)
1310
+
1311
+ @pytest.mark.asyncio
1312
+ async def test_two_concurrent_ungranted_turns_need_two_releases(self, gate_state):
1313
+ a, r = make(mode="queue")
1314
+ await deliver(a, guest_relay("g1", "one", chan="room"))
1315
+ await deliver(a, guest_relay("g2", "two", chan="room"))
1316
+ assert inflight(ROOM) == 2
1317
+ r.release("g1")
1318
+ await settle()
1319
+ assert r.turn("g2") is not None
1320
+ assert inflight(ROOM) == 2
1321
+ r.release("g2")
1322
+ await settle()
1323
+ assert inflight(ROOM) == 0
1324
+ await teardown(a)
1325
+
1326
+ @pytest.mark.asyncio
1327
+ async def test_an_owner_dm_key_is_unaffected_by_a_room_key(self, gate_state):
1328
+ a, r = make()
1329
+ await deliver(a, guest_relay("g1", "guest speaks", chan="room"))
1330
+ await deliver(a, owner_direct("o1", "hi"))
1331
+ assert inflight(ROOM) == 1
1332
+ assert tools(("dm", "owner", None))
1333
+ await teardown(a)
1334
+
1335
+ @pytest.mark.asyncio
1336
+ async def test_an_unreadable_idle_input_counts_as_busy(self, gate_state):
1337
+ a, r = make()
1338
+ await deliver(a, guest_relay("g1", "guest speaks", chan="room"))
1339
+
1340
+ def broken(key):
1341
+ raise RuntimeError("state unreadable")
1342
+
1343
+ r._peek_session_state = broken
1344
+ r.release("g1")
1345
+ await settle()
1346
+ assert inflight(ROOM) == 1 # fail closed: never released on an unreadable input
1347
+ await teardown(a)
1348
+
1349
+ @pytest.mark.asyncio
1350
+ async def test_an_acceptance_timeout_releases_nothing(self, gate_state, monkeypatch):
1351
+ monkeypatch.setattr(adapter, "_ACCEPT_WAIT_S", 0.05)
1352
+ a, r = make()
1353
+ a.gateway_runner = None # no runner state: idle cannot be proven
1354
+ await deliver(a, guest_relay("g1", "guest speaks", chan="room"))
1355
+ await asyncio.sleep(0.1)
1356
+ assert inflight(ROOM) == 1
1357
+ await teardown(a)
1358
+
1359
+ @pytest.mark.asyncio
1360
+ async def test_a_replaced_adapter_releases_a_count_whose_task_never_ran(self, gate_state):
1361
+ # F2: the guest's task is cancelled before its first step (no hook
1362
+ # ever fires for it) and the adapter instance is replaced; the next
1363
+ # idle completion in the same session, on the new instance, releases it.
1364
+ a1, r1 = make()
1365
+ session = "agent:main:securecoms:group:room:relay"
1366
+ await a1._receive_message(guest_relay("g1", "guest speaks", chan="room"))
1367
+ task = a1._session_tasks[session]
1368
+ task.cancel()
1369
+ await asyncio.gather(task, return_exceptions=True)
1370
+ assert r1.turn("g1") is None and inflight(ROOM) == 1
1371
+ await teardown(a1)
1372
+ a2, r2 = make()
1373
+ await deliver(a2, guest_relay("g2", "guest again", chan="room"))
1374
+ assert inflight(ROOM) == 2
1375
+ r2.release("g2")
1376
+ await settle()
1377
+ assert inflight(ROOM) == 0
1378
+ await teardown(a2)
1379
+
1380
+ @pytest.mark.asyncio
1381
+ async def test_a_missing_idle_input_warns_once(self, gate_state, caplog):
1382
+ a, r = make()
1383
+ a.gateway_runner = None
1384
+ with caplog.at_level(logging.WARNING, logger=adapter.logger.name):
1385
+ await deliver(a, guest_relay("g1", "one", chan="room"))
1386
+ await deliver(a, guest_relay("g2", "two", chan="dm"))
1387
+ lines = [rec for rec in caplog.records if "idle state" in rec.getMessage()]
1388
+ assert len(lines) == 1 and lines[0].args == ()
1389
+ await teardown(a)
1390
+
1391
+
1392
+ # ---------------------------------------------------------------------------
1393
+ # H0.2b: group_sessions_per_user off -> room turns ungranted
1394
+ # ---------------------------------------------------------------------------
1395
+
1396
+
1397
+ class TestSessionSeparationGuard:
1398
+ async def _connect(self, a, monkeypatch):
1399
+ monkeypatch.setattr(adapter, "resolve_bound_identity", lambda *args: None)
1400
+ assert await a.connect() is False # stops at the (absent) binding
1401
+
1402
+ @pytest.mark.asyncio
1403
+ async def test_off_makes_every_room_turn_ungranted_and_logs_once(self, gate_state, monkeypatch, caplog):
1404
+ # Defence in depth: today the daemon marks relay_speaker only in DMs,
1405
+ # so no room frame carries a grant the connector honours. Simulate a
1406
+ # granted room relay frame by not reading the block's DM mark.
1407
+ monkeypatch.setattr(authz, "block_says_dm", lambda frame: False)
1408
+ a, r = make(extra={"group_sessions_per_user": False})
1409
+ with caplog.at_level(logging.WARNING, logger=adapter.logger.name):
1410
+ await self._connect(a, monkeypatch)
1411
+ await self._connect(a, monkeypatch)
1412
+ a2, _ = make(extra={"group_sessions_per_user": False})
1413
+ await self._connect(a2, monkeypatch)
1414
+ assert len([rec for rec in caplog.records if "group_sessions_per_user" in rec.getMessage()]) == 1
1415
+ await deliver(a, owner_relay("o1", "owner speaks", chan="room"))
1416
+ ev = r.turn("o1").event
1417
+ assert ev.metadata["securecoms"]["tools_allowed"] is False
1418
+ assert not tools(ROOM)
1419
+ assert a.toolsets_for_source(ev.source) == list(authz.NO_TOOLS_TOOLSETS)
1420
+ # A DM is unaffected.
1421
+ await deliver(a, owner_direct("d1", "hi"))
1422
+ assert tools(("dm", "owner", None))
1423
+ await teardown(a)
1424
+
1425
+ @pytest.mark.asyncio
1426
+ @pytest.mark.parametrize("extra", [{}, {"group_sessions_per_user": True}])
1427
+ async def test_absent_or_true_is_unchanged(self, gate_state, monkeypatch, caplog, extra):
1428
+ monkeypatch.setattr(authz, "block_says_dm", lambda frame: False) # as in the test above
1429
+ a, r = make(extra=extra)
1430
+ with caplog.at_level(logging.WARNING, logger=adapter.logger.name):
1431
+ await self._connect(a, monkeypatch)
1432
+ assert not any("group_sessions_per_user" in rec.getMessage() for rec in caplog.records)
1433
+ await deliver(a, owner_relay("o1", "owner speaks", chan="room"))
1434
+ assert tools(ROOM)
1435
+ assert a.toolsets_for_source(r.turn("o1").event.source) is None
1436
+ await teardown(a)