mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
* fix(channels): share chat-to-thread bindings through the database ChannelManager kept its IM chat-to-thread bindings (channel_name:chat_id[:topic_id] -> thread_id) in a per-process JSON file, channels/store.json, loaded once and rewritten whole on every change. With several Gateway processes a binding created on one replica was invisible to the others (the next message for the same chat opened a second thread) and concurrent writers clobbered each other's file. - New channel_thread_bindings table (migration 0036_channel_thread_bindings) and harness package deerflow.persistence.channel_thread_bindings: key PK (the legacy composite string), channel_name, chat_id, nullable topic_id, thread_id, user_id, epoch created_at/updated_at; index (channel_name, chat_id). Dialect upsert keeps created_at; prefix remove and batched ON CONFLICT DO NOTHING import. - app.channels.store.ChannelStore is an async protocol: SqlChannelStore for sqlite/postgres, JsonChannelStore (file I/O via asyncio.to_thread) for database.backend: memory. resolve_channel_store picks at ChannelService construction; ChannelService.start() imports an existing store.json once into an empty table and renames it store.json.migrated (a concurrently importing peer neither duplicates nor clobbers; a populated table leaves the file alone). - Callers updated: manager, connection_identity.lookup_thread_id, Feishu (send() awaits the mapping writer; the synchronous lark callback bridges its two store calls to the Gateway loop with a bounded wait), Discord (channels_dir). - Head pins in the 0027_0028/0029/0032/0034/0035 migration tests point at the new head; docs (channels and migrations AGENTS.md, CONFIGURATION.md, README) and CHANGELOG updated. * docs(changelog): link the channel thread bindings entry to #6558 * fix(channels): reject only the Gateway loop in the Feishu store bridge lark-oapi 1.5.5 invokes the message callback synchronously from its async frame handler, on the private event loop that FeishuChannel._run_ws starts for the SDK thread. The bridge's guard treated any running loop as unsafe, so every real inbound message skipped the stored-topic lookup and the pending-mapping restore: a reply whose parent card was mapped but whose root was not fell back to the unknown root, and could consume an unrelated pending clarification. Only a wait issued from the Gateway loop itself (the loop that executes the submitted coroutine) is refused now; the SDK's own loop may block for the bounded wait, as that thread blocked on the JSON rewrite before. Tests drive _on_message from a worker thread running its own loop, the production shape, for both the lookup and the write path, and pin the Gateway-loop refusal. * fix(channels): defer unresolved Feishu lookups and harden the store.json import Review follow-ups on the chat-to-thread binding store (#6558): - Import: a legacy entry whose key, channel_name, chat_id, topic_id, thread_id or user_id exceeds its column width is skipped and counted with the malformed ones instead of failing the whole batch on PostgreSQL (SQLite silently accepted the same row, so the backends diverged on one file). The corrupt-file warning now says what each caller does: the JSON store starts fresh, the import is skipped and the file kept. - Feishu bridge: store calls run on the Gateway loop under a 3s asyncio.wait_for (the SDK loop also services ws keepalives; the old 10s bound could stall it per message), and the lark-thread wait is that budget plus 1s. A deadline or store error is StoreLookupUnresolved, never a miss: _on_message no longer routes such a reply onto its unknown root (which created a second thread and later overwrote the parent alias). The message joins a per-chat FIFO drained on the Gateway loop with backoff for up to 30s, then is dropped with a warning. _on_message is split into parse / resolve / dispatch so the deferred path dispatches the same way; later messages for the chat queue behind the deferred one. * fix(tests): expect follow_redirects in the Jina client kwargs after #5808 #5808 made JinaClient pass follow_redirects=True to httpx.AsyncClient and updated test_jina_client.py, but test_jina_response_limit.py and test_jina_retry_after.py still pin the exact client kwargs to proxy and trust_env, so every backend-unit-tests shard that lands on them fails on main (the backend unit-test workflow only runs on pull requests, which is why the push to main did not surface it). Update the two expectations to include the new keyword. (cherry picked from commit d5aa23156ce19ce36d95df03953442f83ae6d050) * Revert "fix(tests): expect follow_redirects in the Jina client kwargs after #5808" This reverts commit 5bee00bfb09c65a30f77fb4df47a1f82c47ecef7. * fix(channels): bound deferred Feishu routing by the inbound queue capacity Review follow-up on #6558: a store outage let every subsequent message for a chat join the deferred queue with no capacity check (the bus reservation was only taken after routing succeeded), and each queued message started a fresh 30s retry budget when it reached the head, so a burst could be retained for N x 30s and grow without bound. - Intake is reserved BEFORE routing for every store-backed message (bus.reserve_inbound is thread-safe and already used from SDK threads), so deferred and live messages share the one configured bound, channels.inbound_queue_maxsize; a message that cannot be admitted is dropped at once with a warning, never queued or routed as a miss. - The reservation travels with the message into dispatch (committed by _prepare_inbound), is released on the batch path (the flush reserves at publish time), on drop after the budget, on a failed deferral, on dispatch failure and for every leftover when the drain task is cancelled by stop(). - The retry budget runs from the message's arrival: a queued message whose budget is spent when it reaches the head is dropped without a lookup. - The bounded resolver returns the unresolved outcome instead of raising it through the tracked task, which logged an ERROR for an expected, handled store timeout. - Upstream's new blocking-io test constructs the store by its old class name; it now uses JsonChannelStore. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
440 lines
19 KiB
Python
440 lines
19 KiB
Python
"""Server-owned ``deerflow_origin`` on runs and threads.
|
|
|
|
Only server-side launchers (scheduler, MCP notifications, extension handles,
|
|
via ``request.state.run_origin``) and the internal channel caller (via
|
|
``body.metadata``) may stamp it; every client copy is dropped from both
|
|
metadata forks of run admission (the run record and the live config, which is
|
|
what reaches checkpoint metadata). ``RunRepository`` derives
|
|
``runs.origin_kind`` from it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import FastAPI, Request
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
from app.gateway.auth.models import User
|
|
from app.gateway.authz import AuthContext
|
|
from app.gateway.extension_agent_runs import GatewayAgentRunsHost
|
|
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, get_internal_user
|
|
from app.gateway.run_models import RunCreateRequest
|
|
from app.gateway.run_origin import resolve_request_origin
|
|
from deerflow.persistence.base import Base
|
|
from deerflow.persistence.run import RunRepository
|
|
from deerflow.persistence.run.model import RunChangeClockRow, RunRow
|
|
from deerflow.runtime.run_origin import DEERFLOW_ORIGIN_KEY, ORIGIN_KINDS, admit_origin, make_origin, origin_kind_of
|
|
|
|
CONTRACT = json.loads((Path(__file__).resolve().parents[2] / "contracts" / "thread_origin_contract.json").read_text())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The origin value itself
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_contract_matches_the_module():
|
|
assert CONTRACT["version"] == 1
|
|
assert CONTRACT["key"] == DEERFLOW_ORIGIN_KEY
|
|
assert sorted(CONTRACT["kinds"]) == CONTRACT["kinds"]
|
|
assert set(CONTRACT["kinds"]) == ORIGIN_KINDS
|
|
|
|
|
|
def test_origin_key_is_distinct_from_the_message_level_scheduled_origin_key():
|
|
from app.gateway.services import SCHEDULED_ORIGIN_KEY
|
|
|
|
notes = json.loads((Path(__file__).resolve().parents[2] / "contracts" / "scheduled_goal_notes_contract.json").read_text())
|
|
assert DEERFLOW_ORIGIN_KEY != SCHEDULED_ORIGIN_KEY == notes["scheduled_origin_key"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
{"kind": "schedule"},
|
|
{"kind": "im_channel", "provider": "feishu"},
|
|
{"kind": "github", "provider": "github"},
|
|
{"kind": "extension", "namespace": "community.agent-teams"},
|
|
{"kind": "mcp_notification"},
|
|
],
|
|
)
|
|
def test_well_formed_origins_are_admitted(value):
|
|
assert admit_origin(value) == value
|
|
assert origin_kind_of({DEERFLOW_ORIGIN_KEY: value}) == value["kind"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
None,
|
|
"schedule",
|
|
{},
|
|
{"kind": "browser"},
|
|
{"kind": "schedule", "extra": "x"},
|
|
{"kind": "im_channel", "provider": "Feishu"},
|
|
{"kind": "im_channel", "provider": "x" * 33},
|
|
{"kind": "im_channel", "provider": 1},
|
|
{"kind": "extension", "namespace": "a:b"},
|
|
{"kind": "extension", "namespace": ""},
|
|
{"kind": "extension", "namespace": "n" * 97},
|
|
{"kind": True},
|
|
],
|
|
)
|
|
def test_malformed_origins_are_dropped_whole(value):
|
|
assert admit_origin(value) is None
|
|
assert origin_kind_of({DEERFLOW_ORIGIN_KEY: value}) is None
|
|
|
|
|
|
def test_make_origin_rejects_malformed_values():
|
|
assert make_origin("im_channel", provider="slack") == {"kind": "im_channel", "provider": "slack"}
|
|
with pytest.raises(ValueError):
|
|
make_origin("browser")
|
|
with pytest.raises(ValueError):
|
|
make_origin("extension", namespace="bad:ns")
|
|
|
|
|
|
def test_origin_kind_of_tolerates_missing_metadata():
|
|
assert origin_kind_of(None) is None
|
|
assert origin_kind_of({}) is None
|
|
assert origin_kind_of([("deerflow_origin", {"kind": "schedule"})]) is None
|
|
|
|
|
|
def test_request_origin_trusts_only_host_state_and_internal_callers():
|
|
origin = {"kind": "im_channel", "provider": "slack"}
|
|
body = {DEERFLOW_ORIGIN_KEY: origin}
|
|
# Session/PAT/API callers: the body copy is ignored.
|
|
assert resolve_request_origin(SimpleNamespace(state=SimpleNamespace(auth_source="session")), body) is None
|
|
assert resolve_request_origin(SimpleNamespace(state=SimpleNamespace(auth_source="pat")), body) is None
|
|
assert resolve_request_origin(SimpleNamespace(), body) is None
|
|
# The internal channel caller may declare it; invalid shapes still drop.
|
|
internal = SimpleNamespace(state=SimpleNamespace(auth_source="internal"))
|
|
assert resolve_request_origin(internal, body) == origin
|
|
assert resolve_request_origin(internal, {DEERFLOW_ORIGIN_KEY: {"kind": "schedule", "x": 1}}) is None
|
|
assert resolve_request_origin(internal, None) is None
|
|
# A host-set origin wins over anything in the body.
|
|
host = SimpleNamespace(state=SimpleNamespace(auth_source="internal", run_origin={"kind": "schedule"}))
|
|
assert resolve_request_origin(host, body) == {"kind": "schedule"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Run admission through a real Gateway composition (SQL run store, real graph)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_USER = User(email="origin@example.com")
|
|
OWNER = str(_USER.id)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def gateway(monkeypatch, tmp_path):
|
|
from langchain_core.messages import AIMessage
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.graph import MessagesState, StateGraph
|
|
from langgraph.store.memory import InMemoryStore
|
|
|
|
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
|
|
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
|
from deerflow.runtime import RunManager
|
|
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
|
from deerflow.runtime.stream_bridge.memory import MemoryStreamBridge
|
|
|
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'runs.db'}")
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(lambda sync: Base.metadata.create_all(sync, tables=[RunRow.__table__, RunChangeClockRow.__table__]))
|
|
sf = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
app = FastAPI()
|
|
state = app.state
|
|
state.checkpointer = InMemorySaver()
|
|
state.store = InMemoryStore()
|
|
state.thread_store = MemoryThreadMetaStore(state.store)
|
|
state.run_store = RunRepository(sf)
|
|
state.run_manager = RunManager(store=state.run_store)
|
|
state.run_event_store = MemoryRunEventStore()
|
|
state.stream_bridge = MemoryStreamBridge()
|
|
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
|
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}))
|
|
|
|
def factory(*, config):
|
|
builder = StateGraph(MessagesState)
|
|
builder.add_node("answer", lambda _state: {"messages": [AIMessage(content="done")]})
|
|
builder.set_entry_point("answer")
|
|
builder.set_finish_point("answer")
|
|
return builder.compile()
|
|
|
|
monkeypatch.setattr("app.gateway.services.resolve_agent_factory", lambda _: factory)
|
|
monkeypatch.setattr("app.gateway.services.get_local_provider", lambda: SimpleNamespace(get_user=AsyncMock(return_value=_USER)))
|
|
host = GatewayAgentRunsHost(app, load_user=AsyncMock(return_value=_USER))
|
|
state.agent_runs_host = host
|
|
try:
|
|
yield app, sf
|
|
finally:
|
|
host.close()
|
|
await state.run_manager.shutdown()
|
|
reset_app_config()
|
|
await engine.dispose()
|
|
|
|
|
|
def _session_request(app):
|
|
request = Request({"type": "http", "app": app, "headers": [], "state": {}})
|
|
request.state.user = _USER
|
|
request.state.auth_source = "session"
|
|
request.state.auth = AuthContext(_USER, ["threads:write", "threads:read", "runs:create", "runs:read", "runs:cancel"])
|
|
return request
|
|
|
|
|
|
def _internal_request(app, owner=OWNER):
|
|
return SimpleNamespace(app=app, headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: owner}, state=SimpleNamespace(user=get_internal_user(), auth_source="internal"), cookies={})
|
|
|
|
|
|
def _body(**kwargs):
|
|
return RunCreateRequest(assistant_id="lead_agent", input={"messages": [{"role": "user", "content": "hi"}]}, **kwargs)
|
|
|
|
|
|
async def _start(app, request, body, thread_id):
|
|
from app.gateway.services import start_run
|
|
|
|
record = await start_run(body, thread_id, request)
|
|
await record.task
|
|
return record
|
|
|
|
|
|
async def _origin_kind(sf, run_id):
|
|
async with sf() as session:
|
|
return (await session.get(RunRow, run_id)).origin_kind
|
|
|
|
|
|
async def _checkpoint_metadata(app, thread_id):
|
|
checkpoint = await app.state.checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}})
|
|
assert checkpoint is not None
|
|
return checkpoint.metadata
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_caller_cannot_forge_an_origin_in_body_metadata(gateway):
|
|
app, sf = gateway
|
|
record = await _start(app, _session_request(app), _body(metadata={DEERFLOW_ORIGIN_KEY: {"kind": "schedule"}, "kept": 1}), "forged-body")
|
|
assert DEERFLOW_ORIGIN_KEY not in record.metadata
|
|
assert record.metadata["kept"] == 1
|
|
assert await _origin_kind(sf, record.run_id) is None
|
|
stored = await app.state.run_store.get(record.run_id, user_id=None)
|
|
assert DEERFLOW_ORIGIN_KEY not in stored["metadata"]
|
|
assert DEERFLOW_ORIGIN_KEY not in await _checkpoint_metadata(app, "forged-body")
|
|
# The thread this run auto-created carries no marker either.
|
|
assert DEERFLOW_ORIGIN_KEY not in (await app.state.thread_store.get("forged-body", user_id=None))["metadata"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_client_origin_in_config_metadata_is_dropped(gateway, monkeypatch):
|
|
app, sf = gateway
|
|
captured = {}
|
|
from app.gateway import services
|
|
|
|
real_run_agent = services.run_agent
|
|
|
|
async def capture(*args, **kwargs):
|
|
captured["config"] = kwargs["config"]
|
|
return await real_run_agent(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("app.gateway.services.run_agent", capture)
|
|
body = _body(config={"metadata": {DEERFLOW_ORIGIN_KEY: {"kind": "schedule"}, "caller_key": "kept"}})
|
|
record = await _start(app, _session_request(app), body, "forged-config")
|
|
assert DEERFLOW_ORIGIN_KEY not in captured["config"]["metadata"]
|
|
assert captured["config"]["metadata"]["caller_key"] == "kept"
|
|
assert DEERFLOW_ORIGIN_KEY not in record.metadata
|
|
assert await _origin_kind(sf, record.run_id) is None
|
|
assert DEERFLOW_ORIGIN_KEY not in await _checkpoint_metadata(app, "forged-config")
|
|
|
|
# An internal caller's server-resolved origin overrides a conflicting copy.
|
|
origin = {"kind": "im_channel", "provider": "slack"}
|
|
body = _body(metadata={DEERFLOW_ORIGIN_KEY: origin}, config={"metadata": {DEERFLOW_ORIGIN_KEY: {"kind": "extension"}}})
|
|
record = await _start(app, _internal_request(app), body, "internal-config")
|
|
assert captured["config"]["metadata"][DEERFLOW_ORIGIN_KEY] == origin
|
|
assert record.metadata[DEERFLOW_ORIGIN_KEY] == origin
|
|
assert await _origin_kind(sf, record.run_id) == "im_channel"
|
|
# LangGraph copies only scalar config metadata into checkpoints, so the
|
|
# origin object lives on the run (record and runs.origin_kind), not there.
|
|
assert DEERFLOW_ORIGIN_KEY not in await _checkpoint_metadata(app, "internal-config")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("value", "expected"),
|
|
[
|
|
({"kind": "github", "provider": "github"}, "github"),
|
|
({"kind": "im_channel", "provider": "feishu", "extra": True}, None),
|
|
({"kind": "browser"}, None),
|
|
],
|
|
ids=["valid", "extra-key", "unknown-kind"],
|
|
)
|
|
async def test_internal_caller_origin_is_kept_only_when_well_formed(gateway, value, expected):
|
|
app, sf = gateway
|
|
record = await _start(app, _internal_request(app), _body(metadata={DEERFLOW_ORIGIN_KEY: value}), f"internal-{expected}")
|
|
assert await _origin_kind(sf, record.run_id) == expected
|
|
assert record.user_id == OWNER
|
|
if expected is None:
|
|
assert DEERFLOW_ORIGIN_KEY not in record.metadata
|
|
else:
|
|
assert record.metadata[DEERFLOW_ORIGIN_KEY] == value
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scheduled_launch_stamps_run_and_fresh_thread(gateway):
|
|
from app.gateway.services import launch_scheduled_thread_run
|
|
|
|
app, sf = gateway
|
|
result = await launch_scheduled_thread_run(
|
|
app=app,
|
|
thread_id="sched-run-thread",
|
|
assistant_id="lead_agent",
|
|
prompt="check",
|
|
owner_user_id=OWNER,
|
|
metadata={"scheduled_task_id": "task-1", "scheduled_task_run_id": "occurrence-1", "scheduled_trigger": "manual"},
|
|
)
|
|
await (await app.state.run_manager.get(result["run_id"])).task
|
|
stored = await app.state.run_store.get(result["run_id"], user_id=None)
|
|
assert stored["metadata"][DEERFLOW_ORIGIN_KEY] == {"kind": "schedule"}
|
|
assert stored["origin_kind"] == "schedule"
|
|
assert stored["user_id"] == OWNER
|
|
thread = await app.state.thread_store.get("sched-run-thread", user_id=OWNER)
|
|
assert thread["metadata"][DEERFLOW_ORIGIN_KEY] == {"kind": "schedule"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mcp_notification_launch_stamps_run_but_not_the_existing_thread(gateway):
|
|
from app.gateway.services import launch_mcp_task_notification_run
|
|
|
|
app, sf = gateway
|
|
await app.state.thread_store.create("mcp-thread", user_id=OWNER, metadata={"title": "chat"})
|
|
result = await launch_mcp_task_notification_run(
|
|
app=app,
|
|
thread_id="mcp-thread",
|
|
assistant_id="lead_agent",
|
|
owner_user_id=OWNER,
|
|
task_id="remote-1",
|
|
dispatch_version=1,
|
|
dispatch_attempt=1,
|
|
event={"status": "completed"},
|
|
)
|
|
await (await app.state.run_manager.get(result["run_id"])).task
|
|
assert await _origin_kind(sf, result["run_id"]) == "mcp_notification"
|
|
thread = await app.state.thread_store.get("mcp-thread", user_id=OWNER)
|
|
assert thread["metadata"] == {"title": "chat"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("namespace", [None, "community.agent-teams"])
|
|
async def test_extension_handle_marks_its_threads_and_runs(gateway, monkeypatch, namespace):
|
|
app, sf = gateway
|
|
request = _session_request(app)
|
|
monkeypatch.setattr("app.gateway.extension_agent_runs.resolve_route_permissions", AsyncMock(return_value=list(request.state.auth.permissions)))
|
|
runs = app.state.agent_runs_host.bind(request)
|
|
if namespace is not None:
|
|
runs = runs.for_plugin(namespace)
|
|
expected = make_origin("extension", namespace=namespace)
|
|
|
|
thread_id = await runs.create_thread(thread_id="extension-thread", metadata={DEERFLOW_ORIGIN_KEY: {"kind": "schedule"}, "keep": "me"})
|
|
thread = await app.state.thread_store.get(thread_id, user_id=OWNER)
|
|
assert thread["metadata"][DEERFLOW_ORIGIN_KEY] == expected
|
|
assert thread["metadata"]["keep"] == "me"
|
|
|
|
run = await runs.start(thread_id=thread_id, input={"messages": [{"role": "user", "content": "go"}]})
|
|
await runs.wait(thread_id=thread_id, run_id=run.run_id, timeout=5)
|
|
assert await _origin_kind(sf, run.run_id) == "extension"
|
|
assert (await app.state.run_store.get(run.run_id, user_id=None))["metadata"][DEERFLOW_ORIGIN_KEY] == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# IM channels and GitHub: the manager declares the origin on every run call
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _manager():
|
|
from app.channels.manager import ChannelManager
|
|
from app.channels.message_bus import MessageBus
|
|
from app.channels.store import JsonChannelStore
|
|
|
|
return ChannelManager(bus=MessageBus(), store=JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json"))
|
|
|
|
|
|
def _client(thread_id="im-thread"):
|
|
client = MagicMock()
|
|
client.threads.create = AsyncMock(return_value={"thread_id": thread_id})
|
|
client.threads.update = AsyncMock(return_value={"thread_id": thread_id})
|
|
client.threads.get = AsyncMock(return_value={"thread_id": thread_id})
|
|
client.runs.wait = AsyncMock(return_value={"messages": [{"type": "ai", "content": "ok"}]})
|
|
client.runs.create = AsyncMock(return_value={"run_id": "run-1", "status": "pending"})
|
|
|
|
async def _stream(*_args, **_kwargs):
|
|
yield SimpleNamespace(event="values", data={"messages": [{"type": "ai", "content": "ok"}]})
|
|
|
|
client.runs.stream = MagicMock(side_effect=_stream)
|
|
return client
|
|
|
|
|
|
def _message(channel="slack", **kwargs):
|
|
from app.channels.message_bus import InboundMessage
|
|
|
|
return InboundMessage(channel_name=channel, chat_id="chat-1", user_id="platform-user", owner_user_id="owner-1", text="hello", **kwargs)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_wait_run_declares_im_origin():
|
|
manager = _manager()
|
|
client = _client()
|
|
manager._client = client
|
|
await manager._handle_chat(_message("slack"))
|
|
client.runs.wait.assert_called_once()
|
|
assert client.runs.wait.call_args.kwargs["metadata"] == {DEERFLOW_ORIGIN_KEY: {"kind": "im_channel", "provider": "slack"}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_stream_run_declares_im_origin():
|
|
manager = _manager()
|
|
client = _client()
|
|
await manager._handle_streaming_chat(client, _message("feishu"), "im-thread", "lead_agent", {}, {}, {"role": "user", "content": "hello"})
|
|
client.runs.stream.assert_called_once()
|
|
assert client.runs.stream.call_args.kwargs["metadata"] == {DEERFLOW_ORIGIN_KEY: {"kind": "im_channel", "provider": "feishu"}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_create_run_declares_github_origin():
|
|
import app.gateway.github.run_policy # noqa: F401 - registers the fire-and-forget policy
|
|
|
|
manager = _manager()
|
|
client = _client("gh-thread")
|
|
manager._client = client
|
|
await manager._handle_chat(_github_message())
|
|
client.runs.create.assert_called_once()
|
|
assert client.runs.create.call_args.kwargs["metadata"] == {DEERFLOW_ORIGIN_KEY: {"kind": "github", "provider": "github"}}
|
|
|
|
|
|
def _github_message(text="please fix"):
|
|
from app.channels.message_bus import InboundMessage
|
|
|
|
return InboundMessage(channel_name="github", chat_id="org/repo", user_id="octocat", owner_user_id="owner-1", text=text)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_followup_drain_declares_the_carrier_origin():
|
|
manager = _manager()
|
|
client = _client("gh-thread")
|
|
for index in range(2):
|
|
manager._buffer_followup("gh-thread", _github_message(f"comment {index}"))
|
|
await manager._drain_followups_for_thread(client, "gh-thread", _github_message("carrier"))
|
|
client.runs.create.assert_called_once()
|
|
assert client.runs.create.call_args.kwargs["metadata"] == {DEERFLOW_ORIGIN_KEY: {"kind": "github", "provider": "github"}}
|
|
|
|
|
|
def test_unusable_channel_name_sends_no_origin():
|
|
from app.channels.manager import _apply_run_origin
|
|
|
|
kwargs = {}
|
|
_apply_run_origin(kwargs, _message("Bad Channel"))
|
|
assert "metadata" not in kwargs
|