mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-21 15:36:48 +00:00
e93f658472
* fix(task-tool): unwrap callback manager when locating usage recorder
`config["callbacks"]` may arrive as a `BaseCallbackManager` (e.g. the
`AsyncCallbackManager` LangChain hands to async tool runs), not just a plain
list. The previous `for cb in callbacks` loop raised
`TypeError: 'AsyncCallbackManager' object is not iterable`, which
`ToolErrorHandlingMiddleware` then converted into a failed `task` ToolMessage
even though the subagent had completed internally — Ultra mode lost subagent
results and the lead agent fell back to redoing the work.
Unwrap `BaseCallbackManager.handlers` before searching for the recorder.
Refs: bytedance/deer-flow#3107 (BUG-002)
* fix(frontend): treat any task tool error as a terminal subtask failure
The subtask card status machine matched only three English prefixes (`Task
Succeeded. Result:`, `Task failed.`, `Task timed out`). Anything else fell
through to `in_progress`, so a `task` tool error wrapped by
`ToolErrorHandlingMiddleware` (`Error: Tool 'task' failed ...`) left the card
spinning forever even after the run had ended.
Extract the prefix logic into `parseSubtaskResult` and recognise any leading
`Error:` token as a terminal failure. The extracted function is unit-tested
against the legacy prefixes plus the `AsyncCallbackManager` regression
captured in the upstream issue.
Refs: bytedance/deer-flow#3107 (BUG-007)
* fix(frontend): exclude hidden, reasoning, and tool payloads from chat export
`formatThreadAsMarkdown` / `formatThreadAsJSON` iterated raw messages without
running the UI-level `isHiddenFromUIMessage` filter. Exported transcripts
therefore included `hide_from_ui` system reminders, memory injections,
provider `reasoning_content`, tool calls, and tool result messages — content
that is intentionally hidden in the chat view.
Filter the export to the user-visible transcript by default and gate
reasoning / tool calls / tool messages / hidden messages behind explicit
`ExportOptions` flags so a future debug export can opt back in without
forking the formatter.
Refs: bytedance/deer-flow#3107 (BUG-006)
* fix(gateway): route get_config through get_app_config for mtime hot reload
`get_config(request)` returned the `app.state.config` snapshot captured at
startup. The worker / lead-agent path then threaded that frozen `AppConfig`
through `RunContext` and `agent_factory`, so per-run fields edited in
`config.yaml` (notably `max_tokens`) were ignored until the gateway process
was restarted — even though `get_app_config()` already does mtime-based
reload at the bottom layer.
Route the request dependency through `get_app_config()` directly. Runtime
`ContextVar` overrides (`push_current_app_config`) and test-injected
singletons (`set_app_config`) keep working; `app.state.config` is now only
read at startup for one-shot bootstrap (logging level, IM channels,
`langgraph_runtime` engines).
`tests/test_gateway_deps_config.py` encoded the old snapshot contract and is
removed; `tests/test_gateway_config_freshness.py` replaces it with mtime,
ContextVar, and `set_app_config` coverage. `test_skills_custom_router.py` and
`test_uploads_router.py` now inject test configs via FastAPI
`dependency_overrides[get_config]` instead of mutating `app.state.config`.
Document the hot-reload boundary in `backend/CLAUDE.md` so reviewers know
which fields are picked up on the next request vs. which still require a
restart (`database`, `checkpointer`, `run_events`, `stream_bridge`,
`sandbox.use`, `log_level`, `channels.*`).
Refs: bytedance/deer-flow#3107 (BUG-001)
* fix(gateway): broaden get_config 503 to any config-load failure
Address review feedback on the previous commit:
1. Narrow exception catch removed. The old contract returned 503 whenever
`app.state.config is None`. The first cut only mapped
`FileNotFoundError`, leaving `PermissionError`, YAML parse errors, and
pydantic `ValidationError` to bubble up as 500. At the request boundary
we treat any inability to materialise the config as "configuration not
available" (503) and log the original exception so the operator still
has the stack.
2. Removed the unused `request: Request` parameter and the matching
`# noqa: ARG001`. FastAPI's `Depends()` does not require the dependency
to accept `Request`; the only call site uses the no-arg form.
3. `backend/CLAUDE.md` boundary now lists the *reason* each field is
restart-required (engine binding, singleton caching, one-shot
`apply_logging_level`, etc.), not just the field name, so reviewers do
not have to reverse-engineer the boundary themselves.
Tests parametrise four exception classes (`FileNotFoundError`,
`PermissionError`, `ValueError`, `RuntimeError`) and assert 503 for each.
Refs: bytedance/deer-flow#3107 (BUG-001)
* fix(task-tool): defend _find_usage_recorder against non-list callbacks
Address review feedback. The previous commit handled the two common shapes
LangChain hands to async tool runs — a plain `list[BaseCallbackHandler]` and
a `BaseCallbackManager` subclass — but iterated any other shape directly,
which would still raise `TypeError` if e.g. a single handler instance leaked
through without a list wrapper.
Treat any non-list, non-manager `config["callbacks"]` value as "no recorder"
rather than crash. Docstring now lists all four shapes explicitly. New tests
cover the single-handler-object case, `runtime is None`, `callbacks is None`,
and `runtime.config` being a non-dict — all required to be silent no-ops.
Refs: bytedance/deer-flow#3107 (BUG-002)
* fix(frontend): drop dead identity ternary and add opt-in export tests
Address review feedback on the previous export commit:
1. Removed the no-op `typeof msg.content === "string" ? msg.content : msg.content`
expression in `formatThreadAsJSON`. Both branches returned the same value;
the message content now flows through unchanged whether it is a string or
the rich `MessageContent[]` shape (LangChain JSON-serialises the array
structure correctly already).
2. Expanded the JSDoc on `ExportOptions` to make it clearer that the four
flags are not currently wired to any UI control — callers wanting a debug
export must build the options object explicitly. The default behaviour
continues to match the explicit prescription in
bytedance/deer-flow#3107 BUG-006.
3. Added opt-in coverage. The previous tests only exercised the
`options = {}` default path; the new cases verify each flag flips the
corresponding payload back into the export so a future debug-export
surface does not silently break the contract.
Refs: bytedance/deer-flow#3107 (BUG-006)
* fix(frontend): export subtask prefix constants and document fallback intent
Address review feedback on the previous BUG-007 commit:
1. `SUCCESS_PREFIX`, `FAILURE_PREFIX`, `TIMEOUT_PREFIX`, and the
`ERROR_WRAPPER_PATTERN` regex are now exported. The JSDoc explicitly
pins them as part of the backend↔frontend contract defined in
`task_tool.py` and `tool_error_handling_middleware.py`, so any future
structured-status migration (e.g. backend writing
`additional_kwargs.subagent_status` instead of leading text) can
reference these from one canonical place rather than redefine them.
2. The `in_progress` fallback now carries a docstring explaining the
deliberate choice — LangChain only ever emits a `ToolMessage` once the
tool itself has returned, so unrecognised content means the contract
has drifted and "still running" is the right operator signal (eagerly
marking it terminal-failed would mask the drift).
No behaviour change; this is documentation and an API export.
Refs: bytedance/deer-flow#3107 (BUG-007)
* fix(gateway): drop app.state.config snapshot and freeze run_events_config
Address @ShenAC-SAC's BUG-001 review on #3131. The previous cut still
stored an ``AppConfig`` snapshot on ``app.state.config`` for startup
bootstrap. Two follow-on hazards from that:
1. Future code touching the gateway lifespan could accidentally start
reading ``app.state.config`` again, silently regressing the request
hot path back to a stale snapshot.
2. ``get_run_context()`` paired a freshly-reloaded ``AppConfig`` with the
startup-bound ``event_store`` and a *live* ``run_events_config``
field — so an operator who edited ``run_events.backend`` mid-flight
would have produced a run context whose ``event_store`` and
``run_events_config`` referred to different backends.
Clean approach (aligned with the direction in PR #3128):
- ``lifespan()`` keeps a local ``startup_config`` variable and passes it
explicitly into ``langgraph_runtime(app, startup_config)`` and into
``start_channel_service``. No ``app.state.config`` attribute is set at
any point.
- ``langgraph_runtime`` now accepts ``startup_config`` as a required
parameter, removing the ``getattr(app.state, "config", None)`` lookup
and the "config not initialised" runtime error.
- The matching ``run_events_config`` is frozen onto ``app.state`` next
to ``run_event_store`` so ``get_run_context`` reads the two from the
same startup-time source. ``app_config`` continues to be resolved
live via ``get_app_config()``.
- ``backend/CLAUDE.md`` boundary explanation updated to spell out the
``startup_config`` / ``get_app_config()`` split.
New regression test ``test_run_context_app_config_reflects_yaml_edit``
exercises the worker-feeding path: it asserts that ``ctx.app_config``
follows a mid-flight ``config.yaml`` edit while
``ctx.run_events_config`` stays frozen to the startup snapshot the
event store was built from.
Refs: bytedance/deer-flow#3107 (BUG-001), bytedance/deer-flow#3131 review
* fix(frontend): parse Task cancelled and polling timed out as terminal
Address @ShenAC-SAC's BUG-007 review on #3131. `task_tool.py` actually
emits five terminal strings:
- `Task Succeeded. Result: …`
- `Task failed. …`
- `Task timed out. …`
- `Task cancelled by user.` ← previously matched none
- `Task polling timed out after N minutes …` ← previously matched none
The previous cut handled three; the last two fell through to the
"unknown content" branch and pushed the subtask card back to
`in_progress` even though the backend had already reached a terminal
state. Add explicit matches plus regression tests for both. The
`in_progress` fallback is now reserved for genuinely unrecognised
output (i.e. contract drift), as documented.
Refs: bytedance/deer-flow#3107 (BUG-007), bytedance/deer-flow#3131 review
* fix(frontend): sanitize JSON export content via the Markdown content path
Address @ShenAC-SAC's BUG-006 review and the Copilot inline comment on
#3131. The previous cut filtered hidden/tool messages out of the JSON
export but still serialised `msg.content` verbatim, so:
- inline `<think>…</think>` wrappers stayed in the exported `content`
even with `includeReasoning: false`,
- content-array thinking blocks leaked the `thinking` field,
- `<uploaded_files>…</uploaded_files>` markers leaked the workspace
paths a user uploaded files to.
JSON now goes through the same sanitiser the Markdown path uses
(`extractContentFromMessage` + `stripUploadedFilesTag`). Reasoning and
tool_calls remain gated behind their `ExportOptions` flags. AI / human
rows that sanitise to empty content with no opted-in reasoning or tool
calls are dropped so the JSON matches the Markdown path's `continue`
on empty assistant fragments.
New regression tests cover the three leak shapes the reviewer called
out plus the empty-content-drop case.
Refs: bytedance/deer-flow#3107 (BUG-006), bytedance/deer-flow#3131 review
* test(gateway): align lifespan stub with langgraph_runtime two-arg signature
Codex round-3 review of c0bc7a06 flagged this: changing
`langgraph_runtime` to require `startup_config` as a second positional
argument broke the one-arg stub `_noop_langgraph_runtime(_app)` in
`test_gateway_lifespan_shutdown.py`, which is patched into
`app.gateway.app.langgraph_runtime` by the lifespan shutdown bounded-timeout
regression. Lifespan would then call the stub with two args and raise
`TypeError` before the bounded-shutdown assertion ran.
Update the stub to match the new signature. The shutdown test itself is
unaffected — it only cares about the channel `stop_channel_service` hang
path.
Refs: bytedance/deer-flow#3107 (BUG-001), bytedance/deer-flow#3131 review
* fix(frontend): strip every known backend marker in export, not just uploads
Codex round-3 review of 258ca800 and the matching maintainer feedback on
PR #3131 made the same point: the JSON export now ran the
Markdown-side sanitiser, but that sanitiser only stripped
`<uploaded_files>`. The full set of payloads middleware embeds inside
message `content` is larger:
- `<uploaded_files>` — `UploadsMiddleware`
- `<system-reminder>` — `DynamicContextMiddleware`
- `<memory>` — `DynamicContextMiddleware` (nested inside system-reminder)
- `<current_date>` — `DynamicContextMiddleware`
The primary protection is still `isHiddenFromUIMessage`: the
`<system-reminder>` HumanMessage is marked `hide_from_ui: true` and never
reaches the formatter. This commit adds the second line of defence so a
regression that drops the `hide_from_ui` flag — or any future middleware
that injects the same tag vocabulary into a visible HumanMessage —
cannot leak the payload into the export file.
Concrete changes:
- New `INTERNAL_MARKER_TAGS` constant + `stripInternalMarkers(content)`
helper in `core/messages/utils.ts`. The constant doubles as
documentation for the backend↔frontend contract.
- `formatMessageContent` in `export.ts` now calls `stripInternalMarkers`
instead of `stripUploadedFilesTag`. UI render paths
(`message-list-item.tsx`) keep using the narrower function so a user
legitimately typing `<memory>` in a meta-discussion is preserved.
- The "drop empty rows" guard in `buildJSONMessage` switched from
`=== undefined` to truthy `!` checks. Codex spotted the asymmetry: when
`extractReasoningContentFromMessage` returned the empty string (which it
legitimately can), the JSON path emitted `{reasoning: ""}` while the
Markdown path's `!reasoning` `continue` correctly dropped the row.
New regression tests cover the defence-in-depth strip with a
`<system-reminder><memory><current_date>` payload deliberately *not*
marked `hide_from_ui`; tool-message sanitization under
`includeToolMessages: true`; the mixed-content-array case
(`thinking + text + image_url`); and the opted-in empty-reasoning drop.
Live verification on a real Ultra-mode thread that uploaded a PDF
(`曾鑫民-薪资交易流水.pdf`): backend state's first HumanMessage carries the
`<uploaded_files>` block (with `/mnt/user-data/uploads/...` paths) as part
of a content-array. The Markdown and JSON export blobs both come back
free of `<uploaded_files>`, `<system-reminder>`, `<current_date>`,
`tool_calls`, and reasoning — while preserving the user's `这是什么 ?`
prompt and the assistant's visible answer.
Refs: bytedance/deer-flow#3107 (BUG-006), bytedance/deer-flow#3131 review
* test(frontend): cover trim, varied N, and pre-execution Error: prefixes
Codex round-3 review of 50e2c257 flagged three coverage gaps in the
subtask-status parser:
1. `Task cancelled by user.` and `Task polling timed out` previously had
no whitespace-trim coverage — the original trim test only exercised
the success prefix. Streaming chunks can arrive with leading/trailing
newlines; the regex needed an explicit assertion.
2. The polling-timeout case was tested only at one `N` (15 minutes). The
backend interpolates the live `timeout_seconds // 60` value, so the
matcher must hold for any positive integer. Now we run the case for
1, 5, and 60 minutes.
3. `task_tool.py` also emits three `Error:` strings for pre-execution
failures — unknown subagent type, host-bash disabled, and "task
disappeared from background tasks". They are intentionally handled by
`ERROR_WRAPPER_PATTERN` rather than dedicated prefixes (the wrapper
already produces the right terminal-failed shape) but had no test
coverage proving that wiring. Codex was right that a refactor splitting
one of them off into its own prefix would silently break things.
The JSDoc on the constants block now spells the three pre-execution
errors out so the relationship between `task_tool.py` returns and the
prefix vocabulary is explicit.
No production code change beyond the docstring — this commit is pure
coverage hardening for the contract that already exists.
Refs: bytedance/deer-flow#3107 (BUG-007), bytedance/deer-flow#3131 review
364 lines
16 KiB
Python
364 lines
16 KiB
Python
import errno
|
|
import json
|
|
import zipfile
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.gateway.deps import get_config
|
|
from app.gateway.routers import skills as skills_router
|
|
from deerflow.skills.storage import get_or_new_skill_storage
|
|
from deerflow.skills.types import Skill
|
|
|
|
|
|
def _skill_content(name: str, description: str = "Demo skill") -> str:
|
|
return f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"
|
|
|
|
|
|
async def _async_scan(decision: str, reason: str):
|
|
from deerflow.skills.security_scanner import ScanResult
|
|
|
|
return ScanResult(decision=decision, reason=reason)
|
|
|
|
|
|
def _make_skill(name: str, *, enabled: bool) -> Skill:
|
|
skill_dir = Path(f"/tmp/{name}")
|
|
return Skill(
|
|
name=name,
|
|
description=f"Description for {name}",
|
|
license="MIT",
|
|
skill_dir=skill_dir,
|
|
skill_file=skill_dir / "SKILL.md",
|
|
relative_path=Path(name),
|
|
category="public",
|
|
enabled=enabled,
|
|
)
|
|
|
|
|
|
def _make_test_app(config) -> FastAPI:
|
|
app = FastAPI()
|
|
app.state.config = config # kept for any startup-style reads
|
|
app.dependency_overrides[get_config] = lambda: config
|
|
app.include_router(skills_router.router)
|
|
return app
|
|
|
|
|
|
def _make_skill_archive(tmp_path: Path, name: str, content: str | None = None) -> Path:
|
|
archive = tmp_path / f"{name}.skill"
|
|
skill_content = content or _skill_content(name)
|
|
with zipfile.ZipFile(archive, "w") as zf:
|
|
zf.writestr(f"{name}/SKILL.md", skill_content)
|
|
return archive
|
|
|
|
|
|
def test_install_skill_archive_runs_security_scan(monkeypatch, tmp_path):
|
|
skills_root = tmp_path / "skills"
|
|
(skills_root / "custom").mkdir(parents=True)
|
|
archive = _make_skill_archive(tmp_path, "archive-skill")
|
|
scan_calls = []
|
|
refresh_calls = []
|
|
|
|
async def _scan(content, *, executable, location, app_config=None):
|
|
from deerflow.skills.security_scanner import ScanResult
|
|
|
|
scan_calls.append({"content": content, "executable": executable, "location": location})
|
|
return ScanResult(decision="allow", reason="ok")
|
|
|
|
async def _refresh():
|
|
refresh_calls.append("refresh")
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
|
|
|
|
storage = LocalSkillStorage(host_path=str(skills_root))
|
|
config = SimpleNamespace(
|
|
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
|
|
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
|
|
)
|
|
monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive)
|
|
monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **kw: storage)
|
|
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
|
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
|
|
|
|
app = _make_test_app(config)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/archive-skill.skill"})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["skill_name"] == "archive-skill"
|
|
assert (skills_root / "custom" / "archive-skill" / "SKILL.md").exists()
|
|
assert scan_calls == [
|
|
{
|
|
"content": _skill_content("archive-skill"),
|
|
"executable": False,
|
|
"location": "archive-skill/SKILL.md",
|
|
}
|
|
]
|
|
assert refresh_calls == ["refresh"]
|
|
|
|
|
|
def test_install_skill_archive_security_scan_block_returns_400(monkeypatch, tmp_path):
|
|
skills_root = tmp_path / "skills"
|
|
(skills_root / "custom").mkdir(parents=True)
|
|
archive = _make_skill_archive(tmp_path, "blocked-skill")
|
|
refresh_calls = []
|
|
|
|
async def _scan(*args, **kwargs):
|
|
from deerflow.skills.security_scanner import ScanResult
|
|
|
|
return ScanResult(decision="block", reason="prompt injection")
|
|
|
|
async def _refresh():
|
|
refresh_calls.append("refresh")
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
|
|
|
|
storage = LocalSkillStorage(host_path=str(skills_root))
|
|
config = SimpleNamespace(
|
|
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
|
|
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
|
|
)
|
|
monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive)
|
|
monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **kw: storage)
|
|
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
|
|
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
|
|
|
|
app = _make_test_app(config)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/blocked-skill.skill"})
|
|
|
|
assert response.status_code == 400
|
|
assert "Security scan blocked skill 'blocked-skill': prompt injection" in response.json()["detail"]
|
|
assert not (skills_root / "custom" / "blocked-skill").exists()
|
|
assert refresh_calls == []
|
|
|
|
|
|
def test_custom_skills_router_lifecycle(monkeypatch, tmp_path):
|
|
skills_root = tmp_path / "skills"
|
|
custom_dir = skills_root / "custom" / "demo-skill"
|
|
custom_dir.mkdir(parents=True, exist_ok=True)
|
|
(custom_dir / "SKILL.md").write_text(_skill_content("demo-skill"), encoding="utf-8")
|
|
config = SimpleNamespace(
|
|
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
|
|
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
|
|
)
|
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
|
|
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok"))
|
|
refresh_calls = []
|
|
|
|
async def _refresh():
|
|
refresh_calls.append("refresh")
|
|
|
|
monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh)
|
|
|
|
app = _make_test_app(config)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.get("/api/skills/custom")
|
|
assert response.status_code == 200
|
|
assert response.json()["skills"][0]["name"] == "demo-skill"
|
|
|
|
get_response = client.get("/api/skills/custom/demo-skill")
|
|
assert get_response.status_code == 200
|
|
assert "# demo-skill" in get_response.json()["content"]
|
|
|
|
update_response = client.put(
|
|
"/api/skills/custom/demo-skill",
|
|
json={"content": _skill_content("demo-skill", "Edited skill")},
|
|
)
|
|
assert update_response.status_code == 200
|
|
assert update_response.json()["description"] == "Edited skill"
|
|
|
|
history_response = client.get("/api/skills/custom/demo-skill/history")
|
|
assert history_response.status_code == 200
|
|
assert history_response.json()["history"][-1]["action"] == "human_edit"
|
|
|
|
rollback_response = client.post("/api/skills/custom/demo-skill/rollback", json={"history_index": -1})
|
|
assert rollback_response.status_code == 200
|
|
assert rollback_response.json()["description"] == "Demo skill"
|
|
assert refresh_calls == ["refresh", "refresh"]
|
|
|
|
|
|
def test_custom_skill_rollback_blocked_by_scanner(monkeypatch, tmp_path):
|
|
skills_root = tmp_path / "skills"
|
|
custom_dir = skills_root / "custom" / "demo-skill"
|
|
custom_dir.mkdir(parents=True, exist_ok=True)
|
|
original_content = _skill_content("demo-skill")
|
|
edited_content = _skill_content("demo-skill", "Edited skill")
|
|
(custom_dir / "SKILL.md").write_text(edited_content, encoding="utf-8")
|
|
config = SimpleNamespace(
|
|
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
|
|
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
|
|
)
|
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
|
|
history_file = get_or_new_skill_storage(app_config=config).get_skill_history_file("demo-skill")
|
|
history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
history_file.write_text(
|
|
'{"action":"human_edit","prev_content":' + json.dumps(original_content) + ',"new_content":' + json.dumps(edited_content) + "}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
async def _refresh():
|
|
return None
|
|
|
|
monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh)
|
|
|
|
async def _scan(*args, **kwargs):
|
|
from deerflow.skills.security_scanner import ScanResult
|
|
|
|
return ScanResult(decision="block", reason="unsafe rollback")
|
|
|
|
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", _scan)
|
|
|
|
app = _make_test_app(config)
|
|
|
|
with TestClient(app) as client:
|
|
rollback_response = client.post("/api/skills/custom/demo-skill/rollback", json={"history_index": -1})
|
|
assert rollback_response.status_code == 400
|
|
assert "unsafe rollback" in rollback_response.json()["detail"]
|
|
|
|
history_response = client.get("/api/skills/custom/demo-skill/history")
|
|
assert history_response.status_code == 200
|
|
assert history_response.json()["history"][-1]["scanner"]["decision"] == "block"
|
|
|
|
|
|
def test_custom_skill_delete_preserves_history_and_allows_restore(monkeypatch, tmp_path):
|
|
skills_root = tmp_path / "skills"
|
|
custom_dir = skills_root / "custom" / "demo-skill"
|
|
custom_dir.mkdir(parents=True, exist_ok=True)
|
|
original_content = _skill_content("demo-skill")
|
|
(custom_dir / "SKILL.md").write_text(original_content, encoding="utf-8")
|
|
config = SimpleNamespace(
|
|
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
|
|
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
|
|
)
|
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
|
|
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok"))
|
|
refresh_calls = []
|
|
|
|
async def _refresh():
|
|
refresh_calls.append("refresh")
|
|
|
|
monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh)
|
|
|
|
app = _make_test_app(config)
|
|
|
|
with TestClient(app) as client:
|
|
delete_response = client.delete("/api/skills/custom/demo-skill")
|
|
assert delete_response.status_code == 200
|
|
assert not (custom_dir / "SKILL.md").exists()
|
|
|
|
history_response = client.get("/api/skills/custom/demo-skill/history")
|
|
assert history_response.status_code == 200
|
|
assert history_response.json()["history"][-1]["action"] == "human_delete"
|
|
|
|
rollback_response = client.post("/api/skills/custom/demo-skill/rollback", json={"history_index": -1})
|
|
assert rollback_response.status_code == 200
|
|
assert rollback_response.json()["description"] == "Demo skill"
|
|
assert (custom_dir / "SKILL.md").read_text(encoding="utf-8") == original_content
|
|
assert refresh_calls == ["refresh", "refresh"]
|
|
|
|
|
|
def test_custom_skill_delete_continues_when_history_write_is_readonly(monkeypatch, tmp_path):
|
|
skills_root = tmp_path / "skills"
|
|
custom_dir = skills_root / "custom" / "demo-skill"
|
|
custom_dir.mkdir(parents=True, exist_ok=True)
|
|
(custom_dir / "SKILL.md").write_text(_skill_content("demo-skill"), encoding="utf-8")
|
|
config = SimpleNamespace(
|
|
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
|
|
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
|
|
)
|
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
|
|
refresh_calls = []
|
|
|
|
async def _refresh():
|
|
refresh_calls.append("refresh")
|
|
|
|
def _readonly_history(*args, **kwargs):
|
|
raise OSError(errno.EROFS, "Read-only file system", str(skills_root / "custom" / ".history"))
|
|
|
|
monkeypatch.setattr("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.append_history", _readonly_history)
|
|
monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh)
|
|
|
|
app = _make_test_app(config)
|
|
|
|
with TestClient(app) as client:
|
|
delete_response = client.delete("/api/skills/custom/demo-skill")
|
|
|
|
assert delete_response.status_code == 200
|
|
assert delete_response.json() == {"success": True}
|
|
assert not custom_dir.exists()
|
|
assert refresh_calls == ["refresh"]
|
|
|
|
|
|
def test_custom_skill_delete_fails_when_skill_dir_removal_fails(monkeypatch, tmp_path):
|
|
skills_root = tmp_path / "skills"
|
|
custom_dir = skills_root / "custom" / "demo-skill"
|
|
custom_dir.mkdir(parents=True, exist_ok=True)
|
|
(custom_dir / "SKILL.md").write_text(_skill_content("demo-skill"), encoding="utf-8")
|
|
config = SimpleNamespace(
|
|
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
|
|
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
|
|
)
|
|
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
|
|
refresh_calls = []
|
|
|
|
async def _refresh():
|
|
refresh_calls.append("refresh")
|
|
|
|
def _fail_rmtree(*args, **kwargs):
|
|
raise PermissionError(errno.EACCES, "Permission denied", str(custom_dir))
|
|
|
|
monkeypatch.setattr("deerflow.skills.storage.local_skill_storage.shutil.rmtree", _fail_rmtree)
|
|
monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh)
|
|
|
|
app = _make_test_app(config)
|
|
|
|
with TestClient(app) as client:
|
|
delete_response = client.delete("/api/skills/custom/demo-skill")
|
|
|
|
assert delete_response.status_code == 500
|
|
assert "Failed to delete custom skill" in delete_response.json()["detail"]
|
|
assert custom_dir.exists()
|
|
assert refresh_calls == []
|
|
|
|
|
|
def test_update_skill_refreshes_prompt_cache_before_return(monkeypatch, tmp_path):
|
|
config_path = tmp_path / "extensions_config.json"
|
|
enabled_state = {"value": True}
|
|
refresh_calls = []
|
|
|
|
def _load_skills(*, enabled_only: bool):
|
|
skill = _make_skill("demo-skill", enabled=enabled_state["value"])
|
|
if enabled_only and not skill.enabled:
|
|
return []
|
|
return [skill]
|
|
|
|
async def _refresh():
|
|
refresh_calls.append("refresh")
|
|
enabled_state["value"] = False
|
|
|
|
mock_storage = SimpleNamespace(load_skills=_load_skills)
|
|
monkeypatch.setattr("app.gateway.routers.skills.get_or_new_skill_storage", lambda **kwargs: mock_storage)
|
|
monkeypatch.setattr("app.gateway.routers.skills.get_extensions_config", lambda: SimpleNamespace(mcp_servers={}, skills={}))
|
|
monkeypatch.setattr("app.gateway.routers.skills.reload_extensions_config", lambda: None)
|
|
monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(lambda: config_path))
|
|
monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh)
|
|
|
|
app = _make_test_app(SimpleNamespace())
|
|
|
|
with TestClient(app) as client:
|
|
response = client.put("/api/skills/demo-skill", json={"enabled": False})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["enabled"] is False
|
|
assert refresh_calls == ["refresh"]
|
|
assert json.loads(config_path.read_text(encoding="utf-8")) == {"mcpServers": {}, "skills": {"demo-skill": {"enabled": False}}}
|