mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
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:
parent
d01538a54c
commit
0baa99ca90
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -46,6 +46,7 @@ _MODEL_METADATA_FIELDS = frozenset(
|
||||
{
|
||||
"name",
|
||||
"display_name",
|
||||
"aliases",
|
||||
"description",
|
||||
"use",
|
||||
"context_window",
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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(
|
||||
...,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -20,6 +20,7 @@ _MODEL_METADATA_FIELDS: frozenset[str] = frozenset(
|
||||
"use",
|
||||
"name",
|
||||
"display_name",
|
||||
"aliases",
|
||||
"description",
|
||||
"supports_thinking",
|
||||
"supports_reasoning_effort",
|
||||
|
||||
144
backend/packages/harness/deerflow/models/selection.py
Normal file
144
backend/packages/harness/deerflow/models/selection.py
Normal 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
|
||||
@ -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")
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
176
backend/tests/test_model_selection.py
Normal file
176
backend/tests/test_model_selection.py
Normal 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"}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
33
frontend/src/core/messages/model-control.ts
Normal file
33
frontend/src/core/messages/model-control.ts
Normal 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;
|
||||
}
|
||||
@ -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 ??
|
||||
|
||||
60
frontend/src/core/models/control.ts
Normal file
60
frontend/src/core/models/control.ts
Normal 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>;
|
||||
}
|
||||
172
frontend/src/core/models/use-model-control.ts
Normal file
172
frontend/src/core/models/use-model-control.ts
Normal 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 };
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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] }
|
||||
: {}),
|
||||
|
||||
@ -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)) {
|
||||
|
||||
145
frontend/tests/e2e/model-control-navigation.spec.ts
Normal file
145
frontend/tests/e2e/model-control-navigation.spec.ts
Normal 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([]);
|
||||
});
|
||||
@ -211,6 +211,7 @@ for (const lang of LOCALES) {
|
||||
.last()
|
||||
.evaluate(
|
||||
(card, other) =>
|
||||
other !== null &&
|
||||
Boolean(
|
||||
card.compareDocumentPosition(other) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
|
||||
@ -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.
|
||||
|
||||
57
frontend/tests/unit/core/messages/model-control.test.ts
Normal file
57
frontend/tests/unit/core/messages/model-control.test.ts
Normal 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);
|
||||
});
|
||||
23
frontend/tests/unit/core/models/control.test.ts
Normal file
23
frontend/tests/unit/core/models/control.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
92
frontend/tests/unit/core/models/model-control.dom.test.ts
Normal file
92
frontend/tests/unit/core/models/model-control.dom.test.ts
Normal 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);
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user