mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-05-18 05:05:39 +02:00
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
432 lines
18 KiB
Python
432 lines
18 KiB
Python
"""
|
|
Tests for ``talemate.agents.conversation`` (the ConversationAgent class).
|
|
|
|
Targets:
|
|
- Action registration (``init_actions``) and the property accessors
|
|
(``conversation_format``, ``conversation_format_label``,
|
|
``content_use_*``, ``generation_settings_*``,
|
|
``inject_character_names_into_stop``).
|
|
- ``agent_details`` populated dictionary.
|
|
- ``set_generation_overrides`` writing context attributes when enabled.
|
|
- ``clean_result`` post-processing of LLM output.
|
|
- ``allow_repetition_break`` and ``inject_prompt_paramters``.
|
|
- ``converse`` happy path through ``Prompt.request`` mock for both
|
|
movie_script and narrative formats, including character-name
|
|
prefix handling.
|
|
- ``converse`` raises GenerationCancelled when the LLM returns empty.
|
|
|
|
The full ``build_prompt_default`` pathway is left to template-baseline
|
|
tests; here we patch ``build_prompt`` so converse can be tested end-to-end.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
import talemate.client.context as client_context_module
|
|
from talemate.agents.conversation import (
|
|
ConversationAgent,
|
|
)
|
|
from talemate.character import Character
|
|
from talemate.context import ActiveScene
|
|
from talemate.exceptions import GenerationCancelled
|
|
from talemate.prompts.base import Prompt
|
|
from talemate.scene_message import CharacterMessage
|
|
from talemate.tale_mate import Actor, Player
|
|
|
|
from conftest import MockScene, bootstrap_scene
|
|
|
|
|
|
def _canned_prompt(raw: str, extracted: dict) -> Prompt:
|
|
"""Build a real ``Prompt`` whose ``send`` returns canned LLM output.
|
|
|
|
The function-under-test (``ConversationAgent.converse``) calls
|
|
``await prompt.send(client, kind="conversation")``. We construct a real
|
|
``Prompt`` (a dataclass) and shadow ``send`` on the instance — the
|
|
surrounding contract (the ``Prompt`` class itself, its constructor
|
|
fields, and the ``send`` method's signature) stays anchored to the
|
|
real production type. Renaming ``Prompt`` or removing ``.send`` on the
|
|
real class fails the test instead of being papered over.
|
|
"""
|
|
prompt = Prompt(
|
|
uid="conversation.test",
|
|
agent_type="conversation",
|
|
name="test",
|
|
vars={},
|
|
)
|
|
|
|
async def _send(client, kind, **kwargs):
|
|
return raw, extracted
|
|
|
|
prompt.send = _send # type: ignore[method-assign]
|
|
return prompt
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def alice():
|
|
return Character(name="Alice", description="Test character.", is_player=False)
|
|
|
|
|
|
@pytest.fixture
|
|
def bob_player():
|
|
return Character(name="Bob", description="Player.", is_player=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def conversation_scene(alice, bob_player):
|
|
"""Bootstrapped MockScene with conversation agent + actors."""
|
|
scene = MockScene()
|
|
agents = bootstrap_scene(scene)
|
|
conversation = agents["conversation"]
|
|
|
|
# Wire actor/character relationships so that scene.character_names
|
|
# and actor.character.actor.scene work.
|
|
alice_actor = Actor(character=alice, agent=conversation)
|
|
alice_actor.scene = scene
|
|
scene.actors.append(alice_actor)
|
|
scene.active_characters.append(alice.name)
|
|
scene.character_data[alice.name] = alice
|
|
|
|
bob_actor = Player(character=bob_player, agent=None)
|
|
bob_actor.scene = scene
|
|
scene.actors.append(bob_actor)
|
|
scene.active_characters.append(bob_player.name)
|
|
scene.character_data[bob_player.name] = bob_player
|
|
|
|
with ActiveScene(scene):
|
|
yield scene, conversation, alice_actor
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action registration and properties
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestActionRegistration:
|
|
def test_actions_present(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
for key in ["generation_override", "content"]:
|
|
assert key in conversation.actions
|
|
|
|
def test_init_actions_returns_full_set(self):
|
|
actions = ConversationAgent.init_actions()
|
|
assert "generation_override" in actions
|
|
assert "content" in actions
|
|
assert "prompt_caching" in actions
|
|
|
|
def test_default_format_is_movie_script(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
assert conversation.conversation_format == "movie_script"
|
|
|
|
def test_format_returns_movie_script_when_override_disabled(
|
|
self, conversation_scene
|
|
):
|
|
_, conversation, _ = conversation_scene
|
|
# Override config value first, then disable. Should fall back to
|
|
# the movie_script default.
|
|
conversation.actions["generation_override"].config["format"].value = "chat"
|
|
conversation.actions["generation_override"].enabled = False
|
|
assert conversation.conversation_format == "movie_script"
|
|
|
|
def test_format_returns_value_when_override_enabled(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
conversation.actions["generation_override"].enabled = True
|
|
conversation.actions["generation_override"].config["format"].value = "chat"
|
|
assert conversation.conversation_format == "chat"
|
|
|
|
def test_format_label_resolves_known_value(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
conversation.actions["generation_override"].config["format"].value = "chat"
|
|
# The choices contain a label "Chat (legacy)" for value "chat".
|
|
assert conversation.conversation_format_label == "Chat (legacy)"
|
|
|
|
def test_format_label_falls_back_to_value_when_unknown(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
# Inject an unknown value and confirm the label is the raw value.
|
|
conversation.actions["generation_override"].config["format"].value = "weird"
|
|
assert conversation.conversation_format_label == "weird"
|
|
|
|
def test_generation_setting_properties(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
gen_override = conversation.actions["generation_override"]
|
|
|
|
gen_override.config["instructions"].value = "be cool"
|
|
gen_override.config["actor_instructions"].value = "act"
|
|
gen_override.config["actor_instructions_offset"].value = 5
|
|
gen_override.config["length"].value = 256
|
|
gen_override.config["inject_character_names_into_stop"].value = False
|
|
|
|
assert conversation.generation_settings_task_instructions == "be cool"
|
|
assert conversation.generation_settings_actor_instructions == "act"
|
|
assert conversation.generation_settings_actor_instructions_offset == 5
|
|
assert conversation.generation_settings_response_length == 256
|
|
assert conversation.inject_character_names_into_stop is False
|
|
assert conversation.generation_settings_override_enabled is True
|
|
|
|
def test_content_properties(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
conversation.actions["content"].config["use_scene_intent"].value = False
|
|
conversation.actions["content"].config["use_writing_style"].value = False
|
|
assert conversation.content_use_scene_intent is False
|
|
assert conversation.content_use_writing_style is False
|
|
conversation.actions["content"].config["use_scene_intent"].value = True
|
|
conversation.actions["content"].config["use_writing_style"].value = True
|
|
assert conversation.content_use_scene_intent is True
|
|
assert conversation.content_use_writing_style is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# agent_details
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAgentDetails:
|
|
def test_includes_client_and_format(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
details = conversation.agent_details
|
|
assert "client" in details
|
|
assert "format" in details
|
|
assert details["client"]["value"] == conversation.client.name
|
|
# Format default is movie_script -> label is "Screenplay".
|
|
assert details["format"]["value"] == "Screenplay"
|
|
|
|
def test_no_client_yields_none_value(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
conversation.client = None
|
|
details = conversation.agent_details
|
|
assert details["client"]["value"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# set_generation_overrides
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSetGenerationOverrides:
|
|
def test_does_nothing_when_disabled(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
conversation.actions["generation_override"].enabled = False
|
|
# Should not raise even without a client_context active.
|
|
conversation.set_generation_overrides()
|
|
|
|
def test_writes_length_to_conversation_context(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
conversation.actions["generation_override"].enabled = True
|
|
conversation.actions["generation_override"].config["length"].value = 333
|
|
|
|
with client_context_module.ClientContext():
|
|
conversation.set_generation_overrides()
|
|
# ``set_conversation_context_attribute`` writes through the
|
|
# nested "conversation" dict.
|
|
data = client_context_module.context_data.get()
|
|
assert data["conversation"]["length"] == 333
|
|
|
|
def test_jiggle_sets_nuke_repetition(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
conversation.actions["generation_override"].enabled = True
|
|
conversation.actions["generation_override"].config["jiggle"].value = 0.5
|
|
|
|
with client_context_module.ClientContext():
|
|
# Default nuke_repetition is 0.0 -> override applied.
|
|
conversation.set_generation_overrides()
|
|
assert (
|
|
client_context_module.client_context_attribute("nuke_repetition") == 0.5
|
|
)
|
|
|
|
def test_jiggle_skipped_when_existing_nuke_repetition(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
conversation.actions["generation_override"].enabled = True
|
|
conversation.actions["generation_override"].config["jiggle"].value = 0.5
|
|
|
|
with client_context_module.ClientContext(nuke_repetition=0.7):
|
|
conversation.set_generation_overrides()
|
|
# Existing nuke_repetition is preserved.
|
|
assert (
|
|
client_context_module.client_context_attribute("nuke_repetition") == 0.7
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# clean_result
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCleanResult:
|
|
def test_empty_input_returns_empty(self, conversation_scene, alice):
|
|
_, conversation, _ = conversation_scene
|
|
assert conversation.clean_result("", alice) == ""
|
|
assert conversation.clean_result(None, alice) == ""
|
|
|
|
def test_strips_after_hash(self, conversation_scene, alice):
|
|
_, conversation, _ = conversation_scene
|
|
result = conversation.clean_result("hello # internal", alice)
|
|
assert "internal" not in result
|
|
assert "hello" in result
|
|
|
|
def test_strips_after_internal_marker(self, conversation_scene, alice):
|
|
_, conversation, _ = conversation_scene
|
|
result = conversation.clean_result(
|
|
"real dialogue (Internal: thinking thoughts)", alice
|
|
)
|
|
assert "Internal" not in result
|
|
assert "real dialogue" in result
|
|
|
|
def test_collapses_space_colon(self, conversation_scene, alice):
|
|
_, conversation, _ = conversation_scene
|
|
result = conversation.clean_result("Alice : hi there", alice)
|
|
assert "Alice:" in result
|
|
assert "Alice :" not in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# allow_repetition_break / inject_prompt_paramters
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAllowRepetitionBreakAndInject:
|
|
def test_allow_repetition_break_only_for_converse(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
assert conversation.allow_repetition_break("conversation", "converse") is True
|
|
assert (
|
|
conversation.allow_repetition_break("conversation", "build_prompt") is False
|
|
)
|
|
|
|
def test_inject_prompt_parameters_appends_hash(self, conversation_scene):
|
|
_, conversation, _ = conversation_scene
|
|
params = {}
|
|
conversation.inject_prompt_paramters(params, "conversation", "converse")
|
|
# When inject_character_names_into_stop is True (default), the
|
|
# function still wraps with extra_stopping_strings = [], then adds "#".
|
|
assert params.get("extra_stopping_strings", []) == ["#"]
|
|
|
|
def test_inject_prompt_parameters_resets_when_inject_disabled(
|
|
self, conversation_scene
|
|
):
|
|
_, conversation, _ = conversation_scene
|
|
conversation.actions["generation_override"].config[
|
|
"inject_character_names_into_stop"
|
|
].value = False
|
|
params = {"extra_stopping_strings": ["EXISTING"]}
|
|
conversation.inject_prompt_paramters(params, "conversation", "converse")
|
|
# The function resets to [] when inject_character_names_into_stop is
|
|
# False, and then appends "#".
|
|
assert params["extra_stopping_strings"] == ["#"]
|
|
|
|
def test_inject_prompt_parameters_preserves_existing_with_inject_enabled(
|
|
self, conversation_scene
|
|
):
|
|
_, conversation, _ = conversation_scene
|
|
# When inject_character_names_into_stop is True AND extra_stopping_strings
|
|
# is already a list, it is preserved (existing list + "#").
|
|
conversation.actions["generation_override"].config[
|
|
"inject_character_names_into_stop"
|
|
].value = True
|
|
params = {"extra_stopping_strings": ["EXISTING"]}
|
|
conversation.inject_prompt_paramters(params, "conversation", "converse")
|
|
assert params["extra_stopping_strings"] == ["EXISTING", "#"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# converse — patched build_prompt + LLM client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConverse:
|
|
async def test_converse_movie_script_format(self, conversation_scene, alice):
|
|
scene, conversation, alice_actor = conversation_scene
|
|
conversation.actions["generation_override"].enabled = True
|
|
conversation.actions["generation_override"].config[
|
|
"format"
|
|
].value = "movie_script"
|
|
|
|
# Stub the agent's own build_prompt to skip the templating pipeline.
|
|
# Returns a real Prompt instance whose `send` is overridden to deliver
|
|
# canned LLM output — the contract still flows through the real Prompt
|
|
# class.
|
|
async def fake_build_prompt(character, char_message="", instruction=None):
|
|
return _canned_prompt("raw response", {"response": "Alice:Hello there!"})
|
|
|
|
conversation.build_prompt = fake_build_prompt
|
|
|
|
result = await conversation.converse(alice_actor)
|
|
assert isinstance(result, list)
|
|
assert len(result) == 1
|
|
msg = result[0]
|
|
assert isinstance(msg, CharacterMessage)
|
|
# Movie-script format strips a leading "ALICE\n" pattern; result
|
|
# should still be prefixed with "Alice: " by the converse path.
|
|
assert msg.message.startswith("Alice: ")
|
|
assert "Hello there" in msg.message
|
|
|
|
async def test_converse_narrative_format(self, conversation_scene, alice):
|
|
scene, conversation, alice_actor = conversation_scene
|
|
conversation.actions["generation_override"].enabled = True
|
|
conversation.actions["generation_override"].config["format"].value = "narrative"
|
|
|
|
async def fake_build_prompt(character, char_message="", instruction=None):
|
|
return _canned_prompt(
|
|
"She walked over and smiled.",
|
|
{"response": "She walked over and smiled."},
|
|
)
|
|
|
|
conversation.build_prompt = fake_build_prompt
|
|
result = await conversation.converse(alice_actor)
|
|
assert len(result) == 1
|
|
msg = result[0]
|
|
# Narrative format: prepend character name when not already present.
|
|
assert msg.message.startswith("Alice: ")
|
|
assert "She walked over and smiled" in msg.message
|
|
|
|
async def test_converse_strips_uppercase_name_prefix(
|
|
self, conversation_scene, alice
|
|
):
|
|
scene, conversation, alice_actor = conversation_scene
|
|
conversation.actions["generation_override"].enabled = True
|
|
conversation.actions["generation_override"].config[
|
|
"format"
|
|
].value = "movie_script"
|
|
|
|
async def fake_build_prompt(character, char_message="", instruction=None):
|
|
return _canned_prompt(
|
|
"ALICE\nBack soon.", {"response": "ALICE\nBack soon."}
|
|
)
|
|
|
|
conversation.build_prompt = fake_build_prompt
|
|
result = await conversation.converse(alice_actor)
|
|
msg = result[0]
|
|
# The "ALICE\n" prefix is stripped, then "Alice: " is prepended.
|
|
assert msg.message.startswith("Alice: ")
|
|
assert "ALICE\n" not in msg.message
|
|
assert "Back soon" in msg.message
|
|
|
|
async def test_converse_empty_response_raises(self, conversation_scene, alice):
|
|
scene, conversation, alice_actor = conversation_scene
|
|
conversation.actions["generation_override"].enabled = True
|
|
|
|
async def fake_build_prompt(character, char_message="", instruction=None):
|
|
return _canned_prompt("", {"response": ""})
|
|
|
|
conversation.build_prompt = fake_build_prompt
|
|
# The empty-response handler raises GenerationCancelled.
|
|
with pytest.raises(GenerationCancelled):
|
|
await conversation.converse(alice_actor)
|
|
|
|
async def test_converse_passes_avatar_through_emission(
|
|
self, conversation_scene, alice
|
|
):
|
|
scene, conversation, alice_actor = conversation_scene
|
|
alice.current_avatar = "default-avatar.png"
|
|
|
|
async def fake_build_prompt(character, char_message="", instruction=None):
|
|
return _canned_prompt("Hi!", {"response": "Hi!"})
|
|
|
|
conversation.build_prompt = fake_build_prompt
|
|
messages = await conversation.converse(alice_actor)
|
|
msg = messages[0]
|
|
# Falls back to the character's current avatar.
|
|
assert msg.asset_id == "default-avatar.png"
|
|
assert msg.asset_type == "avatar"
|