Compare commits

...

8 Commits

Author SHA1 Message Date
CleverAgents Bot 2254991776 fix(events): restore event bus close and bridge semantics
CI / load-versions (pull_request) Successful in 20s
CI / push-validation (pull_request) Successful in 31s
CI / lint (pull_request) Successful in 51s
CI / typecheck (pull_request) Successful in 1m3s
CI / quality (pull_request) Successful in 1m18s
CI / build (pull_request) Successful in 48s
CI / security (pull_request) Successful in 1m26s
CI / helm (pull_request) Successful in 45s
CI / unit_tests (pull_request) Successful in 6m12s
CI / docker (pull_request) Successful in 1m40s
CI / integration_tests (pull_request) Successful in 8m46s
CI / coverage (pull_request) Successful in 12m41s
CI / status-check (pull_request) Successful in 3s
2026-06-18 06:17:09 -04:00
controller-ci-rerun 755c78ce36 chore: re-trigger CI [controller]
CI / load-versions (pull_request) Successful in 30s
CI / push-validation (pull_request) Successful in 38s
CI / lint (pull_request) Successful in 48s
CI / typecheck (pull_request) Successful in 59s
CI / build (pull_request) Successful in 34s
CI / helm (pull_request) Successful in 44s
CI / quality (pull_request) Successful in 1m28s
CI / security (pull_request) Successful in 1m34s
CI / unit_tests (pull_request) Failing after 5m17s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 8m41s
CI / status-check (pull_request) Failing after 3s
2026-06-17 14:32:46 -04:00
controller-ci-rerun 11d47c240e chore: re-trigger CI [controller]
CI / load-versions (pull_request) Successful in 16s
CI / push-validation (pull_request) Successful in 35s
CI / lint (pull_request) Successful in 50s
CI / build (pull_request) Successful in 38s
CI / typecheck (pull_request) Successful in 58s
CI / quality (pull_request) Successful in 56s
CI / security (pull_request) Successful in 1m5s
CI / helm (pull_request) Successful in 54s
CI / unit_tests (pull_request) Failing after 6m16s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 8m42s
CI / status-check (pull_request) Failing after 4s
2026-06-16 18:18:02 -04:00
controller-ci-rerun 8db127d3d8 chore: re-trigger CI [controller]
CI / load-versions (pull_request) Successful in 14s
CI / push-validation (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 48s
CI / quality (pull_request) Successful in 48s
CI / typecheck (pull_request) Successful in 1m8s
CI / security (pull_request) Successful in 1m15s
CI / helm (pull_request) Successful in 58s
CI / build (pull_request) Successful in 58s
CI / unit_tests (pull_request) Failing after 7m6s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 8m51s
CI / status-check (pull_request) Failing after 4s
2026-06-15 11:47:08 -04:00
controller-ci-rerun 0054c761f6 chore: re-trigger CI [controller] 2026-06-15 11:47:08 -04:00
HAL9000 fa4337dd1c fix(events): eliminate inline type ignores in event_bus_steps.py
Replace all # type: ignore[arg-type] and # type: ignore[misc] comments
with pyright-compatible alternatives: typing.cast() for intentional
type mismatches in error-handling tests, and explicit stub method bodies
for Protocol subclasses.
2026-06-15 11:47:08 -04:00
CleverThis Bot 6d429b1276 fix(ci): resolve ruff SIM105, E501, W291 lint errors in PR #11197 changes
- events.py: add missing contextlib import, use contextlib.suppress for
  TypeError/AttributeError try/except (SIM105)
- reactive.py: fix docstring line length (E501), remove trailing whitespace (W291)
- logging_bus.py: fix docstring line length (E501), remove trailing whitespace (W291)
2026-06-15 11:47:08 -04:00
HAL9000 d506de50a9 fix(events): add unsubscribe() to EventBus protocol and implementations 2026-06-15 11:47:08 -04:00
8 changed files with 410 additions and 104 deletions
+62 -23
View File
@@ -118,6 +118,68 @@ Feature: EventBus protocol and domain event emission
Scenario: EventBus Protocol emit and subscribe stubs are executable
Then the EventBus Protocol stubs should be callable
# ---------------------------------------------------------------------------
# EventBus unsubscribe
# ---------------------------------------------------------------------------
Scenario: ReactiveEventBus.unsubscribe() removes a handler
Given a ReactiveEventBus
When I subscribe to "plan.created" events
And I unsubscribe from "plan.created" with the same handler
Then the handler count for "plan.created" should be 0
Scenario: ReactiveEventBus.unsubscribe() returns True when found
Given a ReactiveEventBus
When I subscribe to "plan.created" events
And I unsubscribe from "plan.created" with the same handler
Then unsubscribing should return True
Scenario: ReactiveEventBus.unsubscribe() returns False when not found
Given a ReactiveEventBus
Then unsubscribing a non-existent handler should return False
Scenario: ReactiveEventBus.unsubscribe() rejects invalid event_type
Given a ReactiveEventBus
Then unsubscribing with a non-EventType should raise TypeError
Scenario: ReactiveEventBus.unsubscribe() rejects non-callable handler
Given a ReactiveEventBus
Then unsubscribing with a non-callable handler should raise TypeError
Scenario: LoggingEventBus.unsubscribe() removes a handler
Given a LoggingEventBus
When I subscribe to "decision.created" events
And I unsubscribe from "decision.created" with the same handler
Then the handler count for "decision.created" should be 0
Scenario: LoggingEventBus.unsubscribe() returns True when found
Given a LoggingEventBus
When I subscribe to "decision.created" events
And I unsubscribe from "decision.created" with the same handler
Then unsubscribing should return True
Scenario: LoggingEventBus.unsubscribe() returns False when not found
Given a LoggingEventBus
Then unsubscribing a non-existent handler should return False
Scenario: LoggingEventBus.unsubscribe() rejects invalid event_type
Given a LoggingEventBus
Then unsubscribing with a non-EventType should raise TypeError
Scenario: LoggingEventBus.unsubscribe() rejects non-callable handler
Given a LoggingEventBus
Then unsubscribing with a non-callable handler should raise TypeError
Scenario: EventBus Protocol unsubscribe stub is callable
Then the EventBus Protocol unsubscribe stub should be callable
Scenario: Event bus supports selective unsubscription (only specific handler removed)
Given a ReactiveEventBus
When I subscribe two handlers to "plan.created" events
And I unsubscribe only the first handler from "plan.created"
And I emit a "plan.created" DomainEvent
Then exactly one handler should have received 1 event
# ---------------------------------------------------------------------------
# DecisionService event emission
# ---------------------------------------------------------------------------
@@ -163,26 +225,3 @@ Feature: EventBus protocol and domain event emission
Scenario: Container wires event_bus into DecisionService
When I resolve decision_service from the DI container
Then the decision_service should have an event_bus attribute
# ---------------------------------------------------------------------------
# ReactiveEventBus.close() and context manager (issue #10378)
# ---------------------------------------------------------------------------
Scenario: ReactiveEventBus.close() completes the RxPY stream
Given a ReactiveEventBus
When bus.close() is called on the bus
Then the bus should be marked as closed
Scenario: ReactiveEventBus.close() prevents further emit() calls
Given a ReactiveEventBus
When bus.close() is called on the bus
Then emitting after close should raise RuntimeError
Scenario: ReactiveEventBus.close() is idempotent
Given a ReactiveEventBus
When bus.close() is called twice on the bus
Then no exception should be raised on double close
Scenario: ReactiveEventBus supports context manager protocol
Given a ReactiveEventBus used as a context manager
Then the bus should be closed after the context exits
+100 -59
View File
@@ -6,7 +6,8 @@ LoggingEventBus, service event emission, and DI container registration.
from __future__ import annotations
from typing import Any
from collections.abc import Callable
from typing import Any, cast
from unittest.mock import MagicMock
from behave import given, then, when # type: ignore[import-untyped]
@@ -189,6 +190,18 @@ def step_subscribe_two(ctx: Context, et: str) -> None:
ctx.bus.subscribe(EventType(et), collector)
@when('I unsubscribe from "{et}" with the same handler')
def step_unsubscribe_handler(ctx: Context, et: str) -> None:
collector = ctx.collectors[0]
ctx.unsubscribe_result = ctx.bus.unsubscribe(EventType(et), collector)
@when('I unsubscribe only the first handler from "{et}"')
def step_unsubscribe_first_handler(ctx: Context, et: str) -> None:
collector = ctx.collectors[0]
ctx.bus.unsubscribe(EventType(et), collector)
@when('I emit a "{et}" DomainEvent')
def step_emit_event(ctx: Context, et: str) -> None:
ctx.bus.emit(_make_event(et))
@@ -220,6 +233,20 @@ def step_each_handler_received(ctx: Context, n: int) -> None:
)
@then("exactly one handler should have received 1 event")
def step_exactly_one_handler_received(ctx: Context) -> None:
counts = [len(col.received) for col in ctx.collectors]
assert sum(1 for c in counts if c == 1) == 1, (
f"Expected exactly one handler with 1 event, got {counts}"
)
@then('the handler count for "{et}" should be 0')
def step_handler_count_zero(ctx: Context, et: str) -> None:
actual = len(ctx.bus._subscriptions.get(EventType(et), []))
assert actual == 0, f"Expected 0 handlers for {et}, got {actual}"
@then("the bus should expose an observable stream")
def step_bus_has_stream(ctx: Context) -> None:
stream = ctx.bus.stream
@@ -230,7 +257,7 @@ def step_bus_has_stream(ctx: Context) -> None:
def step_emit_non_domain_event(ctx: Context) -> None:
raised = False
try:
ctx.bus.emit("not-an-event") # type: ignore[arg-type]
ctx.bus.emit(cast(DomainEvent, "not-an-event"))
except TypeError:
raised = True
assert raised, "Expected TypeError for non-DomainEvent"
@@ -240,7 +267,7 @@ def step_emit_non_domain_event(ctx: Context) -> None:
def step_subscribe_bad_type(ctx: Context) -> None:
raised = False
try:
ctx.bus.subscribe("plan.created", lambda e: None) # type: ignore[arg-type]
ctx.bus.subscribe(cast(EventType, "plan.created"), lambda e: None)
except TypeError:
raised = True
assert raised, "Expected TypeError for non-EventType event_type"
@@ -250,18 +277,67 @@ def step_subscribe_bad_type(ctx: Context) -> None:
def step_subscribe_non_callable(ctx: Context) -> None:
raised = False
try:
ctx.bus.subscribe(EventType.PLAN_CREATED, "not-callable") # type: ignore[arg-type]
ctx.bus.subscribe(
EventType.PLAN_CREATED, cast(Callable[[DomainEvent], None], "not-callable")
)
except TypeError:
raised = True
assert raised, "Expected TypeError for non-callable handler"
@then("unsubscribing should return True")
def step_unsubscribe_returns_true(ctx: Context) -> None:
result: bool = getattr(ctx, "unsubscribe_result", False)
assert result is True, f"Expected unsubscribe to return True, got {result}"
@then("unsubscribing a non-existent handler should return False")
def step_unsubscribe_nonexistent_returns_false(ctx: Context) -> None:
ctx.exception = None
try:
ctx.unsubscribe_result = ctx.bus.unsubscribe(
EventType.PLAN_CREATED, lambda e: None
)
except Exception as exc:
ctx.exception = exc
assert ctx.unsubscribe_result is False
@then("unsubscribing with a non-EventType should raise TypeError")
def step_unsubscribe_bad_type(ctx: Context) -> None:
raised = False
try:
ctx.bus.unsubscribe(cast(EventType, "plan.created"), lambda e: None)
except TypeError:
raised = True
assert raised, "Expected TypeError for non-EventType event_type in unsubscribe"
@then("unsubscribing with a non-callable handler should raise TypeError")
def step_unsubscribe_non_callable(ctx: Context) -> None:
raised = False
try:
ctx.bus.unsubscribe(
EventType.PLAN_CREATED, cast(Callable[[DomainEvent], None], "not-callable")
)
except TypeError:
raised = True
assert raised, "Expected TypeError for non-callable handler in unsubscribe"
@then("the EventBus Protocol stubs should be callable")
def step_protocol_stubs_callable(ctx: Context) -> None:
"""Exercise Protocol method stubs directly to ensure 100% coverage."""
class _BareImpl(EventBus): # type: ignore[misc]
pass
class _BareImpl(EventBus):
def emit(self, event: DomainEvent) -> None: ...
def subscribe(
self, event_type: EventType, handler: Callable[[DomainEvent], None]
) -> None: ...
def unsubscribe(
self, event_type: EventType, handler: Callable[[DomainEvent], None]
) -> bool:
return True
obj = _BareImpl()
event = _make_event("plan.created")
@@ -269,6 +345,24 @@ def step_protocol_stubs_callable(ctx: Context) -> None:
obj.subscribe(EventType.PLAN_CREATED, lambda e: None)
@then("the EventBus Protocol unsubscribe stub should be callable")
def step_protocol_unsubscribe_callable(ctx: Context) -> None:
"""Exercise Protocol unsubscribe stub directly to ensure coverage."""
class _BareImpl(EventBus):
def emit(self, event: DomainEvent) -> None: ...
def subscribe(
self, event_type: EventType, handler: Callable[[DomainEvent], None]
) -> None: ...
def unsubscribe(
self, event_type: EventType, handler: Callable[[DomainEvent], None]
) -> bool:
return True
obj = _BareImpl()
obj.unsubscribe(EventType.PLAN_CREATED, lambda e: None)
# ---------------------------------------------------------------------------
# DecisionService event emission steps
# ---------------------------------------------------------------------------
@@ -477,56 +571,3 @@ def step_decision_service_has_event_bus(ctx: Context) -> None:
"DecisionService resolved from DI has no event_bus attribute"
)
assert svc.event_bus is not None, "DecisionService.event_bus should not be None"
# ---------------------------------------------------------------------------
# ReactiveEventBus.close() and context manager steps (issue #10378)
# ---------------------------------------------------------------------------
@when("bus.close() is called on the bus")
def step_close_bus(ctx: Context) -> None:
ctx.bus.close()
@then("the bus should be marked as closed")
def step_bus_is_closed(ctx: Context) -> None:
assert ctx.bus._closed, "Expected bus._closed to be True after close()"
@then("emitting after close should raise RuntimeError")
def step_emit_after_close_raises(ctx: Context) -> None:
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType
raised = False
try:
ctx.bus.emit(DomainEvent(event_type=EventType.PLAN_CREATED))
except RuntimeError:
raised = True
assert raised, "Expected RuntimeError when emitting after close()"
@when("bus.close() is called twice on the bus")
def step_close_bus_twice(ctx: Context) -> None:
ctx.bus.close()
ctx.bus.close() # should not raise
@then("no exception should be raised on double close")
def step_no_exception_on_double_close(ctx: Context) -> None:
# If we reached here without exception, the test passes
pass
@given("a ReactiveEventBus used as a context manager")
def step_given_bus_context_manager(ctx: Context) -> None:
ctx.bus = ReactiveEventBus()
ctx.cm_bus: ReactiveEventBus | None = None
with ctx.bus as bus:
ctx.cm_bus = bus
@then("the bus should be closed after the context exits")
def step_bus_closed_after_context(ctx: Context) -> None:
assert ctx.bus._closed, "Expected bus._closed to be True after context manager exit"
+20 -3
View File
@@ -15,6 +15,7 @@ publishes translated :class:`A2aEvent` instances to an event queue.
from __future__ import annotations
import contextlib
import json
from collections.abc import Callable
from typing import Any, ClassVar
@@ -24,6 +25,7 @@ from ulid import ULID
from cleveragents.a2a.errors import A2aNotAvailableError
from cleveragents.a2a.models import A2aEvent
from cleveragents.infrastructure.events.types import EventType
# ---------------------------------------------------------------------------
# SSE event type constants (A2A protocol)
@@ -261,20 +263,35 @@ class EventBusBridge:
self._event_bus = event_bus
self._event_queue = event_queue
self._subscription: Any | None = None
self._subscribed_types: frozenset[EventType] | None = None
def start(self) -> None:
"""Subscribe to the event bus and begin forwarding."""
if hasattr(self._event_bus, "subscribe"):
if not hasattr(self._event_bus, "subscribe"):
return
subscribed_types = frozenset(EventType)
try:
for et in subscribed_types:
self._event_bus.subscribe(et, self._on_domain_event)
except TypeError:
self._subscribed_types = None
self._subscription = self._event_bus.subscribe(self._on_domain_event)
logger.info("a2a.event_bridge.started")
else:
self._subscribed_types = subscribed_types
logger.info("a2a.event_bridge.started")
def stop(self) -> None:
"""Unsubscribe from the event bus."""
if self._subscribed_types is not None:
for et in self._subscribed_types:
with contextlib.suppress(TypeError, AttributeError):
self._event_bus.unsubscribe(et, self._on_domain_event)
self._subscribed_types = None
if self._subscription is not None:
if hasattr(self._subscription, "dispose"):
self._subscription.dispose()
self._subscription = None
logger.info("a2a.event_bridge.stopped")
logger.info("a2a.event_bridge.stopped")
def _on_domain_event(self, domain_event: Any) -> None:
"""Translate a domain event to an A2A event and publish."""
@@ -42,6 +42,7 @@ import threading
from collections import defaultdict
from collections.abc import Callable
from datetime import UTC, datetime
from typing import cast
import structlog
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -287,14 +288,14 @@ class FixThenRevalidateOrchestrator:
"auto_validation_fix must be between 0.0 and 1.0, "
f"got {auto_validation_fix}"
)
if event_bus is not None and not isinstance(event_bus, EventBus):
if event_bus is not None and not callable(getattr(event_bus, "emit", None)):
raise ValidationError("event_bus must be an EventBus instance or None")
self._pipeline = validation_pipeline
self._max_retries = max_retries
self._auto_strategy_revision = float(auto_strategy_revision)
self._auto_validation_fix = float(auto_validation_fix)
self._event_bus = event_bus
self._event_bus = cast(EventBus | None, event_bus)
# Per-(plan_id, validation_key) retry counters.
# Inner key is (validation_name, resource_id) to prevent
# same-named validations on different resources from sharing
@@ -105,5 +105,38 @@ class LoggingEventBus:
raise TypeError("handler must be callable")
self._subscriptions.setdefault(event_type, []).append(handler)
def unsubscribe(
self,
event_type: EventType,
handler: Callable[[DomainEvent], None],
) -> bool:
"""Remove *handler* for events of *event_type*.
Args:
event_type: The :class:`EventType` to stop listening for.
handler: Callable to remove (same object passed to
:meth:`subscribe`).
Returns:
True if the handler was found and removed, False otherwise.
Raises:
TypeError: If *event_type* is not an :class:`EventType`.
TypeError: If *handler* is not callable.
"""
if not isinstance(event_type, EventType):
raise TypeError(
f"event_type must be an EventType, got {type(event_type).__name__!r}"
)
if not callable(handler):
raise TypeError("handler must be callable")
handlers = self._subscriptions.get(event_type, [])
try:
handlers.remove(handler)
return True
except ValueError:
return False
__all__ = ["LoggingEventBus"]
@@ -49,5 +49,25 @@ class EventBus(Protocol):
"""
...
def unsubscribe(
self,
event_type: EventType,
handler: Callable[[DomainEvent], None],
) -> bool:
"""Remove *handler* for events of *event_type*.
Args:
event_type: The :class:`EventType` to stop listening for.
handler: The callable to remove.
Returns:
True if the handler was found and removed, False otherwise.
Raises:
TypeError: If *event_type* is not an :class:`EventType`.
TypeError: If *handler* is not callable.
"""
...
__all__ = ["EventBus"]
@@ -119,7 +119,7 @@ class ReactiveEventBus:
if self._closed:
raise RuntimeError(
"ReactiveEventBus.emit() called after close() "
"ReactiveEventBus.emit() called after close() - "
"the bus has been shut down and the RxPY Subject is completed"
)
@@ -174,6 +174,39 @@ class ReactiveEventBus:
raise TypeError("handler must be callable")
self._subscriptions.setdefault(event_type, []).append(handler)
def unsubscribe(
self,
event_type: EventType,
handler: Callable[[DomainEvent], None],
) -> bool:
"""Remove *handler* for events of *event_type*.
Args:
event_type: The :class:`EventType` to stop listening for.
handler: Callable to remove (same object passed to
:meth:`subscribe`).
Returns:
True if the handler was found and removed, False otherwise.
Raises:
TypeError: If *event_type* is not an :class:`EventType`.
TypeError: If *handler* is not callable.
"""
if not isinstance(event_type, EventType):
raise TypeError(
f"event_type must be an EventType, got {type(event_type).__name__!r}"
)
if not callable(handler):
raise TypeError("handler must be callable")
handlers = self._subscriptions.get(event_type, [])
try:
handlers.remove(handler)
return True
except ValueError:
return False
@property
def stream(self) -> Observable:
"""Read-only observable stream for advanced RxPY operators.
@@ -188,11 +221,10 @@ class ReactiveEventBus:
"""Complete the RxPY stream and prevent further event emission.
Signals ``on_completed()`` to all RxPY subscribers, allowing them to
finalize their state (e.g. buffering operators flush, aggregation
operators emit their final result). After ``close()``, any call to
:meth:`emit` raises :exc:`RuntimeError`.
finalize their state. After ``close()``, any call to :meth:`emit`
raises :exc:`RuntimeError`.
This method is idempotent calling it more than once is safe.
This method is idempotent - calling it more than once is safe.
Call this when the bus is no longer needed (e.g. in test teardown or
application shutdown) to release RxPY Subject resources and prevent
@@ -207,11 +239,7 @@ class ReactiveEventBus:
self._audit_log.clear()
def __enter__(self) -> ReactiveEventBus:
"""Support use as a context manager.
Returns:
The bus instance itself.
"""
"""Support use as a context manager."""
return self
def __exit__(
@@ -220,13 +248,7 @@ class ReactiveEventBus:
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Call :meth:`close` on context manager exit.
Args:
exc_type: Exception type, if any.
exc_val: Exception value, if any.
exc_tb: Exception traceback, if any.
"""
"""Call :meth:`close` on context manager exit."""
self.close()
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from collections.abc import Callable
from cleveragents.a2a.events import A2aEventQueue, EventBusBridge
from cleveragents.application.services.fix_then_revalidate import (
FixThenRevalidateOrchestrator,
)
from cleveragents.application.services.validation_pipeline import ValidationPipeline
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.reactive import ReactiveEventBus
from cleveragents.infrastructure.events.types import EventType
def _event(event_type: EventType = EventType.PLAN_CREATED) -> DomainEvent:
return DomainEvent(event_type=event_type)
def _pipeline() -> ValidationPipeline:
return ValidationPipeline(commands=[], executor=lambda _name, _args: {})
def test_reactive_event_bus_close_completes_and_rejects_later_emit() -> None:
bus = ReactiveEventBus()
completed: list[bool] = []
bus.stream.subscribe(on_completed=lambda: completed.append(True))
bus.close()
assert completed == [True]
try:
bus.emit(_event())
except RuntimeError:
pass
else:
raise AssertionError("emit after close should raise RuntimeError")
def test_reactive_event_bus_context_manager_closes_bus() -> None:
bus = ReactiveEventBus()
completed: list[bool] = []
bus.stream.subscribe(on_completed=lambda: completed.append(True))
with bus as entered:
assert entered is bus
assert bus._closed is True
assert completed == [True]
class _TypedEventBus:
def __init__(self) -> None:
self.handlers: dict[EventType, list[Callable[[DomainEvent], None]]] = {}
def emit(self, event: DomainEvent) -> None:
for handler in self.handlers.get(event.event_type, []):
handler(event)
def subscribe(
self, event_type: EventType, handler: Callable[[DomainEvent], None]
) -> None:
self.handlers.setdefault(event_type, []).append(handler)
def unsubscribe(
self, event_type: EventType, handler: Callable[[DomainEvent], None]
) -> bool:
handlers = self.handlers.get(event_type, [])
try:
handlers.remove(handler)
except ValueError:
return False
return True
def test_event_bus_bridge_unsubscribes_from_typed_event_bus() -> None:
bus = _TypedEventBus()
queue = A2aEventQueue()
bridge = EventBusBridge(bus, queue)
bridge.start()
bus.emit(_event())
bridge.stop()
bus.emit(_event())
assert len(queue.get_events()) == 1
assert all(not handlers for handlers in bus.handlers.values())
class _DisposableSubscription:
def __init__(self) -> None:
self.disposed = False
def dispose(self) -> None:
self.disposed = True
class _LegacyEventBus:
def __init__(self) -> None:
self.callback: Callable[[DomainEvent], None] | None = None
self.subscription = _DisposableSubscription()
def subscribe(self, callback: Callable[[DomainEvent], None]) -> object:
self.callback = callback
return self.subscription
def test_event_bus_bridge_still_supports_disposable_subscription_bus() -> None:
bus = _LegacyEventBus()
queue = A2aEventQueue()
bridge = EventBusBridge(bus, queue)
bridge.start()
assert bus.callback is not None
bus.callback(_event())
bridge.stop()
assert len(queue.get_events()) == 1
assert bus.subscription.disposed is True
assert bridge._subscription is None
class _EmitOnlyBus:
def emit(self, event: DomainEvent) -> None:
pass
def test_fix_then_revalidate_accepts_emit_only_event_sink() -> None:
orchestrator = FixThenRevalidateOrchestrator(
validation_pipeline=_pipeline(),
event_bus=_EmitOnlyBus(), # type: ignore[arg-type]
)
assert orchestrator.event_bus is not None