deer-flow/backend/tests/test_slash_skills.py
DanielWalnut 25ea6970a6
feat(runtime): implement goal continuations (#3858)
* implement goal continuations

* fix(goal): address review findings for goal continuations

- goal: key the no-progress breaker on a signature of the latest visible
  assistant evidence instead of the evaluator's volatile free-text, so it
  actually fires on stalled turns; thread the signature through every
  worker persist / no-progress call site
- goal: align _stand_down_reason default caps with should_continue_goal
  (8 / 2) so the two gate functions agree on goals missing the fields
- runtime: offload the synchronous checkpointer fallback via
  asyncio.to_thread (goal.py + worker.py) to keep blocking IO off the loop
- frontend: i18n the GoalStatus "Goal" label (goalLabel in en/zh/types)
- frontend: extract pure composer helpers into input-box-helpers.ts with
  unit tests (parseGoalCommand, readGoalResponseError, skill suggestions)
- tests: cover the evidence-based no-progress and default-cap behavior
- docs: align backend/AGENTS.md goal paragraph with actual behavior
- e2e: prettier-format chat.spec.ts (fixes the lint-frontend CI failure)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(frontend): hide goal continuation counter until the agent continues

The goal status bar rendered a raw "0/8" before any auto-continuation, which
read as a mysterious score. Now the counter is hidden until
continuation_count > 0, then shows "Continuing N/M" with a tooltip explaining
the auto-continuation cap.

- Extract getGoalContinuationDisplay into a pure helper (hides at 0) + unit tests
- Add goalContinuing / goalContinuationTooltip i18n keys (en/zh/types)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(goal): address review findings for goal continuations

Frontend correctness
- Fix the optimistic /goal result permanently shadowing server goal state:
  the streamed continuation counter never surfaced for a goal set in-session.
  Extract a shared useActiveGoal hook (used by both chat pages) that reconciles
  the optimistic copy with server state via a goalReconciliationKey, de-duping
  the copy-pasted goal block across the two pages.
- Stop /goal status|clear failures from escaping handleSubmit as unhandled
  rejections (handleGoalCommand now returns success; the run only starts when a
  goal was actually saved).
- Use a function replacer for the goal-status toast so an objective containing
  $&/$1 isn't treated as a replacement pattern.

Backend cleanliness / correctness
- De-duplicate four byte-identical helpers (_call_checkpointer_method,
  _message_type, _additional_kwargs, _is_visible_message) by importing them
  from runtime.goal instead of re-defining them in the run worker.
- Remove the dead `checkpoint_tuple.tasks` durability guard (CheckpointTuple has
  no tasks field) and document that pending_writes is the durability signal.
- Decompose the 176-line _prepare_goal_continuation_input: extract
  _reread_goal_and_checkpoint and a _persist closure so the thread-unchanged
  guard and stand-down persistence aren't open-coded three times. Document the
  last-writer-wins write-window limitation as a follow-up.
- Add a shared parse_goal_command helper and use it from the TUI and IM-channel
  /goal handlers (one place for the status/clear/set semantics).

Tests
- Restore the 11 command-registry tests dropped by the previous goal change
  (filter_commands ranking/description, build_registry builtins/skills, resolve
  cases) alongside the new goal tests.
- Add coverage for the IM-channel _handle_goal_command, the TUI _handle_goal
  handler, parse_goal_command, and goalReconciliationKey.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix goal review feedback

* fix goal continuation checkpoint races

* prioritize goal commands while streaming

Route composer submits through a shared helper so /goal commands can be handled before the streaming stop shortcut, while ordinary streaming submits still stop the active run.

Testing: cd frontend && pnpm exec rstest run tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; cd frontend && pnpm check

* preserve goal status during clarification

Keep omitted stream goal fields distinct from explicit null clears so clarification interrupts do not hide an active thread goal that is still present in the checkpoint.

Testing: pnpm exec rstest run tests/unit/components/workspace/use-active-goal.test.ts tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; pnpm check; git diff --check

* style: format active goal hook

Run Prettier on use-active-goal.ts to satisfy the frontend lint workflow formatting gate.

Testing: pnpm format; pnpm exec rstest run tests/unit/components/workspace/use-active-goal.test.ts tests/unit/components/workspace/input-box-helpers.test.ts tests/unit/components/workspace/goal-status-helpers.test.ts; pnpm check; git diff --check

* fix goal review followups

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:49:33 +08:00

563 lines
25 KiB
Python

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", "goal", "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_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})
request = _make_model_request([real_user, hidden_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_ignores_legacy_summary_messages():
summary_msg = HumanMessage(content="/data-analysis should not activate from summary", name="summary")
assert middleware_module._is_user_activation_target(summary_msg) is False
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