chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:54 +08:00
commit ecb5ae4e59
153 changed files with 21551 additions and 0 deletions
View File
+105
View File
@@ -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()
+48
View File
@@ -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()
+135
View File
@@ -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")
+70
View File
@@ -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"])
+69
View File
@@ -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)
+127
View File
@@ -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)
+38
View File
@@ -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()
+37
View File
@@ -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()
+229
View File
@@ -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()
+117
View File
@@ -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()
+29
View File
@@ -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()
+29
View File
@@ -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()
+162
View File
@@ -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)
+188
View File
@@ -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()
+52
View File
@@ -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()
+277
View File
@@ -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()
+136
View File
@@ -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()
+40
View File
@@ -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()
+175
View File
@@ -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()
+225
View File
@@ -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()
+452
View File
@@ -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")
+184
View File
@@ -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()