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>
This commit is contained in:
Totoro 2026-10-22 08:08:23 +08:00 committed by GitHub
parent d01538a54c
commit 0baa99ca90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1642 additions and 23 deletions

View File

@ -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

View File

@ -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,

View File

@ -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.

View File

@ -46,6 +46,7 @@ _MODEL_METADATA_FIELDS = frozenset(
{
"name",
"display_name",
"aliases",
"description",
"use",
"context_window",

View File

@ -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]

View File

@ -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(
...,

View File

@ -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

View File

@ -20,6 +20,7 @@ _MODEL_METADATA_FIELDS: frozenset[str] = frozenset(
"use",
"name",
"display_name",
"aliases",
"description",
"supports_thinking",
"supports_reasoning_effort",

View File

@ -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<action>帮我|来|去|处理|计算|回答|分析|生成|写|做|[,::])|$)"),
("turn", r"^(?:please\s+)?use\s+(.+?)\s+for this\s+(?:turn|task)\b"),
(None, r"^(?:请)?(?:用|使用|改用|换成)\s*(.+?)(?:\s*(?:模型)?(?P<action>帮我|来|去|处理|计算|回答|分析|生成|写|做|[,::])|$)"),
(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

View File

@ -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")

View File

@ -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.

View File

@ -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

View File

@ -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"}

View File

@ -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)

View File

@ -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

View File

@ -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 = []

View File

@ -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"

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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<string, unknown> = { 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() {
</div>
)}
{modelControl.request && (
<div className="mb-3" data-testid="model-selection-card">
<HumanInputCard
request={modelControl.request}
onSubmit={(response) => modelControl.choose(response)}
/>
</div>
)}
<InputBox
className={cn(
"bg-background/5 w-full",
@ -585,6 +706,7 @@ export default function AgentChatPage() {
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
modelControl.busy ||
(selectorVisible && agent === null) ||
(!isNewThread && isHistoryLoading)
}

View File

@ -24,6 +24,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,
@ -58,13 +59,15 @@ 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 { useProject } from "@/core/projects";
import { useThreadScheduledTaskEvents } from "@/core/scheduled-tasks/events";
import { useScheduleToolResultRefresh } from "@/core/scheduled-tasks/hooks";
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 {
useBranchThread,
@ -87,7 +90,7 @@ import { useMarkOpenThreadRead } from "./use-mark-open-thread-read";
import { useThreadChat } from "./use-thread-chat";
export default function ChatPage() {
const { t } = useI18n();
const { t, locale } = useI18n();
const { user } = useAuth();
const canStopStreaming = hasPermission(user, PERMISSIONS.RUNS_CANCEL);
const canCreateRuns = hasPermission(user, PERMISSIONS.RUNS_CREATE);
@ -112,6 +115,13 @@ export default function ChatPage() {
const [isWelcomeMode, setIsWelcomeMode] = useState(isNewThread);
const queryClient = useQueryClient();
const [settings, setSettings] = useThreadSettings(threadId);
const modelControl = useModelControl(
`${user?.id ?? "anonymous"}:${threadId}`,
locale,
);
const prepareModelControl = modelControl.prepare;
const [modelChoiceSummary, setModelChoiceSummary] = useState("");
useEffect(() => 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<string, unknown> = {};
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() {
</div>
</div>
)}
{modelControl.request && (
<div className="mb-3" data-testid="model-selection-card">
<HumanInputCard
request={modelControl.request}
onSubmit={(response) => modelControl.choose(response)}
/>
</div>
)}
{modelChoiceSummary && (
<p
className="text-muted-foreground mb-2 text-sm"
data-testid="model-selection-applied"
>
{modelChoiceSummary}
</p>
)}
{mountedRef.current ? (
<InputBox
className={cn(
@ -662,6 +798,7 @@ export default function ChatPage() {
isMock ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
modelControl.busy ||
(!isNewThread && isHistoryLoading)
}
onContextChange={(context, options) => {

View File

@ -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<string, unknown>;
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;
}

View File

@ -12,6 +12,8 @@ import {
isClosingFence,
} from "@/core/streamdown/fences";
import { modelControlDisplayText } from "./model-control";
interface GenericMessageGroup<T = string> {
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 ??

View File

@ -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<ModelControlDecision> {
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<ModelControlDecision>;
}

View File

@ -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<HumanInputRequest | null>(null);
const [busy, setBusy] = useState(false);
const answer = useRef<Answer | null>(null);
const generation = useRef(0);
const abort = useRef<AbortController | null>(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<ModelControlDecision | null> => {
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<HumanInputResponse | null>(
(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 };
}

View File

@ -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<K extends keyof LocalSettings>(
threadId: string,
key: K,

View File

@ -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] }
: {}),

View File

@ -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)) {

View File

@ -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<void>((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<void>((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([]);
});

View File

@ -211,6 +211,7 @@ for (const lang of LOCALES) {
.last()
.evaluate(
(card, other) =>
other !== null &&
Boolean(
card.compareDocumentPosition(other) &
Node.DOCUMENT_POSITION_FOLLOWING,

View File

@ -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.

View File

@ -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);
});

View File

@ -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);
});
});

View File

@ -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<typeof result.current.prepare>;
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<unknown>;
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);
});

View File

@ -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");
});

View File

@ -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,