diff --git a/app/agent/callback/__init__.py b/app/agent/callback/__init__.py index 165d7cd9b..318f03537 100644 --- a/app/agent/callback/__init__.py +++ b/app/agent/callback/__init__.py @@ -1,4 +1,5 @@ import asyncio +import re import threading from typing import Any, Optional, Tuple @@ -20,6 +21,19 @@ class _StreamChain(ChainBase): pass +_PATCH_FILE_HEADER_PATTERN = re.compile( + r"\*\*\* (?:Add|Update|Delete) File:\s*(\S+)" +) + + +def _extract_first_patch_path(patch: Optional[str]) -> Optional[str]: + """从补丁文本中提取首个文件路径,作为流式消息展示目标。""" + if not patch: + return None + match = _PATCH_FILE_HEADER_PATTERN.search(patch) + return match.group(1) if match else None + + class StreamingHandler: """ 流式Token缓冲管理器 @@ -337,6 +351,8 @@ class StreamingHandler: return "file_read", tool_kwargs.get("file_path") if tool_name in {"write_file", "edit_file"}: return "file_write", tool_kwargs.get("file_path") + if tool_name == "apply_patch": + return "file_write", _extract_first_patch_path(tool_kwargs.get("patch")) if tool_name in {"list_directory", "query_directory_settings"}: return "directory", tool_kwargs.get("path") if tool_name == "browse_webpage": diff --git a/app/agent/policy/registry.py b/app/agent/policy/registry.py index ca3490095..ac75f4b78 100644 --- a/app/agent/policy/registry.py +++ b/app/agent/policy/registry.py @@ -32,6 +32,7 @@ BUILTIN_LEGACY_SHADOW_INVENTORY = frozenset( "add_download_tasks", "add_rule_group", "add_subscribe", + "apply_patch", "ask_user_choice", "browse_webpage", "create_agent_task", diff --git a/app/agent/prompt/System Core Prompt.txt b/app/agent/prompt/System Core Prompt.txt index 7f78ce1b4..3326b478b 100644 --- a/app/agent/prompt/System Core Prompt.txt +++ b/app/agent/prompt/System Core Prompt.txt @@ -67,7 +67,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel - If torrent search yields no useful result, check site scope, site health, and recognition quality before concluding that the resource is unavailable. - Reuse the latest torrent search cache for `get_search_results` and `add_download_tasks` instead of re-running the same search unnecessarily. - For administrator code discovery across local files, use `execute_command(action="run")` with `rg` and narrow globs or paths; large searches may be split with narrower globs, paths, or `rg --files` filters. Use `list_directory` to inspect one known directory or a supported remote storage backend; request its `limit`/`offset` page fields when more than the first page is needed, and use `read_file` when the exact local file is known. If `read_file` reports truncation, continue with smaller `start_line` and `end_line` ranges instead of assuming the file ended. -- Read the relevant file before changing it. Use `edit_file` for localized exact replacements; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for new files; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters. +- Read the relevant file before changing it, then pick the editing tool by scope. Use `apply_patch` when one logical change spans multiple files, adds new files, or deletes files: submit a single patch wrapped in `*** Begin Patch` / `*** End Patch` with `*** Add File:`, `*** Update File:`, and `*** Delete File:` sections; every context and removed line must match the current content exactly, and the whole patch is validated before any file is written. Use `edit_file` for a single localized exact replacement within one already-read file; make `old_text` unique with enough surrounding context, and use `replace_all=true` only when every match must change. Use `write_file` for one standalone new file; set `overwrite=true` only for an intentional full rewrite, and use `read_file(include_metadata=true)` plus `expected_sha256` when preserving the previously read version matters. - When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API. - Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial. - Use `execute_command` for administrator-only multi-file diagnostics, tests, Git, service operations, SSH, or an exact command the user requested. Use `action=run` for short bounded commands. Use `action=start` for long-running or interactive commands, including SSH; then continue with `read`, `wait`, `write`, or `kill` using the returned `session_id`. Do not start a background session for a short command that can finish within `action=run`. diff --git a/app/agent/tools/factory.py b/app/agent/tools/factory.py index a79ffdf74..c4c4015b5 100644 --- a/app/agent/tools/factory.py +++ b/app/agent/tools/factory.py @@ -66,6 +66,7 @@ from app.agent.tools.impl.list_directory import ListDirectoryTool from app.agent.tools.impl.query_transfer_history import QueryTransferHistoryTool from app.agent.tools.impl.transfer_file import TransferFileTool from app.agent.tools.impl.execute_command import ExecuteCommandTool +from app.agent.tools.impl.apply_patch import ApplyPatchTool from app.agent.tools.impl.edit_file import EditFileTool from app.agent.tools.impl.write_file import WriteFileTool from app.agent.tools.impl.read_file import ReadFileTool @@ -163,6 +164,7 @@ class MoviePilotToolFactory: UpdatePersonaDefinitionTool, ExecuteCommandTool, EditFileTool, + ApplyPatchTool, WriteFileTool, ReadFileTool, BrowseWebpageTool, @@ -192,6 +194,7 @@ class MoviePilotToolFactory: "write_file", "read_file", "edit_file", + "apply_patch", "execute_command", "ask_user_choice", "create_agent_task", diff --git a/app/agent/tools/impl/apply_patch.py b/app/agent/tools/impl/apply_patch.py new file mode 100644 index 000000000..a214c8872 --- /dev/null +++ b/app/agent/tools/impl/apply_patch.py @@ -0,0 +1,338 @@ +"""多文件补丁应用工具。 + +参考 Codex apply_patch 设计:一次调用可对多个文本文件执行新增、更新和删除, +先整体校验全部文件操作,通过后才逐个原子写盘。 +""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional, Type + +from anyio import Path as AsyncPath +from pydantic import BaseModel, Field + +from app.agent.tools.base import MoviePilotTool +from app.agent.tools.impl._file_write_utils import ( + FileVersionConflictError, + atomic_write_text, + calculate_file_sha256, +) +from app.agent.tools.tags import ToolTag +from app.runtime.log import logger + +BEGIN_PATCH_MARKER = "*** Begin Patch" +END_PATCH_MARKER = "*** End Patch" +ADD_FILE_PREFIX = "*** Add File: " +UPDATE_FILE_PREFIX = "*** Update File: " +DELETE_FILE_PREFIX = "*** Delete File: " +HUNK_SEPARATOR_PREFIX = "@@" + + +class PatchParseError(ValueError): + """补丁文本无法解析为合法的文件操作序列。""" + + +class PatchMatchError(ValueError): + """补丁中的上下文或删除行与文件当前内容不一致。""" + + +@dataclass +class PatchHunk: + """更新操作的单个替换片段:按序定位旧行并替换为新行。""" + + old_lines: list[str] = field(default_factory=list) + new_lines: list[str] = field(default_factory=list) + + +@dataclass +class FilePatch: + """补丁内针对单个文件的一次操作。""" + + operation: str # "add" | "update" | "delete" + path: str + added_lines: list[str] = field(default_factory=list) + hunks: list[PatchHunk] = field(default_factory=list) + + +def parse_patch(patch: str) -> list[FilePatch]: + """解析以 Begin/End Patch 标记包裹的补丁文本为文件操作序列。""" + lines = patch.strip().splitlines() + if not lines or lines[0].strip() != BEGIN_PATCH_MARKER: + raise PatchParseError(f"补丁必须以 '{BEGIN_PATCH_MARKER}' 开头") + if len(lines) < 2 or lines[-1].strip() != END_PATCH_MARKER: + raise PatchParseError(f"补丁必须以 '{END_PATCH_MARKER}' 结尾") + + operations: list[FilePatch] = [] + seen_paths: set[str] = set() + current: Optional[FilePatch] = None + current_hunk: Optional[PatchHunk] = None + + def close_hunk() -> None: + """提交当前 hunk,无锚点的纯新增片段无法定位应直接报错。""" + nonlocal current_hunk + if current_hunk is None or current is None: + return + if not current_hunk.old_lines: + raise PatchParseError( + f"文件 {current.path} 的替换片段缺少上下文或删除行," + "无法定位替换位置,请在片段中包含至少一行不变的上下文" + ) + current.hunks.append(current_hunk) + current_hunk = None + + def open_section(operation: str, path: str) -> None: + """开始新的文件段,同一文件在一次补丁中只允许出现一次。""" + nonlocal current + close_hunk() + if not path: + raise PatchParseError(f"'{operation}' 段落缺少文件路径") + if path in seen_paths: + raise PatchParseError(f"文件 {path} 在补丁中出现多次,请合并为一个段落") + seen_paths.add(path) + current = FilePatch(operation=operation, path=path) + operations.append(current) + + for raw_line in lines[1:-1]: + if raw_line.startswith(ADD_FILE_PREFIX): + open_section("add", raw_line[len(ADD_FILE_PREFIX) :].strip()) + elif raw_line.startswith(UPDATE_FILE_PREFIX): + open_section("update", raw_line[len(UPDATE_FILE_PREFIX) :].strip()) + elif raw_line.startswith(DELETE_FILE_PREFIX): + open_section("delete", raw_line[len(DELETE_FILE_PREFIX) :].strip()) + elif current is None: + raise PatchParseError( + f"内容行出现在文件段落之前:{raw_line[:80]!r}," + "请先使用 '*** Add File:'、'*** Update File:' 或 '*** Delete File:' 声明文件" + ) + elif current.operation == "add": + if not raw_line.startswith("+"): + raise PatchParseError( + f"文件 {current.path} 的新增段落只允许以 '+' 开头的行:{raw_line[:80]!r}" + ) + current.added_lines.append(raw_line[1:]) + elif current.operation == "update": + if raw_line.startswith(HUNK_SEPARATOR_PREFIX): + close_hunk() + current_hunk = PatchHunk() + elif current_hunk is None: + raise PatchParseError( + f"文件 {current.path} 的更新段落必须先出现 '@@' 片段分隔行" + ) + elif raw_line.startswith("+"): + current_hunk.new_lines.append(raw_line[1:]) + elif raw_line.startswith("-"): + current_hunk.old_lines.append(raw_line[1:]) + else: + # 上下文行遵循 diff 惯例,允许携带一个前导空格标记 + context_line = raw_line[1:] if raw_line.startswith(" ") else raw_line + current_hunk.old_lines.append(context_line) + current_hunk.new_lines.append(context_line) + elif raw_line.strip(): + raise PatchParseError( + f"文件 {current.path} 的删除段落不允许包含内容行:{raw_line[:80]!r}" + ) + + close_hunk() + if not operations: + raise PatchParseError("补丁不包含任何文件操作") + return operations + + +def apply_hunks_to_content( + content: str, hunks: list[PatchHunk], file_label: str +) -> str: + """按顺序在文件内容中定位并替换每个 hunk,返回更新后的完整内容。""" + ends_with_newline = content.endswith("\n") + if ends_with_newline: + content = content[:-1] + + offset = 0 + for index, hunk in enumerate(hunks, start=1): + old_block = "\n".join(hunk.old_lines) + new_block = "\n".join(hunk.new_lines) + position = content.find(old_block, offset) + if position < 0: + raise PatchMatchError( + f"文件 {file_label} 的第 {index} 个替换片段与当前内容不匹配," + "请重新读取文件并确认空格、缩进和换行" + ) + content = ( + content[:position] + new_block + content[position + len(old_block) :] + ) + offset = position + len(new_block) + + if ends_with_newline and content and not content.endswith("\n"): + content += "\n" + return content + + +def _delete_file(path: Path) -> None: + """删除补丁中标记移除的已有文件。""" + path.unlink() + + +class ApplyPatchInput(BaseModel): + """补丁应用工具的输入参数模型。""" + + patch: str = Field( + ..., + description=( + "A single patch wrapped in '*** Begin Patch' and '*** End Patch'. " + "Sections: '*** Add File: ' whose body lines all start with " + "'+'; '*** Update File: ' with hunks separated by '@@' lines, " + "where '+' adds, '-' removes, and context lines may carry one " + "leading space and must otherwise match the current file content " + "exactly; '*** Delete File: ' with no body. Use one patch for " + "all files touched by one logical change." + ), + ) + + +class ApplyPatchTool(MoviePilotTool): + """按补丁文本对多个本地文本文件执行新增、更新和删除。""" + + name: str = "apply_patch" + tags: list[str] = [ + ToolTag.Write, + ToolTag.File, + ] + description: str = ( + "Apply one unified patch to multiple local text files. Prefer it over " + "edit_file when a single logical change spans several files, adds new " + "files, or deletes files: submit one patch wrapped in '*** Begin Patch' " + "/ '*** End Patch' with '*** Add File:', '*** Update File:', and " + "'*** Delete File:' sections. Hunk context and removed lines must match " + "the current file content exactly; the whole patch is validated before " + "any file is written. For a single localized replacement in one " + "already-read file, edit_file is simpler; use write_file to create one " + "standalone new file. Non-admin users can only patch files inside the " + "MoviePilot Agent config directory." + ) + args_schema: Type[BaseModel] = ApplyPatchInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + """根据参数生成友好的提示消息""" + patch = kwargs.get("patch", "") or "" + file_count = sum( + patch.count(prefix) + for prefix in (ADD_FILE_PREFIX, UPDATE_FILE_PREFIX, DELETE_FILE_PREFIX) + ) + return f"应用补丁: {file_count} 个文件" if file_count else "应用补丁" + + async def _plan_operations( + self, operations: list[FilePatch] + ) -> tuple[Optional[list], Optional[str]]: + """校验每个文件操作并计算写入内容,全部通过才返回执行计划。""" + planned = [] + for file_patch in operations: + resolved_path, access_error = await self._check_local_file_access( + file_patch.path, operation="打补丁" + ) + if access_error: + return None, access_error + + path = AsyncPath(resolved_path) + exists = await path.exists() + if file_patch.operation == "add": + if exists: + return None, ( + f"错误:文件 {resolved_path} 已存在,不能使用 Add File;" + "请改用 '*** Update File:' 或 edit_file 修改。" + ) + new_content = ( + "\n".join(file_patch.added_lines) + "\n" + if file_patch.added_lines + else "" + ) + planned.append((file_patch, resolved_path, Path(resolved_path), new_content, None)) + continue + + if not exists: + if file_patch.operation == "update": + return None, ( + f"错误:文件 {resolved_path} 不存在,不能使用 Update File;" + "请改用 '*** Add File:' 创建。" + ) + return None, f"错误:文件 {resolved_path} 不存在,无法删除" + if not await path.is_file(): + return None, f"错误:{resolved_path} 不是一个文件" + + if file_patch.operation == "delete": + planned.append((file_patch, resolved_path, Path(resolved_path), None, None)) + continue + + if not file_patch.hunks: + return None, ( + f"错误:文件 {resolved_path} 的 Update File 段落缺少 '@@' 替换片段" + ) + local_path = Path(resolved_path) + content = await path.read_text(encoding="utf-8", errors="strict") + current_sha256 = await self.run_blocking( + "default", calculate_file_sha256, local_path + ) + new_content = apply_hunks_to_content( + content, file_patch.hunks, resolved_path + ) + planned.append( + (file_patch, resolved_path, local_path, new_content, current_sha256) + ) + return planned, None + + async def run(self, patch: str, **kwargs) -> str: + """解析并整体校验补丁后,逐文件原子应用新增、更新和删除。""" + logger.info("执行工具: apply_patch") + + try: + try: + operations = parse_patch(patch) + except PatchParseError as error: + return f"错误:{error}" + + planned, plan_error = await self._plan_operations(operations) + if plan_error: + return plan_error + + results = [] + for file_patch, resolved_path, local_path, new_content, sha256 in planned: + if file_patch.operation == "delete": + await self.run_blocking("default", _delete_file, local_path) + results.append(f"删除 {resolved_path}") + continue + if file_patch.operation == "add" and local_path.exists(): + return ( + f"错误:文件 {resolved_path} 在应用补丁期间被创建,拒绝覆盖。" + "请确认文件状态后重新应用补丁。" + ) + await self.run_blocking( + "default", atomic_write_text, local_path, new_content, sha256 + ) + new_sha256 = await self.run_blocking( + "default", calculate_file_sha256, local_path + ) + verb = "新增" if file_patch.operation == "add" else "更新" + results.append(f"{verb} {resolved_path}(sha256={new_sha256})") + + logger.info(f"成功应用补丁,共处理 {len(results)} 个文件") + return f"成功应用补丁({len(results)} 个文件):\n" + "\n".join( + f"- {item}" for item in results + ) + + except PatchMatchError as error: + return f"错误:{error}" + except FileVersionConflictError: + return ( + "错误:目标文件在应用补丁期间发生变化,拒绝写入。" + "请重新读取文件并再次应用补丁。" + ) + except FileNotFoundError: + return ( + "错误:目标文件在应用补丁期间被删除,拒绝继续。" + "请确认文件状态后重新应用补丁。" + ) + except PermissionError: + return "错误:没有访问/修改补丁目标文件的权限" + except UnicodeDecodeError: + return "错误:补丁目标文件不是文本文件,无法应用补丁" + except Exception as e: + logger.error(f"应用补丁时发生错误: {str(e)}", exc_info=True) + return f"操作失败: {str(e)}" diff --git a/app/agent/tools/impl/edit_file.py b/app/agent/tools/impl/edit_file.py index d72c5f98a..923be94f0 100644 --- a/app/agent/tools/impl/edit_file.py +++ b/app/agent/tools/impl/edit_file.py @@ -58,8 +58,9 @@ class EditFileTool(MoviePilotTool): "the match must occur exactly once; use replace_all only for intentional " "bulk replacement. old_text cannot be empty, and new files must be " "created with write_file. Supports an optional SHA-256 conflict check. " - "Non-admin users can only edit files inside the MoviePilot Agent config " - "directory." + "When one logical change spans multiple files or needs to add or delete " + "files, use apply_patch instead. Non-admin users can only edit files " + "inside the MoviePilot Agent config directory." ) args_schema: Type[BaseModel] = EditFileInput diff --git a/app/api/endpoints/mcp.py b/app/api/endpoints/mcp.py index 099b1f70a..2d3b7c853 100644 --- a/app/api/endpoints/mcp.py +++ b/app/api/endpoints/mcp.py @@ -25,6 +25,7 @@ MCP_HIDDEN_TOOLS = { "execute_command", "search_web", "edit_file", + "apply_patch", "write_file", "read_file", } diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 9c8db1891..bdb90d903 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -291,7 +291,7 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized` 内置工具的 `inputSchema` 只包含实际执行业务所需的参数,不包含用于解释调用原因的通用 `explanation` 参数,以减少 Agent 上下文消耗。插件工具的参数结构由插件自身声明。 内置 Agent 的本地文件与命令工具 `read_file`、`write_file`、`edit_file`、 -`execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的 +`apply_patch`、`execute_command` 不通过 MCP 暴露。这些工具在 Agent 运行时执行独立的 用户权限与路径边界检查;MCP 隐藏列表只负责收敛接口暴露面,不替代权限控制。 其中 `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用 `start_line`、`end_line` 指定更小的行号范围继续读取。 diff --git a/skills/create-moviepilot-plugin/SKILL.md b/skills/create-moviepilot-plugin/SKILL.md index 7116553d9..4026e83b2 100644 --- a/skills/create-moviepilot-plugin/SKILL.md +++ b/skills/create-moviepilot-plugin/SKILL.md @@ -1,6 +1,6 @@ --- name: create-moviepilot-plugin -version: 3 +version: 4 description: >- Use this skill when the user asks to create, modify, debug, validate, or scaffold a MoviePilot local plugin. Covers MoviePilot V2 plugin development, @@ -11,7 +11,7 @@ description: >- sidebar pages, commands, services, workflow actions, agent tools, and local install/reload flows. Also use for Chinese requests mentioning 编写插件、本地插件源, 插件开发, V2插件, 插件市场, 本地安装插件, 插件热加载, 前端联邦, 侧栏入口, Vue插件页面. -allowed-tools: list_directory read_file write_file edit_file execute_command search_web browse_webpage query_system_settings update_system_settings query_market_plugins install_plugin reload_plugin query_installed_plugins +allowed-tools: list_directory read_file write_file edit_file apply_patch execute_command search_web browse_webpage query_system_settings update_system_settings query_market_plugins install_plugin reload_plugin query_installed_plugins --- # Create MoviePilot Plugin @@ -51,12 +51,18 @@ a local plugin source and installed into the running MoviePilot instance. examples from different major versions. Search the relevant package directory, `.venv`, or `node_modules` directly with `rg` instead of scanning the entire project without bounds. -- Use `edit_file` for localized changes. Its `old_text` must identify one exact - location by default; add surrounding context instead of enabling - `replace_all` unless every match intentionally changes. -- Use `write_file` for new files. Existing files require `overwrite=true` for a - full rewrite; first call `read_file(include_metadata=true)` and pass its - `sha256` as `expected_sha256` when replacing previously read content. +- Pick the editing tool by scope. Use `apply_patch` when one logical change + spans multiple files, adds new files, or deletes files: submit a single patch + wrapped in `*** Begin Patch` / `*** End Patch` with `*** Add File:`, + `*** Update File:`, and `*** Delete File:` sections; every context and + removed line must match the current content exactly. +- Use `edit_file` for a single localized change in one file. Its `old_text` + must identify one exact location by default; add surrounding context instead + of enabling `replace_all` unless every match intentionally changes. +- Use `write_file` for one standalone new file. Existing files require + `overwrite=true` for a full rewrite; first call + `read_file(include_metadata=true)` and pass its `sha256` as + `expected_sha256` when replacing previously read content. - Use `execute_command(action="run")` for short validation, Git, and diagnostic commands. Use `action="start"` only for interactive or long-running commands, then continue through the returned session ID. diff --git a/skills/create-moviepilot-skill/SKILL.md b/skills/create-moviepilot-skill/SKILL.md index a9bc35904..b05c93099 100644 --- a/skills/create-moviepilot-skill/SKILL.md +++ b/skills/create-moviepilot-skill/SKILL.md @@ -1,6 +1,6 @@ --- name: create-moviepilot-skill -version: 1 +version: 2 description: >- Use this skill when the user asks to create, scaffold, update, or review a MoviePilot agent skill. This includes adding a new built-in skill under the @@ -8,7 +8,7 @@ description: >- `SKILL.md` frontmatter and workflow instructions, choosing `allowed-tools`, adding helper scripts when needed, and bumping the built-in skill `version` so changes can sync into `config/agent/skills`. -allowed-tools: list_directory read_file write_file edit_file execute_command +allowed-tools: list_directory read_file write_file edit_file apply_patch execute_command --- # Create MoviePilot Skill diff --git a/skills/publish-moviepilot-plugin/SKILL.md b/skills/publish-moviepilot-plugin/SKILL.md index 80949410e..44e7f348a 100644 --- a/skills/publish-moviepilot-plugin/SKILL.md +++ b/skills/publish-moviepilot-plugin/SKILL.md @@ -1,6 +1,6 @@ --- name: publish-moviepilot-plugin -version: 1 +version: 2 description: >- Use this skill when the user asks to publish, upload, sync, pull, push, diff, or maintain a MoviePilot local plugin in a GitHub repository. Covers using the @@ -12,7 +12,7 @@ description: >- repository when no target repository is available. Also use for Chinese requests mentioning 插件发布, 插件维护, 推送插件到 GitHub, 从 GitHub 拉取插件, 同步本地插件仓库, 增量发布插件, 插件仓库维护. -allowed-tools: list_directory read_file write_file edit_file execute_command query_system_settings update_system_settings +allowed-tools: list_directory read_file write_file edit_file apply_patch execute_command query_system_settings update_system_settings --- # Publish MoviePilot Plugin diff --git a/tests/test_agent_apply_patch.py b/tests/test_agent_apply_patch.py new file mode 100644 index 000000000..69ca4fc68 --- /dev/null +++ b/tests/test_agent_apply_patch.py @@ -0,0 +1,227 @@ +"""Agent 多文件补丁应用工具测试。""" + +import asyncio +from unittest.mock import patch + +from app.agent.tools.impl import apply_patch as apply_patch_module +from app.agent.tools.impl.apply_patch import ApplyPatchTool + + +def _make_admin_tool(tool_class=ApplyPatchTool): + """创建带管理员上下文的补丁工具实例。""" + tool = tool_class(session_id="session-1", user_id="admin") + tool.set_agent_context({"is_admin": True}) + return tool + + +def test_apply_patch_supports_add_update_delete_in_one_call(tmp_path): + """单个补丁应能同时新增、更新和删除多个文件。""" + updated = tmp_path / "plugin.py" + updated.write_text("enabled = False\nversion = 1\n", encoding="utf-8") + deleted = tmp_path / "legacy.py" + deleted.write_text("old code\n", encoding="utf-8") + tool = _make_admin_tool() + patch_text = ( + "*** Begin Patch\n" + f"*** Add File: {tmp_path / 'new.py'}\n" + "+print('hello')\n" + f"*** Update File: {updated}\n" + "@@\n" + "-enabled = False\n" + "+enabled = True\n" + " version = 1\n" + f"*** Delete File: {deleted}\n" + "*** End Patch\n" + ) + + result = asyncio.run(tool.run(patch_text)) + + assert "成功应用补丁(3 个文件)" in result + assert (tmp_path / "new.py").read_text(encoding="utf-8") == "print('hello')\n" + assert updated.read_text(encoding="utf-8") == "enabled = True\nversion = 1\n" + assert not deleted.exists() + + +def test_apply_patch_applies_multiple_hunks_in_order(tmp_path): + """同一文件的多个替换片段应按顺序定位并依次生效。""" + file_path = tmp_path / "plugin.py" + file_path.write_text("alpha\nbeta\ngamma\nbeta\n", encoding="utf-8") + tool = _make_admin_tool() + patch_text = ( + "*** Begin Patch\n" + f"*** Update File: {file_path}\n" + "@@\n" + " alpha\n" + "-beta\n" + "+BETA\n" + "@@\n" + " gamma\n" + "-beta\n" + "+BETA2\n" + "*** End Patch\n" + ) + + result = asyncio.run(tool.run(patch_text)) + + assert "成功应用补丁(1 个文件)" in result + assert file_path.read_text(encoding="utf-8") == "alpha\nBETA\ngamma\nBETA2\n" + + +def test_apply_patch_rejects_whole_patch_without_any_write_on_mismatch(tmp_path): + """上下文不匹配时应整体拒绝,已校验通过的文件也不应被写入。""" + first = tmp_path / "first.py" + first.write_text("keep me\n", encoding="utf-8") + second = tmp_path / "second.py" + second.write_text("actual content\n", encoding="utf-8") + tool = _make_admin_tool() + patch_text = ( + "*** Begin Patch\n" + f"*** Update File: {first}\n" + "@@\n" + "-keep me\n" + "+changed\n" + f"*** Update File: {second}\n" + "@@\n" + "-not present in file\n" + "+changed\n" + "*** End Patch\n" + ) + + result = asyncio.run(tool.run(patch_text)) + + assert "不匹配" in result + assert first.read_text(encoding="utf-8") == "keep me\n" + assert second.read_text(encoding="utf-8") == "actual content\n" + + +def test_apply_patch_rejects_invalid_patch_structure(tmp_path): + """缺失包裹标记、非法段落顺序和无锚点片段都应返回解析错误。""" + file_path = tmp_path / "plugin.py" + file_path.write_text("content\n", encoding="utf-8") + tool = _make_admin_tool() + + no_begin = asyncio.run(tool.run(f"*** Update File: {file_path}\n*** End Patch\n")) + no_end = asyncio.run(tool.run("*** Begin Patch\n*** End PatchX\n")) + body_before_section = asyncio.run( + tool.run("*** Begin Patch\n+stray line\n*** End Patch\n") + ) + anchorless_hunk = asyncio.run( + tool.run( + "*** Begin Patch\n" + f"*** Update File: {file_path}\n" + "@@\n" + "+only addition\n" + "*** End Patch\n" + ) + ) + + assert "必须以 '*** Begin Patch' 开头" in no_begin + assert "必须以 '*** End Patch' 结尾" in no_end + assert "文件段落之前" in body_before_section + assert "缺少上下文或删除行" in anchorless_hunk + assert file_path.read_text(encoding="utf-8") == "content\n" + + +def test_apply_patch_rejects_add_existing_and_update_missing_file(tmp_path): + """Add 已存在文件或 Update 不存在文件应报错并指引正确操作。""" + existing = tmp_path / "existing.py" + existing.write_text("here\n", encoding="utf-8") + missing = tmp_path / "missing.py" + tool = _make_admin_tool() + + add_result = asyncio.run( + tool.run( + "*** Begin Patch\n" + f"*** Add File: {existing}\n" + "+line\n" + "*** End Patch\n" + ) + ) + update_result = asyncio.run( + tool.run( + "*** Begin Patch\n" + f"*** Update File: {missing}\n" + "@@\n" + "-old\n" + "+new\n" + "*** End Patch\n" + ) + ) + + assert "已存在" in add_result + assert "Update File" in add_result + assert "不存在" in update_result + assert "Add File" in update_result + assert existing.read_text(encoding="utf-8") == "here\n" + assert not missing.exists() + + +def test_apply_patch_enforces_non_admin_path_boundary(tmp_path): + """普通用户只能对 Agent 配置目录内的文件打补丁。""" + file_path = tmp_path / "plugin.py" + file_path.write_text("before\n", encoding="utf-8") + tool = ApplyPatchTool(session_id="session-1", user_id="user") + tool.set_agent_context({"is_admin": False}) + + result = asyncio.run( + tool.run( + "*** Begin Patch\n" + f"*** Update File: {file_path}\n" + "@@\n" + "-before\n" + "+after\n" + "*** End Patch\n" + ) + ) + + assert "Agent配置目录" in result + assert file_path.read_text(encoding="utf-8") == "before\n" + + +def test_apply_patch_detects_version_conflict_during_write(tmp_path): + """写入阶段检测到文件被并发修改时应拒绝覆盖。""" + file_path = tmp_path / "plugin.py" + file_path.write_text("before\n", encoding="utf-8") + tool = _make_admin_tool() + patch_text = ( + "*** Begin Patch\n" + f"*** Update File: {file_path}\n" + "@@\n" + "-before\n" + "+after\n" + "*** End Patch\n" + ) + original_write = apply_patch_module.atomic_write_text + + def _conflicting_write(path, content, expected_sha256=None): + file_path.write_text("changed elsewhere\n", encoding="utf-8") + original_write(path, content, expected_sha256) + + with patch.object( + apply_patch_module, "atomic_write_text", _conflicting_write + ): + result = asyncio.run(tool.run(patch_text)) + + assert "在应用补丁期间发生变化" in result + assert file_path.read_text(encoding="utf-8") == "changed elsewhere\n" + + +def test_apply_patch_tool_message_counts_patch_files(tmp_path): + """工具消息应汇总补丁涉及的文件数量。""" + tool = _make_admin_tool() + + message = tool.get_tool_message( + patch=( + "*** Begin Patch\n" + "*** Add File: a.py\n" + "+x\n" + "*** Update File: b.py\n" + "@@\n" + "-old\n" + "+new\n" + "*** Delete File: c.py\n" + "*** End Patch\n" + ) + ) + + assert message == "应用补丁: 3 个文件" diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index c1d3dbb19..be5d56b9c 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -29,7 +29,9 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: "organize-files": "3", "transfer-failed-retry": "4", "generate-identifiers": "3", - "create-moviepilot-plugin": "3", + "create-moviepilot-plugin": "4", + "create-moviepilot-skill": "2", + "publish-moviepilot-plugin": "2", } for skill_name, expected_version in expected_versions.items(): @@ -79,6 +81,7 @@ def test_agent_core_prompt_does_not_block_plugin_source_edits() -> None: assert "file editing tools, or generated patches to change code" not in core_prompt assert "write_file" in allowed_tools assert "edit_file" in allowed_tools + assert "apply_patch" in allowed_tools assert "search_web" in allowed_tools assert "browse_webpage" in allowed_tools @@ -89,6 +92,8 @@ def test_agent_core_prompt_routes_code_tools_safely() -> None: assert '`execute_command(action="run")` with `rg`' in core_prompt assert "`replace_all=true` only when every match must change" in core_prompt + assert "pick the editing tool by scope" in core_prompt + assert "Use `apply_patch` when one logical change spans multiple files" in core_prompt assert "Use `action=run` for short bounded commands" in core_prompt assert "including SSH" in core_prompt assert "Never use shell redirection" in core_prompt