mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-15 15:50:35 +00:00
* feat(goal): add /goal from-chat for IM channels First slice of #7050: add an explicit IM command that composes the active goal from the recent conversation instead of taking the text literally. Natural-language intent detection remains out of scope. Gateway: - POST /api/threads/{thread_id}/goal/from-chat synthesizes the objective from the thread summary and recent agreement turns (visible user/assistant text, clarification questions, and card answers), preserving agreed constraints and the stopping condition. - Reject a busy thread before the model call; the save reuses the existing checkpoint-write reservation so a run that starts mid-synthesis still wins. - Refuse to synthesize while a human-input request is unanswered, and return structured no_context/unclear/synthesis_failed outcomes without echoing model-generated reasons. IM: - /goal from-chat [hint] shows the saved objective, then starts exactly one run through the ordinary chat path; a longer read timeout covers synthesis. - Map busy/pending/unclear/no-context/timeout outcomes to localized replies; a saved goal whose run cannot start is reported honestly. - The TUI explains that from-chat is not supported yet; the web input box still treats it literally (documented follow-up). Docs: README, CHANGELOG, IM channel guide, TUI guide, AGENTS, and the acceptance plan/execution record. Tests: new route, synthesis and manager suites, plus parser and TUI coverage. * fix(goal): keep the from-chat confirmation visible on every channel The saved-objective reply was published as a non-final progress update. QQ and DingTalk card mode drop non-final outbounds before a stream exists, and Feishu/Telegram fold the update into the run's stream, so the run output overwrote the objective. Publish it as the turn's final message. Redact PII on whole lines and the summary before the context budget can split an identifier; the composed prompt is still redacted once more as defense in depth, matching the goal evaluator's direct call. Also correct the gateway/IM docs (from-chat never creates a checkpoint, the busy pre-check is worker-local, and synthesis uses the deployment default model), fix the route test clock patch, and add regression tests for the final-outbound and truncation-boundary cases. * fix(goal): align from-chat with the goal-route admission gates Reject a from-chat save while a tool approval is pending, matching PUT/DELETE /goal, and suppress subagent-batch continuations queued under the previous goal after the save. Both invariants landed upstream while this branch was in review; the new route now shares them. Add route tests for both behaviors. * docs(changelog): remove the from-chat entry to avoid conflicts The maintainer asked to drop the changelog entry because the section conflicts with upstream changes. The feature remains documented in the goals guide and README. * fix(goal): close three review gaps in /goal from-chat - Enforce model-use authorization for the deployment default model before the synthesis call, matching the suggestions route, so a denied role never sends conversation context to the model. - Save with expected_checkpoint_id pinned to the head the synthesis read; a write that landed during the model call is a 409 goal_changed instead of silently clobbering the newer goal. - Re-check the conversation binding before starting the run: a /new during synthesis keeps the goal in the original thread and reports that the task did not start instead of running it in the switched conversation. * fix(goal): preserve from-chat handoff and run status Pin the saved thread through run preparation and reject a changed binding. Read configuration and model authorization off the event loop, and report rejected starts separately from requests whose response was lost. Cover the handoff races and streaming/wait admission outcomes through the real channel manager and SDK transport, plus file-backed config under the strict blocking-I/O detector. Co-authored-by: Loveca <44801706+Loveca@users.noreply.github.com> * fix(goal): preserve reasoning tags in synthesis JSON Use the existing JSON-aware reasoning stripper so literal tag text in objective and reason values survives parsing. Cover complete and unclosed tags while still removing model reasoning before the JSON payload. Co-authored-by: Loveca <44801706+Loveca@users.noreply.github.com> --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com> Co-authored-by: Totoro <msy626836554@gmail.com>
391 lines
18 KiB
Python
391 lines
18 KiB
Python
"""IM manager handling of ``/goal from-chat`` (the #7050 first slice).
|
||
|
||
The manager branch differs from the existing ``set`` branch in one deliberate
|
||
way: ``set`` starts the run silently because the user typed the objective
|
||
themselves, but ``from-chat`` synthesizes it, so the user must be shown what
|
||
was saved. On success the manager publishes a ``from_chat_saved`` confirmation
|
||
carrying the objective, then replaces the message text with that objective and
|
||
hands it to ``_handle_chat`` so exactly one run starts through the ordinary
|
||
path, and returns ``None`` (the run's own streaming output follows).
|
||
|
||
The pins here: the confirmation-then-single-run success path, the reply
|
||
mapping for each Gateway outcome, the no-thread short circuit, that a
|
||
saved-but-not-started run is reported honestly rather than silently, and that
|
||
a failed confirmation card does not strand the saved objective.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import tempfile
|
||
from dataclasses import replace
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock
|
||
|
||
import httpx
|
||
import pytest
|
||
from langgraph_sdk._async import LangGraphClient
|
||
|
||
from app.channels.manager import ChannelManager
|
||
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus
|
||
from app.channels.store import JsonChannelStore
|
||
|
||
|
||
def _run(coro):
|
||
import asyncio
|
||
|
||
loop = asyncio.new_event_loop()
|
||
try:
|
||
return loop.run_until_complete(coro)
|
||
finally:
|
||
loop.close()
|
||
|
||
|
||
def _msg(text: str) -> InboundMessage:
|
||
return InboundMessage(
|
||
channel_name="feishu",
|
||
chat_id="C1",
|
||
user_id="U1",
|
||
text=text,
|
||
msg_type=InboundMessageType.COMMAND,
|
||
)
|
||
|
||
|
||
def _make_manager(monkeypatch, *, thread_id: str | None = "t-1", respond=None) -> tuple[ChannelManager, list[httpx.Request]]:
|
||
"""A manager whose goal HTTP calls hit ``respond`` (an httpx.MockTransport handler)."""
|
||
bus = MessageBus()
|
||
store = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
|
||
manager = ChannelManager(bus=bus, store=store, gateway_url="http://gateway:8001")
|
||
|
||
async def _lookup(msg):
|
||
return thread_id
|
||
|
||
monkeypatch.setattr(manager, "_lookup_thread_id", _lookup)
|
||
|
||
calls: list[httpx.Request] = []
|
||
|
||
def _respond(request: httpx.Request) -> httpx.Response:
|
||
calls.append(request)
|
||
return respond(request) if respond else httpx.Response(200, json={"goal": {"objective": "x", "status": "active"}})
|
||
|
||
real_async_client = httpx.AsyncClient
|
||
monkeypatch.setattr(
|
||
"app.channels.manager.httpx.AsyncClient",
|
||
lambda *a, **kw: real_async_client(*a, transport=httpx.MockTransport(_respond), **kw),
|
||
)
|
||
return manager, calls
|
||
|
||
|
||
def _captured_replies(manager: ChannelManager) -> list:
|
||
replies = []
|
||
manager.bus.subscribe_outbound(AsyncMock(side_effect=replies.append))
|
||
return replies
|
||
|
||
|
||
def test_from_chat_success_confirms_objective_then_starts_one_run(monkeypatch):
|
||
objective = "Reconcile vendors A and B, excluding C, until every discrepancy is explained."
|
||
manager, calls = _make_manager(monkeypatch, respond=lambda r: httpx.Response(200, json={"goal": {"objective": objective, "status": "active"}}))
|
||
replies = _captured_replies(manager)
|
||
chat = AsyncMock()
|
||
monkeypatch.setattr(manager, "_handle_chat", chat)
|
||
|
||
result = _run(manager._handle_goal_command(_msg("/goal from-chat"), "from-chat"))
|
||
|
||
# reply is None: the run's own streaming output follows, like the set branch.
|
||
assert result is None
|
||
assert calls[0].method == "POST"
|
||
assert calls[0].url.path == "/api/threads/t-1/goal/from-chat"
|
||
# The synthesized objective is shown to the user before the run starts.
|
||
# It must be a final outbound: a progress update would be dropped by
|
||
# QQ/DingTalk or overwritten by the run's stream on Feishu/Telegram.
|
||
assert len(replies) == 1
|
||
assert objective in replies[0].text
|
||
assert replies[0].is_final is True
|
||
chat.assert_awaited_once()
|
||
started = chat.await_args.args[0]
|
||
assert started.text == objective
|
||
assert started.msg_type == InboundMessageType.CHAT
|
||
assert chat.await_args.kwargs == {"bound_identity_checked": True, "expected_thread_id": "t-1"}
|
||
|
||
|
||
def test_from_chat_confirmation_card_failure_still_starts_the_run(monkeypatch):
|
||
"""The goal is already saved; a failed confirmation card must not strand it."""
|
||
objective = "Ship the reconciliation."
|
||
manager, _ = _make_manager(monkeypatch, respond=lambda r: httpx.Response(200, json={"goal": {"objective": objective, "status": "active"}}))
|
||
chat = AsyncMock()
|
||
monkeypatch.setattr(manager, "_handle_chat", chat)
|
||
publish = AsyncMock(side_effect=RuntimeError("feishu send failed"))
|
||
monkeypatch.setattr(manager, "_publish_progress_update", publish)
|
||
|
||
result = _run(manager._handle_goal_command(_msg("/goal from-chat"), "from-chat"))
|
||
|
||
# The card failed, but the run still starts with the saved objective.
|
||
assert result is None
|
||
publish.assert_awaited_once()
|
||
chat.assert_awaited_once()
|
||
assert chat.await_args.args[0].text == objective
|
||
|
||
|
||
def test_from_chat_guidance_is_forwarded(monkeypatch):
|
||
manager, calls = _make_manager(monkeypatch, respond=lambda r: httpx.Response(200, json={"goal": {"objective": "Ship it.", "status": "active"}}))
|
||
_captured_replies(manager)
|
||
monkeypatch.setattr(manager, "_handle_chat", AsyncMock())
|
||
|
||
_run(manager._handle_goal_command(_msg("/goal from-chat focus on the vendor total"), "from-chat focus on the vendor total"))
|
||
|
||
assert calls[0].url.path == "/api/threads/t-1/goal/from-chat"
|
||
assert json.loads(calls[0].content) == {"guidance": "focus on the vendor total"}
|
||
|
||
|
||
def test_from_chat_waits_past_the_checkpoint_timeout_for_synthesis(monkeypatch):
|
||
# The Gateway answers this route only after an LLM synthesis call; the
|
||
# shared 10s budget used by the other /goal calls fired mid-save in the
|
||
# live Feishu run, so the manager reported failure for a goal that was
|
||
# saved seconds later.
|
||
manager, calls = _make_manager(monkeypatch, respond=lambda r: httpx.Response(200, json={"goal": {"objective": "Ship it.", "status": "active"}}))
|
||
_captured_replies(manager)
|
||
monkeypatch.setattr(manager, "_handle_chat", AsyncMock())
|
||
|
||
_run(manager._handle_goal_command(_msg("/goal from-chat"), "from-chat"))
|
||
|
||
timeout = calls[0].extensions["timeout"]
|
||
assert timeout["read"] >= 60
|
||
# A dead Gateway must still fail the connection quickly.
|
||
assert timeout["connect"] <= 10
|
||
|
||
|
||
def test_other_goal_requests_keep_the_short_timeout(monkeypatch):
|
||
manager, calls = _make_manager(monkeypatch)
|
||
|
||
_run(manager._goal_request("get", "t-1", headers={}))
|
||
|
||
assert calls[0].extensions["timeout"]["read"] == 10
|
||
|
||
|
||
def test_from_chat_timeout_reply_says_the_save_may_still_finish(monkeypatch):
|
||
def _timeout(request):
|
||
raise httpx.ReadTimeout("timed out", request=request)
|
||
|
||
manager, _ = _make_manager(monkeypatch, respond=_timeout)
|
||
_captured_replies(manager)
|
||
chat = AsyncMock()
|
||
monkeypatch.setattr(manager, "_handle_chat", chat)
|
||
|
||
result = _run(manager._handle_goal_command(_msg("/goal from-chat"), "from-chat"))
|
||
|
||
# The Gateway may save the goal after the manager stops waiting: the reply
|
||
# must not claim a plain failure, and no second run may start.
|
||
assert "may still finish" in result
|
||
assert "check with /goal" in result
|
||
chat.assert_not_awaited()
|
||
|
||
|
||
def test_from_chat_no_thread_short_circuits_without_http(monkeypatch):
|
||
manager, calls = _make_manager(monkeypatch, thread_id=None)
|
||
_captured_replies(manager)
|
||
chat = AsyncMock()
|
||
monkeypatch.setattr(manager, "_handle_chat", chat)
|
||
|
||
result = _run(manager._handle_goal_command(_msg("/goal from-chat"), "from-chat"))
|
||
|
||
assert result == "No conversation yet. Send a message first, then ask /goal from-chat."
|
||
assert calls == []
|
||
chat.assert_not_awaited()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("status", "payload", "expected"),
|
||
[
|
||
pytest.param(409, {"detail": "Thread has a run in flight. Set the goal after the run finishes."}, "A task is still running. Wait for it to finish before changing the goal.", id="busy"),
|
||
pytest.param(422, {"detail": {"code": "unclear"}}, "I couldn't form a clear goal from this conversation. Try adding a hint, e.g. /goal from-chat focus on the vendor totals.", id="unclear"),
|
||
pytest.param(422, {"detail": {"code": "no_context"}}, "There is no conversation content to build a goal from yet. Discuss the task first, or set it directly with /goal <objective>.", id="no-context"),
|
||
pytest.param(422, {"detail": {"code": "pending_clarification"}}, "The assistant is still waiting for your answer in this conversation. Reply to it first, then retry /goal from-chat.", id="pending-clarification"),
|
||
pytest.param(409, {"detail": {"code": "goal_changed"}}, "The conversation changed while the goal was being composed, so nothing was saved. Retry /goal from-chat, or set it directly with /goal <objective>.", id="goal-changed"),
|
||
pytest.param(502, {"detail": {"code": "synthesis_failed"}}, "Failed to compose the goal from the conversation. Try again, or set it directly with /goal <objective>.", id="synthesis-failed"),
|
||
pytest.param(500, {"detail": "Failed to set thread goal"}, "Failed to compose the goal from the conversation. Try again, or set it directly with /goal <objective>.", id="server-error"),
|
||
],
|
||
)
|
||
def test_from_chat_error_paths_reply_and_never_start_a_run(monkeypatch, status, payload, expected):
|
||
manager, calls = _make_manager(monkeypatch, respond=lambda r: httpx.Response(status, json=payload))
|
||
replies = _captured_replies(manager)
|
||
chat = AsyncMock()
|
||
monkeypatch.setattr(manager, "_handle_chat", chat)
|
||
|
||
result = _run(manager._handle_goal_command(_msg("/goal from-chat"), "from-chat"))
|
||
|
||
assert result == expected
|
||
chat.assert_not_awaited()
|
||
assert replies == [] # an error reply is returned, not published as a confirmation
|
||
|
||
|
||
def test_from_chat_unclear_never_echoes_a_leaked_reason(monkeypatch):
|
||
# Even if the Gateway (wrongly) put a reason in the body, the manager's
|
||
# fixed reply must not carry model-generated text back to the user.
|
||
manager, _ = _make_manager(monkeypatch, respond=lambda r: httpx.Response(422, json={"detail": {"code": "unclear", "reason": "sk-secret provider note"}}))
|
||
_captured_replies(manager)
|
||
monkeypatch.setattr(manager, "_handle_chat", AsyncMock())
|
||
|
||
result = _run(manager._handle_goal_command(_msg("/goal from-chat"), "from-chat"))
|
||
|
||
assert "sk-secret" not in result and "provider note" not in result
|
||
|
||
|
||
def test_from_chat_saved_but_run_not_started_is_reported(monkeypatch):
|
||
manager, _ = _make_manager(monkeypatch, respond=lambda r: httpx.Response(200, json={"goal": {"objective": "Ship it.", "status": "active"}}))
|
||
replies = _captured_replies(manager)
|
||
monkeypatch.setattr(manager, "_handle_chat", AsyncMock(side_effect=RuntimeError("admission rejected")))
|
||
|
||
result = _run(manager._handle_goal_command(_msg("/goal from-chat"), "from-chat"))
|
||
|
||
# Keep the saved objective visible and report the rejected start.
|
||
assert len(replies) == 2 and "Ship it." in replies[0].text
|
||
assert result is None and "did not start" in replies[-1].text
|
||
|
||
|
||
def test_from_chat_conversation_switched_during_synthesis_does_not_start_the_run(monkeypatch):
|
||
# The reviewer's repro: ``/new`` arrives while synthesis waits. The goal is
|
||
# saved in the original thread, but the chat's binding now points at the
|
||
# new thread — starting the run would execute the old task there.
|
||
objective = "Reconcile vendors A and B."
|
||
manager, calls = _make_manager(monkeypatch, respond=lambda r: httpx.Response(200, json={"goal": {"objective": objective, "status": "active"}}))
|
||
replies = _captured_replies(manager)
|
||
chat = AsyncMock()
|
||
monkeypatch.setattr(manager, "_handle_chat", chat)
|
||
# First lookup (command dispatch) → t-1; the post-save re-check → t-2.
|
||
lookups = iter(["t-1", "t-2"])
|
||
monkeypatch.setattr(manager, "_lookup_thread_id", AsyncMock(side_effect=lambda msg: next(lookups)))
|
||
|
||
result = _run(manager._handle_goal_command(_msg("/goal from-chat"), "from-chat"))
|
||
|
||
# The save still addressed the original thread.
|
||
assert calls[0].url.path == "/api/threads/t-1/goal/from-chat"
|
||
# The saved thread receives an explicit recovery reply.
|
||
chat.assert_not_awaited()
|
||
assert result is None
|
||
assert len(replies) == 1 and replies[0].thread_id == "t-1"
|
||
assert "switched to a new conversation" in replies[0].text
|
||
assert objective in replies[0].text
|
||
|
||
|
||
def test_from_chat_binding_recheck_failure_preserves_goal_without_starting(monkeypatch):
|
||
objective = "Ship it."
|
||
manager, _ = _make_manager(monkeypatch, respond=lambda r: httpx.Response(200, json={"goal": {"objective": objective, "status": "active"}}))
|
||
replies = _captured_replies(manager)
|
||
chat = AsyncMock()
|
||
monkeypatch.setattr(manager, "_handle_chat", chat)
|
||
monkeypatch.setattr(manager, "_lookup_thread_id", AsyncMock(side_effect=["t-1", RuntimeError("store down")]))
|
||
|
||
result = _run(manager._handle_goal_command(_msg("/goal from-chat"), "from-chat"))
|
||
|
||
assert result is None
|
||
chat.assert_not_awaited()
|
||
assert len(replies) == 1 and replies[0].thread_id == "t-1"
|
||
assert "Goal saved" in replies[0].text and "did not start" in replies[0].text
|
||
|
||
|
||
def test_from_chat_zh_locale_confirmation_is_chinese(monkeypatch):
|
||
objective = "核对供应商 A 和 B,排除 C,直到每条差异都有解释。"
|
||
manager, _ = _make_manager(monkeypatch, respond=lambda r: httpx.Response(200, json={"goal": {"objective": objective, "status": "active"}}))
|
||
replies = _captured_replies(manager)
|
||
monkeypatch.setattr(manager, "_reply_locale", SimpleNamespace(locale_for=AsyncMock(return_value="zh-CN")))
|
||
monkeypatch.setattr(manager, "_handle_chat", AsyncMock())
|
||
|
||
_run(manager._handle_goal_command(replace(_msg("/goal from-chat"), owner_user_id="owner-zh"), "from-chat"))
|
||
|
||
assert len(replies) == 1
|
||
assert replies[0].text.startswith("已从对话设置目标:")
|
||
assert objective in replies[0].text
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("pause_at", ["synthesis", "confirmation", "preload"])
|
||
async def test_from_chat_switch_during_handoff_never_runs_in_new_thread(monkeypatch, tmp_path, pause_at):
|
||
entered, release = asyncio.Event(), asyncio.Event()
|
||
objective = "Reconcile the original conversation."
|
||
|
||
async def respond(request):
|
||
path = request.url.path
|
||
if path.endswith("/goal/from-chat"):
|
||
if pause_at == "synthesis":
|
||
entered.set()
|
||
await release.wait()
|
||
return httpx.Response(200, json={"goal": {"objective": objective, "status": "active"}})
|
||
if request.method == "POST" and path == "/threads":
|
||
return httpx.Response(200, json={"thread_id": "t-2"})
|
||
if request.method == "GET" and pause_at == "preload":
|
||
entered.set()
|
||
await release.wait()
|
||
return httpx.Response(200, json={"metadata": {}})
|
||
|
||
manager, calls = _make_manager(monkeypatch, respond=respond)
|
||
monkeypatch.delattr(manager, "_lookup_thread_id")
|
||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||
await manager.store.set_thread_id("feishu", "C1", "t-1", user_id="U1")
|
||
replies = _captured_replies(manager)
|
||
|
||
async def pause_confirmation(message):
|
||
if pause_at == "confirmation" and message.text.startswith("Goal set from the conversation"):
|
||
entered.set()
|
||
await release.wait()
|
||
|
||
manager.bus.subscribe_outbound(pause_confirmation)
|
||
async with httpx.AsyncClient(base_url="http://gateway:8001") as http:
|
||
manager._client = LangGraphClient(http)
|
||
pending = asyncio.create_task(manager._handle_message(_msg("/goal from-chat")))
|
||
try:
|
||
await asyncio.wait_for(entered.wait(), timeout=5)
|
||
await manager._handle_message(_msg("/new"))
|
||
release.set()
|
||
await asyncio.wait_for(pending, timeout=5)
|
||
finally:
|
||
release.set()
|
||
if not pending.done():
|
||
pending.cancel()
|
||
await asyncio.gather(pending, return_exceptions=True)
|
||
|
||
assert await manager.store.get_thread_id("feishu", "C1") == "t-2"
|
||
assert calls[0].url.path == "/api/threads/t-1/goal/from-chat"
|
||
assert not any("/runs/" in request.url.path for request in calls)
|
||
assert any("switched to a new conversation" in message.text and objective in message.text for message in replies)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("channel", ["feishu", "dingtalk"])
|
||
@pytest.mark.parametrize("failure", [None, 403, 409, "timeout"])
|
||
async def test_from_chat_reports_actual_run_admission(monkeypatch, tmp_path, channel, failure):
|
||
def respond(request):
|
||
path = request.url.path
|
||
if path.endswith("/goal/from-chat"):
|
||
return httpx.Response(200, json={"goal": {"objective": "Ship it.", "status": "active"}})
|
||
if "/runs/" in path:
|
||
if failure == "timeout":
|
||
raise httpx.ReadTimeout("Response lost after sending the request", request=request)
|
||
if failure is not None:
|
||
return httpx.Response(failure, json={"detail": "Run admission rejected"})
|
||
values = {"messages": [{"type": "ai", "content": "Done."}]}
|
||
if path.endswith("/stream"):
|
||
return httpx.Response(200, headers={"content-type": "text/event-stream"}, content="event: values\ndata: " + json.dumps(values) + "\n\n")
|
||
return httpx.Response(200, json=values)
|
||
return httpx.Response(200, json={"metadata": {}})
|
||
|
||
manager, calls = _make_manager(monkeypatch, respond=respond)
|
||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||
replies = _captured_replies(manager)
|
||
msg = replace(_msg("/goal from-chat"), channel_name=channel, metadata={"message_id": "goal-1", "conversation_id": "C1"})
|
||
assert await manager._claim_inbound(msg)
|
||
async with httpx.AsyncClient(base_url="http://gateway:8001") as http:
|
||
manager._client = LangGraphClient(http)
|
||
await manager._handle_message(msg)
|
||
|
||
assert len([request for request in calls if "/runs/" in request.url.path]) == 1
|
||
if failure is None:
|
||
assert replies[-1].text == "Done."
|
||
elif failure == "timeout":
|
||
assert "may still be running" in replies[-1].text
|
||
assert "did not start" not in replies[-1].text
|
||
else:
|
||
assert "Goal saved" in replies[-1].text and "did not start" in replies[-1].text
|
||
assert await manager._is_duplicate_inbound(msg) is (failure in (None, "timeout"))
|