feat: refine job handling by filtering active jobs and updating date context in prompts

This commit is contained in:
jxxghp
2026-05-11 13:15:32 +08:00
parent 1b2433f7c2
commit b7fc5b0203
8 changed files with 302 additions and 36 deletions

View File

@@ -4,8 +4,14 @@ from unittest.mock import AsyncMock, patch
from langchain_core.messages import AIMessage
from app.agent import MoviePilotAgent, AgentManager, ReplyMode
from app.agent import (
HEARTBEAT_SESSION_PREFIX,
MoviePilotAgent,
AgentManager,
ReplyMode,
)
from app.agent.memory import memory_manager
from app.core.config import settings
from app.utils.identity import SYSTEM_INTERNAL_USER_ID
@@ -38,8 +44,8 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
stop_streaming=AsyncMock(return_value=(False, ""))
)
agent._should_stream = lambda: False
agent._create_agent = lambda streaming=False: _FakeAgent(
[AIMessage(content="后台结果")]
agent._create_agent = AsyncMock(
return_value=_FakeAgent([AIMessage(content="后台结果")])
)
agent.send_agent_message = AsyncMock()
agent._save_agent_message_to_db = AsyncMock()
@@ -66,8 +72,8 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
stop_streaming=AsyncMock(return_value=(False, ""))
)
agent._should_stream = lambda: False
agent._create_agent = lambda streaming=False: _FakeAgent(
[AIMessage(content="后台结果")]
agent._create_agent = AsyncMock(
return_value=_FakeAgent([AIMessage(content="后台结果")])
)
agent.send_agent_message = AsyncMock()
agent._save_agent_message_to_db = AsyncMock()
@@ -94,8 +100,8 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
stop_streaming=AsyncMock(return_value=(False, ""))
)
agent._should_stream = lambda: False
agent._create_agent = lambda streaming=False: _FakeAgent(
[AIMessage(content="后台结果")]
agent._create_agent = AsyncMock(
return_value=_FakeAgent([AIMessage(content="后台结果")])
)
agent.send_agent_message = AsyncMock()
agent._save_agent_message_to_db = AsyncMock()
@@ -114,6 +120,15 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
manager = AgentManager()
with (
patch("app.agent.load_jobs_metadata", new=AsyncMock(return_value=[{
"id": "job-1",
"name": "测试任务",
"description": "desc",
"path": "/tmp/job-1/JOB.md",
"schedule": "once",
"status": "pending",
"last_run": None,
}])),
patch.object(manager, "_build_heartbeat_prompt", return_value="HEARTBEAT"),
patch.object(manager, "process_message", new=AsyncMock()) as process_message,
):
@@ -125,6 +140,80 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
process_message.await_args.kwargs["reply_mode"],
)
async def test_heartbeat_check_jobs_skips_when_no_active_jobs(self):
manager = AgentManager()
with (
patch("app.agent.load_jobs_metadata", new=AsyncMock(return_value=[])),
patch.object(manager, "process_message", new=AsyncMock()) as process_message,
):
await manager.heartbeat_check_jobs()
process_message.assert_not_awaited()
async def test_create_agent_excludes_activity_log_for_heartbeat_session(self):
agent = MoviePilotAgent(
session_id=f"{HEARTBEAT_SESSION_PREFIX}test__",
user_id="system",
)
agent._initialize_tools = lambda: []
with (
patch.object(settings, "LLM_MAX_TOOLS", 0),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch(
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch("app.agent.SkillsMiddleware", side_effect=lambda *args, **kwargs: "skills"),
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch("app.agent.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime"),
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch("app.agent.ActivityLogMiddleware", side_effect=lambda *args, **kwargs: "activity"),
patch("app.agent.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary"),
patch("app.agent.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch"),
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.InMemorySaver", return_value="checkpointer"),
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
self.assertEqual(
["skills", "jobs", "runtime", "memory", "summary", "patch", "usage"],
created["middleware"],
)
async def test_create_agent_keeps_activity_log_for_normal_session(self):
agent = MoviePilotAgent(session_id="normal-session", user_id="system")
agent._initialize_tools = lambda: []
with (
patch.object(settings, "LLM_MAX_TOOLS", 0),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch(
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch("app.agent.SkillsMiddleware", side_effect=lambda *args, **kwargs: "skills"),
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch("app.agent.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime"),
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch("app.agent.ActivityLogMiddleware", side_effect=lambda *args, **kwargs: "activity"),
patch("app.agent.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary"),
patch("app.agent.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch"),
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.InMemorySaver", return_value="checkpointer"),
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
self.assertEqual(
["skills", "jobs", "runtime", "memory", "activity", "summary", "patch", "usage"],
created["middleware"],
)
async def test_run_background_prompt_forces_disable_message_tools_when_capture_only(self):
captured = {}

View File

@@ -0,0 +1,82 @@
import tempfile
import unittest
from pathlib import Path
from anyio import Path as AsyncPath
from app.agent.middleware.jobs import _alist_jobs, filter_active_jobs
class JobsMiddlewareTest(unittest.TestCase):
def test_filter_active_jobs_only_keeps_pending_and_in_progress(self):
jobs_metadata = [
{
"id": "pending-job",
"name": "待执行任务",
"description": "desc",
"path": "/tmp/pending/JOB.md",
"schedule": "once",
"status": "pending",
"last_run": None,
},
{
"id": "running-job",
"name": "执行中任务",
"description": "desc",
"path": "/tmp/running/JOB.md",
"schedule": "recurring",
"status": "in_progress",
"last_run": "2026-05-10 10:00",
},
{
"id": "completed-recurring-job",
"name": "已完成循环任务",
"description": "desc",
"path": "/tmp/completed/JOB.md",
"schedule": "recurring",
"status": "completed",
"last_run": "2026-05-10 11:00",
},
{
"id": "cancelled-job",
"name": "已取消任务",
"description": "desc",
"path": "/tmp/cancelled/JOB.md",
"schedule": "once",
"status": "cancelled",
"last_run": None,
},
]
active_job_ids = [job["id"] for job in filter_active_jobs(jobs_metadata)]
self.assertEqual(["pending-job", "running-job"], active_job_ids)
class JobsMiddlewareAsyncTest(unittest.IsolatedAsyncioTestCase):
async def test_alist_jobs_sorts_job_directories_by_name(self):
with tempfile.TemporaryDirectory() as tempdir:
root = Path(tempdir)
for job_id in ("z-job", "a-job", "m-job"):
job_dir = root / job_id
job_dir.mkdir()
(job_dir / "JOB.md").write_text(
f"""---
name: {job_id}
description: test
schedule: once
status: pending
---
# {job_id}
""",
encoding="utf-8",
)
jobs = await _alist_jobs(AsyncPath(str(root)))
self.assertEqual(["a-job", "m-job", "z-job"], [job["id"] for job in jobs])
if __name__ == "__main__":
unittest.main()

View File

@@ -35,6 +35,8 @@ class TestAgentPromptStyle(unittest.TestCase):
"Do not let user memory or persona style override this core identity",
prompt,
)
self.assertIn("当前日期", prompt)
self.assertNotIn("当前时间", prompt)
def test_runtime_config_middleware_injects_persona_only(self):
middleware = RuntimeConfigMiddleware()

View File

@@ -0,0 +1,37 @@
import tempfile
import unittest
from pathlib import Path
from anyio import Path as AsyncPath
from app.agent.middleware.skills import _alist_skills
class SkillsMiddlewareAsyncTest(unittest.IsolatedAsyncioTestCase):
async def test_alist_skills_sorts_skill_directories_by_name(self):
with tempfile.TemporaryDirectory() as tempdir:
root = Path(tempdir)
for skill_id in ("z-skill", "a-skill", "m-skill"):
skill_dir = root / skill_id
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
f"""---
name: {skill_id}
description: test
---
# {skill_id}
""",
encoding="utf-8",
)
skills = await _alist_skills(AsyncPath(str(root)))
self.assertEqual(
["a-skill", "m-skill", "z-skill"],
[skill["id"] for skill in skills],
)
if __name__ == "__main__":
unittest.main()