deer-flow/backend/tests/test_gateway_services.py
Xinmin Zeng 4fc08b4f15
feat: add scheduled tasks MVP (#3898)
* feat: add scheduled tasks MVP

* fix: harden scheduled task execution semantics

* feat(scheduled-tasks): preset-driven schedule form with timezone and live preview

Replace the raw cron input with a preset Select (hourly/daily/weekly/monthly/custom)
plus structured inputs (time picker, weekday toggles, day-of-month), datetime-local
for one-time tasks, a timezone selector defaulting to the browser timezone, and a
live human-readable preview. Reuses one ScheduledTaskScheduleInput for create and
edit; backend contract unchanged; zero new deps (pure Intl + DST-safe offset helpers).

* feat(scheduled-tasks): full-page i18n + recipe templates + E2E locale pin

Localize the rest of the scheduled-tasks page (filters, detail pane, actions,
edit form, run list, enum values) via t.scheduledTasks.* in en/zh. Add four
built-in recipe templates (GitHub Trending, news digest, issue triage, weekly
report) exposed as a chip row that pre-fills title + prompt + schedule. Pin
Playwright locale to en-US so E2E selectors stay stable against i18n. No backend
change, no new deps.

* fix(scheduled-tasks): idempotent 0003 migration, update head constants, future-date once test

Merge with main surfaced three CI failures:
- 0003_scheduled_tasks create_table collided with legacy test seeds that
  build from full metadata; guard with inspector.has_table so the revision
  no-ops when the table already exists (0004/0005 are already idempotent via
  _helpers.py).
- persistence bootstrap concurrency/regression tests pinned HEAD to main's
  0002_runs_token_usage; bump to the new head 0005_scheduled_task_thread_nullable.
- once-task router test used a fixed past run_at and tripped the
  must-be-in-the-future validation; use a future date.

* address review: ok-check, 502 for trigger failure, mock fields, migration filename, doc fences

- fetchThreadScheduledTasks now checks response.ok like the other fetchers.
- trigger endpoint returns 502 (not 409) when dispatch fails outright, so
  clients can distinguish a real conflict from a server-side failure.
- E2E mock normalizes scheduled-task objects with context_mode/last_thread_id
  and nullable thread_id, matching the backend contract the UI renders against.
- Rename 0002_scheduled_tasks.py -> 0003_scheduled_tasks.py to match its
  revision id (file was renamed in spirit already; filename now follows).
- CONFIGURATION.md: close the Tool Groups yaml fence and drop the stray fence
  after the Scheduler notes so the sections render correctly.

* fix(scheduled-tasks): harden lease, poller, config, and frontend UX after review

* fix(scheduled-tasks): harden run lifecycle, overlap skip, non_interactive gating, and DST conversion after review

- defer a once task's terminal status to the run-completion hook; the task
  stays running until the real outcome, and a startup sweep cancels once
  tasks orphaned by a crash (launch-time 'completed' could stick forever)
- record interrupted runs as a distinct 'interrupted' run status with a
  readable message; an interrupted once task ends 'cancelled', not 'failed'
- enforce overlap_policy=skip for fresh_thread_per_run via an active-run
  pre-check (same-thread ConflictError can never fire across fresh threads)
- protect terminal run statuses from the late launch-path 'running' write
- honor context.non_interactive only for internally-authenticated callers;
  arbitrary clients can no longer strip ask_clarification
- fix DST-stale timezone offset in zonedLocalToUtcIso by re-deriving the
  offset at the resolved instant (once tasks fired an hour late around
  spring-forward and the create->edit round-trip diverged)
- drop dead ScheduledTaskRunRepository.update_by_run_id; share one Gateway
  API error helper between channels and scheduled-tasks frontends

* fix(scheduled-tasks): close review round-3 gaps in guards, concurrency, and API ergonomics

- scrub internal-only context keys (non_interactive) from the assembled run
  config for non-internal callers: gating body.context alone left the same
  key smuggle-able through the free-form body.config copied verbatim by
  build_run_config
- guard update_after_launch with protect_terminal so the launch bookkeeping
  write cannot clobber a once task already finalized by a fast-failing run's
  completion hook (parent-row sibling of the run-row guard)
- reject a manual trigger while the task has an active run (409) instead of
  launching a duplicate concurrent run on fresh_thread_per_run
- re-arm a terminal once task to enabled when PATCH pushes run_at into the
  future; previously the endpoint returned 200 with a next_run_at that could
  never be claimed
- make max_concurrent_runs a real global cap: each poll claims only into the
  remaining budget of active (queued/running) scheduled runs
- paginate GET /scheduled-tasks/{id}/runs (limit<=200, offset) and push the
  thread filter of /threads/{id}/scheduled-tasks into SQL
- stamp context.user_id on scheduler-launched runs, matching IM channels, so
  user-scoped guardrail providers see the owning user

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-04 21:51:57 +08:00

1041 lines
39 KiB
Python

"""Tests for app.gateway.services — run lifecycle service layer."""
from __future__ import annotations
import json
import pytest
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
@pytest.fixture
def _stub_app_config():
"""Keep run-context tests independent from a developer-local config.yaml."""
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}))
yield
reset_app_config()
def test_format_sse_basic():
from app.gateway.services import format_sse
frame = format_sse("metadata", {"run_id": "abc"})
assert frame.startswith("event: metadata\n")
assert "data: " in frame
parsed = json.loads(frame.split("data: ")[1].split("\n")[0])
assert parsed["run_id"] == "abc"
def test_format_sse_with_event_id():
from app.gateway.services import format_sse
frame = format_sse("metadata", {"run_id": "abc"}, event_id="123-0")
assert "id: 123-0" in frame
def test_format_sse_end_event_null():
from app.gateway.services import format_sse
frame = format_sse("end", None)
assert "data: null" in frame
def test_format_sse_no_event_id():
from app.gateway.services import format_sse
frame = format_sse("values", {"x": 1})
assert "id:" not in frame
def test_sanitize_log_param_strips_control_characters():
from app.gateway.utils import sanitize_log_param
assert sanitize_log_param("thread\nid\rwith\x00controls") == "threadidwithcontrols"
def test_normalize_stream_modes_none():
from app.gateway.services import normalize_stream_modes
assert normalize_stream_modes(None) == ["values"]
def test_normalize_stream_modes_string():
from app.gateway.services import normalize_stream_modes
assert normalize_stream_modes("messages-tuple") == ["messages-tuple"]
def test_normalize_stream_modes_list():
from app.gateway.services import normalize_stream_modes
assert normalize_stream_modes(["values", "messages-tuple"]) == ["values", "messages-tuple"]
def test_normalize_stream_modes_empty_list():
from app.gateway.services import normalize_stream_modes
assert normalize_stream_modes([]) == ["values"]
def test_normalize_input_none():
from app.gateway.services import normalize_input
assert normalize_input(None) == {}
def test_normalize_input_with_messages():
from app.gateway.services import normalize_input
result = normalize_input({"messages": [{"role": "user", "content": "hi"}]})
assert len(result["messages"]) == 1
assert result["messages"][0].content == "hi"
def test_normalize_input_passthrough():
from app.gateway.services import normalize_input
result = normalize_input({"custom_key": "value"})
assert result == {"custom_key": "value"}
def test_normalize_input_preserves_additional_kwargs_and_id():
"""Regression: gh #3132 — frontend ships uploaded-file metadata in
additional_kwargs.files (and a client-side message id). The gateway must
not strip them before the graph runs, otherwise UploadsMiddleware reports
"(empty)" for new uploads and the frontend message loses its file chip.
"""
from langchain_core.messages import HumanMessage
from app.gateway.services import normalize_input
files = [{"filename": "a.csv", "size": 100, "path": "/mnt/user-data/uploads/a.csv", "status": "uploaded"}]
result = normalize_input(
{
"messages": [
{
"type": "human",
"id": "client-msg-1",
"name": "user-input",
"content": [{"type": "text", "text": "clean it"}],
"additional_kwargs": {"files": files, "custom": "keep-me"},
}
]
}
)
assert len(result["messages"]) == 1
msg = result["messages"][0]
assert isinstance(msg, HumanMessage)
assert msg.id == "client-msg-1"
assert msg.name == "user-input"
assert msg.content == [{"type": "text", "text": "clean it"}]
assert msg.additional_kwargs == {"files": files, "custom": "keep-me"}
def test_normalize_input_passes_through_basemessage_instances():
from langchain_core.messages import HumanMessage
from app.gateway.services import normalize_input
msg = HumanMessage(content="hello", id="m-1", additional_kwargs={"files": [{"filename": "x"}]})
result = normalize_input({"messages": [msg]})
assert result["messages"][0] is msg
def test_normalize_input_rejects_malformed_message_with_400():
"""Boundary validation: ``convert_to_messages`` raises ``ValueError`` when a
message dict is missing ``role``/``type``/``content``. ``normalize_input``
runs inside the gateway HTTP boundary, so a malformed payload should surface
as a 400 referencing the offending entry — not bubble up as a 500.
Raised after the Copilot review on PR #3136.
"""
import pytest
from fastapi import HTTPException
from app.gateway.services import normalize_input
with pytest.raises(HTTPException) as excinfo:
normalize_input({"messages": [{"role": "human", "content": "ok"}, {"oops": "no role here"}]})
assert excinfo.value.status_code == 400
assert "input.messages[1]" in excinfo.value.detail
def test_normalize_input_handles_non_human_roles():
"""The previous implementation collapsed every role to HumanMessage with a
`# TODO: handle other message types` comment. Resuming a thread with prior
AI/tool messages would silently rewrite them as human turns — corrupting
the conversation. Use langchain's standard conversion so ai/system/tool
roles round-trip correctly.
"""
from langchain_core.messages import AIMessage, SystemMessage, ToolMessage
from app.gateway.services import normalize_input
result = normalize_input(
{
"messages": [
{"role": "system", "content": "sys"},
{"role": "ai", "content": "hi", "id": "ai-1"},
{"role": "tool", "content": "result", "tool_call_id": "call-1"},
]
}
)
types = [type(m) for m in result["messages"]]
assert types == [SystemMessage, AIMessage, ToolMessage]
assert result["messages"][1].id == "ai-1"
assert result["messages"][2].tool_call_id == "call-1"
def test_build_run_config_basic():
from app.gateway.services import build_run_config
config = build_run_config("thread-1", None, None)
assert config["configurable"]["thread_id"] == "thread-1"
assert config["recursion_limit"] == 100
def test_build_run_config_with_overrides():
from app.gateway.services import build_run_config
config = build_run_config(
"thread-1",
{"configurable": {"model_name": "gpt-4"}, "tags": ["test"]},
{"user": "alice"},
)
assert config["configurable"]["model_name"] == "gpt-4"
assert config["tags"] == ["test"]
assert config["metadata"]["user"] == "alice"
def test_build_run_config_context_path_still_sets_configurable_thread_id(_stub_app_config):
"""A caller-supplied context (e.g. request-scoped secrets, #3861) must not
deprive the checkpointer of configurable.thread_id, which it always needs to
scope checkpoints. Secrets stay in context; thread_id is mirrored into
configurable for the checkpointer."""
from app.gateway.services import build_run_config
config = build_run_config("thread-1", {"context": {"secrets": {"ERP_TOKEN": "v"}}}, None)
assert config["context"]["secrets"] == {"ERP_TOKEN": "v"}
assert config["context"]["thread_id"] == "thread-1"
assert config["configurable"]["thread_id"] == "thread-1"
# Secrets must NOT be mirrored into configurable.
assert "secrets" not in config["configurable"]
# ---------------------------------------------------------------------------
# recursion_limit clamping: the Gateway must not trust a client-supplied
# recursion_limit verbatim (runaway LLM cost / DoS). See build_run_config.
# ---------------------------------------------------------------------------
def test_build_run_config_clamps_excessive_recursion_limit(_stub_app_config):
"""A huge client recursion_limit is capped at the configured ceiling (default 1000)."""
from app.gateway.services import build_run_config
config = build_run_config("thread-1", {"recursion_limit": 100_000_000}, None)
assert config["recursion_limit"] == 1000
def test_build_run_config_ceiling_is_configurable(_stub_app_config):
"""The clamp ceiling comes from AppConfig.max_recursion_limit, not a hardcoded value."""
from app.gateway.services import build_run_config
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, "max_recursion_limit": 300}))
try:
config = build_run_config("thread-1", {"recursion_limit": 100_000_000}, None)
assert config["recursion_limit"] == 300
finally:
reset_app_config()
def test_build_run_config_allows_recursion_limit_at_ceiling(_stub_app_config):
"""A value at the configured ceiling is preserved unchanged."""
from app.gateway.services import build_run_config
config = build_run_config("thread-1", {"recursion_limit": 1000}, None)
assert config["recursion_limit"] == 1000
def test_build_run_config_preserves_reasonable_recursion_limit(_stub_app_config):
"""A modest client value below the ceiling is honoured as-is."""
from app.gateway.services import build_run_config
config = build_run_config("thread-1", {"recursion_limit": 250}, None)
assert config["recursion_limit"] == 250
def test_build_run_config_rejects_invalid_recursion_limit(_stub_app_config):
"""Non-positive / non-int / bool values fall back to the server default."""
from app.gateway.services import _DEFAULT_RECURSION_LIMIT, build_run_config
for bad in (0, -5, "1000", 3.5, True, None):
config = build_run_config("thread-1", {"recursion_limit": bad}, None)
assert config["recursion_limit"] == _DEFAULT_RECURSION_LIMIT, bad
def test_build_run_config_clamps_recursion_limit_with_context(_stub_app_config):
"""Clamping also applies on the LangGraph >= 0.6.0 context passthrough path."""
from app.gateway.services import build_run_config
config = build_run_config(
"thread-1",
{"context": {"thread_id": "thread-1"}, "recursion_limit": 999_999},
None,
)
assert config["recursion_limit"] == 1000
# ---------------------------------------------------------------------------
# Regression tests for issue #1644:
# assistant_id not mapped to agent_name → custom agent SOUL.md never loaded
# ---------------------------------------------------------------------------
def test_build_run_config_custom_agent_injects_agent_name():
"""Custom assistant_id must be forwarded as configurable['agent_name']."""
from app.gateway.services import build_run_config
config = build_run_config("thread-1", None, None, assistant_id="finalis")
assert config["configurable"]["agent_name"] == "finalis"
assert config["run_name"] == "finalis"
def test_build_run_config_lead_agent_no_agent_name():
"""'lead_agent' assistant_id must NOT inject configurable['agent_name']."""
from app.gateway.services import build_run_config
config = build_run_config("thread-1", None, None, assistant_id="lead_agent")
assert "agent_name" not in config["configurable"]
assert "run_name" not in config
def test_build_run_config_none_assistant_id_no_agent_name():
"""None assistant_id must NOT inject configurable['agent_name']."""
from app.gateway.services import build_run_config
config = build_run_config("thread-1", None, None, assistant_id=None)
assert "agent_name" not in config["configurable"]
assert "run_name" not in config
def test_build_run_config_explicit_agent_name_not_overwritten():
"""An explicit configurable['agent_name'] in the request must take precedence."""
from app.gateway.services import build_run_config
config = build_run_config(
"thread-1",
{"configurable": {"agent_name": "explicit-agent"}},
None,
assistant_id="other-agent",
)
assert config["configurable"]["agent_name"] == "explicit-agent"
assert config["context"]["agent_name"] == "explicit-agent"
assert config["run_name"] == "explicit-agent"
def test_build_run_config_context_custom_agent_injects_agent_name():
"""Custom assistant_id must be forwarded as ``agent_name`` in both
``context`` and ``configurable`` (issue #3549). Previously only the
active container was populated, so when the caller sent context-only the
setup_agent tool — which reads ``ToolRuntime.context`` — saw
``agent_name=None`` and wrote SOUL.md to the global base_dir.
"""
from app.gateway.services import build_run_config
config = build_run_config(
"thread-1",
{"context": {"model_name": "deepseek-v3"}},
None,
assistant_id="finalis",
)
assert config["context"]["agent_name"] == "finalis"
assert config["configurable"]["agent_name"] == "finalis"
def test_resolve_agent_factory_returns_make_lead_agent():
"""resolve_agent_factory always returns make_lead_agent regardless of assistant_id."""
from app.gateway.services import resolve_agent_factory
from deerflow.agents.lead_agent.agent import make_lead_agent
assert resolve_agent_factory(None) is make_lead_agent
assert resolve_agent_factory("lead_agent") is make_lead_agent
assert resolve_agent_factory("finalis") is make_lead_agent
assert resolve_agent_factory("custom-agent-123") is make_lead_agent
def test_build_run_config_configurable_custom_agent_dual_writes_agent_name():
"""Regression for issue #3549: even when the caller uses the legacy
``configurable`` path, ``agent_name`` must also land in
``config['context']`` so LangGraph >=1.1.9 ``ToolRuntime.context`` consumers
(e.g. ``setup_agent``) observe the same value.
"""
from app.gateway.services import build_run_config
config = build_run_config("thread-1", None, None, assistant_id="finalis")
assert config["configurable"]["agent_name"] == "finalis"
assert config["context"]["agent_name"] == "finalis"
def test_build_run_config_context_explicit_agent_name_not_overwritten():
"""An explicit ``context['agent_name']`` from the request must take
precedence over the value derived from ``assistant_id`` and be mirrored
to ``configurable`` so the two containers never diverge.
"""
from app.gateway.services import build_run_config
config = build_run_config(
"thread-1",
{"context": {"agent_name": "explicit-agent"}},
None,
assistant_id="other-agent",
)
assert config["context"]["agent_name"] == "explicit-agent"
assert config["configurable"]["agent_name"] == "explicit-agent"
assert config["run_name"] == "explicit-agent"
def test_build_run_config_dual_write_matches_merge_run_context_overrides_shape():
"""The shape produced by ``build_run_config`` for a custom agent must be
indistinguishable from what ``merge_run_context_overrides`` would produce
when ``agent_name`` is supplied via ``body.context`` — guarding against
the two code paths drifting apart again (issue #3549).
"""
from app.gateway.services import build_run_config, merge_run_context_overrides
via_assistant_id = build_run_config("thread-1", None, None, assistant_id="finalis")
via_context = build_run_config("thread-1", None, None)
merge_run_context_overrides(via_context, {"agent_name": "finalis"})
assert via_assistant_id["configurable"]["agent_name"] == via_context["configurable"]["agent_name"]
assert via_assistant_id["context"]["agent_name"] == via_context["context"]["agent_name"]
def test_non_interactive_context_override_is_internal_only():
"""Client-supplied ``non_interactive`` must be dropped: it strips the
``ask_clarification`` tool, so only the internal scheduler path may set it."""
from app.gateway.services import build_run_config, merge_run_context_overrides
config = build_run_config("thread-1", None, None)
merge_run_context_overrides(config, {"non_interactive": True})
assert "non_interactive" not in config["configurable"]
assert "non_interactive" not in config["context"]
def test_non_interactive_context_override_honored_for_internal_caller():
from app.gateway.services import build_run_config, merge_run_context_overrides
config = build_run_config("thread-1", None, None)
merge_run_context_overrides(config, {"non_interactive": True, "model_name": "gpt"}, internal=True)
assert config["configurable"]["non_interactive"] is True
assert config["context"]["non_interactive"] is True
assert config["configurable"]["model_name"] == "gpt"
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Regression tests for issue #1699:
# context field in langgraph-compat requests not merged into configurable
# ---------------------------------------------------------------------------
def test_run_create_request_accepts_context():
"""RunCreateRequest must accept the ``context`` field without dropping it."""
from app.gateway.routers.thread_runs import RunCreateRequest
body = RunCreateRequest(
input={"messages": [{"role": "user", "content": "hi"}]},
context={
"model_name": "deepseek-v3",
"thinking_enabled": True,
"is_plan_mode": True,
"subagent_enabled": True,
"thread_id": "some-thread-id",
},
)
assert body.context is not None
assert body.context["model_name"] == "deepseek-v3"
assert body.context["is_plan_mode"] is True
assert body.context["subagent_enabled"] is True
def test_run_create_request_context_defaults_to_none():
"""RunCreateRequest without context should default to None (backward compat)."""
from app.gateway.routers.thread_runs import RunCreateRequest
body = RunCreateRequest(input=None)
assert body.context is None
def test_apply_checkpoint_to_run_config_writes_checkpoint_fields():
import asyncio
from types import SimpleNamespace
from app.gateway.services import apply_checkpoint_to_run_config
class FakeCheckpointer:
def __init__(self):
self.seen_config = None
async def aget_tuple(self, config):
self.seen_config = config
return SimpleNamespace(config=config, checkpoint={"channel_values": {}})
checkpointer = FakeCheckpointer()
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer)))
body = SimpleNamespace(
checkpoint={
"checkpoint_ns": "",
"checkpoint_id": "ckpt-1",
"checkpoint_map": {"": "ckpt-1"},
},
checkpoint_id=None,
)
config = {"configurable": {"thread_id": "thread-1"}}
asyncio.run(apply_checkpoint_to_run_config(config, body=body, thread_id="thread-1", request=request))
assert checkpointer.seen_config == {
"configurable": {
"thread_id": "thread-1",
"checkpoint_ns": "",
"checkpoint_id": "ckpt-1",
"checkpoint_map": {"": "ckpt-1"},
}
}
assert config["configurable"]["checkpoint_id"] == "ckpt-1"
assert config["configurable"]["checkpoint_ns"] == ""
assert config["configurable"]["checkpoint_map"] == {"": "ckpt-1"}
def test_apply_checkpoint_to_run_config_rejects_missing_checkpoint():
import asyncio
from types import SimpleNamespace
from fastapi import HTTPException
from app.gateway.services import apply_checkpoint_to_run_config
class FakeCheckpointer:
async def aget_tuple(self, config):
return None
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(checkpointer=FakeCheckpointer())))
body = SimpleNamespace(checkpoint=None, checkpoint_id="missing")
config = {"configurable": {"thread_id": "thread-1"}}
with pytest.raises(HTTPException) as exc:
asyncio.run(apply_checkpoint_to_run_config(config, body=body, thread_id="thread-1", request=request))
assert exc.value.status_code == 404
assert "missing" in exc.value.detail
def test_context_merges_into_configurable():
"""Context values must be merged into config['configurable'] by start_run.
Since start_run is async and requires many dependencies, we test the
merging logic directly by simulating what start_run does.
"""
from app.gateway.services import build_run_config
# Simulate the context merging logic from start_run
config = build_run_config("thread-1", None, None)
context = {
"model_name": "deepseek-v3",
"mode": "ultra",
"reasoning_effort": "high",
"thinking_enabled": True,
"is_plan_mode": True,
"subagent_enabled": True,
"max_concurrent_subagents": 5,
"thread_id": "should-be-ignored",
}
_CONTEXT_CONFIGURABLE_KEYS = {
"model_name",
"mode",
"thinking_enabled",
"reasoning_effort",
"is_plan_mode",
"subagent_enabled",
"max_concurrent_subagents",
}
configurable = config.setdefault("configurable", {})
for key in _CONTEXT_CONFIGURABLE_KEYS:
if key in context:
configurable.setdefault(key, context[key])
assert config["configurable"]["model_name"] == "deepseek-v3"
assert config["configurable"]["thinking_enabled"] is True
assert config["configurable"]["is_plan_mode"] is True
assert config["configurable"]["subagent_enabled"] is True
assert config["configurable"]["max_concurrent_subagents"] == 5
assert config["configurable"]["reasoning_effort"] == "high"
assert config["configurable"]["mode"] == "ultra"
# thread_id from context should NOT override the one from build_run_config
assert config["configurable"]["thread_id"] == "thread-1"
# Non-allowlisted keys should not appear
assert "thread_id" not in {k for k in context if k in _CONTEXT_CONFIGURABLE_KEYS}
def test_merge_run_context_overrides_propagates_to_runtime_context():
"""Regression for issue #2677: ``agent_name`` (and other whitelisted keys) from
``body.context`` must be propagated into BOTH ``config['configurable']`` and
``config['context']``. Previously only ``configurable`` was populated, so after
the LangGraph 1.1.x upgrade removed the fallback from ``configurable``, the
``setup_agent`` tool read ``runtime.context`` with ``agent_name=None`` and
silently wrote SOUL.md to the global base_dir.
"""
from app.gateway.services import build_run_config, merge_run_context_overrides
config = build_run_config("thread-1", None, None)
merge_run_context_overrides(config, {"agent_name": "my-agent", "is_bootstrap": True, "thread_id": "ignored"})
assert config["configurable"]["agent_name"] == "my-agent"
assert config["configurable"]["is_bootstrap"] is True
assert config["context"]["agent_name"] == "my-agent"
assert config["context"]["is_bootstrap"] is True
# Non-whitelisted keys are not forwarded.
assert "thread_id" not in config["context"]
def test_merge_run_context_overrides_noop_for_empty_context():
from app.gateway.services import build_run_config, merge_run_context_overrides
config = build_run_config("thread-1", None, None)
before = {k: dict(v) if isinstance(v, dict) else v for k, v in config.items()}
merge_run_context_overrides(config, None)
merge_run_context_overrides(config, {})
assert config == before
def test_context_does_not_override_existing_configurable():
"""Values already in config.configurable must NOT be overridden by context."""
from app.gateway.services import build_run_config
config = build_run_config(
"thread-1",
{"configurable": {"model_name": "gpt-4", "is_plan_mode": False}},
None,
)
context = {
"model_name": "deepseek-v3",
"is_plan_mode": True,
"subagent_enabled": True,
}
_CONTEXT_CONFIGURABLE_KEYS = {
"model_name",
"mode",
"thinking_enabled",
"reasoning_effort",
"is_plan_mode",
"subagent_enabled",
"max_concurrent_subagents",
}
configurable = config.setdefault("configurable", {})
for key in _CONTEXT_CONFIGURABLE_KEYS:
if key in context:
configurable.setdefault(key, context[key])
# Existing values must NOT be overridden
assert config["configurable"]["model_name"] == "gpt-4"
assert config["configurable"]["is_plan_mode"] is False
# New values should be added
assert config["configurable"]["subagent_enabled"] is True
def test_inject_authenticated_user_context_overrides_client_user_id():
"""Run context should carry the authenticated user, not client-supplied user_id."""
from types import SimpleNamespace
from app.gateway.services import build_run_config, inject_authenticated_user_context
config = build_run_config("thread-1", None, None)
config["context"] = {"user_id": "spoofed-client"}
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(id="auth-user-42")))
inject_authenticated_user_context(config, request)
assert config["context"]["user_id"] == "auth-user-42"
def test_merge_run_context_overrides_propagates_user_id():
"""Regression for PR #3294: ``user_id`` from ``body.context`` must land in
``config['context']`` so non-web callers (e.g. IM channels) keep their identity
on ``ToolRuntime.context``.
"""
from app.gateway.services import build_run_config, merge_run_context_overrides
config = build_run_config("thread-1", None, None)
merge_run_context_overrides(config, {"user_id": "channel-user-7"})
assert config["context"]["user_id"] == "channel-user-7"
def test_merge_run_context_overrides_does_not_clobber_existing_user_id():
"""``merge_run_context_overrides`` must not override an already-stamped
authenticated ``context.user_id`` with the client-supplied value.
"""
from app.gateway.services import build_run_config, merge_run_context_overrides
config = build_run_config("thread-1", {"context": {"user_id": "auth-user-42"}}, None)
merge_run_context_overrides(config, {"user_id": "spoofed-client"})
assert config["context"]["user_id"] == "auth-user-42"
def test_inject_authenticated_user_context_skips_internal_role():
"""Regression for PR #3294: internal system-role callers must not overwrite an
already-present ``context.user_id`` (e.g. a channel-supplied identity), so the
real end user keeps owning the per-user storage bucket.
"""
from types import SimpleNamespace
from app.gateway.services import build_run_config, inject_authenticated_user_context
config = build_run_config("thread-1", None, None)
config["context"] = {"user_id": "channel-user-7"}
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(id="internal-bot", system_role="internal")))
inject_authenticated_user_context(config, request)
assert config["context"]["user_id"] == "channel-user-7"
async def _capture_start_run_graph_input(body):
from types import SimpleNamespace
from unittest.mock import patch
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
from app.gateway.services import start_run
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.runtime import RunManager
from deerflow.runtime.runs.store.memory import MemoryRunStore
run_manager = RunManager(store=MemoryRunStore())
state = SimpleNamespace(
stream_bridge=SimpleNamespace(),
run_manager=run_manager,
checkpointer=InMemorySaver(),
store=InMemoryStore(),
run_event_store=SimpleNamespace(),
run_events_config=None,
thread_store=MemoryThreadMetaStore(InMemoryStore()),
)
request = SimpleNamespace(
headers={},
state=SimpleNamespace(),
app=SimpleNamespace(state=state),
)
captured: dict[str, object] = {}
async def fake_run_agent(*args, **kwargs):
captured["graph_input"] = kwargs["graph_input"]
with (
patch("app.gateway.services.resolve_agent_factory", return_value=object()),
patch("app.gateway.services.run_agent", side_effect=fake_run_agent),
):
record = await start_run(body, "thread-command-test", request)
await record.task
return captured["graph_input"]
def test_start_run_translates_resume_command_to_langgraph_command(_stub_app_config):
import asyncio
from langgraph.types import Command
from app.gateway.routers.thread_runs import RunCreateRequest
graph_input = asyncio.run(
_capture_start_run_graph_input(
RunCreateRequest(
input=None,
command={"resume": {"answer": "approved"}},
)
)
)
assert isinstance(graph_input, Command)
assert graph_input.resume == {"answer": "approved"}
def test_start_run_uses_normalized_input_without_command(_stub_app_config):
import asyncio
from langchain_core.messages import HumanMessage
from app.gateway.routers.thread_runs import RunCreateRequest
graph_input = asyncio.run(
_capture_start_run_graph_input(
RunCreateRequest(
input={"messages": [{"role": "human", "content": "hi"}]},
command=None,
)
)
)
assert isinstance(graph_input, dict)
assert isinstance(graph_input["messages"][0], HumanMessage)
assert graph_input["messages"][0].content == "hi"
def test_start_run_uses_internal_owner_header_for_persistence(_stub_app_config):
import asyncio
from types import SimpleNamespace
from unittest.mock import patch
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
from app.gateway.services import start_run
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.runtime import RunManager
from deerflow.runtime.runs.store.memory import MemoryRunStore
from deerflow.runtime.user_context import get_effective_user_id
async def _scenario():
run_store = MemoryRunStore()
thread_store = MemoryThreadMetaStore(InMemoryStore())
await thread_store.create("channel-thread", user_id="default", metadata={"legacy": True})
run_manager = RunManager(store=run_store)
state = SimpleNamespace(
stream_bridge=SimpleNamespace(),
run_manager=run_manager,
checkpointer=InMemorySaver(),
store=InMemoryStore(),
run_event_store=SimpleNamespace(),
run_events_config=None,
thread_store=thread_store,
)
request = SimpleNamespace(
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE)),
app=SimpleNamespace(state=state),
)
body = SimpleNamespace(
assistant_id="lead_agent",
input={"messages": [{"role": "human", "content": "hi"}]},
metadata={},
config=None,
context=None,
on_disconnect="cancel",
multitask_strategy="reject",
stream_mode=None,
stream_subgraphs=False,
interrupt_before=None,
interrupt_after=None,
)
task_context: dict[str, str] = {}
async def fake_run_agent(*args, **kwargs):
task_context["user_id"] = get_effective_user_id()
with (
patch("app.gateway.services.resolve_agent_factory", return_value=object()),
patch("app.gateway.services.run_agent", side_effect=fake_run_agent),
):
record = await start_run(body, "channel-thread", request)
await record.task
owner_run = await run_store.get(record.run_id, user_id="owner-1")
default_run = await run_store.get(record.run_id, user_id="default")
owner_thread = await thread_store.get("channel-thread", user_id="owner-1")
default_thread = await thread_store.get("channel-thread", user_id="default")
return owner_run, default_run, owner_thread, default_thread, task_context
owner_run, default_run, owner_thread, default_thread, task_context = asyncio.run(_scenario())
assert owner_run is not None
assert owner_run["user_id"] == "owner-1"
assert default_run is None
assert owner_thread is not None
assert owner_thread["user_id"] == "owner-1"
assert owner_thread["metadata"] == {"legacy": True}
assert default_thread is None
assert task_context["user_id"] == "owner-1"
def test_launch_scheduled_thread_run_marks_context_non_interactive(_stub_app_config):
import asyncio
from types import SimpleNamespace
from unittest.mock import patch
from app.gateway.services import launch_scheduled_thread_run
async def _scenario():
captured: dict[str, object] = {}
async def fake_start_run(body, thread_id, request):
captured["thread_id"] = thread_id
captured["context"] = body.context
captured["metadata"] = body.metadata
return SimpleNamespace(run_id="run-1", thread_id=thread_id)
with patch("app.gateway.services.start_run", side_effect=fake_start_run):
result = await launch_scheduled_thread_run(
thread_id="thread-scheduled",
assistant_id="lead_agent",
prompt="Run in background",
app=SimpleNamespace(state=SimpleNamespace()),
owner_user_id="user-1",
metadata={"scheduled_task_id": "task-1"},
)
return captured, result
captured, result = asyncio.run(_scenario())
assert captured["thread_id"] == "thread-scheduled"
assert captured["context"] == {"non_interactive": True, "user_id": "user-1"}
assert captured["metadata"] == {"scheduled_task_id": "task-1"}
assert result == {"run_id": "run-1", "thread_id": "thread-scheduled"}
# ---------------------------------------------------------------------------
# build_run_config — context / configurable precedence (LangGraph >= 0.6.0)
# ---------------------------------------------------------------------------
def test_build_run_config_with_context():
"""When caller sends 'context', prefer it over 'configurable'."""
from app.gateway.services import build_run_config
config = build_run_config(
"thread-1",
{"context": {"user_id": "u-42", "thread_id": "thread-1"}},
None,
)
assert "context" in config
assert config["context"]["user_id"] == "u-42"
assert config["context"]["thread_id"] == "thread-1"
# configurable carries thread_id for the checkpointer; user context stays in context.
assert config["configurable"] == {"thread_id": "thread-1"}
assert config["recursion_limit"] == 100
def test_build_run_config_context_injects_thread_id():
from app.gateway.services import build_run_config
config = build_run_config(
"T-deadbeef-42",
{"context": {"user_id": "u-1", "thinking_enabled": True}},
None,
)
assert config["context"]["user_id"] == "u-1"
assert config["context"]["thinking_enabled"] is True
assert config["context"]["thread_id"] == "T-deadbeef-42"
assert config["configurable"] == {"thread_id": "T-deadbeef-42"}
def test_build_run_config_null_context_becomes_empty_context():
"""When caller sends context=null, treat it as an empty context object."""
from app.gateway.services import build_run_config
config = build_run_config("thread-1", {"context": None}, None)
assert config["context"] == {"thread_id": "thread-1"}
assert config["configurable"] == {"thread_id": "thread-1"}
def test_build_run_config_rejects_non_mapping_context():
"""When caller sends a non-object context, raise a clear error instead of a TypeError."""
import pytest
from app.gateway.services import build_run_config
with pytest.raises(ValueError, match="context"):
build_run_config("thread-1", {"context": "bad-context"}, None)
def test_build_run_config_null_context_custom_agent_injects_agent_name():
"""Custom assistant_id must be injected into both containers even when the
request started in context-only mode with ``context=null`` .
"""
from app.gateway.services import build_run_config
config = build_run_config("thread-1", {"context": None}, None, assistant_id="finalis")
assert config["context"]["agent_name"] == "finalis"
assert config["configurable"]["agent_name"] == "finalis"
def test_build_run_config_context_plus_configurable_warns(caplog):
"""When caller sends both 'context' and 'configurable', prefer 'context' and log a warning."""
import logging
from app.gateway.services import build_run_config
with caplog.at_level(logging.WARNING, logger="app.gateway.services"):
config = build_run_config(
"thread-1",
{
"context": {"user_id": "u-42"},
"configurable": {"model_name": "gpt-4"},
},
None,
)
assert "context" in config
assert config["context"]["user_id"] == "u-42"
# context wins: caller's configurable (model_name) is dropped, but thread_id is
# still set for the checkpointer.
assert config["configurable"] == {"thread_id": "thread-1"}
assert "model_name" not in config["configurable"]
assert any("both 'context' and 'configurable'" in r.message for r in caplog.records)
def test_build_run_config_context_passthrough_other_keys():
"""Non-conflicting keys from request_config are still passed through when context is used."""
from app.gateway.services import build_run_config
config = build_run_config(
"thread-1",
{"context": {"thread_id": "thread-1"}, "tags": ["prod"]},
None,
)
assert config["context"]["thread_id"] == "thread-1"
assert config["configurable"] == {"thread_id": "thread-1"}
assert config["tags"] == ["prod"]
def test_build_run_config_no_request_config():
"""When request_config is None, fall back to basic configurable with thread_id."""
from app.gateway.services import build_run_config
config = build_run_config("thread-abc", None, None)
assert config["configurable"] == {"thread_id": "thread-abc"}
assert "context" not in config
def test_strip_internal_context_keys_scrubs_config_smuggled_non_interactive():
"""A non-internal client must not force ``non_interactive`` via the free-form
``body.config`` either — ``build_run_config`` copies ``config.context`` and
``config.configurable`` verbatim, so the assembled config gets scrubbed."""
from app.gateway.services import build_run_config, strip_internal_context_keys
via_context = build_run_config("thread-1", {"context": {"non_interactive": True, "model_name": "gpt"}}, None)
strip_internal_context_keys(via_context)
assert "non_interactive" not in via_context["context"]
assert via_context["context"]["model_name"] == "gpt"
via_configurable = build_run_config("thread-1", {"configurable": {"non_interactive": True}}, None)
strip_internal_context_keys(via_configurable)
assert "non_interactive" not in via_configurable["configurable"]