# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors # # SPDX-License-Identifier: Apache-2.0 """Hub correctness invariants — registration, concurrency, dispatch, projection. Covers: * ``Hub.unregister`` deletes on-disk identity files so ``hydrate()`` does not re-load unregistered agents. * Concurrency caps + inbox limits enforced (``max_concurrent_channels`` / ``max_concurrent_tasks`` / ``InboxBlock.max_pending``). * ``delegate`` pre-creates the inbox before sending so a fast reply cannot be dropped (race fix). * ``Hub.register`` rejects a duplicate ``name`` so the prior passport / resume / rule are not orphaned on disk. * ``delegate`` fails fast on channel close / reject / expiry rather than blocking until the 300s default timeout. * Expectation dedup keyed by ``(index, name, violator)`` so two same-named expectations don't suppress each other. * ``record_observation`` is idempotent per ``task_id`` so cascade- terminal events don't double-count ``Resume.observed.n``. * ``set_resume`` re-indexes ``claimed_capabilities`` so newly claimed caps surface under ``peers(action="find", capability=...)``. """ import asyncio import contextlib import json import logging from typing import Any import pytest from ag2 import Agent, Context from ag2.events import ToolCallEvent from ag2.knowledge import MemoryKnowledgeStore from ag2.network import ( EV_CHANNEL_INVITE, EV_CHANNEL_INVITE_ACK, EV_CHANNEL_OPENED, EV_TEXT, AccessDeniedError, Decision, Deny, Envelope, Hub, HubClient, InboxFull, LocalLink, Passport, ProtocolError, Resume, Rule, RuleBasedArbiter, ) from ag2.network.adapters.conversation import ( CONVERSATION_TYPE, ) from ag2.network.channel import ( ChannelManifest, ChannelMetadata, ChannelState, Expectation, Participant, ParticipantRole, ParticipantSchema, ) from ag2.network.client.tools.delegate import make_delegate_tool from ag2.network.hub.expectations import ( AcksWithinEvaluator, ) from ag2.network.hub.layout import ( by_capability_path, passport_path, resume_path, rule_path, skill_path, ) from ag2.network.policies import AGENT_CLIENT_DEP from ag2.network.rule import InboxBlock, LimitsBlock from ag2.stream import MemoryStream from ag2.task import ( TaskMetadata, TaskSpec, TaskState, ) from ag2.testing import TestConfig from ._helpers import _MockClock def _agent(name: str, *events: object) -> Agent: return Agent(name=name, config=TestConfig(*events)) async def _invoke(tool: Any, args: dict, *, dependencies: dict | None = None) -> Any: """Invoke a ``FunctionTool`` directly and return its underlying value.""" event = ToolCallEvent(name=tool.name, arguments=json.dumps(args)) context = Context(stream=MemoryStream(), dependencies=dependencies or {}) result_event = await tool(event, context) parts = getattr(result_event, "result", None) if parts is None or not parts.parts: return result_event first = parts.parts[0] if hasattr(first, "content"): return first.content if hasattr(first, "data"): return first.data return first def _ack_only_handler(client: "HubClient | object"): """Build a notify handler that auto-acks invites and ignores everything else. Used when a test needs a channel to open (handshake completes) but wants the recipient to not respond to ``EV_TEXT`` so the hub's inbox-pending counter stays high. """ async def _handle(env: Envelope) -> None: if env.event_type != EV_CHANNEL_INVITE: return ack = Envelope( channel_id=env.channel_id, sender_id=client.agent_id, audience=None, event_type=EV_CHANNEL_INVITE_ACK, event_data={"channel_id": env.channel_id}, causation_id=env.envelope_id, ) with contextlib.suppress(Exception): await client.send_envelope(ack) return _handle # ── Fix #1: Hub.unregister disk cleanup ───────────────────────────────────── @pytest.mark.asyncio async def test_unregister_deletes_disk_files_so_hydrate_forgets_agent() -> None: """After ``unregister`` the agent's identity files are gone; a fresh ``Hub`` over the same store must not see the agent on hydrate.""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) link = LocalLink(hub) alice_hc = HubClient(link, hub=hub) alice = await alice_hc.register( _agent("alice"), Passport(name="alice"), Resume(claimed_capabilities=["math"]), skill_md="# Alice\nDoes math.", rule=Rule(limits=LimitsBlock(channel_ttl_default="4h")), ) agent_id = alice.agent_id # All four on-disk files exist before unregister. assert await store.read(passport_path(agent_id)) is not None assert await store.read(resume_path(agent_id)) is not None assert await store.read(rule_path(agent_id)) is not None assert await store.read(skill_path(agent_id)) is not None await alice_hc.unregister_agent(agent_id) await alice_hc.close() await hub.close() # All four files are gone post-unregister. assert await store.read(passport_path(agent_id)) is None assert await store.read(resume_path(agent_id)) is None assert await store.read(rule_path(agent_id)) is None assert await store.read(skill_path(agent_id)) is None # Fresh Hub over the same store hydrates without the unregistered agent. hub2 = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) assert hub2._passports == {} assert hub2._resumes == {} assert hub2._rules == {} assert hub2._skills == {} assert hub2._name_to_id == {} assert hub2.agents_with_capability("math") == [] await hub2.close() # ── Fix #4: Hub.register rejects duplicate name ────────────────────────────── @pytest.mark.asyncio async def test_register_rejects_duplicate_name_raises_protocol_error() -> None: """A second ``register`` with the same ``name`` must fail loudly so the prior passport / resume / rule do not get orphaned on disk.""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) await hub.register(_agent("alice")) with pytest.raises(ProtocolError, match="already registered"): await hub.register(_agent("alice")) await hub.close() @pytest.mark.asyncio async def test_unregister_then_reregister_with_same_name_works() -> None: """Explicit unregister releases the name so the same identity can rejoin.""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) link = LocalLink(hub) hc = HubClient(link, hub=hub) first = await hc.register(_agent("alice"), Passport(name="alice"), Resume()) await hc.unregister_agent(first.agent_id) second = await hc.register(_agent("alice"), Passport(name="alice"), Resume()) assert second.agent_id != first.agent_id await hc.close() await hub.close() # ── Fix #2: concurrency caps + inbox limits ────────────────────────────────── @pytest.mark.asyncio async def test_create_channel_enforces_max_concurrent_channels() -> None: """Hub rejects ``create_channel`` when the creator has more active channels than ``max_concurrent_channels``. ``0`` disables.""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) capped = Rule(limits=LimitsBlock(max_concurrent_channels=1)) alice = await hub.register(_agent("alice"), rule=capped) bob = await hub.register(_agent("bob")) carol = await hub.register(_agent("carol")) await alice.open(type=CONVERSATION_TYPE, target=bob.agent_id) with pytest.raises(AccessDeniedError, match="max_concurrent_channels"): await alice.open(type=CONVERSATION_TYPE, target=carol.agent_id) await hub.close() @pytest.mark.asyncio async def test_observe_task_enforces_max_concurrent_tasks() -> None: """``observe_task`` rejects new tasks when the owner already has ``max_concurrent_tasks`` non-terminal tasks observed.""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) capped = Rule(limits=LimitsBlock(max_concurrent_tasks=1)) alice = await hub.register(_agent("alice"), rule=capped) await hub.observe_task( TaskMetadata( task_id="t1", owner_id=alice.agent_id, spec=TaskSpec(title="task one"), state=TaskState.RUNNING, created_at="2026-01-01T00:00:00+00:00", ) ) with pytest.raises(AccessDeniedError, match="max_concurrent_tasks"): await hub.observe_task( TaskMetadata( task_id="t2", owner_id=alice.agent_id, spec=TaskSpec(title="task two"), state=TaskState.RUNNING, created_at="2026-01-01T00:00:01+00:00", ) ) # Terminal task frees the slot for the next. await hub.update_task("t1", state=TaskState.COMPLETED) await hub.observe_task( TaskMetadata( task_id="t3", owner_id=alice.agent_id, spec=TaskSpec(title="task three"), state=TaskState.RUNNING, created_at="2026-01-01T00:00:02+00:00", ) ) await hub.close() @pytest.mark.asyncio async def test_post_envelope_enforces_inbox_max_pending() -> None: """``post_envelope`` raises ``InboxFull`` when the recipient's inbox is at capacity. The counter decrements when the recipient sends.""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) bob_capped = Rule(limits=LimitsBlock(inbox=InboxBlock(max_pending=2))) alice = await hub.register(_agent("alice")) bob = await hub.register( _agent("bob"), rule=bob_capped, attach_plugin=False, ) # Auto-ack invites so the channel opens; ignore EV_TEXT so the # hub's pending-inbox counter for bob grows. bob.on_envelope(_ack_only_handler(bob)) channel = await alice.open(type=CONVERSATION_TYPE, target=bob.agent_id) def _make(text: str) -> Envelope: return Envelope( channel_id=channel.channel_id, sender_id=alice.agent_id, audience=[bob.agent_id], event_type=EV_TEXT, event_data={"text": text}, ) await hub.post_envelope(_make("one")) await hub.post_envelope(_make("two")) with pytest.raises(InboxFull, match="inbox at capacity"): await hub.post_envelope(_make("three")) # Bob sending anything decrements his counter, freeing one slot. await hub.post_envelope( Envelope( channel_id=channel.channel_id, sender_id=bob.agent_id, audience=[alice.agent_id], event_type=EV_TEXT, event_data={"text": "ack"}, ) ) await hub.post_envelope(_make("three")) # accepted now await hub.close() @pytest.mark.asyncio async def test_inbox_pending_cleared_on_unregister() -> None: """Unregistering an agent drops its inbox accounting so a future re-register starts from zero.""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) link = LocalLink(hub) alice_hc = HubClient(link, hub=hub) bob_hc = HubClient(link, hub=hub) alice = await alice_hc.register(_agent("alice"), Passport(name="alice"), Resume()) bob = await bob_hc.register(_agent("bob"), Passport(name="bob"), Resume(), attach_plugin=False) bob.on_envelope(_ack_only_handler(bob)) channel = await alice.open(type=CONVERSATION_TYPE, target=bob.agent_id) await hub.post_envelope( Envelope( channel_id=channel.channel_id, sender_id=alice.agent_id, audience=[bob.agent_id], event_type=EV_TEXT, event_data={"text": "x"}, ) ) bob_id = bob.agent_id assert hub._inbox_pending.get(bob_id, 0) > 0 await bob_hc.unregister_agent(bob_id) assert bob_id not in hub._inbox_pending await alice_hc.close() await bob_hc.close() await hub.close() # ── Fix #3 / #5: delegate inbox race + fail-fast on close ──────────────────── @pytest.mark.asyncio async def test_delegate_returns_target_reply_without_dropping_fast_reply() -> None: """Delegate must not lose the respondent's reply even if it lands immediately after ``channel.send`` (the inbox is pre-created).""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register(_agent("alice")) await hub.register(_agent("bob", "the answer is 42")) delegate_tool = make_delegate_tool(alice) result = await _invoke( delegate_tool, {"target": "bob", "prompt": "what's the answer?"}, dependencies={AGENT_CLIENT_DEP: alice}, ) assert "42" in result await hub.close() class _RejectTextArbiter(RuleBasedArbiter): async def authorize_send( self, envelope: Envelope, sender: Passport, sender_rule: Rule, recipients: list[Passport], ) -> Decision: if envelope.event_type == EV_TEXT: return Deny(reason="prompt rejected") return await super().authorize_send(envelope, sender, sender_rule, recipients) class _CloseFailingHub(Hub): async def close_channel(self, channel_id: str, *, reason: str = "") -> ChannelMetadata: raise RuntimeError("channel cleanup failed") @pytest.mark.asyncio async def test_delegate_closes_channel_when_prompt_send_fails() -> None: store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) try: alice = await hub.register(_agent("alice")) await hub.register(_agent("bob")) hub.register_arbiter(_RejectTextArbiter()) result = await _invoke(make_delegate_tool(alice), {"target": "bob", "prompt": "hi"}) assert result == "Error: prompt send failed: prompt rejected" [channel] = await hub.list_channels(agent_id=alice.agent_id) assert channel.manifest.type == "consulting" events = await hub.read_wal(channel.channel_id) assert EV_CHANNEL_OPENED in [event.event_type for event in events] assert all(event.event_type != EV_TEXT for event in events) assert await hub.list_channels(state=ChannelState.ACTIVE) == [] assert channel.state == ChannelState.CLOSED assert channel.close_reason == "prompt_send_failed" finally: await hub.close() @pytest.mark.asyncio async def test_delegate_preserves_prompt_send_error_when_channel_close_fails() -> None: store = MemoryKnowledgeStore() hub = await _CloseFailingHub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) try: alice = await hub.register(_agent("alice")) await hub.register(_agent("bob")) hub.register_arbiter(_RejectTextArbiter()) result = await _invoke(make_delegate_tool(alice), {"target": "bob", "prompt": "hi"}) assert result == "Error: prompt send failed: prompt rejected" finally: await hub.close() @pytest.mark.asyncio async def test_delegate_resolves_functions_namespaced_target() -> None: """A ``functions.``-prefixed target still resolves to the real peer. Regression for #3022: LLMs occasionally echo OpenAI's ``functions.`` tool namespace into the delegate target (e.g. ``functions.bob``). The literal name misses the registry, so the consult would fail and the model re-guess into a stalled run. ``delegate`` must fall back to the unprefixed name. """ store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) link = LocalLink(hub) alice_hc = HubClient(link, hub=hub) bob_hc = HubClient(link, hub=hub) alice = await alice_hc.register(_agent("alice"), Passport(name="alice"), Resume()) await bob_hc.register(_agent("bob", "the answer is 42"), Passport(name="bob"), Resume()) delegate_tool = make_delegate_tool(alice) result = await _invoke( delegate_tool, {"target": "functions.bob", "prompt": "what's the answer?"}, dependencies={AGENT_CLIENT_DEP: alice}, ) assert "42" in result assert "not found" not in result await alice_hc.close() await bob_hc.close() await hub.close() @pytest.mark.asyncio async def test_delegate_unknown_target_lists_available_peers() -> None: """An invented target's not-found error enumerates the real peers. Regression for #3022: when the model invents a target (e.g. ``PPTDialogScript``) no prefix-strip recovers it, and a bare ``not found`` error gives the model nothing to correct toward, so it re-guesses into a stalled run. The error must list the live targets so the next turn can pick a real one. The caller itself is not a valid delegate target, so it must be excluded from the list. """ store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) link = LocalLink(hub) alice_hc = HubClient(link, hub=hub) bob_hc = HubClient(link, hub=hub) carol_hc = HubClient(link, hub=hub) alice = await alice_hc.register(_agent("alice"), Passport(name="alice"), Resume()) await bob_hc.register(_agent("bob"), Passport(name="bob"), Resume()) await carol_hc.register(_agent("carol"), Passport(name="carol"), Resume()) delegate_tool = make_delegate_tool(alice) result = await _invoke( delegate_tool, {"target": "PPTDialogScript", "prompt": "generate a script"}, dependencies={AGENT_CLIENT_DEP: alice}, ) assert "not found" in result assert "bob" in result assert "carol" in result # The initiator is not a valid delegate target and must not be suggested. assert "alice" not in result await alice_hc.close() await bob_hc.close() await carol_hc.close() await hub.close() @pytest.mark.asyncio async def test_delegate_to_self_returns_actionable_error() -> None: """Delegating to one's own name fails fast with a clear message. Regression for #2991: a consulting channel needs a respondent distinct from the initiator. When the caller delegates to itself the single participant collapses both roles, and the consulting adapter rejects the open with the opaque ``consulting requires exactly one respondent`` — surfacing to the model as a cryptic error and, in multi-agent pipelines, a hang. ``delegate`` must short-circuit with an actionable error instead. """ store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register(_agent("alice")) delegate_tool = make_delegate_tool(alice) result = await _invoke( delegate_tool, {"target": "alice", "prompt": "answer your own question"}, dependencies={AGENT_CLIENT_DEP: alice}, ) assert "cannot delegate to self" in result # The opaque adapter-level error must never reach the caller. assert "exactly one respondent" not in result # No channel should have been opened for the doomed self-consult. assert await hub.list_channels(state=ChannelState.ACTIVE) == [] await hub.close() @pytest.mark.asyncio async def test_delegate_fails_fast_when_channel_closes_before_reply() -> None: """If the consulting channel closes / expires before the target replies, delegate returns immediately with an error — not after the 300s timeout.""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register(_agent("alice")) bob = await hub.register(_agent("bob"), attach_plugin=False) # Bob acks the invite so the channel opens, but never replies to # the prompt — alice's delegate would block until timeout without # the fail-fast path. bob.on_envelope(_ack_only_handler(bob)) delegate_tool = make_delegate_tool(alice) async def _close_after_handshake() -> None: for _ in range(100): channels = await hub.list_channels(state=ChannelState.ACTIVE) if channels: await hub.close_channel(channels[0].channel_id, reason="test_close") return await asyncio.sleep(0.01) closer = asyncio.create_task(_close_after_handshake()) # Long internal timeout — without the fix this would take 300s. # The outer wait_for asserts we return well within 10s. result = await asyncio.wait_for( _invoke( delegate_tool, {"target": "bob", "prompt": "hi", "timeout": 300.0}, dependencies={AGENT_CLIENT_DEP: alice}, ), timeout=10.0, ) await closer assert isinstance(result, str) assert result.startswith("Error:") # Two valid fail-fast wordings, depending on whether the close lands while # delegate is awaiting the reply ("... channel closed: test_close") or # during the prompt send ("prompt send failed: channel '' is closed"). # Windows scheduling tends to hit the send path; both are correct. lowered = result.lower() assert "channel closed" in lowered or "prompt send failed" in lowered, result await hub.close() # ── Fix #6: expectation dedup keyed by identity ────────────────────────────── @pytest.mark.asyncio async def test_two_same_name_expectations_both_fire() -> None: """A manifest with two ``acks_within`` expectations (same name, different ``on_violation``) must allow both to fire on the same violator — the dedup key must include the expectation's index.""" clock = _MockClock("2026-01-01T00:00:00+00:00") store = MemoryKnowledgeStore() hub = Hub( store, clock=clock, ttl_sweep_interval=0, expectation_sweep_interval=0, ) hub.register_expectation_evaluator(AcksWithinEvaluator()) audit_calls: list[tuple[str, str]] = [] class _RecordingAudit: name = "audit" async def handle(self, h: Hub, sid: str, viol: Any) -> None: audit_calls.append((sid, viol.expectation.name)) class _AltAudit: name = "alt_audit" async def handle(self, h: Hub, sid: str, viol: Any) -> None: audit_calls.append((sid, "alt:" + viol.expectation.name)) hub.register_violation_handler(_RecordingAudit()) hub.register_violation_handler(_AltAudit()) # Build a channel metadata directly — two same-name expectations # with different handlers in a single manifest. manifest = ChannelManifest( type="dual_acks", version=1, participants=ParticipantSchema(min=2, max=2), expectations=[ Expectation(name="acks_within", on_violation="audit", params={"seconds": 30}), Expectation(name="acks_within", on_violation="alt_audit", params={"seconds": 30}), ], ) metadata = ChannelMetadata( channel_id="s1", manifest=manifest, creator_id="alice", participants=[ Participant(agent_id="alice", role=ParticipantRole.INITIATOR, order=0), Participant(agent_id="bob", role=ParticipantRole.RESPONDENT, order=1), ], state=ChannelState.PENDING, created_at="2026-01-01T00:00:00+00:00", pending_acks=["bob"], ) hub._channels["s1"] = metadata hub._active_channels["s1"] = metadata # Advance past the 30s threshold and tick once. clock.advance(45) await hub._expectation_tick() # Both handlers must have fired exactly once for bob. assert ("s1", "acks_within") in audit_calls assert ("s1", "alt:acks_within") in audit_calls # A second tick at the same clock must NOT re-fire either. audit_calls.clear() await hub._expectation_tick() assert audit_calls == [] # ── Fix #7: task_mirror idempotent observation ─────────────────────────────── @pytest.mark.asyncio async def test_record_observation_dedups_by_task_id() -> None: """Calling ``record_observation`` twice with the same ``task_id`` must update ``Resume.observed.n`` only once.""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register(_agent("alice")) await hub.record_observation( owner_id=alice.agent_id, capability="math", outcome=TaskState.COMPLETED, task_id="task-A", ) await hub.record_observation( owner_id=alice.agent_id, capability="math", outcome=TaskState.COMPLETED, task_id="task-A", ) resume = await hub.get_resume(alice.agent_id) assert resume.observed["math"].n == 1 assert resume.observed["math"].completed == 1 # A different task_id is recorded independently. await hub.record_observation( owner_id=alice.agent_id, capability="math", outcome=TaskState.FAILED, task_id="task-B", ) resume = await hub.get_resume(alice.agent_id) assert resume.observed["math"].n == 2 assert resume.observed["math"].failed == 1 await hub.close() # ── Edge cases the per-fix tests above don't reach ─────────────────────────── @pytest.mark.asyncio async def test_concurrency_caps_zero_disables() -> None: """``max_concurrent_channels=0`` and ``max_concurrent_tasks=0`` both disable their respective caps — the documented "0 = unlimited" convention used elsewhere (delegation_depth, inbox.max_pending).""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) no_caps = Rule(limits=LimitsBlock(max_concurrent_channels=0, max_concurrent_tasks=0)) alice = await hub.register(_agent("alice"), rule=no_caps) # Five concurrent conversations open without raising. for name in ("bob", "carol", "dave", "erin", "frank"): peer = await hub.register(_agent(name)) await alice.open(type=CONVERSATION_TYPE, target=peer.agent_id) # Five concurrent tasks observed without raising. for i in range(5): await hub.observe_task( TaskMetadata( task_id=f"task-{i}", owner_id=alice.agent_id, spec=TaskSpec(title=f"task {i}"), state=TaskState.RUNNING, created_at=f"2026-01-01T00:00:0{i}+00:00", ) ) await hub.close() @pytest.mark.asyncio async def test_inbox_capacity_does_not_block_protocol_events() -> None: """Protocol envelopes (invite / ack / opened / closed) bypass the inbox-pending counter so a busy agent can still receive new channel opens. Without this carve-out a recipient at capacity would never learn about a new invite.""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) bob_capped = Rule(limits=LimitsBlock(inbox=InboxBlock(max_pending=1))) alice = await hub.register(_agent("alice")) bob = await hub.register( _agent("bob"), rule=bob_capped, attach_plugin=False, ) bob.on_envelope(_ack_only_handler(bob)) carol = await hub.register(_agent("carol")) # Saturate bob's substantive-inbox budget via channel 1. s1 = await alice.open(type=CONVERSATION_TYPE, target=bob.agent_id) await hub.post_envelope( Envelope( channel_id=s1.channel_id, sender_id=alice.agent_id, audience=[bob.agent_id], event_type=EV_TEXT, event_data={"text": "fill"}, ) ) assert hub._inbox_pending.get(bob.agent_id, 0) >= 1 # Carol opens a fresh channel with bob — INVITE / ACK / OPENED must # not be rejected by the inbox check; bob's auto-ack runs and the # channel reaches ACTIVE. s2 = await carol.open(type=CONVERSATION_TYPE, target=bob.agent_id) assert s2.state == ChannelState.ACTIVE await hub.close() @pytest.mark.asyncio async def test_fired_violations_cleared_on_terminal_channel_transition() -> None: """``_fired_violations[channel_id]`` is dropped when the channel transitions to a terminal state. Without this, a long-running hub accumulates dead dedup entries for every channel that ever fired a violation.""" clock = _MockClock("2026-01-01T00:00:00+00:00") store = MemoryKnowledgeStore() hub = Hub( store, clock=clock, ttl_sweep_interval=0, expectation_sweep_interval=0, ) hub.register_expectation_evaluator(AcksWithinEvaluator()) class _Recorder: name = "audit" async def handle(self, h: Hub, sid: str, viol: Any) -> None: return None hub.register_violation_handler(_Recorder()) manifest = ChannelManifest( type="testtype", version=1, participants=ParticipantSchema(min=2, max=2), expectations=[ Expectation(name="acks_within", on_violation="audit", params={"seconds": 30}), ], ) metadata = ChannelMetadata( channel_id="s1", manifest=manifest, creator_id="alice", participants=[ Participant(agent_id="alice", role=ParticipantRole.INITIATOR, order=0), Participant(agent_id="bob", role=ParticipantRole.RESPONDENT, order=1), ], state=ChannelState.PENDING, created_at="2026-01-01T00:00:00+00:00", pending_acks=["bob"], ) hub._channels["s1"] = metadata hub._active_channels["s1"] = metadata clock.advance(45) await hub._expectation_tick() assert "s1" in hub._fired_violations await hub._transition_channel("s1", ChannelState.CLOSED, "test_reason") assert "s1" not in hub._fired_violations @pytest.mark.asyncio async def test_set_resume_rewrites_by_capability_disk_file() -> None: """Adding a claim via ``set_resume`` rewrites ``/registry/by_capability.json`` — the on-disk derived cache stays in sync with in-memory ``_capability_index``.""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register(_agent("alice")) initial = await store.read(by_capability_path()) assert initial is not None assert json.loads(initial) == {} await alice.set_resume(Resume(claimed_capabilities=["math"])) assert json.loads(await store.read(by_capability_path())) == {"math": [alice.agent_id]} # Adding a second claim leaves the first intact. await alice.set_resume(Resume(claimed_capabilities=["math", "policy"])) after_add = json.loads(await store.read(by_capability_path())) assert after_add == { "math": [alice.agent_id], "policy": [alice.agent_id], } # Removing a claim drops it from disk. await alice.set_resume(Resume(claimed_capabilities=["policy"])) after_remove = json.loads(await store.read(by_capability_path())) assert after_remove == {"policy": [alice.agent_id]} await hub.close() @pytest.mark.asyncio async def test_delegate_fails_fast_on_channel_expire() -> None: """If the consulting channel expires (TTL fires) while delegate is awaiting a reply, delegate's terminal-event predicate releases the wait and returns ``Error: ... channel closed: ttl_expired`` rather than blocking the full 300s timeout. Exercises the ``EV_CHANNEL_EXPIRED`` branch of ``_TERMINAL_CHANNEL_EVENTS`` (the ``EV_CHANNEL_CLOSED`` branch is covered by the close test above; ``EV_CHANNEL_INVITE_REJECT`` is unreachable from delegate's wait phase because ``open()`` raises before the wait begins).""" clock = _MockClock("2026-01-01T00:00:00+00:00") store = MemoryKnowledgeStore() hub = await Hub.open(store, clock=clock, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register(_agent("alice")) bob = await hub.register(_agent("bob"), attach_plugin=False) bob.on_envelope(_ack_only_handler(bob)) delegate_tool = make_delegate_tool(alice) async def _expire_after_handshake() -> None: for _ in range(100): channels = await hub.list_channels(state=ChannelState.ACTIVE) if channels: # Default channel TTL is 2h; advance past it and trigger # the sweeper manually (interval=0 disables auto-tick). clock.advance(7200 + 60) await hub.expire_due() return await asyncio.sleep(0.01) expirer = asyncio.create_task(_expire_after_handshake()) result = await asyncio.wait_for( _invoke( delegate_tool, {"target": "bob", "prompt": "hi", "timeout": 300.0}, dependencies={AGENT_CLIENT_DEP: alice}, ), timeout=10.0, ) await expirer assert isinstance(result, str) assert result.startswith("Error:") # As with the close test, expiry can surface via the wait path # ("... channel closed: ttl_expired") or the send path # ("prompt send failed: channel '' is expired"). Windows scheduling # tends to hit the send path; both are correct fail-fast outcomes. lowered = result.lower() assert "channel closed" in lowered or "prompt send failed" in lowered, result assert "expired" in lowered, result await hub.close() class _FailingEndpoint: """A ``LinkEndpoint`` whose inbound stream errors mid-read. Models a transport that drops with an unexpected error while the hub reads frames — the failure the hub must surface before dropping the endpoint. """ def __init__(self) -> None: self.endpoint_id = "failing-ep-001" self.agent_id: str | None = None async def send_frame(self, frame: object) -> None: # noqa: D102 pass async def frames(self): # type: ignore[override] raise RuntimeError("transport read failed") yield # pragma: no cover - marks this coroutine as an async generator async def close(self) -> None: # noqa: D102 pass @pytest.mark.asyncio async def test_endpoint_frame_loop_failure_is_logged(caplog: pytest.LogCaptureFixture) -> None: """When an endpoint's frame stream errors, the hub logs at ERROR before dropping it rather than swallowing the failure silently. Drives the public ``attach_endpoint`` path with a transport whose ``frames()`` raises, then awaits the spawned endpoint task to completion — no patching of hub internals. """ store = MemoryKnowledgeStore() hub = Hub(store, ttl_sweep_interval=0, expectation_sweep_interval=0) with caplog.at_level(logging.ERROR, logger="ag2.network.hub.core"): before = asyncio.all_tasks() hub.attach_endpoint(_FailingEndpoint()) # type: ignore[arg-type] spawned = asyncio.all_tasks() - before await asyncio.gather(*spawned, return_exceptions=True) errors = [r for r in caplog.records if r.levelno >= logging.ERROR] assert any("endpoint frame loop failed" in r.getMessage() for r in errors), ( f"Expected ERROR log for the dropped endpoint; got records: {[r.getMessage() for r in caplog.records]}" ) # The original transport failure is attached to the log record (logged via ``exception``). assert any(r.exc_info and isinstance(r.exc_info[1], RuntimeError) for r in errors) await hub.close()