Files
talemate/tests/test_director_plan_nodes.py
veguAI f5d41c04c8 0.37.0 (#267)
0.37.0

- **Director Planning** — Multi-step todo lists in director chat plus a Generate long progress action for multi-beat scene arcs.
- **Auto Narration** — Unified auto-narration replacing the old Narrate after Dialogue toggle, with a chance slider and weighted action mix.
- **LLM Prompt Templates Manager** — Dedicated UI tab for viewing, creating, editing, and deleting prompt templates.
- **Character Folders** — Collapsible folders in the World Editor character list, synced across linked scenes.
- **OpenAI Compatible TTS** — Connect any number of OpenAI-compatible TTS servers in parallel.
- **KoboldCpp TTS Auto-Setup** — KoboldCpp clients with a TTS model loaded register themselves as a TTS backend.
- **Model Testing Harness** — Bundled scene that runs basic capability tests against any connected LLM.

Plus 27 improvements and 28 bug fixes
2026-05-12 21:01:51 +03:00

945 lines
31 KiB
Python

"""Unit tests for talemate.agents.director.plan.nodes.
Each plan node is exercised inside a real GraphContext bound to a real Scene
(MockScene + bootstrap_scene). LLM-bound paths are not exercised — these node
classes do not call Prompt.request directly; ExpandStoryArc delegates to
PlanMixin.plan_expand_beats which is tested separately.
The CompleteTask/RemoveTask/EditTask/InsertTask/DeletePlan/CreatePlan/etc.
nodes all manipulate plan state via the same util functions covered in
test_plan_tasks.py — these tests focus on the node-level glue: input wiring,
output values, error paths via ActionFailed, and chat-context interactions.
"""
from __future__ import annotations
from typing import Any
import pytest
from conftest import MockScene, bootstrap_scene
from talemate.agents.director.action_core.exceptions import ActionFailed
from talemate.agents.director.chat.context import (
DirectorChatContext,
director_chat_context,
)
from talemate.agents.director.chat.settings import (
ChatModeSettings,
GenerateArcSettings,
)
from talemate.agents.director.plan.nodes import (
CompleteTask,
CreatePlan,
DeletePlan,
EditTask,
EstimateWords,
ExpandStoryArc,
GetActiveChatPlanId,
GetActivePlan,
InsertTask,
RemoveTask,
_validate_task,
)
from talemate.agents.director.plan.schema import (
Beat,
Plan,
PlanStatus,
Task,
)
from talemate.agents.director.plan.util import (
get_plan,
save_plan,
)
from talemate.context import ActiveScene
from talemate.game.engine.nodes.core import (
GraphContext,
NodeVerbosity,
)
from talemate.game.engine.nodes.registry import import_talemate_node_definitions
# ---------------------------------------------------------------------------
# Session-scoped: register node definitions once.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session", autouse=True)
def _import_node_definitions():
import_talemate_node_definitions()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_RESERVED_PROPERTY_NAMES = {"title", "id"}
@pytest.fixture
def scene():
"""Real Scene with bootstrapped agents (mock memory + mock client)."""
s = MockScene()
bootstrap_scene(s)
return s
async def _run_node(
node,
scene,
*,
inputs: dict | None = None,
chat_ctx: DirectorChatContext | None = None,
state_setup=None,
) -> dict[str, Any]:
"""Run a node inside a GraphContext bound to the given scene.
`inputs` are pre-loaded as properties (the fallback path used when an
input socket has no connected producer).
`chat_ctx` is optionally set on the director_chat_context contextvar.
Returns a dict of {output_name: value} captured before the context exits.
"""
if inputs:
for k, v in inputs.items():
if k in _RESERVED_PROPERTY_NAMES:
node.properties[k] = v
else:
node.set_property(k, v)
token = None
if chat_ctx is not None:
token = director_chat_context.set(chat_ctx)
try:
with ActiveScene(scene):
with GraphContext() as state:
state.verbosity = NodeVerbosity.NORMAL
if state_setup:
state_setup(state)
await node.run(state)
return {sock.name: sock.value for sock in node.outputs}
finally:
if token is not None:
director_chat_context.reset(token)
def _ensure_director_state(scene):
if "director" not in scene.agent_state:
scene.agent_state["director"] = {}
# ---------------------------------------------------------------------------
# _validate_task
# ---------------------------------------------------------------------------
class TestValidateTask:
def test_returns_beat_when_beat_fields_present(self):
out = _validate_task(
{"description": "x", "type": "dialogue", "tension": 0.6}, order=2
)
assert isinstance(out, Beat)
assert out.order == 2
assert out.tension == 0.6
def test_returns_task_when_no_beat_fields(self):
out = _validate_task({"description": "x"}, order=4)
assert isinstance(out, Task)
assert not isinstance(out, Beat)
assert out.order == 4
def test_uses_explicit_order_over_argument(self):
out = _validate_task({"description": "x", "order": 9}, order=2)
assert out.order == 9
def test_pacing_field_promotes_to_beat(self):
out = _validate_task({"description": "x", "pacing": "fast"}, order=1)
assert isinstance(out, Beat)
assert out.pacing == "fast"
def test_characters_field_promotes_to_beat(self):
out = _validate_task({"description": "x", "characters": ["A"]}, order=1)
assert isinstance(out, Beat)
assert out.characters == ["A"]
# ---------------------------------------------------------------------------
# CreatePlan
# ---------------------------------------------------------------------------
class TestCreatePlan:
@pytest.mark.asyncio
async def test_creates_plan_with_validated_tasks(self, scene):
node = CreatePlan()
out = await _run_node(
node,
scene,
inputs={
"state": {},
"instructions": "outline a beach scene",
"tasks": [
{"description": "wake up", "type": "narration", "tension": 0.2},
{"description": "set goal"}, # plain task
],
},
)
assert out["plan_id"]
plan = get_plan(scene, out["plan_id"])
assert plan is not None
assert plan.instructions == "outline a beach scene"
assert plan.status == PlanStatus.ready
assert len(plan.tasks) == 2
assert isinstance(plan.tasks[0], Beat)
assert plan.tasks[0].order == 1
assert isinstance(plan.tasks[1], Task)
assert plan.tasks[1].order == 2
@pytest.mark.asyncio
async def test_handles_non_list_tasks_with_warning(self, scene):
node = CreatePlan()
out = await _run_node(
node,
scene,
inputs={
"state": {},
"instructions": "x",
"tasks": "not-a-list", # invalid
},
)
plan = get_plan(scene, out["plan_id"])
assert plan is not None
assert plan.tasks == []
@pytest.mark.asyncio
async def test_skips_non_dict_items_in_tasks(self, scene):
node = CreatePlan()
out = await _run_node(
node,
scene,
inputs={
"state": {},
"instructions": "x",
"tasks": [{"description": "valid"}, "string-not-dict", 42, None],
},
)
plan = get_plan(scene, out["plan_id"])
assert len(plan.tasks) == 1
assert plan.tasks[0].description == "valid"
@pytest.mark.asyncio
async def test_skips_invalid_task_dicts(self, scene):
node = CreatePlan()
out = await _run_node(
node,
scene,
inputs={
"state": {},
"instructions": "x",
"tasks": [
{"description": "ok"},
# tension > 1.0 — pydantic doesn't reject by default since
# it's a float; use an explicit invalid type instead
{"description": "bad", "type": "not-a-valid-type"},
],
},
)
plan = get_plan(scene, out["plan_id"])
# The invalid type should cause _validate_task to raise; node skips it.
assert all(t.description != "bad" for t in plan.tasks)
@pytest.mark.asyncio
async def test_stamps_close_arc_from_chat_context(self, scene):
chat_ctx = DirectorChatContext(
chat_id="chat-1",
modes=ChatModeSettings(generate_arc=GenerateArcSettings(close_arc=True)),
)
node = CreatePlan()
out = await _run_node(
node,
scene,
inputs={"state": {}, "instructions": "x", "tasks": []},
chat_ctx=chat_ctx,
)
plan = get_plan(scene, out["plan_id"])
assert plan.meta["close_arc"] is True
@pytest.mark.asyncio
async def test_meta_close_arc_is_preserved_when_set_explicitly(self, scene):
chat_ctx = DirectorChatContext(
chat_id="chat-1",
modes=ChatModeSettings(generate_arc=GenerateArcSettings(close_arc=True)),
)
node = CreatePlan()
out = await _run_node(
node,
scene,
inputs={
"state": {},
"instructions": "x",
"tasks": [],
"meta": {"close_arc": False, "perspective": "First person"},
},
chat_ctx=chat_ctx,
)
plan = get_plan(scene, out["plan_id"])
# Existing meta close_arc must NOT be overwritten by chat-ctx.
assert plan.meta["close_arc"] is False
assert plan.meta["perspective"] == "First person"
@pytest.mark.asyncio
async def test_sets_scene_perspective_from_meta(self, scene):
node = CreatePlan()
await _run_node(
node,
scene,
inputs={
"state": {},
"instructions": "x",
"tasks": [],
"meta": {"perspective": "Third person omniscient"},
},
)
assert scene.perspective == "Third person omniscient"
@pytest.mark.asyncio
async def test_links_plan_to_chat_context(self, scene):
chat_ctx = DirectorChatContext(chat_id="chat-1")
node = CreatePlan()
out = await _run_node(
node,
scene,
inputs={"state": {}, "instructions": "x", "tasks": []},
chat_ctx=chat_ctx,
)
assert chat_ctx.plan_id == out["plan_id"]
@pytest.mark.asyncio
async def test_state_passes_through(self, scene):
node = CreatePlan()
sentinel = {"key": "value"}
out = await _run_node(
node,
scene,
inputs={"state": sentinel, "instructions": "x", "tasks": []},
)
assert out["state"] is sentinel
@pytest.mark.asyncio
async def test_status_value_default_ready(self, scene):
node = CreatePlan()
out = await _run_node(
node,
scene,
inputs={"state": {}, "instructions": "x", "tasks": []},
)
plan = get_plan(scene, out["plan_id"])
assert plan.status == PlanStatus.ready
@pytest.mark.asyncio
async def test_explicit_status_value_used(self, scene):
node = CreatePlan()
out = await _run_node(
node,
scene,
inputs={
"state": {},
"instructions": "x",
"tasks": [],
"status": "planning",
},
)
plan = get_plan(scene, out["plan_id"])
assert plan.status == PlanStatus.planning
# ---------------------------------------------------------------------------
# EstimateWords
# ---------------------------------------------------------------------------
class TestEstimateWords:
@pytest.mark.asyncio
async def test_estimates_increase_with_beat_count(self, scene):
node1 = EstimateWords()
out1 = await _run_node(node1, scene, inputs={"state": {}, "beat_count": 4})
node2 = EstimateWords()
out2 = await _run_node(node2, scene, inputs={"state": {}, "beat_count": 12})
assert out1["estimated_words"] > 0
assert out2["estimated_words"] > out1["estimated_words"]
assert out1["beat_count"] == 4
assert out2["beat_count"] == 12
@pytest.mark.asyncio
async def test_default_beat_count(self, scene):
node = EstimateWords()
out = await _run_node(node, scene, inputs={"state": {}})
assert out["beat_count"] == 8
assert out["estimated_words"] > 0
assert out["estimated_reading_minutes"] >= 0
@pytest.mark.asyncio
async def test_reading_minutes_consistent_with_word_count(self, scene):
from talemate.agents.director.plan.schema import READING_SPEED_WPM
node = EstimateWords()
out = await _run_node(node, scene, inputs={"state": {}, "beat_count": 8})
# The node rounds reading_minutes to 1 decimal. Check that word count
# divided by the WPM constant matches the reported reading time.
expected = round(out["estimated_words"] / READING_SPEED_WPM, 1)
assert out["estimated_reading_minutes"] == expected
# ---------------------------------------------------------------------------
# GetActiveChatPlanId
# ---------------------------------------------------------------------------
class TestGetActiveChatPlanId:
@pytest.mark.asyncio
async def test_returns_plan_id_when_chat_has_plan(self, scene):
ctx = DirectorChatContext(chat_id="c1", plan_id="plan-abc")
node = GetActiveChatPlanId()
out = await _run_node(node, scene, inputs={"state": {}}, chat_ctx=ctx)
assert out["plan_id"] == "plan-abc"
assert out["has_plan"] is True
@pytest.mark.asyncio
async def test_returns_empty_string_when_no_chat_context(self, scene):
node = GetActiveChatPlanId()
out = await _run_node(node, scene, inputs={"state": {}})
assert out["plan_id"] == ""
assert out["has_plan"] is False
@pytest.mark.asyncio
async def test_returns_empty_when_chat_has_no_plan(self, scene):
ctx = DirectorChatContext(chat_id="c1")
node = GetActiveChatPlanId()
out = await _run_node(node, scene, inputs={"state": {}}, chat_ctx=ctx)
assert out["plan_id"] == ""
assert out["has_plan"] is False
# ---------------------------------------------------------------------------
# GetActivePlan
# ---------------------------------------------------------------------------
class TestGetActivePlan:
@pytest.mark.asyncio
async def test_returns_plan_with_beats_and_meta(self, scene):
plan = Plan(
instructions="x",
status=PlanStatus.ready,
tasks=[
Beat(description="b1", order=1, tension=0.2),
Task(description="t1", order=2), # not a Beat
Beat(description="b2", order=3, tension=0.6),
],
meta={"perspective": "First person", "close_arc": True},
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = GetActivePlan()
out = await _run_node(node, scene, inputs={"state": {}}, chat_ctx=ctx)
assert out["has_plan"] is True
assert out["plan_id"] == plan.id
assert out["plan"].id == plan.id
# Only Beats are included in the beats output (Task `t1` excluded).
assert [b.description for b in out["beats"]] == ["b1", "b2"]
assert out["perspective"] == "First person"
assert out["close_arc"] is True
@pytest.mark.asyncio
async def test_falls_back_to_scene_perspective(self, scene):
plan = Plan(instructions="x", tasks=[], meta={})
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
scene.perspective = "Custom scene perspective"
node = GetActivePlan()
out = await _run_node(node, scene, inputs={"state": {}}, chat_ctx=ctx)
assert out["perspective"] == "Custom scene perspective"
@pytest.mark.asyncio
async def test_returns_default_perspective_when_nothing_set(self, scene):
plan = Plan(instructions="x", tasks=[], meta={})
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
# ensure scene has no perspective
if hasattr(scene, "perspective"):
scene.perspective = ""
node = GetActivePlan()
out = await _run_node(node, scene, inputs={"state": {}}, chat_ctx=ctx)
assert out["perspective"] == "Third person, past tense."
@pytest.mark.asyncio
async def test_no_plan_when_chat_has_no_plan_id(self, scene):
ctx = DirectorChatContext(chat_id="c1")
node = GetActivePlan()
out = await _run_node(node, scene, inputs={"state": {}}, chat_ctx=ctx)
assert out["has_plan"] is False
assert out["plan"] is None
assert out["beats"] == []
@pytest.mark.asyncio
async def test_no_chat_context_returns_no_plan(self, scene):
node = GetActivePlan()
out = await _run_node(node, scene, inputs={"state": {}})
assert out["has_plan"] is False
# ---------------------------------------------------------------------------
# CompleteTask
# ---------------------------------------------------------------------------
class TestCompleteTaskNode:
@pytest.mark.asyncio
async def test_marks_task_completed_with_explicit_plan_id(self, scene):
plan = Plan(
instructions="x",
tasks=[Task(description="a", order=1)],
status=PlanStatus.ready,
)
save_plan(scene, plan)
task_id = plan.tasks[0].id
node = CompleteTask()
out = await _run_node(
node,
scene,
inputs={"state": {}, "task_id": task_id, "plan_id": plan.id},
)
assert "completed" in out["result"]
loaded = get_plan(scene, plan.id)
assert loaded.get_task(task_id).status == "completed"
@pytest.mark.asyncio
async def test_resolves_plan_id_from_chat_ctx(self, scene):
plan = Plan(
instructions="x",
tasks=[Task(description="a", order=1)],
status=PlanStatus.ready,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
task_id = plan.tasks[0].id
node = CompleteTask()
out = await _run_node(
node,
scene,
inputs={"state": {}, "task_id": task_id},
chat_ctx=ctx,
)
assert "completed" in out["result"]
# ---------------------------------------------------------------------------
# RemoveTask
# ---------------------------------------------------------------------------
class TestRemoveTaskNode:
@pytest.mark.asyncio
async def test_removes_task_from_active_plan(self, scene):
plan = Plan(
instructions="x",
tasks=[
Task(description="a", order=1),
Task(description="b", order=2),
],
status=PlanStatus.ready,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
task_id = plan.tasks[0].id
node = RemoveTask()
out = await _run_node(
node,
scene,
inputs={"state": {}, "task_id": task_id},
chat_ctx=ctx,
)
assert "Removed" in out["result"]
loaded = get_plan(scene, plan.id)
assert len(loaded.tasks) == 1
assert loaded.tasks[0].description == "b"
assert loaded.tasks[0].order == 1 # renumbered
@pytest.mark.asyncio
async def test_raises_when_no_active_plan(self, scene):
node = RemoveTask()
with pytest.raises(ActionFailed, match="No active plan"):
await _run_node(node, scene, inputs={"state": {}, "task_id": "x"})
@pytest.mark.asyncio
async def test_raises_when_plan_completed(self, scene):
plan = Plan(
instructions="x",
tasks=[Task(description="a", order=1)],
status=PlanStatus.completed,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = RemoveTask()
with pytest.raises(ActionFailed, match="completed"):
await _run_node(
node,
scene,
inputs={"state": {}, "task_id": plan.tasks[0].id},
chat_ctx=ctx,
)
@pytest.mark.asyncio
async def test_raises_when_task_id_unknown(self, scene):
plan = Plan(
instructions="x",
tasks=[Task(description="a", order=1)],
status=PlanStatus.ready,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = RemoveTask()
with pytest.raises(ActionFailed, match="No task with ID"):
await _run_node(
node,
scene,
inputs={"state": {}, "task_id": "missing"},
chat_ctx=ctx,
)
# ---------------------------------------------------------------------------
# EditTask
# ---------------------------------------------------------------------------
class TestEditTaskNode:
@pytest.mark.asyncio
async def test_edits_existing_task_and_returns_changed(self, scene):
plan = Plan(
instructions="x",
tasks=[Task(description="orig", order=1)],
status=PlanStatus.ready,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
task_id = plan.tasks[0].id
node = EditTask()
out = await _run_node(
node,
scene,
inputs={
"state": {},
"task_id": task_id,
"updates": {"description": "edited"},
},
chat_ctx=ctx,
)
assert "description" in out["result"]
loaded = get_plan(scene, plan.id)
assert loaded.get_task(task_id).description == "edited"
@pytest.mark.asyncio
async def test_raises_when_no_active_plan(self, scene):
node = EditTask()
with pytest.raises(ActionFailed, match="No active plan"):
await _run_node(
node,
scene,
inputs={"state": {}, "task_id": "x", "updates": {}},
)
@pytest.mark.asyncio
async def test_raises_when_plan_completed(self, scene):
plan = Plan(
instructions="x",
tasks=[Task(description="a", order=1)],
status=PlanStatus.completed,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = EditTask()
with pytest.raises(ActionFailed, match="completed"):
await _run_node(
node,
scene,
inputs={
"state": {},
"task_id": plan.tasks[0].id,
"updates": {"description": "x"},
},
chat_ctx=ctx,
)
@pytest.mark.asyncio
async def test_raises_when_updates_not_dict(self, scene):
plan = Plan(
instructions="x",
tasks=[Task(description="a", order=1)],
status=PlanStatus.ready,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = EditTask()
with pytest.raises(ActionFailed, match="Updates must be a dict"):
await _run_node(
node,
scene,
inputs={
"state": {},
"task_id": plan.tasks[0].id,
"updates": "not-a-dict",
},
chat_ctx=ctx,
)
@pytest.mark.asyncio
async def test_raises_when_task_unknown(self, scene):
plan = Plan(
instructions="x",
tasks=[Task(description="a", order=1)],
status=PlanStatus.ready,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = EditTask()
with pytest.raises(ActionFailed, match="No task with ID"):
await _run_node(
node,
scene,
inputs={
"state": {},
"task_id": "missing",
"updates": {"description": "x"},
},
chat_ctx=ctx,
)
# ---------------------------------------------------------------------------
# InsertTask
# ---------------------------------------------------------------------------
class TestInsertTaskNode:
@pytest.mark.asyncio
async def test_inserts_task_at_default_end(self, scene):
plan = Plan(
instructions="x",
tasks=[Task(description="a", order=1)],
status=PlanStatus.ready,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = InsertTask()
out = await _run_node(
node,
scene,
inputs={
"state": {},
"task": {"description": "appended"},
},
chat_ctx=ctx,
)
assert "Inserted" in out["result"]
loaded = get_plan(scene, plan.id)
assert len(loaded.tasks) == 2
assert loaded.tasks[1].description == "appended"
@pytest.mark.asyncio
async def test_inserts_at_start(self, scene):
plan = Plan(
instructions="x",
tasks=[Task(description="a", order=1)],
status=PlanStatus.ready,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = InsertTask()
await _run_node(
node,
scene,
inputs={
"state": {},
"task": {"description": "first"},
"position": "start",
},
chat_ctx=ctx,
)
loaded = get_plan(scene, plan.id)
assert loaded.tasks[0].description == "first"
@pytest.mark.asyncio
async def test_inserts_beat_when_beat_fields_present(self, scene):
plan = Plan(
instructions="x",
tasks=[Task(description="a", order=1)],
status=PlanStatus.ready,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = InsertTask()
await _run_node(
node,
scene,
inputs={
"state": {},
"task": {"description": "b", "type": "dialogue", "tension": 0.7},
},
chat_ctx=ctx,
)
loaded = get_plan(scene, plan.id)
assert isinstance(loaded.tasks[-1], Beat)
assert loaded.tasks[-1].tension == 0.7
@pytest.mark.asyncio
async def test_raises_when_no_active_plan(self, scene):
node = InsertTask()
with pytest.raises(ActionFailed, match="No active plan"):
await _run_node(
node,
scene,
inputs={"state": {}, "task": {"description": "x"}},
)
@pytest.mark.asyncio
async def test_raises_when_plan_completed(self, scene):
plan = Plan(
instructions="x",
tasks=[],
status=PlanStatus.completed,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = InsertTask()
with pytest.raises(ActionFailed, match="completed"):
await _run_node(
node,
scene,
inputs={"state": {}, "task": {"description": "x"}},
chat_ctx=ctx,
)
@pytest.mark.asyncio
async def test_raises_when_task_not_dict(self, scene):
plan = Plan(instructions="x", tasks=[], status=PlanStatus.ready)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = InsertTask()
with pytest.raises(ActionFailed, match="Task must be a dict"):
await _run_node(
node,
scene,
inputs={"state": {}, "task": "string-not-dict"},
chat_ctx=ctx,
)
@pytest.mark.asyncio
async def test_raises_on_invalid_task_payload(self, scene):
plan = Plan(instructions="x", tasks=[], status=PlanStatus.ready)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = InsertTask()
with pytest.raises(ActionFailed, match="Invalid task"):
await _run_node(
node,
scene,
inputs={
"state": {},
"task": {"description": "x", "type": "not-a-valid-type"},
},
chat_ctx=ctx,
)
@pytest.mark.asyncio
async def test_raises_when_position_unknown_id(self, scene):
plan = Plan(
instructions="x",
tasks=[Task(description="a", order=1)],
status=PlanStatus.ready,
)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = InsertTask()
with pytest.raises(ActionFailed, match="No task with ID"):
await _run_node(
node,
scene,
inputs={
"state": {},
"task": {"description": "x"},
"position": "missing-id",
},
chat_ctx=ctx,
)
# ---------------------------------------------------------------------------
# DeletePlan
# ---------------------------------------------------------------------------
class TestDeletePlanNode:
@pytest.mark.asyncio
async def test_deletes_active_plan(self, scene):
plan = Plan(instructions="x", tasks=[], status=PlanStatus.ready)
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
node = DeletePlan()
out = await _run_node(node, scene, inputs={"state": {}}, chat_ctx=ctx)
assert "Deleted" in out["result"]
assert get_plan(scene, plan.id) is None
# chat_ctx plan_id is cleared
assert ctx.plan_id is None
@pytest.mark.asyncio
async def test_raises_when_no_active_plan(self, scene):
node = DeletePlan()
with pytest.raises(ActionFailed, match="No active plan"):
await _run_node(node, scene, inputs={"state": {}})
# ---------------------------------------------------------------------------
# ExpandStoryArc — only the `no beats` early-out path (LLM path skipped)
# ---------------------------------------------------------------------------
class TestExpandStoryArcEarlyOut:
@pytest.mark.asyncio
async def test_returns_zero_when_no_beats(self, scene):
node = ExpandStoryArc()
out = await _run_node(
node,
scene,
inputs={
"state": {},
"plan_id": "anything",
"beats": [],
"perspective": "Third person",
},
)
assert out["result"] == "No beats to expand"
assert out["word_count"] == 0