Some checks failed
Release / semantic-release (push) Successful in 34s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m17s
Release / build-linux (push) Failing after 7m9s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
load_settings() coerced any tts_engine outside {piper, chatterbox} to piper, so
with TTS_ENGINE=edge the reply engine saw "piper" and treated the voice as
English-only in reply_language_directive() (only the OUTPUT_LANGUAGE lock kept
replies Korean). Add "edge" (and "melo") to the accepted set so the engine is
labelled multilingual correctly.
Also: a stale tts_engine in the persistent /data/jarvis-settings.json (melo/xtts
from an earlier voice, no longer built) would override the configured engine via
the entrypoint merge and leave the bot silent. Reset those to the env engine
during the merge.
Verified: load_settings() with tts_engine=edge now returns "edge"; the merge
maps melo/xtts -> edge; reply_language_directive("edge") is multilingual; 27
tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
174 lines
7.5 KiB
Python
174 lines
7.5 KiB
Python
"""Tests for the unified persona system prompt.
|
|
|
|
The persona should match the user's configured wake word so renaming the
|
|
wake word to e.g. "Friday" produces a butler named Friday, not one still
|
|
hardcoded to Jarvis.
|
|
"""
|
|
|
|
from jarvis.system_prompt import (
|
|
build_system_prompt,
|
|
load_agent_instructions,
|
|
output_language_directive,
|
|
reply_language_directive,
|
|
ENGLISH_ONLY_DIRECTIVE,
|
|
)
|
|
|
|
|
|
class TestBuildSystemPrompt:
|
|
def test_default_name_is_jarvis(self):
|
|
prompt = build_system_prompt()
|
|
assert "named Jarvis" in prompt
|
|
|
|
def test_custom_name_replaces_jarvis(self):
|
|
prompt = build_system_prompt("Friday")
|
|
assert "named Friday" in prompt
|
|
assert "named Jarvis" not in prompt
|
|
|
|
def test_lowercase_wake_word_is_capitalised(self):
|
|
prompt = build_system_prompt("friday".capitalize())
|
|
assert "named Friday" in prompt
|
|
|
|
def test_blank_name_falls_back_to_jarvis(self):
|
|
assert "named Jarvis" in build_system_prompt("")
|
|
assert "named Jarvis" in build_system_prompt(" ")
|
|
assert "named Jarvis" in build_system_prompt(None) # type: ignore[arg-type]
|
|
|
|
def test_default_keeps_user_language_clause(self):
|
|
# Without a lock, the persona still mirrors the user's language.
|
|
assert "in the user's language" in build_system_prompt("Jarvis")
|
|
|
|
def test_language_lock_rewrites_user_language_clause(self):
|
|
# With a lock, the contradicting "user's language" clause is rewritten
|
|
# so the persona does not fight the OUTPUT_LANGUAGE directive.
|
|
prompt = build_system_prompt("Jarvis", "Korean")
|
|
assert "in the user's language" not in prompt
|
|
assert "in Korean" in prompt
|
|
|
|
|
|
class TestOutputLanguageDirective:
|
|
"""A deployment may lock replies to a single language via OUTPUT_LANGUAGE.
|
|
|
|
Unset (the default) must keep the assistant's multilingual behaviour of
|
|
replying in the user's own language, so the helper returns None and no
|
|
directive is injected.
|
|
"""
|
|
|
|
def test_unset_returns_none(self):
|
|
assert output_language_directive(None) is None
|
|
assert output_language_directive("") is None
|
|
assert output_language_directive(" ") is None
|
|
|
|
def test_set_language_is_named_and_exclusive(self):
|
|
directive = output_language_directive("Korean")
|
|
assert directive is not None
|
|
assert "Korean" in directive
|
|
# Must force exclusivity, not merely prefer the language.
|
|
assert "only" in directive.lower()
|
|
|
|
def test_language_agnostic(self):
|
|
# The helper takes any language string — no hardcoded single language.
|
|
assert "French" in (output_language_directive("French") or "")
|
|
assert "日本語" in (output_language_directive("日本語") or "")
|
|
|
|
def test_strips_surrounding_whitespace(self):
|
|
directive = output_language_directive(" Korean ")
|
|
assert directive is not None
|
|
assert "Korean" in directive
|
|
assert " Korean" not in directive
|
|
|
|
|
|
class TestReplyLanguageDirective:
|
|
"""Precedence: explicit OUTPUT_LANGUAGE lock > English-only TTS > free.
|
|
|
|
The lock must override the Piper/Chatterbox English fallback, because a
|
|
deployment that sets OUTPUT_LANGUAGE (e.g. Korean) also runs a TTS voice
|
|
that can speak it. Without this, the English lock and the Korean lock
|
|
contradict each other and the model reverts to English.
|
|
"""
|
|
|
|
def test_lock_overrides_english_only_tts(self):
|
|
directive = reply_language_directive("Korean", "piper")
|
|
assert directive is not None
|
|
assert "Korean" in directive
|
|
assert directive != ENGLISH_ONLY_DIRECTIVE
|
|
|
|
def test_english_only_tts_forces_english_without_lock(self):
|
|
assert reply_language_directive(None, "piper") == ENGLISH_ONLY_DIRECTIVE
|
|
assert reply_language_directive("", "chatterbox") == ENGLISH_ONLY_DIRECTIVE
|
|
|
|
def test_no_lock_multilingual_tts_is_free(self):
|
|
# A non-English-only engine (e.g. melo) with no lock → reply in the
|
|
# user's own language, so no directive.
|
|
assert reply_language_directive(None, "melo") is None
|
|
|
|
def test_unknown_tts_defaults_to_english_only(self):
|
|
# Preserves the original getattr(cfg, 'tts_engine', 'piper') default:
|
|
# an unknown/missing engine is treated conservatively as English-only.
|
|
assert reply_language_directive(None, None) == ENGLISH_ONLY_DIRECTIVE
|
|
|
|
def test_lock_wins_even_with_multilingual_tts(self):
|
|
directive = reply_language_directive("Korean", "melo")
|
|
assert directive is not None and "Korean" in directive
|
|
|
|
def test_edge_is_multilingual(self):
|
|
# Edge TTS (the default Korean voice) is not English-only: no lock → the
|
|
# user's own language, and a lock is honoured (not forced to English).
|
|
assert reply_language_directive(None, "edge") is None
|
|
directive = reply_language_directive("Korean", "edge")
|
|
assert directive is not None and "Korean" in directive
|
|
assert directive != ENGLISH_ONLY_DIRECTIVE
|
|
|
|
|
|
class TestLoadAgentInstructions:
|
|
"""Operator can extend the reply LLM's system prompt by dropping *.md files
|
|
into an agents/ folder. The loader concatenates them in filename order and
|
|
fails open so a missing/empty/broken folder never breaks a reply."""
|
|
|
|
def test_missing_dir_returns_empty(self, tmp_path):
|
|
assert load_agent_instructions(str(tmp_path / "does-not-exist")) == ""
|
|
|
|
def test_empty_dir_returns_empty(self, tmp_path):
|
|
assert load_agent_instructions(str(tmp_path)) == ""
|
|
|
|
def test_reads_and_concatenates_single_file(self, tmp_path):
|
|
(tmp_path / "rules.md").write_text("Always be brief.", encoding="utf-8")
|
|
assert load_agent_instructions(str(tmp_path)) == "Always be brief."
|
|
|
|
def test_files_are_ordered_by_filename(self, tmp_path):
|
|
# Filename prefixes let the operator control ordering.
|
|
(tmp_path / "10-second.md").write_text("SECOND", encoding="utf-8")
|
|
(tmp_path / "00-first.md").write_text("FIRST", encoding="utf-8")
|
|
result = load_agent_instructions(str(tmp_path))
|
|
assert result.index("FIRST") < result.index("SECOND")
|
|
|
|
def test_only_md_files_are_read(self, tmp_path):
|
|
(tmp_path / "note.txt").write_text("IGNORE ME", encoding="utf-8")
|
|
(tmp_path / "use.md").write_text("USE ME", encoding="utf-8")
|
|
result = load_agent_instructions(str(tmp_path))
|
|
assert "USE ME" in result
|
|
assert "IGNORE ME" not in result
|
|
|
|
def test_blank_files_are_skipped(self, tmp_path):
|
|
(tmp_path / "blank.md").write_text(" \n ", encoding="utf-8")
|
|
(tmp_path / "real.md").write_text("Real instruction.", encoding="utf-8")
|
|
assert load_agent_instructions(str(tmp_path)) == "Real instruction."
|
|
|
|
def test_env_var_is_used_when_no_arg(self, tmp_path, monkeypatch):
|
|
(tmp_path / "a.md").write_text("FROM ENV", encoding="utf-8")
|
|
monkeypatch.setenv("AGENTS_DIR", str(tmp_path))
|
|
assert load_agent_instructions() == "FROM ENV"
|
|
|
|
def test_explicit_arg_overrides_env(self, tmp_path, monkeypatch):
|
|
(tmp_path / "env.md").write_text("ENV", encoding="utf-8")
|
|
other = tmp_path / "other"
|
|
other.mkdir()
|
|
(other / "arg.md").write_text("ARG", encoding="utf-8")
|
|
monkeypatch.setenv("AGENTS_DIR", str(tmp_path))
|
|
assert load_agent_instructions(str(other)) == "ARG"
|
|
|
|
def test_a_file_path_instead_of_dir_returns_empty(self, tmp_path):
|
|
f = tmp_path / "file.md"
|
|
f.write_text("x", encoding="utf-8")
|
|
# Pointed at a file, not a directory → fail-open.
|
|
assert load_agent_instructions(str(f)) == ""
|