mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
feat: add inline @ references with multiple skill activation (#6063)
* feat(frontend): unify composer references with an @ picker * feat: edit references inline and activate multiple skills * docs: describe inline references and multiple skill selection * fix: prevent sent drafts from returning and repair CI checks * fix: reconcile inline references and address review feedback * fix: preserve references through discovery errors and polish
This commit is contained in:
parent
5c67d61d65
commit
4c05e88c52
@ -1486,7 +1486,9 @@ The Web UI composer can polish draft input before sending. The rewrite runs as a
|
||||
|
||||
When the agent asks for clarification, the Web UI shows the structured response card but keeps the normal composer available. Users can complete the card or send a free-form chat message to bypass it; that message closes the latest pending clarification and becomes the agent's next input. Accompanying answer text stays outside the execution steps panel, and answering the request keeps previously completed text in the conversation while the agent continues.
|
||||
|
||||
Unsent Web UI composer drafts survive page reloads and switching between conversations within the same browser tab. Drafts are isolated by user, agent, and conversation, include a selected slash skill when present, and are cleared once a send is accepted. Attachments and quoted conversation context are intentionally not persisted.
|
||||
Unsent Web UI composer drafts survive page reloads and switching between conversations within the same browser tab. Drafts are isolated by user, agent, and conversation, include the selected skill and explicit conversation references, and are cleared once a send is accepted. Project documents already attached to the thread also survive reloads; browser-local uploads and quoted message excerpts are not persisted.
|
||||
|
||||
The Web UI composer supports `@` references at the cursor or through the `@` button. Search enabled skills, documents in the current project, and other conversations in a grouped picker; selected references appear inline alongside the draft text and can be removed with normal editing. Multiple skills can be selected in one message (up to 16 unique skills); selecting a checked skill again removes it, with each selection checked against the current user registry and agent allowlist. Project files are attached only after ingestion succeeds; conversation references require the `read_conversation` capability and retain its per-run limit. If capability discovery fails, saved conversation references remain intact and sending waits for a successful retry. Polishing preserves complete reference labels when instructions are reordered. The file group also offers local uploads. `/goal`, `/compact`, and legacy `/skill-name` input remain supported.
|
||||
|
||||
The Web UI composer also supports browser-based voice dictation when the browser exposes the Web Speech API. The microphone button transcribes speech into the local draft only; DeerFlow receives only the transcribed text, while audio handling is delegated to the browser or operating system speech-recognition service according to that environment's policy. Users can review or edit the text before sending.
|
||||
|
||||
|
||||
@ -19,31 +19,22 @@ stay `in_progress`. Never infer status from reply text.
|
||||
|
||||
Assembly order: `tool_error_handling_middleware.py::_build_runtime_middlewares` (exposed as `build_lead_runtime_middlewares`), then `../lead_agent/agent.py::build_middlewares` appends lead-only entries. Optional entries require their config/runtime condition.
|
||||
|
||||
**Message provenance.** At injection/rewrite, always stamp `additional_kwargs`
|
||||
via `deerflow_extension_api.provenance.provenance_kwargs()`:
|
||||
`deerflow_content_kind`, `deerflow_producer_kind`, optional
|
||||
`deerflow_producer_entity_id`. All are server-owned inbound metadata; stamp even
|
||||
without observers, since downstream cannot recover producers. Producers:
|
||||
DynamicContext (reminder/memory), DurableContext (contract/data),
|
||||
**Message provenance.** Stamp injected/rewritten messages with
|
||||
`provenance_kwargs()` from `deerflow_extension_api.provenance`: server-owned
|
||||
`deerflow_content_kind`, `deerflow_producer_kind`, and optional entity ID, even
|
||||
without observers. Producers: DynamicContext, DurableContext,
|
||||
SystemMessageCoalescing, ViewImage, SkillActivation. Summarization/Title use
|
||||
`SystemOperationKind.SUMMARIZATION`/`.TITLE` model-call attribution; summaries
|
||||
enter via DurableContext's stamped `durable_context_data`, not separate
|
||||
messages. Memory only queues extraction; recall uses DynamicContext's
|
||||
`dynamic_context_memory` stamp.
|
||||
`SystemOperationKind.SUMMARIZATION`/`.TITLE`; summaries enter via DurableContext's
|
||||
`durable_context_data`, memory recall via DynamicContext's
|
||||
`dynamic_context_memory`. Memory only queues extraction.
|
||||
|
||||
**Middleware self-description.** Behaviour-configurable middleware implements
|
||||
`release_policy_parameters() -> dict[str, object]` (duck-typed
|
||||
`deerflow_extension_api.release.ReleasePolicyProvider`, no base class).
|
||||
Use JSON-serialisable values and `canonical_hash` for long text, not prompt
|
||||
copies. `collect_release_policies()` gathers stack declarations; update them
|
||||
alongside every behaviour-affecting field.
|
||||
Summarization declares enabled `task_continuity` retention settings (otherwise
|
||||
`None`). DurableContext declares its normalized skills root, sorted read-tool
|
||||
names and continuity switch, so each capture/injection policy affects assembly
|
||||
identity without depending on private-field probing.
|
||||
Continuity history readers share shape validation, including the capture failure
|
||||
path and DurableContext rendering, so malformed persisted metadata cannot abort
|
||||
ordinary compaction or a model call.
|
||||
**Self-description.** Configurable middleware exposes JSON-serialisable
|
||||
`release_policy_parameters()` (`ReleasePolicyProvider`, duck typed). Update
|
||||
`collect_release_policies()` declarations with behavior; use `canonical_hash`
|
||||
for long text. Summarization declares enabled task-continuity retention or None;
|
||||
DurableContext declares normalized skills root, sorted read tools and continuity
|
||||
switch. History readers, including capture failures, validate persisted metadata
|
||||
so malformed values cannot abort compaction/model calls.
|
||||
|
||||
**Removing tool calls.** Use `clone_ai_message_with_tool_calls`, not a bare
|
||||
`tool_calls` update: adapters resend stale `content` tool-call blocks, which
|
||||
@ -131,3 +122,9 @@ Before changing a later authorization phase, read the [authorization RFC](../../
|
||||
37. **ModelLengthFinishReasonMiddleware** - Match stamps `stop_reason=model_length_capped` and ends tool loop. Suppresses calls, appends notice even with partial text, and stamps `model_length_termination` so downstream guards stand down. Preserves content blocks.
|
||||
38. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Suppresses tool execution when the provider safety-terminated the response (e.g. `finish_reason=content_filter`); registered after terminal-response/custom/configured middlewares so LangChain's reverse-order `after_model` dispatch runs it first
|
||||
39. **ClarificationMiddleware** - Intercepts `ask_clarification`, writes a readable `ToolMessage.content` fallback plus a structured `ToolMessage.artifact.human_input` payload, and interrupts via `Command(goto=END)` (must be last). `after_model` drops same-turn sibling tool calls so they cannot run before the user answers; a malformed `ask_clarification` parked on `invalid_tool_calls` is the same stop signal. `disable_clarification` runs keep the siblings. Payloads are versioned — legacy `free_text`/`choice_with_other` stay `version: 1`; the v2 `form` mode (from `fields`) is `version: 2` so older frontends reject it and fall back to plain text. Field normalization is deterministic and lives in the middleware (it short-circuits before tool execution, so tool-arg typing gives no runtime validation), and it is atomic: any structurally broken entry — non-dict, bad/duplicate name, a name colliding with a JS `Object.prototype` member (`__proto__`/`constructor`), or exceeding the caps (16 fields / 24 options per field / 200 chars per text / `MAX_FORM_SERIALIZED_BYTES` = 16KB UTF-8, the per-item caps alone admitting forms whose IM text fallback overruns channel limits) — degrades the whole form to the legacy option/free-text modes, so a card never renders "complete" while missing a field. Benign issues degrade locally (unknown types — incl. unhashable JSON like `type: []`, which must not raise from the membership probe — and option-less selects become `text`); options are trimmed/deduped with blanks dropped (form- and top-level) since the frontend rejects blank labels. XML-to-dict option payloads are recursively flattened from dict/list containers in source order, scalar leaves kept, residual XML tags stripped before that trimming. Checkboxes are booleans defaulting to "no"; `required` on one means consent semantics. The response protocol is unchanged (v1 `text`/`option`): form cards submit a text summary as `response_kind: "text"`, so journal persistence needs no new allowlist entries. `RunJournal` reconciles visible, unpersisted `ToolMessage`s for current-run lead-agent calls at the next lead-agent `on_chat_model_start` after consumption, or at successful root `on_chain_end`. This preserves any middleware short-circuit, including blocked writes, across later model errors and checkpoint compaction (#4666). `_remember_current_run_tool_calls` excludes subagents; their results stay in `subagent.step`. Human Input Card replies are `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden sources (currently `ask_clarification`) as `llm.human.input`.
|
||||
|
||||
Inline `skill_references` accepts 1–16 names; empty lists retain slash/plain flow.
|
||||
Resolve the whole batch through user storage, enabled state and agent allowlist
|
||||
before activation; reject any invalid entry. Keep bodies in escaped HumanMessage
|
||||
context with task text once. Authenticated paths feed secrets/tool policy; record usage per skill.
|
||||
Legacy slash syntax is unchanged.
|
||||
|
||||
@ -5,11 +5,12 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
@ -18,7 +19,7 @@ from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelRequest, ModelResponse
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from deerflow.agents.middlewares.skill_usage import SKILL_USAGE_KEY, build_skill_usage, record_skill_usage
|
||||
from deerflow.agents.middlewares.skill_usage import SKILL_USAGE_KEY, SKILL_USAGES_KEY, build_skill_usage, record_skill_usage
|
||||
from deerflow.runtime.events.catalog import (
|
||||
MIDDLEWARE_SKILL_ACTIVATION_TAG,
|
||||
MIDDLEWARE_SKILL_SECRETS_TAG,
|
||||
@ -28,8 +29,8 @@ from deerflow.runtime.secret_context import (
|
||||
_SLASH_SKILL_ACTIVATION_RUN_KEY,
|
||||
ACTIVE_SECRETS_CONTEXT_KEY,
|
||||
extract_request_secrets,
|
||||
read_slash_skill_source_path,
|
||||
write_slash_skill_source_path,
|
||||
read_slash_skill_source_paths,
|
||||
write_slash_skill_source_paths,
|
||||
)
|
||||
from deerflow.skills.slash import parse_slash_skill_reference, resolve_slash_skill
|
||||
from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage
|
||||
@ -83,6 +84,7 @@ class _Activation:
|
||||
remaining_text: str
|
||||
editable: bool
|
||||
required_secrets: tuple[SecretRequirement, ...] = ()
|
||||
additional_activations: tuple[_Activation, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@ -187,9 +189,17 @@ class SkillActivationMiddleware(AgentMiddleware):
|
||||
for index in range(len(messages) - 1, -1, -1):
|
||||
if _is_user_activation_target(messages[index]):
|
||||
content = get_original_user_content_text(messages[index].content, messages[index].additional_kwargs)
|
||||
reference = parse_slash_skill_reference(content)
|
||||
if reference is not None:
|
||||
names.append(reference.name)
|
||||
explicit_names = messages[index].additional_kwargs.get("skill_references")
|
||||
if explicit_names is not None and explicit_names != []:
|
||||
if isinstance(explicit_names, list) and 1 <= len(explicit_names) <= 16:
|
||||
for name in explicit_names:
|
||||
reference = parse_slash_skill_reference(f"/{name}") if isinstance(name, str) else None
|
||||
if reference is not None and reference.name == name:
|
||||
names.append(name)
|
||||
else:
|
||||
reference = parse_slash_skill_reference(content)
|
||||
if reference is not None:
|
||||
names.append(reference.name)
|
||||
break
|
||||
state = getattr(request, "state", None) or {}
|
||||
try:
|
||||
@ -236,6 +246,7 @@ class SkillActivationMiddleware(AgentMiddleware):
|
||||
# None means "any enabled, runtime-allowed skill may be activated";
|
||||
# a concrete list narrows that to a fixed set.
|
||||
"available_skills": sorted(self._available_skills) if self._available_skills is not None else None,
|
||||
"max_explicit_skill_references": 16,
|
||||
}
|
||||
|
||||
def _storage(self) -> SkillStorage:
|
||||
@ -319,9 +330,10 @@ class SkillActivationMiddleware(AgentMiddleware):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_activation_reminder(activation: _Activation) -> str:
|
||||
def _build_activation_reminder(activation: _Activation, *, include_user_request: bool = True) -> str:
|
||||
user_request = activation.remaining_text or ("No additional task text was provided after the slash skill command. Ask the user what they want to do with this skill if the next step is unclear.")
|
||||
escaped_user_request = html.escape(user_request, quote=False)
|
||||
request_block = f"Treat the task text as:\n<user_request>\n{escaped_user_request}\n</user_request>\n" if include_user_request else ""
|
||||
escaped_skill_content = html.escape(activation.skill_content, quote=False)
|
||||
escaped_skill_name = html.escape(activation.skill_name, quote=True)
|
||||
escaped_category = html.escape(activation.category, quote=True)
|
||||
@ -330,11 +342,7 @@ class SkillActivationMiddleware(AgentMiddleware):
|
||||
editable_str = "true" if activation.editable else "false"
|
||||
return f"""<slash_skill_activation>
|
||||
The user explicitly activated the `{escaped_skill_name}` skill for this turn.
|
||||
Treat the task text as:
|
||||
<user_request>
|
||||
{escaped_user_request}
|
||||
</user_request>
|
||||
|
||||
{request_block}
|
||||
Follow this skill before choosing a general workflow. Load supporting resources from the same skill directory only when needed.
|
||||
|
||||
<skill name="{escaped_skill_name}" category="{escaped_category}" path="{escaped_path}" sha256="{escaped_content_hash}" editable="{editable_str}">
|
||||
@ -372,6 +380,9 @@ Follow this skill before choosing a general workflow. Load supporting resources
|
||||
if target.id:
|
||||
return target.id
|
||||
content = get_original_user_content_text(target.content, target.additional_kwargs)
|
||||
references = target.additional_kwargs.get("skill_references")
|
||||
if references is not None:
|
||||
content += "\n" + json.dumps(references, sort_keys=True, default=str)
|
||||
return "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
@ -420,7 +431,29 @@ Follow this skill before choosing a general workflow. Load supporting resources
|
||||
return None
|
||||
|
||||
content = get_original_user_content_text(target.content, target.additional_kwargs)
|
||||
resolution = self._resolve_activation(content, activation_decisions=activation_decisions)
|
||||
names = target.additional_kwargs.get("skill_references")
|
||||
if names is not None and names != []:
|
||||
if not isinstance(names, list) or not 1 <= len(names) <= 16:
|
||||
resolution = _ActivationResolution(failure_message="Select between 1 and 16 skills.")
|
||||
else:
|
||||
activations: list[_Activation] = []
|
||||
resolution = None
|
||||
for name in names:
|
||||
reference = parse_slash_skill_reference(f"/{name}") if isinstance(name, str) else None
|
||||
if reference is None or reference.name != name:
|
||||
resolution = _ActivationResolution(failure_message="Invalid skill reference.")
|
||||
break
|
||||
if any(item.skill_name == name for item in activations):
|
||||
continue
|
||||
resolved = self._resolve_activation(f"/{name} {content}", activation_decisions=activation_decisions)
|
||||
if resolved is None or resolved.failure_message or resolved.activation is None:
|
||||
resolution = resolved or _ActivationResolution(failure_message="Invalid skill reference.")
|
||||
break
|
||||
activations.append(resolved.activation)
|
||||
if resolution is None and activations:
|
||||
resolution = _ActivationResolution(activation=replace(activations[0], additional_activations=tuple(activations[1:])))
|
||||
else:
|
||||
resolution = self._resolve_activation(content, activation_decisions=activation_decisions)
|
||||
if resolution is None:
|
||||
return None
|
||||
return target_index, target, resolution, run_key
|
||||
@ -469,7 +502,8 @@ Follow this skill before choosing a general workflow. Load supporting resources
|
||||
activation.container_file_path,
|
||||
activation.content_hash,
|
||||
)
|
||||
self._record_activation(request, activation, hook=hook)
|
||||
for item in (activation, *activation.additional_activations):
|
||||
self._record_activation(request, item, hook=hook)
|
||||
# Mark this slash message as activated for the run so the tool loop's later
|
||||
# model calls skip the redundant re-activation (#3861: one activation call,
|
||||
# many follow-up model calls). A new user slash message keys differently and
|
||||
@ -481,7 +515,7 @@ Follow this skill before choosing a general workflow. Load supporting resources
|
||||
# (computed once there, threaded through here) rather than recomputed.
|
||||
if run_context is not None:
|
||||
run_context[_SLASH_SKILL_ACTIVATION_RUN_KEY] = run_key
|
||||
activation_msg = self._make_activation_message(target, self._build_activation_reminder(activation))
|
||||
activation_msg = self._make_activation_message(target, "\n\n".join(self._build_activation_reminder(item, include_user_request=index == 0) for index, item in enumerate((activation, *activation.additional_activations))))
|
||||
messages = list(request.messages)
|
||||
messages.insert(target_index, activation_msg)
|
||||
return request.override(messages=messages), activation
|
||||
@ -500,7 +534,8 @@ Follow this skill before choosing a general workflow. Load supporting resources
|
||||
effective = prepared if prepared is not None else request
|
||||
self._resolve_secret_bindings(effective, activation, hook=hook, activation_decisions=activation_decisions, entry_registry=entry_registry)
|
||||
if activation is not None:
|
||||
record_skill_usage(getattr(request, "runtime", None), self._usage_snapshot(activation))
|
||||
for item in (activation, *activation.additional_activations):
|
||||
record_skill_usage(getattr(request, "runtime", None), self._usage_snapshot(item))
|
||||
return effective, activation
|
||||
|
||||
@staticmethod
|
||||
@ -525,7 +560,11 @@ Follow this skill before choosing a general workflow. Load supporting resources
|
||||
messages = [response] if isinstance(response, AIMessage) else getattr(response, "result", [])
|
||||
for message in messages:
|
||||
if isinstance(message, AIMessage):
|
||||
message.additional_kwargs = {**message.additional_kwargs, SKILL_USAGE_KEY: usage}
|
||||
if activation.additional_activations:
|
||||
usages = [snapshot for item in (activation, *activation.additional_activations) if (snapshot := SkillActivationMiddleware._usage_snapshot(item)) is not None]
|
||||
message.additional_kwargs = {**message.additional_kwargs, SKILL_USAGES_KEY: usages}
|
||||
else:
|
||||
message.additional_kwargs = {**message.additional_kwargs, SKILL_USAGE_KEY: usage}
|
||||
break
|
||||
return response
|
||||
|
||||
@ -573,9 +612,9 @@ Follow this skill before choosing a general workflow. Load supporting resources
|
||||
# consumers authenticate the source and resolve the live registry skill
|
||||
# by path, so caller-mergeable context cannot forge an activation.
|
||||
if activation is not None:
|
||||
write_slash_skill_source_path(
|
||||
write_slash_skill_source_paths(
|
||||
context,
|
||||
activation.container_file_path,
|
||||
tuple(item.container_file_path for item in (activation, *activation.additional_activations)),
|
||||
owner_token=self._slash_source_owner_token,
|
||||
)
|
||||
|
||||
@ -612,13 +651,14 @@ Follow this skill before choosing a general workflow. Load supporting resources
|
||||
# The two sources resolve independently — a transient failure of
|
||||
# the fresh slash lookup must not zero the entry sources (they
|
||||
# have their own snapshot and never consult that registry).
|
||||
slash_path = read_slash_skill_source_path(context, owner_token=self._slash_source_owner_token)
|
||||
slash_paths = read_slash_skill_source_paths(context, owner_token=self._slash_source_owner_token)
|
||||
if slash_registry is not None:
|
||||
# Slash source: exempt from the ``secrets-autonomous`` opt-out
|
||||
# (explicit ceremony), but still enabled + allowlist checked.
|
||||
slash_skill = self._resolve_registry_skill(slash_registry, slash_path, require_autonomous=False)
|
||||
if slash_skill is not None:
|
||||
sources.append((slash_skill.name, tuple(slash_skill.required_secrets)))
|
||||
for slash_path in slash_paths:
|
||||
slash_skill = self._resolve_registry_skill(slash_registry, slash_path, require_autonomous=False)
|
||||
if slash_skill is not None:
|
||||
sources.append((slash_skill.name, tuple(slash_skill.required_secrets)))
|
||||
# Entry sources resolve independently. The same-skill exclusion is
|
||||
# anchored on the AUTHENTICATED slash activation identity — not on
|
||||
# binding success, and not on the declared name alone: a
|
||||
@ -631,10 +671,8 @@ Follow this skill before choosing a general workflow. Load supporting resources
|
||||
# first, then the entry snapshot) additionally cover the cross-path
|
||||
# same-name shadowing case.
|
||||
slash_bound = {name for name, _ in sources}
|
||||
identity_paths: frozenset[str] = frozenset()
|
||||
if isinstance(slash_path, str) and slash_path:
|
||||
normalized_identity = posixpath.normpath(slash_path)
|
||||
identity_paths = frozenset({normalized_identity})
|
||||
identity_paths = frozenset(posixpath.normpath(path) for path in slash_paths)
|
||||
for normalized_identity in identity_paths:
|
||||
for registry in (slash_registry, entry_registry_effective):
|
||||
identity = registry.get(normalized_identity) if isinstance(registry, dict) else None
|
||||
if identity is not None:
|
||||
|
||||
@ -17,7 +17,7 @@ from langchain_core.messages import ToolMessage
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
|
||||
from deerflow.runtime.secret_context import SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY, read_slash_skill_source_path
|
||||
from deerflow.runtime.secret_context import SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY, read_slash_skill_source_paths
|
||||
from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage
|
||||
from deerflow.skills.tool_policy import ALWAYS_AVAILABLE_BUILTIN_TOOL_NAMES, allowed_tool_names_for_skills
|
||||
from deerflow.skills.types import Skill
|
||||
@ -177,9 +177,9 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]):
|
||||
|
||||
def _active_policy(self, request: ModelRequest | ToolCallRequest) -> _PolicySignature:
|
||||
context = getattr(getattr(request, "runtime", None), "context", None)
|
||||
slash_path = read_slash_skill_source_path(context, owner_token=self._slash_source_owner_token)
|
||||
if slash_path is not None:
|
||||
return _POLICY_SOURCE_SLASH, (slash_path,)
|
||||
slash_paths = read_slash_skill_source_paths(context, owner_token=self._slash_source_owner_token)
|
||||
if slash_paths:
|
||||
return _POLICY_SOURCE_SLASH, slash_paths
|
||||
|
||||
paths: list[str] = []
|
||||
for entry in self._skill_context_entries(request):
|
||||
|
||||
@ -103,6 +103,28 @@ def read_slash_skill_source_path(context: Any, *, owner_token: str) -> str | Non
|
||||
return path if isinstance(path, str) and path else None
|
||||
|
||||
|
||||
def write_slash_skill_source_paths(context: Any, paths: tuple[str, ...], *, owner_token: str) -> None:
|
||||
"""Bind all explicitly selected skills with the same private owner token."""
|
||||
if paths:
|
||||
write_slash_skill_source_path(context, paths[0], owner_token=owner_token)
|
||||
if read_slash_skill_source_path(context, owner_token=owner_token) == paths[0]:
|
||||
context[_SLASH_SECRET_SOURCE_KEY]["paths"] = list(dict.fromkeys(paths))
|
||||
|
||||
|
||||
def read_slash_skill_source_paths(context: Any, *, owner_token: str) -> tuple[str, ...]:
|
||||
"""Authenticate before accepting a list; retain the legacy single-path shape."""
|
||||
first = read_slash_skill_source_path(context, owner_token=owner_token)
|
||||
if first is None:
|
||||
return ()
|
||||
source = context[_SLASH_SECRET_SOURCE_KEY]
|
||||
if "paths" not in source:
|
||||
return (first,)
|
||||
paths = source["paths"]
|
||||
if not isinstance(paths, list) or not 1 <= len(paths) <= 16 or paths[0] != first or any(not isinstance(path, str) or not path for path in paths):
|
||||
return ()
|
||||
return tuple(dict.fromkeys(paths))
|
||||
|
||||
|
||||
# Reserved run-context key holding the per-model-step ``skill:activate``
|
||||
# decisions for persisted ``skill_context`` entry paths, keyed by normalized
|
||||
# container SKILL.md path. Published by the skill-activation middleware (async
|
||||
|
||||
234
backend/tests/test_inline_skill_references.py
Normal file
234
backend/tests/test_inline_skill_references.py
Normal file
@ -0,0 +1,234 @@
|
||||
"""Explicit multi-skill activation uses the existing owner and policy boundaries."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from langchain.agents.middleware.types import ModelRequest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
from deerflow.runtime.secret_context import (
|
||||
_SLASH_SECRET_SOURCE_KEY,
|
||||
_SLASH_SKILL_ACTIVATION_RUN_KEY,
|
||||
ACTIVE_SECRETS_CONTEXT_KEY,
|
||||
read_slash_skill_source_path,
|
||||
read_slash_skill_source_paths,
|
||||
write_slash_skill_source_path,
|
||||
write_slash_skill_source_paths,
|
||||
)
|
||||
from deerflow.skills.types import SecretRequirement, Skill, SkillCategory
|
||||
|
||||
OWNER = "test-chain-owner"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def catalog(tmp_path, monkeypatch):
|
||||
skills = []
|
||||
for index in range(16):
|
||||
name = f"skill-{index}"
|
||||
directory = tmp_path / name
|
||||
directory.mkdir()
|
||||
skill_file = directory / "SKILL.md"
|
||||
skill_file.write_text(f"# {name}\nDo <safe> work.", encoding="utf-8")
|
||||
skills.append(
|
||||
Skill(name=name, description=name, license="MIT", skill_dir=directory, skill_file=skill_file, relative_path=Path(name), category=SkillCategory.CUSTOM, enabled=True, required_secrets=(SecretRequirement(name=f"KEY_{index}"),))
|
||||
)
|
||||
storage = SimpleNamespace(load_skills=lambda **_: skills, get_container_root=lambda: "/mnt/skills", get_skills_root_path=lambda: tmp_path, validate_skill_file_path=lambda path: path.resolve())
|
||||
factory = Mock(return_value=storage)
|
||||
monkeypatch.setattr("deerflow.agents.middlewares.skill_activation_middleware.get_or_new_user_skill_storage", factory)
|
||||
middleware = SkillActivationMiddleware(user_id="owner-a", slash_source_owner_token=OWNER)
|
||||
return middleware, skills, factory
|
||||
|
||||
|
||||
def request(text, metadata, context):
|
||||
message = HumanMessage(content=text, additional_kwargs=metadata)
|
||||
return ModelRequest(model=object(), messages=[message], state={"messages": [message]}, runtime=SimpleNamespace(context=context))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 2, 16])
|
||||
@pytest.mark.parametrize("async_call", [False, True])
|
||||
def test_inline_batch_activates_once_with_owner_scoped_secrets_and_usage(catalog, count, async_call):
|
||||
middleware, skills, factory = catalog
|
||||
names = [skill.name for skill in skills[:count]]
|
||||
journal = SimpleNamespace(record_middleware=Mock(), record_skill_usage=Mock())
|
||||
context = {"__run_journal": journal, "secrets": {f"KEY_{i}": f"value-{i}" for i in range(16)}}
|
||||
original = request("Compare <inputs>", {"skill_references": names}, context)
|
||||
seen = []
|
||||
|
||||
def handler(value):
|
||||
seen.append(value)
|
||||
return AIMessage(content="done")
|
||||
|
||||
async def ahandler(value):
|
||||
return handler(value)
|
||||
|
||||
def invoke():
|
||||
return asyncio.run(middleware.awrap_model_call(original, ahandler)) if async_call else middleware.wrap_model_call(original, handler)
|
||||
|
||||
response = invoke()
|
||||
reminder = seen[0].messages[0]
|
||||
assert isinstance(reminder, HumanMessage)
|
||||
assert reminder.additional_kwargs["hide_from_ui"] is True
|
||||
assert reminder.additional_kwargs["deerflow_producer_kind"]
|
||||
assert "Do <safe> work." in reminder.content
|
||||
assert reminder.content.count("Compare <inputs>") == 1
|
||||
assert original.state["messages"] == original.messages
|
||||
expected_paths = tuple(skill.get_container_file_path() for skill in skills[:count])
|
||||
assert read_slash_skill_source_paths(context, owner_token=OWNER) == expected_paths
|
||||
assert context[ACTIVE_SECRETS_CONTEXT_KEY] == {f"KEY_{i}": f"value-{i}" for i in range(count)}
|
||||
assert all(call.args == ("owner-a",) for call in factory.call_args_list)
|
||||
key = "skill_usage" if count == 1 else "skill_usages"
|
||||
usages = [response.additional_kwargs[key]] if count == 1 else response.additional_kwargs[key]
|
||||
assert [entry["name"] for entry in usages] == names
|
||||
assert journal.record_skill_usage.call_count == count
|
||||
assert context[_SLASH_SKILL_ACTIVATION_RUN_KEY]
|
||||
invoke()
|
||||
assert seen[1].messages == original.messages
|
||||
assert journal.record_skill_usage.call_count == count
|
||||
assert context[ACTIVE_SECRETS_CONTEXT_KEY] == {f"KEY_{i}": f"value-{i}" for i in range(count)}
|
||||
|
||||
|
||||
def test_duplicate_references_activate_and_record_each_skill_once(catalog):
|
||||
middleware, _, _ = catalog
|
||||
captured = Mock(return_value=AIMessage(content="done"))
|
||||
response = middleware.wrap_model_call(request("compare", {"skill_references": ["skill-0", "skill-1", "skill-0"]}, {}), captured)
|
||||
assert [entry["name"] for entry in response.additional_kwargs["skill_usages"]] == ["skill-0", "skill-1"]
|
||||
assert captured.call_args.args[0].messages[0].content.count('<skill name="skill-0"') == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("names", [False, 0, "", {}, "skill-0", ["skill-0", None], ["skill-0", "a--b"], ["skill-0", "missing"], ["skill-0"] * 17])
|
||||
def test_invalid_inline_batch_never_reaches_model_or_publishes_partial_authority(catalog, names):
|
||||
middleware, _, _ = catalog
|
||||
context = {"secrets": {"KEY_0": "test-only"}}
|
||||
handler = Mock()
|
||||
response = middleware.wrap_model_call(request("task", {"skill_references": names}, context), handler)
|
||||
assert isinstance(response, AIMessage)
|
||||
handler.assert_not_called()
|
||||
assert _SLASH_SECRET_SOURCE_KEY not in context
|
||||
assert _SLASH_SKILL_ACTIVATION_RUN_KEY not in context
|
||||
assert ACTIVE_SECRETS_CONTEXT_KEY not in context
|
||||
assert "skill_usage" not in response.additional_kwargs
|
||||
assert "skill_usages" not in response.additional_kwargs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("denied", ["disabled", "agent-allowlist"])
|
||||
def test_invalid_second_selection_rejects_the_entire_batch(catalog, denied):
|
||||
middleware, skills, _ = catalog
|
||||
if denied == "disabled":
|
||||
skills[1] = replace(skills[1], enabled=False)
|
||||
else:
|
||||
middleware._available_skills = {"skill-0"}
|
||||
handler = Mock()
|
||||
context = {}
|
||||
middleware.wrap_model_call(request("task", {"skill_references": ["skill-0", "skill-1"]}, context), handler)
|
||||
handler.assert_not_called()
|
||||
assert not context
|
||||
|
||||
|
||||
@pytest.mark.parametrize("metadata", [{}, {"skill_references": None}, {"skill_references": []}])
|
||||
@pytest.mark.parametrize("text", ["ordinary task", "/skill-0 legacy task"])
|
||||
def test_empty_selection_preserves_ordinary_and_legacy_paths(catalog, metadata, text):
|
||||
middleware, _, _ = catalog
|
||||
handler = Mock(return_value=AIMessage(content="done"))
|
||||
result = middleware.wrap_model_call(request(text, metadata, {}), handler)
|
||||
handler.assert_called_once()
|
||||
assert ("skill_usage" in result.additional_kwargs) == text.startswith("/")
|
||||
|
||||
|
||||
def test_idless_activation_identity_includes_selected_skills():
|
||||
key = SkillActivationMiddleware._activation_run_key
|
||||
first = HumanMessage(content="same text", additional_kwargs={"skill_references": ["skill-0"]})
|
||||
second = HumanMessage(content="same text", additional_kwargs={"skill_references": ["skill-1"]})
|
||||
assert key(first) != key(second)
|
||||
assert key(first) == key(first.model_copy())
|
||||
|
||||
|
||||
def test_plural_source_round_trip_and_single_path_compatibility():
|
||||
context = {}
|
||||
paths = ("/mnt/skills/custom/one/SKILL.md", "/mnt/skills/custom/two/SKILL.md")
|
||||
write_slash_skill_source_paths(context, (*paths, paths[0]), owner_token=OWNER)
|
||||
assert read_slash_skill_source_paths(context, owner_token=OWNER) == paths
|
||||
assert read_slash_skill_source_path(context, owner_token=OWNER) == paths[0]
|
||||
assert read_slash_skill_source_paths(context, owner_token="foreign") == ()
|
||||
write_slash_skill_source_path(context, paths[1], owner_token=OWNER)
|
||||
assert read_slash_skill_source_paths(context, owner_token=OWNER) == (paths[1],)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("paths", [None, [], "first", {}, ["other"], ["first", ""], ["first", 1], ["first"] * 17])
|
||||
def test_plural_source_malformed_list_fails_closed(paths):
|
||||
context = {_SLASH_SECRET_SOURCE_KEY: {"path": "first", "paths": paths, "owner_token": OWNER}}
|
||||
assert read_slash_skill_source_paths(context, owner_token=OWNER) == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("denied", [False, True])
|
||||
@pytest.mark.parametrize("fail_closed", [False, True])
|
||||
def test_async_inline_batch_authorizes_every_selection_on_the_event_loop(catalog, denied, fail_closed):
|
||||
from deerflow.authz.provider import AuthzDecision
|
||||
from deerflow.authz.skill_filter import ResolvedSkillAuthorization
|
||||
|
||||
middleware, _, _ = catalog
|
||||
provider = SimpleNamespace(authorize=Mock(side_effect=RuntimeError("sync API must not run")))
|
||||
calls = []
|
||||
|
||||
async def authorize(value):
|
||||
calls.append(value.target)
|
||||
return AuthzDecision(allow=not (denied and value.target == "skill-1"))
|
||||
|
||||
provider.aauthorize = authorize
|
||||
middleware._skill_authorization = ResolvedSkillAuthorization(provider=provider, principal=None, fail_closed=fail_closed)
|
||||
context = {"secrets": {"KEY_0": "zero", "KEY_1": "one"}}
|
||||
original = request("compare", {"skill_references": ["skill-0", "skill-1"]}, context)
|
||||
seen = []
|
||||
|
||||
async def handler(value):
|
||||
seen.append(value)
|
||||
return AIMessage(content="done")
|
||||
|
||||
result = asyncio.run(middleware.awrap_model_call(original, handler))
|
||||
assert calls == ["skill-0", "skill-1"]
|
||||
provider.authorize.assert_not_called()
|
||||
if denied:
|
||||
assert not seen
|
||||
assert "not available" in result.content
|
||||
assert _SLASH_SECRET_SOURCE_KEY not in context
|
||||
assert ACTIVE_SECRETS_CONTEXT_KEY not in context
|
||||
else:
|
||||
assert len(seen) == 1
|
||||
assert context[ACTIVE_SECRETS_CONTEXT_KEY] == {"KEY_0": "zero", "KEY_1": "one"}
|
||||
assert [entry["name"] for entry in result.additional_kwargs["skill_usages"]] == ["skill-0", "skill-1"]
|
||||
|
||||
|
||||
def test_each_inline_activation_excludes_its_stale_entry_secret_snapshot(catalog):
|
||||
from deerflow.authz.provider import AuthzDecision
|
||||
from deerflow.authz.skill_filter import ResolvedSkillAuthorization
|
||||
|
||||
middleware, skills, factory = catalog
|
||||
old = skills[1]
|
||||
skills[1] = replace(old, required_secrets=(SecretRequirement(name="OLD_KEY"),))
|
||||
calls = []
|
||||
|
||||
async def authorize(value):
|
||||
calls.append(value.target)
|
||||
# The prepass already captured OLD_KEY; activation and binding must use
|
||||
# the new declaration, including for the second explicit selection.
|
||||
skills[1] = old
|
||||
return AuthzDecision(allow=True)
|
||||
|
||||
provider = SimpleNamespace(aauthorize=authorize, authorize=Mock(side_effect=RuntimeError("sync API must not run")))
|
||||
middleware._skill_authorization = ResolvedSkillAuthorization(provider=provider, principal=None, fail_closed=True)
|
||||
context = {"secrets": {"KEY_0": "zero", "KEY_1": "one", "OLD_KEY": "stale"}}
|
||||
original = request("compare", {"skill_references": ["skill-0", "skill-1"]}, context)
|
||||
original.state["skill_context"] = [{"name": old.name, "path": old.get_container_file_path()}]
|
||||
|
||||
async def handler(value):
|
||||
return AIMessage(content="done")
|
||||
|
||||
asyncio.run(middleware.awrap_model_call(original, handler))
|
||||
assert calls == ["skill-0", "skill-1"]
|
||||
provider.authorize.assert_not_called()
|
||||
assert context[ACTIVE_SECRETS_CONTEXT_KEY] == {"KEY_0": "zero", "KEY_1": "one"}
|
||||
assert factory.called
|
||||
@ -917,3 +917,30 @@ def test_active_policy_load_failure_fails_closed_to_framework_tools():
|
||||
"tool_search",
|
||||
"describe_skill",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("async_call", [False, True])
|
||||
def test_explicit_multi_skill_policy_unions_paths_and_refreshes_cached_decision(async_call):
|
||||
from deerflow.runtime.secret_context import write_slash_skill_source_paths
|
||||
|
||||
first = _skill("research", ["web_search"])
|
||||
second = _skill("writer", ["write_file"])
|
||||
context = {}
|
||||
middleware = _middleware([first, second])
|
||||
tools = [NamedTool(name) for name in ["read_file", "web_search", "write_file", "task"]]
|
||||
request = ModelRequestStub(tools, context=context)
|
||||
|
||||
def invoke():
|
||||
async def handler(value):
|
||||
return value
|
||||
|
||||
return asyncio.run(middleware.awrap_model_call(request, handler)) if async_call else middleware.wrap_model_call(request, lambda value: value)
|
||||
|
||||
write_slash_skill_source_paths(context, (first.get_container_file_path(), second.get_container_file_path()), owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
||||
assert _tool_names(invoke()) == ["read_file", "web_search", "write_file"]
|
||||
signature = context[SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY].copy()
|
||||
write_slash_skill_source_paths(context, (first.get_container_file_path(),), owner_token=_SLASH_SOURCE_OWNER_TOKEN)
|
||||
assert _tool_names(invoke()) == ["read_file", "web_search"]
|
||||
assert context[SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY] != signature
|
||||
blocked = middleware.wrap_tool_call(ToolRequestStub("write_file", context=context), lambda _: pytest.fail("removed path must not authorize execution"))
|
||||
assert "not allowed" in blocked.content.lower() or "blocked" in blocked.content.lower()
|
||||
|
||||
BIN
docs/images/composer-mentions.png
Normal file
BIN
docs/images/composer-mentions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@ -302,3 +302,20 @@ Plugin page `openConversation(threadId)` resolves authenticated thread metadata
|
||||
with `pathOfThread`; do not let plugins hardcode default-agent routes. The page's
|
||||
abort signal fences late navigation after unmount/account changes. Synchronous
|
||||
conversation-action callbacks reject Promise returns while consuming rejections.
|
||||
|
||||
### Composer references
|
||||
|
||||
`components/workspace/mentions/` owns cursor-local `@` detection, the picker,
|
||||
and atomic references. Render labels as DOM text, never HTML; canonical tokens
|
||||
preserve draft positions. Submission expands tokens to `@label` and sends up to
|
||||
16 unique skill IDs in `additional_kwargs.skill_references`; the backend checks
|
||||
each against the user registry and agent allowlist. Legacy slash input remains.
|
||||
Project files require confirmed `additional_kwargs.files`. Conversation context
|
||||
is reconciled from tokens against the current capability and limit. Only successful
|
||||
discovery may flatten references; pending/errors preserve IDs and block reference
|
||||
sends, with retry on failure. Polish restores whole labels longest-first in one
|
||||
pass. Confirmed thread creation seeds metadata before migration; references stay
|
||||
locked during attachment. Deleting an object removes its context; undo restores
|
||||
references already attached to the draft. Labels grant no read authority. IME,
|
||||
caret, thread changes and attachment fences apply to editor and picker. The `@`
|
||||
and attachment buttons share the picker.
|
||||
|
||||
@ -71,7 +71,7 @@ import {
|
||||
selectContextUsage,
|
||||
threadTokenUsageToTokenUsage,
|
||||
} from "@/core/threads/token-usage";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { projectIdOfThread, textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@ -513,6 +513,11 @@ export default function AgentChatPage() {
|
||||
)}
|
||||
isWelcomeMode={isWelcomeMode}
|
||||
threadId={threadId}
|
||||
projectId={
|
||||
!isNewThread && threadMetadata.data
|
||||
? projectIdOfThread(threadMetadata.data)
|
||||
: null
|
||||
}
|
||||
draftThreadId={isNewThread ? "new" : threadId}
|
||||
draftAgentName={agent_name}
|
||||
agentSkillNames={agent?.skills}
|
||||
|
||||
@ -263,7 +263,13 @@ export default function ChatPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createThread(threadId, projectParam);
|
||||
const created = await createThread(threadId, projectParam);
|
||||
// Keep confirmed membership available while the first metadata read is pending
|
||||
// or fails after the composer materializes this new project thread.
|
||||
queryClient.setQueryData(
|
||||
["thread", "metadata", threadId, false],
|
||||
created,
|
||||
);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
});
|
||||
@ -598,6 +604,7 @@ export default function ChatPage() {
|
||||
)}
|
||||
isWelcomeMode={isWelcomeMode}
|
||||
threadId={threadId}
|
||||
projectId={projectParam ?? affiliatedProjectId}
|
||||
draftThreadId={isNewThread ? "new" : threadId}
|
||||
knowledgeScopeControl={
|
||||
selectorVisible && knowledgeScope ? (
|
||||
@ -636,6 +643,16 @@ export default function ChatPage() {
|
||||
}}
|
||||
onGoalChange={setLocalGoal}
|
||||
onPrepareThread={ensureProjectThread}
|
||||
onReferenceFileAttached={() => {
|
||||
if (!isNewThread) return;
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`/workspace/chats/${threadId}`,
|
||||
);
|
||||
setThreadId(threadId);
|
||||
setIsNewThread(false);
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
canStopStreaming={canStopStreaming}
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { RESERVED_SLASH_SKILL_NAMES, type Skill } from "@/core/skills";
|
||||
import {
|
||||
parseSlashSkillReference,
|
||||
RESERVED_SLASH_SKILL_NAMES,
|
||||
type Skill,
|
||||
} from "@/core/skills";
|
||||
export {
|
||||
SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN,
|
||||
findSuggestionTemplatePlaceholder,
|
||||
@ -204,7 +208,10 @@ export function getMatchingSkillSuggestions(
|
||||
name: skill.name.toLowerCase(),
|
||||
}))
|
||||
.filter(({ skill, name }) => {
|
||||
if (!skill.enabled) {
|
||||
if (
|
||||
!skill.enabled ||
|
||||
parseSlashSkillReference(`/${skill.name}`)?.name !== skill.name
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (reservedNames.has(name) && name !== "context") {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
210
frontend/src/components/workspace/mentions/inline-references.ts
Normal file
210
frontend/src/components/workspace/mentions/inline-references.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import type { ConversationReference } from "@/core/conversation-references";
|
||||
|
||||
export const MAX_EXPLICIT_SKILLS = 16;
|
||||
|
||||
export type InlineReference = {
|
||||
kind: "skill" | "file" | "conversation";
|
||||
id: string;
|
||||
label: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
const pattern = /@\[([^\]]*)\]\(ref:(skill|file|conversation):([^)]*)\)/g;
|
||||
|
||||
export function referenceToken(
|
||||
kind: InlineReference["kind"],
|
||||
id: string,
|
||||
label: string,
|
||||
) {
|
||||
const encode = (value: string) =>
|
||||
encodeURIComponent(value).replace(
|
||||
/[!'()*]/g,
|
||||
(char) => `%${char.charCodeAt(0).toString(16)}`,
|
||||
);
|
||||
return `@[${encode(label)}](ref:${kind}:${encode(id)})`;
|
||||
}
|
||||
|
||||
export function inlineReferences(text: string): InlineReference[] {
|
||||
const result: InlineReference[] = [];
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
try {
|
||||
result.push({
|
||||
kind: match[2] as InlineReference["kind"],
|
||||
id: decodeURIComponent(match[3]!),
|
||||
label: decodeURIComponent(match[1]!),
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
} catch {
|
||||
/* Malformed pasted text remains ordinary text. */
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function readableReferences(text: string) {
|
||||
let value = text;
|
||||
for (const reference of inlineReferences(text).reverse()) {
|
||||
value =
|
||||
value.slice(0, reference.start) +
|
||||
`@${reference.label}` +
|
||||
value.slice(reference.end);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Restore display labels in one pass so replacement tokens cannot match again. */
|
||||
export function restoreReferenceLabels(original: string, rewritten: string) {
|
||||
const remaining = inlineReferences(original);
|
||||
if (!remaining.length) return rewritten;
|
||||
const labels = [...new Set(remaining.map((ref) => ref.label))]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
// Longest labels win; a short label cannot consume a longer word or slug.
|
||||
const pattern = new RegExp(
|
||||
`@(?:${labels.join("|")})(?![\\p{L}\\p{M}\\p{N}_-])`,
|
||||
"gu",
|
||||
);
|
||||
const restored = rewritten.replace(pattern, (label) => {
|
||||
const index = remaining.findIndex((ref) => `@${ref.label}` === label);
|
||||
if (index < 0) return label;
|
||||
const [ref] = remaining.splice(index, 1);
|
||||
return referenceToken(ref!.kind, ref!.id, ref!.label);
|
||||
});
|
||||
// A rewrite may omit a label; retain that explicit selection in draft order.
|
||||
return [
|
||||
...remaining.map((ref) => referenceToken(ref.kind, ref.id, ref.label)),
|
||||
restored,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function readReferenceEditor(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
|
||||
if (node instanceof HTMLElement) {
|
||||
if (node.dataset.reference) return node.dataset.reference;
|
||||
if (node.tagName === "BR") return "\n";
|
||||
}
|
||||
return Array.from(node.childNodes, readReferenceEditor).join("");
|
||||
}
|
||||
|
||||
export function referenceCaret(root: HTMLElement): number | null {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.isCollapsed || !selection.rangeCount) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!root.contains(range.endContainer)) return null;
|
||||
const before = range.cloneRange();
|
||||
before.selectNodeContents(root);
|
||||
before.setEnd(range.endContainer, range.endOffset);
|
||||
return readReferenceEditor(before.cloneContents()).length;
|
||||
}
|
||||
|
||||
export function focusReferenceAt(root: HTMLElement, offset: number) {
|
||||
root.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
let remaining = offset;
|
||||
for (const node of root.childNodes) {
|
||||
const length = readReferenceEditor(node).length;
|
||||
if (remaining <= length) {
|
||||
if (node.nodeType === Node.TEXT_NODE) range.setStart(node, remaining);
|
||||
else if (remaining === 0) range.setStartBefore(node);
|
||||
else range.setStartAfter(node);
|
||||
range.collapse(true);
|
||||
break;
|
||||
}
|
||||
remaining -= length;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
export function renderReferenceEditor(root: HTMLElement, text: string) {
|
||||
if (readReferenceEditor(root) === text) return;
|
||||
const caret = referenceCaret(root);
|
||||
const fragment = document.createDocumentFragment();
|
||||
let end = 0;
|
||||
for (const ref of inlineReferences(text)) {
|
||||
fragment.append(document.createTextNode(text.slice(end, ref.start)));
|
||||
const token = document.createElement("span");
|
||||
token.contentEditable = "false";
|
||||
token.dataset.reference = text.slice(ref.start, ref.end);
|
||||
token.dataset.referenceKind = ref.kind;
|
||||
token.dataset.testid =
|
||||
ref.kind === "file"
|
||||
? "project-attachment-chip"
|
||||
: ref.kind === "conversation"
|
||||
? "conversation-reference-chip"
|
||||
: "inline-skill-reference";
|
||||
token.className =
|
||||
"inline-flex items-baseline gap-1 align-baseline font-medium select-all " +
|
||||
(ref.kind === "skill"
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: ref.kind === "file"
|
||||
? "text-rose-600 dark:text-rose-400"
|
||||
: "text-violet-600 dark:text-violet-400");
|
||||
const icon = document.createElement("span");
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent =
|
||||
ref.kind === "skill" ? "✦" : ref.kind === "file" ? "▤" : "◉";
|
||||
token.append(icon, document.createTextNode(ref.label));
|
||||
token.setAttribute("aria-label", `@${ref.label}`);
|
||||
fragment.append(token);
|
||||
end = ref.end;
|
||||
}
|
||||
fragment.append(document.createTextNode(text.slice(end)));
|
||||
root.replaceChildren(fragment);
|
||||
if (caret !== null) focusReferenceAt(root, Math.min(caret, text.length));
|
||||
}
|
||||
|
||||
export function reconcileConversationReferences(
|
||||
text: string,
|
||||
known: ConversationReference[],
|
||||
capability: {
|
||||
enabled: boolean;
|
||||
maxReferences: number;
|
||||
isLoading: boolean;
|
||||
isSuccess: boolean;
|
||||
},
|
||||
threadId: string,
|
||||
): { text: string; references: ConversationReference[] } {
|
||||
// Only a successful discovery may destructively normalize the draft.
|
||||
// Preserve known metadata while pending/failed, including across editor changes.
|
||||
if (!capability.isSuccess || capability.isLoading) {
|
||||
const ids = new Set(
|
||||
inlineReferences(text)
|
||||
.filter((ref) => ref.kind === "conversation")
|
||||
.map((ref) => ref.id),
|
||||
);
|
||||
return { text, references: known.filter((ref) => ids.has(ref.threadId)) };
|
||||
}
|
||||
const references = new Map<string, ConversationReference>();
|
||||
const limit = capability.enabled ? capability.maxReferences : 0;
|
||||
const tokens = inlineReferences(text).filter(
|
||||
(ref) => ref.kind === "conversation",
|
||||
);
|
||||
for (const ref of tokens) {
|
||||
if (
|
||||
!ref.id ||
|
||||
ref.id === threadId ||
|
||||
references.has(ref.id) ||
|
||||
references.size >= limit
|
||||
)
|
||||
continue;
|
||||
references.set(
|
||||
ref.id,
|
||||
known.find((item) => item.threadId === ref.id) ?? {
|
||||
threadId: ref.id,
|
||||
title: ref.label,
|
||||
},
|
||||
);
|
||||
}
|
||||
// Disabled, self, and over-limit references become honest ordinary text.
|
||||
for (const ref of tokens.reverse()) {
|
||||
if (!references.has(ref.id))
|
||||
text = text.slice(0, ref.start) + `@${ref.label}` + text.slice(ref.end);
|
||||
}
|
||||
return { text, references: [...references.values()] };
|
||||
}
|
||||
420
frontend/src/components/workspace/mentions/mention-picker.tsx
Normal file
420
frontend/src/components/workspace/mentions/mention-picker.tsx
Normal file
@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
FileIcon,
|
||||
MessagesSquareIcon,
|
||||
SparklesIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type Ref,
|
||||
} from "react";
|
||||
|
||||
import type { ConversationReference } from "@/core/conversation-references";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useInfiniteProjectDocuments } from "@/core/projects/hooks";
|
||||
import type { ProjectDocument } from "@/core/projects/types";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { useInfiniteThreads } from "@/core/threads/hooks";
|
||||
import { agentNameOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { isCompositionConfirmEnter, isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { MAX_EXPLICIT_SKILLS } from "./inline-references";
|
||||
|
||||
export type MentionSelection =
|
||||
| { kind: "skill"; skill: Skill }
|
||||
| { kind: "file"; document: ProjectDocument }
|
||||
| { kind: "conversation"; reference: ConversationReference }
|
||||
| { kind: "upload" };
|
||||
export type MentionPickerHandle = {
|
||||
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
||||
};
|
||||
type Option = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
selection: MentionSelection;
|
||||
disabled?: boolean;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export function MentionPicker({
|
||||
ref,
|
||||
query,
|
||||
searchInput = false,
|
||||
skills,
|
||||
skillsLoading = false,
|
||||
skillsError,
|
||||
onRetrySkills,
|
||||
capability,
|
||||
onActiveOptionChange,
|
||||
selectedSkills,
|
||||
references,
|
||||
threadId,
|
||||
projectId,
|
||||
busy,
|
||||
error,
|
||||
onSelect,
|
||||
onClose,
|
||||
listId,
|
||||
}: {
|
||||
ref?: Ref<MentionPickerHandle>;
|
||||
query: string;
|
||||
searchInput?: boolean;
|
||||
skills: Skill[];
|
||||
skillsLoading?: boolean;
|
||||
skillsError?: unknown;
|
||||
onRetrySkills?: () => unknown;
|
||||
capability: {
|
||||
enabled: boolean;
|
||||
maxReferences: number;
|
||||
isLoading: boolean;
|
||||
error?: unknown;
|
||||
refetch?: () => unknown;
|
||||
};
|
||||
onActiveOptionChange?: (id: string | undefined) => void;
|
||||
selectedSkills?: string[];
|
||||
references: ConversationReference[];
|
||||
threadId: string;
|
||||
projectId?: string | null;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onSelect: (selection: MentionSelection) => void;
|
||||
onClose: () => void;
|
||||
listId: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const labels = t.inputBox;
|
||||
const documents = useInfiniteProjectDocuments(projectId ?? "", {
|
||||
enabled: !!projectId,
|
||||
});
|
||||
const conversations = useInfiniteThreads(undefined, {
|
||||
enabled: capability.enabled,
|
||||
});
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const compositionEndedAt = useRef(-Infinity);
|
||||
const filter = (searchInput ? search : query).toLocaleLowerCase();
|
||||
const options = useMemo(() => {
|
||||
const result: Option[] = skills
|
||||
.filter((skill) => skill.enabled)
|
||||
.map((skill) => ({
|
||||
id: `skill:${skill.name}`,
|
||||
label: skill.name,
|
||||
description: skill.description,
|
||||
selection: { kind: "skill", skill },
|
||||
selected: selectedSkills?.includes(skill.name),
|
||||
disabled:
|
||||
!selectedSkills?.includes(skill.name) &&
|
||||
new Set(selectedSkills).size >= MAX_EXPLICIT_SKILLS,
|
||||
}));
|
||||
for (const document of documents.data?.pages.flatMap(
|
||||
(page) => page.documents,
|
||||
) ?? []) {
|
||||
result.push({
|
||||
id: `file:${document.id}`,
|
||||
label: document.name,
|
||||
description: document.content_missing
|
||||
? labels.mentionUnavailable
|
||||
: undefined,
|
||||
selection: { kind: "file", document },
|
||||
disabled: document.content_missing,
|
||||
});
|
||||
}
|
||||
if (capability.enabled) {
|
||||
const seen = new Set<string>();
|
||||
for (const thread of conversations.data?.pages.flat() ?? []) {
|
||||
if (thread.thread_id === threadId || seen.has(thread.thread_id))
|
||||
continue;
|
||||
seen.add(thread.thread_id);
|
||||
const selected = references.some(
|
||||
(item) => item.threadId === thread.thread_id,
|
||||
);
|
||||
result.push({
|
||||
id: `conversation:${thread.thread_id}`,
|
||||
label: titleOfThread(thread),
|
||||
selection: {
|
||||
kind: "conversation",
|
||||
reference: {
|
||||
threadId: thread.thread_id,
|
||||
title: titleOfThread(thread),
|
||||
agentName: agentNameOfThread(thread),
|
||||
},
|
||||
},
|
||||
selected,
|
||||
disabled: !selected && references.length >= capability.maxReferences,
|
||||
});
|
||||
}
|
||||
}
|
||||
const filtered = result.filter((option) =>
|
||||
`${option.label} ${option.description ?? ""}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(filter),
|
||||
);
|
||||
filtered.push({
|
||||
id: "upload",
|
||||
label: labels.mentionUpload,
|
||||
selection: { kind: "upload" },
|
||||
});
|
||||
return filtered;
|
||||
}, [
|
||||
skills,
|
||||
selectedSkills,
|
||||
documents.data,
|
||||
conversations.data,
|
||||
capability.enabled,
|
||||
capability.maxReferences,
|
||||
threadId,
|
||||
references,
|
||||
filter,
|
||||
labels,
|
||||
]);
|
||||
const available = options.filter((option) => !option.disabled);
|
||||
const active =
|
||||
available.find((option) => option.id === activeId) ??
|
||||
(filter
|
||||
? available.find((option) => option.selection.kind !== "upload")
|
||||
: available[0]);
|
||||
const optionId = (id: string) => `${listId}-${encodeURIComponent(id)}`;
|
||||
const activeOptionId = active ? optionId(active.id) : undefined;
|
||||
useEffect(() => {
|
||||
onActiveOptionChange?.(searchInput ? undefined : activeOptionId);
|
||||
}, [activeOptionId, searchInput, onActiveOptionChange]);
|
||||
useEffect(
|
||||
() => () => onActiveOptionChange?.(undefined),
|
||||
[onActiveOptionChange],
|
||||
);
|
||||
const choose = (option: Option) => {
|
||||
if (!busy && !option.disabled) onSelect(option.selection);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
if (isCompositionConfirmEnter(event, compositionEndedAt.current)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (isIMEComposing(event)) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const index = available.findIndex((option) => option.id === active?.id);
|
||||
const next =
|
||||
available[
|
||||
(index + (event.key === "ArrowDown" ? 1 : -1) + available.length) %
|
||||
available.length
|
||||
];
|
||||
setActiveId(next?.id ?? null);
|
||||
} else if (event.key === "Enter" && !event.shiftKey && active) {
|
||||
event.preventDefault();
|
||||
choose(active);
|
||||
}
|
||||
};
|
||||
useImperativeHandle(ref, () => ({ onKeyDown }));
|
||||
useEffect(() => {
|
||||
if (activeOptionId)
|
||||
document
|
||||
.getElementById(activeOptionId)
|
||||
?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [activeOptionId]);
|
||||
const loading =
|
||||
skillsLoading ||
|
||||
capability.isLoading ||
|
||||
(!!projectId && documents.isPending) ||
|
||||
(capability.enabled && conversations.isPending);
|
||||
const failed =
|
||||
!!capability.error ||
|
||||
!!skillsError ||
|
||||
(!!projectId && documents.isError) ||
|
||||
(capability.enabled && conversations.isError);
|
||||
const groups = [
|
||||
{ kind: "skill", title: labels.mentionSkills, Icon: SparklesIcon },
|
||||
{ kind: "file", title: labels.mentionFiles, Icon: FileIcon },
|
||||
{
|
||||
kind: "conversation",
|
||||
title: labels.mentionConversations,
|
||||
Icon: MessagesSquareIcon,
|
||||
},
|
||||
{ kind: "upload", title: "", Icon: UploadIcon },
|
||||
] as const;
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="bg-popover text-popover-foreground border-border rounded-xl border p-2 shadow-lg"
|
||||
data-testid="mention-picker"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2 pb-2">
|
||||
{searchInput ? (
|
||||
<input
|
||||
aria-label={labels.mentionSearch}
|
||||
autoFocus
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={activeOptionId}
|
||||
aria-autocomplete="list"
|
||||
placeholder={labels.mentionSearch}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setActiveId(null);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onCompositionEnd={() => {
|
||||
compositionEndedAt.current = Date.now();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 text-sm font-medium">
|
||||
{labels.mentionPicker}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={labels.mentionClose}
|
||||
onClick={onClose}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
aria-label={labels.mentionPicker}
|
||||
aria-busy={busy || loading}
|
||||
className="max-h-72 overflow-y-auto"
|
||||
>
|
||||
{groups.map(({ kind, title, Icon }) => {
|
||||
const items = options.filter(
|
||||
(option) => option.selection.kind === kind,
|
||||
);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div
|
||||
key={kind}
|
||||
role="group"
|
||||
aria-label={title || labels.mentionUpload}
|
||||
>
|
||||
{title && (
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{items.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="option"
|
||||
id={optionId(option.id)}
|
||||
aria-selected={option.selected === true}
|
||||
aria-disabled={busy || option.disabled}
|
||||
disabled={busy || option.disabled}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => setActiveId(option.id)}
|
||||
onClick={() => choose(option)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm disabled:opacity-40",
|
||||
active?.id === option.id &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{option.selected && <CheckIcon className="size-4 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{options.length === 1 && !loading && !failed && (
|
||||
<p className="text-muted-foreground px-2 py-2 text-xs">
|
||||
{labels.mentionEmpty}
|
||||
</p>
|
||||
)}
|
||||
{loading && (
|
||||
<p role="status" className="px-2 py-2 text-xs">
|
||||
{labels.mentionLoading}
|
||||
</p>
|
||||
)}
|
||||
{failed && (
|
||||
<div role="alert" className="px-2 text-xs">
|
||||
{labels.mentionFailed}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (capability.error) void capability.refetch?.();
|
||||
if (skillsError) void onRetrySkills?.();
|
||||
if (projectId && documents.isError) void documents.refetch();
|
||||
if (capability.enabled && conversations.isError)
|
||||
void conversations.refetch();
|
||||
}}
|
||||
>
|
||||
{labels.mentionRetry}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(documents.hasNextPage ||
|
||||
(capability.enabled && conversations.hasNextPage)) && (
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-2 text-xs underline"
|
||||
disabled={
|
||||
documents.isFetchingNextPage || conversations.isFetchingNextPage
|
||||
}
|
||||
onClick={() => {
|
||||
if (documents.hasNextPage) void documents.fetchNextPage();
|
||||
if (capability.enabled && conversations.hasNextPage)
|
||||
void conversations.fetchNextPage();
|
||||
}}
|
||||
>
|
||||
{labels.mentionLoadMore}
|
||||
</button>
|
||||
)}
|
||||
{!projectId && (
|
||||
<p className="text-muted-foreground px-2 py-1 text-xs">
|
||||
{labels.mentionNoProject}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground px-2 py-1 text-xs">
|
||||
{labels.mentionMultipleSkills}
|
||||
</p>
|
||||
{capability.enabled && (
|
||||
<p className="text-muted-foreground px-2 py-1 text-xs">
|
||||
{labels.referenceConversationsLimit(capability.maxReferences)}
|
||||
</p>
|
||||
)}
|
||||
{busy && (
|
||||
<p role="status" className="px-2 py-1 text-xs">
|
||||
{labels.mentionAttaching}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive px-2 py-1 text-xs">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/workspace/mentions/query.ts
Normal file
14
frontend/src/components/workspace/mentions/query.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export type MentionQuery = { start: number; end: number; query: string };
|
||||
|
||||
/** Only a standalone @ at the caret opens the picker, never an email or URL. */
|
||||
export function getMentionQuery(
|
||||
text: string,
|
||||
caret: number,
|
||||
): MentionQuery | null {
|
||||
if (caret < 0 || caret > text.length) return null;
|
||||
const before = text.slice(0, caret);
|
||||
const match = /(?:^|[\s((,,。::])@([^\s@/\\]*)$/u.exec(before);
|
||||
if (!match) return null;
|
||||
const query = match[1] ?? "";
|
||||
return { start: caret - query.length - 1, end: caret, query };
|
||||
}
|
||||
@ -3,7 +3,7 @@ import { XIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Shared visual for a `/skill` activation, used both as a removable chip in the
|
||||
* Shared visual for a `@skill` activation, used both as a removable chip in the
|
||||
* composer and as a read-only chip in the chat transcript. Keeping a single
|
||||
* source of truth means the two stay in lockstep instead of drifting apart in
|
||||
* their Tailwind classes.
|
||||
@ -16,26 +16,29 @@ export function SlashSkillChip({
|
||||
className,
|
||||
onRemove,
|
||||
removeLabel,
|
||||
disabled = false,
|
||||
}: {
|
||||
name: string;
|
||||
className?: string;
|
||||
/** When provided, the chip renders as a removable button with a close icon. */
|
||||
onRemove?: () => void;
|
||||
removeLabel?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
if (onRemove) {
|
||||
return (
|
||||
<button
|
||||
aria-label={removeLabel ?? `Remove /${name}`}
|
||||
aria-label={removeLabel ?? `Remove @${name}`}
|
||||
className={cn(
|
||||
CHIP_BASE_CLASS,
|
||||
"hover:bg-primary/20 cursor-pointer gap-1 transition-colors",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 truncate">/{name}</span>
|
||||
<span className="min-w-0 truncate">@{name}</span>
|
||||
<XIcon className="text-primary/70 size-2.5 shrink-0" />
|
||||
</button>
|
||||
);
|
||||
@ -43,7 +46,7 @@ export function SlashSkillChip({
|
||||
|
||||
return (
|
||||
<span className={cn(CHIP_BASE_CLASS, "max-w-full", className)}>
|
||||
/{name}
|
||||
@{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ export function useSubagentBatchesCapability() {
|
||||
}
|
||||
|
||||
export function useConversationReferencesCapability() {
|
||||
const { data, isPending } = useQuery({
|
||||
const { data, isPending, isSuccess, error, refetch } = useQuery({
|
||||
queryKey: ["features", "conversation_references"],
|
||||
queryFn: () => fetchConversationReferencesCapability(),
|
||||
staleTime: 0,
|
||||
@ -63,9 +63,12 @@ export function useConversationReferencesCapability() {
|
||||
retry: false,
|
||||
});
|
||||
return {
|
||||
enabled: data?.enabled ?? false,
|
||||
enabled: isSuccess && (data?.enabled ?? false),
|
||||
maxReferences: data?.maxReferences ?? 0,
|
||||
isLoading: isPending,
|
||||
isSuccess,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -347,6 +347,26 @@ export const enUS: Translations = {
|
||||
|
||||
// Input Box
|
||||
inputBox: {
|
||||
mentionPicker: "Add a reference",
|
||||
mentionSearch: "Search skills, project files and conversations",
|
||||
mentionSkills: "Skills",
|
||||
mentionFiles: "Project files",
|
||||
mentionConversations: "Conversations",
|
||||
mentionUpload: "Upload a file",
|
||||
mentionEmpty: "No matching references in loaded results",
|
||||
mentionLoadMore: "Load more",
|
||||
mentionLoading: "Loading references…",
|
||||
mentionFailed: "Could not load references. Try again.",
|
||||
mentionRetry: "Retry",
|
||||
mentionAttaching: "Adding file…",
|
||||
mentionAttachFailed: "Could not add this file. Try again.",
|
||||
mentionMultipleSkills:
|
||||
"Select up to 16 skills per message; select a checked skill again to remove it.",
|
||||
mentionNoProject: "Open a project chat to reference its documents.",
|
||||
mentionRemoveSkill: "Remove skill",
|
||||
mentionUnavailable: "File unavailable",
|
||||
mentionClose: "Close references",
|
||||
|
||||
placeholder: "How can I assist you today?",
|
||||
disclaimer: "DeerFlow is AI and can make mistakes",
|
||||
createSkillPrompt:
|
||||
|
||||
@ -301,6 +301,25 @@ export interface Translations {
|
||||
|
||||
// Input Box
|
||||
inputBox: {
|
||||
mentionPicker: string;
|
||||
mentionSearch: string;
|
||||
mentionSkills: string;
|
||||
mentionFiles: string;
|
||||
mentionConversations: string;
|
||||
mentionUpload: string;
|
||||
mentionEmpty: string;
|
||||
mentionLoadMore: string;
|
||||
mentionLoading: string;
|
||||
mentionFailed: string;
|
||||
mentionRetry: string;
|
||||
mentionAttaching: string;
|
||||
mentionAttachFailed: string;
|
||||
mentionMultipleSkills: string;
|
||||
mentionNoProject: string;
|
||||
mentionRemoveSkill: string;
|
||||
mentionUnavailable: string;
|
||||
mentionClose: string;
|
||||
|
||||
placeholder: string;
|
||||
disclaimer: string;
|
||||
createSkillPrompt: string;
|
||||
|
||||
@ -330,6 +330,26 @@ export const zhCN: Translations = {
|
||||
|
||||
// Input Box
|
||||
inputBox: {
|
||||
mentionPicker: "添加引用",
|
||||
mentionSearch: "搜索技能、项目文件和对话",
|
||||
mentionSkills: "技能",
|
||||
mentionFiles: "项目文件",
|
||||
mentionConversations: "对话",
|
||||
mentionUpload: "上传文件",
|
||||
mentionEmpty: "已加载的内容中没有匹配的引用",
|
||||
mentionLoadMore: "加载更多",
|
||||
mentionLoading: "正在加载引用…",
|
||||
mentionFailed: "引用加载失败,请重试。",
|
||||
mentionRetry: "重试",
|
||||
mentionAttaching: "正在添加文件…",
|
||||
mentionAttachFailed: "文件添加失败,请重试。",
|
||||
mentionMultipleSkills:
|
||||
"每条消息最多选择 16 个技能,再次选择已勾选技能可移除。",
|
||||
mentionNoProject: "在项目对话中可引用该项目的文档。",
|
||||
mentionRemoveSkill: "移除技能",
|
||||
mentionUnavailable: "文件不可用",
|
||||
mentionClose: "关闭引用面板",
|
||||
|
||||
placeholder: "今天我能为你做些什么?",
|
||||
disclaimer: "内容由AI生成,重要信息请务必核查",
|
||||
createSkillPrompt:
|
||||
|
||||
@ -82,6 +82,9 @@ export type PromoteThreadFileInput = {
|
||||
* the ingested thread upload the composer adds to its attachment list.
|
||||
*/
|
||||
export type AttachProjectDocumentResult = {
|
||||
/** Client draft provenance for deduplicating picker selections. */
|
||||
source_document_id?: string;
|
||||
source_project_id?: string;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
virtual_path: string;
|
||||
|
||||
@ -5,12 +5,12 @@ import { enableSkill, SkillRequestError, uploadSkillArchive } from "./api";
|
||||
import { loadSkills } from ".";
|
||||
|
||||
export function useSkills() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
const { data, isLoading, isFetching, error, refetch } = useQuery({
|
||||
queryKey: ["skills"],
|
||||
queryFn: () => loadSkills(),
|
||||
retry: (count, err) => !(err instanceof SkillRequestError) && count < 3,
|
||||
});
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
return { skills: data ?? [], isLoading, isFetching, error, refetch };
|
||||
}
|
||||
|
||||
export function useEnableSkill() {
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import {
|
||||
readConversationReferences,
|
||||
type ConversationReference,
|
||||
} from "@/core/conversation-references";
|
||||
|
||||
const COMPOSER_DRAFT_VERSION = 1;
|
||||
const COMPOSER_DRAFT_PREFIX = "deerflow:composer-draft:v1";
|
||||
|
||||
export type ComposerDraft = {
|
||||
text: string;
|
||||
skillName: string | null;
|
||||
conversationReferences?: ConversationReference[];
|
||||
};
|
||||
|
||||
export type ComposerDraftStorage = Pick<
|
||||
@ -56,6 +62,7 @@ export function readComposerDraft(
|
||||
version?: unknown;
|
||||
text?: unknown;
|
||||
skillName?: unknown;
|
||||
conversationReferences?: unknown;
|
||||
};
|
||||
if (
|
||||
parsed.version !== COMPOSER_DRAFT_VERSION ||
|
||||
@ -65,7 +72,23 @@ export function readComposerDraft(
|
||||
return null;
|
||||
}
|
||||
|
||||
const references = Array.isArray(parsed.conversationReferences)
|
||||
? readConversationReferences({
|
||||
conversation_references: parsed.conversationReferences.map(
|
||||
(item: unknown) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const value = item as Record<string, unknown>;
|
||||
return {
|
||||
thread_id: value.threadId,
|
||||
title: value.title,
|
||||
agent_name: value.agentName,
|
||||
};
|
||||
},
|
||||
),
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...(references.length ? { conversationReferences: references } : {}),
|
||||
text: parsed.text,
|
||||
skillName: parsed.skillName,
|
||||
};
|
||||
@ -83,7 +106,11 @@ export function writeComposerDraft(
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
if (!draft.text && !draft.skillName) {
|
||||
if (
|
||||
!draft.text &&
|
||||
!draft.skillName &&
|
||||
!draft.conversationReferences?.length
|
||||
) {
|
||||
storage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
@ -94,6 +121,9 @@ export function writeComposerDraft(
|
||||
version: COMPOSER_DRAFT_VERSION,
|
||||
text: draft.text,
|
||||
skillName: draft.skillName,
|
||||
...(draft.conversationReferences?.length
|
||||
? { conversationReferences: draft.conversationReferences }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
@ -124,6 +154,7 @@ export function resolveComposerDraft(
|
||||
}
|
||||
|
||||
return {
|
||||
...draft,
|
||||
text: `/${draft.skillName}${draft.text ? ` ${draft.text}` : ""}`,
|
||||
skillName: null,
|
||||
};
|
||||
|
||||
@ -3438,6 +3438,7 @@ export function useInfiniteThreads(
|
||||
sortOrder: "desc",
|
||||
select: ["thread_id", "updated_at", "values", "metadata"],
|
||||
},
|
||||
{ enabled = true }: { enabled?: boolean } = {},
|
||||
) {
|
||||
const apiClient = getAPIClient();
|
||||
return useInfiniteQuery<
|
||||
@ -3459,6 +3460,7 @@ export function useInfiniteThreads(
|
||||
getNextPageParam: (lastPage, allPages) =>
|
||||
getInfiniteThreadsNextPageParam(lastPage, allPages),
|
||||
refetchOnWindowFocus: false,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -321,7 +321,7 @@ test.describe("Chat workspace", () => {
|
||||
await textarea.fill("/dat");
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect(page.getByText("/data-analysis")).toBeVisible();
|
||||
await expect(page.getByText("@data-analysis")).toBeVisible();
|
||||
const skillInput = page.getByRole("textbox", {
|
||||
name: /how can i assist you/i,
|
||||
});
|
||||
@ -334,7 +334,7 @@ test.describe("Chat workspace", () => {
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByText("/data-analysis")).toBeVisible();
|
||||
await expect(page.getByText("@data-analysis")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("textbox", {
|
||||
name: /how can i assist you/i,
|
||||
@ -452,6 +452,22 @@ test.describe("Chat workspace", () => {
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// A new user edit must still save, even when it repeats the accepted text.
|
||||
await page
|
||||
.getByPlaceholder(/how can i assist you/i)
|
||||
.fill("Send this immediately");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => Object.values(window.sessionStorage).join("\n")),
|
||||
)
|
||||
.toContain("Send this immediately");
|
||||
await page.getByPlaceholder(/how can i assist you/i).fill("");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => Object.values(window.sessionStorage).join("\n")),
|
||||
)
|
||||
.not.toContain("Send this immediately");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue(
|
||||
"",
|
||||
@ -650,7 +666,7 @@ test.describe("Chat workspace", () => {
|
||||
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect(page.getByText("/data-analysis")).toBeVisible();
|
||||
await expect(page.getByText("@data-analysis")).toBeVisible();
|
||||
const skillInput = page.getByRole("textbox", {
|
||||
name: /how can i assist you/i,
|
||||
});
|
||||
@ -688,7 +704,7 @@ test.describe("Chat workspace", () => {
|
||||
page.getByRole("option", { name: /data-analysis/i }),
|
||||
).toBeVisible();
|
||||
await textarea.press("Enter");
|
||||
await expect(page.getByText("/data-analysis")).toBeVisible();
|
||||
await expect(page.getByText("@data-analysis")).toBeVisible();
|
||||
|
||||
const skillInput = page.getByRole("textbox", {
|
||||
name: /how can i assist you/i,
|
||||
@ -712,8 +728,8 @@ test.describe("Chat workspace", () => {
|
||||
|
||||
await skillInput.press("Enter");
|
||||
|
||||
await expect(page.getByText("/frontend-design")).toBeVisible();
|
||||
await expect(page.getByText("/data-analysis")).toBeHidden();
|
||||
await expect(page.getByText("@frontend-design")).toBeVisible();
|
||||
await expect(page.getByText("@data-analysis")).toBeHidden();
|
||||
|
||||
await skillInput.pressSequentially("polish the composer");
|
||||
await skillInput.press("Enter");
|
||||
@ -778,7 +794,7 @@ test.describe("Chat workspace", () => {
|
||||
page.getByRole("option", { name: /data-analysis/i }),
|
||||
).toBeVisible();
|
||||
await textarea.press("Enter");
|
||||
await expect(page.getByText("/data-analysis")).toBeVisible();
|
||||
await expect(page.getByText("@data-analysis")).toBeVisible();
|
||||
|
||||
const skillInput = page.getByRole("textbox", {
|
||||
name: /how can i assist you/i,
|
||||
@ -934,7 +950,7 @@ test.describe("Chat workspace", () => {
|
||||
await textarea.press("ArrowDown");
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect(page.getByText("/frontend-design")).toBeVisible();
|
||||
await expect(page.getByText("@frontend-design")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: /how can i assist you/i }),
|
||||
).toBeVisible();
|
||||
|
||||
304
frontend/tests/e2e/mentions.spec.ts
Normal file
304
frontend/tests/e2e/mentions.spec.ts
Normal file
@ -0,0 +1,304 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
|
||||
|
||||
const composer = (page: Page) =>
|
||||
page.getByRole("textbox", { name: /how can i assist you/i });
|
||||
const nextRun = (page: Page) =>
|
||||
page.waitForRequest(
|
||||
(r) => r.method() === "POST" && r.url().includes("/runs/stream"),
|
||||
);
|
||||
test.beforeEach(async ({ page }) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "Writer brief",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{ name: "research", description: "Research a topic", enabled: true },
|
||||
{ name: "writing", description: "Write a report", enabled: true },
|
||||
],
|
||||
projects: [{ id: "proj-1", name: "Research project" }],
|
||||
projectDocuments: [
|
||||
{ id: "doc-1", project_id: "proj-1", name: "report.pdf", size_bytes: 10 },
|
||||
],
|
||||
});
|
||||
await page.route("**/api/features", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
agents_api: { enabled: true },
|
||||
browser_control: { enabled: false },
|
||||
mcp_tasks: { enabled: false },
|
||||
knowledge_base: { scope_selection_enabled: false },
|
||||
conversation_references: { enabled: true, max_references: 3 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
test("mid-draft skill selection preserves both text and caret and submits the activation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/workspace/chats/new");
|
||||
const input = composer(page);
|
||||
await input.fill("Use carefully");
|
||||
await input.press("Home");
|
||||
for (let i = 0; i < 4; i++) await input.press("ArrowRight");
|
||||
await input.pressSequentially("@res");
|
||||
await expect(
|
||||
page.getByRole("option", { name: "research Research a topic" }),
|
||||
).toBeVisible();
|
||||
await input.press("Enter");
|
||||
await expect(page.getByTestId("inline-skill-reference")).toBeVisible();
|
||||
await expect(input).toHaveText("Use ✦research carefully");
|
||||
await input.pressSequentially("it");
|
||||
await expect(input).toHaveText("Use ✦research it carefully");
|
||||
const request = nextRun(page);
|
||||
await input.press("Enter");
|
||||
expect(
|
||||
JSON.stringify((await request).postDataJSON().input.messages),
|
||||
).toContain("Use @research it carefully");
|
||||
});
|
||||
test("keeps multiple inline skills and restores their positions on reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/workspace/chats/new");
|
||||
const input = composer(page);
|
||||
await input.fill("@res");
|
||||
await page.getByRole("option", { name: "research Research a topic" }).click();
|
||||
await input.pressSequentially("Keep this @wri");
|
||||
await page.getByRole("option", { name: "writing Write a report" }).click();
|
||||
await expect(input).toHaveText("✦research Keep this ✦writing ");
|
||||
await page.reload();
|
||||
await expect(input).toHaveText("✦research Keep this ✦writing ");
|
||||
const request = nextRun(page);
|
||||
await input.press("Enter");
|
||||
const message = (await request).postDataJSON().input.messages.at(-1);
|
||||
expect(message.additional_kwargs.skill_references).toEqual([
|
||||
"research",
|
||||
"writing",
|
||||
]);
|
||||
});
|
||||
test("emails and dismissed queries stay literal", async ({ page }) => {
|
||||
await page.goto("/workspace/chats/new");
|
||||
const input = composer(page);
|
||||
await input.fill("a@example.com");
|
||||
await expect(page.getByTestId("mention-picker")).toBeHidden();
|
||||
await input.fill("@res");
|
||||
await expect(page.getByTestId("mention-picker")).toBeVisible();
|
||||
await input.press("Escape");
|
||||
await expect(page.getByTestId("mention-picker")).toBeHidden();
|
||||
await expect(input).toHaveValue("@res");
|
||||
});
|
||||
test("attaches a document to a new project thread and submits its confirmed file", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/workspace/chats/new?project=proj-1");
|
||||
const input = composer(page);
|
||||
await input.fill("Read @report");
|
||||
await page.getByRole("option", { name: "report.pdf" }).click();
|
||||
await expect(page.getByTestId("project-attachment-chip")).toContainText(
|
||||
"report.pdf",
|
||||
);
|
||||
await expect(input).toHaveText("Read ▤report.pdf ");
|
||||
await expect(page).not.toHaveURL(/\/new/);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("project-attachment-chip")).toContainText(
|
||||
"report.pdf",
|
||||
);
|
||||
await expect(input).toHaveText("Read ▤report.pdf ");
|
||||
const request = nextRun(page);
|
||||
await input.press("Enter");
|
||||
expect(
|
||||
JSON.stringify((await request).postDataJSON().input.messages),
|
||||
).toContain("/mnt/user-data/uploads/report.pdf");
|
||||
});
|
||||
test("restores a conversation reference and sends its run-scoped ID", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/workspace/chats/new");
|
||||
const input = composer(page);
|
||||
await input.fill("Summarize @Writer");
|
||||
await page.getByRole("option", { name: "Writer brief" }).click();
|
||||
await expect(page.getByTestId("conversation-reference-chip")).toContainText(
|
||||
"Writer brief",
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("conversation-reference-chip")).toContainText(
|
||||
"Writer brief",
|
||||
);
|
||||
const request = nextRun(page);
|
||||
await input.press("Enter");
|
||||
expect(
|
||||
(await request).postDataJSON().context.conversation_references,
|
||||
).toEqual([MOCK_THREAD_ID]);
|
||||
});
|
||||
test("mouse upload entry and mobile picker stay usable", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/workspace/chats/new");
|
||||
await page.getByTestId("mention-button").click();
|
||||
const picker = page.getByTestId("mention-picker");
|
||||
await expect(picker).toBeVisible();
|
||||
const box = await picker.boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
|
||||
await page.screenshot({ path: testInfo.outputPath("mentions-mobile.png") });
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page.getByRole("option", { name: "Upload a file" }).click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({
|
||||
name: "note.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("hello"),
|
||||
});
|
||||
await expect(page.getByText("note.txt", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
for (const metadataState of ["pending", "failed"] as const) {
|
||||
test(`keeps confirmed project files after materialization when metadata is ${metadataState}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(
|
||||
/\/api\/(?:langgraph\/)?threads\/[^/]+$/,
|
||||
async (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback();
|
||||
if (metadataState === "pending") await pending;
|
||||
return route.fulfill({
|
||||
status: 503,
|
||||
json: { detail: "metadata unavailable" },
|
||||
});
|
||||
},
|
||||
);
|
||||
try {
|
||||
await page.goto("/workspace/chats/new?project=proj-1");
|
||||
await composer(page).fill("Read @report");
|
||||
await page.getByRole("option", { name: "report.pdf" }).click();
|
||||
await expect(page).not.toHaveURL(/\/new/);
|
||||
await expect(page.getByTestId("project-attachment-chip")).toBeVisible();
|
||||
await page.getByTestId("mention-button").click();
|
||||
await expect(
|
||||
page.getByRole("option", { name: "report.pdf" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Open a project chat to reference project files."),
|
||||
).toBeHidden();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const enabledAfterRetry of [true, false]) {
|
||||
test(`preserves references across a failed discovery and retries to enabled=${enabledAfterRetry}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/workspace/chats/new");
|
||||
const input = composer(page);
|
||||
await input.fill("Summarize @Writer");
|
||||
await page.getByRole("option", { name: "Writer brief" }).click();
|
||||
await expect(page.getByTestId("conversation-reference-chip")).toBeVisible();
|
||||
let failed = true;
|
||||
await page.route("**/api/features", (route) =>
|
||||
failed
|
||||
? route.fulfill({ status: 503, body: "Temporarily unavailable" })
|
||||
: enabledAfterRetry
|
||||
? route.fallback()
|
||||
: route.fulfill({
|
||||
json: {
|
||||
agents_api: { enabled: true },
|
||||
conversation_references: { enabled: false, max_references: 0 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.reload();
|
||||
const retry = page.getByTestId("retry-conversation-capability");
|
||||
await expect(retry).toBeVisible();
|
||||
await expect(page.getByTestId("conversation-reference-chip")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Submit", exact: true }),
|
||||
).toBeDisabled();
|
||||
await input.press("End");
|
||||
await input.pressSequentially("after retry");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
Object.values(sessionStorage).some(
|
||||
(value) =>
|
||||
value.includes("after retry") &&
|
||||
value.includes("ref:conversation:"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.reload();
|
||||
await expect(retry).toBeVisible();
|
||||
await expect(page.getByTestId("conversation-reference-chip")).toBeVisible();
|
||||
failed = false;
|
||||
await retry.click();
|
||||
await expect(retry).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Submit", exact: true }),
|
||||
).toBeEnabled();
|
||||
if (enabledAfterRetry)
|
||||
await expect(
|
||||
page.getByTestId("conversation-reference-chip"),
|
||||
).toBeVisible();
|
||||
else
|
||||
await expect(
|
||||
page.getByTestId("conversation-reference-chip"),
|
||||
).toBeHidden();
|
||||
const request = nextRun(page);
|
||||
await input.press("Enter");
|
||||
const body = (await request).postDataJSON();
|
||||
expect(body.context.conversation_references ?? []).toEqual(
|
||||
enabledAfterRetry ? [MOCK_THREAD_ID] : [],
|
||||
);
|
||||
expect(JSON.stringify(body.input.messages)).toContain("after retry");
|
||||
});
|
||||
}
|
||||
|
||||
test("polish preserves reordered overlapping reference labels and their IDs", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/input-polish", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
rewritten_text: "Use @research-tools first, then @research.",
|
||||
changed: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto("/workspace/chats/new");
|
||||
const input = composer(page);
|
||||
await input.fill(
|
||||
"Use @[research](ref:skill:research) after @[research-tools](ref:skill:research-tools)",
|
||||
);
|
||||
await expect(page.getByTestId("inline-skill-reference")).toHaveCount(2);
|
||||
await page.getByTestId("polish-input-button").click();
|
||||
await expect(input).toHaveText("Use ✦research-tools first, then ✦research.");
|
||||
await expect(
|
||||
page.getByTestId("inline-skill-reference").first(),
|
||||
).toHaveAttribute(
|
||||
"data-reference",
|
||||
"@[research-tools](ref:skill:research-tools)",
|
||||
);
|
||||
const request = nextRun(page);
|
||||
await input.press("Enter");
|
||||
const message = (await request).postDataJSON().input.messages.at(-1);
|
||||
expect(message.additional_kwargs.skill_references).toEqual([
|
||||
"research-tools",
|
||||
"research",
|
||||
]);
|
||||
expect(JSON.stringify(message)).toContain(
|
||||
"Use @research-tools first, then @research.",
|
||||
);
|
||||
});
|
||||
@ -113,9 +113,7 @@ describe("InputBox agent skill draft hydration", () => {
|
||||
agentSkillNames: undefined,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Remove /research" }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull();
|
||||
expect(window.sessionStorage.getItem(draftKey)).toContain(
|
||||
'"skillName":"research"',
|
||||
);
|
||||
@ -160,9 +158,7 @@ describe("InputBox agent skill draft hydration", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove /research" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByRole("textbox").textContent).toBe("topic");
|
||||
});
|
||||
@ -179,9 +175,7 @@ describe("InputBox agent skill draft hydration", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove /research" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -518,3 +518,14 @@ describe("findSuggestionTemplatePlaceholder", () => {
|
||||
expect(findSuggestionTemplatePlaceholder("no placeholder here")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("never offers filesystem-discovered names outside the activation grammar", () => {
|
||||
const names = ["research", "a--b", "a_b", "Research", "a-", "context"];
|
||||
expect(
|
||||
getMatchingSkillSuggestions(
|
||||
names.map((name) => makeSkill(name)),
|
||||
"",
|
||||
[],
|
||||
).map((item) => item.name),
|
||||
).toEqual(["research", "context"]);
|
||||
});
|
||||
|
||||
@ -0,0 +1,742 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { referenceToken } from "@/components/workspace/mentions/inline-references";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { AuthProvider } from "@/core/auth/AuthProvider";
|
||||
import { DEFAULT_LOCALE } from "@/core/i18n";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import {
|
||||
buildComposerDraftKey,
|
||||
writeComposerDraft,
|
||||
} from "@/core/threads/composer-draft";
|
||||
|
||||
rs.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
|
||||
usePathname: () => "/workspace",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
rs.mock("@/core/models/hooks", () => ({
|
||||
useModels: () => ({
|
||||
models: [],
|
||||
tokenUsageEnabled: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch: rs.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const skillsQuery = {
|
||||
isLoading: false,
|
||||
error: null as Error | null,
|
||||
refetch: rs.fn(),
|
||||
};
|
||||
rs.mock("@/core/skills/hooks", () => ({
|
||||
useSkills: () => ({
|
||||
skills: [
|
||||
{
|
||||
name: "research",
|
||||
description: "Research a topic",
|
||||
category: "general",
|
||||
license: "MIT",
|
||||
enabled: true,
|
||||
editable: false,
|
||||
},
|
||||
],
|
||||
...skillsQuery,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Each test gets its own thread id: the composer persists the selected skill in
|
||||
// a debounced, thread-scoped draft, and a shared id would let the first test's
|
||||
// selection land in the second test's storage key.
|
||||
function renderComposer(
|
||||
threadId = "mentions-thread",
|
||||
onSubmit = rs.fn(),
|
||||
onPrepareThread = rs.fn(),
|
||||
props: Partial<ComponentProps<typeof InputBox>> = {},
|
||||
isMock = true,
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const tree: ReactNode = (
|
||||
<I18nProvider initialLocale={DEFAULT_LOCALE}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider
|
||||
initialUser={{
|
||||
id: "user-1",
|
||||
email: "user@example.test",
|
||||
system_role: "user",
|
||||
needs_setup: false,
|
||||
oauth_provider: null,
|
||||
}}
|
||||
>
|
||||
<ThreadContext.Provider
|
||||
value={{ thread: { messages: [] } as never, isMock }}
|
||||
>
|
||||
<PromptInputProvider>
|
||||
<InputBox
|
||||
threadId={threadId}
|
||||
projectId="project-1"
|
||||
onSubmit={onSubmit}
|
||||
onPrepareThread={onPrepareThread}
|
||||
status="ready"
|
||||
context={{ mode: "flash" } as never}
|
||||
{...props}
|
||||
/>
|
||||
</PromptInputProvider>
|
||||
</ThreadContext.Provider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
return render(tree);
|
||||
}
|
||||
|
||||
const attach = rs.fn();
|
||||
const capability = {
|
||||
enabled: true,
|
||||
maxReferences: 3,
|
||||
isLoading: false,
|
||||
isSuccess: true,
|
||||
error: null as Error | null,
|
||||
refetch: rs.fn(),
|
||||
};
|
||||
const polish = rs.fn();
|
||||
rs.mock("@/core/input-polish/api", () => ({
|
||||
polishInputDraft: (...args: unknown[]) => polish(...args),
|
||||
}));
|
||||
rs.mock("@/core/features/hooks", () => ({
|
||||
useConversationReferencesCapability: () => capability,
|
||||
}));
|
||||
rs.mock("@/core/projects/api", () => ({
|
||||
attachProjectDocument: (...args: unknown[]) => attach(...args),
|
||||
}));
|
||||
rs.mock("@/core/projects/hooks", () => ({
|
||||
useInfiniteProjectDocuments: () => ({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
documents: [
|
||||
{
|
||||
id: "doc-1",
|
||||
name: "report.pdf",
|
||||
size_bytes: 10,
|
||||
content_missing: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
rs.mock("@/core/threads/hooks", () => ({
|
||||
useInfiniteThreads: () => ({
|
||||
data: {
|
||||
pages: [
|
||||
[
|
||||
{
|
||||
thread_id: "source-1",
|
||||
values: { title: "Writer brief" },
|
||||
metadata: { agent_name: "writer" },
|
||||
},
|
||||
...[2, 3, 4].map((id) => ({
|
||||
thread_id: `source-${id}`,
|
||||
values: { title: `Brief ${id}` },
|
||||
metadata: {},
|
||||
})),
|
||||
],
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
capability.enabled = true;
|
||||
capability.maxReferences = 3;
|
||||
capability.isLoading = false;
|
||||
capability.isSuccess = true;
|
||||
capability.error = null;
|
||||
capability.refetch.mockReset();
|
||||
polish.mockReset();
|
||||
skillsQuery.isLoading = false;
|
||||
skillsQuery.error = null;
|
||||
skillsQuery.refetch.mockReset();
|
||||
attach.mockReset();
|
||||
attach.mockResolvedValue({
|
||||
filename: "report.pdf",
|
||||
size_bytes: 10,
|
||||
virtual_path: "/mnt/user-data/uploads/report.pdf",
|
||||
artifact_url: "/artifact",
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.sessionStorage.clear();
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
function enterMention(
|
||||
container: HTMLElement,
|
||||
text: string,
|
||||
caret = text.length,
|
||||
) {
|
||||
const input = container.querySelector("textarea");
|
||||
if (!input) {
|
||||
const editor = container.querySelector<HTMLElement>(
|
||||
'[contenteditable="true"]',
|
||||
)!;
|
||||
const node = document.createTextNode(" " + text);
|
||||
editor.append(node);
|
||||
const range = document.createRange();
|
||||
range.setStart(node, 1 + caret);
|
||||
range.collapse(true);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
window.getSelection()?.addRange(range);
|
||||
fireEvent.input(editor);
|
||||
return editor as HTMLTextAreaElement;
|
||||
}
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: text, selectionStart: caret, selectionEnd: caret },
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
describe("unified composer mentions", () => {
|
||||
it("keeps a skill inline in the middle and sends its explicit activation metadata", async () => {
|
||||
const submit = rs.fn();
|
||||
const { container } = renderComposer("skill-mention", submit);
|
||||
const input = enterMention(container, "Use @res carefully", 8);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("inline-skill-reference")).toBeTruthy(),
|
||||
);
|
||||
const editor = container.querySelector('[contenteditable="true"]')!;
|
||||
await waitFor(() =>
|
||||
expect(editor.textContent).toBe("Use ✦research carefully"),
|
||||
);
|
||||
fireEvent.keyDown(editor, { key: "Enter" });
|
||||
await waitFor(() => expect(submit).toHaveBeenCalled());
|
||||
expect(submit.mock.calls[0]![0].text).toBe("Use @research carefully");
|
||||
expect(submit.mock.calls[0]![1].additionalKwargs.skill_references).toEqual([
|
||||
"research",
|
||||
]);
|
||||
});
|
||||
it("leaves emails and cancelled or composing queries as text", () => {
|
||||
const { container } = renderComposer();
|
||||
enterMention(container, "a@example.com");
|
||||
expect(screen.queryByTestId("mention-picker")).toBeNull();
|
||||
const input = enterMention(container, "@res");
|
||||
fireEvent.keyDown(input, { key: "Enter", keyCode: 229 });
|
||||
expect(input.value).toBe("@res");
|
||||
expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull();
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(screen.queryByTestId("mention-picker")).toBeNull();
|
||||
expect(input.value).toBe("@res");
|
||||
});
|
||||
it("prepares the project thread, attaches once, and sends the confirmed file", async () => {
|
||||
const submit = rs.fn();
|
||||
const prepare = rs.fn();
|
||||
const { container } = renderComposer("file-mention", submit, prepare);
|
||||
enterMention(container, "Read @report");
|
||||
fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("project-attachment-chip")).toBeTruthy(),
|
||||
);
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
expect(attach).toHaveBeenCalledWith("project-1", "doc-1", "file-mention");
|
||||
enterMention(container, "Read @report");
|
||||
fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
|
||||
expect(attach).toHaveBeenCalledTimes(1);
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await waitFor(() => expect(submit).toHaveBeenCalled());
|
||||
expect(submit.mock.calls[0]![1].additionalKwargs.files).toEqual([
|
||||
{
|
||||
filename: "report.pdf",
|
||||
size: 10,
|
||||
path: "/mnt/user-data/uploads/report.pdf",
|
||||
status: "uploaded",
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("keeps the query after an attachment error and permits retry", async () => {
|
||||
attach.mockRejectedValueOnce(new Error("offline"));
|
||||
const { container } = renderComposer();
|
||||
const input = enterMention(container, "Read @report");
|
||||
fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain("Could not add"),
|
||||
);
|
||||
expect(input.value).toBe("Read @report");
|
||||
expect(screen.queryByTestId("project-attachment-chip")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("project-attachment-chip")).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
it("persists a conversation reference with its draft and sends its ID and display metadata", async () => {
|
||||
const submit = rs.fn();
|
||||
const rendered = renderComposer("reference-draft", submit);
|
||||
enterMention(rendered.container, "Review @Writer");
|
||||
fireEvent.click(screen.getByRole("option", { name: "Writer brief" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(),
|
||||
);
|
||||
fireEvent(window, new Event("pagehide"));
|
||||
rendered.unmount();
|
||||
const { container } = renderComposer("reference-draft", submit);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(),
|
||||
);
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await waitFor(() => expect(submit).toHaveBeenCalled());
|
||||
expect(submit.mock.calls[0]![1].conversationReferences).toEqual([
|
||||
"source-1",
|
||||
]);
|
||||
expect(
|
||||
submit.mock.calls[0]![1].additionalKwargs.conversation_references,
|
||||
).toEqual([
|
||||
{ thread_id: "source-1", title: "Writer brief", agent_name: "writer" },
|
||||
]);
|
||||
});
|
||||
it("hides conversations when the deployment disables references", () => {
|
||||
capability.enabled = false;
|
||||
const { container } = renderComposer();
|
||||
enterMention(container, "@");
|
||||
expect(screen.queryByRole("option", { name: "Writer brief" })).toBeNull();
|
||||
});
|
||||
it("the attachment button opens the same picker", () => {
|
||||
renderComposer();
|
||||
fireEvent.click(screen.getByTestId("add-attachments-button"));
|
||||
expect(screen.getByTestId("mention-picker")).toBeTruthy();
|
||||
expect(screen.getByRole("option", { name: "Upload a file" })).toBeTruthy();
|
||||
});
|
||||
it("does not select upload automatically for an unknown query", () => {
|
||||
const { container } = renderComposer();
|
||||
enterMention(container, "@missing");
|
||||
const upload = screen.getByRole("option", { name: "Upload a file" });
|
||||
expect(upload.getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
it("ignores late attachment completion after the composer is replaced", async () => {
|
||||
let finish!: (value: unknown) => void;
|
||||
attach.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const first = renderComposer("old-thread");
|
||||
enterMention(first.container, "Read @report");
|
||||
fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
|
||||
await waitFor(() => expect(attach).toHaveBeenCalled());
|
||||
first.unmount();
|
||||
const second = renderComposer("new-thread");
|
||||
enterMention(second.container, "Keep this draft");
|
||||
finish({
|
||||
filename: "report.pdf",
|
||||
size_bytes: 10,
|
||||
virtual_path: "/mnt/user-data/uploads/report.pdf",
|
||||
artifact_url: "/artifact",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(second.container.querySelector("textarea")!.value).toBe(
|
||||
"Keep this draft",
|
||||
),
|
||||
);
|
||||
expect(screen.queryByTestId("project-attachment-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("enforces the conversation cap while allowing removal and reselection", () => {
|
||||
const { container } = renderComposer();
|
||||
for (const name of ["Writer brief", "Brief 2", "Brief 3"]) {
|
||||
enterMention(container, `Review @${name.split(" ")[0]}`);
|
||||
fireEvent.click(screen.getByRole("option", { name }));
|
||||
}
|
||||
enterMention(container, "Review @Brief");
|
||||
const fourth = screen.getByRole("option", { name: "Brief 4" });
|
||||
expect(fourth.hasAttribute("disabled")).toBe(true);
|
||||
screen.getAllByTestId("conversation-reference-chip")[0]!.remove();
|
||||
fireEvent.input(container.querySelector('[contenteditable="true"]')!);
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Brief 4" }).hasAttribute("disabled"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function saveDraft(threadId: string, text: string, extra = {}) {
|
||||
writeComposerDraft(
|
||||
window.sessionStorage,
|
||||
buildComposerDraftKey({
|
||||
userId: "user-1",
|
||||
agentName: null,
|
||||
threadId,
|
||||
}),
|
||||
{ text, skillName: null, ...extra },
|
||||
);
|
||||
}
|
||||
|
||||
describe("reference review regressions", () => {
|
||||
for (const entry of ["paste", "draft"] as const) {
|
||||
it(`resolves conversation tokens from ${entry} without another editor input`, async () => {
|
||||
const text =
|
||||
referenceToken("conversation", "source-1", "Writer brief") +
|
||||
" summarize";
|
||||
if (entry === "draft") saveDraft("raw-token", text);
|
||||
const submit = rs.fn();
|
||||
const { container } = renderComposer("raw-token", submit);
|
||||
if (entry === "paste") enterMention(container, text);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(),
|
||||
);
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await waitFor(() => expect(submit).toHaveBeenCalledTimes(1));
|
||||
expect(submit.mock.calls[0]![1].conversationReferences).toEqual([
|
||||
"source-1",
|
||||
]);
|
||||
expect(
|
||||
submit.mock.calls[0]![1].additionalKwargs.conversation_references,
|
||||
).toEqual([{ thread_id: "source-1", title: "Writer brief" }]);
|
||||
});
|
||||
}
|
||||
it("drops disabled restored context and renders its label as ordinary text", async () => {
|
||||
capability.enabled = false;
|
||||
saveDraft(
|
||||
"disabled-restore",
|
||||
referenceToken("conversation", "source-1", "Writer brief") + " summarize",
|
||||
{
|
||||
conversationReferences: [
|
||||
{ threadId: "source-1", title: "Writer brief" },
|
||||
],
|
||||
},
|
||||
);
|
||||
const submit = rs.fn();
|
||||
const { container } = renderComposer("disabled-restore", submit);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("conversation-reference-chip")).toBeNull(),
|
||||
);
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await waitFor(() => expect(submit).toHaveBeenCalled());
|
||||
expect(submit.mock.calls[0]![1].conversationReferences).toBeUndefined();
|
||||
expect(submit.mock.calls[0]![0].text).toContain("@Writer brief");
|
||||
});
|
||||
it("uses the current capability limit on paste, edits, and picker counts", async () => {
|
||||
capability.maxReferences = 4;
|
||||
const submit = rs.fn();
|
||||
const { container } = renderComposer("cap-four", submit);
|
||||
enterMention(
|
||||
container,
|
||||
[1, 2, 3, 4, 5]
|
||||
.map((id) =>
|
||||
referenceToken("conversation", `source-${id}`, `Brief ${id}`),
|
||||
)
|
||||
.join(" "),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("conversation-reference-chip")).toHaveLength(
|
||||
4,
|
||||
),
|
||||
);
|
||||
const editor = container.querySelector('[contenteditable="true"]')!;
|
||||
fireEvent.input(editor);
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await waitFor(() => expect(submit).toHaveBeenCalled());
|
||||
expect(submit.mock.calls[0]![1].conversationReferences).toEqual([
|
||||
"source-1",
|
||||
"source-2",
|
||||
"source-3",
|
||||
"source-4",
|
||||
]);
|
||||
});
|
||||
it("does not send while conversation capability is loading", async () => {
|
||||
capability.isLoading = true;
|
||||
capability.isSuccess = false;
|
||||
const submit = rs.fn();
|
||||
const { container } = renderComposer("cap-loading", submit);
|
||||
enterMention(
|
||||
container,
|
||||
referenceToken("conversation", "source-1", "Brief"),
|
||||
);
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects pasted skill batches over the cap without losing the draft", async () => {
|
||||
const submit = rs.fn();
|
||||
const { container } = renderComposer("skill-cap", submit);
|
||||
enterMention(
|
||||
container,
|
||||
Array.from({ length: 17 }, (_, i) =>
|
||||
referenceToken("skill", `skill-${i}`, `skill-${i}`),
|
||||
).join(" "),
|
||||
);
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
expect(screen.getAllByTestId("inline-skill-reference")).toHaveLength(17);
|
||||
});
|
||||
it("toggles an already selected skill instead of inserting duplicate tokens", async () => {
|
||||
const { container } = renderComposer("toggle-skill");
|
||||
enterMention(container, "@res");
|
||||
fireEvent.click(
|
||||
screen.getByRole("option", { name: "research Research a topic" }),
|
||||
);
|
||||
enterMention(container, "@res");
|
||||
const option = screen.getByRole("option", {
|
||||
name: "research Research a topic",
|
||||
});
|
||||
expect(option.getAttribute("aria-selected")).toBe("true");
|
||||
fireEvent.click(option);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("inline-skill-reference")).toBeNull(),
|
||||
);
|
||||
});
|
||||
it("reports skills loading and retries a failed skills query", () => {
|
||||
skillsQuery.error = new Error("offline");
|
||||
renderComposer("skill-error");
|
||||
fireEvent.click(screen.getByTestId("mention-button"));
|
||||
expect(screen.getByRole("alert").textContent).toContain("Could not load");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(skillsQuery.refetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("exposes the keyboard highlight separately from actual selections", () => {
|
||||
const { container } = renderComposer("aria");
|
||||
const input = enterMention(container, "@res");
|
||||
const option = screen.getByRole("option", {
|
||||
name: "research Research a topic",
|
||||
});
|
||||
expect(option.getAttribute("aria-selected")).toBe("false");
|
||||
expect(option.id).not.toBe("");
|
||||
expect(input.getAttribute("aria-activedescendant")).toBe(option.id);
|
||||
});
|
||||
it("locks legacy skill removal until attachment migration settles", async () => {
|
||||
let finish!: (value: unknown) => void;
|
||||
attach.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
saveDraft("new", "Read @report", { skillName: "research" });
|
||||
const materialized = rs.fn();
|
||||
const { container } = renderComposer("materialized", rs.fn(), rs.fn(), {
|
||||
draftThreadId: "new",
|
||||
onReferenceFileAttached: materialized,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy(),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("mention-button"));
|
||||
fireEvent.click(screen.getByRole("option", { name: "report.pdf" }));
|
||||
await waitFor(() => expect(attach).toHaveBeenCalled());
|
||||
const remove = screen.getByRole("button", { name: "Remove skill" });
|
||||
expect(remove.hasAttribute("disabled")).toBe(true);
|
||||
fireEvent.click(remove);
|
||||
finish({
|
||||
filename: "report.pdf",
|
||||
size_bytes: 10,
|
||||
virtual_path: "/mnt/user-data/uploads/report.pdf",
|
||||
artifact_url: "/artifact",
|
||||
});
|
||||
await waitFor(() => expect(materialized).toHaveBeenCalledTimes(1));
|
||||
expect(container.textContent).toContain("@research");
|
||||
});
|
||||
});
|
||||
|
||||
it("announces a pending skills request in the picker", () => {
|
||||
skillsQuery.isLoading = true;
|
||||
renderComposer("skills-pending");
|
||||
fireEvent.click(screen.getByTestId("mention-button"));
|
||||
expect(screen.getByRole("listbox").getAttribute("aria-busy")).toBe("true");
|
||||
expect(screen.getByRole("status").textContent).toContain("Loading");
|
||||
});
|
||||
|
||||
it("the picker search exposes its active option while selected state remains independent", () => {
|
||||
renderComposer("search-aria");
|
||||
fireEvent.click(screen.getByTestId("mention-button"));
|
||||
const search = screen.getByRole("combobox");
|
||||
const research = screen.getByRole("option", {
|
||||
name: "research Research a topic",
|
||||
});
|
||||
expect(search.getAttribute("aria-activedescendant")).toBe(research.id);
|
||||
expect(research.getAttribute("aria-selected")).toBe("false");
|
||||
fireEvent.keyDown(search, { key: "ArrowDown" });
|
||||
expect(search.getAttribute("aria-activedescendant")).toBe(
|
||||
screen.getByRole("option", { name: "report.pdf" }).id,
|
||||
);
|
||||
expect(research.getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
|
||||
it("pasted file labels never manufacture confirmed attachment context", async () => {
|
||||
const submit = rs.fn();
|
||||
const { container } = renderComposer("file-paste", submit);
|
||||
enterMention(
|
||||
container,
|
||||
referenceToken("file", "doc-1", "report.pdf") + " read",
|
||||
);
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await waitFor(() => expect(submit).toHaveBeenCalled());
|
||||
expect(submit.mock.calls[0]![1].additionalKwargs?.files).toBeUndefined();
|
||||
expect(attach).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a capability rerender before send acceptance cannot revive the accepted draft", async () => {
|
||||
let accepted!: () => void;
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const submit = rs.fn((_message, options) => {
|
||||
accepted = options.onSent;
|
||||
return pending;
|
||||
});
|
||||
const { container } = renderComposer("accepted-rerender", submit);
|
||||
enterMention(container, "Accepted task");
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await waitFor(() => expect(submit).toHaveBeenCalledTimes(1));
|
||||
// Opening the picker changes state without editing the submitted snapshot.
|
||||
fireEvent.click(screen.getByTestId("mention-button"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("mention-picker")).toBeTruthy(),
|
||||
);
|
||||
accepted();
|
||||
fireEvent(window, new Event("pagehide"));
|
||||
expect(Object.values(window.sessionStorage).join("\n")).not.toContain(
|
||||
"Accepted task",
|
||||
);
|
||||
release();
|
||||
});
|
||||
|
||||
describe("reference discovery failures and polish round trips", () => {
|
||||
it("preserves restored references and blocks sending until capability retry succeeds", async () => {
|
||||
capability.enabled = false;
|
||||
capability.maxReferences = 0;
|
||||
capability.isSuccess = false;
|
||||
capability.error = new Error("503 temporary");
|
||||
const token = referenceToken("conversation", "source-1", "Writer brief");
|
||||
const text = token + " summarize";
|
||||
saveDraft("cap-error", text, {
|
||||
conversationReferences: [
|
||||
{ threadId: "source-1", title: "Writer brief", agentName: "writer" },
|
||||
],
|
||||
});
|
||||
const submit = rs.fn();
|
||||
const { container } = renderComposer("cap-error", submit);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(),
|
||||
);
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
const key = buildComposerDraftKey({
|
||||
userId: "user-1",
|
||||
agentName: null,
|
||||
threadId: "cap-error",
|
||||
});
|
||||
expect(JSON.parse(window.sessionStorage.getItem(key)!).text).toBe(text);
|
||||
expect(
|
||||
JSON.parse(window.sessionStorage.getItem(key)!).conversationReferences,
|
||||
).toEqual([
|
||||
{ threadId: "source-1", title: "Writer brief", agentName: "writer" },
|
||||
]);
|
||||
capability.refetch.mockImplementation(() => {
|
||||
capability.isSuccess = true;
|
||||
capability.enabled = true;
|
||||
capability.maxReferences = 3;
|
||||
capability.error = null;
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("retry-conversation-capability"));
|
||||
expect(capability.refetch).toHaveBeenCalledTimes(1);
|
||||
// An ordinary editor update observes the recovered mock capability.
|
||||
enterMention(container, " now");
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await waitFor(() => expect(submit).toHaveBeenCalledTimes(1));
|
||||
expect(submit.mock.calls[0]![1].conversationReferences).toEqual([
|
||||
"source-1",
|
||||
]);
|
||||
expect(
|
||||
submit.mock.calls[0]![1].additionalKwargs.conversation_references[0]
|
||||
.agent_name,
|
||||
).toBe("writer");
|
||||
});
|
||||
|
||||
it("offers capability retry from the picker without a selected conversation", async () => {
|
||||
capability.enabled = false;
|
||||
capability.isSuccess = false;
|
||||
capability.error = new Error("503 temporary");
|
||||
renderComposer("picker-cap-error");
|
||||
fireEvent.click(screen.getByTestId("mention-button"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(capability.refetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("restores reordered overlapping skill labels in the intended positions and supports undo", async () => {
|
||||
const short = referenceToken("skill", "research", "research");
|
||||
const long = referenceToken("skill", "research-tools", "research-tools");
|
||||
const original = `Use ${short} after ${long}`;
|
||||
saveDraft("polish-prefix", original);
|
||||
polish.mockResolvedValue({
|
||||
rewritten_text: "Use @research-tools first, then @research.",
|
||||
changed: true,
|
||||
});
|
||||
const submit = rs.fn();
|
||||
const { container } = renderComposer(
|
||||
"polish-prefix",
|
||||
submit,
|
||||
rs.fn(),
|
||||
{},
|
||||
false,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("inline-skill-reference")).toHaveLength(2),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("polish-input-button"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
container.querySelector('[contenteditable="true"]')?.textContent,
|
||||
).toBe("Use ✦research-tools first, then ✦research."),
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getAllByTestId("inline-skill-reference")
|
||||
.map((el) => el.dataset.reference),
|
||||
).toEqual([long, short]);
|
||||
fireEvent.click(screen.getByTestId("polish-input-button"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen
|
||||
.getAllByTestId("inline-skill-reference")
|
||||
.map((el) => el.dataset.reference),
|
||||
).toEqual([short, long]),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("polish-input-button"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen
|
||||
.getAllByTestId("inline-skill-reference")
|
||||
.map((el) => el.dataset.reference),
|
||||
).toEqual([long, short]),
|
||||
);
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
await waitFor(() => expect(submit).toHaveBeenCalledTimes(1));
|
||||
expect(submit.mock.calls[0]![0].text).toBe(
|
||||
"Use @research-tools first, then @research.",
|
||||
);
|
||||
expect(submit.mock.calls[0]![1].additionalKwargs.skill_references).toEqual([
|
||||
"research-tools",
|
||||
"research",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -109,9 +109,7 @@ describe("InputBox skill suggestion IME handling", () => {
|
||||
// IME composition; the catalog must not claim it.
|
||||
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 229 });
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Remove /research" }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull();
|
||||
expect(textarea.value).toBe("/res");
|
||||
});
|
||||
|
||||
@ -124,9 +122,7 @@ describe("InputBox skill suggestion IME handling", () => {
|
||||
fireEvent.compositionEnd(textarea);
|
||||
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 13 });
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Remove /research" }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull();
|
||||
expect(textarea.value).toBe("/res");
|
||||
});
|
||||
|
||||
@ -141,9 +137,7 @@ describe("InputBox skill suggestion IME handling", () => {
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 13 });
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove /research" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still selects the highlighted skill on a plain Enter", () => {
|
||||
@ -152,8 +146,6 @@ describe("InputBox skill suggestion IME handling", () => {
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove /research" }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import {
|
||||
inlineReferences,
|
||||
reconcileConversationReferences,
|
||||
referenceToken,
|
||||
restoreReferenceLabels,
|
||||
} from "@/components/workspace/mentions/inline-references";
|
||||
|
||||
const text = ["one", "two", "one", "self", "three"]
|
||||
.map((id) => referenceToken("conversation", id, `Title ${id}`))
|
||||
.join(" ");
|
||||
describe("conversation reference reconciliation", () => {
|
||||
it("uses token order, deduplicates, excludes self, and preserves known display metadata", () => {
|
||||
const result = reconcileConversationReferences(
|
||||
text,
|
||||
[{ threadId: "one", title: "Known", agentName: "writer" }],
|
||||
{ enabled: true, maxReferences: 2, isLoading: false, isSuccess: true },
|
||||
"self",
|
||||
);
|
||||
expect(result.references).toEqual([
|
||||
{ threadId: "one", title: "Known", agentName: "writer" },
|
||||
{ threadId: "two", title: "Title two" },
|
||||
]);
|
||||
expect(inlineReferences(result.text).map((ref) => ref.id)).toEqual([
|
||||
"one",
|
||||
"two",
|
||||
"one",
|
||||
]);
|
||||
expect(result.text).toContain("@Title self @Title three");
|
||||
});
|
||||
it("retains pending text, then flattens disabled references without losing user words", () => {
|
||||
expect(
|
||||
reconcileConversationReferences(
|
||||
text,
|
||||
[],
|
||||
{ enabled: false, maxReferences: 0, isLoading: true, isSuccess: false },
|
||||
"self",
|
||||
),
|
||||
).toEqual({ text, references: [] });
|
||||
const result = reconcileConversationReferences(
|
||||
text,
|
||||
[],
|
||||
{ enabled: false, maxReferences: 3, isLoading: false, isSuccess: true },
|
||||
"self",
|
||||
);
|
||||
expect(result.references).toEqual([]);
|
||||
expect(result.text).toBe(
|
||||
"@Title one @Title two @Title one @Title self @Title three",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("polished reference labels", () => {
|
||||
for (const [kind, shortLabel, longLabel] of [
|
||||
["skill", "research", "research-tools"],
|
||||
["file", "report", "report.pdf"],
|
||||
["conversation", "调研", "调研计划(Q4)"],
|
||||
["file", "notes[1]", "notes[1].txt"],
|
||||
] as const) {
|
||||
it(`restores reordered overlapping ${kind} labels: ${longLabel}`, () => {
|
||||
const short = referenceToken(kind, "short-id", shortLabel);
|
||||
const long = referenceToken(kind, "long-id", longLabel);
|
||||
expect(
|
||||
restoreReferenceLabels(
|
||||
`Use ${short} after ${long}`,
|
||||
`Use @${longLabel} first, then @${shortLabel}.`,
|
||||
),
|
||||
).toBe(`Use ${long} first, then ${short}.`);
|
||||
});
|
||||
}
|
||||
it("does not consume a longer unselected word", () => {
|
||||
const token = referenceToken("skill", "research", "research");
|
||||
expect(
|
||||
restoreReferenceLabels(
|
||||
token,
|
||||
"Keep @research-tools unchanged; use @research.",
|
||||
),
|
||||
).toBe(`Keep @research-tools unchanged; use ${token}.`);
|
||||
});
|
||||
it("replaces each occurrence once without scanning inserted tokens", () => {
|
||||
const token = referenceToken("skill", "id", "research");
|
||||
expect(
|
||||
restoreReferenceLabels(
|
||||
`${token} ${token}`,
|
||||
"@research then @research and @research",
|
||||
),
|
||||
).toBe(`${token} then ${token} and @research`);
|
||||
});
|
||||
it("retains omitted selections in their original order", () => {
|
||||
const a = referenceToken("skill", "a", "A");
|
||||
const b = referenceToken("file", "b", "B");
|
||||
expect(
|
||||
restoreReferenceLabels(`${a} then ${b}`, "Task without labels"),
|
||||
).toBe(`${a} ${b} Task without labels`);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves known reference metadata while discovery has failed, including edits", () => {
|
||||
const kept = { threadId: "one", title: "Known", agentName: "writer" };
|
||||
const result = reconcileConversationReferences(
|
||||
referenceToken("conversation", "one", "Title one") + " edited",
|
||||
[kept, { threadId: "deleted", title: "Deleted" }],
|
||||
{ enabled: false, maxReferences: 0, isLoading: false, isSuccess: false },
|
||||
"self",
|
||||
);
|
||||
expect(result.references).toEqual([kept]);
|
||||
expect(inlineReferences(result.text).map((ref) => ref.id)).toEqual(["one"]);
|
||||
});
|
||||
|
||||
it("restores labels adjacent to Chinese prose", () => {
|
||||
const token = referenceToken("skill", "research", "research");
|
||||
expect(
|
||||
restoreReferenceLabels(
|
||||
`请用${token},总结资料。`,
|
||||
"请用@research,总结资料。",
|
||||
),
|
||||
).toBe(`请用${token},总结资料。`);
|
||||
});
|
||||
@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import { getMentionQuery } from "@/components/workspace/mentions/query";
|
||||
|
||||
describe("composer mention query", () => {
|
||||
it("replaces only the query at the caret, keeping both sides of the draft", () => {
|
||||
const text = "请使用 @research 分析结果";
|
||||
const caret = text.indexOf(" 分析");
|
||||
const query = getMentionQuery(text, caret)!;
|
||||
expect(query.query).toBe("research");
|
||||
});
|
||||
it("allows Unicode and filenames", () => {
|
||||
expect(getMentionQuery("@报告.pdf", 7)?.query).toBe("报告.pdf");
|
||||
expect(getMentionQuery("(@报告", 4)?.query).toBe("报告");
|
||||
});
|
||||
it("does not activate in an email, URL, or completed text", () => {
|
||||
for (const text of [
|
||||
"a@example.com",
|
||||
"https://host/@name",
|
||||
"@name ",
|
||||
"@@name",
|
||||
"path/@name",
|
||||
]) {
|
||||
expect(getMentionQuery(text, text.length)).toBeNull();
|
||||
}
|
||||
});
|
||||
it("keeps an unselected query as literal text", () => {
|
||||
const text = "@unknown";
|
||||
expect(getMentionQuery(text, text.length)).toEqual({
|
||||
start: 0,
|
||||
end: 8,
|
||||
query: "unknown",
|
||||
});
|
||||
expect(text).toBe("@unknown");
|
||||
});
|
||||
});
|
||||
69
frontend/tests/unit/core/features/hooks.dom.test.tsx
Normal file
69
frontend/tests/unit/core/features/hooks.dom.test.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { fetchConversationReferencesCapability } from "@/core/features/api";
|
||||
import { useConversationReferencesCapability } from "@/core/features/hooks";
|
||||
|
||||
rs.mock("@/core/features/api", () => ({
|
||||
fetchConversationReferencesCapability: rs.fn(),
|
||||
}));
|
||||
const fetchCapability = rs.mocked(fetchConversationReferencesCapability);
|
||||
function renderCapability() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderHook(useConversationReferencesCapability, {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
fetchCapability.mockReset();
|
||||
});
|
||||
|
||||
describe("conversation reference discovery", () => {
|
||||
it("distinguishes an initial failure from a confirmed disable and supports retry", async () => {
|
||||
fetchCapability.mockRejectedValueOnce(new Error("503 temporary"));
|
||||
const { result } = renderCapability();
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.isSuccess).toBe(false);
|
||||
await waitFor(() => expect(result.current.error).toBeTruthy());
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isSuccess).toBe(false);
|
||||
fetchCapability.mockResolvedValueOnce({ enabled: true, maxReferences: 4 });
|
||||
await result.current.refetch();
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current).toMatchObject({
|
||||
enabled: true,
|
||||
maxReferences: 4,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
it("marks a successful disabled response as authoritative", async () => {
|
||||
fetchCapability.mockResolvedValueOnce({ enabled: false, maxReferences: 0 });
|
||||
const { result } = renderCapability();
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current).toMatchObject({
|
||||
enabled: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
it("does not expose a cached enabled capability after a failed refresh", async () => {
|
||||
fetchCapability.mockResolvedValueOnce({ enabled: true, maxReferences: 3 });
|
||||
const { result } = renderCapability();
|
||||
await waitFor(() => expect(result.current.enabled).toBe(true));
|
||||
fetchCapability.mockRejectedValueOnce(new Error("network unavailable"));
|
||||
await result.current.refetch();
|
||||
await waitFor(() => expect(result.current.error).toBeTruthy());
|
||||
expect(result.current).toMatchObject({
|
||||
enabled: false,
|
||||
isSuccess: false,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user