fix(skills): harden slash skill activation across chat channels (#3466)

* support slash skill activation

* format slash skill activation

* Preserve slash skill activation with uploads

* Address slash skill review feedback

* Address slash skill follow-up review

* Fix lazy slash skill storage resolution

* Keep slash skill activation out of system prompt

* Address slash skill review issues

* fix: harden slash skill command handling

* feat(frontend): add slash skill autocomplete

* fix: address slash skill review feedback

* fix: preserve slash skill text for IM uploads
This commit is contained in:
DanielWalnut 2026-06-09 23:07:17 +08:00 committed by GitHub
parent 18bbb82f07
commit 16391e35ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2758 additions and 57 deletions

View File

@ -585,6 +585,8 @@ A standard Agent Skill is a structured capability module — a Markdown file tha
Skills are loaded progressively — only when the task needs them, not all at once. This keeps the context window lean and makes DeerFlow work well even with token-sensitive models.
Users can explicitly activate an enabled skill for a single turn by starting the request with `/skill-name`, for example `/data-analysis analyze uploads/foo.csv`. DeerFlow loads that skill's `SKILL.md` as hidden current-turn context while leaving the base prompt limited to skill metadata. Slash activation respects disabled skills, custom-agent skill whitelists, and existing channel commands such as `/new` and `/help`.
When you install `.skill` archives through the Gateway, DeerFlow accepts standard optional frontmatter metadata such as `version`, `author`, and `compatibility` instead of rejecting otherwise valid external skills.
Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything.

View File

@ -202,16 +202,17 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional); subagent usage is cached by `tool_call_id` only while token usage is enabled and merged back into the dispatching AIMessage by message position rather than message id
12. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
13. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
14. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
15. **DeferredToolFilterMiddleware** - Hides deferred (MCP) tool schemas from the bound model using a build-time deferred-name set + catalog hash, reading per-thread promotions from `ThreadState.promoted` (hash-scoped, no ContextVar); a tool becomes bound on subsequent turns after `tool_search` returns its schema (optional, if `tool_search.enabled`)
16. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
17. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
9. **SkillActivationMiddleware** - Detects strict `/skill-name task` syntax on the latest real user message, resolves only enabled and runtime-allowed skills, reads `SKILL.md` from trusted skill storage, injects the skill body as hidden current-turn model context, and records a `middleware:skill_activation` audit event with skill name, category, path, and content hash
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional); subagent usage is cached by `tool_call_id` only while token usage is enabled and merged back into the dispatching AIMessage by message position rather than message id
13. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
14. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
15. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
16. **DeferredToolFilterMiddleware** - Hides deferred (MCP) tool schemas from the bound model using a build-time deferred-name set + catalog hash, reading per-thread promotions from `ThreadState.promoted` (hash-scoped, no ContextVar); a tool becomes bound on subsequent turns after `tool_search` returns its schema (optional, if `tool_search.enabled`)
17. **SubagentLimitMiddleware** - Truncates excess `task` tool calls from model response to enforce `MAX_CONCURRENT_SUBAGENTS` limit (optional, if `subagent_enabled`)
18. **LoopDetectionMiddleware** - Detects repeated tool-call loops; hard-stop responses clear both structured `tool_calls` and raw provider tool-call metadata before forcing a final text answer
19. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
### Configuration System
@ -348,6 +349,7 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools)
- **Loading**: `load_skills()` recursively scans `skills/{public,custom}` for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json
- **Injection**: Enabled skills listed in agent system prompt with container paths
- **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`), disabled skills, and skills outside a custom agent's whitelist.
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
### Model Factory (`packages/harness/deerflow/models/factory.py`)

View File

@ -18,3 +18,10 @@ KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset(
"/help",
}
)
def is_known_channel_command(text: str) -> bool:
"""Return whether text starts with a registered channel control command."""
if not text.startswith("/"):
return False
return text.split(maxsplit=1)[0].lower() in KNOWN_CHANNEL_COMMANDS

View File

@ -14,7 +14,7 @@ from typing import Any
import httpx
from app.channels.base import Channel
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
from app.channels.commands import is_known_channel_command
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__)
@ -59,9 +59,7 @@ def _normalize_allowed_users(allowed_users: Any) -> set[str]:
def _is_dingtalk_command(text: str) -> bool:
if not text.startswith("/"):
return False
return text.split(maxsplit=1)[0].lower() in KNOWN_CHANNEL_COMMANDS
return is_known_channel_command(text)
def _extract_text_from_rich_text(rich_text_list: list) -> str:

View File

@ -10,6 +10,7 @@ from pathlib import Path
from typing import Any
from app.channels.base import Channel
from app.channels.commands import is_known_channel_command
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__)
@ -300,7 +301,7 @@ class DiscordChannel(Channel):
# If this is a known active thread, process normally
if thread_id in self._active_thread_ids:
msg_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
msg_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
inbound = self._make_inbound(
chat_id=chat_id,
user_id=str(message.author.id),
@ -407,7 +408,7 @@ class DiscordChannel(Channel):
chat_id = channel_id
typing_target = message.channel # Type into the channel
msg_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
msg_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
inbound = self._make_inbound(
chat_id=chat_id,
user_id=str(message.author.id),

View File

@ -11,7 +11,7 @@ import time
from typing import Any, Literal
from app.channels.base import Channel
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
from app.channels.commands import is_known_channel_command
from app.channels.message_bus import (
PENDING_CLARIFICATION_METADATA_KEY,
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY,
@ -30,9 +30,7 @@ PENDING_CLARIFICATION_TTL_SECONDS = 30 * 60
def _is_feishu_command(text: str) -> bool:
if not text.startswith("/"):
return False
return text.split(maxsplit=1)[0].lower() in KNOWN_CHANNEL_COMMANDS
return is_known_channel_command(text)
class FeishuChannel(Channel):

View File

@ -8,6 +8,7 @@ import mimetypes
import re
import time
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@ -26,8 +27,13 @@ from app.channels.message_bus import (
from app.channels.store import ChannelStore
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
from app.gateway.internal_auth import create_internal_auth_headers
from deerflow.config.agents_config import load_agent_config
from deerflow.config.paths import make_safe_user_id
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills.slash import parse_slash_skill_reference
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.storage.skill_storage import SkillStorage
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
logger = logging.getLogger(__name__)
@ -124,6 +130,16 @@ class InvalidChannelSessionConfigError(ValueError):
"""Raised when IM channel session overrides contain invalid agent config."""
class SlashSkillCommandResolutionError(RuntimeError):
"""Raised when IM slash-skill command resolution cannot complete safely."""
@dataclass(frozen=True, slots=True)
class _SlashSkillCommandResolution:
route_to_chat: bool = False
failure_message: str | None = None
def _is_thread_busy_error(exc: BaseException | None) -> bool:
if exc is None:
return False
@ -410,6 +426,46 @@ def _format_artifact_text(artifacts: list[str]) -> str:
_OUTPUTS_VIRTUAL_PREFIX = "/mnt/user-data/outputs/"
def _unknown_command_reply(command: str | None = None) -> str:
available = " | ".join(sorted(KNOWN_CHANNEL_COMMANDS))
if command:
return f"Unknown command: /{command}. Available commands: {available}"
return f"Unknown command. Available commands: {available}"
def _human_input_message(content: str, *, original_content: str | None = None) -> dict[str, Any]:
message: dict[str, Any] = {"role": "human", "content": content}
if original_content is not None and original_content != content:
message["additional_kwargs"] = {ORIGINAL_USER_CONTENT_KEY: original_content}
return message
def _resolve_slash_skill_command(
text: str,
available_skills: set[str] | None = None,
storage: SkillStorage | Callable[[], SkillStorage] | None = None,
) -> _SlashSkillCommandResolution | None:
reference = parse_slash_skill_reference(text)
if reference is None:
return None
try:
resolved_storage = storage() if callable(storage) else storage or get_or_new_skill_storage()
skills = resolved_storage.load_skills(enabled_only=False)
skill = next((candidate for candidate in skills if candidate.name == reference.name), None)
if skill is None:
return None
if not skill.enabled:
return _SlashSkillCommandResolution(failure_message=f"Skill `/{reference.name}` is installed but disabled. Enable it before using slash activation.")
if available_skills is not None and reference.name not in available_skills:
return _SlashSkillCommandResolution(failure_message=f"Skill `/{reference.name}` is not available for this agent.")
return _SlashSkillCommandResolution(route_to_chat=True)
except Exception as exc:
logger.exception("[Manager] failed to resolve slash skill command")
raise SlashSkillCommandResolutionError("Failed to resolve slash skill command. Please check the skill configuration.") from exc
def _resolve_attachments(thread_id: str, artifacts: list[str]) -> list[ResolvedAttachment]:
"""Resolve virtual artifact paths to host filesystem paths with metadata.
@ -624,6 +680,7 @@ class ChannelManager:
self._default_session = _as_dict(default_session)
self._channel_sessions = dict(channel_sessions or {})
self._client = None # lazy init — langgraph_sdk async client
self._skill_storage: SkillStorage | None = None
self._csrf_token = generate_csrf_token()
self._semaphore: asyncio.Semaphore | None = None
self._running = False
@ -696,6 +753,21 @@ class ChannelManager:
return assistant_id, run_config, run_context
def _resolve_available_skill_names(self, msg: InboundMessage) -> set[str] | None:
thread_id = self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id) or ""
_, _, run_context = self._resolve_run_params(msg, thread_id)
if run_context.get("is_bootstrap"):
return {"bootstrap"}
agent_name = run_context.get("agent_name")
if not isinstance(agent_name, str) or not agent_name.strip():
return None
agent_config = load_agent_config(_normalize_custom_agent_name(agent_name))
if agent_config and agent_config.skills is not None:
return set(agent_config.skills)
return None
# -- LangGraph SDK client (lazy) ----------------------------------------
def _get_client(self):
@ -713,6 +785,11 @@ class ChannelManager:
)
return self._client
def _get_skill_storage(self) -> SkillStorage:
if self._skill_storage is None:
self._skill_storage = get_or_new_skill_storage()
return self._skill_storage
# -- lifecycle ---------------------------------------------------------
async def start(self) -> None:
@ -782,6 +859,14 @@ class ChannelManager:
exc,
)
await self._send_error(msg, str(exc))
except SlashSkillCommandResolutionError as exc:
logger.warning(
"Slash skill command resolution failed for %s (chat=%s): %s",
msg.channel_name,
msg.chat_id,
exc,
)
await self._send_error(msg, str(exc))
except Exception:
logger.exception(
"Error handling message from %s (chat=%s)",
@ -836,9 +921,11 @@ class ChannelManager:
if extra_context:
run_context.update(extra_context)
original_text = msg.text
uploaded = await _ingest_inbound_files(thread_id, msg)
if uploaded:
msg.text = f"{_format_uploaded_files_block(uploaded)}\n\n{msg.text}".strip()
human_message = _human_input_message(msg.text, original_content=original_text)
if self._channel_supports_streaming(msg.channel_name):
await self._handle_streaming_chat(
@ -848,6 +935,7 @@ class ChannelManager:
assistant_id,
run_config,
run_context,
human_message,
)
return
@ -856,7 +944,7 @@ class ChannelManager:
result = await client.runs.wait(
thread_id,
assistant_id,
input={"messages": [{"role": "human", "content": msg.text}]},
input={"messages": [human_message]},
config=run_config,
context=run_context,
multitask_strategy="reject",
@ -909,6 +997,7 @@ class ChannelManager:
assistant_id: str,
run_config: dict[str, Any],
run_context: dict[str, Any],
human_message: dict[str, Any],
) -> None:
logger.info("[Manager] invoking runs.stream(thread_id=%s, text=%r)", thread_id, msg.text[:100])
@ -924,7 +1013,7 @@ class ChannelManager:
async for chunk in client.runs.stream(
thread_id,
assistant_id,
input={"messages": [{"role": "human", "content": msg.text}]},
input={"messages": [human_message]},
config=run_config,
context=run_context,
stream_mode=["messages-tuple", "values"],
@ -1011,11 +1100,20 @@ class ChannelManager:
# -- command handling --------------------------------------------------
async def _handle_command(self, msg: InboundMessage) -> None:
text = msg.text.strip()
raw_text = msg.text
text = raw_text.strip()
parts = text.split(maxsplit=1)
command = parts[0].lower().lstrip("/")
reply: str | None = None
if not parts:
command = None
reply = _unknown_command_reply()
else:
command = parts[0].lower().removeprefix("/")
if command == "bootstrap":
if reply is None and not raw_text.startswith("/"):
reply = _unknown_command_reply(command)
if reply is None and command == "bootstrap":
from dataclasses import replace as _dc_replace
chat_text = parts[1] if len(parts) > 1 else "Initialize workspace"
@ -1023,7 +1121,7 @@ class ChannelManager:
await self._handle_chat(chat_msg, extra_context={"is_bootstrap": True})
return
if command == "new":
if reply is None and command == "new":
# Create a new thread through Gateway
client = self._get_client()
thread = await client.threads.create()
@ -1036,14 +1134,14 @@ class ChannelManager:
user_id=msg.user_id,
)
reply = "New conversation started."
elif command == "status":
elif reply is None and command == "status":
thread_id = self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id)
reply = f"Active thread: {thread_id}" if thread_id else "No active conversation."
elif command == "models":
elif reply is None and command == "models":
reply = await self._fetch_gateway("/api/models", "models")
elif command == "memory":
elif reply is None and command == "memory":
reply = await self._fetch_gateway("/api/memory", "memory")
elif command == "help":
elif reply is None and command == "help":
reply = (
"Available commands:\n"
"/bootstrap — Start a bootstrap session (enables agent setup)\n"
@ -1051,16 +1149,32 @@ class ChannelManager:
"/status — Show current thread info\n"
"/models — List available models\n"
"/memory — Show memory status\n"
"/<skill-name> <task> — Activate an enabled skill for one turn\n"
"/help — Show this help"
)
else:
available = " | ".join(sorted(KNOWN_CHANNEL_COMMANDS))
reply = f"Unknown command: /{command}. Available commands: {available}"
elif reply is None:
slash_resolution = await asyncio.to_thread(
lambda: _resolve_slash_skill_command(
raw_text,
self._resolve_available_skill_names(msg),
self._get_skill_storage,
)
)
if slash_resolution and slash_resolution.failure_message:
reply = slash_resolution.failure_message
elif slash_resolution and slash_resolution.route_to_chat:
from dataclasses import replace as _dc_replace
chat_msg = _dc_replace(msg, msg_type=InboundMessageType.CHAT)
await self._handle_chat(chat_msg)
return
else:
reply = _unknown_command_reply(command)
outbound = OutboundMessage(
channel_name=msg.channel_name,
chat_id=msg.chat_id,
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id) or "",
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id) or "",
text=reply,
thread_ts=msg.thread_ts,
metadata=_slim_metadata(msg.metadata),
@ -1098,7 +1212,7 @@ class ChannelManager:
outbound = OutboundMessage(
channel_name=msg.channel_name,
chat_id=msg.chat_id,
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id) or "",
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id) or "",
text=error_text,
thread_ts=msg.thread_ts,
metadata=_slim_metadata(msg.metadata),

View File

@ -9,6 +9,7 @@ from typing import Any
from markdown_to_mrkdwn import SlackMarkdownConverter
from app.channels.base import Channel
from app.channels.commands import is_known_channel_command
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__)
@ -32,6 +33,20 @@ def _normalize_allowed_users(allowed_users: Any) -> set[str]:
return {str(user_id) for user_id in values if str(user_id)}
def _strip_leading_slack_bot_mention(text: str, bot_user_id: str | None) -> str:
if not bot_user_id:
return text
if not text.startswith("<@"):
return text
end = text.find(">")
if end <= 2:
return text
mentioned_user_id = text[2:end].split("|", 1)[0].lstrip("!")
if mentioned_user_id != bot_user_id:
return text
return text[end + 1 :].lstrip()
class SlackChannel(Channel):
"""Slack IM channel using Socket Mode (WebSocket, no public IP).
@ -49,6 +64,8 @@ class SlackChannel(Channel):
self._web_client = None
self._loop: asyncio.AbstractEventLoop | None = None
self._allowed_users = _normalize_allowed_users(config.get("allowed_users", []))
configured_bot_user_id = config.get("bot_user_id")
self._bot_user_id = str(configured_bot_user_id).lstrip("@") if configured_bot_user_id else None
async def start(self) -> None:
if self._running:
@ -72,6 +89,17 @@ class SlackChannel(Channel):
return
self._web_client = WebClient(token=bot_token)
if self._bot_user_id is None:
try:
auth_info = await asyncio.to_thread(self._web_client.auth_test)
user_id = auth_info.get("user_id") if isinstance(auth_info, dict) else None
if user_id is None:
auth_get = getattr(auth_info, "get", None)
user_id = auth_get("user_id") if callable(auth_get) else None
if isinstance(user_id, str) and user_id:
self._bot_user_id = user_id
except Exception:
logger.warning("[Slack] failed to resolve bot user id; app mention text may include the bot mention", exc_info=True)
self._socket_client = SocketModeClient(
app_token=app_token,
web_client=self._web_client,
@ -210,6 +238,12 @@ class SlackChannel(Channel):
if event_type != "events_api":
return
if self._bot_user_id is None:
authorization = next((item for item in req.payload.get("authorizations", []) if isinstance(item, dict)), None)
user_id = authorization.get("user_id") if authorization else None
if isinstance(user_id, str) and user_id:
self._bot_user_id = user_id
event = req.payload.get("event", {})
etype = event.get("type", "")
@ -233,13 +267,15 @@ class SlackChannel(Channel):
return
text = event.get("text", "").strip()
if event.get("type") == "app_mention":
text = _strip_leading_slack_bot_mention(text, self._bot_user_id)
if not text:
return
channel_id = event.get("channel", "")
thread_ts = event.get("thread_ts") or event.get("ts", "")
if text.startswith("/"):
if is_known_channel_command(text):
msg_type = InboundMessageType.COMMAND
else:
msg_type = InboundMessageType.CHAT

View File

@ -60,12 +60,17 @@ class TelegramChannel(Channel):
# Command handlers
app.add_handler(CommandHandler("start", self._cmd_start))
app.add_handler(CommandHandler("bootstrap", self._cmd_generic))
app.add_handler(CommandHandler("new", self._cmd_generic))
app.add_handler(CommandHandler("status", self._cmd_generic))
app.add_handler(CommandHandler("models", self._cmd_generic))
app.add_handler(CommandHandler("memory", self._cmd_generic))
app.add_handler(CommandHandler("help", self._cmd_generic))
# Slash skill commands are dynamic and cannot all be pre-registered
# with Telegram, so route unknown slash commands through chat handling.
app.add_handler(MessageHandler(filters.TEXT & filters.COMMAND, self._on_text))
# General message handler
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_text))
@ -228,6 +233,33 @@ class TelegramChannel(Channel):
return True
return user_id in self._allowed_users
def _get_bot_username(self, context) -> str | None:
bot = getattr(context, "bot", None)
username = getattr(bot, "username", None)
if not username and self._application is not None:
username = getattr(getattr(self._application, "bot", None), "username", None)
return str(username) if username else None
@staticmethod
def _strip_bot_username_from_leading_command(text: str, bot_username: str | None) -> str:
username = (bot_username or "").lstrip("@").lower()
if not username or not text.startswith("/"):
return text
parts = text.split(maxsplit=1)
command_token = parts[0]
if "@" not in command_token:
return text
command_name, addressed_username = command_token[1:].rsplit("@", 1)
if not command_name or addressed_username.lower() != username:
return text
normalized = f"/{command_name}"
if len(parts) > 1:
normalized = f"{normalized} {parts[1]}"
return normalized
async def _cmd_start(self, update, context) -> None:
"""Handle /start command."""
if not self._check_user(update.effective_user.id):
@ -243,7 +275,7 @@ class TelegramChannel(Channel):
if not self._check_user(update.effective_user.id):
return
text = update.message.text
text = self._strip_bot_username_from_leading_command(update.message.text.strip(), self._get_bot_username(context))
chat_id = str(update.effective_chat.id)
user_id = str(update.effective_user.id)
msg_id = str(update.message.message_id)
@ -279,7 +311,7 @@ class TelegramChannel(Channel):
if not self._check_user(update.effective_user.id):
return
text = update.message.text.strip()
text = self._strip_bot_username_from_leading_command(update.message.text.strip(), self._get_bot_username(context))
if not text:
return

View File

@ -22,6 +22,7 @@ from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.channels.base import Channel
from app.channels.commands import is_known_channel_command
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__)
@ -620,7 +621,7 @@ class WechatChannel(Channel):
chat_id=chat_id,
user_id=chat_id,
text=text,
msg_type=InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT,
msg_type=InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT,
thread_ts=thread_ts,
files=files,
metadata={

View File

@ -8,6 +8,7 @@ from collections.abc import Awaitable, Callable
from typing import Any, cast
from app.channels.base import Channel
from app.channels.commands import is_known_channel_command
from app.channels.message_bus import (
InboundMessageType,
MessageBus,
@ -270,7 +271,7 @@ class WeComChannel(Channel):
user_id = (body.get("from") or {}).get("userid")
inbound_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
inbound_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
inbound = self._make_inbound(
chat_id=user_id, # keep user's conversation in memory
user_id=user_id,

View File

@ -49,6 +49,8 @@ from deerflow.tracing import build_tracing_callbacks
logger = logging.getLogger(__name__)
_BOOTSTRAP_SKILL_NAMES = {"bootstrap"}
def _get_runtime_config(config: RunnableConfig) -> dict:
"""Merge legacy configurable options with LangGraph runtime context."""
@ -271,6 +273,7 @@ def build_middlewares(
agent_name: str | None = None,
custom_middlewares: list[AgentMiddleware] | None = None,
*,
available_skills: set[str] | None = None,
app_config: AppConfig | None = None,
deferred_setup=None,
):
@ -302,6 +305,13 @@ def build_middlewares(
middlewares.append(DynamicContextMiddleware(agent_name=agent_name, app_config=resolved_app_config))
# Deterministically load a full SKILL.md when the user starts the turn with
# /skill-name. This keeps the base system prompt metadata-only while giving
# explicit user activation priority over model-side relevance guessing.
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
middlewares.append(SkillActivationMiddleware(available_skills=available_skills, app_config=resolved_app_config))
# Add summarization middleware if enabled
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
if summarization_middleware is not None:
@ -369,7 +379,7 @@ def build_middlewares(
def _available_skill_names(agent_config, is_bootstrap: bool) -> set[str] | None:
if is_bootstrap:
return {"bootstrap"}
return set(_BOOTSTRAP_SKILL_NAMES)
if agent_config and agent_config.skills is not None:
return set(agent_config.skills)
return None
@ -475,17 +485,25 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if is_bootstrap:
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
# Keep the bootstrap skill set intentionally narrow so agent creation
# remains deterministic before the custom agent's own config exists.
raw_tools = get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config) + [setup_agent]
filtered = filter_tools_by_skill_allowed_tools(raw_tools, skills_for_tool_policy)
final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled)
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
tools=final_tools,
middleware=build_middlewares(config, model_name=model_name, app_config=resolved_app_config, deferred_setup=setup),
middleware=build_middlewares(
config,
model_name=model_name,
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
app_config=resolved_app_config,
deferred_setup=setup,
),
system_prompt=apply_prompt_template(
subagent_enabled=subagent_enabled,
max_concurrent_subagents=max_concurrent_subagents,
available_skills=set(["bootstrap"]),
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
app_config=resolved_app_config,
deferred_names=setup.deferred_names,
),
@ -502,12 +520,19 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False),
tools=final_tools,
middleware=build_middlewares(config, model_name=model_name, agent_name=agent_name, app_config=resolved_app_config, deferred_setup=setup),
middleware=build_middlewares(
config,
model_name=model_name,
agent_name=agent_name,
available_skills=available_skills,
app_config=resolved_app_config,
deferred_setup=setup,
),
system_prompt=apply_prompt_template(
subagent_enabled=subagent_enabled,
max_concurrent_subagents=max_concurrent_subagents,
agent_name=agent_name,
available_skills=set(agent_config.skills) if agent_config and agent_config.skills is not None else None,
available_skills=available_skills,
app_config=resolved_app_config,
deferred_names=setup.deferred_names,
),

View File

@ -625,6 +625,11 @@ You have access to skills that provide optimized workflows for specific tasks. E
4. Load referenced resources only when needed during execution
5. Follow the skill's instructions precisely
**Explicit Slash Skill Activation:**
- If the user starts a request with `/<skill-name>`, that skill was explicitly requested for the current turn.
- Follow the activated skill before choosing a general workflow.
- The runtime injects the activated skill content for explicit slash activations; do not call `read_file` for that SKILL.md again unless the injected skill references supporting resources you need.
**Skills are located at:** {container_base_path}
{skill_evolution_section}
{skills_list}

View File

@ -0,0 +1,289 @@
"""Middleware for explicit slash skill activation."""
from __future__ import annotations
import asyncio
import hashlib
import html
import logging
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, override
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.messages import AIMessage, HumanMessage
from deerflow.skills.slash import parse_slash_skill_reference, resolve_slash_skill
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.storage.skill_storage import SkillStorage
from deerflow.skills.types import SKILL_MD_FILE
from deerflow.utils.messages import get_original_user_content_text
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
logger = logging.getLogger(__name__)
_SLASH_SKILL_ACTIVATION_KEY = "slash_skill_activation"
_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY = "slash_skill_activation_target_id"
_SUMMARY_MESSAGE_NAME = "summary"
@dataclass(frozen=True, slots=True)
class _Activation:
skill_name: str
category: str
container_file_path: str
skill_content: str
content_hash: str
remaining_text: str
@dataclass(frozen=True, slots=True)
class _ActivationResolution:
activation: _Activation | None = None
failure_message: str | None = None
def is_slash_skill_activation_reminder(message: object) -> bool:
"""Return whether a message is hidden slash-skill activation context."""
return isinstance(message, HumanMessage) and bool(message.additional_kwargs.get(_SLASH_SKILL_ACTIVATION_KEY))
def _is_user_activation_target(message: object) -> bool:
if not isinstance(message, HumanMessage):
return False
if message.name == _SUMMARY_MESSAGE_NAME:
return False
if message.additional_kwargs.get("hide_from_ui"):
return False
return True
class SkillActivationMiddleware(AgentMiddleware):
"""Inject full SKILL.md content when the user explicitly types /skill-name."""
def __init__(
self,
*,
available_skills: set[str] | None = None,
app_config: AppConfig | None = None,
) -> None:
super().__init__()
self._available_skills = set(available_skills) if available_skills is not None else None
self._app_config = app_config
def _storage(self) -> SkillStorage:
if self._app_config is not None:
return get_or_new_skill_storage(app_config=self._app_config)
return get_or_new_skill_storage()
@staticmethod
def _read_skill_content(skill_file: Path, skills_root: Path) -> str:
if skill_file.name != SKILL_MD_FILE:
raise ValueError(f"Expected {SKILL_MD_FILE}, got {skill_file.name}")
resolved_root = skills_root.resolve()
resolved_file = skill_file.resolve()
try:
resolved_file.relative_to(resolved_root)
except ValueError as exc:
raise ValueError("Resolved skill file must stay within the configured skills root.") from exc
if not resolved_file.is_file():
raise FileNotFoundError(resolved_file)
return resolved_file.read_text(encoding="utf-8")
def _resolve_activation(self, text: str) -> _ActivationResolution | None:
reference = parse_slash_skill_reference(text)
if reference is None:
return None
storage = self._storage()
skills = storage.load_skills(enabled_only=False)
skill = next((candidate for candidate in skills if candidate.name == reference.name), None)
if skill is None:
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is not installed.")
if not skill.enabled:
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is installed but disabled. Enable it before using slash activation.")
if self._available_skills is not None and reference.name not in self._available_skills:
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is not available for this agent.")
resolved = resolve_slash_skill(
text,
skills,
available_skills=self._available_skills,
container_base_path=storage.get_container_root(),
)
if resolved is None:
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` could not be resolved.")
try:
skill_content = self._read_skill_content(resolved.skill.skill_file, storage.get_skills_root_path())
except (OSError, ValueError):
logger.exception("Failed to read slash-activated skill %s", resolved.skill.name)
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` could not be loaded safely. Please check the skill installation.")
content_hash = hashlib.sha256(skill_content.encode("utf-8")).hexdigest()
return _ActivationResolution(
activation=_Activation(
skill_name=resolved.skill.name,
category=str(resolved.skill.category),
container_file_path=resolved.container_file_path,
skill_content=skill_content,
content_hash=content_hash,
remaining_text=resolved.remaining_text,
)
)
@staticmethod
def _build_activation_reminder(activation: _Activation) -> 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)
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)
escaped_path = html.escape(activation.container_file_path, quote=True)
escaped_content_hash = html.escape(activation.content_hash, quote=True)
return f"""<slash_skill_activation>
The user explicitly activated the `{activation.skill_name}` skill for this turn.
Treat the task text as:
<user_request>
{escaped_user_request}
</user_request>
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}">
<skill_content encoding="xml-escaped">
{escaped_skill_content}
</skill_content>
</skill>
</slash_skill_activation>"""
@staticmethod
def _has_existing_activation_for_target(messages: list, target_index: int, target: HumanMessage) -> bool:
if target_index <= 0:
return False
if target.id:
for previous in messages[:target_index]:
if not is_slash_skill_activation_reminder(previous):
continue
target_id = previous.additional_kwargs.get(_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY)
if target_id == target.id or previous.id == f"{target.id}__slash_activation":
return True
previous = messages[target_index - 1]
return is_slash_skill_activation_reminder(previous)
def _find_activation_target(self, messages: list) -> tuple[int, HumanMessage, _ActivationResolution] | None:
if not messages:
return None
target_index = next((idx for idx in range(len(messages) - 1, -1, -1) if _is_user_activation_target(messages[idx])), None)
if target_index is None:
return None
target = messages[target_index]
if target is None:
return None
if self._has_existing_activation_for_target(messages, target_index, target):
return None
content = get_original_user_content_text(target.content, target.additional_kwargs)
resolution = self._resolve_activation(content)
if resolution is None:
return None
return target_index, target, resolution
@staticmethod
def _record_activation(request: ModelRequest, activation: _Activation, *, hook: str) -> None:
runtime = getattr(request, "runtime", None)
context = getattr(runtime, "context", None)
journal = context.get("__run_journal") if isinstance(context, dict) else None
if journal is None:
return
try:
journal.record_middleware(
"skill_activation",
name="SkillActivationMiddleware",
hook=hook,
action="activate",
changes={
"skill_name": activation.skill_name,
"category": activation.category,
"path": activation.container_file_path,
"content_hash": activation.content_hash,
},
)
except Exception:
logger.debug("Failed to record slash skill activation audit event", exc_info=True)
def _prepare_model_request(self, request: ModelRequest, *, hook: str) -> ModelRequest | AIMessage | None:
target_and_resolution = self._find_activation_target(list(request.messages))
if target_and_resolution is None:
return None
target_index, target, resolution = target_and_resolution
if resolution.failure_message:
return AIMessage(content=resolution.failure_message)
activation = resolution.activation
if activation is None:
return None
logger.info(
"SkillActivationMiddleware: activating slash skill %s category=%s path=%s hash=%s",
activation.skill_name,
activation.category,
activation.container_file_path,
activation.content_hash,
)
self._record_activation(request, activation, hook=hook)
activation_msg = self._make_activation_message(target, self._build_activation_reminder(activation))
messages = list(request.messages)
messages.insert(target_index, activation_msg)
return request.override(messages=messages)
@staticmethod
def _make_activation_message(target: HumanMessage, activation_content: str) -> HumanMessage:
stable_id = target.id or str(uuid.uuid4())
additional_kwargs = {
"hide_from_ui": True,
_SLASH_SKILL_ACTIVATION_KEY: True,
}
if target.id:
additional_kwargs[_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY] = target.id
return HumanMessage(
content=activation_content,
id=f"{stable_id}__slash_activation",
additional_kwargs=additional_kwargs,
)
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse | AIMessage:
prepared = self._prepare_model_request(request, hook="wrap_model_call")
if prepared is None:
return handler(request)
if isinstance(prepared, AIMessage):
return prepared
return handler(prepared)
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse | AIMessage:
prepared = await asyncio.to_thread(self._prepare_model_request, request, hook="awrap_model_call")
if prepared is None:
return await handler(request)
if isinstance(prepared, AIMessage):
return prepared
return await handler(prepared)

View File

@ -13,6 +13,7 @@ from langgraph.runtime import Runtime
from deerflow.config.paths import Paths, get_paths
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.file_conversion import extract_outline
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, message_content_to_text
logger = logging.getLogger(__name__)
@ -265,6 +266,8 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
# Extract original content - handle both string and list formats
original_content = last_message.content
additional_kwargs = dict(last_message.additional_kwargs or {})
additional_kwargs.setdefault(ORIGINAL_USER_CONTENT_KEY, message_content_to_text(original_content))
if isinstance(original_content, str):
# Simple case: string content, just prepend files message
updated_content = f"{files_message}\n\n{original_content}"
@ -285,7 +288,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
content=updated_content,
id=last_message.id,
name=last_message.name,
additional_kwargs=last_message.additional_kwargs,
additional_kwargs=additional_kwargs,
)
messages[last_message_index] = updated_message

View File

@ -247,7 +247,15 @@ class DeerFlowClient:
# Attaching them again on the model would emit duplicate spans.
"model": create_chat_model(name=model_name, thinking_enabled=thinking_enabled, attach_tracing=False),
"tools": final_tools,
"middleware": build_middlewares(config, model_name=model_name, agent_name=self._agent_name, custom_middlewares=self._middlewares, deferred_setup=deferred_setup),
"middleware": build_middlewares(
config,
model_name=model_name,
agent_name=self._agent_name,
available_skills=self._available_skills,
custom_middlewares=self._middlewares,
app_config=self._app_config,
deferred_setup=deferred_setup,
),
"system_prompt": apply_prompt_template(
subagent_enabled=subagent_enabled,
max_concurrent_subagents=max_concurrent_subagents,

View File

@ -0,0 +1,65 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from deerflow.skills.types import Skill
RESERVED_SLASH_SKILL_NAMES = frozenset({"bootstrap", "help", "memory", "models", "new", "status"})
_SLASH_SKILL_RE = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)")
@dataclass(frozen=True, slots=True)
class SlashSkillReference:
"""Parsed slash-skill command with the skill name and remaining task text."""
name: str
remaining_text: str
@dataclass(frozen=True, slots=True)
class ResolvedSlashSkill:
"""Slash-skill activation resolved against enabled runtime-visible skills."""
skill: Skill
remaining_text: str
container_file_path: str
def parse_slash_skill_reference(text: str) -> SlashSkillReference | None:
"""Parse strict `/skill-name task` syntax, ignoring reserved control commands."""
match = _SLASH_SKILL_RE.match(text)
if not match:
return None
name = match.group(1)
if name in RESERVED_SLASH_SKILL_NAMES:
return None
return SlashSkillReference(
name=name,
remaining_text=text[match.end() :].lstrip(),
)
def resolve_slash_skill(
text: str,
skills: list[Skill],
*,
available_skills: set[str] | None = None,
container_base_path: str = "/mnt/skills",
) -> ResolvedSlashSkill | None:
"""Resolve text into an enabled, whitelisted skill activation if possible."""
reference = parse_slash_skill_reference(text)
if reference is None:
return None
if available_skills is not None and reference.name not in available_skills:
return None
skill = next((candidate for candidate in skills if candidate.name == reference.name and candidate.enabled), None)
if skill is None:
return None
return ResolvedSlashSkill(
skill=skill,
remaining_text=reference.remaining_text,
container_file_path=skill.get_container_file_path(container_base_path),
)

View File

@ -0,0 +1,31 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
ORIGINAL_USER_CONTENT_KEY = "original_user_content"
def message_content_to_text(content: Any) -> str:
"""Extract text from LangChain message content shapes."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
text = item.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(part for part in parts if part)
return str(content)
def get_original_user_content_text(content: Any, additional_kwargs: Mapping[str, Any] | None) -> str:
"""Return pre-middleware user text when available, otherwise content text."""
original_content = (additional_kwargs or {}).get(ORIGINAL_USER_CONTENT_KEY)
if isinstance(original_content, str):
return original_content
return message_content_to_text(content)

View File

@ -21,6 +21,42 @@ from app.channels.message_bus import (
ResolvedAttachment,
)
from app.channels.store import ChannelStore
from deerflow.skills.types import Skill, SkillCategory
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
def test_known_channel_command_detection_only_matches_control_commands():
from app.channels.commands import is_known_channel_command
assert is_known_channel_command("/new")
assert is_known_channel_command("/HELP now")
assert not is_known_channel_command("/mnt/user-data/uploads/report.pdf")
assert not is_known_channel_command("/data-analysis analyze uploads/foo.csv")
assert not is_known_channel_command(" /new")
def _make_channel_skill(tmp_path: Path, name: str, *, enabled: bool = True) -> Skill:
skill_dir = tmp_path / name
skill_dir.mkdir(parents=True, exist_ok=True)
skill_file = skill_dir / "SKILL.md"
skill_file.write_text(f"# {name}\n", encoding="utf-8")
return Skill(
name=name,
description=f"Description for {name}",
license="MIT",
skill_dir=skill_dir,
skill_file=skill_file,
relative_path=Path(name),
category=SkillCategory.CUSTOM,
enabled=enabled,
)
def _make_channel_skill_storage(skills: list[Skill]):
return SimpleNamespace(
load_skills=lambda *, enabled_only: [skill for skill in skills if skill.enabled] if enabled_only else skills,
get_container_root=lambda: "/mnt/skills",
)
def _run(coro):
@ -1334,6 +1370,496 @@ class TestChannelManager:
_run(go())
def test_handle_command_blank_text_is_reported_without_running_agent(self):
from app.channels.manager import ChannelManager
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store)
mock_client = _make_mock_langgraph_client()
manager._client = mock_client
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
await manager.start()
inbound = InboundMessage(
channel_name="test",
chat_id="chat1",
user_id="user1",
text=" ",
msg_type=InboundMessageType.COMMAND,
)
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
mock_client.runs.wait.assert_not_called()
assert outbound_received[0].text.startswith("Unknown command.")
_run(go())
def test_handle_command_rejects_multi_slash_control_command(self):
from app.channels.manager import ChannelManager
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store)
mock_client = _make_mock_langgraph_client()
manager._client = mock_client
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
await manager.start()
inbound = InboundMessage(
channel_name="test",
chat_id="chat1",
user_id="user1",
text="//help",
msg_type=InboundMessageType.COMMAND,
)
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
mock_client.runs.wait.assert_not_called()
assert outbound_received[0].text.startswith("Unknown command: //help.")
_run(go())
def test_handle_command_requires_control_command_at_start(self):
from app.channels.manager import ChannelManager
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store)
mock_client = _make_mock_langgraph_client(thread_id="new-thread-456")
manager._client = mock_client
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
await manager.start()
inbound = InboundMessage(
channel_name="test",
chat_id="chat1",
user_id="user1",
text=" /new",
msg_type=InboundMessageType.COMMAND,
)
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
mock_client.threads.create.assert_not_called()
assert store.get_thread_id("test", "chat1") is None
assert outbound_received[0].text.startswith("Unknown command: /new.")
_run(go())
def test_handle_command_outbound_thread_id_uses_topic_thread(self):
from app.channels.manager import ChannelManager
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store)
store.set_thread_id("test", "chat1", "base-thread")
store.set_thread_id("test", "chat1", "topic-thread", topic_id="topic-1")
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
await manager.start()
inbound = InboundMessage(
channel_name="test",
chat_id="chat1",
user_id="user1",
text="/status",
msg_type=InboundMessageType.COMMAND,
topic_id="topic-1",
)
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
assert outbound_received[0].text == "Active thread: topic-thread"
assert outbound_received[0].thread_id == "topic-thread"
_run(go())
def test_handle_command_slash_skill_routes_to_chat(self, tmp_path):
from app.channels.manager import ChannelManager
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store)
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
mock_client = _make_mock_langgraph_client()
manager._client = mock_client
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
await manager.start()
inbound = InboundMessage(
channel_name="test",
chat_id="chat1",
user_id="user1",
text="/data-analysis analyze uploads/foo.csv",
msg_type=InboundMessageType.COMMAND,
)
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
mock_client.runs.wait.assert_called_once()
call_args = mock_client.runs.wait.call_args
assert call_args[1]["input"]["messages"][0]["content"] == "/data-analysis analyze uploads/foo.csv"
assert outbound_received[0].text == "Hello from agent!"
_run(go())
def test_handle_command_slash_skill_with_attachment_preserves_original_content(self, monkeypatch, tmp_path):
from app.channels.manager import ChannelManager
async def fake_ingest(thread_id, msg):
return [
{
"filename": "report.pdf",
"size": 12,
"path": "/mnt/user-data/uploads/report.pdf",
"is_image": False,
}
]
monkeypatch.setattr("app.channels.manager._ingest_inbound_files", fake_ingest)
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store)
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
mock_client = _make_mock_langgraph_client()
manager._client = mock_client
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
await manager.start()
original_text = "/data-analysis analyze report.pdf"
inbound = InboundMessage(
channel_name="test",
chat_id="chat1",
user_id="user1",
text=original_text,
files=[{"filename": "report.pdf"}],
msg_type=InboundMessageType.COMMAND,
)
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
mock_client.runs.wait.assert_called_once()
human_message = mock_client.runs.wait.call_args[1]["input"]["messages"][0]
assert human_message["content"].startswith("<uploaded_files>")
assert original_text in human_message["content"]
assert human_message["additional_kwargs"][ORIGINAL_USER_CONTENT_KEY] == original_text
assert outbound_received[0].text == "Hello from agent!"
_run(go())
def test_streaming_slash_skill_with_attachment_preserves_original_content(self, monkeypatch, tmp_path):
from app.channels.manager import ChannelManager
async def fake_ingest(thread_id, msg):
return [
{
"filename": "report.pdf",
"size": 12,
"path": "/mnt/user-data/uploads/report.pdf",
"is_image": False,
}
]
monkeypatch.setattr("app.channels.manager._ingest_inbound_files", fake_ingest)
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store)
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
mock_client = _make_mock_langgraph_client()
mock_client.runs.stream = MagicMock(
return_value=_make_async_iterator(
[
_make_stream_part(
"values",
{"messages": [{"type": "ai", "content": "streamed response"}]},
)
]
)
)
manager._client = mock_client
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
await manager.start()
original_text = "/data-analysis analyze report.pdf"
inbound = InboundMessage(
channel_name="feishu",
chat_id="chat1",
user_id="user1",
text=original_text,
files=[{"filename": "report.pdf"}],
msg_type=InboundMessageType.COMMAND,
)
await bus.publish_inbound(inbound)
await _wait_for(lambda: any(message.is_final for message in outbound_received))
await manager.stop()
mock_client.runs.stream.assert_called_once()
human_message = mock_client.runs.stream.call_args[1]["input"]["messages"][0]
assert human_message["content"].startswith("<uploaded_files>")
assert original_text in human_message["content"]
assert human_message["additional_kwargs"][ORIGINAL_USER_CONTENT_KEY] == original_text
_run(go())
def test_handle_command_slash_skill_requires_command_at_start(self, tmp_path):
from app.channels.manager import ChannelManager
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store)
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
mock_client = _make_mock_langgraph_client()
manager._client = mock_client
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
await manager.start()
inbound = InboundMessage(
channel_name="test",
chat_id="chat1",
user_id="user1",
text=" /data-analysis analyze uploads/foo.csv",
msg_type=InboundMessageType.COMMAND,
)
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
mock_client.runs.wait.assert_not_called()
assert outbound_received[0].text.startswith("Unknown command: /data-analysis.")
_run(go())
def test_handle_command_slash_skill_respects_custom_agent_skill_whitelist(self, monkeypatch, tmp_path):
from app.channels.manager import ChannelManager
monkeypatch.setattr("app.channels.manager.load_agent_config", lambda name: SimpleNamespace(skills=["frontend-design"]))
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(
bus=bus,
store=store,
default_session={"assistant_id": "analyst-agent"},
)
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis")])
mock_client = _make_mock_langgraph_client()
manager._client = mock_client
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
await manager.start()
inbound = InboundMessage(
channel_name="test",
chat_id="chat1",
user_id="user1",
text="/data-analysis analyze uploads/foo.csv",
msg_type=InboundMessageType.COMMAND,
)
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
mock_client.runs.wait.assert_not_called()
assert outbound_received[0].text == "Skill `/data-analysis` is not available for this agent."
_run(go())
def test_handle_command_slash_skill_reports_disabled_skill(self, tmp_path):
from app.channels.manager import ChannelManager
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store)
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "data-analysis", enabled=False)])
mock_client = _make_mock_langgraph_client()
manager._client = mock_client
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
await manager.start()
inbound = InboundMessage(
channel_name="test",
chat_id="chat1",
user_id="user1",
text="/data-analysis analyze uploads/foo.csv",
msg_type=InboundMessageType.COMMAND,
)
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
mock_client.runs.wait.assert_not_called()
assert outbound_received[0].text == "Skill `/data-analysis` is installed but disabled. Enable it before using slash activation."
_run(go())
def test_handle_command_uninstalled_slash_skill_stays_unknown_command(self, tmp_path):
from app.channels.manager import ChannelManager
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store)
manager._skill_storage = _make_channel_skill_storage([_make_channel_skill(tmp_path, "frontend-design")])
mock_client = _make_mock_langgraph_client()
manager._client = mock_client
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
await manager.start()
inbound = InboundMessage(
channel_name="test",
chat_id="chat1",
user_id="user1",
text="/data-analysis analyze uploads/foo.csv",
msg_type=InboundMessageType.COMMAND,
)
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
mock_client.runs.wait.assert_not_called()
assert outbound_received[0].text.startswith("Unknown command: /data-analysis.")
_run(go())
def test_handle_command_slash_skill_resolution_error_is_reported(self, monkeypatch):
from app.channels.manager import ChannelManager, SlashSkillCommandResolutionError
def fail_resolution(text, available_skills=None, storage=None):
raise SlashSkillCommandResolutionError("Failed to resolve slash skill command. Please check the skill configuration.")
monkeypatch.setattr("app.channels.manager._resolve_slash_skill_command", fail_resolution)
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
manager = ChannelManager(bus=bus, store=store)
store.set_thread_id("test", "chat1", "base-thread")
store.set_thread_id("test", "chat1", "topic-thread", topic_id="topic-1")
mock_client = _make_mock_langgraph_client()
manager._client = mock_client
outbound_received = []
async def capture_outbound(msg):
outbound_received.append(msg)
bus.subscribe_outbound(capture_outbound)
await manager.start()
inbound = InboundMessage(
channel_name="test",
chat_id="chat1",
user_id="user1",
text="/data-analysis analyze uploads/foo.csv",
msg_type=InboundMessageType.COMMAND,
topic_id="topic-1",
)
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
mock_client.runs.wait.assert_not_called()
assert outbound_received[0].text == "Failed to resolve slash skill command. Please check the skill configuration."
assert outbound_received[0].thread_id == "topic-thread"
_run(go())
def test_handle_command_new(self):
from app.channels.manager import ChannelManager
@ -2440,6 +2966,36 @@ class TestWeComChannel:
_run(go())
def test_publish_ws_inbound_treats_slash_prefixed_paths_as_chat(self, monkeypatch):
from app.channels.wecom import WeComChannel
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = WeComChannel(bus, config={})
channel._ws_client = SimpleNamespace(reply_stream=AsyncMock())
monkeypatch.setitem(
__import__("sys").modules,
"aibot",
SimpleNamespace(generate_req_id=lambda prefix: "stream-1"),
)
frame = {
"body": {
"msgid": "msg-1",
"from": {"userid": "user-1"},
}
}
await channel._publish_ws_inbound(frame, "/mnt/user-data/uploads/report.pdf")
inbound = bus.publish_inbound.await_args.args[0]
assert inbound.text == "/mnt/user-data/uploads/report.pdf"
assert inbound.msg_type == InboundMessageType.CHAT
_run(go())
def test_on_outbound_sends_attachment_before_clearing_context(self, tmp_path):
from app.channels.wecom import WeComChannel
@ -2788,6 +3344,219 @@ class TestSlackAllowedUsers:
assert inbound.chat_id == "C123"
assert inbound.text == "hello from slack"
def test_app_mention_strips_leading_bot_mention_before_command_detection(self):
from app.channels.slack import SlackChannel
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
channel._loop = MagicMock()
channel._loop.is_running.return_value = True
channel._add_reaction = MagicMock()
channel._send_running_reply = MagicMock()
event = {
"type": "app_mention",
"user": "U123456",
"text": "<@UBOT> /help",
"channel": "C123",
"ts": "1710000000.000100",
}
with patch(
"app.channels.slack.asyncio.run_coroutine_threadsafe",
side_effect=self._submit_coro,
):
channel._handle_message_event(event)
inbound = bus.publish_inbound.call_args.args[0]
assert inbound.text == "/help"
assert inbound.msg_type == InboundMessageType.COMMAND
def test_app_mention_strips_labelled_leading_bot_mention(self):
from app.channels.slack import SlackChannel
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
channel._loop = MagicMock()
channel._loop.is_running.return_value = True
channel._add_reaction = MagicMock()
channel._send_running_reply = MagicMock()
event = {
"type": "app_mention",
"user": "U123456",
"text": "<@UBOT|deerflow> /help",
"channel": "C123",
"ts": "1710000000.000100",
}
with patch(
"app.channels.slack.asyncio.run_coroutine_threadsafe",
side_effect=self._submit_coro,
):
channel._handle_message_event(event)
inbound = bus.publish_inbound.call_args.args[0]
assert inbound.text == "/help"
assert inbound.msg_type == InboundMessageType.COMMAND
def test_app_mention_strips_leading_bot_mention_before_slash_skill(self):
from app.channels.slack import SlackChannel
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
channel._loop = MagicMock()
channel._loop.is_running.return_value = True
channel._add_reaction = MagicMock()
channel._send_running_reply = MagicMock()
event = {
"type": "app_mention",
"user": "U123456",
"text": "<@UBOT> /data-analysis analyze uploads/foo.csv",
"channel": "C123",
"ts": "1710000000.000100",
}
with patch(
"app.channels.slack.asyncio.run_coroutine_threadsafe",
side_effect=self._submit_coro,
):
channel._handle_message_event(event)
inbound = bus.publish_inbound.call_args.args[0]
assert inbound.text == "/data-analysis analyze uploads/foo.csv"
assert inbound.msg_type == InboundMessageType.CHAT
def test_app_mention_preserves_following_user_mention(self):
from app.channels.slack import SlackChannel
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
channel._loop = MagicMock()
channel._loop.is_running.return_value = True
channel._add_reaction = MagicMock()
channel._send_running_reply = MagicMock()
event = {
"type": "app_mention",
"user": "U123456",
"text": "<@UBOT> <@UASSIGNEE> please review this",
"channel": "C123",
"ts": "1710000000.000100",
}
with patch(
"app.channels.slack.asyncio.run_coroutine_threadsafe",
side_effect=self._submit_coro,
):
channel._handle_message_event(event)
inbound = bus.publish_inbound.call_args.args[0]
assert inbound.text == "<@UASSIGNEE> please review this"
assert inbound.msg_type == InboundMessageType.CHAT
def test_app_mention_preserves_leading_non_bot_mention_when_bot_id_known(self):
from app.channels.slack import SlackChannel
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
channel._loop = MagicMock()
channel._loop.is_running.return_value = True
channel._add_reaction = MagicMock()
channel._send_running_reply = MagicMock()
event = {
"type": "app_mention",
"user": "U123456",
"text": "<@UASSIGNEE> <@UBOT> please review this",
"channel": "C123",
"ts": "1710000000.000100",
}
with patch(
"app.channels.slack.asyncio.run_coroutine_threadsafe",
side_effect=self._submit_coro,
):
channel._handle_message_event(event)
inbound = bus.publish_inbound.call_args.args[0]
assert inbound.text == "<@UASSIGNEE> <@UBOT> please review this"
assert inbound.msg_type == InboundMessageType.CHAT
def test_app_mention_preserves_leading_non_bot_mention_when_bot_id_unknown(self):
from app.channels.slack import SlackChannel
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = SlackChannel(bus=bus, config={})
channel._loop = MagicMock()
channel._loop.is_running.return_value = True
channel._add_reaction = MagicMock()
channel._send_running_reply = MagicMock()
event = {
"type": "app_mention",
"user": "U123456",
"text": "<@UASSIGNEE> /help <@UBOT>",
"channel": "C123",
"ts": "1710000000.000100",
}
with patch(
"app.channels.slack.asyncio.run_coroutine_threadsafe",
side_effect=self._submit_coro,
):
channel._handle_message_event(event)
inbound = bus.publish_inbound.call_args.args[0]
assert inbound.text == "<@UASSIGNEE> /help <@UBOT>"
assert inbound.msg_type == InboundMessageType.CHAT
def test_socket_event_resolves_bot_user_id_before_app_mention_command_detection(self):
from app.channels.slack import SlackChannel
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = SlackChannel(bus=bus, config={})
channel._SocketModeResponse = lambda envelope_id: SimpleNamespace(envelope_id=envelope_id)
channel._loop = MagicMock()
channel._loop.is_running.return_value = True
channel._add_reaction = MagicMock()
channel._send_running_reply = MagicMock()
client = SimpleNamespace(send_socket_mode_response=MagicMock())
req = SimpleNamespace(
envelope_id="env-1",
type="events_api",
payload={
"authorizations": [{"user_id": "UBOT"}],
"event": {
"type": "app_mention",
"user": "U123456",
"text": "<@UBOT> /help",
"channel": "C123",
"ts": "1710000000.000100",
},
},
)
with patch(
"app.channels.slack.asyncio.run_coroutine_threadsafe",
side_effect=self._submit_coro,
):
channel._on_socket_event(client, req)
inbound = bus.publish_inbound.call_args.args[0]
assert channel._bot_user_id == "UBOT"
assert inbound.text == "/help"
assert inbound.msg_type == InboundMessageType.COMMAND
def test_scalar_allowed_users_warns_and_matches_stringified_event_user_id(self, caplog):
from app.channels.slack import SlackChannel
@ -2861,6 +3630,86 @@ class TestSlackAllowedUsers:
class TestTelegramSendRetry:
def test_start_registers_known_channel_commands(self, monkeypatch):
import sys
from types import ModuleType
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
from app.channels.telegram import TelegramChannel
class FakeFilter:
def __init__(self, expr: str):
self.expr = expr
def __and__(self, other):
return FakeFilter(f"{self.expr}&{other.expr}")
def __invert__(self):
return FakeFilter(f"~{self.expr}")
class FakeApplication:
def __init__(self):
self.handlers = []
def add_handler(self, handler):
self.handlers.append(handler)
fake_app = FakeApplication()
class FakeApplicationBuilder:
def token(self, token):
assert token == "test-token"
return self
def build(self):
return fake_app
def fake_command_handler(command, callback):
return SimpleNamespace(kind="command", command=command, callback=callback)
def fake_message_handler(filter_expr, callback):
return SimpleNamespace(kind="message", filter_expr=filter_expr, callback=callback)
telegram_mod = ModuleType("telegram")
telegram_ext_mod = ModuleType("telegram.ext")
telegram_ext_mod.ApplicationBuilder = FakeApplicationBuilder
telegram_ext_mod.CommandHandler = fake_command_handler
telegram_ext_mod.MessageHandler = fake_message_handler
telegram_ext_mod.filters = SimpleNamespace(TEXT=FakeFilter("TEXT"), COMMAND=FakeFilter("COMMAND"))
telegram_mod.ext = telegram_ext_mod
monkeypatch.setitem(sys.modules, "telegram", telegram_mod)
monkeypatch.setitem(sys.modules, "telegram.ext", telegram_ext_mod)
class FakeThread:
def __init__(self, *, target, daemon):
self.target = target
self.daemon = daemon
def start(self):
return None
def join(self, timeout=None):
return None
monkeypatch.setattr("app.channels.telegram.threading.Thread", FakeThread)
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
await ch.start()
try:
registered_commands = {handler.command for handler in fake_app.handlers if handler.kind == "command"}
expected_commands = {command.removeprefix("/") for command in KNOWN_CHANNEL_COMMANDS}
assert expected_commands <= registered_commands
assert "start" in registered_commands
message_filters = {handler.filter_expr.expr for handler in fake_app.handlers if handler.kind == "message"}
assert {"TEXT&COMMAND", "TEXT&~COMMAND"} <= message_filters
finally:
await ch.stop()
_run(go())
def test_retries_on_failure_then_succeeds(self):
from app.channels.telegram import TelegramChannel
@ -2984,6 +3833,47 @@ class TestTelegramPrivateChatThread:
_run(go())
def test_private_chat_slash_skill_text_routes_as_chat(self):
from app.channels.telegram import TelegramChannel
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
ch._main_loop = asyncio.get_event_loop()
update = _make_telegram_update("private", message_id=12, text="/data-analysis analyze uploads/foo.csv")
await ch._on_text(update, None)
msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
assert msg.text == "/data-analysis analyze uploads/foo.csv"
assert msg.msg_type == InboundMessageType.CHAT
assert msg.topic_id is None
_run(go())
def test_slash_skill_addressed_to_telegram_bot_strips_username(self):
from app.channels.telegram import TelegramChannel
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
ch._main_loop = asyncio.get_event_loop()
update = _make_telegram_update(
"group",
message_id=13,
text="/data-analysis@DeerFlowBot analyze uploads/foo.csv",
)
context = SimpleNamespace(bot=SimpleNamespace(username="DeerFlowBot"))
await ch._on_text(update, context)
msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
assert msg.text == "/data-analysis analyze uploads/foo.csv"
assert msg.msg_type == InboundMessageType.CHAT
assert msg.topic_id == "13"
_run(go())
def test_private_chat_with_reply_still_uses_none_topic(self):
from app.channels.telegram import TelegramChannel
@ -3099,6 +3989,25 @@ class TestTelegramPrivateChatThread:
_run(go())
def test_cmd_generic_strips_addressed_telegram_bot_username(self):
from app.channels.telegram import TelegramChannel
async def go():
bus = MessageBus()
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
ch._main_loop = asyncio.get_event_loop()
update = _make_telegram_update("group", message_id=33, text="/status@DeerFlowBot")
context = SimpleNamespace(bot=SimpleNamespace(username="DeerFlowBot"))
await ch._cmd_generic(update, context)
msg = await asyncio.wait_for(bus.get_inbound(), timeout=2)
assert msg.text == "/status"
assert msg.topic_id == "33"
assert msg.msg_type == InboundMessageType.COMMAND
_run(go())
class TestTelegramProcessingOrder:
"""Ensure 'working on it...' is sent before inbound is published."""

View File

@ -2,9 +2,13 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.channels.discord import DiscordChannel
from app.channels.manager import CHANNEL_CAPABILITIES
from app.channels.message_bus import MessageBus
from app.channels.message_bus import InboundMessageType, MessageBus
from app.channels.service import _CHANNEL_REGISTRY
@ -21,3 +25,64 @@ def test_discord_channel_init() -> None:
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
assert channel.name == "discord"
def _make_discord_message(text: str):
return SimpleNamespace(
id=111,
content=text,
author=SimpleNamespace(id=123, bot=False, display_name="alice"),
guild=SimpleNamespace(id=321),
channel=SimpleNamespace(id=456),
add_reaction=lambda _emoji: None,
)
@pytest.mark.asyncio
async def test_discord_bot_mention_slash_skill_routes_as_chat() -> None:
bus = MessageBus()
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
captured = []
channel._running = True
channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>"))
channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {}))
channel._publish = captured.append
async def noop(*_args, **_kwargs):
return None
channel._start_typing = noop
channel._add_reaction = noop
await channel._on_message(_make_discord_message("<@999> /data-analysis analyze uploads/foo.csv"))
assert len(captured) == 1
inbound = captured[0]
assert inbound.text == "/data-analysis analyze uploads/foo.csv"
assert inbound.msg_type == InboundMessageType.CHAT
assert inbound.topic_id == "456"
@pytest.mark.asyncio
async def test_discord_bot_mention_known_command_routes_as_command() -> None:
bus = MessageBus()
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
captured = []
channel._running = True
channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>"))
channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {}))
channel._publish = captured.append
async def noop(*_args, **_kwargs):
return None
channel._start_typing = noop
channel._add_reaction = noop
await channel._on_message(_make_discord_message("<@999> /help"))
assert len(captured) == 1
inbound = captured[0]
assert inbound.text == "/help"
assert inbound.msg_type == InboundMessageType.COMMAND
assert inbound.topic_id == "456"

View File

@ -60,6 +60,17 @@ def test_get_skills_prompt_section_returns_all_when_available_skills_is_none(mon
assert "skill2" in result
def test_get_skills_prompt_section_includes_slash_activation_guidance(monkeypatch):
skills = [_make_skill("data-analysis")]
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)
result = get_skills_prompt_section(available_skills={"data-analysis"})
assert "Explicit Slash Skill Activation" in result
assert "The runtime injects the activated skill content" in result
assert "do not call `read_file` for that SKILL.md again" in result
def test_get_skills_prompt_section_includes_self_evolution_rules(monkeypatch):
skills = [_make_skill("skill1")]
monkeypatch.setattr("deerflow.agents.lead_agent.prompt._get_enabled_skills", lambda: skills)

View File

@ -0,0 +1,557 @@
import asyncio
import hashlib
from pathlib import Path
from types import SimpleNamespace
from langchain.agents.middleware.types import ModelRequest
from langchain_core.messages import AIMessage, HumanMessage
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
from deerflow.agents.middlewares import skill_activation_middleware as middleware_module
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware, is_slash_skill_activation_reminder
from deerflow.skills.slash import RESERVED_SLASH_SKILL_NAMES, parse_slash_skill_reference, resolve_slash_skill
from deerflow.skills.types import Skill, SkillCategory
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
def _make_skill(tmp_path: Path, name: str, content: str = "skill body") -> Skill:
skill_dir = tmp_path / name
skill_dir.mkdir()
skill_file = skill_dir / "SKILL.md"
skill_file.write_text(content, encoding="utf-8")
return Skill(
name=name,
description=f"Description for {name}",
license="MIT",
skill_dir=skill_dir,
skill_file=skill_file,
relative_path=Path(name),
category=SkillCategory.CUSTOM,
enabled=True,
)
def _make_storage(tmp_path: Path, skills: list[Skill]):
return SimpleNamespace(
load_skills=lambda *, enabled_only: [skill for skill in skills if skill.enabled] if enabled_only else skills,
get_container_root=lambda: "/mnt/skills",
get_skills_root_path=lambda: tmp_path,
)
def _make_model_request(messages: list[HumanMessage], *, runtime=None) -> ModelRequest:
return ModelRequest(
model=object(),
messages=messages,
state={"messages": list(messages)},
runtime=runtime,
)
def test_parse_slash_skill_reference_extracts_name_and_remaining_text():
parsed = parse_slash_skill_reference("/data-analysis analyze uploads/foo.csv")
assert parsed is not None
assert parsed.name == "data-analysis"
assert parsed.remaining_text == "analyze uploads/foo.csv"
def test_parse_slash_skill_reference_accepts_skill_name_without_task():
parsed = parse_slash_skill_reference("/data-analysis")
assert parsed is not None
assert parsed.name == "data-analysis"
assert parsed.remaining_text == ""
def test_parse_slash_skill_reference_rejects_invalid_names():
assert parse_slash_skill_reference("/DataAnalysis run") is None
assert parse_slash_skill_reference("/data_analysis run") is None
assert parse_slash_skill_reference("please use /data-analysis") is None
assert parse_slash_skill_reference(" /data-analysis run") is None
assert parse_slash_skill_reference("/data-analysis分析这个文档") is None
def test_resolve_slash_skill_ignores_reserved_control_commands(tmp_path):
for command in ["bootstrap", "help", "memory", "models", "new", "status"]:
skill = _make_skill(tmp_path, command)
assert resolve_slash_skill(f"/{command} create an agent", [skill]) is None
def test_reserved_slash_skill_names_match_channel_commands():
assert RESERVED_SLASH_SKILL_NAMES == {command.removeprefix("/") for command in KNOWN_CHANNEL_COMMANDS}
def test_resolve_slash_skill_respects_available_skill_whitelist(tmp_path):
skill = _make_skill(tmp_path, "data-analysis")
assert resolve_slash_skill("/data-analysis run", [skill], available_skills=set()) is None
resolved = resolve_slash_skill("/data-analysis run", [skill], available_skills={"data-analysis"})
assert resolved is not None
assert resolved.skill.name == "data-analysis"
assert resolved.remaining_text == "run"
assert resolved.container_file_path == "/mnt/skills/custom/data-analysis/SKILL.md"
def test_resolve_slash_skill_rejects_disabled_skills(tmp_path):
skill = _make_skill(tmp_path, "data-analysis")
skill.enabled = False
assert resolve_slash_skill("/data-analysis run", [skill]) is None
def test_skill_activation_middleware_injects_hidden_human_context_for_model_call(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
request = _make_model_request([original])
captured = {}
def handler(model_request: ModelRequest):
captured["messages"] = model_request.messages
return AIMessage(content="ok")
result = middleware.wrap_model_call(request, handler)
assert isinstance(result, AIMessage)
assert result.content == "ok"
activation_msg, user_msg = captured["messages"]
assert is_slash_skill_activation_reminder(activation_msg)
assert activation_msg.additional_kwargs["hide_from_ui"] is True
assert "Use pandas." in activation_msg.content
assert "<user_request>\nanalyze uploads/foo.csv\n</user_request>" in activation_msg.content
assert user_msg.content == original.content
assert request.state["messages"] == [original]
def test_skill_activation_middleware_does_not_duplicate_existing_activation(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
first_capture = {}
def first_handler(model_request: ModelRequest):
first_capture["messages"] = model_request.messages
return AIMessage(content="ok")
first_result = middleware.wrap_model_call(_make_model_request([original]), first_handler)
assert isinstance(first_result, AIMessage)
activation_msg, user_msg = first_capture["messages"]
assert is_slash_skill_activation_reminder(activation_msg)
second_capture = {}
def second_handler(model_request: ModelRequest):
second_capture["messages"] = model_request.messages
return AIMessage(content="ok")
second_result = middleware.wrap_model_call(_make_model_request([activation_msg, user_msg]), second_handler)
assert isinstance(second_result, AIMessage)
assert second_capture["messages"] == [activation_msg, user_msg]
assert sum(is_slash_skill_activation_reminder(message) for message in second_capture["messages"]) == 1
def test_skill_activation_middleware_does_not_duplicate_activation_separated_by_hidden_context(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
first_capture = {}
def first_handler(model_request: ModelRequest):
first_capture["messages"] = model_request.messages
return AIMessage(content="ok")
middleware.wrap_model_call(_make_model_request([original]), first_handler)
activation_msg, user_msg = first_capture["messages"]
hidden_context = HumanMessage(content="dynamic context", additional_kwargs={"hide_from_ui": True})
second_capture = {}
def second_handler(model_request: ModelRequest):
second_capture["messages"] = model_request.messages
return AIMessage(content="ok")
second_result = middleware.wrap_model_call(_make_model_request([activation_msg, hidden_context, user_msg]), second_handler)
assert isinstance(second_result, AIMessage)
assert second_capture["messages"] == [activation_msg, hidden_context, user_msg]
assert sum(is_slash_skill_activation_reminder(message) for message in second_capture["messages"]) == 1
def test_skill_activation_middleware_dedupes_immediately_previous_activation_without_target_id(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
legacy_activation_msg = SkillActivationMiddleware._make_activation_message(
HumanMessage(content="/data-analysis analyze uploads/foo.csv"),
"existing activation context",
)
target = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
captured = {}
def handler(model_request: ModelRequest):
captured["messages"] = model_request.messages
return AIMessage(content="ok")
result = middleware.wrap_model_call(_make_model_request([legacy_activation_msg, target]), handler)
assert isinstance(result, AIMessage)
assert captured["messages"] == [legacy_activation_msg, target]
assert sum(is_slash_skill_activation_reminder(message) for message in captured["messages"]) == 1
def test_skill_activation_middleware_async_injects_hidden_human_context_for_model_call(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
request = _make_model_request([original])
captured = {}
async def handler(model_request: ModelRequest):
captured["messages"] = model_request.messages
return AIMessage(content="ok")
result = asyncio.run(middleware.awrap_model_call(request, handler))
assert isinstance(result, AIMessage)
assert result.content == "ok"
activation_msg, user_msg = captured["messages"]
assert is_slash_skill_activation_reminder(activation_msg)
assert activation_msg.additional_kwargs["hide_from_ui"] is True
assert "Use pandas." in activation_msg.content
assert "<user_request>\nanalyze uploads/foo.csv\n</user_request>" in activation_msg.content
assert user_msg.content == original.content
assert request.state["messages"] == [original]
def test_skill_activation_middleware_uses_fallback_when_task_text_is_empty(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
original = HumanMessage(content="/data-analysis", id="msg-1")
captured = {}
def handler(model_request: ModelRequest):
captured["messages"] = model_request.messages
return AIMessage(content="ok")
result = middleware.wrap_model_call(_make_model_request([original]), handler)
assert isinstance(result, AIMessage)
activation_msg = captured["messages"][0]
assert "No additional task text was provided after the slash skill command." in activation_msg.content
def test_skill_activation_middleware_uses_original_user_content_when_uploads_are_injected(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
original = HumanMessage(
content="<uploaded_files>\n- report.pdf\n</uploaded_files>\n\n/data-analysis 分析这个文档",
id="msg-1",
additional_kwargs={ORIGINAL_USER_CONTENT_KEY: "/data-analysis 分析这个文档"},
)
captured = {}
def handler(model_request: ModelRequest):
captured["messages"] = model_request.messages
return AIMessage(content="ok")
result = middleware.wrap_model_call(_make_model_request([original]), handler)
assert isinstance(result, AIMessage)
assert result.content == "ok"
activation_msg, user_msg = captured["messages"]
assert is_slash_skill_activation_reminder(activation_msg)
assert "Use pandas." in activation_msg.content
assert "<user_request>\n分析这个文档\n</user_request>" in activation_msg.content
assert user_msg.content == original.content
assert user_msg.additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "/data-analysis 分析这个文档"
def test_skill_activation_middleware_activates_from_list_content(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
original = HumanMessage(content=[{"type": "text", "text": "/data-analysis analyze uploads/foo.csv"}], id="msg-1")
captured = {}
def handler(model_request: ModelRequest):
captured["messages"] = model_request.messages
return AIMessage(content="ok")
result = middleware.wrap_model_call(_make_model_request([original]), handler)
assert isinstance(result, AIMessage)
activation_msg, user_msg = captured["messages"]
assert is_slash_skill_activation_reminder(activation_msg)
assert "<user_request>\nanalyze uploads/foo.csv\n</user_request>" in activation_msg.content
assert user_msg.content == original.content
def test_skill_activation_middleware_records_activation_audit_event(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
recorded = []
journal = SimpleNamespace(record_middleware=lambda *args, **kwargs: recorded.append((args, kwargs)))
runtime = SimpleNamespace(context={"__run_journal": journal})
middleware = SkillActivationMiddleware()
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
def handler(model_request: ModelRequest):
return AIMessage(content="ok")
result = middleware.wrap_model_call(_make_model_request([original], runtime=runtime), handler)
assert isinstance(result, AIMessage)
assert len(recorded) == 1
args, kwargs = recorded[0]
assert args == ("skill_activation",)
assert kwargs["name"] == "SkillActivationMiddleware"
assert kwargs["hook"] == "wrap_model_call"
assert kwargs["action"] == "activate"
assert kwargs["changes"] == {
"skill_name": "data-analysis",
"category": "custom",
"path": "/mnt/skills/custom/data-analysis/SKILL.md",
"content_hash": hashlib.sha256(b"# Data Analysis\nUse pandas.").hexdigest(),
}
def test_skill_activation_middleware_async_records_activation_audit_event(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
recorded = []
journal = SimpleNamespace(record_middleware=lambda *args, **kwargs: recorded.append((args, kwargs)))
runtime = SimpleNamespace(context={"__run_journal": journal})
middleware = SkillActivationMiddleware()
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
async def handler(model_request: ModelRequest):
return AIMessage(content="ok")
result = asyncio.run(middleware.awrap_model_call(_make_model_request([original], runtime=runtime), handler))
assert isinstance(result, AIMessage)
assert len(recorded) == 1
args, kwargs = recorded[0]
assert args == ("skill_activation",)
assert kwargs["hook"] == "awrap_model_call"
assert kwargs["changes"]["skill_name"] == "data-analysis"
assert kwargs["changes"]["content_hash"] == hashlib.sha256(b"# Data Analysis\nUse pandas.").hexdigest()
def test_skill_activation_middleware_ignores_activation_audit_errors(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
journal = SimpleNamespace(record_middleware=lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("db down")))
runtime = SimpleNamespace(context={"__run_journal": journal})
middleware = SkillActivationMiddleware()
original = HumanMessage(content="/data-analysis analyze uploads/foo.csv", id="msg-1")
def handler(model_request: ModelRequest):
return AIMessage(content="ok")
result = middleware.wrap_model_call(_make_model_request([original], runtime=runtime), handler)
assert isinstance(result, AIMessage)
assert result.content == "ok"
def test_skill_activation_middleware_activates_only_latest_real_user_message(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
old_slash = HumanMessage(content="/data-analysis old request", id="msg-1")
latest_user = HumanMessage(content="continue normally", id="msg-2")
request = _make_model_request([old_slash, AIMessage(content="done"), latest_user])
captured = {}
def handler(model_request: ModelRequest):
captured["messages"] = model_request.messages
return AIMessage(content="ok")
result = middleware.wrap_model_call(request, handler)
assert isinstance(result, AIMessage)
assert captured["messages"] == request.messages
assert not any(is_slash_skill_activation_reminder(message) for message in captured["messages"])
def test_skill_activation_middleware_ignores_hidden_and_summary_user_messages(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis", content="# Data Analysis\nUse pandas.")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
real_user = HumanMessage(content="continue normally", id="msg-1")
hidden_slash = HumanMessage(content="/data-analysis hidden request", id="msg-2", additional_kwargs={"hide_from_ui": True})
summary_slash = HumanMessage(content="/data-analysis summary request", id="msg-3", name="summary")
request = _make_model_request([real_user, hidden_slash, summary_slash])
captured = {}
def handler(model_request: ModelRequest):
captured["messages"] = model_request.messages
return AIMessage(content="ok")
result = middleware.wrap_model_call(request, handler)
assert isinstance(result, AIMessage)
assert captured["messages"] == request.messages
assert not any(is_slash_skill_activation_reminder(message) for message in captured["messages"])
def test_skill_activation_middleware_returns_clear_error_for_disallowed_skill(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware(available_skills={"frontend-design"})
original = HumanMessage(content="/data-analysis run")
def handler(model_request: ModelRequest):
raise AssertionError("handler should not be called for invalid slash skills")
result = middleware.wrap_model_call(_make_model_request([original]), handler)
assert isinstance(result, AIMessage)
assert "not available for this agent" in result.content
def test_skill_activation_middleware_returns_clear_error_for_missing_skill(monkeypatch, tmp_path):
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, []))
middleware = SkillActivationMiddleware()
original = HumanMessage(content="/data-analysis run")
def handler(model_request: ModelRequest):
raise AssertionError("handler should not be called for missing slash skills")
result = middleware.wrap_model_call(_make_model_request([original]), handler)
assert isinstance(result, AIMessage)
assert "not installed" in result.content
def test_skill_activation_middleware_returns_clear_error_for_disabled_skill(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis")
skill.enabled = False
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
original = HumanMessage(content="/data-analysis run")
def handler(model_request: ModelRequest):
raise AssertionError("handler should not be called for disabled slash skills")
result = middleware.wrap_model_call(_make_model_request([original]), handler)
assert isinstance(result, AIMessage)
assert "installed but disabled" in result.content
def test_skill_activation_middleware_escapes_activation_content(monkeypatch, tmp_path):
skill = _make_skill(
tmp_path,
"data-analysis",
content="# Data Analysis\nUse <xml> & avoid </skill> collisions.\n----- END SKILL.md -----",
)
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
original = HumanMessage(content="/data-analysis analyze </user_request>")
captured = {}
def handler(model_request: ModelRequest):
captured["messages"] = model_request.messages
return AIMessage(content="ok")
result = middleware.wrap_model_call(_make_model_request([original]), handler)
assert isinstance(result, AIMessage)
activation_msg = captured["messages"][0]
assert '<skill_content encoding="xml-escaped">' in activation_msg.content
assert "analyze &lt;/user_request&gt;" in activation_msg.content
assert "Use &lt;xml&gt; &amp; avoid &lt;/skill&gt; collisions." in activation_msg.content
assert "----- BEGIN SKILL.md -----" not in activation_msg.content
def test_skill_activation_middleware_rejects_skill_file_outside_skills_root(monkeypatch, tmp_path):
skills_root = tmp_path / "skills"
skill_dir = skills_root / "custom" / "data-analysis"
skill_dir.mkdir(parents=True)
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
outside_file = outside_dir / "SKILL.md"
outside_file.write_text("# Leaked\nDo not read me.", encoding="utf-8")
(skill_dir / "SKILL.md").symlink_to(outside_file)
skill = Skill(
name="data-analysis",
description="Description for data-analysis",
license="MIT",
skill_dir=skill_dir,
skill_file=skill_dir / "SKILL.md",
relative_path=Path("data-analysis"),
category=SkillCategory.CUSTOM,
enabled=True,
)
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(skills_root, [skill]))
middleware = SkillActivationMiddleware()
def handler(model_request: ModelRequest):
raise AssertionError("handler should not be called when SKILL.md fails safety checks")
result = middleware.wrap_model_call(_make_model_request([HumanMessage(content="/data-analysis run")]), handler)
assert isinstance(result, AIMessage)
assert "could not be loaded safely" in result.content
def test_skill_activation_middleware_reports_missing_skill_file_safely(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis")
skill.skill_file.unlink()
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
def handler(model_request: ModelRequest):
raise AssertionError("handler should not be called when SKILL.md is missing")
result = middleware.wrap_model_call(_make_model_request([HumanMessage(content="/data-analysis run")]), handler)
assert isinstance(result, AIMessage)
assert "could not be loaded safely" in result.content
def test_skill_activation_middleware_reports_invalid_utf8_skill_file_safely(monkeypatch, tmp_path):
skill = _make_skill(tmp_path, "data-analysis")
skill.skill_file.write_bytes(b"\xff\xfe\x00")
monkeypatch.setattr(middleware_module, "get_or_new_skill_storage", lambda **kwargs: _make_storage(tmp_path, [skill]))
middleware = SkillActivationMiddleware()
def handler(model_request: ModelRequest):
raise AssertionError("handler should not be called when SKILL.md is not valid UTF-8")
result = middleware.wrap_model_call(_make_model_request([HumanMessage(content="/data-analysis run")]), handler)
assert isinstance(result, AIMessage)
assert "could not be loaded safely" in result.content

View File

@ -14,6 +14,7 @@ from langchain_core.messages import AIMessage, HumanMessage
from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware
from deerflow.config.paths import Paths
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
THREAD_ID = "thread-abc123"
@ -263,6 +264,22 @@ class TestBeforeAgent:
assert "<uploaded_files>" in combined_text
assert "analyse this" in combined_text
def test_list_content_preserves_original_slash_skill_text(self, tmp_path):
mw = _middleware(tmp_path)
uploads_dir = _uploads_dir(tmp_path)
(uploads_dir / "data.csv").write_bytes(b"a,b")
msg = _human(
[{"type": "text", "text": "/data-analysis analyze data.csv"}],
files=[{"filename": "data.csv", "size": 3, "path": "/mnt/user-data/uploads/data.csv"}],
)
result = mw.before_agent(self._state(msg), _runtime())
assert result is not None
updated_msg = result["messages"][-1]
assert isinstance(updated_msg.content, list)
assert updated_msg.additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "/data-analysis analyze data.csv"
def test_preserves_additional_kwargs_on_updated_message(self, tmp_path):
mw = _middleware(tmp_path)
uploads_dir = _uploads_dir(tmp_path)
@ -278,6 +295,37 @@ class TestBeforeAgent:
assert updated_kwargs.get("files") == files_meta
assert updated_kwargs.get("element") == "task"
def test_preserves_original_user_content_before_upload_context(self, tmp_path):
mw = _middleware(tmp_path)
uploads_dir = _uploads_dir(tmp_path)
(uploads_dir / "report.pdf").write_bytes(b"pdf")
msg = _human(
"/data-analysis 分析这个文档",
files=[{"filename": "report.pdf", "size": 3, "path": "/mnt/user-data/uploads/report.pdf"}],
)
result = mw.before_agent(self._state(msg), _runtime())
assert result is not None
updated_msg = result["messages"][-1]
assert updated_msg.content.startswith("<uploaded_files>")
assert updated_msg.additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "/data-analysis 分析这个文档"
def test_preserves_existing_original_user_content_marker(self, tmp_path):
mw = _middleware(tmp_path)
uploads_dir = _uploads_dir(tmp_path)
(uploads_dir / "report.pdf").write_bytes(b"pdf")
msg = _human(
"<uploaded_files>\nold\n</uploaded_files>\n\n/data-analysis run",
files=[{"filename": "report.pdf", "size": 3, "path": "/mnt/user-data/uploads/report.pdf"}],
**{ORIGINAL_USER_CONTENT_KEY: "/data-analysis run"},
)
result = mw.before_agent(self._state(msg), _runtime())
assert result is not None
assert result["messages"][-1].additional_kwargs[ORIGINAL_USER_CONTENT_KEY] == "/data-analysis run"
def test_uploaded_files_returned_in_state_update(self, tmp_path):
mw = _middleware(tmp_path)
uploads_dir = _uploads_dir(tmp_path)

View File

@ -9,6 +9,8 @@ export default tseslint.config(
{
ignores: [
".next",
"playwright-report",
"test-results",
"src/components/ui/**",
"src/components/ai-elements/**",
"*.js",

View File

@ -881,6 +881,7 @@ export type PromptInputTextareaProps = ComponentProps<
export const PromptInputTextarea = ({
onChange,
onKeyDown,
className,
placeholder = "What would you like to know?",
...props
@ -891,6 +892,10 @@ export const PromptInputTextarea = ({
const [isComposing, setIsComposing] = useState(false);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
onKeyDown?.(e);
if (e.defaultPrevented) {
return;
}
if (e.key === "Enter") {
if (isIMEComposing(e, isComposing)) {
return;

View File

@ -20,6 +20,7 @@ import {
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
} from "react";
import {
@ -59,6 +60,8 @@ import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import type { Skill } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils";
import { cn } from "@/lib/utils";
@ -86,6 +89,48 @@ import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra";
const MAX_SKILL_SUGGESTIONS = 6;
function getLeadingSlashSkillQuery(value: string): string | null {
if (!value.startsWith("/")) {
return null;
}
const query = value.slice(1);
if (query.includes("/") || /\s/.test(query)) {
return null;
}
return query;
}
function getMatchingSkillSuggestions(skills: Skill[], query: string): Skill[] {
const normalizedQuery = query.toLowerCase();
return skills
.map((skill, index) => ({
skill,
index,
name: skill.name.toLowerCase(),
}))
.filter(({ skill, name }) => {
if (!skill.enabled) {
return false;
}
return !normalizedQuery || name.includes(normalizedQuery);
})
.sort((a, b) => {
const aStartsWith = a.name.startsWith(normalizedQuery);
const bStartsWith = b.name.startsWith(normalizedQuery);
if (aStartsWith !== bStartsWith) {
return aStartsWith ? -1 : 1;
}
return a.index - b.index;
})
.slice(0, MAX_SKILL_SUGGESTIONS)
.map(({ skill }) => skill);
}
function getResolvedMode(
mode: InputMode | undefined,
supportsThinking: boolean,
@ -153,11 +198,17 @@ export function InputBox({
const { models } = useModels();
const { thread, isMock } = useThread();
const { textInput } = usePromptInputController();
const { skills } = useSkills();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false);
const [textareaFocused, setTextareaFocused] = useState(false);
const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0);
const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] =
useState<string | null>(null);
const lastGeneratedForAiIdRef = useRef<string | null>(null);
const wasStreamingRef = useRef(false);
const messagesRef = useRef(thread.messages);
@ -347,9 +398,98 @@ export function InputBox({
setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]);
const slashSkillQuery = useMemo(
() => getLeadingSlashSkillQuery(textInput.value ?? ""),
[textInput.value],
);
const skillSuggestions = useMemo(
() =>
slashSkillQuery === null
? []
: getMatchingSkillSuggestions(skills, slashSkillQuery),
[skills, slashSkillQuery],
);
const showSkillSuggestions =
!disabled &&
textareaFocused &&
slashSkillQuery !== null &&
skillSuggestions.length > 0 &&
dismissedSkillSuggestionValue !== textInput.value;
useEffect(() => {
setSkillSuggestionIndex(0);
}, [slashSkillQuery, skillSuggestions.length]);
const applySkillSuggestion = useCallback(
(skill: Skill) => {
const nextValue = `/${skill.name} `;
textInput.setInput(nextValue);
setDismissedSkillSuggestionValue(nextValue);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.focus();
textarea.setSelectionRange(nextValue.length, nextValue.length);
});
},
[textInput],
);
const handleSkillSuggestionKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (!showSkillSuggestions) {
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setSkillSuggestionIndex(
(index) => (index + 1) % skillSuggestions.length,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setSkillSuggestionIndex(
(index) =>
(index - 1 + skillSuggestions.length) % skillSuggestions.length,
);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
if (event.shiftKey) {
return;
}
event.preventDefault();
const selectedSkill = skillSuggestions[skillSuggestionIndex];
if (selectedSkill) {
applySkillSuggestion(selectedSkill);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
setDismissedSkillSuggestionValue(textInput.value);
}
},
[
applySkillSuggestion,
showSkillSuggestions,
skillSuggestionIndex,
skillSuggestions,
textInput.value,
],
);
const showFollowups =
!disabled &&
!isWelcomeMode &&
!showSkillSuggestions &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
@ -478,6 +618,48 @@ export function InputBox({
</div>
</div>
)}
{showSkillSuggestions && (
<div className="absolute right-0 bottom-full left-0 z-40 mb-2 px-1">
<div
aria-label="Skill suggestions"
className="bg-popover/95 text-popover-foreground border-border max-h-72 overflow-y-auto rounded-xl border p-1 shadow-lg backdrop-blur-sm"
role="listbox"
>
{skillSuggestions.map((skill, index) => {
const selected = index === skillSuggestionIndex;
return (
<button
aria-selected={selected}
className={cn(
"flex min-h-12 w-full min-w-0 cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors",
selected
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/70 hover:text-accent-foreground",
)}
key={skill.name}
onClick={() => applySkillSuggestion(skill)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setSkillSuggestionIndex(index)}
role="option"
type="button"
>
<SparklesIcon className="text-muted-foreground size-4 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
/{skill.name}
</span>
{skill.description && (
<span className="text-muted-foreground block truncate text-xs">
{skill.description}
</span>
)}
</span>
</button>
);
})}
</div>
</div>
)}
<PromptInput
className={cn(
"bg-background/85 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
@ -506,6 +688,10 @@ export function InputBox({
placeholder={t.inputBox.placeholder}
autoFocus={autoFocus}
defaultValue={initialValue}
onBlur={() => setTextareaFocused(false)}
onFocus={() => setTextareaFocused(true)}
onKeyDown={handleSkillSuggestionKeyDown}
ref={textareaRef}
/>
</PromptInputBody>
<PromptInputFooter className="flex">
@ -860,11 +1046,13 @@ export function InputBox({
)}
</PromptInput>
{isWelcomeMode && searchParams.get("mode") !== "skill" && (
<div className="flex items-center justify-center pt-2">
<SuggestionList />
</div>
)}
{isWelcomeMode &&
searchParams.get("mode") !== "skill" &&
!showSkillSuggestions && (
<div className="flex items-center justify-center pt-2">
<SuggestionList />
</div>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>

View File

@ -469,10 +469,14 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
}
export function isHiddenFromUIMessage(message: Message) {
const content = extractTextFromMessage(message);
return (
message.additional_kwargs?.hide_from_ui === true ||
(typeof message.name === "string" &&
HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name))
HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name)) ||
(message.type === "human" &&
content.includes("<slash_skill_activation>") &&
stripUploadedFilesTag(content).length === 0)
);
}
@ -488,12 +492,13 @@ export interface FileInMessage {
}
/**
* Strip <uploaded_files> tag from message content.
* Returns the content with the tag removed.
* Strip backend-injected human context tags from message content.
* Kept under its historical name because callers use it for uploaded-file
* display cleanup.
*/
export function stripUploadedFilesTag(content: string): string {
return content
.replace(/<uploaded_files>[\s\S]*?<\/uploaded_files>/g, "")
.replace(/<(uploaded_files|slash_skill_activation)>[\s\S]*?<\/\1>/g, "")
.trim();
}
@ -504,6 +509,7 @@ export function stripUploadedFilesTag(content: string): string {
* These markers are *not* user copy they come from:
*
* - ``UploadsMiddleware`` ``<uploaded_files>``
* - ``SkillActivationMiddleware`` ``<slash_skill_activation>``
* - ``DynamicContextMiddleware`` ``<system-reminder>`` (carrying
* ``<memory>`` / ``<current_date>`` inside)
* - ``TodoListMiddleware`` / ``LoopDetectionMiddleware`` style reminders
@ -517,6 +523,7 @@ export function stripUploadedFilesTag(content: string): string {
*/
export const INTERNAL_MARKER_TAGS = [
"uploaded_files",
"slash_skill_activation",
"system-reminder",
"memory",
"current_date",

View File

@ -24,6 +24,61 @@ test.describe("Chat workspace", () => {
await expect(textarea).toHaveValue("Hello, DeerFlow!");
});
test("suggests matching skills after a leading slash", async ({ page }) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await expect(
page.getByRole("option", { name: /disabled-skill/i }),
).toBeHidden();
await textarea.press("Enter");
await expect(textarea).toHaveValue("/data-analysis ");
});
test("keeps Shift+Enter as newline while skill suggestions are visible", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await textarea.press("Shift+Enter");
await expect(textarea).toHaveValue("/dat\n");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeHidden();
});
test("does not suggest skills for slash text away from the prompt start", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("please /dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeHidden();
});
test("sending a message triggers API call and shows response", async ({
page,
}) => {
@ -49,6 +104,150 @@ test.describe("Chat workspace", () => {
});
});
test("slash skill command is submitted as normal chat text", async ({
page,
}) => {
const slashCommand = "/data-analysis analyze uploads/foo.csv";
let submittedText: string | undefined;
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill(slashCommand);
await textarea.press("Enter");
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe(slashCommand);
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
});
test("slash skill command with attachment preserves command text and file metadata", async ({
page,
}) => {
const slashCommand = "/data-analysis analyze report.docx";
let uploadCalled = false;
let submittedText: string | undefined;
let submittedFiles:
| Array<{ filename?: string; path?: string; status?: string }>
| undefined;
await page.route("**/api/threads/*/uploads", async (route) => {
uploadCalled = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
message: "Uploaded",
files: [
{
filename: "report.docx",
size: 12,
path: "report.docx",
virtual_path: "/mnt/user-data/uploads/report.docx",
artifact_url: "/api/threads/test/uploads/report.docx",
extension: ".docx",
},
],
}),
});
});
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: {
messages?: Array<{
content?: unknown;
additional_kwargs?: {
files?: Array<{
filename?: string;
path?: string;
status?: string;
}>;
};
}>;
};
};
const message = body.input?.messages?.at(-1);
const content = message?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
submittedFiles = message?.additional_kwargs?.files;
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await page.getByLabel("Upload files").setInputFiles({
name: "report.docx",
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
buffer: Buffer.from("fake docx"),
});
await textarea.fill(slashCommand);
await textarea.press("Enter");
await expect.poll(() => uploadCalled, { timeout: 10_000 }).toBeTruthy();
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe(slashCommand);
await expect
.poll(() => submittedFiles, { timeout: 10_000 })
.toEqual([
{
filename: "report.docx",
size: 12,
path: "/mnt/user-data/uploads/report.docx",
status: "uploaded",
},
]);
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
});
test("keeps attachments visible while upload submit is pending", async ({
page,
}) => {

View File

@ -35,11 +35,41 @@ export type MockAgent = {
system_prompt?: string;
};
export type MockSkill = {
name: string;
description: string;
category?: string;
license?: string | null;
enabled?: boolean;
};
export type MockAPIOptions = {
threads?: MockThread[];
agents?: MockAgent[];
skills?: MockSkill[];
};
const DEFAULT_SKILLS: MockSkill[] = [
{
name: "data-analysis",
description: "Analyze structured data and produce charts.",
category: "public",
enabled: true,
},
{
name: "frontend-design",
description: "Create polished frontend interfaces.",
category: "public",
enabled: true,
},
{
name: "disabled-skill",
description: "Hidden from slash autocomplete.",
category: "public",
enabled: false,
},
];
// ---------------------------------------------------------------------------
// mockLangGraphAPI
// ---------------------------------------------------------------------------
@ -52,6 +82,7 @@ export type MockAPIOptions = {
export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
const threads = options?.threads ?? [];
const agents = options?.agents ?? [];
const skills = options?.skills ?? DEFAULT_SKILLS;
// Thread search — sidebar thread list & chats list page
void page.route("**/api/langgraph/threads/search", (route) => {
@ -259,6 +290,18 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
return route.fallback();
});
// Skills list — settings page and slash autocomplete
void page.route("**/api/skills", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ skills }),
});
}
return route.fallback();
});
// Follow-up suggestions — input box auto-suggest after AI response
void page.route("**/api/threads/*/suggestions", (route) => {
if (route.request().method() === "POST") {

View File

@ -11,6 +11,7 @@ import {
hasContent,
hasReasoning,
isAssistantMessageGroupStreaming,
stripUploadedFilesTag,
} from "@/core/messages/utils";
function aiMessage(content: string): Message {
@ -173,6 +174,38 @@ describe("inline <think> tag splitting", () => {
});
});
describe("human message internal context stripping", () => {
test("strips slash skill activation context from display content", () => {
const content =
"<slash_skill_activation>\n<skill_content># Secret SKILL.md</skill_content>\n</slash_skill_activation>\nreal user task";
expect(stripUploadedFilesTag(content)).toBe("real user task");
});
test("hides leaked slash skill activation messages with no user text", () => {
const messages = [
{
id: "slash-activation",
type: "human",
content:
"<slash_skill_activation>\n<skill_content># Secret SKILL.md</skill_content>\n</slash_skill_activation>",
},
{
id: "ai-1",
type: "ai",
content: "Public answer",
},
] as Message[];
const groups = getMessageGroups(messages);
expect(groups.map((group) => group.type)).toEqual(["assistant"]);
expect(
groups.flatMap((group) => group.messages).map((message) => message.id),
).toEqual(["ai-1"]);
});
});
test("hides internal todo reminder messages from message groups", () => {
const messages = [
{

View File

@ -260,6 +260,22 @@ describe("formatThreadAsJSON", () => {
expect(raw).toContain("real user text");
});
it("strips <slash_skill_activation> as defence in depth", () => {
// Slash activation normally rides in a hidden HumanMessage. If a replay
// or state merge loses the flag, export must still not leak full SKILL.md
// content into a user-visible transcript.
const leaky = human("real user task", {
id: "leak-slash-skill",
content:
"<slash_skill_activation>\n<skill_content># Secret SKILL.md\nUse internal source.</skill_content>\n</slash_skill_activation>\nreal user task",
} as unknown as Partial<Message>);
const raw = formatThreadAsJSON(makeThread(), [leaky]);
expect(raw).not.toContain("<slash_skill_activation>");
expect(raw).not.toContain("Secret SKILL.md");
expect(raw).not.toContain("internal source");
expect(raw).toContain("real user task");
});
it("sanitises tool message content when includeToolMessages is true", () => {
const message = {
id: "t-leak",