ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,769 @@
|
||||
"""Unit tests for chapter4/collaboration-tools/src/notification_dispatcher.py."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
# Ensure chapter4/collaboration-tools/src is in sys.path
|
||||
ch4_src = (Path(__file__).resolve().parent.parent / "chapter4" / "collaboration-tools" / "src").resolve()
|
||||
if str(ch4_src) not in sys.path:
|
||||
sys.path.insert(0, str(ch4_src))
|
||||
|
||||
from notification_dispatcher import (
|
||||
DecisionRequest,
|
||||
DecisionTrace,
|
||||
FallbackAction,
|
||||
NotificationDispatcher,
|
||||
dispatch_and_wait,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_channel_dispatch_all():
|
||||
"""Test unified multi-channel notification dispatching across mock channels."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
channels = ["telegram", "slack", "webhook", "email"]
|
||||
message = "Deployment preflight check completed."
|
||||
|
||||
results = await dispatcher.dispatch_all(channels, message, context={"env": "prod"})
|
||||
|
||||
assert len(results) == 4
|
||||
for res in results:
|
||||
assert res["success"] is True
|
||||
assert res["channel"] in channels
|
||||
assert "timestamp" in res
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hitl_human_approval_before_timeout():
|
||||
"""Test Human-in-the-Loop decision approval submitted before timeout."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
request_id = "req_test_approve_123"
|
||||
|
||||
request = {
|
||||
"request_id": request_id,
|
||||
"message": "Approve production schema migration",
|
||||
"channels": ["telegram", "slack"],
|
||||
"fallback_action": "auto-reject",
|
||||
}
|
||||
|
||||
# Start dispatch and wait in background task
|
||||
task = asyncio.create_task(dispatcher.dispatch_and_wait(request, timeout=2.0))
|
||||
|
||||
# Wait briefly for task to enter waiting state
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Submit human approval decision
|
||||
submitted = dispatcher.submit_decision(
|
||||
request_id=request_id, approved=True, notes="Approved by Lead DB Architect"
|
||||
)
|
||||
assert submitted is True
|
||||
|
||||
trace = await task
|
||||
|
||||
assert isinstance(trace, DecisionTrace)
|
||||
assert trace.request_id == request_id
|
||||
assert trace.approved is True
|
||||
assert trace.status == "approved"
|
||||
assert trace.decision == "approved"
|
||||
assert trace.fallback_triggered is False
|
||||
assert trace.notes == "Approved by Lead DB Architect"
|
||||
assert len(trace.channels_dispatched) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hitl_human_rejection_before_timeout():
|
||||
"""Test Human-in-the-Loop decision rejection submitted before timeout."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
request_id = "req_test_reject_456"
|
||||
|
||||
request = DecisionRequest(
|
||||
request_id=request_id,
|
||||
message="Request permission for data wipe",
|
||||
channels=["email"],
|
||||
fallback_action="auto-approve",
|
||||
)
|
||||
|
||||
task = asyncio.create_task(dispatcher.dispatch_and_wait(request, timeout=2.0))
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
submitted = dispatcher.submit_decision(
|
||||
request_id=request_id, approved=False, notes="Denied due to compliance"
|
||||
)
|
||||
assert submitted is True
|
||||
|
||||
trace = await task
|
||||
|
||||
assert trace.approved is False
|
||||
assert trace.status == "rejected"
|
||||
assert trace.decision == "rejected"
|
||||
assert trace.fallback_triggered is False
|
||||
assert trace.notes == "Denied due to compliance"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hitl_timeout_fallback_auto_approve():
|
||||
"""Test HITL timeout triggering auto-approve fallback policy."""
|
||||
dispatcher = NotificationDispatcher(fallback_action="auto-approve", use_mock_channels=True)
|
||||
|
||||
request = {
|
||||
"message": "Routine server restart",
|
||||
"fallback_action": "auto-approve",
|
||||
}
|
||||
|
||||
trace = await dispatcher.dispatch_and_wait(request, timeout=0.1)
|
||||
|
||||
assert trace.fallback_triggered is True
|
||||
assert trace.approved is True
|
||||
assert trace.status == "auto-approved"
|
||||
assert trace.decision == "auto-approved"
|
||||
assert "auto-approved request" in trace.notes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hitl_timeout_fallback_auto_reject():
|
||||
"""Test HITL timeout triggering auto-reject fallback policy."""
|
||||
dispatcher = NotificationDispatcher(fallback_action="auto-reject", use_mock_channels=True)
|
||||
|
||||
request = {
|
||||
"message": "High-risk administrative action",
|
||||
"fallback_action": "auto-reject",
|
||||
}
|
||||
|
||||
trace = await dispatcher.dispatch_and_wait(request, timeout=0.1)
|
||||
|
||||
assert trace.fallback_triggered is True
|
||||
assert trace.approved is False
|
||||
assert trace.status == "auto-rejected"
|
||||
assert trace.decision == "auto-rejected"
|
||||
assert "auto-rejected request" in trace.notes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hitl_timeout_fallback_escalate():
|
||||
"""Test HITL timeout triggering escalation fallback policy and escalation notification."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
|
||||
request = {
|
||||
"message": "Critical security policy exception",
|
||||
"channels": ["slack", "email"],
|
||||
"fallback_action": "escalate",
|
||||
}
|
||||
|
||||
trace = await dispatcher.dispatch_and_wait(request, timeout=0.1)
|
||||
|
||||
assert trace.fallback_triggered is True
|
||||
assert trace.approved is False
|
||||
assert trace.status == "escalated"
|
||||
assert trace.decision == "escalated"
|
||||
assert "escalated request" in trace.notes
|
||||
|
||||
|
||||
def test_custom_channel_handler():
|
||||
"""Test registering a custom channel handler."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
|
||||
invoked = []
|
||||
|
||||
def custom_pager(msg, ctx):
|
||||
invoked.append((msg, ctx))
|
||||
return {"pager_id": "pager_999"}
|
||||
|
||||
dispatcher.register_channel_handler("pager", custom_pager)
|
||||
|
||||
res = asyncio.run(dispatcher.dispatch_notification("pager", "Alert!", {"severity": 1}))
|
||||
|
||||
assert res["success"] is True
|
||||
assert res["channel"] == "pager"
|
||||
assert res["result"] == {"pager_id": "pager_999"}
|
||||
assert len(invoked) == 1
|
||||
|
||||
|
||||
def test_sync_wrapper():
|
||||
"""Test synchronous dispatch_and_wait_sync wrapper."""
|
||||
dispatcher = NotificationDispatcher(fallback_action="auto-approve", use_mock_channels=True)
|
||||
|
||||
trace = dispatcher.dispatch_and_wait_sync("Ping test", timeout=0.05)
|
||||
|
||||
assert isinstance(trace, DecisionTrace)
|
||||
assert trace.approved is True
|
||||
assert trace.status == "auto-approved"
|
||||
assert trace.fallback_triggered is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatcher_default_channels_honored():
|
||||
"""Test that configured default_channels on dispatcher are honored when request has no channels."""
|
||||
dispatcher = NotificationDispatcher(default_channels=["slack"], use_mock_channels=True)
|
||||
trace = await dispatcher.dispatch_and_wait("Test msg", timeout=0.05)
|
||||
assert len(trace.channels_dispatched) == 1
|
||||
assert trace.channels_dispatched[0]["channel"] == "slack"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_decision_string_accepted():
|
||||
"""Test that custom decision string submitted by operator is preserved without fallback trigger."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
req_id = "req_custom_dec_1"
|
||||
request = {"request_id": req_id, "message": "Deploy code"}
|
||||
|
||||
task = asyncio.create_task(dispatcher.dispatch_and_wait(request, timeout=2.0))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
dispatcher.submit_decision(req_id, approved=True, decision="approved_by_lead")
|
||||
trace = await task
|
||||
|
||||
assert trace.fallback_triggered is False
|
||||
assert trace.approved is True
|
||||
assert trace.decision == "approved_by_lead"
|
||||
assert trace.status == "approved_by_lead"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_on_cancellation():
|
||||
"""Test that pending requests and decision events are cleaned up if task is cancelled."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
req_id = "req_cancel_test"
|
||||
task = asyncio.create_task(
|
||||
dispatcher.dispatch_and_wait({"request_id": req_id, "message": "Long wait"}, timeout=10.0)
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
assert req_id in dispatcher._pending_requests
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
assert req_id not in dispatcher._pending_requests
|
||||
assert req_id not in dispatcher._decision_events
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_late_decision_submission_rejected_after_fallback():
|
||||
"""Test that submitting a decision after fallback policy has triggered returns False."""
|
||||
dispatcher = NotificationDispatcher(fallback_action="escalate", use_mock_channels=True)
|
||||
|
||||
# Slow custom channel to simulate delay during escalation dispatch
|
||||
async def slow_channel(msg, ctx):
|
||||
await asyncio.sleep(0.3)
|
||||
return {"sent": True}
|
||||
|
||||
dispatcher.register_channel_handler("slow", slow_channel)
|
||||
req_id = "req_late_sub"
|
||||
request = {
|
||||
"request_id": req_id,
|
||||
"message": "Escalated task",
|
||||
"channels": ["slow"],
|
||||
"fallback_action": "escalate",
|
||||
}
|
||||
|
||||
task = asyncio.create_task(dispatcher.dispatch_and_wait(request, timeout=0.05))
|
||||
await asyncio.sleep(0.4)
|
||||
|
||||
# Attempt decision submission after timeout
|
||||
submitted = dispatcher.submit_decision(req_id, approved=True)
|
||||
assert submitted is False
|
||||
|
||||
trace = await task
|
||||
assert trace.status == "escalated"
|
||||
assert trace.fallback_triggered is True
|
||||
|
||||
|
||||
def test_custom_channel_handler_failure_dict():
|
||||
"""Test that a custom channel returning success=False in dict result is marked as success=False."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
|
||||
def failing_handler(msg, ctx):
|
||||
return {"success": False, "error": "Gateway unavailable"}
|
||||
|
||||
dispatcher.register_channel_handler("sms", failing_handler)
|
||||
res = asyncio.run(dispatcher.dispatch_notification("sms", "Test sms"))
|
||||
|
||||
assert res["success"] is False
|
||||
assert res["channel"] == "sms"
|
||||
assert res["result"]["error"] == "Gateway unavailable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decision_request_default_channels_none():
|
||||
"""Test DecisionRequest has channels default to None."""
|
||||
req = DecisionRequest(message="Test message")
|
||||
assert req.channels is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_pending_record_with_custom_decision_string_without_approved():
|
||||
"""Test that a non-pending record with a custom decision string and approved=None is accepted."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
req_id = "req_custom_no_approved"
|
||||
request = {"request_id": req_id, "message": "Manual override test"}
|
||||
|
||||
task = asyncio.create_task(dispatcher.dispatch_and_wait(request, timeout=2.0))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# Manually set non-pending status with custom decision and no approved boolean
|
||||
dispatcher._pending_requests[req_id]["status"] = "deferred"
|
||||
dispatcher._pending_requests[req_id]["decision"] = "deferred"
|
||||
dispatcher._pending_requests[req_id]["approved"] = None
|
||||
dispatcher._decision_events[req_id].set()
|
||||
|
||||
trace = await task
|
||||
assert trace.fallback_triggered is False
|
||||
assert trace.status == "deferred"
|
||||
assert trace.decision == "deferred"
|
||||
assert trace.approved is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_try_finally_cleanup_on_dispatch_exception():
|
||||
"""Test that pending requests and decision events are cleaned up even if dispatch raises an exception."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
|
||||
async def mock_raise(*args, **kwargs):
|
||||
raise RuntimeError("Internal dispatch pipeline failure")
|
||||
|
||||
dispatcher.dispatch_all = mock_raise
|
||||
req_id = "req_exception_cleanup"
|
||||
request = DecisionRequest(request_id=req_id, message="Fail test")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Internal dispatch pipeline failure"):
|
||||
await dispatcher.dispatch_and_wait(request, timeout=1.0)
|
||||
|
||||
assert req_id not in dispatcher._pending_requests
|
||||
assert req_id not in dispatcher._decision_events
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enum_fallback_action_normalization():
|
||||
"""Test that passing FallbackAction Enum instances normalizes correctly."""
|
||||
dispatcher = NotificationDispatcher(fallback_action=FallbackAction.AUTO_APPROVE, use_mock_channels=True)
|
||||
assert dispatcher.fallback_action == "auto-approve"
|
||||
|
||||
request = DecisionRequest(message="Enum test", fallback_action=FallbackAction.ESCALATE)
|
||||
trace = await dispatcher.dispatch_and_wait(request, timeout=0.05)
|
||||
assert trace.fallback_action == "escalate"
|
||||
assert trace.status == "escalated"
|
||||
|
||||
def test_custom_channel_handler_returns_false():
|
||||
"""Test that a custom channel returning boolean False is marked as success=False."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
|
||||
def false_handler(msg, ctx):
|
||||
return False
|
||||
|
||||
dispatcher.register_channel_handler("webhook_custom", false_handler)
|
||||
res = asyncio.run(dispatcher.dispatch_notification("webhook_custom", "Test message"))
|
||||
|
||||
assert res["success"] is False
|
||||
assert res["channel"] == "webhook_custom"
|
||||
assert res["result"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_decision_non_boolean_approved():
|
||||
"""Test submit_decision with non-boolean approved argument preserves custom decision string."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
req_id = "req_non_bool"
|
||||
request = DecisionRequest(request_id=req_id, message="Non-bool test")
|
||||
|
||||
task = asyncio.create_task(dispatcher.dispatch_and_wait(request, timeout=2.0))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
submitted = dispatcher.submit_decision(req_id, approved="custom_approved_status")
|
||||
assert submitted is True
|
||||
|
||||
trace = await task
|
||||
assert trace.fallback_triggered is False
|
||||
assert trace.status == "custom_approved_status"
|
||||
assert trace.decision == "custom_approved_status"
|
||||
assert trace.approved is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enum_string_fallback_action_normalization():
|
||||
"""Test that string representation of Enum like 'FallbackAction.AUTO_APPROVE' normalizes correctly."""
|
||||
dispatcher = NotificationDispatcher(fallback_action="FallbackAction.AUTO_APPROVE", use_mock_channels=True)
|
||||
assert dispatcher.fallback_action == "auto-approve"
|
||||
|
||||
request = DecisionRequest(message="Enum string test", fallback_action="FallbackAction.ESCALATE")
|
||||
trace = await dispatcher.dispatch_and_wait(request, timeout=0.05)
|
||||
assert trace.fallback_action == "escalate"
|
||||
assert trace.status == "escalated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_late_decision_rejection_is_logged(caplog):
|
||||
"""Regression: late human decision after timeout fallback must be explicitly rejected with a log warning, not silently dropped."""
|
||||
dispatcher = NotificationDispatcher(fallback_action="auto-reject", use_mock_channels=True)
|
||||
req_id = "req_late_logged"
|
||||
|
||||
# Simulate a request already resolved by timeout fallback
|
||||
dispatcher._pending_requests[req_id] = {
|
||||
"request_id": req_id,
|
||||
"message": "Late decision log test",
|
||||
"channels": ["telegram"],
|
||||
"fallback_action": "auto-reject",
|
||||
"status": "auto-rejected",
|
||||
"approved": False,
|
||||
"decision": "auto-rejected",
|
||||
"notes": "Timeout reached",
|
||||
"dispatched_at": "2025-01-01T00:00:00+00:00",
|
||||
"resolved_at": "2025-01-01T00:00:01+00:00",
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
submitted = dispatcher.submit_decision(req_id, approved=True)
|
||||
|
||||
assert submitted is False
|
||||
assert any(
|
||||
"late decision" in record.message.lower() for record in caplog.records
|
||||
), "Expected a warning log when late decision is rejected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_reject_decision_recorded_as_rejected():
|
||||
"""Regression: human-submitted text 'reject' must be recorded as rejected (approved=False), not approved."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
req_id = "req_text_reject"
|
||||
request = DecisionRequest(request_id=req_id, message="Reject text test")
|
||||
|
||||
task = asyncio.create_task(dispatcher.dispatch_and_wait(request, timeout=2.0))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
submitted = dispatcher.submit_decision(req_id, approved="reject")
|
||||
assert submitted is True
|
||||
|
||||
trace = await task
|
||||
assert trace.approved is False
|
||||
assert trace.fallback_triggered is False
|
||||
assert trace.decision == "reject"
|
||||
assert trace.status == "reject"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_deny_decision_recorded_as_rejected():
|
||||
"""Regression: human-submitted text 'deny' must be recorded as rejected (approved=False), not approved."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
req_id = "req_text_deny"
|
||||
request = DecisionRequest(request_id=req_id, message="Deny text test")
|
||||
|
||||
task = asyncio.create_task(dispatcher.dispatch_and_wait(request, timeout=2.0))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
submitted = dispatcher.submit_decision(req_id, approved="deny")
|
||||
assert submitted is True
|
||||
|
||||
trace = await task
|
||||
assert trace.approved is False
|
||||
assert trace.fallback_triggered is False
|
||||
assert trace.decision == "deny"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_request_id_preserves_existing_decision():
|
||||
"""Regression: re-submitting same request ID must not discard an existing human decision by overwriting with a fresh pending record."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
req_id = "req_dup_preserve"
|
||||
|
||||
# Pre-populate a pending request that already has a human decision submitted
|
||||
dispatcher._pending_requests[req_id] = {
|
||||
"request_id": req_id,
|
||||
"message": "Original request",
|
||||
"channels": ["telegram"],
|
||||
"fallback_action": "auto-reject",
|
||||
"status": "approved",
|
||||
"approved": True,
|
||||
"decision": "approved",
|
||||
"notes": "Approved by lead",
|
||||
"dispatched_at": "2025-01-01T00:00:00+00:00",
|
||||
"resolved_at": "2025-01-01T00:00:01+00:00",
|
||||
}
|
||||
|
||||
request = DecisionRequest(request_id=req_id, message="Duplicate request")
|
||||
trace = await dispatcher.dispatch_and_wait(request, timeout=0.1)
|
||||
|
||||
# The existing decision must be preserved, not overwritten to pending + fallback
|
||||
assert trace.approved is True
|
||||
assert trace.status == "approved"
|
||||
assert trace.fallback_triggered is False
|
||||
assert trace.decision == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_as_decision_string_rejected():
|
||||
"""Regression: 'pending' is reserved; submitting it as a decision must be rejected, not silently treated as timeout."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
req_id = "req_pending_str"
|
||||
request = DecisionRequest(request_id=req_id, message="Pending string test")
|
||||
|
||||
task = asyncio.create_task(dispatcher.dispatch_and_wait(request, timeout=0.1))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
submitted = dispatcher.submit_decision(req_id, approved=True, decision="pending")
|
||||
assert submitted is False
|
||||
|
||||
trace = await task
|
||||
# No human decision was accepted, so fallback must fire
|
||||
assert trace.fallback_triggered is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_exception_does_not_kill_dispatch_all():
|
||||
"""Regression: a single channel raising must not abort the entire dispatch_all batch."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
|
||||
def boom_handler(msg, ctx):
|
||||
raise RuntimeError("channel exploded")
|
||||
|
||||
dispatcher.register_channel_handler("boom", boom_handler)
|
||||
results = await dispatcher.dispatch_all(["boom", "telegram"], "msg", {})
|
||||
|
||||
# The healthy channel must still produce a result
|
||||
assert len(results) == 2
|
||||
telegram_result = [r for r in results if isinstance(r, dict) and r.get("channel") == "telegram"]
|
||||
assert len(telegram_result) == 1
|
||||
assert telegram_result[0]["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_and_unsupported_channels_include_timestamp():
|
||||
"""Regression: all dispatch branches must return a 'timestamp' key for downstream consumers."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
|
||||
def sync_handler(msg, ctx):
|
||||
return {"info": "ok"}
|
||||
|
||||
dispatcher.register_channel_handler("custom_ts", sync_handler)
|
||||
custom_res = await dispatcher.dispatch_notification("custom_ts", "msg", {})
|
||||
assert "timestamp" in custom_res
|
||||
|
||||
unsupported_res = await dispatcher.dispatch_notification("nonexistent_channel", "msg", {})
|
||||
assert "timestamp" in unsupported_res
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalation_dispatch_timeout_does_not_hang():
|
||||
"""Regression: escalation dispatch must have a timeout so a slow channel cannot block dispatch_and_wait indefinitely."""
|
||||
dispatcher = NotificationDispatcher(fallback_action="escalate", use_mock_channels=True)
|
||||
|
||||
call_count = 0
|
||||
async def fast_then_slow(msg, ctx):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count > 1:
|
||||
await asyncio.sleep(10)
|
||||
return {"sent": True}
|
||||
|
||||
dispatcher.register_channel_handler("slow_esc", fast_then_slow)
|
||||
request = DecisionRequest(
|
||||
message="Escalation timeout test",
|
||||
channels=["slow_esc"],
|
||||
fallback_action="escalate",
|
||||
)
|
||||
|
||||
# timeout=0.05 means the decision wait times out quickly, then escalation
|
||||
# dispatch gets the same 0.05s budget. Total should be well under 5s.
|
||||
trace = await asyncio.wait_for(
|
||||
dispatcher.dispatch_and_wait(request, timeout=0.05),
|
||||
timeout=5.0,
|
||||
)
|
||||
assert trace.fallback_triggered is True
|
||||
assert trace.status == "escalated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unconfigured_production_channel_fails_explicitly():
|
||||
"""Regression: an unconfigured production channel must fail explicitly, not silently succeed.
|
||||
|
||||
Closes the class where mock channels reported success by default, causing
|
||||
dispatch_and_wait to mark a HITL request as dispatched when nothing left
|
||||
the process. With use_mock_channels=False (the default), a built-in
|
||||
channel with no configured adapter must return success=False with an error.
|
||||
"""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=False)
|
||||
res = await dispatcher.dispatch_notification("telegram", "test message", {})
|
||||
assert res["success"] is False
|
||||
assert "error" in res
|
||||
assert "timestamp" in res
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unconfigured_webhook_fails_explicitly():
|
||||
"""Regression: webhook channel without webhook_url in channel_config must fail explicitly."""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=False)
|
||||
res = await dispatcher.dispatch_notification("webhook", "test message", {})
|
||||
assert res["success"] is False
|
||||
assert "webhook_url" in res["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_adapter_routes_to_notification_tools(monkeypatch):
|
||||
"""Regression: built-in channels route to real notification_tools adapters, not mocks.
|
||||
|
||||
Verifies the adapter boundary: when use_mock_channels=False and a real
|
||||
adapter is loaded, dispatch delegates to it. The adapter's own
|
||||
not-configured error propagates as success=False.
|
||||
"""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=False)
|
||||
|
||||
# If notification_tools is importable, the adapter should be loaded
|
||||
if "telegram" not in dispatcher._real_adapters:
|
||||
pytest.skip("notification_tools not importable in this environment")
|
||||
|
||||
res = await dispatcher.dispatch_notification("telegram", "test", {})
|
||||
# The real adapter returns success=False when no token is configured
|
||||
assert res["success"] is False
|
||||
assert "error" in res
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_channels_opt_in_still_succeeds():
|
||||
"""Regression: use_mock_channels=True preserves the original mock success behavior.
|
||||
|
||||
Ensures the opt-in mock path still returns success=True for built-in
|
||||
channels, so existing test suites that rely on mock delivery continue
|
||||
to work.
|
||||
"""
|
||||
dispatcher = NotificationDispatcher(use_mock_channels=True)
|
||||
res = await dispatcher.dispatch_notification("telegram", "test", {})
|
||||
assert res["success"] is True
|
||||
assert res["channel"] == "telegram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_config_passed_to_real_adapter(monkeypatch):
|
||||
"""Regression: channel_config values are forwarded to the real adapter.
|
||||
|
||||
Verifies that config like chat_id, webhook_url, and to_email are passed
|
||||
through to the adapter callable, not ignored.
|
||||
"""
|
||||
dispatcher = NotificationDispatcher(
|
||||
use_mock_channels=False,
|
||||
channel_config={"slack": {"webhook_url": "https://hooks.example.com/test"}},
|
||||
)
|
||||
|
||||
# Monkeypatch the real adapter to capture args
|
||||
captured = {}
|
||||
|
||||
async def fake_slack(message, webhook_url=None, channel=None, username="Collaboration Agent"):
|
||||
captured["message"] = message
|
||||
captured["webhook_url"] = webhook_url
|
||||
captured["channel"] = channel
|
||||
return {"success": True, "channel": channel or "default"}
|
||||
|
||||
dispatcher._real_adapters["slack"] = fake_slack
|
||||
res = await dispatcher.dispatch_notification("slack", "hello", {})
|
||||
|
||||
assert res["success"] is True
|
||||
assert captured["message"] == "hello"
|
||||
assert captured["webhook_url"] == "https://hooks.example.com/test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_and_wait_does_not_silently_succeed_unconfigured():
|
||||
"""Regression: dispatch_and_wait with unconfigured channels must not mark dispatched as successful.
|
||||
|
||||
The channels_dispatched results must show success=False for unconfigured
|
||||
production channels, so downstream consumers know nothing was delivered.
|
||||
"""
|
||||
dispatcher = NotificationDispatcher(
|
||||
use_mock_channels=False,
|
||||
fallback_action="auto-reject",
|
||||
)
|
||||
trace = await dispatcher.dispatch_and_wait(
|
||||
{"message": "test", "channels": ["telegram"]}, timeout=0.05
|
||||
)
|
||||
# Fallback fires on timeout, but channel dispatch must show failure
|
||||
assert trace.fallback_triggered is True
|
||||
channel_result = trace.channels_dispatched[0]
|
||||
assert channel_result["success"] is False
|
||||
assert "error" in channel_result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_duplicate_request_id_same_decision():
|
||||
"""Regression: two simultaneous dispatches sharing a request_id get the
|
||||
same decision when one approval is submitted.
|
||||
|
||||
Closes the class where dispatch_and_wait assigned a new asyncio.Event to
|
||||
_decision_events[request_id] before checking for an existing pending
|
||||
request. The duplicate branch then retrieved the newly assigned event
|
||||
rather than the first waiter's event, so a single approval submission
|
||||
woke only the second waiter while the first timed out to auto-rejected,
|
||||
producing contradictory HITL decisions for one request.
|
||||
"""
|
||||
dispatcher = NotificationDispatcher(
|
||||
use_mock_channels=True,
|
||||
fallback_action="auto-reject",
|
||||
)
|
||||
request_id = "dup-req-001"
|
||||
req = DecisionRequest(
|
||||
message="Concurrent duplicate test",
|
||||
channels=["slack"],
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
# Launch both dispatches concurrently so they overlap while pending.
|
||||
task_a = asyncio.create_task(dispatcher.dispatch_and_wait(req, timeout=2.0))
|
||||
task_b = asyncio.create_task(dispatcher.dispatch_and_wait(req, timeout=2.0))
|
||||
|
||||
# Give both tasks time to register as waiters on the same request_id.
|
||||
await asyncio.sleep(0.1)
|
||||
assert dispatcher._waiter_counts.get(request_id) == 2
|
||||
|
||||
# Submit a single approval — both waiters must see the same decision.
|
||||
dispatcher.submit_decision(request_id, approved=True, decision="approved")
|
||||
|
||||
trace_a = await task_a
|
||||
trace_b = await task_b
|
||||
|
||||
assert trace_a.request_id == request_id
|
||||
assert trace_b.request_id == request_id
|
||||
assert trace_a.approved is True
|
||||
assert trace_b.approved is True
|
||||
assert trace_a.status == "approved"
|
||||
assert trace_b.status == "approved"
|
||||
assert trace_a.fallback_triggered is False
|
||||
assert trace_b.fallback_triggered is False
|
||||
|
||||
# Cleanup must have removed all tracking for this request_id.
|
||||
assert request_id not in dispatcher._pending_requests
|
||||
assert request_id not in dispatcher._decision_events
|
||||
assert request_id not in dispatcher._waiter_counts
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_dispatch_timeout_does_not_block_hitl_timeout():
|
||||
"""Regression: a slow initial dispatch must not prevent the HITL timeout.
|
||||
|
||||
The initial dispatch_all() is bounded by the same deadline as the
|
||||
HITL wait. A slow or hung channel that exceeds the timeout must
|
||||
trigger the fallback, not block indefinitely. This is analogous to
|
||||
the escalation-dispatch timeout test but for the initial
|
||||
notification path.
|
||||
"""
|
||||
dispatcher = NotificationDispatcher(
|
||||
fallback_action="auto-reject", use_mock_channels=True
|
||||
)
|
||||
|
||||
async def slow_handler(msg, ctx):
|
||||
await asyncio.sleep(10)
|
||||
return {"sent": True}
|
||||
|
||||
dispatcher.register_channel_handler("slow_init", slow_handler)
|
||||
request = DecisionRequest(
|
||||
message="Initial dispatch timeout test",
|
||||
channels=["slow_init"],
|
||||
fallback_action="auto-reject",
|
||||
)
|
||||
|
||||
# timeout=0.05 means the initial dispatch must be bounded to 0.05s.
|
||||
# Without the fix, the slow handler blocks for 10s and the fallback
|
||||
# never runs. Total must be well under 5s.
|
||||
trace = await asyncio.wait_for(
|
||||
dispatcher.dispatch_and_wait(request, timeout=0.05),
|
||||
timeout=5.0,
|
||||
)
|
||||
assert trace.fallback_triggered is True
|
||||
assert trace.status == "auto-rejected"
|
||||
assert trace.approved is False
|
||||
Reference in New Issue
Block a user