chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import yaml
|
||||
|
||||
from agent_runtime.config import (
|
||||
api_provider_from_base_url,
|
||||
embedding_api_key,
|
||||
embedding_base_url,
|
||||
embedding_model,
|
||||
embedding_model_provider,
|
||||
image_api_key,
|
||||
image_base_url,
|
||||
image_model,
|
||||
llm_api_key,
|
||||
llm_base_url,
|
||||
llm_model,
|
||||
llm_model_provider,
|
||||
load_agent_config,
|
||||
reranker_api_key,
|
||||
reranker_base_url,
|
||||
reranker_model,
|
||||
video_api_key,
|
||||
video_base_url,
|
||||
video_model,
|
||||
video_provider,
|
||||
)
|
||||
|
||||
|
||||
class AgentConfigTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
load_agent_config.cache_clear()
|
||||
|
||||
def tearDown(self):
|
||||
load_agent_config.cache_clear()
|
||||
|
||||
def test_reads_agent_local_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_dir = Path(tmp) / "configs"
|
||||
config_dir.mkdir()
|
||||
(config_dir / "agent.local.yaml").write_text(yaml.safe_dump({
|
||||
"llm": {"model_provider": "openai", "model": "config-llm", "base_url": "https://config.test/v1", "api_key": "config-key"},
|
||||
"image": {"model": "config-image", "base_url": "https://image.test", "api_key": "image-key"},
|
||||
"video": {"model": "config-video", "base_url": "https://openrouter.ai/api/v1", "api_key": "video-key"},
|
||||
"embedding": {"model_provider": "openai", "model": "config-embedding", "base_url": "https://embedding.test/v1", "api_key": "embedding-key"},
|
||||
"reranker": {"model": "config-reranker", "base_url": "https://reranker.test", "api_key": "reranker-key"},
|
||||
}), encoding="utf-8")
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(llm_model(tmp), "config-llm")
|
||||
self.assertEqual(llm_model_provider(tmp), "openai")
|
||||
self.assertEqual(llm_base_url(tmp), "https://config.test/v1")
|
||||
self.assertEqual(llm_api_key(tmp), "config-key")
|
||||
self.assertEqual(image_model(tmp), "config-image")
|
||||
self.assertEqual(image_base_url(tmp), "https://image.test")
|
||||
self.assertEqual(image_api_key(tmp), "image-key")
|
||||
self.assertEqual(video_model(tmp), "config-video")
|
||||
self.assertEqual(video_provider(tmp), "openrouter")
|
||||
self.assertEqual(video_base_url(tmp), "https://openrouter.ai/api/v1")
|
||||
self.assertEqual(video_api_key(tmp), "video-key")
|
||||
self.assertEqual(embedding_model_provider(tmp), "openai")
|
||||
self.assertEqual(embedding_model(tmp), "config-embedding")
|
||||
self.assertEqual(embedding_base_url(tmp), "https://embedding.test/v1")
|
||||
self.assertEqual(embedding_api_key(tmp), "embedding-key")
|
||||
self.assertEqual(reranker_model(tmp), "config-reranker")
|
||||
self.assertEqual(reranker_base_url(tmp), "https://reranker.test")
|
||||
self.assertEqual(reranker_api_key(tmp), "reranker-key")
|
||||
|
||||
def test_environment_overrides_agent_local_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_dir = Path(tmp) / "configs"
|
||||
config_dir.mkdir()
|
||||
(config_dir / "agent.local.yaml").write_text(yaml.safe_dump({"llm": {"model": "config-llm", "api_key": "config-key"}}), encoding="utf-8")
|
||||
with patch.dict(os.environ, {"VIMAX_LLM_MODEL": "env-llm", "VIMAX_LLM_MODEL_PROVIDER": "openai", "VIMAX_LLM_API_KEY": "env-key", "VIMAX_VIDEO_BASE_URL": "https://openrouter.ai/api/v1", "VIMAX_EMBEDDING_MODEL": "env-embedding", "VIMAX_EMBEDDING_BASE_URL": "https://env-embedding.test/v1", "VIMAX_EMBEDDING_API_KEY": "env-embedding-key", "VIMAX_RERANKER_MODEL": "env-reranker", "VIMAX_RERANKER_BASE_URL": "https://env-reranker.test", "VIMAX_RERANKER_API_KEY": "env-reranker-key"}, clear=True):
|
||||
self.assertEqual(llm_model(tmp), "env-llm")
|
||||
self.assertEqual(llm_model_provider(tmp), "openai")
|
||||
self.assertEqual(llm_api_key(tmp), "env-key")
|
||||
self.assertEqual(video_provider(tmp), "openrouter")
|
||||
self.assertEqual(video_base_url(tmp), "https://openrouter.ai/api/v1")
|
||||
self.assertEqual(embedding_model(tmp), "env-embedding")
|
||||
self.assertEqual(embedding_base_url(tmp), "https://env-embedding.test/v1")
|
||||
self.assertEqual(embedding_api_key(tmp), "env-embedding-key")
|
||||
self.assertEqual(reranker_model(tmp), "env-reranker")
|
||||
self.assertEqual(reranker_base_url(tmp), "https://env-reranker.test")
|
||||
self.assertEqual(reranker_api_key(tmp), "env-reranker-key")
|
||||
|
||||
def test_image_and_video_keys_fall_back_to_llm_key(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
config_dir = Path(tmp) / "configs"
|
||||
config_dir.mkdir()
|
||||
(config_dir / "agent.local.yaml").write_text(yaml.safe_dump({"llm": {"api_key": "shared-key"}}), encoding="utf-8")
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(image_api_key(tmp), "shared-key")
|
||||
self.assertEqual(video_api_key(tmp), "shared-key")
|
||||
|
||||
def test_video_provider_is_inferred_from_base_url(self):
|
||||
self.assertEqual(api_provider_from_base_url("https://openrouter.ai/api/v1"), "openrouter")
|
||||
self.assertEqual(api_provider_from_base_url("https://yunwu.ai/v1"), "yunwu")
|
||||
self.assertEqual(api_provider_from_base_url("https://example.com/v1"), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from agent_runtime.llm import OpenAICompatibleLLM
|
||||
|
||||
|
||||
class AgentLLMTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_string_response_retries_before_clear_error(self):
|
||||
llm = OpenAICompatibleLLM(model="test", base_url="https://example.invalid/v1", api_key="test-key")
|
||||
create = AsyncMock(side_effect=["data: [DONE]", "bad response"])
|
||||
llm.client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
|
||||
with self.assertRaisesRegex(RuntimeError, "returned a string"):
|
||||
await llm.complete([], [])
|
||||
self.assertEqual(create.await_count, 2)
|
||||
|
||||
async def test_string_response_retry_can_recover(self):
|
||||
llm = OpenAICompatibleLLM(model="test", base_url="https://example.invalid/v1", api_key="test-key")
|
||||
create = AsyncMock(side_effect=["data: [DONE]", {"choices": [{"message": {"content": "recovered", "tool_calls": []}}]}])
|
||||
llm.client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
|
||||
message = await llm.complete([], [])
|
||||
self.assertEqual(message.text, "recovered")
|
||||
self.assertEqual(create.await_count, 2)
|
||||
|
||||
async def test_tool_request_falls_back_to_plain_chat_after_bad_tool_responses(self):
|
||||
llm = OpenAICompatibleLLM(model="test", base_url="https://example.invalid/v1", api_key="test-key")
|
||||
create = AsyncMock(side_effect=[
|
||||
"data: [DONE]",
|
||||
"data: [DONE]",
|
||||
{"choices": [{"message": {"content": "plain fallback", "tool_calls": []}}]},
|
||||
])
|
||||
llm.client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
|
||||
message = await llm.complete([], [{"type": "function", "function": {"name": "x", "parameters": {}}}])
|
||||
self.assertEqual(message.text, "plain fallback")
|
||||
self.assertEqual(create.await_count, 3)
|
||||
self.assertIsNone(create.await_args_list[-1].kwargs.get("tools"))
|
||||
|
||||
async def test_dict_response_is_accepted(self):
|
||||
llm = OpenAICompatibleLLM(model="test", base_url="https://example.invalid/v1", api_key="test-key")
|
||||
llm.client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=AsyncMock(return_value={
|
||||
"choices": [{"message": {"content": "hello", "tool_calls": []}}]
|
||||
}))))
|
||||
message = await llm.complete([], [])
|
||||
self.assertEqual(message.text, "hello")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,135 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from agent_runtime.context_compactor import ContextCompactor
|
||||
from agent_runtime.llm import AssistantMessage
|
||||
from agent_runtime.loop import AgentLoop
|
||||
from agent_runtime.models import ToolCall, ToolResult
|
||||
from agent_runtime.prompts import PromptBuilder
|
||||
from agent_runtime.session_index import SessionIndex
|
||||
from agent_runtime.tool_executor import ToolExecutor
|
||||
from agent_runtime.tools import ToolArgumentSchema, ToolRegistry, ToolSpec
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def __init__(self, replies):
|
||||
self.replies = list(replies)
|
||||
|
||||
async def complete(self, messages, tools):
|
||||
return self.replies.pop(0)
|
||||
|
||||
|
||||
class FailingLLM:
|
||||
async def complete(self, messages, tools):
|
||||
raise RuntimeError("provider returned invalid response shape")
|
||||
|
||||
|
||||
class AgentLoopTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_no_tool_call_finishes(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
registry = ToolRegistry([])
|
||||
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), FakeLLM([AssistantMessage(text="done")]))
|
||||
events = [event async for event in loop.stream_events("hi")]
|
||||
self.assertEqual(events[-2]["type"], "done")
|
||||
turn_id = events[0]["turn_id"]
|
||||
self.assertTrue(all(event.get("turn_id") == turn_id for event in events))
|
||||
log_text = (index.logs_dir / "loop_history.jsonl").read_text(encoding="utf-8")
|
||||
self.assertIn("assistant_finished_without_tools", log_text)
|
||||
|
||||
|
||||
async def test_turn_record_follows_session_created_by_tool(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
old = index.create(idea="old")
|
||||
|
||||
def create_actual(args):
|
||||
record = index.create(idea="actual")
|
||||
return ToolResult("create_actual", True, record["session_id"])
|
||||
|
||||
registry = ToolRegistry([ToolSpec("create_actual", "Create actual session", create_actual, schema={})])
|
||||
llm = FakeLLM([AssistantMessage(tool_calls=[ToolCall(name="create_actual", arguments={})]), AssistantMessage(text="finished")])
|
||||
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), llm)
|
||||
events = [event async for event in loop.stream_events("start new project")]
|
||||
active = index.active()
|
||||
self.assertNotEqual(active["session_id"], old["session_id"])
|
||||
self.assertEqual(len(index.get(active["session_id"])["recent_turn_records"]), 1)
|
||||
self.assertEqual(index.get(old["session_id"])["recent_turn_records"], [])
|
||||
self.assertEqual(events[-1]["session"]["active_session_id"], active["session_id"])
|
||||
|
||||
|
||||
async def test_tool_progress_streams_before_tool_result(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def slow_tool(args, runtime):
|
||||
runtime.emit_progress("started", stage="running")
|
||||
await release.wait()
|
||||
return ToolResult("slow_tool", True, "done")
|
||||
|
||||
registry = ToolRegistry([ToolSpec("slow_tool", "Slow tool", slow_tool, schema={})])
|
||||
llm = FakeLLM([AssistantMessage(tool_calls=[ToolCall(name="slow_tool", arguments={})]), AssistantMessage(text="finished")])
|
||||
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), llm)
|
||||
agen = loop.stream_events("start")
|
||||
seen = []
|
||||
while True:
|
||||
event = await asyncio.wait_for(anext(agen), timeout=1)
|
||||
seen.append(event["type"])
|
||||
if event["type"] == "tool_progress":
|
||||
self.assertFalse(release.is_set())
|
||||
break
|
||||
release.set()
|
||||
async for event in agen:
|
||||
seen.append(event["type"])
|
||||
self.assertLess(seen.index("tool_progress"), seen.index("tool_result"))
|
||||
|
||||
|
||||
async def test_preflight_compact_summarizes_old_history(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
registry = ToolRegistry([])
|
||||
compactor = ContextCompactor(None, token_threshold=200, buffer_tokens=0, preserve_last_n=2, summary_max_chars=2000)
|
||||
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), FakeLLM([AssistantMessage(text="after compact")]), compactor)
|
||||
loop.history = [
|
||||
{"role": "user", "content": "old request " + "x" * 1200},
|
||||
{"role": "assistant", "content": "old answer " + "y" * 1200},
|
||||
{"role": "user", "content": "recent request"},
|
||||
{"role": "assistant", "content": "recent answer"},
|
||||
]
|
||||
events = [event async for event in loop.stream_events("continue")]
|
||||
self.assertIn("compact", [event.get("phase") for event in events if event["type"] == "status"])
|
||||
session = index.active()
|
||||
self.assertIn("Reference Context Only", session["compacted_summary"])
|
||||
self.assertGreaterEqual(session["compacted_turns"], 1)
|
||||
self.assertTrue(session["compaction_snapshots"])
|
||||
self.assertEqual(loop.history[0]["role"], "system")
|
||||
self.assertIn("after compact", loop.history[-1]["content"])
|
||||
self.assertNotIn("old request", index.memory_text())
|
||||
|
||||
|
||||
async def test_llm_sampling_error_yields_error_without_crashing_loop(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
registry = ToolRegistry([])
|
||||
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), FailingLLM())
|
||||
events = [event async for event in loop.stream_events("start")]
|
||||
self.assertTrue(any(event["type"] == "error" and event.get("metadata", {}).get("error_type") == "llm_sampling_failed" for event in events))
|
||||
self.assertEqual(events[-2]["type"], "done")
|
||||
self.assertEqual(events[-1]["type"], "session")
|
||||
self.assertEqual(index.active()["recent_turn_records"][-1]["status"], "failed")
|
||||
|
||||
async def test_tool_call_continues_then_finishes(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
|
||||
def hello(args):
|
||||
return ToolResult("hello", True, "hello result")
|
||||
|
||||
registry = ToolRegistry([ToolSpec("hello", "Say hello", hello, schema={"name": ToolArgumentSchema(str, False, "x")})])
|
||||
llm = FakeLLM([AssistantMessage(tool_calls=[ToolCall(name="hello", arguments={})]), AssistantMessage(text="finished")])
|
||||
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), llm)
|
||||
events = [event async for event in loop.stream_events("start")]
|
||||
self.assertTrue(any(event["type"] == "tool_result" for event in events))
|
||||
self.assertEqual(events[-2]["assistant"], "finished")
|
||||
@@ -0,0 +1,70 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from agent_runtime.prompts import PromptBuilder
|
||||
from agent_runtime.session_index import SessionIndex
|
||||
from agent_runtime.tools import build_builtin_registry
|
||||
|
||||
|
||||
class PromptBuilderTests(unittest.TestCase):
|
||||
def test_prompt_injects_context_and_tool_manifest(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "prompts").mkdir()
|
||||
(root / "prompts" / "agent.md").write_text("agent rules", encoding="utf-8")
|
||||
(root / "prompts" / "workflow.md").write_text("workflow rules", encoding="utf-8")
|
||||
index = SessionIndex(root)
|
||||
index.create(idea="cat")
|
||||
registry = build_builtin_registry(root, index)
|
||||
builder = PromptBuilder(root / "prompts", index, registry)
|
||||
messages = builder.build_messages("start")
|
||||
self.assertIn("Available tools", messages[0]["content"])
|
||||
self.assertIn("当前 working_dir 尚未完成结构化文本文件", messages[0]["content"])
|
||||
self.assertIn("read_file", messages[0]["content"])
|
||||
trace = builder.trace(builder.build_parts("start"))
|
||||
self.assertGreater(trace["total_estimated_tokens"], 0)
|
||||
|
||||
|
||||
def test_prompt_injects_compacted_session_summary_as_reference(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "prompts").mkdir()
|
||||
(root / "prompts" / "agent.md").write_text("agent rules", encoding="utf-8")
|
||||
(root / "prompts" / "workflow.md").write_text("workflow rules", encoding="utf-8")
|
||||
index = SessionIndex(root)
|
||||
record = index.create(idea="cat")
|
||||
index.update_compaction(record["session_id"], {"summary": "## Reference Context Only\n- user wants moon cat", "compacted_message_count": 4, "preserved_message_count": 2})
|
||||
registry = build_builtin_registry(root, index)
|
||||
builder = PromptBuilder(root / "prompts", index, registry)
|
||||
message = builder.build_messages("continue")[0]["content"]
|
||||
self.assertIn("Session context summary", message)
|
||||
self.assertIn("reference context only", message)
|
||||
self.assertIn("user wants moon cat", message)
|
||||
trace = builder.trace(builder.build_parts("continue"))
|
||||
self.assertGreater(trace["totals"]["dynamic_tokens"], 0)
|
||||
|
||||
def test_prompt_treats_novel_text_artifacts_as_text_stage_complete(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "prompts").mkdir()
|
||||
(root / "prompts" / "agent.md").write_text("agent rules", encoding="utf-8")
|
||||
(root / "prompts" / "workflow.md").write_text("workflow rules", encoding="utf-8")
|
||||
index = SessionIndex(root)
|
||||
record = index.create(idea="novel")
|
||||
session_root = root / record["working_dir"] / "novel2video"
|
||||
(session_root / "novel").mkdir(parents=True)
|
||||
(session_root / "novel" / "novel_compressed.txt").write_text("compressed", encoding="utf-8")
|
||||
(session_root / "events").mkdir()
|
||||
(session_root / "events" / "event_0.json").write_text("{}", encoding="utf-8")
|
||||
(session_root / "relevant_chunks" / "event_0").mkdir(parents=True)
|
||||
(session_root / "relevant_chunks" / "event_0" / "chunk.txt").write_text("chunk", encoding="utf-8")
|
||||
(session_root / "scenes" / "event_0").mkdir(parents=True)
|
||||
(session_root / "scenes" / "event_0" / "scene_0.json").write_text("{}", encoding="utf-8")
|
||||
(session_root / "global_information" / "characters" / "novel_level").mkdir(parents=True)
|
||||
(session_root / "global_information" / "characters" / "novel_level" / "novel_characters_after_event_0.json").write_text("[]", encoding="utf-8")
|
||||
registry = build_builtin_registry(root, index)
|
||||
builder = PromptBuilder(root / "prompts", index, registry)
|
||||
messages = builder.build_messages("continue")
|
||||
self.assertIn("文本规划阶段已完成", messages[0]["content"])
|
||||
self.assertIn("novel2video/events/event_*.json: present", messages[0]["content"])
|
||||
@@ -0,0 +1,69 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from agent_runtime.session_index import SessionIndex
|
||||
|
||||
|
||||
class SessionIndexTests(unittest.TestCase):
|
||||
def test_generated_session_id_round_trips_after_slug_truncation(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="A red ball rolls across a white table.")
|
||||
self.assertIsNotNone(index.get(record["session_id"]))
|
||||
self.assertEqual(index.working_dir(record["session_id"]).name, record["session_id"])
|
||||
|
||||
def test_create_session_and_checklist(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="Moon cat", user_requirement="short", style="anime")
|
||||
self.assertEqual(index.active()["session_id"], record["session_id"])
|
||||
working_dir = Path(tmp) / record["working_dir"]
|
||||
self.assertTrue((working_dir / "idea2video").exists())
|
||||
self.assertTrue((working_dir / "script2video").exists())
|
||||
checklist = index.artifact_checklist(record["session_id"])
|
||||
self.assertFalse(checklist["script2video/storyboard.json"])
|
||||
self.assertFalse(checklist["idea2video/scene_*/storyboard.json"])
|
||||
self.assertEqual(record["compacted_summary"], "")
|
||||
self.assertEqual(record["compaction_snapshots"], [])
|
||||
|
||||
|
||||
def test_session_id_is_sanitized_and_stays_under_working_dir(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(session_id="../../escaped-review")
|
||||
self.assertEqual(record["session_id"], "escaped-review")
|
||||
working_dir = (Path(tmp) / record["working_dir"]).resolve()
|
||||
self.assertTrue(str(working_dir).startswith(str((Path(tmp) / ".working_dir").resolve())))
|
||||
self.assertFalse((Path(tmp).parent / "escaped-review").exists())
|
||||
|
||||
|
||||
def test_update_compaction_writes_session_state_not_memory(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="compact")
|
||||
index.update_compaction(record["session_id"], {
|
||||
"summary": "## Reference Context Only\n- old context",
|
||||
"compacted_message_count": 4,
|
||||
"preserved_message_count": 2,
|
||||
"estimated_tokens_before": 1000,
|
||||
"estimated_tokens_after": 300,
|
||||
"reason": "manual",
|
||||
"mode": "fallback-local",
|
||||
})
|
||||
session = index.get(record["session_id"])
|
||||
self.assertIn("old context", session["compacted_summary"])
|
||||
self.assertEqual(session["compacted_turns"], 2)
|
||||
self.assertEqual(session["last_compaction_reason"], "manual")
|
||||
self.assertTrue(session["compaction_snapshots"])
|
||||
self.assertNotIn("old context", index.memory_text())
|
||||
|
||||
def test_memory_and_turn_record_boundaries(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create()
|
||||
index.write_memory("# User Preferences\n- 16:9\n")
|
||||
self.assertIn("16:9", index.memory_text())
|
||||
index.append_turn_record(record["session_id"], {"turn_id": "t1", "status": "completed", "tool_rounds": [], "final_assistant_text": "done"})
|
||||
self.assertTrue((Path(tmp) / ".vimax" / "logs" / "loop_history.jsonl").exists())
|
||||
self.assertEqual(len(index.get(record["session_id"])["recent_turn_records"]), 1)
|
||||
@@ -0,0 +1,127 @@
|
||||
import asyncio
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from agent_runtime.models import ToolCall, TurnControl
|
||||
from agent_runtime.session_index import SessionIndex
|
||||
from agent_runtime.tool_executor import ToolExecutor
|
||||
from agent_runtime.tools import build_builtin_registry
|
||||
|
||||
|
||||
class ToolRegistryTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_validation_default_write_json_and_logging(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
index.create()
|
||||
registry = build_builtin_registry(tmp, index)
|
||||
executor = ToolExecutor(registry, index)
|
||||
record = await executor.execute(ToolCall(name="write_json", arguments={"path": "data/a.json", "data": {"x": 1}}), TurnControl())
|
||||
self.assertTrue(record.result.ok)
|
||||
self.assertEqual(json.loads((Path(tmp) / "data" / "a.json").read_text())["x"], 1)
|
||||
self.assertTrue((Path(tmp) / ".vimax" / "logs" / "tool_calls.jsonl").exists())
|
||||
|
||||
async def test_unknown_and_missing_argument_return_tool_errors(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
registry = build_builtin_registry(tmp, index)
|
||||
executor = ToolExecutor(registry, index)
|
||||
missing = await executor.execute(ToolCall(name="read_file", arguments={}), TurnControl())
|
||||
self.assertFalse(missing.result.ok)
|
||||
unknown = await executor.execute(ToolCall(name="does_not_exist", arguments={}), TurnControl())
|
||||
self.assertFalse(unknown.result.ok)
|
||||
|
||||
|
||||
async def test_run_shell_is_disabled_by_default(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
registry = build_builtin_registry(tmp, index)
|
||||
executor = ToolExecutor(registry, index)
|
||||
record = await executor.execute(ToolCall(name="run_shell", arguments={"command": "pwd"}), TurnControl())
|
||||
self.assertFalse(record.result.ok)
|
||||
self.assertEqual(record.result.metadata["error_type"], "disabled")
|
||||
|
||||
|
||||
async def test_todo_read_returns_empty_items_by_default(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
registry = build_builtin_registry(tmp, index)
|
||||
executor = ToolExecutor(registry, index)
|
||||
record = await executor.execute(ToolCall(name="todo_read", arguments={}), TurnControl())
|
||||
self.assertTrue(record.result.ok)
|
||||
self.assertEqual(json.loads(record.result.content)["items"], [])
|
||||
|
||||
async def test_todo_write_then_read_persists_items_and_logs(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
index.create()
|
||||
registry = build_builtin_registry(tmp, index)
|
||||
executor = ToolExecutor(registry, index)
|
||||
items = [{"content": "实现 TUI", "status": "in_progress"}, {"content": "补测试"}]
|
||||
written = await executor.execute(ToolCall(name="todo_write", arguments={"items": items}), TurnControl())
|
||||
self.assertTrue(written.result.ok)
|
||||
todo_path = Path(tmp) / ".vimax" / "todo.json"
|
||||
self.assertTrue(todo_path.exists())
|
||||
payload = json.loads(todo_path.read_text())
|
||||
self.assertEqual(payload["items"][0]["content"], "实现 TUI")
|
||||
self.assertEqual(payload["items"][1]["status"], "pending")
|
||||
|
||||
read = await executor.execute(ToolCall(name="todo_read", arguments={}), TurnControl())
|
||||
self.assertTrue(read.result.ok)
|
||||
self.assertEqual(json.loads(read.result.content)["items"], payload["items"])
|
||||
logs = (Path(tmp) / ".vimax" / "logs" / "tool_calls.jsonl").read_text()
|
||||
self.assertIn('"tool": "todo_write"', logs)
|
||||
|
||||
async def test_todo_write_rejects_invalid_payload(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
registry = build_builtin_registry(tmp, index)
|
||||
executor = ToolExecutor(registry, index)
|
||||
not_list = await executor.execute(ToolCall(name="todo_write", arguments={"items": {"content": "x"}}), TurnControl())
|
||||
self.assertFalse(not_list.result.ok)
|
||||
missing_content = await executor.execute(ToolCall(name="todo_write", arguments={"items": [{"status": "pending"}]}), TurnControl())
|
||||
self.assertFalse(missing_content.result.ok)
|
||||
bad_status = await executor.execute(ToolCall(name="todo_write", arguments={"items": [{"content": "x", "status": "blocked"}]}), TurnControl())
|
||||
self.assertFalse(bad_status.result.ok)
|
||||
|
||||
async def test_read_json_supports_virtual_session_json_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(session_id="20260630-125442-vimax", idea="surfing")
|
||||
registry = build_builtin_registry(tmp, index)
|
||||
executor = ToolExecutor(registry, index)
|
||||
result = await executor.execute(
|
||||
ToolCall(name="read_json", arguments={"path": f"{record['working_dir']}/session.json"}),
|
||||
TurnControl(),
|
||||
)
|
||||
self.assertTrue(result.result.ok)
|
||||
payload = json.loads(result.result.content)
|
||||
self.assertEqual(payload["session"]["session_id"], "20260630-125442-vimax")
|
||||
self.assertEqual(payload["source"], ".vimax/sessions.json")
|
||||
self.assertTrue(result.result.metadata["virtual_path"])
|
||||
|
||||
async def test_read_file_supports_virtual_session_log_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(session_id="20260630-125442-vimax", idea="surfing")
|
||||
index.append_turn_record(record["session_id"], {"turn_id": "turn-1", "status": "completed", "final_assistant_text": "done"})
|
||||
registry = build_builtin_registry(tmp, index)
|
||||
executor = ToolExecutor(registry, index)
|
||||
result = await executor.execute(
|
||||
ToolCall(name="read_file", arguments={"path": ".vimax/logs/20260630-125442-vimax.log"}),
|
||||
TurnControl(),
|
||||
)
|
||||
self.assertTrue(result.result.ok)
|
||||
payload = json.loads(result.result.content)
|
||||
self.assertEqual(payload["session_id"], "20260630-125442-vimax")
|
||||
self.assertEqual(payload["source"], ".vimax/logs/*.jsonl")
|
||||
self.assertEqual(payload["records"][0]["turn_id"], "turn-1")
|
||||
self.assertTrue(result.result.metadata["virtual_path"])
|
||||
|
||||
def test_concurrency_partition_groups_read_tools(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
registry = build_builtin_registry(tmp, SessionIndex(tmp))
|
||||
batches = registry.partition_calls([ToolCall("read_file", {"path": "x"}), ToolCall("glob_files", {"pattern": "*"}), ToolCall("write_json", {"path": "x", "data": {}})])
|
||||
self.assertEqual(len(batches), 2)
|
||||
self.assertEqual(len(batches[0]), 2)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Regression tests for small crash bugs in helper stringification paths."""
|
||||
|
||||
import unittest
|
||||
|
||||
from agent_runtime.context_compactor import ContextCompactor
|
||||
from interfaces.shot_description import ShotBriefDescription
|
||||
|
||||
|
||||
class TestContextCompactorToolCallPreview(unittest.TestCase):
|
||||
def test_fallback_summary_handles_tool_call_messages(self):
|
||||
compactor = ContextCompactor(None, token_threshold=200, buffer_tokens=0, preserve_last_n=2, summary_max_chars=2000)
|
||||
messages = [
|
||||
{"role": "user", "content": "list the files"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [{"id": "c1", "function": {"name": "list_files", "arguments": "{}"}}]},
|
||||
]
|
||||
summary = compactor._fallback_summary(messages, [], "", "test")
|
||||
self.assertIn("[tool calls]", summary)
|
||||
self.assertIn("list_files", summary)
|
||||
|
||||
|
||||
class TestShotBriefDescriptionStr(unittest.TestCase):
|
||||
def test_str_uses_existing_fields(self):
|
||||
shot = ShotBriefDescription(
|
||||
idx=0,
|
||||
is_last=False,
|
||||
cam_idx=1,
|
||||
visual_desc="<Alice> waves at the camera.",
|
||||
audio_desc="[Speaker] Alice (Happy): Hello!",
|
||||
)
|
||||
text = str(shot)
|
||||
self.assertIn("Shot 0", text)
|
||||
self.assertIn("Camera Index: 1", text)
|
||||
self.assertIn("<Alice> waves at the camera.", text)
|
||||
self.assertIn("[Speaker] Alice (Happy): Hello!", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Every video generator must satisfy the VideoGenerator protocol, which
|
||||
declares **kwargs: pipelines pass progress= callbacks, and generators that
|
||||
reject unknown kwargs crash mid-render (TypeError) on the transition path."""
|
||||
|
||||
import inspect
|
||||
import unittest
|
||||
|
||||
from tools.video_generator_doubao_seedance_yunwu_api import VideoGeneratorDoubaoSeedanceYunwuAPI
|
||||
from tools.video_generator_omni_yunwu_api import VideoGeneratorOmniYunwuAPI
|
||||
from tools.video_generator_openrouter_api import VideoGeneratorOpenRouterAPI
|
||||
from tools.video_generator_veo_google_api import VideoGeneratorVeoGoogleAPI
|
||||
from tools.video_generator_veo_yunwu_api import VideoGeneratorVeoYunwuAPI
|
||||
|
||||
|
||||
class TestVideoGeneratorProtocol(unittest.TestCase):
|
||||
GENERATORS = [
|
||||
VideoGeneratorDoubaoSeedanceYunwuAPI,
|
||||
VideoGeneratorOmniYunwuAPI,
|
||||
VideoGeneratorOpenRouterAPI,
|
||||
VideoGeneratorVeoGoogleAPI,
|
||||
VideoGeneratorVeoYunwuAPI,
|
||||
]
|
||||
|
||||
def test_generate_single_video_accepts_arbitrary_kwargs(self):
|
||||
for cls in self.GENERATORS:
|
||||
with self.subTest(cls=cls.__name__):
|
||||
params = inspect.signature(cls.generate_single_video).parameters
|
||||
accepts_var_kwargs = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
|
||||
self.assertTrue(
|
||||
accepts_var_kwargs,
|
||||
f"{cls.__name__}.generate_single_video must accept **kwargs per tools.protocols.VideoGenerator "
|
||||
"(pipelines pass progress=...)",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Regression tests for unbounded retry/polling loops and LLM-trusted graphs.
|
||||
|
||||
The bugs under test previously looped forever. Each test's fakes succeed after
|
||||
N calls, so the buggy code produces a fast assertion failure (extra calls or a
|
||||
missing exception) rather than hanging the suite; the fixed code must give up
|
||||
before the fake ever succeeds.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from agents.camera_image_generator import _validate_camera_tree
|
||||
from agents.event_extractor import EventExtractor
|
||||
from interfaces.camera import Camera
|
||||
from interfaces.event import Event
|
||||
from pipelines.novel2movie_pipeline import _ensure_extraction_cap
|
||||
from tools.video_generator_doubao_seedance_yunwu_api import VideoGeneratorDoubaoSeedanceYunwuAPI
|
||||
from tools.video_generator_omni_yunwu_api import VideoGeneratorOmniYunwuAPI
|
||||
from utils.image import download_image
|
||||
from utils.video import download_video
|
||||
|
||||
|
||||
def _no_sleep(fn):
|
||||
retrying = getattr(fn, "retry", None)
|
||||
if retrying is not None:
|
||||
retrying.sleep = lambda seconds: None
|
||||
|
||||
|
||||
class TestDownloadRetries(unittest.TestCase):
|
||||
def setUp(self):
|
||||
_no_sleep(download_image)
|
||||
_no_sleep(download_video)
|
||||
|
||||
def test_download_image_gives_up_on_persistent_network_error(self):
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky_get(url, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 10:
|
||||
raise requests.ConnectionError("connection refused")
|
||||
return MagicMock()
|
||||
|
||||
with patch("utils.image.requests.get", side_effect=flaky_get):
|
||||
with self.assertRaises(requests.ConnectionError):
|
||||
download_image("http://example.com/a.png", "/tmp/a.png")
|
||||
self.assertLessEqual(calls["n"], 5, "retry must be bounded, not retry-until-success")
|
||||
|
||||
def test_download_image_does_not_retry_client_errors(self):
|
||||
calls = {"n": 0}
|
||||
gone = MagicMock()
|
||||
gone.raise_for_status.side_effect = requests.HTTPError(
|
||||
"404", response=MagicMock(status_code=404)
|
||||
)
|
||||
|
||||
def expired_then_ok(url, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 3:
|
||||
return gone
|
||||
return MagicMock()
|
||||
|
||||
with patch("utils.image.requests.get", side_effect=expired_then_ok):
|
||||
with self.assertRaises(requests.HTTPError):
|
||||
download_image("http://example.com/expired.png", "/tmp/a.png")
|
||||
self.assertEqual(calls["n"], 1, "4xx responses must fail fast, not be retried")
|
||||
|
||||
def test_download_image_sets_a_timeout(self):
|
||||
captured = {}
|
||||
|
||||
def record_get(url, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
with patch("utils.image.requests.get", side_effect=record_get):
|
||||
download_image("http://example.com/a.png", "/tmp/a.png")
|
||||
self.assertIsNotNone(captured.get("timeout"), "requests.get must not wait forever")
|
||||
|
||||
def test_download_video_gives_up_on_persistent_network_error(self):
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky_get(url, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 10:
|
||||
raise requests.ConnectionError("connection refused")
|
||||
return MagicMock()
|
||||
|
||||
with patch("utils.video.requests.get", side_effect=flaky_get):
|
||||
with self.assertRaises(requests.ConnectionError):
|
||||
download_video("http://example.com/a.mp4", "/tmp/a.mp4")
|
||||
self.assertLessEqual(calls["n"], 5, "retry must be bounded, not retry-until-success")
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload, status=200):
|
||||
self.payload = payload
|
||||
self.status = status
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Returns each scripted (payload, status) in turn, repeating the last one."""
|
||||
|
||||
def __init__(self, scripted):
|
||||
self.scripted = list(scripted)
|
||||
self.calls = 0
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def _next(self):
|
||||
response = self.scripted[min(self.calls, len(self.scripted) - 1)]
|
||||
self.calls += 1
|
||||
return _FakeResponse(*response)
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
return self._next()
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
return self._next()
|
||||
|
||||
|
||||
class TestSeedanceClientBounds(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create_task_fails_fast_on_auth_error(self):
|
||||
session = _FakeSession([
|
||||
({"error": "invalid api key"}, 401),
|
||||
({"error": "invalid api key"}, 401),
|
||||
({"id": "task-1"}, 200),
|
||||
])
|
||||
generator = VideoGeneratorDoubaoSeedanceYunwuAPI(api_key="bad-key")
|
||||
with patch("tools.video_generator_doubao_seedance_yunwu_api.aiohttp.ClientSession", return_value=session), \
|
||||
patch("tools.video_generator_doubao_seedance_yunwu_api.asyncio.sleep", new=AsyncMock()):
|
||||
with self.assertRaises(RuntimeError):
|
||||
await generator.create_video_generation_task("a prompt", [])
|
||||
self.assertEqual(session.calls, 1, "4xx must not be retried")
|
||||
|
||||
async def test_query_task_polling_is_bounded(self):
|
||||
session = _FakeSession([({"status": "queued"}, 200)])
|
||||
generator = VideoGeneratorDoubaoSeedanceYunwuAPI(api_key="key", max_poll_attempts=3)
|
||||
with patch("tools.video_generator_doubao_seedance_yunwu_api.aiohttp.ClientSession", return_value=session), \
|
||||
patch("tools.video_generator_doubao_seedance_yunwu_api.asyncio.sleep", new=AsyncMock()):
|
||||
with self.assertRaises(TimeoutError):
|
||||
await generator.query_video_generation_task("task-1")
|
||||
self.assertLessEqual(session.calls, 3)
|
||||
|
||||
|
||||
class TestOmniClientBounds(unittest.IsolatedAsyncioTestCase):
|
||||
def test_polling_is_bounded_by_default(self):
|
||||
generator = VideoGeneratorOmniYunwuAPI(api_key="key")
|
||||
self.assertIsNotNone(generator.max_poll_attempts, "default polling must have a deadline")
|
||||
|
||||
async def test_create_task_fails_fast_on_auth_error(self):
|
||||
session = _FakeSession([
|
||||
({"error": "invalid api key"}, 401),
|
||||
({"error": "invalid api key"}, 401),
|
||||
({"id": "task-1"}, 200),
|
||||
])
|
||||
generator = VideoGeneratorOmniYunwuAPI(api_key="bad-key")
|
||||
with patch("tools.video_generator_omni_yunwu_api.aiohttp.ClientSession", return_value=session), \
|
||||
patch("tools.video_generator_omni_yunwu_api.asyncio.sleep", new=AsyncMock()):
|
||||
with self.assertRaises(RuntimeError):
|
||||
await generator.create_video_generation_task("a prompt", [])
|
||||
self.assertEqual(session.calls, 1, "4xx must not be retried")
|
||||
|
||||
|
||||
class TestEventExtractionCap(unittest.TestCase):
|
||||
def test_extraction_aborts_when_model_never_emits_is_last(self):
|
||||
extractor = object.__new__(EventExtractor)
|
||||
calls = {"n": 0}
|
||||
|
||||
def never_last(novel_text, extracted_events):
|
||||
calls["n"] += 1
|
||||
if calls["n"] > 200:
|
||||
raise AssertionError("loop was not capped")
|
||||
return Event(
|
||||
index=len(extracted_events),
|
||||
is_last=False,
|
||||
description="an event",
|
||||
process_chain=["something happens"],
|
||||
)
|
||||
|
||||
extractor.extract_next_event = never_last
|
||||
with self.assertRaisesRegex(RuntimeError, "[Mm]ax|[Cc]ap|exceed"):
|
||||
extractor("some novel text")
|
||||
|
||||
def test_pipeline_extraction_cap_helper(self):
|
||||
_ensure_extraction_cap(0, 50, "events")
|
||||
_ensure_extraction_cap(49, 50, "events")
|
||||
with self.assertRaisesRegex(RuntimeError, "events"):
|
||||
_ensure_extraction_cap(50, 50, "events")
|
||||
|
||||
|
||||
class TestCameraTreeValidation(unittest.TestCase):
|
||||
def _camera(self, idx, parent=None):
|
||||
return Camera(idx=idx, active_shot_idxs=[idx], parent_cam_idx=parent, parent_shot_idx=idx if parent is not None else None)
|
||||
|
||||
def test_valid_tree_passes(self):
|
||||
cameras = [self._camera(0), self._camera(1, parent=0), self._camera(2, parent=1)]
|
||||
_validate_camera_tree(cameras)
|
||||
|
||||
def test_cycle_is_rejected(self):
|
||||
cameras = [self._camera(0, parent=1), self._camera(1, parent=0)]
|
||||
with self.assertRaisesRegex(ValueError, "[Cc]ycle"):
|
||||
_validate_camera_tree(cameras)
|
||||
|
||||
def test_self_parent_is_rejected(self):
|
||||
cameras = [self._camera(0, parent=0)]
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_camera_tree(cameras)
|
||||
|
||||
def test_unknown_parent_index_is_rejected(self):
|
||||
cameras = [self._camera(0), self._camera(1, parent=7)]
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_camera_tree(cameras)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Regression tests for rate-limiter lock behavior, media resource cleanup,
|
||||
packaging metadata, config templates, and test-suite isolation."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
import unittest
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import yaml
|
||||
|
||||
from utils.rate_limiter import RateLimiter
|
||||
from utils.video import concatenate_video_files
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class TestRateLimiterLocking(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_waiting_acquirer_does_not_hold_the_lock(self):
|
||||
limiter = RateLimiter(max_requests_per_minute=1)
|
||||
await limiter.acquire() # consume the only slot in the window
|
||||
waiter = asyncio.create_task(limiter.acquire())
|
||||
await asyncio.sleep(0.05) # waiter is now waiting for the window to free
|
||||
try:
|
||||
try:
|
||||
await asyncio.wait_for(limiter.lock.acquire(), timeout=0.25)
|
||||
limiter.lock.release()
|
||||
except asyncio.TimeoutError:
|
||||
self.fail("rate limiter sleeps while holding its lock, blocking every other caller")
|
||||
finally:
|
||||
waiter.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await waiter
|
||||
|
||||
async def test_min_delay_smoothing_still_applies(self):
|
||||
limiter = RateLimiter(max_requests_per_minute=600) # min delay 0.1s
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
await limiter.acquire()
|
||||
await limiter.acquire()
|
||||
self.assertGreaterEqual(loop.time() - start, 0.08)
|
||||
|
||||
|
||||
class TestVideoConcatenationCleanup(unittest.TestCase):
|
||||
def test_concatenate_closes_all_clips_even_on_failure(self):
|
||||
clips = [MagicMock(), MagicMock()]
|
||||
final = MagicMock()
|
||||
with patch("utils.video.VideoFileClip", side_effect=clips), \
|
||||
patch("utils.video.concatenate_videoclips", return_value=final):
|
||||
concatenate_video_files(["a.mp4", "b.mp4"], "out.mp4")
|
||||
final.write_videofile.assert_called_once()
|
||||
final.close.assert_called_once()
|
||||
for clip in clips:
|
||||
clip.close.assert_called_once()
|
||||
|
||||
# And when writing fails, the ffmpeg readers must still be released.
|
||||
clips = [MagicMock(), MagicMock()]
|
||||
final = MagicMock()
|
||||
final.write_videofile.side_effect = OSError("disk full")
|
||||
with patch("utils.video.VideoFileClip", side_effect=clips), \
|
||||
patch("utils.video.concatenate_videoclips", return_value=final):
|
||||
with self.assertRaises(OSError):
|
||||
concatenate_video_files(["a.mp4", "b.mp4"], "out.mp4")
|
||||
final.close.assert_called_once()
|
||||
for clip in clips:
|
||||
clip.close.assert_called_once()
|
||||
|
||||
|
||||
class TestPackagingMetadata(unittest.TestCase):
|
||||
def test_pyproject_is_consistent(self):
|
||||
with open(REPO_ROOT / "pyproject.toml", "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
readme = data["project"]["readme"]
|
||||
self.assertTrue((REPO_ROOT / readme).exists(), f"readme points at missing file: {readme}")
|
||||
self.assertNotIn("index", data, "top-level [[index]] is not valid pyproject TOML and is silently ignored")
|
||||
dev_group = data.get("dependency-groups", {}).get("dev", [])
|
||||
self.assertTrue(any("pytest" in str(item) for item in dev_group), "pytest must be a declared dev dependency so the suite runs from the venv")
|
||||
|
||||
|
||||
class TestProviderConfigTemplates(unittest.TestCase):
|
||||
def test_minimax_templates_do_not_ship_truthy_placeholders(self):
|
||||
for name in ("idea2video_minimax.yaml", "script2video_minimax.yaml"):
|
||||
with open(REPO_ROOT / "configs" / name, encoding="utf-8") as f:
|
||||
config = yaml.safe_load(f)
|
||||
chat_key = config["chat_model"]["init_args"].get("api_key")
|
||||
self.assertFalse(chat_key, f"{name}: a truthy api_key placeholder defeats the MINIMAX_API_KEY env fallback")
|
||||
for section in ("image_generator", "video_generator"):
|
||||
key = config[section]["init_args"].get("api_key")
|
||||
self.assertNotIn("<", str(key or ""), f"{name}: {section} ships an angle-bracket placeholder that would be sent as a bearer token")
|
||||
|
||||
|
||||
class TestSuiteIsolation(unittest.TestCase):
|
||||
def test_importing_minimax_tests_does_not_stub_global_modules(self):
|
||||
code = (
|
||||
"import sys; import tests.test_minimax_integration; "
|
||||
"mod = sys.modules.get('cv2'); "
|
||||
"from unittest.mock import MagicMock; "
|
||||
"exit(1 if isinstance(mod, MagicMock) else 0)"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
result.returncode, 0,
|
||||
"importing tests.test_minimax_integration replaces sys.modules entries at import time, "
|
||||
"making every later-collected test module see MagicMock stubs instead of real libraries",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,29 @@
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from tools.image_orientation import ensure_not_portrait, landscape_guard_requested
|
||||
|
||||
|
||||
class ImageOrientationTests(unittest.TestCase):
|
||||
def test_landscape_guard_requested_defaults_to_all_images(self):
|
||||
self.assertTrue(landscape_guard_requested(size="1600x900"))
|
||||
self.assertTrue(landscape_guard_requested(size="512x512"))
|
||||
self.assertTrue(landscape_guard_requested(size=None))
|
||||
self.assertTrue(landscape_guard_requested(aspect_ratio="16:9", enforce_landscape=False))
|
||||
self.assertFalse(landscape_guard_requested(allow_portrait=True))
|
||||
|
||||
def test_portrait_detection_allows_slightly_tall_images(self):
|
||||
ensure_not_portrait(Image.new("RGB", (1000, 1040)))
|
||||
with self.assertRaises(ValueError):
|
||||
ensure_not_portrait(Image.new("RGB", (1000, 1100)))
|
||||
|
||||
def test_portrait_tolerance_env_override(self):
|
||||
with patch.dict(os.environ, {"VIMAX_IMAGE_PORTRAIT_RETRY_TOLERANCE": "1.20"}):
|
||||
ensure_not_portrait(Image.new("RGB", (1000, 1100)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,29 @@
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from tools.image_response import image_from_response_part
|
||||
|
||||
|
||||
class ImageResponseTests(unittest.TestCase):
|
||||
def test_extracts_image_when_part_has_no_as_image_method(self):
|
||||
expected = Image.new("RGB", (1, 1), (255, 0, 0))
|
||||
with patch("tools.image_response.Image.open", return_value=expected):
|
||||
part = SimpleNamespace(inline_data=SimpleNamespace(data=b"fake-png"))
|
||||
image = image_from_response_part(part)
|
||||
self.assertEqual(image.size, (1, 1))
|
||||
|
||||
def test_extracts_base64_data_url(self):
|
||||
expected = Image.new("RGB", (1, 1), (255, 0, 0))
|
||||
payload = "data:image/png;base64," + base64.b64encode(b"fake-png").decode("ascii")
|
||||
with patch("tools.image_response.Image.open", return_value=expected):
|
||||
part = {"inline_data": {"data": payload}}
|
||||
image = image_from_response_part(part)
|
||||
self.assertEqual(image.size, (1, 1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,162 @@
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import main_agent
|
||||
|
||||
|
||||
class FakeSessionIndex:
|
||||
def __init__(self, fail_session=False):
|
||||
self.fail_session = fail_session
|
||||
self.activated = ""
|
||||
self.created = 0
|
||||
|
||||
def set_active(self, session_id):
|
||||
if self.fail_session:
|
||||
raise KeyError(session_id)
|
||||
self.activated = session_id
|
||||
|
||||
def create(self):
|
||||
self.created += 1
|
||||
self.activated = f"new-{self.created}"
|
||||
return {"session_id": self.activated}
|
||||
|
||||
def snapshot(self):
|
||||
return {"active_session_id": self.activated, "session": {"session_id": self.activated, "stage": "created"}}
|
||||
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, fail_session=False):
|
||||
self.session_index = FakeSessionIndex(fail_session=fail_session)
|
||||
self.inputs = []
|
||||
|
||||
async def compact_history(self, reason="manual"):
|
||||
return "Compacted context 100 -> 50 (fallback-local)."
|
||||
|
||||
async def stream_events(self, user_input):
|
||||
self.inputs.append(user_input)
|
||||
turn_id = "turn-test"
|
||||
yield {"type": "turn", "turn_id": turn_id, "turn": {"id": turn_id}}
|
||||
yield {"type": "status", "turn_id": turn_id, "phase": "sampling_assistant", "message": "Sampling assistant"}
|
||||
yield {"type": "tool_progress", "turn_id": turn_id, "tool": {"name": "fake_tool"}, "progress": {"stage": "running", "message": "working"}}
|
||||
yield {"type": "terminal", "turn_id": turn_id, "stream": "stdout", "line": "pipeline output"}
|
||||
yield {"type": "token", "turn_id": turn_id, "delta": "done"}
|
||||
yield {"type": "done", "turn_id": turn_id, "assistant": "done", "tool_results": []}
|
||||
yield {"type": "session", "turn_id": turn_id, "session": {"session": {"session_id": "s1", "stage": "narrative_planned"}}}
|
||||
|
||||
|
||||
class MainAgentCliTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def run_cli(self, argv, runtime=None, stdin_text="", session_index=None, load_runtime_side_effect=None):
|
||||
runtime = runtime or FakeRuntime()
|
||||
session_index = session_index or runtime.session_index
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
stdin = io.StringIO(stdin_text)
|
||||
load_runtime_patch = patch.object(main_agent, "load_runtime", return_value=runtime)
|
||||
if load_runtime_side_effect is not None:
|
||||
load_runtime_patch = patch.object(main_agent, "load_runtime", side_effect=load_runtime_side_effect)
|
||||
with load_runtime_patch, \
|
||||
patch.object(main_agent, "load_session_index", return_value=session_index), \
|
||||
patch.object(sys, "stdin", stdin), \
|
||||
contextlib.redirect_stdout(stdout), \
|
||||
contextlib.redirect_stderr(stderr):
|
||||
code = await main_agent.amain(argv)
|
||||
return code, stdout.getvalue(), stderr.getvalue(), runtime
|
||||
|
||||
def test_help_parser_contains_once(self):
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout), self.assertRaises(SystemExit) as ctx:
|
||||
main_agent.parse_args(["--help"])
|
||||
self.assertEqual(ctx.exception.code, 0)
|
||||
self.assertIn("--once", stdout.getvalue())
|
||||
|
||||
async def test_jsonl_once_outputs_valid_events_with_turn_id(self):
|
||||
code, stdout, stderr, runtime = await self.run_cli(["--jsonl", "--once", "hello"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(stderr, "")
|
||||
self.assertEqual(runtime.inputs, ["hello"])
|
||||
lines = [json.loads(line) for line in stdout.splitlines()]
|
||||
self.assertTrue(lines)
|
||||
self.assertEqual({event["turn_id"] for event in lines}, {"turn-test"})
|
||||
self.assertEqual(lines[0]["type"], "turn")
|
||||
self.assertIn("terminal", [event["type"] for event in lines])
|
||||
self.assertNotIn("›", stdout)
|
||||
|
||||
async def test_stdin_non_tty_is_single_prompt(self):
|
||||
code, stdout, stderr, runtime = await self.run_cli(["--jsonl"], stdin_text="from stdin\n")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(runtime.inputs, ["from stdin"])
|
||||
self.assertTrue(stdout.strip())
|
||||
|
||||
|
||||
async def test_stdin_repl_reads_each_line_as_a_turn(self):
|
||||
code, stdout, stderr, runtime = await self.run_cli(["--jsonl", "--stdin-repl"], stdin_text="first\nsecond\n")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(stderr, "")
|
||||
self.assertEqual(runtime.inputs, ["first", "second"])
|
||||
events = [json.loads(line) for line in stdout.splitlines()]
|
||||
self.assertEqual([event["type"] for event in events if event["type"] == "done"], ["done", "done"])
|
||||
|
||||
async def test_session_error_is_clear_before_runtime_load(self):
|
||||
runtime = FakeRuntime()
|
||||
failing_index = FakeSessionIndex(fail_session=True)
|
||||
code, stdout, stderr, runtime = await self.run_cli(
|
||||
["--session", "missing", "--once", "hello"],
|
||||
runtime=runtime,
|
||||
session_index=failing_index,
|
||||
load_runtime_side_effect=AssertionError("runtime should not load"),
|
||||
)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertEqual(stdout, "")
|
||||
self.assertIn("unknown session id", stderr)
|
||||
self.assertEqual(runtime.inputs, [])
|
||||
|
||||
|
||||
async def test_new_session_is_created_before_runtime_load(self):
|
||||
runtime = FakeRuntime()
|
||||
session_index = FakeSessionIndex()
|
||||
code, stdout, stderr, runtime = await self.run_cli(
|
||||
["--new-session", "--jsonl", "--once", "hello"],
|
||||
runtime=runtime,
|
||||
session_index=session_index,
|
||||
)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(stderr, "")
|
||||
self.assertEqual(session_index.created, 1)
|
||||
self.assertEqual(session_index.activated, "new-1")
|
||||
self.assertEqual(runtime.inputs, ["hello"])
|
||||
|
||||
async def test_new_session_and_session_are_mutually_exclusive(self):
|
||||
runtime = FakeRuntime()
|
||||
code, stdout, stderr, runtime = await self.run_cli(
|
||||
["--new-session", "--session", "s1", "--once", "hello"],
|
||||
runtime=runtime,
|
||||
load_runtime_side_effect=AssertionError("runtime should not load"),
|
||||
)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertEqual(stdout, "")
|
||||
self.assertIn("cannot be used together", stderr)
|
||||
self.assertEqual(runtime.inputs, [])
|
||||
|
||||
|
||||
async def test_compact_command_outputs_jsonl_without_llm_turn(self):
|
||||
code, stdout, stderr, runtime = await self.run_cli(["--jsonl", "--once", "/compact"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(stderr, "")
|
||||
self.assertEqual(runtime.inputs, [])
|
||||
events = [json.loads(line) for line in stdout.splitlines()]
|
||||
self.assertEqual([event["type"] for event in events], ["turn", "status", "token", "done", "session"])
|
||||
turn_ids = {event["turn_id"] for event in events}
|
||||
self.assertEqual(len(turn_ids), 1)
|
||||
self.assertTrue(next(iter(turn_ids)).startswith("turn-"))
|
||||
self.assertIn("Compacted context", events[2]["delta"])
|
||||
|
||||
async def test_plain_mode_prints_progress_terminal_and_session(self):
|
||||
code, stdout, stderr, _ = await self.run_cli(["--once", "hello"])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("tool: fake_tool running: working", stdout)
|
||||
self.assertIn("terminal[stdout]: pipeline output", stdout)
|
||||
self.assertIn("session: s1 narrative_planned", stdout)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Integration tests for MiniMax provider support.
|
||||
|
||||
These tests verify provider preset resolution and default pipeline config
|
||||
loading. They mock the LangChain factory so no real API calls are made.
|
||||
|
||||
Heavy multimedia dependencies (moviepy, scenedetect, cv2, google-genai,
|
||||
etc.) are stubbed in setUpModule and restored in tearDownModule. Stubbing
|
||||
at import time leaked the MagicMocks into sys.modules for every test module
|
||||
collected after this one, making suite results import-order dependent.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
_STUB_MODULES = [
|
||||
"moviepy", "cv2", "scenedetect", "scenedetect.detectors",
|
||||
"PIL", "PIL.Image",
|
||||
"faiss",
|
||||
"google", "google.genai", "google.genai.types", "google.genai.errors",
|
||||
"langchain_community", "langchain_community.vectorstores",
|
||||
"langchain_community.vectorstores.FAISS",
|
||||
]
|
||||
_saved = {}
|
||||
_modules_before_stubs = set()
|
||||
|
||||
|
||||
def setUpModule():
|
||||
_modules_before_stubs.update(sys.modules)
|
||||
for _mod in _STUB_MODULES:
|
||||
_saved[_mod] = sys.modules.get(_mod)
|
||||
mock = MagicMock()
|
||||
# Give stub a __spec__ so importlib.util.find_spec() works
|
||||
mock.__spec__ = importlib.machinery.ModuleSpec(_mod, None)
|
||||
mock.__path__ = []
|
||||
sys.modules[_mod] = mock
|
||||
|
||||
|
||||
def tearDownModule():
|
||||
# Drop project modules that were first imported while the stubs were
|
||||
# active, so later test modules import them fresh against real libraries.
|
||||
for name in list(sys.modules):
|
||||
if name in _modules_before_stubs:
|
||||
continue
|
||||
if name.split(".")[0] in {"pipelines", "agents", "tools", "interfaces"}:
|
||||
del sys.modules[name]
|
||||
for _mod, original in _saved.items():
|
||||
if original is None:
|
||||
sys.modules.pop(_mod, None)
|
||||
else:
|
||||
sys.modules[_mod] = original
|
||||
_saved.clear()
|
||||
_modules_before_stubs.clear()
|
||||
|
||||
|
||||
from utils.provider_presets import resolve_chat_model_config
|
||||
|
||||
|
||||
class TestPipelineConfigResolution(unittest.TestCase):
|
||||
"""Integration: config dict -> resolve -> init_chat_model kwargs."""
|
||||
|
||||
def _make_minimax_config(self, **overrides):
|
||||
base = {
|
||||
"model": "MiniMax-M3",
|
||||
"model_provider": "minimax",
|
||||
"api_key": "test-key",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
def test_full_minimax_config_resolution(self):
|
||||
config = self._make_minimax_config()
|
||||
resolved = resolve_chat_model_config(config)
|
||||
self.assertEqual(resolved["model_provider"], "openai")
|
||||
self.assertEqual(resolved["base_url"], "https://api.minimax.io/v1")
|
||||
self.assertEqual(resolved["model"], "MiniMax-M3")
|
||||
self.assertEqual(resolved["api_key"], "test-key")
|
||||
|
||||
def test_minimax_highspeed_model(self):
|
||||
config = self._make_minimax_config(model="MiniMax-M2.7-highspeed")
|
||||
resolved = resolve_chat_model_config(config)
|
||||
self.assertEqual(resolved["model"], "MiniMax-M2.7-highspeed")
|
||||
self.assertEqual(resolved["model_provider"], "openai")
|
||||
|
||||
def test_minimax_m27_model(self):
|
||||
config = self._make_minimax_config(model="MiniMax-M2.7")
|
||||
resolved = resolve_chat_model_config(config)
|
||||
self.assertEqual(resolved["model"], "MiniMax-M2.7")
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "env-api-key"})
|
||||
def test_env_key_fallback_in_config(self):
|
||||
config = {
|
||||
"model": "MiniMax-M3",
|
||||
"model_provider": "minimax",
|
||||
}
|
||||
resolved = resolve_chat_model_config(config)
|
||||
self.assertEqual(resolved["api_key"], "env-api-key")
|
||||
|
||||
def test_openrouter_config_unchanged(self):
|
||||
"""Existing OpenRouter configs must not be affected."""
|
||||
config = {
|
||||
"model": "google/gemini-2.5-flash-lite-preview-09-2025",
|
||||
"model_provider": "openai",
|
||||
"api_key": "or-key",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
}
|
||||
resolved = resolve_chat_model_config(config)
|
||||
self.assertEqual(resolved["model_provider"], "openai")
|
||||
self.assertEqual(resolved["base_url"], "https://openrouter.ai/api/v1")
|
||||
self.assertEqual(resolved["model"], "google/gemini-2.5-flash-lite-preview-09-2025")
|
||||
|
||||
def test_init_chat_model_receives_openai_provider(self):
|
||||
"""Verify that resolved kwargs have model_provider='openai'."""
|
||||
config = self._make_minimax_config()
|
||||
resolved = resolve_chat_model_config(config)
|
||||
self.assertEqual(resolved["model_provider"], "openai")
|
||||
self.assertEqual(resolved["base_url"], "https://api.minimax.io/v1")
|
||||
self.assertEqual(resolved["model"], "MiniMax-M3")
|
||||
|
||||
def test_temperature_clamping_in_pipeline_flow(self):
|
||||
config = self._make_minimax_config(temperature=2.0)
|
||||
resolved = resolve_chat_model_config(config)
|
||||
self.assertEqual(resolved["temperature"], 1.0)
|
||||
|
||||
def test_extra_kwargs_preserved(self):
|
||||
config = self._make_minimax_config(max_tokens=4096, top_p=0.9)
|
||||
resolved = resolve_chat_model_config(config)
|
||||
self.assertEqual(resolved["max_tokens"], 4096)
|
||||
self.assertEqual(resolved["top_p"], 0.9)
|
||||
|
||||
|
||||
class TestPipelineInitFromConfig(unittest.TestCase):
|
||||
"""Integration: full pipeline init_from_config with provider configs."""
|
||||
|
||||
@patch("pipelines.idea2video_pipeline.init_chat_model")
|
||||
@patch("pipelines.idea2video_pipeline.RenderBackend.from_config")
|
||||
def test_idea2video_pipeline_minimax_config(self, mock_backend, mock_init):
|
||||
mock_model = MagicMock()
|
||||
mock_init.return_value = mock_model
|
||||
mock_backend.return_value = MagicMock(image_generator=MagicMock(), video_generator=MagicMock())
|
||||
|
||||
from pipelines.idea2video_pipeline import Idea2VideoPipeline
|
||||
pipeline = Idea2VideoPipeline.init_from_config("configs/idea2video_minimax.yaml")
|
||||
|
||||
mock_init.assert_called_once()
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
self.assertEqual(call_kwargs["model_provider"], "openai")
|
||||
self.assertEqual(call_kwargs["base_url"], "https://api.minimax.io/v1")
|
||||
self.assertEqual(call_kwargs["model"], "MiniMax-M3")
|
||||
|
||||
@patch("pipelines.script2video_pipeline.init_chat_model")
|
||||
@patch("pipelines.script2video_pipeline.RenderBackend.from_config")
|
||||
def test_script2video_pipeline_minimax_config(self, mock_backend, mock_init):
|
||||
mock_model = MagicMock()
|
||||
mock_init.return_value = mock_model
|
||||
mock_backend.return_value = MagicMock(image_generator=MagicMock(), video_generator=MagicMock())
|
||||
|
||||
from pipelines.script2video_pipeline import Script2VideoPipeline
|
||||
pipeline = Script2VideoPipeline.init_from_config("configs/script2video_minimax.yaml")
|
||||
|
||||
mock_init.assert_called_once()
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
self.assertEqual(call_kwargs["model_provider"], "openai")
|
||||
self.assertEqual(call_kwargs["base_url"], "https://api.minimax.io/v1")
|
||||
self.assertEqual(call_kwargs["model"], "MiniMax-M3")
|
||||
"""Integration: full pipeline init_from_config with default configs."""
|
||||
|
||||
@patch("pipelines.idea2video_pipeline.init_chat_model")
|
||||
@patch("pipelines.idea2video_pipeline.RenderBackend.from_config")
|
||||
def test_existing_openrouter_config_still_works(self, mock_backend, mock_init):
|
||||
mock_model = MagicMock()
|
||||
mock_init.return_value = mock_model
|
||||
mock_backend.return_value = MagicMock(image_generator=MagicMock(), video_generator=MagicMock())
|
||||
|
||||
from pipelines.idea2video_pipeline import Idea2VideoPipeline
|
||||
pipeline = Idea2VideoPipeline.init_from_config("configs/idea2video.yaml")
|
||||
|
||||
mock_init.assert_called_once()
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
self.assertEqual(call_kwargs["model_provider"], "openai")
|
||||
self.assertEqual(call_kwargs["base_url"], "https://openrouter.ai/api/v1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for Novel2MoviePipeline initialization."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
class TestNovel2MoviePipelineInit(unittest.TestCase):
|
||||
def _class_node(self):
|
||||
source = Path("pipelines/novel2movie_pipeline.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
return next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "Novel2MoviePipeline"
|
||||
)
|
||||
|
||||
def test_initializes_runtime_dependencies(self):
|
||||
class_node = self._class_node()
|
||||
init_node = next(
|
||||
node
|
||||
for node in class_node.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "__init__"
|
||||
)
|
||||
assigned = {
|
||||
target.attr
|
||||
for node in ast.walk(init_node)
|
||||
if isinstance(node, ast.Assign)
|
||||
for target in node.targets
|
||||
if isinstance(target, ast.Attribute)
|
||||
and isinstance(target.value, ast.Name)
|
||||
and target.value.id == "self"
|
||||
}
|
||||
|
||||
self.assertTrue(
|
||||
{
|
||||
"working_dir",
|
||||
"novel_compressor",
|
||||
"event_extractor",
|
||||
"embeddings",
|
||||
"rerank_model",
|
||||
"scene_extractor",
|
||||
"global_information_planner",
|
||||
"image_generator",
|
||||
"rewriter",
|
||||
"script2video_pipeline",
|
||||
}.issubset(assigned)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,277 @@
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from interfaces import CharacterInEvent, CharacterInNovel, CharacterInScene, Event, Scene
|
||||
from interfaces.environment import EnvironmentInScene
|
||||
from agent_runtime.session_index import SessionIndex
|
||||
from agent_runtime.tools import ToolRuntimeContext
|
||||
from agent_runtime.vimax_adapters import ViMaxAdapters, _run_planning_step
|
||||
from agents.global_information_planner import GlobalInformationPlanner, MergeCharactersAcrossScenesInEventResponse
|
||||
from pipelines.novel2movie_pipeline import Novel2MoviePipeline
|
||||
|
||||
|
||||
class FakeCompressor:
|
||||
def split(self, novel_text):
|
||||
return [novel_text]
|
||||
|
||||
async def compress_single_novel_chunk(self, semaphore, index, novel_chunk):
|
||||
return index, f"compressed {novel_chunk}"
|
||||
|
||||
def aggregate(self, chunks):
|
||||
return "\n".join(chunks)
|
||||
|
||||
|
||||
class FakeEventExtractor:
|
||||
def extract_next_event(self, novel_text, extracted_events):
|
||||
return Event(index=len(extracted_events), is_last=True, description="Hero leaves home", process_chain=["Hero opens the door"])
|
||||
|
||||
|
||||
class FakeKnowledgeBase:
|
||||
def similarity_search(self, process, k=10):
|
||||
return [SimpleNamespace(page_content="Hero opens the old wooden door.")]
|
||||
|
||||
|
||||
class FakeReranker:
|
||||
async def __call__(self, documents, query, top_n):
|
||||
return [(documents[0], 0.95)]
|
||||
|
||||
|
||||
class FakeSceneExtractor:
|
||||
async def get_next_scene(self, relevant_chunks, event, previous_scenes):
|
||||
return Scene(
|
||||
idx=len(previous_scenes),
|
||||
is_last=True,
|
||||
environment=EnvironmentInScene(slugline="INT. HOUSE - DAY", description="A quiet room."),
|
||||
characters=[CharacterInScene(idx=0, identifier_in_scene="Hero", is_visible=True, static_features="adult", dynamic_features="coat")],
|
||||
script="<Hero> opens the door.",
|
||||
)
|
||||
|
||||
|
||||
class FakeGlobalPlanner:
|
||||
async def merge_characters_across_scenes_in_event(self, event_idx, scenes):
|
||||
return [CharacterInEvent(index=0, identifier_in_event="Hero", active_scenes={0: "Hero"}, static_features="adult")]
|
||||
|
||||
def merge_characters_to_existing_characters_in_novel(self, event_idx, existing_characters_in_novel, characters_in_event):
|
||||
return [CharacterInNovel(index=0, identifier_in_novel="Hero", active_events={event_idx: "Hero"}, static_features="adult")]
|
||||
|
||||
|
||||
class FakeNovelPipeline:
|
||||
def __init__(self, working_dir: Path):
|
||||
self.working_dir = working_dir
|
||||
|
||||
async def plan_text_artifacts(self, novel_text, user_requirement="", style="", progress=None, quiet=False):
|
||||
if progress:
|
||||
for stage in ["save_novel", "compress_novel", "extract_events", "retrieve_chunks", "extract_scenes", "merge_characters", "completed"]:
|
||||
progress(stage, stage, {})
|
||||
novel = self.working_dir / "novel"
|
||||
novel.mkdir(parents=True, exist_ok=True)
|
||||
(novel / "novel.txt").write_text(novel_text, encoding="utf-8")
|
||||
(novel / "novel_compressed.txt").write_text("compressed", encoding="utf-8")
|
||||
events = self.working_dir / "events"
|
||||
events.mkdir(parents=True, exist_ok=True)
|
||||
(events / "event_0.json").write_text(json.dumps(Event(index=0, is_last=True, description="d", process_chain=["p"]).model_dump()), encoding="utf-8")
|
||||
chunks = self.working_dir / "relevant_chunks" / "event_0"
|
||||
chunks.mkdir(parents=True, exist_ok=True)
|
||||
(chunks / "chunk_0-score_0.95.txt").write_text("chunk", encoding="utf-8")
|
||||
scenes = self.working_dir / "scenes" / "event_0"
|
||||
scenes.mkdir(parents=True, exist_ok=True)
|
||||
scene = Scene(idx=0, is_last=True, environment=EnvironmentInScene(slugline="INT. ROOM - DAY", description="room"), characters=[CharacterInScene(idx=0, identifier_in_scene="Hero", is_visible=True, static_features="adult", dynamic_features="coat")], script="<Hero> walks.")
|
||||
(scenes / "scene_0.json").write_text(json.dumps(scene.model_dump()), encoding="utf-8")
|
||||
event_level = self.working_dir / "global_information" / "characters" / "event_level"
|
||||
event_level.mkdir(parents=True, exist_ok=True)
|
||||
event_char = CharacterInEvent(index=0, identifier_in_event="Hero", active_scenes={0: "Hero"}, static_features="adult")
|
||||
(event_level / "event_0_characters.json").write_text(json.dumps([event_char.model_dump()]), encoding="utf-8")
|
||||
novel_level = self.working_dir / "global_information" / "characters" / "novel_level"
|
||||
novel_level.mkdir(parents=True, exist_ok=True)
|
||||
novel_char = CharacterInNovel(index=0, identifier_in_novel="Hero", active_events={0: "Hero"}, static_features="adult")
|
||||
(novel_level / "novel_characters_after_event_0.json").write_text(json.dumps([novel_char.model_dump()]), encoding="utf-8")
|
||||
return {}
|
||||
|
||||
|
||||
class FakeMergeChain:
|
||||
async def ainvoke(self, messages):
|
||||
return MergeCharactersAcrossScenesInEventResponse(
|
||||
characters=[CharacterInEvent(index=0, identifier_in_event="Hero", active_scenes={0: "Hero"}, static_features="adult")]
|
||||
)
|
||||
|
||||
|
||||
class FakeMergeChatModel:
|
||||
def __or__(self, parser):
|
||||
return FakeMergeChain()
|
||||
|
||||
|
||||
class GlobalInformationPlannerCompatibilityTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_merge_event_characters_uses_scene_character_idx(self):
|
||||
planner = GlobalInformationPlanner.__new__(GlobalInformationPlanner)
|
||||
planner.chat_model = FakeMergeChatModel()
|
||||
scene = Scene(
|
||||
idx=0,
|
||||
is_last=True,
|
||||
environment=EnvironmentInScene(slugline="INT. ROOM - DAY", description="room"),
|
||||
characters=[CharacterInScene(idx=0, identifier_in_scene="Hero", is_visible=True, static_features="adult", dynamic_features="coat")],
|
||||
script="<Hero> walks.",
|
||||
)
|
||||
characters = await planner.merge_characters_across_scenes_in_event(event_idx=0, scenes=[scene])
|
||||
self.assertEqual(characters[0].identifier_in_event, "Hero")
|
||||
|
||||
|
||||
class PlanningStepOutputSuppressionTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_run_planning_step_suppresses_stdout_stderr_and_warnings(self):
|
||||
async def noisy_step():
|
||||
print("NOISE_STDOUT")
|
||||
logging.warning("NOISE_WARNING")
|
||||
return "ok"
|
||||
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||
result = await _run_planning_step("message", "stage", noisy_step(), runtime=None)
|
||||
self.assertEqual(result, "ok")
|
||||
self.assertNotIn("NOISE_STDOUT", stdout.getvalue())
|
||||
self.assertNotIn("NOISE_WARNING", stderr.getvalue())
|
||||
|
||||
|
||||
class Novel2MoviePlanningTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_plan_text_artifacts_writes_structured_text_and_progress(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pipeline = Novel2MoviePipeline(
|
||||
novel_compressor=FakeCompressor(),
|
||||
event_extractor=FakeEventExtractor(),
|
||||
embeddings=SimpleNamespace(model="fake-embedding"),
|
||||
rerank_model=FakeReranker(),
|
||||
scene_extractor=FakeSceneExtractor(),
|
||||
global_information_planner=FakeGlobalPlanner(),
|
||||
image_generator=object(),
|
||||
rewriter=object(),
|
||||
script2video_pipeline=object(),
|
||||
working_dir=tmp,
|
||||
)
|
||||
events = []
|
||||
with patch("pipelines.novel2movie_pipeline.CacheBackedEmbeddings.from_bytes_store", return_value=object()), \
|
||||
patch("pipelines.novel2movie_pipeline.FAISS.from_texts", return_value=FakeKnowledgeBase()):
|
||||
result = await pipeline.plan_text_artifacts("Hero opens a door.", progress=lambda stage, message, metadata=None: events.append(stage), quiet=True)
|
||||
self.assertEqual(events, ["save_novel", "compress_novel", "extract_events", "retrieve_chunks", "extract_scenes", "merge_characters", "completed"])
|
||||
root = Path(tmp)
|
||||
self.assertTrue((root / "novel" / "novel_compressed.txt").exists())
|
||||
self.assertTrue((root / "events" / "event_0.json").exists())
|
||||
self.assertTrue((root / "relevant_chunks" / "event_0" / "chunk_0-score_0.95.txt").exists())
|
||||
self.assertTrue((root / "scenes" / "event_0" / "scene_0.json").exists())
|
||||
self.assertTrue((root / "global_information" / "characters" / "novel_level" / "novel_characters_after_event_0.json").exists())
|
||||
self.assertFalse((root / "character_portraits").exists())
|
||||
self.assertFalse((root / "videos").exists())
|
||||
self.assertEqual(len(result["events"]), 1)
|
||||
|
||||
|
||||
class FakeNovelRenderPipeline:
|
||||
def __init__(self, working_dir: Path):
|
||||
self.working_dir = Path(working_dir)
|
||||
|
||||
async def render_video_artifacts(self, style, user_requirement="", progress=None, quiet=False):
|
||||
if progress:
|
||||
progress("novel_portraits_start", "portraits", {})
|
||||
progress("novel_scene_render_start", "scene", {"event_idx": 0, "scene_idx": 0})
|
||||
progress("novel_render_completed", "done", {"scene_count": 1})
|
||||
scene_dir = self.working_dir / "videos" / "event_0" / "scene_0"
|
||||
scene_dir.mkdir(parents=True, exist_ok=True)
|
||||
(scene_dir / "final_video.mp4").write_text("video", encoding="utf-8")
|
||||
return {
|
||||
"character_portraits_dir": str(self.working_dir / "character_portraits"),
|
||||
"scene_videos_dir": str(self.working_dir / "videos"),
|
||||
"scene_video_dirs": [str(scene_dir)],
|
||||
"scene_count": 1,
|
||||
}
|
||||
|
||||
|
||||
def write_minimal_novel_artifacts(root: Path):
|
||||
novel = root / "novel2video"
|
||||
(novel / "novel").mkdir(parents=True, exist_ok=True)
|
||||
(novel / "novel" / "novel.txt").write_text("novel", encoding="utf-8")
|
||||
(novel / "novel" / "novel_compressed.txt").write_text("compressed", encoding="utf-8")
|
||||
events = novel / "events"
|
||||
events.mkdir(parents=True, exist_ok=True)
|
||||
(events / "event_0.json").write_text(json.dumps(Event(index=0, is_last=True, description="d", process_chain=["p"]).model_dump()), encoding="utf-8")
|
||||
chunks = novel / "relevant_chunks" / "event_0"
|
||||
chunks.mkdir(parents=True, exist_ok=True)
|
||||
(chunks / "chunk_0-score_0.95.txt").write_text("chunk", encoding="utf-8")
|
||||
scenes = novel / "scenes" / "event_0"
|
||||
scenes.mkdir(parents=True, exist_ok=True)
|
||||
scene = Scene(idx=0, is_last=True, environment=EnvironmentInScene(slugline="INT. ROOM - DAY", description="room"), characters=[CharacterInScene(idx=0, identifier_in_scene="Hero", is_visible=True, static_features="adult", dynamic_features="coat")], script="<Hero> walks.")
|
||||
(scenes / "scene_0.json").write_text(json.dumps(scene.model_dump()), encoding="utf-8")
|
||||
event_level = novel / "global_information" / "characters" / "event_level"
|
||||
event_level.mkdir(parents=True, exist_ok=True)
|
||||
event_char = CharacterInEvent(index=0, identifier_in_event="Hero", active_scenes={0: "Hero"}, static_features="adult")
|
||||
(event_level / "event_0_characters.json").write_text(json.dumps([event_char.model_dump()]), encoding="utf-8")
|
||||
novel_level = novel / "global_information" / "characters" / "novel_level"
|
||||
novel_level.mkdir(parents=True, exist_ok=True)
|
||||
novel_char = CharacterInNovel(index=0, identifier_in_novel="Hero", active_events={0: "Hero"}, static_features="adult")
|
||||
(novel_level / "novel_characters_after_event_0.json").write_text(json.dumps([novel_char.model_dump()]), encoding="utf-8")
|
||||
|
||||
|
||||
class NovelAdapterTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_missing_rag_config_returns_tool_error_and_marks_session_error(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
with patch.dict("os.environ", {"VIMAX_LLM_API_KEY": "llm-key", "VIMAX_LLM_BASE_URL": "https://llm.test/v1"}, clear=True), \
|
||||
patch("agent_runtime.vimax_adapters._build_embedding_model", side_effect=RuntimeError("embedding config missing")):
|
||||
result = await adapter.vimax_novel_planning({"novel_text": "Hero opens a door."})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn("embedding", result.content.lower())
|
||||
self.assertEqual(index.active()["stage"], "error")
|
||||
|
||||
async def test_success_writes_novel2video_artifacts_and_marks_novel_planned(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
progress_events = []
|
||||
runtime = ToolRuntimeContext("vimax_novel_planning", "vimax_novel_planning", turn_id="turn-test", progress_callback=progress_events.append)
|
||||
with patch("agent_runtime.vimax_adapters._build_novel_pipeline", side_effect=lambda working_dir: FakeNovelPipeline(Path(working_dir))):
|
||||
result = await adapter.vimax_novel_planning({"novel_text": "Hero opens a door.", "style": "noir"}, runtime)
|
||||
self.assertTrue(result.ok)
|
||||
payload = json.loads(result.content)
|
||||
root = Path(tmp) / payload["working_dir"]
|
||||
self.assertTrue((root / "novel2video" / "novel" / "novel_compressed.txt").exists())
|
||||
self.assertTrue((root / "novel2video" / "events" / "event_0.json").exists())
|
||||
self.assertIn("novel2video/scenes/event_*/scene_*.json", payload["generated"])
|
||||
self.assertFalse(payload["ready_for_scene_render"])
|
||||
self.assertEqual(index.active()["stage"], "novel_planned")
|
||||
stages = [event["progress"]["stage"] for event in progress_events if event.get("type") == "tool_progress"]
|
||||
self.assertIn("novel_plan_text_artifacts", stages)
|
||||
self.assertIn("merge_characters", stages)
|
||||
|
||||
async def test_render_video_routes_novel2video_with_mock_pipeline(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="novel", user_requirement="scene render", style="noir")
|
||||
root = Path(tmp) / record["working_dir"]
|
||||
write_minimal_novel_artifacts(root)
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
progress_events = []
|
||||
runtime = ToolRuntimeContext("vimax_render_video", "vimax_render_video", turn_id="turn-test", progress_callback=progress_events.append)
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters._build_image_generator", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters._build_video_generator", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters._build_novel_render_pipeline", side_effect=lambda working_dir, chat_model, image_generator, video_generator: FakeNovelRenderPipeline(Path(working_dir))):
|
||||
result = await adapter.vimax_render_video({}, runtime)
|
||||
self.assertTrue(result.ok)
|
||||
payload = json.loads(result.content)
|
||||
self.assertEqual(payload["render_mode"], "novel2video")
|
||||
self.assertTrue(payload["scene_render_completed"])
|
||||
self.assertIsNone(payload["final_video_path"])
|
||||
self.assertEqual(payload["scene_count"], 1)
|
||||
self.assertEqual(index.get(record["session_id"])["stage"], "novel_scene_rendered")
|
||||
self.assertTrue((root / "novel2video" / "videos" / "event_0" / "scene_0" / "final_video.mp4").exists())
|
||||
stages = [event["progress"]["stage"] for event in progress_events if event.get("type") == "tool_progress"]
|
||||
self.assertIn("novel_scene_render_start", stages)
|
||||
self.assertIn("novel_render_completed", stages)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,136 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.video_generator_omni_yunwu_api import (
|
||||
VideoGeneratorOmniYunwuAPI,
|
||||
VideoGeneratorOminiYunwuAPI,
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
self.calls = []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
self.calls.append(("get", url, kwargs))
|
||||
return _FakeResponse(self.payload)
|
||||
|
||||
|
||||
class TestVideoGeneratorOmniYunwuAPI(unittest.IsolatedAsyncioTestCase):
|
||||
def test_text_to_video_payload(self):
|
||||
generator = VideoGeneratorOmniYunwuAPI(api_key="test-key")
|
||||
|
||||
payload = generator._build_payload(
|
||||
prompt="hello world",
|
||||
reference_image_paths=[],
|
||||
aspect_ratio="16:9",
|
||||
seconds=8,
|
||||
size=None,
|
||||
enable_upsample=False,
|
||||
enable_sample=None,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["model"], "omni-flash")
|
||||
self.assertEqual(payload["type"], 1)
|
||||
self.assertEqual(payload["seconds"], "8")
|
||||
self.assertEqual(payload["aspect_ratio"], "16:9")
|
||||
self.assertFalse(payload["enable_upsample"])
|
||||
self.assertNotIn("images", payload)
|
||||
|
||||
def test_first_last_frame_payload(self):
|
||||
generator = VideoGeneratorOmniYunwuAPI(api_key="test-key")
|
||||
|
||||
payload = generator._build_payload(
|
||||
prompt="transition",
|
||||
reference_image_paths=["https://example.com/first.png", "https://example.com/last.png"],
|
||||
aspect_ratio="9:16",
|
||||
seconds=6,
|
||||
size=None,
|
||||
enable_upsample=None,
|
||||
enable_sample=True,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["type"], 2)
|
||||
self.assertEqual(payload["images"], ["https://example.com/first.png", "https://example.com/last.png"])
|
||||
self.assertEqual(payload["seconds"], "6")
|
||||
self.assertTrue(payload["enable_sample"])
|
||||
|
||||
def test_three_reference_images_use_reference_mode(self):
|
||||
generator = VideoGeneratorOmniYunwuAPI(api_key="test-key")
|
||||
|
||||
payload = generator._build_payload(
|
||||
prompt="blend references",
|
||||
reference_image_paths=[
|
||||
"https://example.com/1.png",
|
||||
"https://example.com/2.png",
|
||||
"https://example.com/3.png",
|
||||
],
|
||||
aspect_ratio="16:9",
|
||||
seconds=None,
|
||||
size=None,
|
||||
enable_upsample=None,
|
||||
enable_sample=None,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["type"], 3)
|
||||
self.assertEqual(len(payload["images"]), 3)
|
||||
|
||||
def test_too_many_reference_images_raise(self):
|
||||
generator = VideoGeneratorOmniYunwuAPI(api_key="test-key")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
generator._build_payload(
|
||||
prompt="too many",
|
||||
reference_image_paths=["1", "2", "3", "4"],
|
||||
aspect_ratio="16:9",
|
||||
seconds=None,
|
||||
size=None,
|
||||
enable_upsample=None,
|
||||
enable_sample=None,
|
||||
)
|
||||
|
||||
async def test_query_completed_uses_top_level_video_url(self):
|
||||
generator = VideoGeneratorOmniYunwuAPI(api_key="test-key", poll_interval=0, max_poll_attempts=1)
|
||||
session = _FakeSession({"status": "completed", "video_url": "https://example.com/out.mp4"})
|
||||
|
||||
with patch("tools.video_generator_omni_yunwu_api.aiohttp.ClientSession", return_value=session):
|
||||
video_url = await generator.query_video_generation_task("task-1", "omni-flash")
|
||||
|
||||
self.assertEqual(video_url, "https://example.com/out.mp4")
|
||||
self.assertEqual(session.calls[0][2]["params"], {"id": "task-1", "model": "omni-flash"})
|
||||
|
||||
async def test_query_failed_raises(self):
|
||||
generator = VideoGeneratorOmniYunwuAPI(api_key="test-key", poll_interval=0, max_poll_attempts=1)
|
||||
session = _FakeSession({"status": "failed", "error": "视频生成失败"})
|
||||
|
||||
with patch("tools.video_generator_omni_yunwu_api.aiohttp.ClientSession", return_value=session):
|
||||
with self.assertRaises(RuntimeError):
|
||||
await generator.query_video_generation_task("task-1", "omni-flash")
|
||||
|
||||
def test_omini_alias(self):
|
||||
self.assertTrue(issubclass(VideoGeneratorOminiYunwuAPI, VideoGeneratorOmniYunwuAPI))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,40 @@
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.video_generator_openrouter_api import VideoGeneratorOpenRouterAPI
|
||||
from interfaces.video_output import VideoOutput
|
||||
|
||||
|
||||
class OpenRouterVideoGeneratorTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_default_duration_is_eight_seconds(self):
|
||||
captured = {}
|
||||
|
||||
async def fake_post_json(url, *, headers, payload, timeout, hard_timeout_seconds):
|
||||
captured["payload"] = payload
|
||||
return 200, {"id": "job-1", "polling_url": "/videos/job-1", "status": "queued"}
|
||||
|
||||
async def fake_get_json(url, *, headers, timeout, hard_timeout_seconds):
|
||||
return 200, {"status": "completed", "unsigned_urls": ["https://cdn.example/out.mp4"]}
|
||||
|
||||
async def fake_get_bytes(url, *, headers, timeout, hard_timeout_seconds):
|
||||
return 200, b"video"
|
||||
|
||||
async def fake_sleep(seconds):
|
||||
return None
|
||||
|
||||
generator = VideoGeneratorOpenRouterAPI(api_key="test-key", model="google/veo-3.1-lite")
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch("tools.video_generator_openrouter_api._post_json", fake_post_json), \
|
||||
patch("tools.video_generator_openrouter_api._get_json", fake_get_json), \
|
||||
patch("tools.video_generator_openrouter_api._get_bytes", fake_get_bytes), \
|
||||
patch("tools.video_generator_openrouter_api.asyncio.sleep", fake_sleep):
|
||||
output = await generator.generate_single_video(prompt="hello")
|
||||
|
||||
self.assertIsInstance(output, VideoOutput)
|
||||
self.assertEqual(captured["payload"]["duration"], 8)
|
||||
self.assertEqual(captured["payload"]["model"], "google/veo-3.1-lite")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Unit tests for utils.provider_presets."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from utils.provider_presets import (
|
||||
PROVIDER_PRESETS,
|
||||
resolve_chat_model_config,
|
||||
detect_provider_from_env,
|
||||
)
|
||||
|
||||
|
||||
class TestProviderPresets(unittest.TestCase):
|
||||
"""Tests for the PROVIDER_PRESETS registry."""
|
||||
|
||||
def test_minimax_preset_exists(self):
|
||||
self.assertIn("minimax", PROVIDER_PRESETS)
|
||||
|
||||
def test_minimax_preset_base_url(self):
|
||||
self.assertEqual(
|
||||
PROVIDER_PRESETS["minimax"]["base_url"],
|
||||
"https://api.minimax.io/v1",
|
||||
)
|
||||
|
||||
def test_minimax_preset_env_key(self):
|
||||
self.assertEqual(PROVIDER_PRESETS["minimax"]["env_key"], "MINIMAX_API_KEY")
|
||||
|
||||
def test_minimax_preset_default_model(self):
|
||||
self.assertEqual(PROVIDER_PRESETS["minimax"]["default_model"], "MiniMax-M3")
|
||||
|
||||
def test_minimax_preset_has_models_list(self):
|
||||
models = PROVIDER_PRESETS["minimax"]["models"]
|
||||
self.assertIn("MiniMax-M3", models)
|
||||
self.assertIn("MiniMax-M2.7", models)
|
||||
self.assertIn("MiniMax-M2.7-highspeed", models)
|
||||
|
||||
def test_minimax_preset_temperature_range(self):
|
||||
lo, hi = PROVIDER_PRESETS["minimax"]["temperature_range"]
|
||||
self.assertEqual(lo, 0.0)
|
||||
self.assertEqual(hi, 1.0)
|
||||
|
||||
|
||||
class TestResolveChatModelConfig(unittest.TestCase):
|
||||
"""Tests for resolve_chat_model_config()."""
|
||||
|
||||
def test_unknown_provider_passes_through(self):
|
||||
args = {"model_provider": "openai", "model": "gpt-4", "base_url": "https://example.com"}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["model_provider"], "openai")
|
||||
self.assertEqual(result["model"], "gpt-4")
|
||||
self.assertEqual(result["base_url"], "https://example.com")
|
||||
|
||||
def test_no_model_provider_passes_through(self):
|
||||
args = {"model": "gpt-4"}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["model"], "gpt-4")
|
||||
|
||||
def test_minimax_rewrites_provider_to_openai(self):
|
||||
args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk-test"}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["model_provider"], "openai")
|
||||
|
||||
def test_minimax_sets_base_url(self):
|
||||
args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk-test"}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["base_url"], "https://api.minimax.io/v1")
|
||||
|
||||
def test_minimax_preserves_custom_base_url(self):
|
||||
args = {
|
||||
"model_provider": "minimax",
|
||||
"model": "MiniMax-M3",
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://custom-proxy.example.com/v1",
|
||||
}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["base_url"], "https://custom-proxy.example.com/v1")
|
||||
|
||||
def test_minimax_defaults_model(self):
|
||||
args = {"model_provider": "minimax", "api_key": "sk-test"}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["model"], "MiniMax-M3")
|
||||
|
||||
def test_minimax_preserves_explicit_model(self):
|
||||
args = {"model_provider": "minimax", "model": "MiniMax-M2.7-highspeed", "api_key": "sk-test"}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["model"], "MiniMax-M2.7-highspeed")
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "env-key-123"})
|
||||
def test_minimax_reads_api_key_from_env(self):
|
||||
args = {"model_provider": "minimax", "model": "MiniMax-M3"}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["api_key"], "env-key-123")
|
||||
|
||||
def test_minimax_prefers_explicit_api_key_over_env(self):
|
||||
args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "explicit-key"}
|
||||
with patch.dict(os.environ, {"MINIMAX_API_KEY": "env-key"}):
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["api_key"], "explicit-key")
|
||||
|
||||
def test_minimax_clamps_temperature_above_max(self):
|
||||
args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk", "temperature": 1.5}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["temperature"], 1.0)
|
||||
|
||||
def test_minimax_clamps_temperature_below_min(self):
|
||||
args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk", "temperature": -0.5}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["temperature"], 0.0)
|
||||
|
||||
def test_minimax_passes_valid_temperature(self):
|
||||
args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk", "temperature": 0.7}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["temperature"], 0.7)
|
||||
|
||||
def test_minimax_temperature_zero_allowed(self):
|
||||
args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk", "temperature": 0.0}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["temperature"], 0.0)
|
||||
|
||||
def test_minimax_no_temperature_key(self):
|
||||
args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk"}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertNotIn("temperature", result)
|
||||
|
||||
def test_minimax_temperature_none_ignored(self):
|
||||
args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk", "temperature": None}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertIsNone(result["temperature"])
|
||||
|
||||
def test_original_dict_not_mutated(self):
|
||||
args = {"model_provider": "minimax", "model": "MiniMax-M3", "api_key": "sk"}
|
||||
resolve_chat_model_config(args)
|
||||
self.assertEqual(args["model_provider"], "minimax")
|
||||
|
||||
def test_empty_model_string_gets_default(self):
|
||||
args = {"model_provider": "minimax", "model": "", "api_key": "sk"}
|
||||
result = resolve_chat_model_config(args)
|
||||
self.assertEqual(result["model"], "MiniMax-M3")
|
||||
|
||||
|
||||
class TestDetectProviderFromEnv(unittest.TestCase):
|
||||
"""Tests for detect_provider_from_env()."""
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"}, clear=False)
|
||||
def test_detects_minimax(self):
|
||||
self.assertEqual(detect_provider_from_env(), "minimax")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_returns_none_when_no_keys(self):
|
||||
self.assertIsNone(detect_provider_from_env())
|
||||
|
||||
|
||||
class TestConfigYAMLLoading(unittest.TestCase):
|
||||
"""Test that MiniMax example config files are valid YAML."""
|
||||
|
||||
def test_idea2video_minimax_yaml(self):
|
||||
import yaml
|
||||
path = os.path.join(os.path.dirname(__file__), "..", "configs", "idea2video_minimax.yaml")
|
||||
with open(path) as f:
|
||||
config = yaml.safe_load(f)
|
||||
self.assertEqual(config["chat_model"]["init_args"]["model_provider"], "minimax")
|
||||
self.assertEqual(config["chat_model"]["init_args"]["model"], "MiniMax-M3")
|
||||
|
||||
def test_script2video_minimax_yaml(self):
|
||||
import yaml
|
||||
path = os.path.join(os.path.dirname(__file__), "..", "configs", "script2video_minimax.yaml")
|
||||
with open(path) as f:
|
||||
config = yaml.safe_load(f)
|
||||
self.assertEqual(config["chat_model"]["init_args"]["model_provider"], "minimax")
|
||||
self.assertEqual(config["chat_model"]["init_args"]["model"], "MiniMax-M3")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Regression tests for error-boundary and durability fixes.
|
||||
|
||||
Covers: LLM client retry/empty-choices handling, agent-loop turn error
|
||||
boundary, session index corruption/atomicity/concurrency, and bounded
|
||||
retry policies with backoff across agents and API clients.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from tenacity.stop import stop_never
|
||||
from tenacity.wait import wait_none
|
||||
|
||||
from agent_runtime.llm import OpenAICompatibleLLM
|
||||
from agent_runtime.loop import AgentLoop
|
||||
from agent_runtime.prompts import PromptBuilder
|
||||
from agent_runtime.session_index import SessionIndex
|
||||
from agent_runtime.tool_executor import ToolExecutor
|
||||
from agent_runtime.tools import ToolRegistry
|
||||
from agents.screenwriter import Screenwriter
|
||||
from agents.script_planner import ScriptPlanner
|
||||
from tools.image_generator_doubao_seedream_yunwu_api import ImageGeneratorDoubaoSeedreamYunwuAPI
|
||||
from tools.image_generator_nanobanana_google_api import ImageGeneratorNanobananaGoogleAPI
|
||||
from tools.image_generator_nanobanana_yunwu_api import ImageGeneratorNanobananaYunwuAPI
|
||||
from tools.reranker_bge_silicon_api import RerankerBgeSiliconapi
|
||||
|
||||
|
||||
class FakeStatusError(Exception):
|
||||
def __init__(self, status_code):
|
||||
self.status_code = status_code
|
||||
super().__init__(f"http status {status_code}")
|
||||
|
||||
|
||||
def _fake_completion(text="ok"):
|
||||
message = MagicMock()
|
||||
message.content = text
|
||||
message.tool_calls = None
|
||||
message.model_dump.return_value = {}
|
||||
return MagicMock(choices=[MagicMock(message=message)])
|
||||
|
||||
|
||||
class TestLLMClient(unittest.IsolatedAsyncioTestCase):
|
||||
def _llm(self, create):
|
||||
llm = OpenAICompatibleLLM(model="m", base_url="http://localhost:1", api_key="k")
|
||||
llm.client = MagicMock(chat=MagicMock(completions=MagicMock(create=create)))
|
||||
return llm
|
||||
|
||||
async def test_retries_rate_limit_then_succeeds(self):
|
||||
create = AsyncMock(side_effect=[FakeStatusError(429), _fake_completion("recovered")])
|
||||
llm = self._llm(create)
|
||||
result = await llm.complete([{"role": "user", "content": "x"}], tools=[])
|
||||
self.assertEqual(result.text, "recovered")
|
||||
self.assertEqual(create.await_count, 2)
|
||||
|
||||
async def test_does_not_retry_auth_errors(self):
|
||||
create = AsyncMock(side_effect=FakeStatusError(401))
|
||||
llm = self._llm(create)
|
||||
with self.assertRaises(FakeStatusError):
|
||||
await llm.complete([{"role": "user", "content": "x"}], tools=[])
|
||||
self.assertEqual(create.await_count, 1)
|
||||
|
||||
async def test_gives_up_after_bounded_attempts(self):
|
||||
create = AsyncMock(side_effect=FakeStatusError(500))
|
||||
llm = self._llm(create)
|
||||
with self.assertRaises(FakeStatusError):
|
||||
await llm.complete([{"role": "user", "content": "x"}], tools=[])
|
||||
self.assertLessEqual(create.await_count, 4)
|
||||
self.assertGreater(create.await_count, 1)
|
||||
|
||||
async def test_empty_choices_raises_clear_error(self):
|
||||
create = AsyncMock(return_value=MagicMock(choices=[]))
|
||||
llm = self._llm(create)
|
||||
with self.assertRaisesRegex(RuntimeError, "choice"):
|
||||
await llm.complete([{"role": "user", "content": "x"}], tools=[])
|
||||
|
||||
|
||||
class BoomLLM:
|
||||
async def complete(self, messages, tools):
|
||||
raise RuntimeError("boom-llm")
|
||||
|
||||
|
||||
class TestLoopErrorBoundary(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_llm_failure_emits_error_and_persists_failed_turn(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
registry = ToolRegistry([])
|
||||
loop = AgentLoop(index, PromptBuilder(f"{tmp}/prompts", index, registry), registry, ToolExecutor(registry, index), BoomLLM())
|
||||
events = [event async for event in loop.stream_events("hi")]
|
||||
kinds = [event["type"] for event in events]
|
||||
self.assertIn("error", kinds)
|
||||
error_event = next(event for event in events if event["type"] == "error")
|
||||
self.assertIn("boom-llm", error_event["message"])
|
||||
self.assertEqual(events[-2]["type"], "done")
|
||||
self.assertEqual(events[-1]["type"], "session")
|
||||
active = index.active()
|
||||
records = index.get(active["session_id"])["recent_turn_records"]
|
||||
self.assertEqual(records[-1]["status"], "failed")
|
||||
|
||||
|
||||
class TestSessionIndexDurability(unittest.TestCase):
|
||||
def test_corrupt_sessions_file_is_backed_up_not_silently_replaced(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
index.create(idea="precious work", session_id="keep-me")
|
||||
index.sessions_path.write_text("{ definitely not json", encoding="utf-8")
|
||||
data = index.load()
|
||||
self.assertEqual(data["sessions"], {})
|
||||
backups = list(index.vimax_dir.glob("sessions.json.corrupt-*"))
|
||||
self.assertEqual(len(backups), 1, "corrupt state must be preserved for recovery, not discarded")
|
||||
self.assertIn("definitely not json", backups[0].read_text(encoding="utf-8"))
|
||||
|
||||
def test_save_is_atomic_and_leaves_no_temp_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
index.create(session_id="roundtrip")
|
||||
self.assertEqual(list(index.vimax_dir.glob("*.tmp")), [])
|
||||
self.assertIn("roundtrip", index.load()["sessions"])
|
||||
|
||||
def test_concurrent_creates_do_not_lose_sessions(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index_a = SessionIndex(tmp)
|
||||
index_b = SessionIndex(tmp)
|
||||
|
||||
def worker(index, tag):
|
||||
for i in range(40):
|
||||
index.create(session_id=f"s-{tag}-{i}")
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker, args=(index_a, "a")),
|
||||
threading.Thread(target=worker, args=(index_b, "b")),
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
sessions = index_a.load()["sessions"]
|
||||
self.assertEqual(len(sessions), 80, "concurrent read-modify-write must not lose sessions")
|
||||
|
||||
|
||||
class TestBoundedRetryPolicies(unittest.TestCase):
|
||||
CASES = [
|
||||
("Screenwriter.write_script_based_on_story", Screenwriter.write_script_based_on_story),
|
||||
("ScriptPlanner.plan_script", ScriptPlanner.plan_script),
|
||||
("RerankerBgeSiliconapi.__call__", RerankerBgeSiliconapi.__call__),
|
||||
("ImageGeneratorDoubaoSeedreamYunwuAPI.generate_single_image", ImageGeneratorDoubaoSeedreamYunwuAPI.generate_single_image),
|
||||
("ImageGeneratorNanobananaGoogleAPI.generate_single_image", ImageGeneratorNanobananaGoogleAPI.generate_single_image),
|
||||
("ImageGeneratorNanobananaYunwuAPI.generate_single_image", ImageGeneratorNanobananaYunwuAPI.generate_single_image),
|
||||
]
|
||||
|
||||
def test_every_retry_is_bounded_with_backoff(self):
|
||||
for name, fn in self.CASES:
|
||||
with self.subTest(name=name):
|
||||
retrying = getattr(fn, "retry", None)
|
||||
self.assertIsNotNone(retrying, f"{name} must have a retry policy")
|
||||
self.assertIsNot(retrying.stop, stop_never, f"{name} must not retry forever")
|
||||
self.assertNotIsInstance(retrying.wait, wait_none, f"{name} must back off between attempts")
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload, status=200):
|
||||
self.payload = payload
|
||||
self.status = status
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, scripted):
|
||||
self.scripted = list(scripted)
|
||||
self.calls = 0
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def _next(self):
|
||||
response = self.scripted[min(self.calls, len(self.scripted) - 1)]
|
||||
self.calls += 1
|
||||
return _FakeResponse(*response)
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
return self._next()
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
return self._next()
|
||||
|
||||
|
||||
class TestClientHttpErrors(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_reranker_surfaces_http_error_without_retry(self):
|
||||
session = _FakeSession([
|
||||
({"message": "invalid api key"}, 401),
|
||||
({"results": []}, 200),
|
||||
])
|
||||
reranker = RerankerBgeSiliconapi(api_key="bad", base_url="http://x")
|
||||
with patch("tools.reranker_bge_silicon_api.aiohttp.ClientSession", return_value=session):
|
||||
with self.assertRaisesRegex(RuntimeError, "401"):
|
||||
await reranker(documents=["doc"], query="q", top_n=1)
|
||||
self.assertEqual(session.calls, 1, "4xx must fail fast with the real error, not retry into KeyError")
|
||||
|
||||
async def test_seedream_surfaces_http_error_without_retry(self):
|
||||
session = _FakeSession([
|
||||
({"error": {"message": "invalid api key"}}, 401),
|
||||
({"data": [{"url": "http://img"}]}, 200),
|
||||
])
|
||||
generator = ImageGeneratorDoubaoSeedreamYunwuAPI(api_key="bad")
|
||||
with patch("tools.image_generator_doubao_seedream_yunwu_api.aiohttp.ClientSession", return_value=session):
|
||||
with self.assertRaisesRegex(RuntimeError, "401"):
|
||||
await generator.generate_single_image(prompt="p")
|
||||
self.assertEqual(session.calls, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,62 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from interfaces import Camera, ShotBriefDescription, ShotDescription
|
||||
from pipelines.script2video_pipeline import Script2VideoPipeline, _group_shots_into_cameras
|
||||
|
||||
|
||||
class FlakyCameraImageGenerator:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
async def construct_camera_tree(self, cameras, shot_descs):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return ["not-a-camera"]
|
||||
return cameras
|
||||
|
||||
|
||||
class Script2VideoPipelineGuardTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_group_shots_into_cameras_does_not_use_camera_idx_as_list_index(self):
|
||||
shots = [
|
||||
ShotDescription(idx=0, is_last=False, cam_idx=2, visual_desc="a", variation_type="small", variation_reason="same", ff_desc="a", ff_vis_char_idxs=[], lf_desc="a", lf_vis_char_idxs=[], motion_desc="a", audio_desc="none"),
|
||||
ShotDescription(idx=1, is_last=True, cam_idx=5, visual_desc="b", variation_type="small", variation_reason="same", ff_desc="b", ff_vis_char_idxs=[], lf_desc="b", lf_vis_char_idxs=[], motion_desc="b", audio_desc="none"),
|
||||
ShotDescription(idx=2, is_last=True, cam_idx=2, visual_desc="c", variation_type="small", variation_reason="same", ff_desc="c", ff_vis_char_idxs=[], lf_desc="c", lf_vis_char_idxs=[], motion_desc="c", audio_desc="none"),
|
||||
]
|
||||
cameras = _group_shots_into_cameras(shots)
|
||||
self.assertEqual([camera.idx for camera in cameras], [2, 5])
|
||||
self.assertEqual(cameras[0].active_shot_idxs, [0, 2])
|
||||
self.assertEqual(cameras[1].active_shot_idxs, [1])
|
||||
|
||||
async def test_plan_text_artifacts_retries_bad_camera_tree_schema(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pipeline = Script2VideoPipeline(chat_model=object(), image_generator=object(), video_generator=object(), working_dir=tmp)
|
||||
pipeline.camera_image_generator = FlakyCameraImageGenerator()
|
||||
|
||||
async def design_storyboard(script, characters, user_requirement, quiet=False):
|
||||
return [{"idx": 0, "is_last": True, "cam_idx": 3, "visual_desc": "wide shot", "audio_desc": "waves"}]
|
||||
|
||||
async def decompose_visual_descriptions(shot_brief_descriptions, characters, quiet=False):
|
||||
return [{"idx": 0, "is_last": True, "cam_idx": 3, "visual_desc": "wide shot", "variation_type": "small", "variation_reason": "simple", "ff_desc": "start", "ff_vis_char_idxs": [], "lf_desc": "end", "lf_vis_char_idxs": [], "motion_desc": "walk", "audio_desc": "waves"}]
|
||||
|
||||
pipeline.design_storyboard = design_storyboard
|
||||
pipeline.decompose_visual_descriptions = decompose_visual_descriptions
|
||||
events = []
|
||||
result = await pipeline.plan_text_artifacts(
|
||||
"script",
|
||||
"req",
|
||||
"style",
|
||||
characters=[{"idx": 0, "identifier_in_scene": "Man", "is_visible": True, "static_features": "adult", "dynamic_features": "coat"}],
|
||||
progress=lambda stage, message, metadata=None: events.append(stage),
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
self.assertEqual(pipeline.camera_image_generator.calls, 2)
|
||||
self.assertIn("construct_camera_tree_retry", events)
|
||||
self.assertEqual(result["camera_tree"][0].idx, 3)
|
||||
self.assertTrue((Path(tmp) / "camera_tree.json").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,452 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from interfaces import Camera, CharacterInScene, ShotBriefDescription, ShotDescription
|
||||
from agent_runtime.session_index import SessionIndex
|
||||
from agent_runtime.vimax_adapters import ViMaxAdapters
|
||||
from agent_runtime.tools import ToolRuntimeContext
|
||||
from pipelines.idea2video_pipeline import Idea2VideoPipeline
|
||||
from pipelines.script2video_pipeline import Script2VideoPipeline
|
||||
|
||||
|
||||
class FakeIdeaPipeline:
|
||||
def __init__(self, chat_model, image_generator, video_generator, working_dir):
|
||||
self.working_dir = Path(working_dir)
|
||||
self.working_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def develop_story(self, idea, user_requirement, quiet=False):
|
||||
path = self.working_dir / "story.txt"
|
||||
path.write_text("story", encoding="utf-8")
|
||||
return "story"
|
||||
|
||||
async def extract_characters(self, story, quiet=False):
|
||||
chars = [CharacterInScene(idx=0, identifier_in_scene="Cat", is_visible=True, static_features="black cat", dynamic_features="helmet")]
|
||||
(self.working_dir / "characters.json").write_text(json.dumps([c.model_dump() for c in chars]), encoding="utf-8")
|
||||
return chars
|
||||
|
||||
async def write_script_based_on_story(self, story, user_requirement, quiet=False):
|
||||
script = [{"scene": "cat jumps"}]
|
||||
(self.working_dir / "script.json").write_text(json.dumps(script), encoding="utf-8")
|
||||
return script
|
||||
|
||||
|
||||
|
||||
|
||||
class HangingIdeaPipeline(FakeIdeaPipeline):
|
||||
async def develop_story(self, idea, user_requirement, quiet=False):
|
||||
await asyncio.sleep(10)
|
||||
return "story"
|
||||
|
||||
|
||||
|
||||
class FakeRevisionModel:
|
||||
async def ainvoke(self, prompt):
|
||||
return SimpleNamespace(content='[{"idx": 0, "description": "more oppressive"}]')
|
||||
|
||||
|
||||
class FailRenderIdeaPipeline(FakeIdeaPipeline):
|
||||
async def __call__(self, idea, user_requirement, style, quiet=False):
|
||||
raise RuntimeError("render failed")
|
||||
|
||||
|
||||
class FailRender403IdeaPipeline(FakeIdeaPipeline):
|
||||
async def __call__(self, idea, user_requirement, style, quiet=False):
|
||||
raise RuntimeError("OpenRouter video create failed with HTTP 403: {'error': {'message': 'Key limit exceeded (total limit). Manage it using token sk-short', 'code': 403}}")
|
||||
|
||||
|
||||
class NoisyRenderIdeaPipeline(FakeIdeaPipeline):
|
||||
async def __call__(self, idea, user_requirement, style, quiet=False):
|
||||
print("NOISE_FROM_RENDER_PIPELINE")
|
||||
final = self.working_dir / "final_video.mp4"
|
||||
final.write_text("video", encoding="utf-8")
|
||||
return str(final)
|
||||
|
||||
|
||||
class FakeScriptPipeline:
|
||||
def __init__(self, chat_model, image_generator, video_generator, working_dir):
|
||||
self.working_dir = Path(working_dir)
|
||||
self.working_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def plan_text_artifacts(self, script, user_requirement, style, characters=None, progress=None, quiet=False):
|
||||
if progress:
|
||||
progress("design_storyboard", "Designing storyboard", {})
|
||||
progress("decompose_shots", "Decomposing shot visual descriptions", {"shot_count": 1})
|
||||
progress("construct_camera_tree", "Constructing camera tree", {"shot_count": 1})
|
||||
(self.working_dir / "storyboard.json").write_text("[]", encoding="utf-8")
|
||||
(self.working_dir / "camera_tree.json").write_text("[]", encoding="utf-8")
|
||||
shot_dir = self.working_dir / "shots" / "0"
|
||||
shot_dir.mkdir(parents=True, exist_ok=True)
|
||||
(shot_dir / "shot_description.json").write_text("{}", encoding="utf-8")
|
||||
if characters:
|
||||
(self.working_dir / "characters.json").write_text(json.dumps([c.model_dump() for c in characters]), encoding="utf-8")
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
|
||||
class FailingScriptPipeline(FakeScriptPipeline):
|
||||
async def plan_text_artifacts(self, script, user_requirement, style, characters=None, progress=None, quiet=False):
|
||||
if progress:
|
||||
progress("design_storyboard", "Designing storyboard", {})
|
||||
raise RuntimeError("storyboard failed")
|
||||
|
||||
|
||||
class FakeInitChatModel:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return object()
|
||||
|
||||
|
||||
class Script2VideoPlanningProgressTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_plan_text_artifacts_emits_progress_in_order(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pipeline = Script2VideoPipeline(chat_model=object(), image_generator=object(), video_generator=object(), working_dir=tmp)
|
||||
chars = [CharacterInScene(idx=0, identifier_in_scene="Cat", is_visible=True, static_features="black cat", dynamic_features="helmet")]
|
||||
storyboard = [ShotBriefDescription(idx=0, is_last=True, cam_idx=0, visual_desc="cat jumps", audio_desc="wind")]
|
||||
shot = ShotDescription(idx=0, is_last=True, cam_idx=0, visual_desc="cat jumps", variation_type="small", variation_reason="simple motion", ff_desc="cat starts", ff_vis_char_idxs=[0], lf_desc="cat lands", lf_vis_char_idxs=[0], motion_desc="cat jumps", audio_desc="wind")
|
||||
camera = [Camera(idx=0, active_shot_idxs=[0])]
|
||||
|
||||
async def design_storyboard(script, characters, user_requirement, quiet=False):
|
||||
return storyboard
|
||||
|
||||
async def decompose_visual_descriptions(shot_brief_descriptions, characters, quiet=False):
|
||||
return [shot]
|
||||
|
||||
async def construct_camera_tree(shot_descriptions, quiet=False):
|
||||
return camera
|
||||
|
||||
pipeline.design_storyboard = design_storyboard
|
||||
pipeline.decompose_visual_descriptions = decompose_visual_descriptions
|
||||
pipeline.construct_camera_tree = construct_camera_tree
|
||||
events = []
|
||||
await pipeline.plan_text_artifacts("script", "req", "style", characters=chars, progress=lambda stage, message, metadata=None: events.append(stage))
|
||||
self.assertEqual(events, ["extract_characters", "design_storyboard", "decompose_shots", "construct_camera_tree"])
|
||||
|
||||
|
||||
async def test_idea_pipeline_quiet_suppresses_text_planning_prints(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pipeline = Idea2VideoPipeline(chat_model=object(), image_generator=object(), video_generator=object(), working_dir=tmp)
|
||||
|
||||
async def develop_story(idea, user_requirement):
|
||||
return "story"
|
||||
|
||||
pipeline.screenwriter = SimpleNamespace(develop_story=develop_story)
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
result = await pipeline.develop_story("idea", "req", quiet=True)
|
||||
self.assertEqual(result, "story")
|
||||
self.assertEqual(stdout.getvalue(), "")
|
||||
|
||||
|
||||
class ViMaxAdapterTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_build_chat_model_uses_bounded_init_chat_model_kwargs(self):
|
||||
fake = FakeInitChatModel()
|
||||
with patch.dict("os.environ", {
|
||||
"VIMAX_LLM_API_KEY": "test-key",
|
||||
"VIMAX_LLM_MODEL": "test-model",
|
||||
"VIMAX_LLM_BASE_URL": "https://example.invalid/v1",
|
||||
"VIMAX_LLM_REQUEST_TIMEOUT_SECONDS": "12",
|
||||
"VIMAX_NARRATIVE_MAX_TOKENS": "1234",
|
||||
}), patch("agent_runtime.vimax_adapters.init_chat_model", fake):
|
||||
from agent_runtime.vimax_adapters import _build_chat_model
|
||||
|
||||
_build_chat_model()
|
||||
|
||||
self.assertEqual(fake.calls[0]["model"], "test-model")
|
||||
self.assertEqual(fake.calls[0]["base_url"], "https://example.invalid/v1")
|
||||
self.assertEqual(fake.calls[0]["timeout"], 12.0)
|
||||
self.assertEqual(fake.calls[0]["max_retries"], 0)
|
||||
self.assertEqual(fake.calls[0]["max_completion_tokens"], 1234)
|
||||
|
||||
|
||||
async def test_narrative_planning_uses_text_only_pipeline(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), \
|
||||
patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline):
|
||||
result = await adapter.vimax_narrative_planning({"idea": "moon cat", "user_requirement": "short", "style": "anime"})
|
||||
self.assertTrue(result.ok)
|
||||
payload = json.loads(result.content)
|
||||
self.assertTrue(payload["ready_for_render"])
|
||||
root = Path(tmp) / payload["working_dir"]
|
||||
self.assertTrue((root / "idea2video" / "scene_0" / "storyboard.json").exists())
|
||||
self.assertTrue((root / "idea2video" / "scene_0" / "camera_tree.json").exists())
|
||||
self.assertTrue((root / "idea2video" / "scene_0" / "shots" / "0" / "shot_description.json").exists())
|
||||
self.assertFalse((root / "script2video" / "storyboard.json").exists())
|
||||
self.assertFalse((root / "script2video" / "final_video.mp4").exists())
|
||||
|
||||
|
||||
async def test_script_mode_persists_source_script_for_render(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
script = "A red ball rolls across a white table."
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline):
|
||||
result = await adapter.vimax_narrative_planning({"script": script, "user_requirement": "one shot"})
|
||||
self.assertTrue(result.ok)
|
||||
payload = json.loads(result.content)
|
||||
root = Path(tmp) / payload["working_dir"]
|
||||
self.assertEqual((root / "script2video" / "script.txt").read_text(encoding="utf-8"), script)
|
||||
self.assertEqual(index.artifact_checklist(payload["session_id"])["script2video/script.txt"], True)
|
||||
from agent_runtime.vimax_adapters import _load_script_text
|
||||
self.assertEqual(_load_script_text(root), script)
|
||||
|
||||
|
||||
async def test_narrative_planning_forwards_pipeline_progress(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
events = []
|
||||
runtime = ToolRuntimeContext("vimax_narrative_planning", "vimax_narrative_planning", turn_id="turn-test", progress_callback=events.append)
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), \
|
||||
patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline):
|
||||
result = await adapter.vimax_narrative_planning({"idea": "moon cat"}, runtime)
|
||||
self.assertTrue(result.ok)
|
||||
stages = [event["progress"]["stage"] for event in events if event.get("type") == "tool_progress"]
|
||||
self.assertIn("initializing_llm", stages)
|
||||
self.assertIn("develop_story", stages)
|
||||
self.assertIn("design_storyboard", stages)
|
||||
self.assertIn("decompose_shots", stages)
|
||||
self.assertIn("construct_camera_tree", stages)
|
||||
|
||||
|
||||
async def test_plan_scene_failure_marks_session_error(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), \
|
||||
patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FailingScriptPipeline):
|
||||
result = await adapter.vimax_narrative_planning({"idea": "moon cat"})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertEqual(result.metadata["error_type"], "recoverable_planning_step_failed")
|
||||
self.assertTrue(result.metadata["retryable"])
|
||||
session = index.active()
|
||||
self.assertEqual(session["stage"], "error")
|
||||
self.assertIn("storyboard failed", session["summary"])
|
||||
|
||||
|
||||
async def test_narrative_planning_timeout_marks_session_error(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
with patch.dict("os.environ", {"VIMAX_NARRATIVE_STEP_TIMEOUT_SECONDS": "0.01"}), \
|
||||
patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", HangingIdeaPipeline):
|
||||
result = await adapter.vimax_narrative_planning({"idea": "moon cat"})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertEqual(result.metadata["error_type"], "recoverable_planning_step_failed")
|
||||
session = index.active()
|
||||
self.assertIsNotNone(session)
|
||||
self.assertEqual(session["stage"], "error")
|
||||
self.assertIn("timed out", session["summary"])
|
||||
|
||||
|
||||
|
||||
async def test_active_session_without_new_input_continues_existing_idea(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="moon cat", user_requirement="short", style="anime")
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline):
|
||||
result = await adapter.vimax_narrative_planning({})
|
||||
self.assertTrue(result.ok)
|
||||
payload = json.loads(result.content)
|
||||
self.assertEqual(payload["session_id"], record["session_id"])
|
||||
self.assertEqual(index.active()["session_id"], record["session_id"])
|
||||
|
||||
|
||||
async def test_active_session_continuation_preserves_existing_style(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="moon cat", user_requirement="short", style="anime")
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline):
|
||||
result = await adapter.vimax_narrative_planning({"session_id": record["session_id"]})
|
||||
self.assertTrue(result.ok)
|
||||
self.assertEqual(index.get(record["session_id"])["style"], "anime")
|
||||
|
||||
async def test_new_idea_creates_new_session_instead_of_reusing_active(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), \
|
||||
patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline):
|
||||
first = await adapter.vimax_narrative_planning({"idea": "moon cat"})
|
||||
second = await adapter.vimax_narrative_planning({"idea": "ocean robot"})
|
||||
self.assertNotEqual(json.loads(first.content)["session_id"], json.loads(second.content)["session_id"])
|
||||
|
||||
|
||||
async def test_explicit_session_with_different_idea_creates_new_session(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
old = index.create(idea="old cat")
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FakeIdeaPipeline), \
|
||||
patch("agent_runtime.vimax_adapters.Script2VideoPipeline", FakeScriptPipeline):
|
||||
result = await adapter.vimax_narrative_planning({"session_id": old["session_id"], "idea": "new robot"})
|
||||
self.assertTrue(result.ok)
|
||||
payload = json.loads(result.content)
|
||||
self.assertNotEqual(payload["session_id"], old["session_id"])
|
||||
self.assertEqual(index.get(payload["session_id"])["idea"], "new robot")
|
||||
|
||||
async def test_revision_mode_rewrites_existing_artifact_and_logs(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="x")
|
||||
target = Path(tmp) / record["working_dir"] / "idea2video" / "scene_0" / "storyboard.json"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text('[{"idx": 0, "description": "calm"}]', encoding="utf-8")
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=FakeRevisionModel()):
|
||||
result = await adapter.vimax_narrative_planning({"revision_target": "idea2video/scene_0/storyboard.json", "revision_instruction": "make it oppressive"})
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn("more oppressive", target.read_text(encoding="utf-8"))
|
||||
self.assertTrue((Path(tmp) / ".vimax" / "logs" / "revisions.jsonl").exists())
|
||||
self.assertTrue(index.get(record["session_id"])["stale"]["final_video"])
|
||||
|
||||
|
||||
async def test_revision_missing_instruction_marks_error(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="x")
|
||||
target = Path(tmp) / record["working_dir"] / "idea2video" / "scene_0" / "storyboard.json"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text('[]', encoding="utf-8")
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
result = await adapter.vimax_narrative_planning({"revision_target": "idea2video/scene_0/storyboard.json"})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertEqual(result.metadata["error_type"], "missing_revision_instruction")
|
||||
self.assertEqual(index.get(record["session_id"])["stage"], "error")
|
||||
|
||||
|
||||
async def test_revision_missing_target_marks_error(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="x")
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
result = await adapter.vimax_narrative_planning({"revision_target": "idea2video/scene_0/missing.json", "revision_instruction": "change it"})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertEqual(result.metadata["error_type"], "dependency_missing")
|
||||
self.assertEqual(index.get(record["session_id"])["stage"], "error")
|
||||
|
||||
async def test_render_setup_failure_marks_session_error(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="x")
|
||||
root = Path(tmp) / record["working_dir"] / "idea2video"
|
||||
(root / "scene_0" / "shots" / "0").mkdir(parents=True, exist_ok=True)
|
||||
(root / "story.txt").write_text("story", encoding="utf-8")
|
||||
(root / "characters.json").write_text("[]", encoding="utf-8")
|
||||
(root / "script.json").write_text("[]", encoding="utf-8")
|
||||
(root / "scene_0" / "storyboard.json").write_text("[]", encoding="utf-8")
|
||||
(root / "scene_0" / "camera_tree.json").write_text("[]", encoding="utf-8")
|
||||
(root / "scene_0" / "shots" / "0" / "shot_description.json").write_text("{}", encoding="utf-8")
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", side_effect=RuntimeError("missing key")):
|
||||
result = await adapter.vimax_render_video({})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertEqual(result.metadata["error_type"], "render_failed")
|
||||
self.assertIn("missing key", result.content)
|
||||
self.assertEqual(index.get(record["session_id"])["stage"], "error")
|
||||
|
||||
async def test_render_failure_marks_session_error(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="x")
|
||||
root = Path(tmp) / record["working_dir"] / "idea2video"
|
||||
(root / "scene_0" / "shots" / "0").mkdir(parents=True, exist_ok=True)
|
||||
(root / "story.txt").write_text("story", encoding="utf-8")
|
||||
(root / "characters.json").write_text("[]", encoding="utf-8")
|
||||
(root / "script.json").write_text("[]", encoding="utf-8")
|
||||
(root / "scene_0" / "storyboard.json").write_text("[]", encoding="utf-8")
|
||||
(root / "scene_0" / "camera_tree.json").write_text("[]", encoding="utf-8")
|
||||
(root / "scene_0" / "shots" / "0" / "shot_description.json").write_text("{}", encoding="utf-8")
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters._build_image_generator", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters._build_video_generator", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FailRenderIdeaPipeline):
|
||||
result = await adapter.vimax_render_video({})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertEqual(result.metadata["error_type"], "render_failed")
|
||||
self.assertIn("render failed", result.content)
|
||||
self.assertEqual(index.get(record["session_id"])["stage"], "error")
|
||||
status_path = Path(tmp) / record["working_dir"] / "render_status.json"
|
||||
events_path = Path(tmp) / record["working_dir"] / "render_events.jsonl"
|
||||
self.assertTrue(status_path.exists())
|
||||
self.assertTrue(events_path.exists())
|
||||
status = json.loads(status_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(status["status"], "error")
|
||||
self.assertEqual(status["error_type"], "render_failed")
|
||||
|
||||
async def test_render_403_key_limit_is_non_retryable_and_sanitized(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="x")
|
||||
root = Path(tmp) / record["working_dir"] / "idea2video"
|
||||
(root / "scene_0" / "shots" / "0").mkdir(parents=True, exist_ok=True)
|
||||
(root / "story.txt").write_text("story", encoding="utf-8")
|
||||
(root / "characters.json").write_text("[]", encoding="utf-8")
|
||||
(root / "script.json").write_text("[]", encoding="utf-8")
|
||||
(root / "scene_0" / "storyboard.json").write_text("[]", encoding="utf-8")
|
||||
(root / "scene_0" / "camera_tree.json").write_text("[]", encoding="utf-8")
|
||||
(root / "scene_0" / "shots" / "0" / "shot_description.json").write_text("{}", encoding="utf-8")
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters._build_image_generator", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters._build_video_generator", return_value=object()), \
|
||||
patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", FailRender403IdeaPipeline):
|
||||
result = await adapter.vimax_render_video({})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertFalse(result.metadata["retryable"])
|
||||
self.assertIn("<redacted>", result.metadata["error"])
|
||||
self.assertNotIn("sk-short", result.metadata["error"])
|
||||
status = json.loads((Path(tmp) / record["working_dir"] / "render_status.json").read_text(encoding="utf-8"))
|
||||
self.assertFalse(status["retryable"])
|
||||
self.assertNotIn("sk-short", status["error"])
|
||||
|
||||
|
||||
async def test_render_pipeline_stdout_is_suppressed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
record = index.create(idea="x", style="anime")
|
||||
root = Path(tmp) / record["working_dir"] / "idea2video"
|
||||
(root / "scene_0" / "shots" / "0").mkdir(parents=True, exist_ok=True)
|
||||
(root / "story.txt").write_text("story", encoding="utf-8")
|
||||
(root / "characters.json").write_text("[]", encoding="utf-8")
|
||||
(root / "script.json").write_text("[]", encoding="utf-8")
|
||||
(root / "scene_0" / "storyboard.json").write_text("[]", encoding="utf-8")
|
||||
(root / "scene_0" / "camera_tree.json").write_text("[]", encoding="utf-8")
|
||||
(root / "scene_0" / "shots" / "0" / "shot_description.json").write_text("{}", encoding="utf-8")
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
stdout = io.StringIO()
|
||||
with patch("agent_runtime.vimax_adapters._build_chat_model", return_value=object()), patch("agent_runtime.vimax_adapters._build_image_generator", return_value=object()), patch("agent_runtime.vimax_adapters._build_video_generator", return_value=object()), patch("agent_runtime.vimax_adapters.Idea2VideoPipeline", NoisyRenderIdeaPipeline), contextlib.redirect_stdout(stdout):
|
||||
result = await adapter.vimax_render_video({})
|
||||
self.assertTrue(result.ok)
|
||||
self.assertNotIn("NOISE_FROM_RENDER_PIPELINE", stdout.getvalue())
|
||||
|
||||
async def test_render_dependency_missing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
index = SessionIndex(tmp)
|
||||
index.create(idea="x")
|
||||
adapter = ViMaxAdapters(Path(tmp), index)
|
||||
result = await adapter.vimax_render_video({})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertEqual(result.metadata["error_type"], "dependency_missing")
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Regression tests for silent wrong-output bugs in the script2video render path."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from agents.reference_image_selector import select_pairs_by_indices
|
||||
from agents.storyboard_artist import validate_char_idxs
|
||||
from interfaces.camera import Camera
|
||||
from interfaces.shot_description import ShotDescription
|
||||
from pipelines.script2video_pipeline import (
|
||||
Script2VideoPipeline,
|
||||
_collect_priority_shot_idxs,
|
||||
_group_shots_into_cameras,
|
||||
)
|
||||
from utils.text import safe_path_component
|
||||
|
||||
|
||||
def _shot(idx, cam_idx, variation_type="small", ff_chars=None, lf_chars=None):
|
||||
return ShotDescription(
|
||||
idx=idx,
|
||||
is_last=False,
|
||||
cam_idx=cam_idx,
|
||||
visual_desc=f"shot {idx}",
|
||||
variation_type=variation_type,
|
||||
variation_reason="r",
|
||||
ff_desc=f"first frame {idx}",
|
||||
ff_vis_char_idxs=ff_chars or [],
|
||||
lf_desc=f"last frame {idx}",
|
||||
lf_vis_char_idxs=lf_chars or [],
|
||||
motion_desc="m",
|
||||
audio_desc="a",
|
||||
)
|
||||
|
||||
|
||||
class TestCameraGrouping(unittest.TestCase):
|
||||
def test_out_of_order_camera_indices_group_correctly(self):
|
||||
# Shot 0 uses camera 1, shot 1 uses camera 0, shot 2 uses camera 1 again.
|
||||
shots = [_shot(0, cam_idx=1), _shot(1, cam_idx=0), _shot(2, cam_idx=1)]
|
||||
cameras = _group_shots_into_cameras(shots)
|
||||
by_idx = {camera.idx: camera for camera in cameras}
|
||||
self.assertEqual(by_idx[1].active_shot_idxs, [0, 2])
|
||||
self.assertEqual(by_idx[0].active_shot_idxs, [1])
|
||||
|
||||
|
||||
class TestPriorityShotIdxs(unittest.TestCase):
|
||||
def test_priorities_are_shot_indices_not_camera_indices(self):
|
||||
# Camera 2 depends on shot 7 of camera 0: shot 7 must be prioritized.
|
||||
camera_tree = [
|
||||
Camera(idx=0, active_shot_idxs=[7, 8]),
|
||||
Camera(idx=2, active_shot_idxs=[9], parent_cam_idx=0, parent_shot_idx=7),
|
||||
]
|
||||
self.assertEqual(_collect_priority_shot_idxs(camera_tree), [7])
|
||||
|
||||
|
||||
class TestEventDictsAreInstanceState(unittest.TestCase):
|
||||
def _pipeline(self, working_dir):
|
||||
return Script2VideoPipeline(
|
||||
chat_model=MagicMock(),
|
||||
image_generator=MagicMock(),
|
||||
video_generator=MagicMock(),
|
||||
working_dir=working_dir,
|
||||
)
|
||||
|
||||
def test_two_pipelines_do_not_share_events(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
p1 = self._pipeline(os.path.join(tmp, "a"))
|
||||
p2 = self._pipeline(os.path.join(tmp, "b"))
|
||||
p1.frame_events[0] = {"first_frame": asyncio.Event()}
|
||||
p1.shot_desc_events[0] = asyncio.Event()
|
||||
p1.character_portrait_events[0] = asyncio.Event()
|
||||
self.assertEqual(p2.frame_events, {})
|
||||
self.assertEqual(p2.shot_desc_events, {})
|
||||
self.assertEqual(p2.character_portrait_events, {})
|
||||
|
||||
def test_no_class_level_mutable_event_dicts(self):
|
||||
for name in ("frame_events", "shot_desc_events", "character_portrait_events"):
|
||||
self.assertNotIsInstance(
|
||||
Script2VideoPipeline.__dict__.get(name), dict,
|
||||
f"{name} must not be shared class state",
|
||||
)
|
||||
|
||||
|
||||
class TestResumeIncludesNewCameraReference(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_existing_new_camera_image_is_still_offered_to_selector(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pipeline = Script2VideoPipeline(
|
||||
chat_model=MagicMock(),
|
||||
image_generator=MagicMock(),
|
||||
video_generator=MagicMock(),
|
||||
working_dir=tmp,
|
||||
)
|
||||
shots = [_shot(0, cam_idx=0), _shot(1, cam_idx=1)]
|
||||
camera = Camera(
|
||||
idx=1, active_shot_idxs=[1],
|
||||
parent_cam_idx=0, parent_shot_idx=0,
|
||||
missing_info="wrong background",
|
||||
)
|
||||
parent_done = asyncio.Event()
|
||||
parent_done.set()
|
||||
pipeline.frame_events = {
|
||||
0: {"first_frame": parent_done},
|
||||
1: {"first_frame": asyncio.Event()},
|
||||
}
|
||||
|
||||
# Resume state: transition video and new-camera image already on disk.
|
||||
shot_dir = os.path.join(tmp, "shots", "1")
|
||||
os.makedirs(shot_dir, exist_ok=True)
|
||||
new_camera_path = os.path.join(shot_dir, "new_camera_1.png")
|
||||
open(os.path.join(shot_dir, "transition_video_from_shot_0.mp4"), "wb").close()
|
||||
open(new_camera_path, "wb").close()
|
||||
|
||||
selector = AsyncMock(return_value={"reference_image_path_and_text_pairs": [], "text_prompt": "p"})
|
||||
pipeline.reference_image_selector = MagicMock(select_reference_images_and_generate_prompt=selector)
|
||||
fake_image = MagicMock()
|
||||
pipeline.image_generator.generate_single_image = AsyncMock(return_value=fake_image)
|
||||
|
||||
await pipeline.generate_frames_for_single_camera(
|
||||
camera=camera,
|
||||
shot_descriptions=shots,
|
||||
characters=[],
|
||||
character_portraits_registry={},
|
||||
priority_shot_idxs=[],
|
||||
)
|
||||
|
||||
selector.assert_awaited_once()
|
||||
offered = selector.await_args.kwargs["available_image_path_and_text_pairs"]
|
||||
offered_paths = [pair[0] for pair in offered]
|
||||
self.assertIn(new_camera_path, offered_paths,
|
||||
"resumed runs must offer the new-camera reference image to the selector")
|
||||
|
||||
|
||||
class TestCharIdxValidation(unittest.TestCase):
|
||||
def test_valid_indices_pass(self):
|
||||
validate_char_idxs([0, 1], 2, "ff_vis_char_idxs")
|
||||
|
||||
def test_out_of_range_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
validate_char_idxs([0, 2], 2, "ff_vis_char_idxs")
|
||||
|
||||
def test_negative_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
validate_char_idxs([-1], 2, "lf_vis_char_idxs")
|
||||
|
||||
|
||||
class TestReferenceSelectorIndices(unittest.TestCase):
|
||||
def test_valid_selection(self):
|
||||
pairs = [("a.png", "a"), ("b.png", "b")]
|
||||
self.assertEqual(select_pairs_by_indices(pairs, [1]), [("b.png", "b")])
|
||||
|
||||
def test_negative_index_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
select_pairs_by_indices([("a.png", "a")], [-1])
|
||||
|
||||
def test_out_of_range_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
select_pairs_by_indices([("a.png", "a")], [3])
|
||||
|
||||
|
||||
class TestSafePathComponent(unittest.TestCase):
|
||||
def test_clean_names_unchanged(self):
|
||||
self.assertEqual(safe_path_component("Alice"), "Alice")
|
||||
self.assertEqual(safe_path_component("Bob_2"), "Bob_2")
|
||||
|
||||
def test_unicode_names_preserved(self):
|
||||
self.assertEqual(safe_path_component("李雷"), "李雷")
|
||||
|
||||
def test_path_separators_removed(self):
|
||||
self.assertNotIn("/", safe_path_component("a/b"))
|
||||
self.assertNotIn("\\", safe_path_component("a\\b"))
|
||||
|
||||
def test_traversal_neutralized(self):
|
||||
cleaned = safe_path_component("../../etc/passwd")
|
||||
self.assertNotIn("/", cleaned)
|
||||
self.assertFalse(cleaned.startswith("."))
|
||||
|
||||
def test_empty_becomes_placeholder(self):
|
||||
self.assertEqual(safe_path_component(""), "unnamed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user