Files
talemate/tests/test_focal.py

707 lines
22 KiB
Python
Raw Normal View History

0.36.0 (#255) Major Features - API key encryption at rest using Fernet (OS keyring with file fallback) - Prompt Manager: unified UI with template groups, priority ordering, override tracking, response extractors - Scene context history review panel with token budgets and best-fit mode - Multiple concurrent director chats with auto-generated titles - Granular scene state reset dialog - Time passage insert/edit/delete in scene view - Image analysis via OpenAI-compatible and Talemate Client backends - Volatile context placement after scene history for improved prompt caching Improvements - Configurable narrator generation length per narration type - AI Aware conversation mode - Summarizer: custom instructions, writing style inclusion, short line filtering - Anthropic: adaptive thinking support, updated model list (opus-4-5/4-6, haiku-4-5) - Google: gemini-3.1 support - World editor: generate from topic, quick create state reinforcement, reorganized menus - Node editor: promote scene modules to global - Frontend: version mismatch detection, hideable bracket content, required scene name - TTS: improved pause handling, audio tag support for vocal markers (ElevenLabs v3) - Writing style template for AI-generated instructions - Added Kimi.jinja2 LLM prompt template - Option to disable character names in stopping strings - Client response length enforcement options - Graduated token count sliders - Increased summarizer token threshold max Bugfixes - Fix bracket/paren/brace terminators stripped from message ends - Fix colon in conversation causing content loss - Fix "Use as reference" navigating to blank page - Fix avatar regeneration and manual regenerate - Fix conversation agent ignoring generation length - Fix duplicate length instructions with reasoning enabled - Fix trailing newline on message edits - Fix summarize dialogue sending too much context with layered history - Fix layered history inspection and construction issues - Fix empty response handling in summarization - Fix context ID dot notation with dotted character names - Fix recursive retry in focal agent - Fix leading whitespace causing duplicate prepared responses - Fix summarization not stripping ANALYSIS OF lines - Fix template group selection/removal in prompt manager - Fix multiline text in parentheses/brackets parser - Fix determine_character_name resolution - Fix character activate/deactivate desyncing creative menu - Fix character image generation missing context - Fix LMStudio client not sending token limits - Fix Recent Scene images on newer Chromium - Fix sequential reinforcement messages cut off at first linebreak - Fix reinforcement removal not clearing state - Fixes #252, #256, #258 Deprecations - Removed context investigations (replaced by AI-assisted RAG mixin) - Removed deprecated prompt templates (fix-continuity-errors, fix-exposition, etc.) - Removed conversation/edit.jinja2, auto break repetition, CLI reset layered history --------- Co-authored-by: theDTV2 <47825738+theDTV2@users.noreply.github.com>
2026-03-15 12:00:57 +02:00
"""
Tests for the focal system's execution logic.
Tests _execute() directly using real Focal/Callback/Call/State objects
plus a real `DirectorAgent` (with a stubbed `log_function_call`) and a
real `ClientBase` subclass. LLM client is not driven during these tests
0.36.0 (#255) Major Features - API key encryption at rest using Fernet (OS keyring with file fallback) - Prompt Manager: unified UI with template groups, priority ordering, override tracking, response extractors - Scene context history review panel with token budgets and best-fit mode - Multiple concurrent director chats with auto-generated titles - Granular scene state reset dialog - Time passage insert/edit/delete in scene view - Image analysis via OpenAI-compatible and Talemate Client backends - Volatile context placement after scene history for improved prompt caching Improvements - Configurable narrator generation length per narration type - AI Aware conversation mode - Summarizer: custom instructions, writing style inclusion, short line filtering - Anthropic: adaptive thinking support, updated model list (opus-4-5/4-6, haiku-4-5) - Google: gemini-3.1 support - World editor: generate from topic, quick create state reinforcement, reorganized menus - Node editor: promote scene modules to global - Frontend: version mismatch detection, hideable bracket content, required scene name - TTS: improved pause handling, audio tag support for vocal markers (ElevenLabs v3) - Writing style template for AI-generated instructions - Added Kimi.jinja2 LLM prompt template - Option to disable character names in stopping strings - Client response length enforcement options - Graduated token count sliders - Increased summarizer token threshold max Bugfixes - Fix bracket/paren/brace terminators stripped from message ends - Fix colon in conversation causing content loss - Fix "Use as reference" navigating to blank page - Fix avatar regeneration and manual regenerate - Fix conversation agent ignoring generation length - Fix duplicate length instructions with reasoning enabled - Fix trailing newline on message edits - Fix summarize dialogue sending too much context with layered history - Fix layered history inspection and construction issues - Fix empty response handling in summarization - Fix context ID dot notation with dotted character names - Fix recursive retry in focal agent - Fix leading whitespace causing duplicate prepared responses - Fix summarization not stripping ANALYSIS OF lines - Fix template group selection/removal in prompt manager - Fix multiline text in parentheses/brackets parser - Fix determine_character_name resolution - Fix character activate/deactivate desyncing creative menu - Fix character image generation missing context - Fix LMStudio client not sending token limits - Fix Recent Scene images on newer Chromium - Fix sequential reinforcement messages cut off at first linebreak - Fix reinforcement removal not clearing state - Fixes #252, #256, #258 Deprecations - Removed context investigations (replaced by AI-assisted RAG mixin) - Removed deprecated prompt templates (fix-continuity-errors, fix-exposition, etc.) - Removed conversation/edit.jinja2, auto break repetition, CLI reset layered history --------- Co-authored-by: theDTV2 <47825738+theDTV2@users.noreply.github.com>
2026-03-15 12:00:57 +02:00
since we feed well-formed responses that _extract() can parse natively.
"""
import asyncio
import json
import pytest
from unittest.mock import AsyncMock
from conftest import MockClient
0.36.0 (#255) Major Features - API key encryption at rest using Fernet (OS keyring with file fallback) - Prompt Manager: unified UI with template groups, priority ordering, override tracking, response extractors - Scene context history review panel with token budgets and best-fit mode - Multiple concurrent director chats with auto-generated titles - Granular scene state reset dialog - Time passage insert/edit/delete in scene view - Image analysis via OpenAI-compatible and Talemate Client backends - Volatile context placement after scene history for improved prompt caching Improvements - Configurable narrator generation length per narration type - AI Aware conversation mode - Summarizer: custom instructions, writing style inclusion, short line filtering - Anthropic: adaptive thinking support, updated model list (opus-4-5/4-6, haiku-4-5) - Google: gemini-3.1 support - World editor: generate from topic, quick create state reinforcement, reorganized menus - Node editor: promote scene modules to global - Frontend: version mismatch detection, hideable bracket content, required scene name - TTS: improved pause handling, audio tag support for vocal markers (ElevenLabs v3) - Writing style template for AI-generated instructions - Added Kimi.jinja2 LLM prompt template - Option to disable character names in stopping strings - Client response length enforcement options - Graduated token count sliders - Increased summarizer token threshold max Bugfixes - Fix bracket/paren/brace terminators stripped from message ends - Fix colon in conversation causing content loss - Fix "Use as reference" navigating to blank page - Fix avatar regeneration and manual regenerate - Fix conversation agent ignoring generation length - Fix duplicate length instructions with reasoning enabled - Fix trailing newline on message edits - Fix summarize dialogue sending too much context with layered history - Fix layered history inspection and construction issues - Fix empty response handling in summarization - Fix context ID dot notation with dotted character names - Fix recursive retry in focal agent - Fix leading whitespace causing duplicate prepared responses - Fix summarization not stripping ANALYSIS OF lines - Fix template group selection/removal in prompt manager - Fix multiline text in parentheses/brackets parser - Fix determine_character_name resolution - Fix character activate/deactivate desyncing creative menu - Fix character image generation missing context - Fix LMStudio client not sending token limits - Fix Recent Scene images on newer Chromium - Fix sequential reinforcement messages cut off at first linebreak - Fix reinforcement removal not clearing state - Fixes #252, #256, #258 Deprecations - Removed context investigations (replaced by AI-assisted RAG mixin) - Removed deprecated prompt templates (fix-continuity-errors, fix-exposition, etc.) - Removed conversation/edit.jinja2, auto break repetition, CLI reset layered history --------- Co-authored-by: theDTV2 <47825738+theDTV2@users.noreply.github.com>
2026-03-15 12:00:57 +02:00
import talemate.instance as instance
from talemate.agents.director import DirectorAgent
0.36.0 (#255) Major Features - API key encryption at rest using Fernet (OS keyring with file fallback) - Prompt Manager: unified UI with template groups, priority ordering, override tracking, response extractors - Scene context history review panel with token budgets and best-fit mode - Multiple concurrent director chats with auto-generated titles - Granular scene state reset dialog - Time passage insert/edit/delete in scene view - Image analysis via OpenAI-compatible and Talemate Client backends - Volatile context placement after scene history for improved prompt caching Improvements - Configurable narrator generation length per narration type - AI Aware conversation mode - Summarizer: custom instructions, writing style inclusion, short line filtering - Anthropic: adaptive thinking support, updated model list (opus-4-5/4-6, haiku-4-5) - Google: gemini-3.1 support - World editor: generate from topic, quick create state reinforcement, reorganized menus - Node editor: promote scene modules to global - Frontend: version mismatch detection, hideable bracket content, required scene name - TTS: improved pause handling, audio tag support for vocal markers (ElevenLabs v3) - Writing style template for AI-generated instructions - Added Kimi.jinja2 LLM prompt template - Option to disable character names in stopping strings - Client response length enforcement options - Graduated token count sliders - Increased summarizer token threshold max Bugfixes - Fix bracket/paren/brace terminators stripped from message ends - Fix colon in conversation causing content loss - Fix "Use as reference" navigating to blank page - Fix avatar regeneration and manual regenerate - Fix conversation agent ignoring generation length - Fix duplicate length instructions with reasoning enabled - Fix trailing newline on message edits - Fix summarize dialogue sending too much context with layered history - Fix layered history inspection and construction issues - Fix empty response handling in summarization - Fix context ID dot notation with dotted character names - Fix recursive retry in focal agent - Fix leading whitespace causing duplicate prepared responses - Fix summarization not stripping ANALYSIS OF lines - Fix template group selection/removal in prompt manager - Fix multiline text in parentheses/brackets parser - Fix determine_character_name resolution - Fix character activate/deactivate desyncing creative menu - Fix character image generation missing context - Fix LMStudio client not sending token limits - Fix Recent Scene images on newer Chromium - Fix sequential reinforcement messages cut off at first linebreak - Fix reinforcement removal not clearing state - Fixes #252, #256, #258 Deprecations - Removed context investigations (replaced by AI-assisted RAG mixin) - Removed deprecated prompt templates (fix-continuity-errors, fix-exposition, etc.) - Removed conversation/edit.jinja2, auto break repetition, CLI reset layered history --------- Co-authored-by: theDTV2 <47825738+theDTV2@users.noreply.github.com>
2026-03-15 12:00:57 +02:00
from talemate.game.focal import (
Focal,
FocalContext,
collect_calls,
)
from talemate.game.focal.schema import Argument, Call, Callback, State
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_callback(
name: str, fn=None, multiple: bool = True, concurrent: bool = False
) -> Callback:
"""Create a Callback with a simple async fn that records invocations."""
if fn is None:
async def fn(**kwargs):
return kwargs
return Callback(
name=name,
arguments=[
Argument(name="text", type="str"),
],
fn=fn,
multiple=multiple,
concurrent=concurrent,
)
def make_response(*calls: dict) -> str:
"""Build a well-formed JSON response that _extract() parses natively."""
blocks = []
for call in calls:
blocks.append(f"```json\n{json.dumps(call)}\n```")
return "\n".join(blocks)
def make_call_dict(name: str, **arguments) -> dict:
"""Shortcut for building a call dict."""
return {"function": name, "arguments": arguments}
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def setup_director():
"""Register a real `DirectorAgent` in the agent registry.
`log_function_call` is stubbed because it's a peripheral RPC the unit
under test (`Focal._execute`) happens to call Focal's behaviour does
not depend on the log side-effect, only on whether it was invoked.
"""
director = DirectorAgent()
0.36.0 (#255) Major Features - API key encryption at rest using Fernet (OS keyring with file fallback) - Prompt Manager: unified UI with template groups, priority ordering, override tracking, response extractors - Scene context history review panel with token budgets and best-fit mode - Multiple concurrent director chats with auto-generated titles - Granular scene state reset dialog - Time passage insert/edit/delete in scene view - Image analysis via OpenAI-compatible and Talemate Client backends - Volatile context placement after scene history for improved prompt caching Improvements - Configurable narrator generation length per narration type - AI Aware conversation mode - Summarizer: custom instructions, writing style inclusion, short line filtering - Anthropic: adaptive thinking support, updated model list (opus-4-5/4-6, haiku-4-5) - Google: gemini-3.1 support - World editor: generate from topic, quick create state reinforcement, reorganized menus - Node editor: promote scene modules to global - Frontend: version mismatch detection, hideable bracket content, required scene name - TTS: improved pause handling, audio tag support for vocal markers (ElevenLabs v3) - Writing style template for AI-generated instructions - Added Kimi.jinja2 LLM prompt template - Option to disable character names in stopping strings - Client response length enforcement options - Graduated token count sliders - Increased summarizer token threshold max Bugfixes - Fix bracket/paren/brace terminators stripped from message ends - Fix colon in conversation causing content loss - Fix "Use as reference" navigating to blank page - Fix avatar regeneration and manual regenerate - Fix conversation agent ignoring generation length - Fix duplicate length instructions with reasoning enabled - Fix trailing newline on message edits - Fix summarize dialogue sending too much context with layered history - Fix layered history inspection and construction issues - Fix empty response handling in summarization - Fix context ID dot notation with dotted character names - Fix recursive retry in focal agent - Fix leading whitespace causing duplicate prepared responses - Fix summarization not stripping ANALYSIS OF lines - Fix template group selection/removal in prompt manager - Fix multiline text in parentheses/brackets parser - Fix determine_character_name resolution - Fix character activate/deactivate desyncing creative menu - Fix character image generation missing context - Fix LMStudio client not sending token limits - Fix Recent Scene images on newer Chromium - Fix sequential reinforcement messages cut off at first linebreak - Fix reinforcement removal not clearing state - Fixes #252, #256, #258 Deprecations - Removed context investigations (replaced by AI-assisted RAG mixin) - Removed deprecated prompt templates (fix-continuity-errors, fix-exposition, etc.) - Removed conversation/edit.jinja2, auto break repetition, CLI reset layered history --------- Co-authored-by: theDTV2 <47825738+theDTV2@users.noreply.github.com>
2026-03-15 12:00:57 +02:00
director.log_function_call = AsyncMock()
original = instance.AGENTS.copy()
instance.AGENTS["director"] = director
yield director
0.36.0 (#255) Major Features - API key encryption at rest using Fernet (OS keyring with file fallback) - Prompt Manager: unified UI with template groups, priority ordering, override tracking, response extractors - Scene context history review panel with token budgets and best-fit mode - Multiple concurrent director chats with auto-generated titles - Granular scene state reset dialog - Time passage insert/edit/delete in scene view - Image analysis via OpenAI-compatible and Talemate Client backends - Volatile context placement after scene history for improved prompt caching Improvements - Configurable narrator generation length per narration type - AI Aware conversation mode - Summarizer: custom instructions, writing style inclusion, short line filtering - Anthropic: adaptive thinking support, updated model list (opus-4-5/4-6, haiku-4-5) - Google: gemini-3.1 support - World editor: generate from topic, quick create state reinforcement, reorganized menus - Node editor: promote scene modules to global - Frontend: version mismatch detection, hideable bracket content, required scene name - TTS: improved pause handling, audio tag support for vocal markers (ElevenLabs v3) - Writing style template for AI-generated instructions - Added Kimi.jinja2 LLM prompt template - Option to disable character names in stopping strings - Client response length enforcement options - Graduated token count sliders - Increased summarizer token threshold max Bugfixes - Fix bracket/paren/brace terminators stripped from message ends - Fix colon in conversation causing content loss - Fix "Use as reference" navigating to blank page - Fix avatar regeneration and manual regenerate - Fix conversation agent ignoring generation length - Fix duplicate length instructions with reasoning enabled - Fix trailing newline on message edits - Fix summarize dialogue sending too much context with layered history - Fix layered history inspection and construction issues - Fix empty response handling in summarization - Fix context ID dot notation with dotted character names - Fix recursive retry in focal agent - Fix leading whitespace causing duplicate prepared responses - Fix summarization not stripping ANALYSIS OF lines - Fix template group selection/removal in prompt manager - Fix multiline text in parentheses/brackets parser - Fix determine_character_name resolution - Fix character activate/deactivate desyncing creative menu - Fix character image generation missing context - Fix LMStudio client not sending token limits - Fix Recent Scene images on newer Chromium - Fix sequential reinforcement messages cut off at first linebreak - Fix reinforcement removal not clearing state - Fixes #252, #256, #258 Deprecations - Removed context investigations (replaced by AI-assisted RAG mixin) - Removed deprecated prompt templates (fix-continuity-errors, fix-exposition, etc.) - Removed conversation/edit.jinja2, auto break repetition, CLI reset layered history --------- Co-authored-by: theDTV2 <47825738+theDTV2@users.noreply.github.com>
2026-03-15 12:00:57 +02:00
instance.AGENTS.clear()
instance.AGENTS.update(original)
class _SequentialClient(MockClient):
"""Real ClientBase subclass used in non-concurrent Focal tests.
Overrides only the two properties Focal reads `data_format` and
`supports_concurrent_inference`. Everything else is the real ClientBase
contract.
"""
@property
def data_format(self):
return "json"
@property
def supports_concurrent_inference(self):
return False
class _ConcurrentClient(_SequentialClient):
"""Variant that opts in to concurrent inference."""
@property
def supports_concurrent_inference(self):
return True
0.36.0 (#255) Major Features - API key encryption at rest using Fernet (OS keyring with file fallback) - Prompt Manager: unified UI with template groups, priority ordering, override tracking, response extractors - Scene context history review panel with token budgets and best-fit mode - Multiple concurrent director chats with auto-generated titles - Granular scene state reset dialog - Time passage insert/edit/delete in scene view - Image analysis via OpenAI-compatible and Talemate Client backends - Volatile context placement after scene history for improved prompt caching Improvements - Configurable narrator generation length per narration type - AI Aware conversation mode - Summarizer: custom instructions, writing style inclusion, short line filtering - Anthropic: adaptive thinking support, updated model list (opus-4-5/4-6, haiku-4-5) - Google: gemini-3.1 support - World editor: generate from topic, quick create state reinforcement, reorganized menus - Node editor: promote scene modules to global - Frontend: version mismatch detection, hideable bracket content, required scene name - TTS: improved pause handling, audio tag support for vocal markers (ElevenLabs v3) - Writing style template for AI-generated instructions - Added Kimi.jinja2 LLM prompt template - Option to disable character names in stopping strings - Client response length enforcement options - Graduated token count sliders - Increased summarizer token threshold max Bugfixes - Fix bracket/paren/brace terminators stripped from message ends - Fix colon in conversation causing content loss - Fix "Use as reference" navigating to blank page - Fix avatar regeneration and manual regenerate - Fix conversation agent ignoring generation length - Fix duplicate length instructions with reasoning enabled - Fix trailing newline on message edits - Fix summarize dialogue sending too much context with layered history - Fix layered history inspection and construction issues - Fix empty response handling in summarization - Fix context ID dot notation with dotted character names - Fix recursive retry in focal agent - Fix leading whitespace causing duplicate prepared responses - Fix summarization not stripping ANALYSIS OF lines - Fix template group selection/removal in prompt manager - Fix multiline text in parentheses/brackets parser - Fix determine_character_name resolution - Fix character activate/deactivate desyncing creative menu - Fix character image generation missing context - Fix LMStudio client not sending token limits - Fix Recent Scene images on newer Chromium - Fix sequential reinforcement messages cut off at first linebreak - Fix reinforcement removal not clearing state - Fixes #252, #256, #258 Deprecations - Removed context investigations (replaced by AI-assisted RAG mixin) - Removed deprecated prompt templates (fix-continuity-errors, fix-exposition, etc.) - Removed conversation/edit.jinja2, auto break repetition, CLI reset layered history --------- Co-authored-by: theDTV2 <47825738+theDTV2@users.noreply.github.com>
2026-03-15 12:00:57 +02:00
@pytest.fixture
def mock_client():
return _SequentialClient("focal_sequential")
0.36.0 (#255) Major Features - API key encryption at rest using Fernet (OS keyring with file fallback) - Prompt Manager: unified UI with template groups, priority ordering, override tracking, response extractors - Scene context history review panel with token budgets and best-fit mode - Multiple concurrent director chats with auto-generated titles - Granular scene state reset dialog - Time passage insert/edit/delete in scene view - Image analysis via OpenAI-compatible and Talemate Client backends - Volatile context placement after scene history for improved prompt caching Improvements - Configurable narrator generation length per narration type - AI Aware conversation mode - Summarizer: custom instructions, writing style inclusion, short line filtering - Anthropic: adaptive thinking support, updated model list (opus-4-5/4-6, haiku-4-5) - Google: gemini-3.1 support - World editor: generate from topic, quick create state reinforcement, reorganized menus - Node editor: promote scene modules to global - Frontend: version mismatch detection, hideable bracket content, required scene name - TTS: improved pause handling, audio tag support for vocal markers (ElevenLabs v3) - Writing style template for AI-generated instructions - Added Kimi.jinja2 LLM prompt template - Option to disable character names in stopping strings - Client response length enforcement options - Graduated token count sliders - Increased summarizer token threshold max Bugfixes - Fix bracket/paren/brace terminators stripped from message ends - Fix colon in conversation causing content loss - Fix "Use as reference" navigating to blank page - Fix avatar regeneration and manual regenerate - Fix conversation agent ignoring generation length - Fix duplicate length instructions with reasoning enabled - Fix trailing newline on message edits - Fix summarize dialogue sending too much context with layered history - Fix layered history inspection and construction issues - Fix empty response handling in summarization - Fix context ID dot notation with dotted character names - Fix recursive retry in focal agent - Fix leading whitespace causing duplicate prepared responses - Fix summarization not stripping ANALYSIS OF lines - Fix template group selection/removal in prompt manager - Fix multiline text in parentheses/brackets parser - Fix determine_character_name resolution - Fix character activate/deactivate desyncing creative menu - Fix character image generation missing context - Fix LMStudio client not sending token limits - Fix Recent Scene images on newer Chromium - Fix sequential reinforcement messages cut off at first linebreak - Fix reinforcement removal not clearing state - Fixes #252, #256, #258 Deprecations - Removed context investigations (replaced by AI-assisted RAG mixin) - Removed deprecated prompt templates (fix-continuity-errors, fix-exposition, etc.) - Removed conversation/edit.jinja2, auto break repetition, CLI reset layered history --------- Co-authored-by: theDTV2 <47825738+theDTV2@users.noreply.github.com>
2026-03-15 12:00:57 +02:00
# ---------------------------------------------------------------------------
# Phase 1: Tests for existing _execute() behaviour
# ---------------------------------------------------------------------------
class TestExecuteSequential:
"""Tests for the current sequential execution in Focal._execute()."""
@pytest.mark.asyncio
async def test_sequential_execution_order(self, mock_client, setup_director):
"""Three callbacks are executed in the order they appear in the response."""
order = []
async def fn_a(text):
order.append("a")
return "result_a"
async def fn_b(text):
order.append("b")
return "result_b"
async def fn_c(text):
order.append("c")
return "result_c"
focal = Focal(
client=mock_client,
callbacks=[
make_callback("a", fn=fn_a),
make_callback("b", fn=fn_b),
make_callback("c", fn=fn_c),
],
max_calls=10,
)
response = make_response(
make_call_dict("a", text="1"),
make_call_dict("b", text="2"),
make_call_dict("c", text="3"),
)
await focal._execute(response, State())
assert order == ["a", "b", "c"]
assert len(focal.state.calls) == 3
assert [c.result for c in focal.state.calls] == [
"result_a",
"result_b",
"result_c",
]
@pytest.mark.asyncio
async def test_max_calls_respected(self, mock_client, setup_director):
"""Only max_calls callbacks are executed even if more are extracted."""
call_count = 0
async def counting_fn(text):
nonlocal call_count
call_count += 1
return "ok"
focal = Focal(
client=mock_client,
callbacks=[make_callback("action", fn=counting_fn)],
max_calls=2,
)
response = make_response(
make_call_dict("action", text="1"),
make_call_dict("action", text="2"),
make_call_dict("action", text="3"),
make_call_dict("action", text="4"),
make_call_dict("action", text="5"),
)
await focal._execute(response, State())
assert call_count == 2
assert len(focal.state.calls) == 2
@pytest.mark.asyncio
async def test_unknown_callback_skipped(self, mock_client, setup_director):
"""Calls referencing unknown callbacks are skipped without error."""
called = []
async def known_fn(text):
called.append(text)
return "ok"
focal = Focal(
client=mock_client,
callbacks=[make_callback("known", fn=known_fn)],
max_calls=10,
)
response = make_response(
make_call_dict("unknown", text="skip me"),
make_call_dict("known", text="run me"),
)
await focal._execute(response, State())
assert called == ["run me"]
# Only the known call is appended to state
assert len(focal.state.calls) == 1
assert focal.state.calls[0].name == "known"
@pytest.mark.asyncio
async def test_callback_error_stored_and_execution_continues(
self, mock_client, setup_director
):
"""A failing callback stores the error and doesn't block subsequent calls."""
called = []
async def failing_fn(text):
raise ValueError("boom")
async def ok_fn(text):
called.append(text)
return "ok"
focal = Focal(
client=mock_client,
callbacks=[
make_callback("fail", fn=failing_fn),
make_callback("ok", fn=ok_fn),
],
max_calls=10,
)
response = make_response(
make_call_dict("fail", text="x"),
make_call_dict("ok", text="y"),
)
await focal._execute(response, State())
# Error call is recorded with error message
assert focal.state.calls[0].error == "boom"
assert focal.state.calls[0].called is False
# Subsequent call still executes
assert called == ["y"]
assert focal.state.calls[1].called is True
@pytest.mark.asyncio
async def test_focal_reraise_propagates(self, mock_client, setup_director):
"""Exceptions with focal_reraise=True are re-raised."""
async def reraise_fn(text):
exc = RuntimeError("must propagate")
exc.focal_reraise = True
raise exc
focal = Focal(
client=mock_client,
callbacks=[make_callback("reraise", fn=reraise_fn)],
max_calls=10,
)
response = make_response(make_call_dict("reraise", text="x"))
with pytest.raises(RuntimeError, match="must propagate"):
await focal._execute(response, State())
@pytest.mark.asyncio
async def test_call_results_stored(self, mock_client, setup_director):
"""call.result and call.called are set after successful execution."""
async def fn(text):
return {"processed": text}
focal = Focal(
client=mock_client,
callbacks=[make_callback("process", fn=fn)],
max_calls=10,
)
response = make_response(make_call_dict("process", text="hello"))
await focal._execute(response, State())
call = focal.state.calls[0]
assert call.called is True
assert call.result == {"processed": "hello"}
assert call.error is None
assert call.name == "process"
@pytest.mark.asyncio
async def test_focal_context_hooks(self, mock_client, setup_director):
"""FocalContext before/after hooks fire around each call."""
hook_log = []
async def before_hook(call: Call):
hook_log.append(("before", call.name))
async def after_hook(call: Call):
hook_log.append(("after", call.name))
async def fn(text):
hook_log.append(("fn", text))
return "ok"
focal = Focal(
client=mock_client,
callbacks=[make_callback("action", fn=fn)],
max_calls=10,
)
ctx = FocalContext()
ctx.hooks_before_call.append(before_hook)
ctx.hooks_after_call.append(after_hook)
response = make_response(
make_call_dict("action", text="1"),
make_call_dict("action", text="2"),
)
with ctx:
await focal._execute(response, State())
assert hook_log == [
("before", "action"),
("fn", "1"),
("after", "action"),
("before", "action"),
("fn", "2"),
("after", "action"),
]
@pytest.mark.asyncio
async def test_director_log_called_per_call(self, mock_client, setup_director):
"""Director.log_function_call is called for each executed call."""
async def fn(text):
return "ok"
focal = Focal(
client=mock_client,
callbacks=[make_callback("action", fn=fn)],
max_calls=10,
)
response = make_response(
make_call_dict("action", text="1"),
make_call_dict("action", text="2"),
)
await focal._execute(response, State())
assert setup_director.log_function_call.call_count == 2
class TestCollectCalls:
"""Tests for the collect_calls() helper."""
def test_basic_collection(self):
"""Collects all calls from a flat list."""
calls = [
Call(name="a", arguments={"x": "1"}, result="r1", called=True),
Call(name="b", arguments={"x": "2"}, result="r2", called=True),
]
result = collect_calls(calls)
assert len(result) == 2
assert result[0].name == "a"
assert result[1].name == "b"
def test_filter(self):
"""Filter function selects matching calls."""
calls = [
Call(name="a", arguments={}, result="r1", called=True),
Call(name="b", arguments={}, result="r2", called=True),
Call(name="a", arguments={}, result="r3", called=True),
]
result = collect_calls(calls, filter=lambda c: c.name == "a")
assert len(result) == 2
assert all(c.name == "a" for c in result)
def test_nested_collection(self):
"""Nested call results are collected when nested=True."""
inner_calls = [
Call(name="inner", arguments={}, result="inner_r", called=True),
]
outer = Call(name="outer", arguments={}, result=inner_calls, called=True)
result = collect_calls([outer], nested=True)
assert len(result) == 2
assert result[0].name == "outer"
assert result[1].name == "inner"
def test_nested_with_filter(self):
"""Filter applies to both outer and inner calls when nested."""
inner_calls = [
Call(name="keep", arguments={}, result="ok", called=True),
Call(name="skip", arguments={}, result="no", called=True),
]
outer = Call(name="skip", arguments={}, result=inner_calls, called=True)
result = collect_calls([outer], nested=True, filter=lambda c: c.name == "keep")
assert len(result) == 1
assert result[0].name == "keep"
# ---------------------------------------------------------------------------
# Phase 2: Tests for concurrent execution
# ---------------------------------------------------------------------------
@pytest.fixture
def concurrent_client():
"""Real ClientBase subclass that opts into concurrent inference."""
return _ConcurrentClient("focal_concurrent")
0.36.0 (#255) Major Features - API key encryption at rest using Fernet (OS keyring with file fallback) - Prompt Manager: unified UI with template groups, priority ordering, override tracking, response extractors - Scene context history review panel with token budgets and best-fit mode - Multiple concurrent director chats with auto-generated titles - Granular scene state reset dialog - Time passage insert/edit/delete in scene view - Image analysis via OpenAI-compatible and Talemate Client backends - Volatile context placement after scene history for improved prompt caching Improvements - Configurable narrator generation length per narration type - AI Aware conversation mode - Summarizer: custom instructions, writing style inclusion, short line filtering - Anthropic: adaptive thinking support, updated model list (opus-4-5/4-6, haiku-4-5) - Google: gemini-3.1 support - World editor: generate from topic, quick create state reinforcement, reorganized menus - Node editor: promote scene modules to global - Frontend: version mismatch detection, hideable bracket content, required scene name - TTS: improved pause handling, audio tag support for vocal markers (ElevenLabs v3) - Writing style template for AI-generated instructions - Added Kimi.jinja2 LLM prompt template - Option to disable character names in stopping strings - Client response length enforcement options - Graduated token count sliders - Increased summarizer token threshold max Bugfixes - Fix bracket/paren/brace terminators stripped from message ends - Fix colon in conversation causing content loss - Fix "Use as reference" navigating to blank page - Fix avatar regeneration and manual regenerate - Fix conversation agent ignoring generation length - Fix duplicate length instructions with reasoning enabled - Fix trailing newline on message edits - Fix summarize dialogue sending too much context with layered history - Fix layered history inspection and construction issues - Fix empty response handling in summarization - Fix context ID dot notation with dotted character names - Fix recursive retry in focal agent - Fix leading whitespace causing duplicate prepared responses - Fix summarization not stripping ANALYSIS OF lines - Fix template group selection/removal in prompt manager - Fix multiline text in parentheses/brackets parser - Fix determine_character_name resolution - Fix character activate/deactivate desyncing creative menu - Fix character image generation missing context - Fix LMStudio client not sending token limits - Fix Recent Scene images on newer Chromium - Fix sequential reinforcement messages cut off at first linebreak - Fix reinforcement removal not clearing state - Fixes #252, #256, #258 Deprecations - Removed context investigations (replaced by AI-assisted RAG mixin) - Removed deprecated prompt templates (fix-continuity-errors, fix-exposition, etc.) - Removed conversation/edit.jinja2, auto break repetition, CLI reset layered history --------- Co-authored-by: theDTV2 <47825738+theDTV2@users.noreply.github.com>
2026-03-15 12:00:57 +02:00
class TestExecuteConcurrent:
"""Tests for concurrent callback execution via asyncio.gather + semaphore."""
@pytest.mark.asyncio
async def test_concurrent_streak_all_complete(
self, concurrent_client, setup_director
):
"""Consecutive concurrent-flagged calls all complete and are recorded."""
results = []
async def fn(text):
results.append(text)
return f"done:{text}"
focal = Focal(
client=concurrent_client,
callbacks=[make_callback("action", fn=fn, concurrent=True)],
max_calls=10,
max_concurrent=3,
)
response = make_response(
make_call_dict("action", text="1"),
make_call_dict("action", text="2"),
make_call_dict("action", text="3"),
)
await focal._execute(response, State())
assert len(focal.state.calls) == 3
assert all(c.called for c in focal.state.calls)
assert all(c.error is None for c in focal.state.calls)
# All three were called (order may vary since concurrent)
assert sorted(results) == ["1", "2", "3"]
@pytest.mark.asyncio
async def test_mixed_sequential_and_concurrent(
self, concurrent_client, setup_director
):
"""seq → concurrent streak → seq preserves overall ordering of groups."""
log = []
async def seq_fn(text):
log.append(("seq", text))
return "seq"
async def conc_fn(text):
log.append(("conc", text))
return "conc"
focal = Focal(
client=concurrent_client,
callbacks=[
make_callback("seq_action", fn=seq_fn, concurrent=False),
make_callback("conc_action", fn=conc_fn, concurrent=True),
],
max_calls=10,
max_concurrent=3,
)
response = make_response(
make_call_dict("seq_action", text="first"),
make_call_dict("conc_action", text="a"),
make_call_dict("conc_action", text="b"),
make_call_dict("conc_action", text="c"),
make_call_dict("seq_action", text="last"),
)
await focal._execute(response, State())
assert len(focal.state.calls) == 5
# First call must be sequential (ran before concurrent streak)
assert focal.state.calls[0].name == "seq_action"
assert focal.state.calls[0].result == "seq"
# Middle 3 are the concurrent streak (all recorded)
concurrent_calls = focal.state.calls[1:4]
assert all(c.name == "conc_action" for c in concurrent_calls)
assert all(c.called for c in concurrent_calls)
# Last call is sequential again
assert focal.state.calls[4].name == "seq_action"
assert focal.state.calls[4].result == "seq"
# Sequential calls must have run before and after the streak
seq_indices = [i for i, entry in enumerate(log) if entry[0] == "seq"]
conc_indices = [i for i, entry in enumerate(log) if entry[0] == "conc"]
assert seq_indices[0] < min(conc_indices)
assert seq_indices[1] > max(conc_indices)
@pytest.mark.asyncio
async def test_concurrent_respects_max_concurrent(
self, concurrent_client, setup_director
):
"""Semaphore limits actual parallelism to max_concurrent."""
active = 0
max_active = 0
lock = asyncio.Lock()
async def tracking_fn(text):
nonlocal active, max_active
async with lock:
active += 1
if active > max_active:
max_active = active
# Yield to allow other tasks to start if they can
await asyncio.sleep(0.01)
async with lock:
active -= 1
return "ok"
focal = Focal(
client=concurrent_client,
callbacks=[make_callback("action", fn=tracking_fn, concurrent=True)],
max_calls=10,
max_concurrent=2,
)
response = make_response(
make_call_dict("action", text="1"),
make_call_dict("action", text="2"),
make_call_dict("action", text="3"),
make_call_dict("action", text="4"),
make_call_dict("action", text="5"),
)
await focal._execute(response, State())
assert len(focal.state.calls) == 5
assert all(c.called for c in focal.state.calls)
# Semaphore should have limited concurrency to 2
assert max_active <= 2
@pytest.mark.asyncio
async def test_client_without_concurrency_falls_back_to_sequential(
self, mock_client, setup_director
):
"""When client doesn't support concurrency, concurrent flag is ignored."""
order = []
async def fn(text):
order.append(text)
return "ok"
# mock_client has supports_concurrent_inference = False
focal = Focal(
client=mock_client,
callbacks=[make_callback("action", fn=fn, concurrent=True)],
max_calls=10,
max_concurrent=3,
)
response = make_response(
make_call_dict("action", text="1"),
make_call_dict("action", text="2"),
make_call_dict("action", text="3"),
)
await focal._execute(response, State())
# All executed, and since sequential, order is deterministic
assert order == ["1", "2", "3"]
assert len(focal.state.calls) == 3
@pytest.mark.asyncio
async def test_max_calls_respected_in_concurrent_streak(
self, concurrent_client, setup_director
):
"""Budget is exhausted correctly even within a concurrent streak."""
call_count = 0
async def fn(text):
nonlocal call_count
call_count += 1
return "ok"
focal = Focal(
client=concurrent_client,
callbacks=[make_callback("action", fn=fn, concurrent=True)],
max_calls=2,
max_concurrent=5,
)
response = make_response(
make_call_dict("action", text="1"),
make_call_dict("action", text="2"),
make_call_dict("action", text="3"),
make_call_dict("action", text="4"),
)
await focal._execute(response, State())
assert call_count == 2
assert len(focal.state.calls) == 2
@pytest.mark.asyncio
async def test_error_in_concurrent_call_doesnt_block_others(
self, concurrent_client, setup_director
):
"""One failing call in a concurrent streak doesn't prevent others."""
results = []
async def failing_fn(text):
if text == "fail":
raise ValueError("concurrent boom")
results.append(text)
return f"ok:{text}"
focal = Focal(
client=concurrent_client,
callbacks=[make_callback("action", fn=failing_fn, concurrent=True)],
max_calls=10,
max_concurrent=5,
)
response = make_response(
make_call_dict("action", text="a"),
make_call_dict("action", text="fail"),
make_call_dict("action", text="b"),
)
await focal._execute(response, State())
assert len(focal.state.calls) == 3
# Find the failed call and verify error stored
error_calls = [c for c in focal.state.calls if c.error is not None]
assert len(error_calls) == 1
assert "concurrent boom" in error_calls[0].error
# Other calls completed successfully
ok_calls = [c for c in focal.state.calls if c.called]
assert len(ok_calls) == 2
@pytest.mark.asyncio
async def test_focal_context_hooks_in_concurrent_mode(
self, concurrent_client, setup_director
):
"""FocalContext before/after hooks fire per-call even in concurrent mode."""
hook_log = []
async def before_hook(call: Call):
hook_log.append(("before", call.name))
async def after_hook(call: Call):
hook_log.append(("after", call.name))
async def fn(text):
return "ok"
focal = Focal(
client=concurrent_client,
callbacks=[make_callback("action", fn=fn, concurrent=True)],
max_calls=10,
max_concurrent=3,
)
ctx = FocalContext()
ctx.hooks_before_call.append(before_hook)
ctx.hooks_after_call.append(after_hook)
response = make_response(
make_call_dict("action", text="1"),
make_call_dict("action", text="2"),
)
with ctx:
await focal._execute(response, State())
# Each call gets before + after hooks
before_count = sum(1 for entry in hook_log if entry[0] == "before")
after_count = sum(1 for entry in hook_log if entry[0] == "after")
assert before_count == 2
assert after_count == 2