diff --git a/README.md b/README.md index ad6a1064d..1ac921d6d 100644 --- a/README.md +++ b/README.md @@ -1497,7 +1497,9 @@ agent has already read stays in the destination conversation after access expires or the source is deleted. A message too long for one read carries a continuation, so the agent can read the rest; it asks for the missing part only if that read is unavailable. -This API-only feature adds no frontend selector or automatic history search. See +SDK clients that cannot add top-level request fields may send the same list as +`context.conversation_references`, and `GET /api/features` reports whether the +tool is enabled. There is no frontend selector or automatic history search. See [configuration](backend/docs/CONFIGURATION.md#reading-referenced-conversations) and the [request contract](backend/docs/API.md#referencing-a-previous-conversation). diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md index ee7073270..8437c63f9 100644 --- a/backend/app/gateway/AGENTS.md +++ b/backend/app/gateway/AGENTS.md @@ -7,6 +7,12 @@ derive grants from message contents or checkpoints. The callback travels through terminal cleanup. `conversation_reader.py` shares the existing HTTP transcript visibility/pagination logic without a Request dependency; ownership remains a caller responsibility. The tool additionally excludes non-text/internal content. +`RunCreateRequest` also accepts the list as `context.conversation_references` +(LangGraph SDK clients cannot send top-level extras): a before-validator lifts +it into the top-level field, so bounds and error locations are shared, and drops +it from `context`, so `merge_run_context_overrides` never sees it; sending both +is a 422. `conversation_references_enabled()` is the one "tool is configured" +predicate, used by admission and by `/api/features`. FastAPI listens on port 8001; health: `GET /health` (liveness) and `GET /health/ready` (readiness; concurrently probes the ORM engine behind `database:` plus the effective LangGraph checkpointer/Store backend - the legacy `checkpointer:` section, otherwise derived from `database:`, resolved from the startup config snapshot recorded on `app.state` - beneath a single bounded deadline, with connection-opening probes serialized behind a strict per-process gate, 503 while either is unreachable or the startup backend cannot be resolved, `not_configured` for process-local backends such as `backend=memory`). Set `GATEWAY_ENABLE_DOCS=false` to disable the default `/docs`, `/redoc`, and `/openapi.json` endpoints. @@ -65,7 +71,7 @@ owner-scoped assistant version selection remains enabled. | Router | Endpoints | |--------|-----------| | **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details | -| **Features** (`/api/features`) | `GET /` - UI capabilities: hot-reloaded agents, guarded browser, startup MCP tasks, and separate batch repository/worker states so history stays readable without a worker | +| **Features** (`/api/features`) | `GET /` - UI capabilities: hot-reloaded agents, guarded browser, startup MCP tasks, separate batch repository/worker states so history stays readable without a worker, and `conversation_references` (whether `read_conversation` is configured, plus the per-run reference cap) | | **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured | | **MCP** (`/api/mcp`) | GET /config - raw/masked; PUT /config - bulk; PATCH /config - toggle; POST /config/servers - add; PUT /config/server - replace; DELETE /config/servers/{server_name:path} - bodyless. Validate expanded, save raw; reload/reset; invalid -> 400. | | **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration | diff --git a/backend/app/gateway/conversation_access.py b/backend/app/gateway/conversation_access.py index 1538a7eb8..408a3319c 100644 --- a/backend/app/gateway/conversation_access.py +++ b/backend/app/gateway/conversation_access.py @@ -114,6 +114,15 @@ def _fit_text(item: dict, room: int) -> str: return text[:low] +def conversation_references_enabled(app_config: AppConfig) -> bool: + """Whether runs may carry references: the opt-in tool is in the configured tool list. + + Shared by run admission and ``/api/features`` so the UI gate and the + server check cannot drift. + """ + return any(tool.use == CONVERSATION_TOOL_USE for tool in app_config.tools) + + def _is_int(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) @@ -143,7 +152,7 @@ def prepare_conversation_reader( """ if not references: return None - if not any(tool.use == CONVERSATION_TOOL_USE for tool in app_config.tools): + if not conversation_references_enabled(app_config): raise HTTPException(status_code=400, detail="read_conversation is not enabled") auth = getattr(request.state, "auth", None) if auth is None or not auth.is_authenticated or not auth.has_permission("runs", "read") or not user_id: diff --git a/backend/app/gateway/routers/features.py b/backend/app/gateway/routers/features.py index 97b009bbd..a57b63a3c 100644 --- a/backend/app/gateway/routers/features.py +++ b/backend/app/gateway/routers/features.py @@ -11,7 +11,9 @@ from fastapi import APIRouter, Depends, Request from pydantic import BaseModel, Field from app.gateway.browser_capability import browser_capability +from app.gateway.conversation_access import conversation_references_enabled from app.gateway.deps import get_config +from app.gateway.run_models import MAX_CONVERSATION_REFERENCES from deerflow.config.app_config import AppConfig from deerflow.subagents.capacity import configured_subagent_max_running @@ -45,6 +47,13 @@ class SubagentBatchesFeature(BaseModel): max_running: int = Field(..., description="Native subagent execution slots in this Gateway process") +class ConversationReferencesFeature(BaseModel): + """Availability of explicit conversation references on run requests.""" + + enabled: bool = Field(..., description="Whether the opt-in read_conversation tool is configured, so run requests may carry conversation_references") + max_references: int = Field(..., description="Maximum conversation references accepted on one run request") + + class FeaturesResponse(BaseModel): """Frontend-facing feature availability flags.""" @@ -52,6 +61,7 @@ class FeaturesResponse(BaseModel): browser_control: BrowserControlFeature mcp_tasks: McpTasksFeature subagent_batches: SubagentBatchesFeature + conversation_references: ConversationReferencesFeature @router.get( @@ -80,4 +90,11 @@ async def list_features(request: Request, config: AppConfig = Depends(get_config worker_running=subagent_batch_worker_running, max_running=configured_subagent_max_running(), ), + # Same predicate as run admission (``prepare_conversation_reader``), read + # through ``get_config`` so enabling the tool in config.yaml shows up + # without a restart. A UI with no entry point still needs no change here. + conversation_references=ConversationReferencesFeature( + enabled=conversation_references_enabled(config), + max_references=MAX_CONVERSATION_REFERENCES, + ), ) diff --git a/backend/app/gateway/run_models.py b/backend/app/gateway/run_models.py index 0bb51feb8..33d749ee1 100644 --- a/backend/app/gateway/run_models.py +++ b/backend/app/gateway/run_models.py @@ -2,14 +2,29 @@ from __future__ import annotations +from collections.abc import Iterable from typing import Annotated, Any, Literal -from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, ValidationInfo, field_validator, model_validator from pydantic_core import PydanticCustomError from deerflow.runtime.stream_modes import RunStreamMode, UnsupportedStreamModeError, normalize_stream_modes from deerflow.utils.thread_id import validate_thread_id +# Upper bound on explicit conversation references per run; ``/api/features`` +# reports it so a UI can cap its selection to the same number. +MAX_CONVERSATION_REFERENCES = 3 + +# One reference as the field accepts it; the conflict guard probes with the +# same annotation so its acceptance set is exactly the field's, now and after +# a pydantic upgrade (lax mode also coerces tuples, sets, generators, ...). +ConversationReference = Annotated[str, Field(strict=True, min_length=1, max_length=2048)] +_REFERENCES_ADAPTER = TypeAdapter(list[ConversationReference]) +# Inputs the lift never materialises: lists and tuples can be read again, and +# str, bytes and dict are rejected by the field as a whole (``list_type``), a +# verdict the field must keep reporting itself. +_READ_MANY_TIMES = (list, tuple, str, bytes, bytearray, dict) + class RunCreateRequest(BaseModel): """Validated run request used by both HTTP and internal launch paths.""" @@ -22,8 +37,10 @@ class RunCreateRequest(BaseModel): metadata: dict[str, Any] | None = Field(default=None, description="Run metadata") config: dict[str, Any] | None = Field(default=None, description="RunnableConfig overrides") context: dict[str, Any] | None = Field(default=None, description="DeerFlow context overrides (model_name, thinking_enabled, etc.)") - conversation_references: list[Annotated[str, Field(strict=True, min_length=1, max_length=2048)]] = Field( - default_factory=list, max_length=3, description="Explicit thread IDs or same-origin chat URLs readable only during this run (opt-in read_conversation tool)" + conversation_references: list[ConversationReference] = Field( + default_factory=list, + max_length=MAX_CONVERSATION_REFERENCES, + description="Explicit thread IDs or same-origin chat URLs readable only during this run (opt-in read_conversation tool); SDK clients may send the same list as context.conversation_references", ) webhook: None = Field(default=None, description="Compatibility placeholder; completion callbacks are not supported") checkpoint_id: str | None = Field(default=None, description="Resume from checkpoint") @@ -40,6 +57,46 @@ class RunCreateRequest(BaseModel): if_not_exists: Literal["create"] = Field(default="create", description="Compatibility default; missing threads are created") feedback_keys: None = Field(default=None, description="Compatibility placeholder; feedback key collection is not supported") + @model_validator(mode="before") + @classmethod + def lift_context_conversation_references(cls, data: Any) -> Any: + """Accept ``context.conversation_references`` as the same explicit grant. + + LangGraph SDK clients build a fixed run body and drop unknown top-level + fields, so the web UI can only reach ``conversation_references`` through + ``context``. The key is moved to the top level before field validation, + so it keeps the same bounds and error locations, and it is removed from + ``context`` so run-context merging never sees it. Sending both is an + error rather than a silent merge. + """ + if not isinstance(data, dict): + return data + context = data.get("context") + if not isinstance(context, dict) or "conversation_references" not in context: + return data + references = context["conversation_references"] + top_level = data.get("conversation_references") + if isinstance(top_level, Iterable) and not isinstance(top_level, _READ_MANY_TIMES): + # Anything else the field would coerce may be walkable only once + # (a generator, or an object whose ``__iter__`` hands out one). + # Materialise it so the probe below and the field validate the same + # items, instead of the field seeing an exhausted input as []. + top_level = list(top_level) + data = {**data, "conversation_references": top_level} + lifted = {**data, "context": {key: value for key, value in context.items() if key != "conversation_references"}} + if references is None: + return lifted + if top_level is not None: + try: + top_level = _REFERENCES_ADAPTER.validate_python(top_level) + except ValidationError: + # Let the field report its own error instead of a misleading conflict. + return data + if top_level: + raise PydanticCustomError("conversation_references_conflict", "Pass conversation_references at the top level or in context, not both") + lifted["conversation_references"] = references + return lifted + @model_validator(mode="after") def validate_configurable_thread_id(self) -> RunCreateRequest: """Validate the stateless-run thread selector inside RunnableConfig.""" diff --git a/backend/docs/API.md b/backend/docs/API.md index 3d1a531a0..f99e6b5cf 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -344,6 +344,25 @@ links in pasted documents, tool results, or previous messages grant no access. The server supplies source IDs to the model as background user-role data and binds the reader to this run's references and authenticated identity. +Clients that cannot add top-level fields to a run request (the LangGraph JS SDK +builds a fixed body and drops unknown keys) may send the same list as +`context.conversation_references`: + +```json +{ + "input": {"messages": [{"role": "user", "content": "Use the requirements agreed in the referenced conversation."}]}, + "context": {"conversation_references": ["https://deerflow.example/workspace/chats/source-thread"]} +} +``` + +The Gateway lifts the key out of `context` before the run context is assembled, +so it has the same bounds and error locations as the top-level field, is +recorded on the run in the same way, and never reaches the merged run context +or the checkpointed `configurable`. Sending the top-level field and the context +key together returns 422. `GET /api/features` reports +`conversation_references.enabled` (the tool is configured) and `max_references`, +so a client can hide its entry point on deployments without the tool. + The request requires `runs:read` as well as the normal run-creation permission. The tool rechecks source ownership on each read; foreign, deleted and unowned legacy threads are unavailable. `read_conversation(thread_id, cursor?, limit?)` diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index a5ca7e401..2ecde2931 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -571,6 +571,10 @@ Custom agents must also permit the `conversation` tool group where they restrict groups. References are limited to owned threads and the current run; they do not enable history discovery, memory extraction or cross-user access. See the [request contract and limits](API.md#referencing-a-previous-conversation). +Once the tool is listed, `GET /api/features` reports +`conversation_references.enabled: true` and the per-run cap, so a client can +show an entry point only where the tool exists; SDK clients that cannot add +top-level request fields pass the list as `context.conversation_references`. Reader pages are sized to stay within the `tool_output` budget for `read_conversation` (12,000 serialized characters by default), so they are not diff --git a/backend/tests/test_conversation_references_context_channel.py b/backend/tests/test_conversation_references_context_channel.py new file mode 100644 index 000000000..749b3e11d --- /dev/null +++ b/backend/tests/test_conversation_references_context_channel.py @@ -0,0 +1,230 @@ +"""``context.conversation_references`` is the same explicit grant as the top-level field. + +LangGraph SDK clients build a fixed run body and drop unknown top-level fields, +so the web UI cannot send ``conversation_references`` there. The ``context`` +object does reach the Gateway. The request model lifts the key out of it before +any run context is assembled, so it is consumed at admission and never forwarded. +""" + +from __future__ import annotations + +import asyncio +import json +from collections import UserList, deque +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from langchain_core.messages import HumanMessage +from pydantic import ValidationError + +from app.gateway.authz import AuthContext +from app.gateway.run_models import MAX_CONVERSATION_REFERENCES, RunCreateRequest +from deerflow.config.app_config import AppConfig + + +def test_context_references_are_lifted_into_the_request_and_removed_from_context(): + body = RunCreateRequest(context={"conversation_references": ["source"], "thinking_enabled": True}) + assert body.conversation_references == ["source"] + assert body.context == {"thinking_enabled": True} + + +@pytest.mark.parametrize("value", [None, []]) +def test_empty_context_references_grant_nothing_and_leave_no_key(value): + body = RunCreateRequest(context={"conversation_references": value, "mode": "flash"}) + assert body.conversation_references == [] + assert body.context == {"mode": "flash"} + + +@pytest.mark.parametrize( + "invalid", + ["source", [""], [1], ["x" * 2049], ["s"] * (MAX_CONVERSATION_REFERENCES + 1), {"thread": "source"}], +) +def test_context_references_keep_the_top_level_bounds(invalid): + with pytest.raises(ValidationError) as exc: + RunCreateRequest(context={"conversation_references": invalid}) + assert exc.value.errors() + assert all(error["loc"][0] == "conversation_references" for error in exc.value.errors()) + + +def test_top_level_and_context_references_together_are_rejected(): + with pytest.raises(ValidationError) as exc: + RunCreateRequest(conversation_references=["source"], context={"conversation_references": ["source"]}) + assert [error["type"] for error in exc.value.errors()] == ["conversation_references_conflict"] + + +def test_a_malformed_top_level_value_reports_its_type_error_not_the_conflict(): + with pytest.raises(ValidationError) as exc: + RunCreateRequest(conversation_references="source", context={"conversation_references": ["source"]}) + assert [(error["type"], error["loc"]) for error in exc.value.errors()] == [("list_type", ("conversation_references",))] + + +@pytest.mark.parametrize( + "top_level", + [ + ("source",), + {"source"}, + frozenset({"source"}), + deque(["source"]), + UserList(["source"]), + dict.fromkeys(["source"]).keys(), + (item for item in ("source",)), + ], + ids=["tuple", "set", "frozenset", "deque", "UserList", "dict_keys", "generator"], +) +def test_everything_the_field_would_coerce_also_reports_the_conflict(top_level): + # Pydantic's lax mode coerces many iterables into the list field, so a + # direct Python caller must not slip both grants past the conflict check. + # The guard asks pydantic itself instead of enumerating types. + with pytest.raises(ValidationError) as exc: + RunCreateRequest(conversation_references=top_level, context={"conversation_references": ["source"]}) + assert [error["type"] for error in exc.value.errors()] == ["conversation_references_conflict"] + + +@pytest.mark.parametrize("top_level", [0, False, 1.5, {"thread": "source"}, b"source"], ids=["zero", "false", "float", "dict", "bytes"]) +def test_values_the_field_rejects_still_report_their_own_type_error(top_level): + with pytest.raises(ValidationError) as exc: + RunCreateRequest(conversation_references=top_level, context={"conversation_references": ["source"]}) + assert [(error["type"], error["loc"]) for error in exc.value.errors()] == [("list_type", ("conversation_references",))] + + +@pytest.mark.parametrize( + "top_level", + [[""], [1], range(3), dict.fromkeys(["source"]).values(), dict.fromkeys(["source"]).items()], + ids=["empty_item", "int_item", "range", "dict_values", "dict_items"], +) +def test_invalid_items_at_the_top_level_report_the_item_error_not_the_conflict(top_level): + # The guard probes with the field's own annotation, so whatever the field + # coerces as a container but rejects per item (a range, dict views, a bad + # string) is reported at the item's index rather than as a conflict. + with pytest.raises(ValidationError) as exc: + RunCreateRequest(conversation_references=top_level, context={"conversation_references": ["source"]}) + errors = exc.value.errors() + assert errors + assert all(error["loc"][0] == "conversation_references" and isinstance(error["loc"][1], int) for error in errors) + assert not any(error["type"] == "conversation_references_conflict" for error in errors) + + +def test_a_one_shot_iterator_with_a_bad_item_still_reports_the_item_error(): + # The probe must not consume a generator and leave the field an exhausted + # one that coerces to [] and validates silently with the key left in context. + with pytest.raises(ValidationError) as exc: + RunCreateRequest(conversation_references=(item for item in ["", "source"]), context={"conversation_references": ["source"]}) + errors = exc.value.errors() + assert [error["loc"] for error in errors] == [("conversation_references", 0)] + assert not any(error["type"] == "conversation_references_conflict" for error in errors) + + +class _OneShotIterable: + """An iterable that is not an ``Iterator`` but can be walked only once.""" + + def __init__(self, items): + self._items = list(items) + self._spent = False + + def __iter__(self): + if self._spent: + return iter(()) + self._spent = True + return iter(self._items) + + +def test_a_one_shot_iterable_that_is_not_an_iterator_is_also_read_once(): + with pytest.raises(ValidationError) as exc: + RunCreateRequest(conversation_references=_OneShotIterable(["", "source"]), context={"conversation_references": ["source"]}) + errors = exc.value.errors() + assert [error["loc"] for error in errors] == [("conversation_references", 0)] + assert not any(error["type"] == "conversation_references_conflict" for error in errors) + body = RunCreateRequest(conversation_references=_OneShotIterable(["source"]), context={"thinking_enabled": True}) + assert body.conversation_references == ["source"] + + +def test_a_one_shot_iterator_of_valid_items_is_read_once_and_kept(): + body = RunCreateRequest(conversation_references=(item for item in ["source"]), context={"thinking_enabled": True}) + assert body.conversation_references == ["source"] + with pytest.raises(ValidationError) as exc: + RunCreateRequest(conversation_references=(item for item in ["source"]), context={"conversation_references": ["other"]}) + assert [error["type"] for error in exc.value.errors()] == ["conversation_references_conflict"] + + +def test_an_empty_top_level_list_does_not_conflict_with_context(): + body = RunCreateRequest(conversation_references=[], context={"conversation_references": ["source"]}) + assert body.conversation_references == ["source"] + assert body.context == {} + + +def test_max_references_matches_the_field_bound(): + assert RunCreateRequest(conversation_references=["s"] * MAX_CONVERSATION_REFERENCES).conversation_references == ["s"] * MAX_CONVERSATION_REFERENCES + with pytest.raises(ValidationError): + RunCreateRequest(conversation_references=["s"] * (MAX_CONVERSATION_REFERENCES + 1)) + + +def test_start_run_grants_through_context_without_forwarding_the_key(monkeypatch): + from test_gateway_services import _make_start_run_persistence_context + + from app.gateway import services + from deerflow.config.app_config import reset_app_config, set_app_config + from deerflow.runtime.user_context import reset_current_user, set_current_user + + async def exercise(): + request, _, threads = _make_start_run_persistence_context() + user = SimpleNamespace(id="alice", system_role="admin", role="admin") + request.state.user = user + request.state.auth = AuthContext(user, ["runs:create", "runs:read"]) + request.state.auth_source = "session" + request.url = "https://deerflow.example/api/threads/current/runs" + await threads.create("source", user_id="alice") + captured = [] + + async def fake_run_agent(*args, **kwargs): + captured.append(kwargs) + + monkeypatch.setattr(services, "run_agent", fake_run_agent) + monkeypatch.setattr(services, "resolve_agent_factory", lambda *_: object()) + + body = RunCreateRequest( + input={"messages": [{"role": "user", "content": "Read the reference"}]}, + context={"conversation_references": ["source"], "thinking_enabled": True}, + ) + record = await services.start_run(body, "current", request) + await record.task + assert callable(captured[0]["ctx"].conversation_reader) + assert any(isinstance(m, HumanMessage) and "source" in str(m.content) for m in captured[0]["graph_input"]["messages"]) + # The run record keeps the references exactly like the top-level field. + assert record.kwargs["conversation_references"] == ["source"] + assert "__conversation_reader" not in json.dumps(record.kwargs) + # Other context keys still flow; the references never reach the run context or the checkpointed configurable. + config = captured[0]["config"] + assert config["context"]["thinking_enabled"] is True + assert "conversation_references" not in config["context"] + assert "conversation_references" not in config["configurable"] + + # Copies smuggled through the free-form RunnableConfig grant nothing. + smuggled = await services.start_run( + RunCreateRequest( + input={"messages": [{"role": "user", "content": "again"}]}, + config={"configurable": {"conversation_references": ["source"]}, "context": {"conversation_references": ["source"]}}, + ), + "smuggled", + request, + ) + await smuggled.task + assert captured[1]["ctx"].conversation_reader is None + assert "conversation_references" not in (smuggled.kwargs or {}) + + # Idempotent replay compares the lifted references like the top-level field. + keyed = await services.start_run(body, "idempotent", request, idempotency_key="ctx-key") + await keyed.task + assert await services.start_run(body, "idempotent", request, idempotency_key="ctx-key") is keyed + changed = RunCreateRequest(input=body.input, context={"conversation_references": ["other"], "thinking_enabled": True}) + with pytest.raises(HTTPException) as conflict: + await services.start_run(changed, "idempotent", request, idempotency_key="ctx-key") + assert conflict.value.status_code == 409 + + set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, "tools": [{"name": "read_conversation", "group": "conversation", "use": "deerflow.tools.conversation:read_conversation"}]})) + user_token = set_current_user(SimpleNamespace(id="alice")) + try: + asyncio.run(exercise()) + finally: + reset_current_user(user_token) + reset_app_config() diff --git a/backend/tests/test_features_router.py b/backend/tests/test_features_router.py index d78de64d3..6b228f812 100644 --- a/backend/tests/test_features_router.py +++ b/backend/tests/test_features_router.py @@ -16,6 +16,7 @@ def _app_with_config( mcp_tasks_available: bool = False, subagent_batches_available: bool = False, subagent_batch_repo_available: bool | None = None, + conversation_references_enabled: bool = False, ) -> FastAPI: app = FastAPI() app.state.mcp_tasks_available = mcp_tasks_available @@ -24,13 +25,11 @@ def _app_with_config( subagent_batch_repo_available = subagent_batches_available app.state.subagent_batch_repo = object() if subagent_batch_repo_available else None app.include_router(features.router) - tools = ( - [ - SimpleNamespace(name="browser_navigate", model_extra=browser_extra or {}), - ] - if browser_enabled - else [] - ) + tools = [] + if browser_enabled: + tools.append(SimpleNamespace(name="browser_navigate", use="deerflow.community.browser:browser_navigate_tool", model_extra=browser_extra or {})) + if conversation_references_enabled: + tools.append(SimpleNamespace(name="read_conversation", use="deerflow.tools.conversation:read_conversation", model_extra={})) fake_config = SimpleNamespace( agents_api=SimpleNamespace(enabled=agents_api_enabled), tools=tools, @@ -54,6 +53,7 @@ def test_features_reports_agents_api_enabled() -> None: "worker_running": False, "max_running": 3, }, + "conversation_references": {"enabled": False, "max_references": 3}, } @@ -71,9 +71,17 @@ def test_features_reports_agents_api_disabled() -> None: "worker_running": False, "max_running": 3, }, + "conversation_references": {"enabled": False, "max_references": 3}, } +def test_features_reports_conversation_references_when_the_tool_is_configured() -> None: + with TestClient(_app_with_config(agents_api_enabled=True, conversation_references_enabled=True)) as client: + response = client.get("/api/features") + assert response.status_code == 200 + assert response.json()["conversation_references"] == {"enabled": True, "max_references": 3} + + def test_features_reports_mcp_tasks_startup_capability() -> None: with TestClient(_app_with_config(agents_api_enabled=True, mcp_tasks_available=True)) as client: response = client.get("/api/features")