From 0baa99ca902dc03e2680d030fca88229bb2ae0e2 Mon Sep 17 00:00:00 2001 From: Totoro Date: Thu, 22 Oct 2026 08:08:23 +0800 Subject: [PATCH] feat(models): add scoped model controls to Web chats (#7249) * test: stabilize reporter evidence and guard missing chat nodes * feat(models): scope Web model choices to tasks, turns and conversations * test(e2e): mock model-control preview for ordinary chat submissions * fix(models): harden scoped choices and stale Agent submissions --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com> --- README.md | 2 + backend/app/gateway/routers/models.py | 40 ++++ backend/app/gateway/services.py | 19 ++ .../deerflow/agents/assembly_descriptor.py | 1 + .../deerflow/agents/lead_agent/agent.py | 20 +- .../harness/deerflow/config/model_config.py | 1 + .../harness/deerflow/models/AGENTS.md | 2 + .../harness/deerflow/models/factory.py | 1 + .../harness/deerflow/models/selection.py | 144 ++++++++++++++ .../deerflow/tools/builtins/task_tool.py | 19 ++ backend/tests/test_gateway_services.py | 45 +++++ .../tests/test_lead_agent_model_resolution.py | 62 ++++++ backend/tests/test_model_selection.py | 176 ++++++++++++++++++ backend/tests/test_models_authorization.py | 30 +++ backend/tests/test_models_router_reasoning.py | 29 +++ backend/tests/test_node_test_acceptance.py | 5 +- backend/tests/test_task_tool_core_logic.py | 73 ++++++++ ...st_tool_args_schema_no_pydantic_warning.py | 3 +- config.example.yaml | 2 + frontend/AGENTS.md | 10 +- .../[agent_name]/chats/[thread_id]/page.tsx | 136 +++++++++++++- .../components/workspace/chats/chat-page.tsx | 149 ++++++++++++++- frontend/src/core/messages/model-control.ts | 33 ++++ frontend/src/core/messages/utils.ts | 6 + frontend/src/core/models/control.ts | 60 ++++++ frontend/src/core/models/use-model-control.ts | 172 +++++++++++++++++ frontend/src/core/settings/store.ts | 17 ++ frontend/src/core/threads/hooks.ts | 3 + frontend/src/core/threads/utils.ts | 3 + .../e2e/model-control-navigation.spec.ts | 145 +++++++++++++++ .../e2e/scheduled-task-chat-events.spec.ts | 1 + frontend/tests/e2e/utils/mock-api.ts | 31 +++ .../unit/core/messages/model-control.test.ts | 57 ++++++ .../tests/unit/core/models/control.test.ts | 23 +++ .../core/models/model-control.dom.test.ts | 92 +++++++++ .../thread-model-selection.dom.test.ts | 31 +++ .../unit/core/threads/run-context.test.ts | 22 +++ 37 files changed, 1642 insertions(+), 23 deletions(-) create mode 100644 backend/packages/harness/deerflow/models/selection.py create mode 100644 backend/tests/test_model_selection.py create mode 100644 frontend/src/core/messages/model-control.ts create mode 100644 frontend/src/core/models/control.ts create mode 100644 frontend/src/core/models/use-model-control.ts create mode 100644 frontend/tests/e2e/model-control-navigation.spec.ts create mode 100644 frontend/tests/unit/core/messages/model-control.test.ts create mode 100644 frontend/tests/unit/core/models/control.test.ts create mode 100644 frontend/tests/unit/core/models/model-control.dom.test.ts create mode 100644 frontend/tests/unit/core/settings/thread-model-selection.dom.test.ts diff --git a/README.md b/README.md index b2b0dd4bae..708c6d6535 100644 --- a/README.md +++ b/README.md @@ -2291,6 +2291,8 @@ When `token_budget.enabled` and `token_usage.enabled` are both enabled, each lea ### Sub-Agents +Web chats accept direct model choices such as “ask MODEL to handle this”, “use MODEL for this turn”, and “from now on use MODEL”; Chinese equivalents are supported too. A child choice leaves the main model unchanged, a one-turn choice leaves defaults unchanged, and a conversation choice uses the existing browser-local thread override without changing account preferences. Choices resolve against available configurations and optional `models[].aliases`; ambiguous names or scopes show a selection card before execution, while delegation still requires enabled subagents (Ultra mode). + When a sub-agent ends with `return_direct=True` tools, including tools contributed by extension middleware, their outputs are returned in tool-call order. A failed tool marks the task as failed while preserving the batch outputs. Ordinary `task` calls accept `context_mode="isolated"` (default) or diff --git a/backend/app/gateway/routers/models.py b/backend/app/gateway/routers/models.py index 46d24a1ef2..dd0f02e2e0 100644 --- a/backend/app/gateway/routers/models.py +++ b/backend/app/gateway/routers/models.py @@ -1,3 +1,4 @@ +import asyncio import logging from typing import Literal @@ -15,6 +16,7 @@ from app.gateway.internal_auth import get_trusted_internal_owner_user_id from deerflow.config.app_config import AppConfig from deerflow.config.model_config import ModelConfig from deerflow.models.reasoning import reasoning_capabilities_payload, resolve_reasoning_contract +from deerflow.models.selection import ModelScope, resolve_model_control logger = logging.getLogger(__name__) @@ -92,6 +94,44 @@ class ModelsListResponse(BaseModel): token_usage: TokenUsageResponse +class ModelControlRequest(BaseModel): + text: str = Field(max_length=4000) + model_name: str | None = Field(default=None, max_length=200) + scope: ModelScope | None = None + + +@router.post("/models/resolve", summary="Resolve a direct user model selection") +async def resolve_model_selection(body: ModelControlRequest, request: Request, config: AppConfig = Depends(get_config)) -> dict: + """Read-only preview. Never starts a run or changes model defaults.""" + listing = await list_models(request, config) + visible_names = {model.name for model in listing.models} + visible_models = [model for model in config.models if model.name in visible_names] + result = resolve_model_control(body.text, visible_models, model_name=body.model_name, scope=body.scope) + if result["candidates"]: + user = await get_optional_user_from_request(request) + if user is not None: + owner = await _resolve_bound_owner_for_model_list(request) + internal = _is_internal_caller(request, user) + if result["status"] == "resolved": + await asyncio.to_thread(authorize_model_use, owner or user, result["model_name"], is_internal=internal, app_config=config) + else: + usable_names = set() + for candidate in result["candidates"]: + try: + await asyncio.to_thread(authorize_model_use, owner or user, candidate["name"], is_internal=internal, app_config=config) + except HTTPException as exc: + if exc.status_code != 403: + raise + else: + usable_names.add(candidate["name"]) + if not usable_names: + # A recognized but denied choice must not become ordinary task text. + result.update(status="unavailable", model_name=None, candidates=[]) + else: + result = resolve_model_control(body.text, [model for model in visible_models if model.name in usable_names], model_name=body.model_name, scope=body.scope) + return result + + @router.get( "/models", response_model=ModelsListResponse, diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index d14693030b..0b2cb6d31c 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -919,6 +919,9 @@ _SERVER_OWNED_RUNTIME_CONTEXT_KEYS: frozenset[str] = ( # they are internal-only as well — see :data:`_INTERNAL_ONLY_CONTEXT_KEYS`. _CONTEXT_RUNTIME_ONLY_KEYS: frozenset[str] = frozenset({"github_token", "disable_clarification", "channel_name"}) +# Public run-only controls are accepted through body.context, not body.config. +_MODEL_SELECTION_CONTEXT_KEYS = frozenset({"subagent_model_name", "model_selection_requested"}) + # Every run-context key an external client may never supply, in either section. # The two sets differ only in *where* a legitimate internal caller's value lands # (both sections vs. ``context`` alone); their trust requirement is identical. @@ -994,6 +997,10 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An # and ``configurable`` is persisted in checkpoints. existing_configurable.pop("client", None) existing_runtime_context = config.get("context") + for section in (existing_configurable, existing_runtime_context): + if isinstance(section, dict): + for key in _MODEL_SELECTION_CONTEXT_KEYS: + section.pop(key, None) if isinstance(existing_runtime_context, dict) and "client" in existing_runtime_context: existing_client_context = _sanitize_request_client_context(existing_runtime_context.get("client")) if existing_client_context is None: @@ -1013,6 +1020,18 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An configurable.setdefault(key, context[key]) if isinstance(runtime_context, dict): runtime_context.setdefault(key, context[key]) + # Explicit choices belong to this run, never to checkpoint defaults. Model + # use is authorized against the authenticated identity during assembly and + # again at child dispatch; these fields grant no additional permissions. + for key in _MODEL_SELECTION_CONTEXT_KEYS: + if key in context: + value = context[key] + if not isinstance(value, str) or not value.strip() or len(value) > 200: + raise HTTPException(status_code=400, detail="A configured model name is required.") + if isinstance(runtime_context, dict): + runtime_context[key] = value + if "model_selection_requested" in context and (context.get("model_name") != context["model_selection_requested"] or not isinstance(runtime_context, dict) or runtime_context.get("model_name") != context["model_selection_requested"]): + raise HTTPException(status_code=400, detail="model_selection_requested must match model_name in context.") # Context-only keys (secrets / runtime flags) land in ``config['context']`` # only — never ``configurable`` (which is persisted in checkpoints) — and only # for internal callers, the sole legitimate producers. diff --git a/backend/packages/harness/deerflow/agents/assembly_descriptor.py b/backend/packages/harness/deerflow/agents/assembly_descriptor.py index 522dbaf1d7..ec1f1a909d 100644 --- a/backend/packages/harness/deerflow/agents/assembly_descriptor.py +++ b/backend/packages/harness/deerflow/agents/assembly_descriptor.py @@ -46,6 +46,7 @@ _MODEL_METADATA_FIELDS = frozenset( { "name", "display_name", + "aliases", "description", "use", "context_window", diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index a09a972516..582bebd32c 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -1067,7 +1067,17 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le # Phase 3: enforce model:use authorization. On deny, fall back to the first # allowed model (graceful) rather than crashing the run (RFC §9). - model_name = _authorize_model_name(model_name, context=cfg, app_config=resolved_app_config) + explicit_selection = cfg.get("model_selection_requested") + if explicit_selection is not None: + from deerflow.models.selection import authorize_selected_model + + if not isinstance(explicit_selection, str) or explicit_selection != requested_model_name: + raise ValueError("An explicit model selection must carry the same model_name.") + if explicit_selection != model_name: + raise ValueError("The explicitly selected model is not configured.") + authorize_selected_model(model_name, context=cfg, app_config=resolved_app_config) + else: + model_name = _authorize_model_name(model_name, context=cfg, app_config=resolved_app_config) model_config = resolved_app_config.get_model_config(model_name) @@ -1318,6 +1328,14 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le app_config=resolved_app_config, chat_model=chat_model, ) + selected_child_model = cfg.get("subagent_model_name") + if selected_child_model is not None: + from deerflow.models.selection import authorize_selected_model, bind_selected_task_model + + if not subagent_enabled or not isinstance(selected_child_model, str): + raise ValueError("A child model selection requires enabled subagents and a configured model name.") + authorize_selected_model(selected_child_model, context=cfg, app_config=resolved_app_config) + raw_tools = bind_selected_task_model(raw_tools, selected_child_model) configured_tools = raw_tools + extra_tools configured_tools = [tool for tool in configured_tools if tool.name not in interaction_policy.disabled_tool_names] authorization_candidates = [*configured_tools] diff --git a/backend/packages/harness/deerflow/config/model_config.py b/backend/packages/harness/deerflow/config/model_config.py index b9a700a48d..1524c09e3f 100644 --- a/backend/packages/harness/deerflow/config/model_config.py +++ b/backend/packages/harness/deerflow/config/model_config.py @@ -137,6 +137,7 @@ class ModelConfig(BaseModel): name: str = Field(..., description="Unique name for the model") request_admission: RequestAdmissionConfig | None = Field(default=None, description="Opt-in process-local RPM pacing. Changing an active group's policy requires a process restart.") display_name: str | None = Field(..., default_factory=lambda: None, description="Display name for the model") + aliases: list[str] = Field(default_factory=list, max_length=16, description="Optional names users can use when selecting this configured model") description: str | None = Field(..., default_factory=lambda: None, description="Description for the model") use: str = Field( ..., diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index 0f0928835e..37f2ec88ab 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -24,6 +24,8 @@ nested underflow into zero. Regression coverage is in `tests/test_mindie_provide ### Model Factory (`packages/harness/deerflow/models/factory.py`) +`selection.py` resolves direct user model controls against the visible catalog without model calls. `aliases` is presentation metadata, stripped from provider kwargs and assembly parameters. Gateway copies `subagent_model_name`/`model_selection_requested` into runtime context only. Explicit choices use `model:use` with no deny-to-default substitution; provider errors retain deployment policy. Bind selected child names through cloned task schemas; never mutate shared tools or agent configs. + Request-admission waits follow the next scheduled admission and configured interval, capped at 50 ms; the cap must not become a minimum poll interval that limits high-RPM throughput. Local `AdmissionError` is structurally non-retriable diff --git a/backend/packages/harness/deerflow/models/factory.py b/backend/packages/harness/deerflow/models/factory.py index d2a5341e68..4c9f48940c 100644 --- a/backend/packages/harness/deerflow/models/factory.py +++ b/backend/packages/harness/deerflow/models/factory.py @@ -20,6 +20,7 @@ _MODEL_METADATA_FIELDS: frozenset[str] = frozenset( "use", "name", "display_name", + "aliases", "description", "supports_thinking", "supports_reasoning_effort", diff --git a/backend/packages/harness/deerflow/models/selection.py b/backend/packages/harness/deerflow/models/selection.py new file mode 100644 index 0000000000..550d5ec91e --- /dev/null +++ b/backend/packages/harness/deerflow/models/selection.py @@ -0,0 +1,144 @@ +"""Resolve direct user model choices without calling a classifier or modifying state. + +Only the caller's visible catalog supplies candidates. This deliberately handles +direct controls, not model mentions in documents, quotations or conditional text. +""" + +from __future__ import annotations + +import re +from typing import Any, Literal + +ModelScope = Literal["task", "turn", "conversation"] + +_PATTERNS = ( + ("conversation", r"^(?:请)?(?:以后|今后|接下来)(?:这个对话|这段对话|这里)?(?:都|一直|默认)?(?:用|使用|改用|换成)\s*(.+?)(?:模型)?(?:[,。::]|$)"), + ("conversation", r"^(?:please\s+)?from now on\s+use\s+(.+?)(?:[,:]|$)"), + ("task", r"^(?:请|麻烦)?你?(?:找|让|叫)\s*(.+?)\s*(?:去|来)?(?:处理|做|解决|分析|计算|回答|帮我)"), + ("task", r"^(?:这个|这部分|这件事|这个任务).{0,16}?(?:交给|让)\s*(.+?)\s*(?:去|来)?(?:处理|做|解决|分析|计算|回答)"), + ("task", r"^(?:please\s+)?(?:ask|delegate to)\s+(.+?)\s+to\s+"), + ("turn", r"^(?:请)?(?:这次|本次|这一轮)(?:请)?(?:用|使用|改用|换成)\s*(.+?)(?:\s*(?:模型)?(?P帮我|来|去|处理|计算|回答|分析|生成|写|做|[,::])|$)"), + ("turn", r"^(?:please\s+)?use\s+(.+?)\s+for this\s+(?:turn|task)\b"), + (None, r"^(?:请)?(?:用|使用|改用|换成)\s*(.+?)(?:\s*(?:模型)?(?P帮我|来|去|处理|计算|回答|分析|生成|写|做|[,::])|$)"), + (None, r"^(?:please\s+)?use\s+(.+?)\s+to\s+"), +) + + +def _tokens(value: str) -> list[str]: + # Keep decimal versions intact: 4.1 must not accidentally match 4.10. + return [part for part in re.findall(r"[a-z]+|\d+(?:\.\d+)*|[\u4e00-\u9fff]+", value.casefold()) if part != "v"] + + +def _spellings(model: Any) -> list[str]: + return [value for value in (model.name, model.display_name, model.model, *getattr(model, "aliases", ())) if isinstance(value, str) and value.strip()] + + +def _matches(query: str, model: Any) -> bool: + wanted = _tokens(query) + if not wanted: + return False + for spelling in _spellings(model): + available = _tokens(spelling) + # CamelCase names such as DeepSeek also admit their initials (ds). + for word in re.findall(r"[A-Za-z]+", spelling): + capitals = "".join(re.findall(r"[A-Z]", word)) + if len(capitals) > 1: + available.append(capitals.casefold()) + if all(any(token == part if token[0].isdigit() else part.startswith(token) for part in available) for token in wanted): + return True + return False + + +def resolve_model_control(text: str, models: list[Any], *, model_name: str | None = None, scope: ModelScope | None = None) -> dict[str, Any]: + """Return a read-only decision; explicit selections are checked afresh. + + The host must enforce model:use before execution. Visibility is not permission + to use a model. Text must be the user's direct input, never attachment text. + """ + result: dict[str, Any] = {"status": "none", "scope": None, "query": "", "model_name": None, "candidates": [], "task_offset": 0} + if len(text) > 4000 or "\n" in text.split(":", 1)[0].split(":", 1)[0] or text.lstrip().startswith(("`", '"', "“", "'")): + return result + for found_scope, pattern in _PATTERNS: + match = re.match(pattern, text.strip(), re.IGNORECASE) + if match: + query = match.group(1).strip() + break + else: + return result + if model_name is not None: + candidates = [model for model in models if model.name == model_name] + if not candidates: + candidates = [model for model in models if _matches(model_name, model)] + else: + candidates = [model for model in models if _matches(query, model)] + # "Use Python to ..." is an ordinary task. Unknown versioned or explicitly + # named models, however, must not fall through to a misleading model reply. + if not candidates and model_name is None and not re.search(r"\bv\d|模型|\bmodel\b", query, re.IGNORECASE): + return result + offset = len(text) - len(text.lstrip()) + match.end() + if match.groupdict().get("action") in {"处理", "计算", "回答", "分析", "生成", "写", "做"}: + offset = len(text) - len(text.lstrip()) + match.start("action") + while offset < len(text) and (text[offset].isspace() or text[offset] in ",。::,"): + offset += 1 + result.update(scope=scope or found_scope, query=query, candidates=[{"name": m.name, "label": m.display_name or m.name} for m in candidates], task_offset=offset) + if not candidates: + result["status"] = "unavailable" + elif len(candidates) != 1 or result["scope"] is None: + result["status"] = "ambiguous" + else: + result.update(status="resolved", model_name=candidates[0].name) + return result + + +def authorize_selected_model(model_name: str, *, context: Any, app_config: Any) -> None: + """Check an explicit choice without the lead agent's default substitution. + + Run off-loop: provider resolution and authorization can perform blocking I/O. + Explicit denials reject; provider failures retain the deployment's policy. + """ + if app_config.get_model_config(model_name) is None: + raise ValueError("The selected model is not configured.") + authz = getattr(app_config, "authorization", None) + if authz is None or authz.enabled is not True: + return + from deerflow.authz.principal import build_principal_from_context + from deerflow.authz.provider import AuthzDecision, AuthzRequest + from deerflow.authz.runtime import resolve_authorization_provider + + try: + provider = resolve_authorization_provider(authz) + if provider is None: + raise RuntimeError("Authorization provider unavailable") + principal = build_principal_from_context(context, default_role=authz.default_role) + decision = provider.authorize(AuthzRequest(principal=principal, resource="model", action="use", target=model_name)) + if not isinstance(decision, AuthzDecision): + raise TypeError("Invalid authorization result") + except Exception: + if authz.fail_closed: + raise ValueError("Model authorization is unavailable.") from None + return + if not decision.allow: + raise ValueError("The selected model is not available for your role.") + + +def bind_selected_task_model(tools: list[Any], model_name: str) -> list[Any]: + """Expose this run's resolved choice in the task parameter, not a prompt. + + Clone both schema and tool: parallel runs must never rewrite the singleton. + No unfiltered model catalog or provider configuration is exposed. + """ + from pydantic import Field, create_model + + selected = [] + for tool in tools: + schema = getattr(tool, "args_schema", None) + if tool.name != "task" or not isinstance(schema, type) or "model_name" not in schema.model_fields: + selected.append(tool) + continue + selected_schema = create_model( + "SelectedTaskModel", + __base__=schema, + model_name=(Literal[model_name] | None, Field(default=model_name, description="The user's selected model for this child task; the parent model is unchanged.")), + ) + selected.append(tool.model_copy(update={"args_schema": selected_schema})) + return selected diff --git a/backend/packages/harness/deerflow/tools/builtins/task_tool.py b/backend/packages/harness/deerflow/tools/builtins/task_tool.py index 1a4a3125ac..eb975c67ee 100644 --- a/backend/packages/harness/deerflow/tools/builtins/task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/task_tool.py @@ -28,6 +28,7 @@ from deerflow.mcp_scope import ( THREAD_INCARNATION_METADATA_GUARD_KEY, runtime_thread_incarnation, ) +from deerflow.models.selection import authorize_selected_model from deerflow.runtime.user_context import resolve_runtime_user_id from deerflow.sandbox.security import LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE, is_host_bash_allowed from deerflow.subagents import SubagentExecutor, get_available_subagent_names, get_subagent_config @@ -758,6 +759,7 @@ async def task_tool( acceptance_criteria: list[str] | None = None, description: str = "", context_mode: Literal["isolated", "snapshot"] = "isolated", + model_name: str | None = None, ) -> str | Command: """Delegate a bounded task to a specialized subagent in its own context. @@ -864,6 +866,9 @@ async def task_tool( summary as background at dispatch time, increasing input tokens. The child keeps its own role/tools; later parent turns are not synced. Historical tool actions are not evidence of child completion. + model_name: Optional configured model name for this child only. Omit to + keep the subagent's configured/inherited model. Does not change the + parent conversation or the saved subagent configuration. """ if context_mode not in {"isolated", "snapshot"}: return _task_result_command(tool_call_id=tool_call_id, status="failed", error=f"Unknown context_mode '{context_mode}'. Use isolated or snapshot.") @@ -956,6 +961,20 @@ async def task_tool( # tool call delegated to a subagent (user_role=None). parent_context = runtime.context if runtime is not None else None parent_context = parent_context if isinstance(parent_context, dict) else {} + requested_model = parent_context.get("subagent_model_name") + if model_name is not None and requested_model is not None and model_name != requested_model: + return _task_result_command(tool_call_id=tool_call_id, status="failed", error="The child model conflicts with the user's selection.") + selected_model = model_name if model_name is not None else requested_model + if selected_model is not None: + resolved_config = runtime_app_config or await run_assembly(get_app_config) + try: + if not isinstance(selected_model, str) or not selected_model.strip(): + raise ValueError("A configured model name is required.") + await run_assembly(authorize_selected_model, selected_model, context=parent_context, app_config=resolved_config) + except ValueError as exc: + return _task_result_command(tool_call_id=tool_call_id, status="failed", error=str(exc)) + runtime_app_config = resolved_config + config = replace(config, model=selected_model) if parent_context.get(THREAD_INCARNATION_METADATA_GUARD_KEY) is True: runtime_thread_incarnation(runtime) user_role = parent_context.get("user_role") diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index ec2021430c..71facbd9ba 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -2330,6 +2330,51 @@ def test_merge_run_context_overrides_forwards_subagent_total_limit(): assert config["context"]["max_total_subagents"] == 8 +@pytest.mark.parametrize("model_name", [None, "", "other", 7]) +def test_model_selection_pair_is_rejected_before_admission(model_name): + from fastapi import HTTPException + + from app.gateway.services import merge_run_context_overrides + + with pytest.raises(HTTPException) as error: + merge_run_context_overrides({}, {"model_name": model_name, "model_selection_requested": "chosen"}) + assert error.value.status_code == 400 + assert "must match model_name" in error.value.detail + + +@pytest.mark.parametrize("internal", [False, True]) +@pytest.mark.parametrize("body_context", [None, {"subagent_model_name": "chosen"}]) +def test_model_selection_only_accepts_validated_top_level_context(internal, body_context): + from app.gateway.services import merge_run_context_overrides, strip_internal_context_keys + + config = {section: {"model_name": "default", "subagent_model_name": ["malformed"], "model_selection_requested": "x" * 201} for section in ("context", "configurable")} + merge_run_context_overrides(config, body_context, internal=internal) + if not internal: + strip_internal_context_keys(config) + assert config["configurable"] == {"model_name": "default"} + expected = {"model_name": "default", **(body_context or {})} + assert config["context"] == expected + + +def test_matching_unknown_model_selection_is_left_for_assembly(): + from app.gateway.services import merge_run_context_overrides + + config = {} + merge_run_context_overrides(config, {"model_name": "missing", "model_selection_requested": "missing"}) + assert config["context"] == {"model_name": "missing", "model_selection_requested": "missing"} + assert "model_selection_requested" not in config["configurable"] + + +def test_model_selection_rejects_conflicting_effective_context(): + from fastapi import HTTPException + + from app.gateway.services import merge_run_context_overrides + + with pytest.raises(HTTPException) as error: + merge_run_context_overrides({"context": {"model_name": "other"}}, {"model_name": "chosen", "model_selection_requested": "chosen"}) + assert error.value.status_code == 400 + + def test_null_subagent_total_limit_from_api_context_builds_with_configured_cap(): # The Gateway forwards an explicit ``null`` verbatim; the lead agent must # read it as "unset" rather than fail the run while building its stack. diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index 914bd374da..734a64a6b7 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -49,6 +49,68 @@ def _make_model(name: str, *, supports_thinking: bool) -> ModelConfig: ) +@pytest.fixture +def model_control_assembly(monkeypatch): + import deerflow.models.selection as selection + import deerflow.tools as tools + + app_config = _make_app_config([_make_model(name, supports_thinking=False) for name in ("default", "chosen")]) + strict = MagicMock() + fallback = MagicMock(side_effect=lambda name, **kwargs: name) + model_factory = MagicMock(return_value=object()) + monkeypatch.setattr(selection, "authorize_selected_model", strict) + monkeypatch.setattr(lead_agent_module, "_authorize_model_name", fallback) + monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda *args, **kwargs: None) + monkeypatch.setattr(lead_agent_module, "_load_enabled_available_skills", lambda *args, **kwargs: []) + monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda *args, **kwargs: []) + monkeypatch.setattr(lead_agent_module, "apply_prompt_template", lambda **kwargs: "system prompt") + monkeypatch.setattr(lead_agent_module, "build_tracing_callbacks", lambda: []) + monkeypatch.setattr(lead_agent_module, "create_chat_model", model_factory) + monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs) + monkeypatch.setattr(tools, "get_available_tools", lambda **kwargs: []) + return app_config, strict, fallback, model_factory + + +@pytest.mark.parametrize("requested,selected,reason", [("default", "chosen", "same model_name"), (None, "default", "same model_name"), ("missing", "missing", "not configured")]) +def test_assembly_rejects_invalid_explicit_model_selection(model_control_assembly, requested, selected, reason): + app_config, strict, fallback, model_factory = model_control_assembly + with pytest.raises(ValueError, match=reason): + lead_agent_module._make_lead_agent({"context": {"model_name": requested, "model_selection_requested": selected}}, app_config=app_config) + strict.assert_not_called() + fallback.assert_not_called() + model_factory.assert_not_called() + + +@pytest.mark.parametrize("denied", [False, True]) +def test_assembly_explicit_model_never_uses_default_fallback(model_control_assembly, denied): + app_config, strict, fallback, model_factory = model_control_assembly + if denied: + strict.side_effect = ValueError("Model use denied") + with pytest.raises(ValueError, match="Model use denied"): + lead_agent_module._make_lead_agent({"context": {"model_name": "chosen", "model_selection_requested": "chosen"}}, app_config=app_config) + model_factory.assert_not_called() + else: + lead_agent_module._make_lead_agent({"context": {"model_name": "chosen", "model_selection_requested": "chosen"}}, app_config=app_config) + assert model_factory.call_args.kwargs["name"] == "chosen" + assert strict.call_args.args == ("chosen",) + fallback.assert_not_called() + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_assembly_child_selection_requires_enabled_subagents(model_control_assembly, enabled): + app_config, strict, fallback, model_factory = model_control_assembly + config = {"context": {"subagent_enabled": enabled, "subagent_model_name": "chosen"}} + if enabled: + lead_agent_module._make_lead_agent(config, app_config=app_config) + assert strict.call_args.args == ("chosen",) + assert model_factory.call_args.kwargs["name"] == "default" + else: + with pytest.raises(ValueError, match="requires enabled subagents"): + lead_agent_module._make_lead_agent(config, app_config=app_config) + strict.assert_not_called() + assert fallback.call_args.args == ("default",) + + class ConfiguredGuardMiddleware(AgentMiddleware): pass diff --git a/backend/tests/test_model_selection.py b/backend/tests/test_model_selection.py new file mode 100644 index 0000000000..268102bfff --- /dev/null +++ b/backend/tests/test_model_selection.py @@ -0,0 +1,176 @@ +"""Direct user model controls resolve only against the supplied visible catalog.""" + +from types import SimpleNamespace + +import pytest + +from deerflow.models.selection import resolve_model_control + + +def model(name, label, aliases=()): + return SimpleNamespace(name=name, model=name, display_name=label, aliases=list(aliases)) + + +DS = model("ds-flash-1017", "DeepSeek v4.1 Flash 1017", ["ds v4.1 f", "ds flash"]) +OPUS = model("opus", "Claude Opus") + + +@pytest.mark.parametrize( + "text,scope", + [ + ("你找 ds v4.1 f 去处理一下:计算 17 * 23", "task"), + ("这次用 ds v4.1 f 帮我计算 17 * 23", "turn"), + ("以后这个对话都用 ds v4.1 f", "conversation"), + ("Please ask ds flash to calculate 17 * 23", "task"), + ("Use ds flash for this turn: calculate 17 * 23", "turn"), + ("From now on use ds flash", "conversation"), + ], +) +def test_three_scopes_and_abbreviations(text, scope): + result = resolve_model_control(text, [DS, OPUS]) + assert result["status"] == "resolved" + assert result["scope"] == scope + assert result["model_name"] == DS.name + + +@pytest.mark.parametrize( + "text,remaining", + [ + ("这次用 ds flash 帮我计算 17 * 23", "计算 17 * 23"), + ("这次用 ds flash 分析这张图", "分析这张图"), + ("Use ds flash for this turn: calculate 17 * 23", "calculate 17 * 23"), + ("From now on use ds flash, calculate 17 * 23", "calculate 17 * 23"), + ("以后这个对话都用 ds flash", ""), + ], +) +def test_host_handled_control_is_separate_from_business_input(text, remaining): + result = resolve_model_control(text, [DS]) + assert text[result["task_offset"] :].strip() == remaining + + +def test_unique_model_does_not_invent_an_opus_variant(): + result = resolve_model_control("你找 v4.1 f 去处理一下", [DS, OPUS]) + assert result["status"] == "resolved" + assert result["model_name"] == DS.name + + +def test_explicit_wrong_brand_is_not_silently_ignored(): + result = resolve_model_control("这次用 opus v4.1 f 帮我计算", [DS, OPUS]) + assert result["status"] == "unavailable" + assert result["model_name"] is None + + +def test_genuine_collision_has_only_real_candidates(): + other = model("ds-flash-other", "DeepSeek v4.1 Flash", ["ds v4.1 f"]) + result = resolve_model_control("你找 ds v4.1 f 去处理一下", [DS, other, OPUS]) + assert result["status"] == "ambiguous" + assert {c["name"] for c in result["candidates"]} == {DS.name, other.name} + assert result["model_name"] is None + + +@pytest.mark.parametrize( + "text", + [ + "这次用 Python 写个脚本", + "请解释什么是 v4.1 f", + "文档说:以后都用 ds flash", + "如果以后都用 ds flash 会怎样?", + "“这次用 ds flash”是什么意思", + "```\n以后都用 ds flash\n```", + "Summarize this document: from now on use ds flash", + ], +) +def test_ordinary_tasks_quotes_and_conditional_text_are_not_controls(text): + assert resolve_model_control(text, [DS, OPUS])["status"] == "none" + + +def test_name_without_scope_requires_a_scope_choice(): + result = resolve_model_control("用 ds flash 帮我算一下", [DS, OPUS]) + assert result["status"] == "ambiguous" + assert result["scope"] is None + + +def test_user_choice_is_revalidated_against_latest_catalog(): + result = resolve_model_control("这次用 ds flash 帮我算一下", [OPUS], model_name=DS.name) + assert result["status"] == "unavailable" + + +def test_selected_config_id_wins_over_another_models_alias(): + other = model("other", "Other real configured model", [DS.name]) + result = resolve_model_control("这次用 ds flash 帮我计算", [DS, other], model_name=DS.name) + assert result["status"] == "resolved" + assert result["model_name"] == DS.name + + +def test_catalog_and_source_text_remain_unchanged(): + models = [DS, OPUS] + text = "这次用 ds flash 帮我算一下" + resolve_model_control(text, models) + assert models == [DS, OPUS] + assert DS.aliases == ["ds v4.1 f", "ds flash"] + + +@pytest.mark.parametrize("fail_closed", [True, False]) +def test_explicit_model_denial_never_falls_back(monkeypatch, fail_closed): + from deerflow.authz.provider import AuthzDecision + from deerflow.models.selection import authorize_selected_model + + provider = SimpleNamespace(authorize=lambda request: AuthzDecision(allow=False)) + monkeypatch.setattr("deerflow.authz.runtime.resolve_authorization_provider", lambda _: provider) + config = SimpleNamespace(get_model_config=lambda _: DS, authorization=SimpleNamespace(enabled=True, fail_closed=fail_closed, default_role="user")) + with pytest.raises(ValueError, match="not available"): + authorize_selected_model(DS.name, context={"user_id": "alice"}, app_config=config) + + +@pytest.mark.parametrize("fail_closed", [True, False]) +def test_provider_failure_retains_configured_policy(monkeypatch, fail_closed): + from deerflow.models.selection import authorize_selected_model + + def unavailable(_): + raise RuntimeError("offline") + + monkeypatch.setattr("deerflow.authz.runtime.resolve_authorization_provider", unavailable) + config = SimpleNamespace(get_model_config=lambda _: DS, authorization=SimpleNamespace(enabled=True, fail_closed=fail_closed, default_role="user")) + if fail_closed: + with pytest.raises(ValueError, match="authorization is unavailable"): + authorize_selected_model(DS.name, context={}, app_config=config) + else: + authorize_selected_model(DS.name, context={}, app_config=config) + + +def test_selected_model_is_in_the_task_schema_without_mutating_the_shared_tool(): + from deerflow.models.selection import bind_selected_task_model + from deerflow.tools.builtins.task_tool import task_tool + + original = task_tool.tool_call_schema.model_json_schema() + selected = bind_selected_task_model([task_tool], "child-B")[0] + field = selected.tool_call_schema.model_json_schema()["properties"]["model_name"] + assert field["default"] == "child-B" + assert selected is not task_tool + assert task_tool.tool_call_schema.model_json_schema() == original + + +def test_gateway_keeps_model_controls_in_the_current_runtime_only(): + from app.gateway.services import merge_run_context_overrides + + config = {"configurable": {"subagent_model_name": "stale", "model_selection_requested": "stale"}} + merge_run_context_overrides(config, {"model_name": "parent-A", "subagent_model_name": "child-B", "model_selection_requested": "parent-A"}) + assert config["context"] == {"model_name": "parent-A", "subagent_model_name": "child-B", "model_selection_requested": "parent-A"} + assert "subagent_model_name" not in config["configurable"] + assert "model_selection_requested" not in config["configurable"] + + +def test_common_initials_and_version_prefix_work_without_configured_aliases(): + plain = model("deepseek-flash", "DeepSeek V4.1 Flash 1017") + result = resolve_model_control("Use ds v4.1 f for this turn: calculate 17 * 23", [plain]) + assert result["status"] == "resolved" + assert result["model_name"] == plain.name + + +def test_presentation_metadata_does_not_resend_handled_control_to_the_model(): + from langchain_core.messages import HumanMessage + from langchain_openai.chat_models.base import _convert_message_to_dict + + message = HumanMessage(content="calculate 17 * 23", additional_kwargs={"model_control": {"version": 1, "original_text": "Use B for this turn: calculate 17 * 23", "execution_text": "calculate 17 * 23"}}) + serialized = _convert_message_to_dict(message) + assert serialized == {"role": "user", "content": "calculate 17 * 23"} diff --git a/backend/tests/test_models_authorization.py b/backend/tests/test_models_authorization.py index 4cb59cc3a9..66daa17c79 100644 --- a/backend/tests/test_models_authorization.py +++ b/backend/tests/test_models_authorization.py @@ -102,6 +102,36 @@ class _RecordingProvider: # ── list_models tests ────────────────────────────────────────────────── +@pytest.mark.parametrize("names,denied,status", [(["family-a", "family-b"], {"family-b"}, "resolved"), (["family-a", "family-b"], {"family-a", "family-b"}, "unavailable"), (["family-a", "family-b", "family-c"], {"family-c"}, "ambiguous")]) +@pytest.mark.parametrize("fail_closed", [False, True]) +def test_model_control_candidates_require_use_permission(monkeypatch, names, denied, status, fail_closed): + class VisibleProvider(_RecordingProvider): + def filter_resources(self, principal, resource_type, candidates): + return list(candidates) + + provider = VisibleProvider(denied=denied) + _enable_authorization(monkeypatch, provider, fail_closed=fail_closed) + config = _make_app_config(names) + config.authorization = AuthorizationConfig(enabled=True, fail_closed=fail_closed) + monkeypatch.setattr(models_router, "get_optional_user_from_request", AsyncMock(return_value=_user())) + monkeypatch.setattr(models_router, "_resolve_bound_owner_for_model_list", AsyncMock(return_value=None)) + with TestClient(_make_models_app(config)) as client: + # Visibility remains distinct from use permission. + assert len(client.get("/api/models").json()["models"]) == len(names) + response = client.post("/api/models/resolve", json={"text": "Use family for this turn: calculate 2 + 2"}) + assert response.status_code == 200 + result = response.json() + assert {item["name"] for item in result["candidates"]} == set(names) - denied + assert result["status"] == status + if status == "resolved": + assert result["model_name"] == "family-a" + else: + assert result["model_name"] is None + assert {request.target for request in provider.authorize_requests if request.resource == "model" and request.action == "use"} == set(names) + # A stale/manual selection still fails authorization rather than falling back. + assert client.post("/api/models/resolve", json={"text": "Use family for this turn: calculate 2 + 2", "model_name": sorted(denied)[0]}).status_code == 403 + + def test_list_models_disabled_returns_all(monkeypatch): """When authorization is disabled, all models are visible.""" config = AuthorizationConfig(enabled=False) diff --git a/backend/tests/test_models_router_reasoning.py b/backend/tests/test_models_router_reasoning.py index dd16ae9111..5cb9291484 100644 --- a/backend/tests/test_models_router_reasoning.py +++ b/backend/tests/test_models_router_reasoning.py @@ -91,3 +91,32 @@ def test_get_model_projects_reasoning(client): body = response.json() assert body["reasoning"]["thinking"] == "required" assert body["reasoning"]["effort"]["values"] == ["low", "high", "max"] + + +def test_model_control_preview_does_not_change_config_or_create_a_run(client): + response = client.post("/api/models/resolve", json={"text": "这次用 legacy 帮我算一下"}) + assert response.status_code == 200 + assert response.json()["model_name"] == "legacy" + assert response.json()["scope"] == "turn" + assert client.get("/api/models").json()["models"][0]["name"] == "legacy" + + +def test_model_control_unknown_version_is_not_replaced(client): + response = client.post("/api/models/resolve", json={"text": "这次用 opus v4 pro 帮我算一下"}) + assert response.status_code == 200 + assert response.json()["status"] == "unavailable" + assert response.json()["model_name"] is None + + +def test_model_control_enforces_use_after_visibility(client, monkeypatch): + from fastapi import HTTPException + + monkeypatch.setattr(models_router, "get_optional_user_from_request", AsyncMock(return_value=object())) + monkeypatch.setattr(models_router, "_resolve_bound_owner_for_model_list", AsyncMock(return_value=None)) + monkeypatch.setattr(models_router, "resolve_model_authorization", lambda *args, **kwargs: (None, None)) + + def deny(*args, **kwargs): + raise HTTPException(status_code=403, detail="Model denied") + + monkeypatch.setattr(models_router, "authorize_model_use", deny) + assert client.post("/api/models/resolve", json={"text": "这次用 legacy 帮我算一下"}).status_code == 403 diff --git a/backend/tests/test_node_test_acceptance.py b/backend/tests/test_node_test_acceptance.py index 8abce8daa7..6e2989a735 100644 --- a/backend/tests/test_node_test_acceptance.py +++ b/backend/tests/test_node_test_acceptance.py @@ -215,7 +215,10 @@ def test_harvest_drops_a_partial_label_before_interpreting_counts(executor_modul ], ) async def test_real_node_results_reach_parent_acceptance(tmp_path, monkeypatch, executor_module, reporter, body, expected): - (tmp_path / "sample.test.cjs").write_text("const { test, describe } = require('node:test');\n" + body, encoding="utf-8") + # Keep reporter counts inside the bounded evidence tail regardless of + # temporary-path length; truncation itself has dedicated coverage above. + (tmp_path / "sample.test.cjs").write_text("const { test, describe } = require('node:test');\nError.stackTraceLimit = 0;\n" + body, encoding="utf-8") + monkeypatch.delenv("NO_COLOR", raising=False) sandbox = LocalSandbox("node-evidence", path_mappings=[PathMapping("/mnt/user-data/workspace", str(tmp_path))]) monkeypatch.setattr("deerflow.sandbox.sandbox_provider.get_sandbox_provider", lambda: SimpleNamespace(get=lambda sandbox_id: sandbox if sandbox_id == sandbox.id else None)) outputs = [] diff --git a/backend/tests/test_task_tool_core_logic.py b/backend/tests/test_task_tool_core_logic.py index 4e6118e85b..061529c320 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -3595,3 +3595,76 @@ def test_task_tool_forwards_execution_only_knowledge_scope(monkeypatch): "mode": "selected", "dataset_ids": ["dataset-1"], } + + +@pytest.mark.parametrize("status", [FakeSubagentStatus.COMPLETED, FakeSubagentStatus.FAILED, FakeSubagentStatus.CANCELLED, FakeSubagentStatus.TIMED_OUT]) +def test_one_task_model_is_local_and_used_before_tool_assembly(monkeypatch, status): + parent = _make_subagent_config() + app_config = SimpleNamespace(get_model_config=lambda name: object() if name == "child-B" else None) + runtime = _make_runtime(app_config=app_config) + seen = {} + + class Executor: + def __init__(self, **kwargs): + seen.update(kwargs) + + def execute_async(self, prompt, task_id=None): + return "execution" + + monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) + monkeypatch.setattr(task_tool_module, "SubagentExecutor", Executor) + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *args, **kwargs: parent) + monkeypatch.setattr(task_tool_module, "get_available_subagent_names", lambda **kwargs: ["general-purpose"]) + monkeypatch.setattr(task_tool_module, "get_background_task_result", lambda _: _make_result(status, result="done", error="stopped")) + monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _: None) + assemble = MagicMock(return_value=[]) + monkeypatch.setattr("deerflow.tools.get_available_tools", assemble) + _run_task_tool(runtime=runtime, prompt="calculate", subagent_type="general-purpose", tool_call_id="choice", model_name="child-B") + assert seen["config"].model == "child-B" + assert seen["config"] is not parent + assert assemble.call_args.kwargs["model_name"] == "child-B" + assert parent.model == "inherit" + assert runtime.config["metadata"]["model_name"] == "ark-model" + assert runtime.context.get("model_name") is None + + +def test_unknown_task_model_rejects_before_assembly(monkeypatch): + config = SimpleNamespace(get_model_config=lambda _: None) + runtime = _make_runtime(app_config=config) + monkeypatch.setattr(task_tool_module, "get_available_subagent_names", lambda **kwargs: ["general-purpose"]) + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *args, **kwargs: _make_subagent_config()) + assemble = MagicMock() + monkeypatch.setattr("deerflow.tools.get_available_tools", assemble) + message = _task_tool_message(_run_task_tool(runtime=runtime, prompt="calculate", subagent_type="general-purpose", tool_call_id="choice", model_name="unknown")) + assert message.additional_kwargs[SUBAGENT_STATUS_KEY] == "failed" + assert "not configured" in message.content + assemble.assert_not_called() + + +def test_parallel_task_choices_do_not_mutate_parent_or_shared_config(monkeypatch): + shared = _make_subagent_config() + runtime = _make_runtime(app_config=SimpleNamespace(get_model_config=lambda _: object())) + seen = [] + + class Executor: + def __init__(self, **kwargs): + seen.append(kwargs) + + def execute_async(self, prompt, task_id=None): + return task_id + + monkeypatch.setattr(task_tool_module, "SubagentStatus", FakeSubagentStatus) + monkeypatch.setattr(task_tool_module, "SubagentExecutor", Executor) + monkeypatch.setattr(task_tool_module, "get_available_subagent_names", lambda **kwargs: ["general-purpose"]) + monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda *args, **kwargs: shared) + monkeypatch.setattr(task_tool_module, "get_background_task_result", lambda _: _make_result(FakeSubagentStatus.COMPLETED, result="done")) + monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _: None) + monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: []) + + async def run_both(): + return await asyncio.gather(*(task_tool_module.task_tool.coroutine(runtime=runtime, prompt="calculate", subagent_type="general-purpose", tool_call_id=name, model_name=name) for name in ["B", "C"])) + + asyncio.run(run_both()) + assert {entry["config"].model for entry in seen} == {"B", "C"} + assert shared.model == "inherit" + assert runtime.config["metadata"]["model_name"] == "ark-model" diff --git a/backend/tests/test_tool_args_schema_no_pydantic_warning.py b/backend/tests/test_tool_args_schema_no_pydantic_warning.py index da2bd3c358..b5ee1bdc3a 100644 --- a/backend/tests/test_tool_args_schema_no_pydantic_warning.py +++ b/backend/tests/test_tool_args_schema_no_pydantic_warning.py @@ -239,11 +239,12 @@ def test_task_tool_description_is_optional_but_discoverable() -> None: parameters = convert_to_openai_tool(task_tool)["function"]["parameters"] assert parameters["required"] == ["prompt", "subagent_type"] - assert list(parameters["properties"]) == ["prompt", "subagent_type", "acceptance_criteria", "description", "context_mode"] + assert list(parameters["properties"]) == ["prompt", "subagent_type", "acceptance_criteria", "description", "context_mode", "model_name"] assert parameters["properties"]["description"]["description"] validated = task_tool.tool_call_schema.model_validate({"prompt": "go", "subagent_type": "general-purpose"}) assert validated.description == "" + assert validated.model_name is None def test_list_uploaded_files_model_schema_excludes_injected_runtime() -> None: diff --git a/config.example.yaml b/config.example.yaml index 1039ef8204..ee20a4273f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -157,6 +157,8 @@ models: # Example: Volcengine (Doubao) model # - name: doubao-seed-1.8 # display_name: Doubao-Seed-1.8 + # aliases: [doubao, seed 1.8] # Optional names for selecting this model in chat + # # Model-name aliases differ from reasoning.effort.aliases (effort values). # use: deerflow.models.patched_deepseek:PatchedChatDeepSeek # model: doubao-seed-1-8-251228 # api_base: https://ark.cn-beijing.volces.com/api/v3 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index bf1bc2d8ab..42060d835d 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -83,6 +83,8 @@ Keep IM notification preferences viewer-fenced and separate from browser alerts; ## Architecture +Model controls: `core/models/use-model-control` + HumanInputCard. + Knowledge evidence is parsed in `core/citations/sources.ts` from version-1 ToolMessage artifacts. Message rows receive sources from preceding loaded tool messages. `#knowledge-source-ID` links resolve to `KnowledgeCitation` and an @@ -232,13 +234,7 @@ round-trip validation must still reject newly entered skipped times. ## Contributing -When adding features: - -1. Follow the established `src/` structure -2. Add TypeScript types and proper error handling -3. Write unit tests under `tests/unit/` (`pnpm test`) and E2E tests under `tests/e2e/` (`pnpm test:e2e`) -4. Run `pnpm check` before committing -5. Update this `AGENTS.md` when architecture, commands, or conventions change +Features follow `src/` ownership, include TypeScript types/error handling, unit tests (`tests/unit/`, `pnpm test`) and E2E (`tests/e2e/`, `pnpm test:e2e`). Run `pnpm check` before committing; update AGENTS.md when architecture, commands or conventions change. Route asset budgets are enforced with `pnpm perf:check`. The command measures `/login` from a normal production build, then builds in static-demo mode for the diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index b6aecf17ba..21f2b54ba4 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -3,6 +3,7 @@ import { BotIcon, PlusSquare } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; @@ -28,6 +29,7 @@ import { MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; +import { HumanInputCard } from "@/components/workspace/messages/human-input-card"; import { SidecarProvider, SidecarTrigger, @@ -62,11 +64,14 @@ import { type HumanInputResponse, } from "@/core/messages/human-input"; import { isHiddenFromUIMessage } from "@/core/messages/utils"; +import { modelTaskInput } from "@/core/models/control"; import { useModels } from "@/core/models/hooks"; +import { useModelControl } from "@/core/models/use-model-control"; import { useNotification } from "@/core/notification/hooks"; import { useThreadScheduledTaskEvents } from "@/core/scheduled-tasks/events"; import { useLocalSettings, useThreadSettings } from "@/core/settings"; -import { resolveThreadContext } from "@/core/settings/store"; +import { resolveThreadContext, selectThreadModel } from "@/core/settings/store"; +import { createThread } from "@/core/threads/api"; import { useThreadMetadata, useThreadStream, @@ -81,7 +86,7 @@ import { env } from "@/env"; import { cn } from "@/lib/utils"; export default function AgentChatPage() { - const { t } = useI18n(); + const { t, locale } = useI18n(); const { user } = useAuth(); const canStopStreaming = hasPermission(user, PERMISSIONS.RUNS_CANCEL); const canCreateRuns = hasPermission(user, PERMISSIONS.RUNS_CREATE); @@ -100,6 +105,11 @@ export default function AgentChatPage() { // it can flip immediately on submit without triggering eager history loads. const [isWelcomeMode, setIsWelcomeMode] = useState(isNewThread); const [settings, setSettings] = useThreadSettings(threadId); + const modelControl = useModelControl( + `${user?.id ?? "anonymous"}:${agent_name}:${threadId}`, + locale, + ); + const prepareModelControl = modelControl.prepare; const [localSettings, setLocalSettings] = useLocalSettings(); const { enabled: browserControlEnabled } = useBrowserControlEnabled(); const { scopeSelectionEnabled } = useKnowledgeBaseEnabled(); @@ -255,8 +265,59 @@ export default function AgentChatPage() { threadMetadata.isLoading, ]); + const submissionEpochRef = useRef(0); + useEffect( + () => () => { + submissionEpochRef.current += 1; + }, + [agent_name, threadId, user?.id], + ); + const handleSubmit = useCallback( - (message: PromptInputMessage, options?: InputBoxSubmitOptions) => { + async (message: PromptInputMessage, options?: InputBoxSubmitOptions) => { + const submissionEpoch = submissionEpochRef.current; + const choice = await prepareModelControl(message.text); + if (submissionEpochRef.current !== submissionEpoch) + throw new Error("thread-submission-stale"); + const modelInput = modelTaskInput(message.text, choice); + const runContext: Record = { agent_name }; + if ( + choice?.scope === "conversation" && + !modelInput.text && + message.files.length === 0 && + isNewThread + ) { + await createThread(threadId); + if (submissionEpochRef.current !== submissionEpoch) + throw new Error("thread-submission-stale"); + } + if (choice?.model_name) { + if (choice.scope === "task") { + if (settings.context.mode !== "ultra") { + toast.error( + locale.startsWith("zh") + ? "当前未启用子任务,请先启用 Ultra 模式。" + : "Subtasks are disabled. Enable Ultra mode first.", + ); + throw new Error("subtasks-disabled"); + } + runContext.subagent_model_name = choice.model_name; + } else { + runContext.model_name = choice.model_name; + runContext.model_selection_requested = choice.model_name; + } + if (choice.scope === "conversation") + try { + selectThreadModel(threadId, choice.model_name); + } catch (error) { + toast.error( + locale.startsWith("zh") + ? "无法在浏览器保存本对话的默认模型,请检查存储权限。" + : "Unable to save this conversation's default model. Check browser storage permissions.", + ); + throw error; + } + } const scopedOptions = currentKnowledgeScopeSnapshot ? { ...options, @@ -266,18 +327,70 @@ export default function AgentChatPage() { }, } : options; + if ( + choice?.scope === "turn" && + !modelInput.text && + message.files.length === 0 + ) { + toast.info( + locale.startsWith("zh") + ? "请补充这次需要处理的任务。" + : "Please include the task to run with this model.", + ); + throw new Error("model-task-missing"); + } + if ( + choice?.scope === "conversation" && + !modelInput.text && + message.files.length === 0 + ) { + if (isNewThread) { + history.replaceState( + null, + "", + `/workspace/agents/${agent_name}/chats/${threadId}`, + ); + setThreadId(threadId); + setIsNewThread(false); + } + options?.onSent?.(); + toast.success( + locale.startsWith("zh") + ? "已设置本对话的默认模型。" + : "Conversation default model saved.", + ); + return; + } + const executionOptions = { + ...scopedOptions, + additionalKwargs: { + ...scopedOptions?.additionalKwargs, + ...modelInput.additionalKwargs, + }, + }; const sendPromise = sendMessage( threadId, - message, - { agent_name }, - scopedOptions, + { ...message, text: modelInput.text }, + runContext, + executionOptions, ); if (message.files.length > 0) { return sendPromise; } void sendPromise; }, - [currentKnowledgeScopeSnapshot, sendMessage, threadId, agent_name], + [ + currentKnowledgeScopeSnapshot, + sendMessage, + threadId, + agent_name, + prepareModelControl, + settings.context.mode, + locale, + isNewThread, + setThreadId, + setIsNewThread, + ], ); const handleSubmitHumanInput = useCallback( @@ -534,6 +647,14 @@ export default function AgentChatPage() { )} + {modelControl.request && ( +
+ modelControl.choose(response)} + /> +
+ )} setModelChoiceSummary(""), [threadId, user?.id]); const [localSettings, setLocalSettings] = useLocalSettings(); const { enabled: browserControlEnabled } = useBrowserControlEnabled(); const { tokenUsageEnabled } = useModels(); @@ -321,6 +331,28 @@ export default function ChatPage() { const handleSubmit = useCallback( async (message: PromptInputMessage, options?: InputBoxSubmitOptions) => { const submissionEpoch = submissionEpochRef.current; + setModelChoiceSummary(""); + const choice = await prepareModelControl(message.text); + const modelInput = modelTaskInput(message.text, choice); + if (submissionEpochRef.current !== submissionEpoch) + throw new Error("thread-submission-stale"); + const runModelContext: Record = {}; + if (choice?.model_name) { + if (choice.scope === "task") { + if (settings.context.mode !== "ultra") { + toast.error( + locale.startsWith("zh") + ? "当前未启用子任务,请先启用 Ultra 模式。" + : "Subtasks are disabled. Enable Ultra mode first.", + ); + throw new Error("subtasks-disabled"); + } + runModelContext.subagent_model_name = choice.model_name; + } else { + runModelContext.model_name = choice.model_name; + runModelContext.model_selection_requested = choice.model_name; + } + } await ensureProjectThread(); // Conversation switched (or the page unmounted) while the project // pre-create was pending: drop the submission. Reject silently — the @@ -329,6 +361,45 @@ export default function ChatPage() { if (submissionEpochRef.current !== submissionEpoch) { throw new Error("thread-submission-stale"); } + if ( + choice?.scope === "conversation" && + !modelInput.text && + message.files.length === 0 && + isNewThread && + !projectParam + ) { + await createThread(threadId); + if (submissionEpochRef.current !== submissionEpoch) + throw new Error("thread-submission-stale"); + } + if (choice?.model_name) { + if (choice.scope === "conversation") + try { + selectThreadModel(threadId, choice.model_name); + } catch (error) { + toast.error( + locale.startsWith("zh") + ? "无法在浏览器保存本对话的默认模型,请检查存储权限。" + : "Unable to save this conversation's default model. Check browser storage permissions.", + ); + throw error; + } + const label = + choice.candidates.find((item) => item.name === choice.model_name) + ?.label ?? choice.model_name; + const scopeLabel = locale.startsWith("zh") + ? ({ + task: "本次子任务", + turn: "仅本次主任务", + conversation: "本对话默认模型", + } as const) + : ({ + task: "This subtask", + turn: "This turn only", + conversation: "Conversation default", + } as const); + setModelChoiceSummary(`${scopeLabel[choice.scope!]} · ${label}`); + } else setModelChoiceSummary(""); const scopedOptions = currentKnowledgeScopeSnapshot ? { ...options, @@ -338,18 +409,67 @@ export default function ChatPage() { }, } : options; + if ( + choice?.scope === "turn" && + !modelInput.text && + message.files.length === 0 + ) { + toast.info( + locale.startsWith("zh") + ? "请补充这次需要处理的任务。" + : "Please include the task to run with this model.", + ); + throw new Error("model-task-missing"); + } + if ( + choice?.scope === "conversation" && + !modelInput.text && + message.files.length === 0 + ) { + if (isNewThread) { + history.replaceState(null, "", `/workspace/chats/${threadId}`); + setThreadId(threadId); + setIsNewThread(false); + } + options?.onSent?.(); + toast.success( + locale.startsWith("zh") + ? "已设置本对话的默认模型。" + : "Conversation default model saved.", + ); + return; + } + const executionOptions = { + ...scopedOptions, + additionalKwargs: { + ...scopedOptions?.additionalKwargs, + ...modelInput.additionalKwargs, + }, + }; const sendPromise = sendMessage( threadId, - message, - undefined, - scopedOptions, + { ...message, text: modelInput.text }, + runModelContext, + executionOptions, ); if (message.files.length > 0) { return sendPromise; } void sendPromise; }, - [currentKnowledgeScopeSnapshot, sendMessage, threadId, ensureProjectThread], + [ + currentKnowledgeScopeSnapshot, + sendMessage, + threadId, + ensureProjectThread, + prepareModelControl, + settings.context.mode, + locale, + isNewThread, + projectParam, + setThreadId, + setIsNewThread, + ], ); const handleSubmitHumanInput = useCallback( async (request: HumanInputRequest, response: HumanInputResponse) => { @@ -624,6 +744,22 @@ export default function ChatPage() { )} + {modelControl.request && ( +
+ modelControl.choose(response)} + /> +
+ )} + {modelChoiceSummary && ( +

+ {modelChoiceSummary} +

+ )} {mountedRef.current ? ( { diff --git a/frontend/src/core/messages/model-control.ts b/frontend/src/core/messages/model-control.ts new file mode 100644 index 0000000000..40284ced5b --- /dev/null +++ b/frontend/src/core/messages/model-control.ts @@ -0,0 +1,33 @@ +import type { Message } from "@langchain/langgraph-sdk"; + +/** Presentation only. This metadata never authorizes a model or an action. */ +export function modelControlDisplayText(message: Message): string | null { + if (message.type !== "human") return null; + if (typeof message.content !== "string" && !Array.isArray(message.content)) + return null; + const value: unknown = message.additional_kwargs?.model_control; + if (!value || typeof value !== "object") return null; + const data = value as Record; + if ( + data.version !== 1 || + typeof data.original_text !== "string" || + typeof data.execution_text !== "string" + ) + return null; + const content = + typeof message.content === "string" + ? message.content + : message.content + .map((part) => + typeof part === "string" + ? part + : part.type === "text" + ? part.text + : "", + ) + .join(""); + // Edited/replayed content must never display an earlier control statement. + return content.trim() === data.execution_text.trim() + ? data.original_text + : null; +} diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index 0d7d452ebe..457a31f82d 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -12,6 +12,8 @@ import { isClosingFence, } from "@/core/streamdown/fences"; +import { modelControlDisplayText } from "./model-control"; + interface GenericMessageGroup { type: T; id: string | undefined; @@ -683,6 +685,8 @@ export function getMessageCopyData(message: Message) { } export function extractTextFromMessage(message: Message) { + const selectedModelText = modelControlDisplayText(message); + if (selectedModelText !== null) return selectedModelText; if (typeof message.content === "string") { return ( splitInlineReasoningFromAIMessage(message)?.content ?? @@ -941,6 +945,8 @@ function splitInlineReasoningFromAIMessage(message: Message) { } export function extractContentFromMessage(message: Message) { + const selectedModelText = modelControlDisplayText(message); + if (selectedModelText !== null) return selectedModelText; if (typeof message.content === "string") { return ( splitInlineReasoningFromAIMessage(message)?.content ?? diff --git a/frontend/src/core/models/control.ts b/frontend/src/core/models/control.ts new file mode 100644 index 0000000000..ea09e65da8 --- /dev/null +++ b/frontend/src/core/models/control.ts @@ -0,0 +1,60 @@ +import { throwGatewayApiError } from "@/core/api/errors"; +import { fetch } from "@/core/api/fetcher"; +import { getBackendBaseURL } from "@/core/config"; + +export type ModelControlScope = "task" | "turn" | "conversation"; +export type ModelControlDecision = { + status: "none" | "resolved" | "ambiguous" | "unavailable"; + scope: ModelControlScope | null; + query: string; + model_name: string | null; + candidates: { name: string; label: string }[]; + task_offset?: number; +}; + +export function modelTaskInput( + text: string, + choice: ModelControlDecision | null, +) { + if ( + !choice || + choice.scope === "task" || + !Number.isInteger(choice.task_offset) + ) + return { text, additionalKwargs: {} }; + const body = Array.from(text).slice(choice.task_offset).join("").trim(); + return { + text: body, + additionalKwargs: { + model_control: { version: 1, original_text: text, execution_text: body }, + }, + }; +} + +export function maySelectModel(text: string) { + return /^(?:请|麻烦)?(?:你?(?:找|让|叫)|这次|本次|这一轮|以后|今后|接下来|用|使用|改用|换成|这个|这部分|这件事)|^(?:please\s+)?(?:ask\s|delegate to\s|use\s|from now on\s)/i.test( + text.trim(), + ); +} + +export async function resolveModelControl( + text: string, + selection: { model_name?: string; scope?: ModelControlScope } = {}, + signal?: AbortSignal, +): Promise { + const response = await fetch(`${getBackendBaseURL()}/api/models/resolve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: Array.from(text).slice(0, 4000).join(""), + ...selection, + }), + signal, + }); + if (!response.ok) + await throwGatewayApiError( + response, + "Unable to check the model selection.", + ); + return response.json() as Promise; +} diff --git a/frontend/src/core/models/use-model-control.ts b/frontend/src/core/models/use-model-control.ts new file mode 100644 index 0000000000..0d95df715d --- /dev/null +++ b/frontend/src/core/models/use-model-control.ts @@ -0,0 +1,172 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +import type { + HumanInputRequest, + HumanInputResponse, +} from "@/core/messages/human-input"; +import { uuid } from "@/core/utils/uuid"; + +import { + maySelectModel, + resolveModelControl, + type ModelControlDecision, + type ModelControlScope, +} from "./control"; + +type Answer = (response: HumanInputResponse | null) => void; + +/** A pending choice owns no agent run; navigation/account changes cancel it. */ +export function useModelControl(owner: string, locale: string) { + const [request, setRequest] = useState(null); + const [busy, setBusy] = useState(false); + const answer = useRef(null); + const generation = useRef(0); + const abort = useRef(null); + const zh = locale.startsWith("zh"); + + useEffect( + () => () => { + generation.current += 1; + abort.current?.abort(); + abort.current = null; + answer.current?.(null); + answer.current = null; + setRequest(null); + setBusy(false); + }, + [owner], + ); + + const choose = useCallback((response: HumanInputResponse | null) => { + answer.current?.(response); + answer.current = null; + setRequest(null); + }, []); + + const prepare = useCallback( + async (text: string): Promise => { + if (!maySelectModel(text)) return null; + if (abort.current) + throw new Error("A model selection is already pending."); + const epoch = generation.current; + const controller = new AbortController(); + abort.current = controller; + setBusy(true); + const selection: { model_name?: string; scope?: ModelControlScope } = {}; + try { + for (let attempt = 0; attempt < 4; attempt++) { + const result = await resolveModelControl( + text, + selection, + controller.signal, + ); + if (epoch !== generation.current) + throw new Error("model-selection-cancelled"); + if (result.status === "none") return null; + if (result.status === "resolved") return result; + const needsScope = result.scope === null; + const choices = needsScope + ? [ + { + name: "task", + label: zh ? "只交给子任务" : "Delegate this task", + }, + { + name: "turn", + label: zh ? "主任务仅这次" : "Main task, this turn", + }, + { + name: "conversation", + label: zh ? "以后这个对话都用" : "Conversation default", + }, + ] + : result.candidates; + const response = await new Promise( + (resolve) => { + answer.current = resolve; + setRequest({ + version: 1, + kind: "human_input_request", + source: "model_selection", + request_id: uuid(), + title: zh ? "选择这次使用的模型" : "Choose a model", + question: needsScope + ? zh + ? "这次选择要用于哪里?" + : "Where should this choice apply?" + : result.status === "unavailable" + ? zh + ? `没有可用模型与“${result.query}”对应。请输入已配置的模型名称,或取消。` + : `No available model matches “${result.query}”. Enter a configured name, or cancel.` + : zh + ? `“${result.query}”对应多个配置,你指的是哪一个?` + : `Which configured model do you mean by “${result.query}”?`, + context: needsScope + ? undefined + : zh + ? "只在你选定后执行;不会自动更改其他会话。" + : "Execution waits for your choice. Other conversations are unchanged.", + input_mode: needsScope ? "single_choice" : "choice_with_other", + options: [ + ...choices.map((item) => ({ + id: item.name, + value: item.name, + label: item.label, + })), + { + id: "cancel", + value: "__cancel__", + label: zh ? "取消这次操作" : "Cancel this operation", + }, + ], + }); + }, + ); + if ( + !response || + response.value === "__cancel__" || + epoch !== generation.current + ) + throw new Error("model-selection-cancelled"); + if (needsScope) selection.scope = response.value as ModelControlScope; + else selection.model_name = response.value; + } + throw new Error( + zh + ? "还无法确定模型,请换一个说法。" + : "The model is still unclear. Please rephrase.", + ); + } catch (error) { + if ( + epoch === generation.current && + !controller.signal.aborted && + !( + error instanceof Error && + error.message === "model-selection-cancelled" + ) + ) { + toast.error( + error instanceof Error + ? error.message + : zh + ? "无法确认模型选择,请稍后再试。" + : "Unable to check the model selection. Please retry.", + ); + } + throw error; + } finally { + if (epoch === generation.current) { + abort.current = null; + setBusy(false); + setRequest(null); + } + } + }, + [zh], + ); + + return { request, busy, prepare, choose }; +} diff --git a/frontend/src/core/settings/store.ts b/frontend/src/core/settings/store.ts index faa4931b57..73fc29cef3 100644 --- a/frontend/src/core/settings/store.ts +++ b/frontend/src/core/settings/store.ts @@ -4,6 +4,7 @@ import { THREAD_MODEL_KEY_PREFIX, getLocalSettings, getThreadModelName, + safeLocalStorage, saveLocalSettings, saveThreadModelName, type LocalSettings, @@ -211,6 +212,22 @@ export const updateLocalSettings: LocalSettingsSetter = (key, value) => { emitChange(); }; +/** Explicit conversation-only selection; never edits account/base preferences. */ +export function selectThreadModel(threadId: string, modelName: string) { + ensureBaseSettingsLoaded(); + ensureStorageListenerRegistered(); + if ( + !safeLocalStorage.setItem( + `${THREAD_MODEL_KEY_PREFIX}${threadId}`, + modelName, + ) + ) { + throw new Error("Unable to save the conversation model in this browser."); + } + threadModelNames.set(threadId, modelName); + emitChange(); +} + export function updateThreadSettings( threadId: string, key: K, diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 1ea79991f9..ff53270761 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -296,6 +296,9 @@ export function buildRunContext({ return { ...extraContext, ...ownedSettings, + ...(typeof extraContext?.model_name === "string" + ? { model_name: extraContext.model_name } + : {}), ...(conversationReferences?.length ? { conversation_references: [...conversationReferences] } : {}), diff --git a/frontend/src/core/threads/utils.ts b/frontend/src/core/threads/utils.ts index 595f2f28a4..e30d9fde71 100644 --- a/frontend/src/core/threads/utils.ts +++ b/frontend/src/core/threads/utils.ts @@ -1,6 +1,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import type { Translations } from "@/core/i18n/locales/types"; +import { modelControlDisplayText } from "@/core/messages/model-control"; import type { AgentThread, AgentThreadContext } from "./types"; @@ -67,6 +68,8 @@ export function pathOfThread( } export function textOfMessage(message: Message) { + const selectedModelText = modelControlDisplayText(message); + if (selectedModelText !== null) return selectedModelText; if (typeof message.content === "string") { return message.content; } else if (Array.isArray(message.content)) { diff --git a/frontend/tests/e2e/model-control-navigation.spec.ts b/frontend/tests/e2e/model-control-navigation.spec.ts new file mode 100644 index 0000000000..cee6f7d0bc --- /dev/null +++ b/frontend/tests/e2e/model-control-navigation.spec.ts @@ -0,0 +1,145 @@ +import { expect, test } from "@playwright/test"; + +import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api"; + +test.beforeEach(async ({ page }) => { + mockLangGraphAPI(page, { + agents: [{ name: "reviewer", description: "Review agent" }], + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Settled agent chat", + agent_name: "reviewer", + }, + ], + }); + await page.route("**/api/models", (route) => + route.fulfill({ + json: { + models: [ + { name: "model-a", display_name: "Model A" }, + { name: "model-b", display_name: "Model B" }, + ], + }, + }), + ); + await page.route("**/api/models/resolve", (route) => + route.fulfill({ + json: { + status: "resolved", + scope: "conversation", + query: "Model B", + model_name: "model-b", + candidates: [{ name: "model-b", label: "Model B" }], + task_offset: route.request().postDataJSON().text.length, + }, + }), + ); +}); + +for (const destination of ["agent-thread", "normal-chat"] as const) { + test(`a pending Custom Agent model change cannot affect ${destination}`, async ({ + page, + }) => { + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let createdId!: string; + const intercepted = page.waitForRequest( + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === "/api/threads", + ); + const runRequests: string[] = []; + page.on("request", (request) => { + if (request.method() === "POST" && request.url().includes("/runs/stream")) + runRequests.push(request.url()); + }); + await page.route("**/api/threads", async (route) => { + if (route.request().method() === "POST") { + createdId = route.request().postDataJSON().thread_id; + await held; + } + return route.fallback(); + }); + await page.goto("/workspace/agents/reviewer/chats/new"); + const input = page.getByRole("textbox", { name: /how can i assist you/i }); + await input.fill("From now on use Model B"); + await input.press("Enter"); + const createRequest = await intercepted; + const previousModel = await page.evaluate( + // No override and an explicit model-a both select the configured default. + (id) => localStorage.getItem(`deerflow.thread-model.${id}`) ?? "model-a", + createdId, + ); + try { + await page + .getByRole("link", { + name: + destination === "agent-thread" ? "Settled agent chat" : "New chat", + exact: true, + }) + .click(); + const target = + destination === "agent-thread" + ? `/workspace/agents/reviewer/chats/${MOCK_THREAD_ID}` + : "/workspace/chats/new"; + await expect(page).toHaveURL(new RegExp(`${target}$`)); + await input.fill("Keep this draft"); + release(); + const response = await createRequest.response(); + expect(response).not.toBeNull(); + expect(response!.ok()).toBe(true); + await response!.finished(); + // The response body and its submit continuation settle before the next paint. + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + await expect(page).toHaveURL(new RegExp(`${target}$`)); + await expect(input).toHaveText("Keep this draft"); + expect( + await page.evaluate( + (id) => + localStorage.getItem(`deerflow.thread-model.${id}`) ?? "model-a", + createdId, + ), + ).toBe(previousModel); + await expect( + page.getByText("Conversation default model saved.", { exact: true }), + ).toBeHidden(); + expect(runRequests).toEqual([]); + } finally { + release(); + } + }); +} + +test("a completed Custom Agent model change still saves without a run", async ({ + page, +}) => { + const runs: string[] = []; + page.on("request", (request) => { + if (request.method() === "POST" && request.url().includes("/runs/stream")) + runs.push(request.url()); + }); + await page.goto("/workspace/agents/reviewer/chats/new"); + const input = page.getByRole("textbox", { name: /how can i assist you/i }); + await input.fill("From now on use Model B"); + await input.press("Enter"); + await expect( + page.getByText("Conversation default model saved.", { exact: true }), + ).toBeVisible(); + const threadId = new URL(page.url()).pathname.split("/").at(-1)!; + expect(threadId).not.toBe("new"); + expect( + await page.evaluate( + (id) => localStorage.getItem(`deerflow.thread-model.${id}`), + threadId, + ), + ).toBe("model-b"); + expect(runs).toEqual([]); +}); diff --git a/frontend/tests/e2e/scheduled-task-chat-events.spec.ts b/frontend/tests/e2e/scheduled-task-chat-events.spec.ts index 3839b9b739..60d630051e 100644 --- a/frontend/tests/e2e/scheduled-task-chat-events.spec.ts +++ b/frontend/tests/e2e/scheduled-task-chat-events.spec.ts @@ -211,6 +211,7 @@ for (const lang of LOCALES) { .last() .evaluate( (card, other) => + other !== null && Boolean( card.compareDocumentPosition(other) & Node.DOCUMENT_POSITION_FOLLOWING, diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index ce73565afd..52515f8897 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -2344,6 +2344,19 @@ export function mockLangGraphAPI( ); // Models list — model picker dropdown + // Agent settings also read these catalogs. Keep ordinary UI specs independent + // of any real Gateway listening locally; catalog-specific specs override them. + void page.route("**/api/subagents", (route) => + route.request().method() === "GET" + ? route.fulfill({ json: { subagents: [] } }) + : route.fallback(), + ); + void page.route("**/api/agent-tool-groups", (route) => + route.request().method() === "GET" + ? route.fulfill({ json: { tool_groups: [] } }) + : route.fallback(), + ); + void page.route("**/api/models", (route) => { if (route.request().method() === "GET") { return route.fulfill({ @@ -2358,6 +2371,24 @@ export function mockLangGraphAPI( return route.fallback(); }); + // General UI specs do not choose a model. Model-control specs can override + // this preview just as they override the empty model list above. + void page.route("**/api/models/resolve", (route) => { + if (route.request().method() === "POST") { + return route.fulfill({ + json: { + status: "none", + scope: null, + query: "", + model_name: null, + candidates: [], + task_offset: 0, + }, + }); + } + return route.fallback(); + }); + // Feature flags — frontend gates UI (e.g. agents/browser) on these. Default to // enabled so existing tests exercise the normal path; tests that need the // disabled state override this route after calling mockLangGraphAPI. diff --git a/frontend/tests/unit/core/messages/model-control.test.ts b/frontend/tests/unit/core/messages/model-control.test.ts new file mode 100644 index 0000000000..4c1a9a27f0 --- /dev/null +++ b/frontend/tests/unit/core/messages/model-control.test.ts @@ -0,0 +1,57 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { expect, it } from "@rstest/core"; + +import { modelControlDisplayText } from "@/core/messages/model-control"; +import { modelTaskInput } from "@/core/models/control"; + +it("separates an already applied control from the business task without losing its visible text", () => { + const original = "Use B for this turn: calculate 17 * 23"; + const input = modelTaskInput(original, { + status: "resolved", + scope: "turn", + query: "B", + model_name: "B", + candidates: [], + task_offset: original.indexOf("calculate"), + }); + expect(input.text).toBe("calculate 17 * 23"); + const message = { + type: "human", + content: input.text, + additional_kwargs: input.additionalKwargs, + } as Message; + expect(modelControlDisplayText(message)).toBe(original); + expect( + modelControlDisplayText({ ...message, content: "an edited task" }), + ).toBeNull(); + expect( + modelControlDisplayText({ ...message, type: "ai" } as Message), + ).toBeNull(); +}); + +it("preserves long Unicode task bodies beyond the preview window", () => { + const original = "Use B for this turn: " + "🦌".repeat(5000); + const input = modelTaskInput(original, { + status: "resolved", + scope: "turn", + query: "B", + model_name: "B", + candidates: [], + task_offset: 21, + }); + expect(input.text).toBe("🦌".repeat(5000)); +}); + +it("leaves the delegation request for the parent agent to dispatch", () => { + const text = "Ask B to calculate 17 * 23"; + expect( + modelTaskInput(text, { + status: "resolved", + scope: "task", + query: "B", + model_name: "B", + candidates: [], + task_offset: 9, + }).text, + ).toBe(text); +}); diff --git a/frontend/tests/unit/core/models/control.test.ts b/frontend/tests/unit/core/models/control.test.ts new file mode 100644 index 0000000000..cb2c3354c9 --- /dev/null +++ b/frontend/tests/unit/core/models/control.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "@rstest/core"; + +import { maySelectModel } from "@/core/models/control"; + +describe("direct model controls", () => { + it.each([ + "你找 v4 pro 去处理一下", + "这次用 ds v4.1 f", + "以后这个对话都用 ds", + "Please ask a model to help", + ])("preflights %s", (text) => { + expect(maySelectModel(text)).toBe(true); + }); + it.each([ + "文档说以后都用 ds", + "如果以后都用 ds 会怎样", + "解释一下模型选择", + "```用 ds```", + "Read the attachment", + ])("leaves ordinary content alone: %s", (text) => { + expect(maySelectModel(text)).toBe(false); + }); +}); diff --git a/frontend/tests/unit/core/models/model-control.dom.test.ts b/frontend/tests/unit/core/models/model-control.dom.test.ts new file mode 100644 index 0000000000..600b581e74 --- /dev/null +++ b/frontend/tests/unit/core/models/model-control.dom.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, expect, it, rs } from "@rstest/core"; +import { act, renderHook, waitFor } from "@testing-library/react"; + +import { useModelControl } from "@/core/models/use-model-control"; + +const mocks = rs.hoisted(() => ({ resolve: rs.fn(), error: rs.fn() })); +rs.mock("@/core/models/control", () => ({ + maySelectModel: () => true, + resolveModelControl: mocks.resolve, +})); +rs.mock("sonner", () => ({ toast: { error: mocks.error } })); +const ambiguous = { + status: "ambiguous", + scope: "turn", + query: "demo", + model_name: null, + candidates: [ + { name: "B", label: "Model B" }, + { name: "C", label: "Model C" }, + ], +}; + +beforeEach(() => { + mocks.resolve.mockReset(); + mocks.error.mockReset(); +}); + +it("waits for a user choice and revalidates it before resolving", async () => { + mocks.resolve.mockResolvedValueOnce(ambiguous).mockResolvedValueOnce({ + ...ambiguous, + status: "resolved", + model_name: "B", + }); + const { result } = renderHook(() => useModelControl("alice:t1", "en")); + let pending!: ReturnType; + act(() => { + pending = result.current.prepare("use demo"); + }); + await waitFor(() => expect(result.current.request).not.toBeNull()); + expect(mocks.resolve).toHaveBeenCalledTimes(1); + act(() => + result.current.choose({ + version: 1, + kind: "human_input_response", + source: "model_selection", + request_id: result.current.request!.request_id, + response_kind: "option", + option_id: "B", + value: "B", + }), + ); + await act(async () => { + expect((await pending)?.model_name).toBe("B"); + }); + expect(mocks.resolve.mock.calls[1]?.[1]).toEqual({ model_name: "B" }); +}); + +it("account or conversation changes cancel pending choices quietly", async () => { + mocks.resolve.mockResolvedValue(ambiguous); + const { result, rerender } = renderHook( + ({ owner }) => useModelControl(owner, "en"), + { initialProps: { owner: "alice:t1" } }, + ); + let pending!: Promise; + act(() => { + pending = result.current.prepare("use demo").catch((e: Error) => e.message); + }); + await waitFor(() => expect(result.current.request).not.toBeNull()); + rerender({ owner: "bob:t2" }); + await act(async () => { + expect(await pending).toBe("model-selection-cancelled"); + }); + expect(result.current.request).toBeNull(); + expect(mocks.resolve).toHaveBeenCalledTimes(1); + expect(mocks.error).not.toHaveBeenCalled(); +}); + +it("shows authorization or service errors instead of silently dropping the send", async () => { + mocks.resolve.mockRejectedValue( + new Error("Model is not available for your role"), + ); + const { result } = renderHook(() => useModelControl("alice:t1", "en")); + await act(async () => { + await expect(result.current.prepare("use demo")).rejects.toThrow( + "not available", + ); + }); + expect(mocks.error).toHaveBeenCalledWith( + "Model is not available for your role", + ); + expect(result.current.busy).toBe(false); +}); diff --git a/frontend/tests/unit/core/settings/thread-model-selection.dom.test.ts b/frontend/tests/unit/core/settings/thread-model-selection.dom.test.ts new file mode 100644 index 0000000000..8b66f2cabf --- /dev/null +++ b/frontend/tests/unit/core/settings/thread-model-selection.dom.test.ts @@ -0,0 +1,31 @@ +import { afterEach, expect, it, rs } from "@rstest/core"; + +import { safeLocalStorage } from "@/core/settings/local"; +import { + getBaseSettingsSnapshot, + getThreadModelSnapshot, + selectThreadModel, + updateLocalSettings, +} from "@/core/settings/store"; + +afterEach(() => { + rs.restoreAllMocks(); +}); + +it("does not claim to save a default when browser storage rejects the write", () => { + rs.spyOn(safeLocalStorage, "setItem").mockReturnValue(false); + expect(() => selectThreadModel("blocked-write-thread", "model-B")).toThrow(); + expect(getThreadModelSnapshot("blocked-write-thread")).toBeUndefined(); +}); + +it("persists only the selected conversation without editing account preferences", () => { + updateLocalSettings("context", { model_name: "parent-A" }); + const before = getBaseSettingsSnapshot(); + selectThreadModel("selection-test-thread", "model-B"); + expect(getThreadModelSnapshot("selection-test-thread")).toBe("model-B"); + expect(getBaseSettingsSnapshot()).toBe(before); + expect(getThreadModelSnapshot("unrelated-test-thread")).toBeUndefined(); + expect( + localStorage.getItem("deerflow.thread-model.selection-test-thread"), + ).toBe("model-B"); +}); diff --git a/frontend/tests/unit/core/threads/run-context.test.ts b/frontend/tests/unit/core/threads/run-context.test.ts index 31641d87db..508f5b9fdd 100644 --- a/frontend/tests/unit/core/threads/run-context.test.ts +++ b/frontend/tests/unit/core/threads/run-context.test.ts @@ -10,6 +10,28 @@ const settings = { } as unknown as LocalSettings["context"]; describe("buildRunContext", () => { + it("applies a one-turn model without mutating saved context", () => { + const before = { ...settings }; + const current = buildRunContext({ + settings, + threadId: "t-1", + extraContext: { model_name: "child-choice" }, + }); + expect(current.model_name).toBe("child-choice"); + expect(settings).toEqual(before); + expect(buildRunContext({ settings, threadId: "t-1" }).model_name).toBe( + "gemma4", + ); + }); + it("passes child-only selection separately from the parent model", () => { + const context = buildRunContext({ + settings, + threadId: "t-1", + extraContext: { subagent_model_name: "child-choice" }, + }); + expect(context.model_name).toBe("gemma4"); + expect(context.subagent_model_name).toBe("child-choice"); + }); it("sends attached references as a plain string[] under context.conversation_references", () => { const context = buildRunContext({ settings,