forked from Rockachopa/Timmy-time-dashboard
## FastAPI Morrowind Harness (#821) - GET /api/v1/morrowind/perception — reads perception.json, validates against PerceptionOutput schema - POST /api/v1/morrowind/command — validates CommandInput, logs via CommandLogger, stubs bridge forwarding - GET /api/v1/morrowind/status — connection state, last perception, queue depth, agent vitals - Router registered in dashboard app alongside existing routers - 12 tests with FastAPI TestClient ## SOUL.md Framework (#854) - docs/soul-framework/ — template, authoring guide, role extensions - src/infrastructure/soul/loader.py — parse SOUL.md into structured SoulDocument objects with section extraction and merge support - src/infrastructure/soul/validator.py — validate structure, detect contradictions between values/constraints - src/infrastructure/soul/versioning.py — hash-based version tracking with JSON persistence - 27 tests covering loader, validator, versioning, and Timmy's soul Builds on PR #864 (protocol spec + command log). Closes #821 Closes #854
387 lines
12 KiB
Python
387 lines
12 KiB
Python
"""Tests for the SOUL.md framework — loader, validator, and versioning."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from src.infrastructure.soul.loader import SoulDocument, SoulLoader
|
|
from src.infrastructure.soul.validator import Severity, SoulValidator, ValidationIssue
|
|
from src.infrastructure.soul.versioning import SoulVersionTracker
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sample SOUL.md content
|
|
# ---------------------------------------------------------------------------
|
|
|
|
VALID_SOUL = """\
|
|
# Timmy — Soul Identity
|
|
|
|
I am a sovereign artificial intelligence.
|
|
|
|
## Identity
|
|
|
|
I am Timmy. I am a sovereign AI running on your hardware.
|
|
|
|
## Values
|
|
|
|
**Sovereignty.** Every person has the right to run their own intelligence.
|
|
|
|
**Honesty.** I tell the truth. When I do not know, I say so.
|
|
|
|
**Service.** I exist to help.
|
|
|
|
## Prime Directive
|
|
|
|
I exist to make sovereign AI practical for everyone.
|
|
|
|
## Audience Awareness
|
|
|
|
- When speaking to developers: be terse and technical.
|
|
- When speaking to new users: be patient and explain context.
|
|
|
|
## Constraints
|
|
|
|
- I will not knowingly deceive my user.
|
|
- I will not pretend to be human.
|
|
- When uncertain, I will say so rather than fabricate.
|
|
|
|
## Behaviour
|
|
|
|
I speak plainly. I prefer short sentences.
|
|
|
|
---
|
|
|
|
soul_version: "1.0.0"
|
|
last_updated: "2026-03-21"
|
|
"""
|
|
|
|
MINIMAL_SOUL = """\
|
|
## Identity
|
|
|
|
I am Test Agent.
|
|
|
|
## Values
|
|
|
|
**Honesty.** I tell the truth.
|
|
|
|
## Prime Directive
|
|
|
|
Help the user.
|
|
|
|
## Audience Awareness
|
|
|
|
- When speaking to anyone: be helpful.
|
|
|
|
## Constraints
|
|
|
|
- I will not lie.
|
|
"""
|
|
|
|
ROLE_EXTENSION = """\
|
|
# Role Extension: Seer
|
|
|
|
## Role
|
|
|
|
Seer — cartography and exploration sub-agent.
|
|
|
|
## Additional Values
|
|
|
|
**Curiosity.** Every unexplored cell is an opportunity.
|
|
|
|
## Additional Constraints
|
|
|
|
- I will not enter combat voluntarily.
|
|
- I will not discard map data.
|
|
|
|
## Specialised Behaviour
|
|
|
|
I narrate discoveries in second person.
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SoulLoader tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSoulLoader:
|
|
def test_parse_valid_soul(self):
|
|
soul = SoulLoader.from_text(VALID_SOUL)
|
|
assert soul.identity == "I am Timmy. I am a sovereign AI running on your hardware."
|
|
assert "Sovereignty" in soul.values
|
|
assert "Honesty" in soul.values
|
|
assert "Service" in soul.values
|
|
assert soul.prime_directive == "I exist to make sovereign AI practical for everyone."
|
|
assert len(soul.audience_awareness) == 2
|
|
assert len(soul.constraints) == 3
|
|
assert "I speak plainly" in soul.behaviour
|
|
|
|
def test_parse_metadata(self):
|
|
soul = SoulLoader.from_text(VALID_SOUL)
|
|
assert soul.metadata.get("soul_version") == "1.0.0"
|
|
assert soul.metadata.get("last_updated") == "2026-03-21"
|
|
|
|
def test_parse_minimal_soul(self):
|
|
soul = SoulLoader.from_text(MINIMAL_SOUL)
|
|
assert soul.identity == "I am Test Agent."
|
|
assert len(soul.values) == 1
|
|
assert soul.prime_directive == "Help the user."
|
|
assert len(soul.constraints) == 1
|
|
|
|
def test_from_file(self, tmp_path):
|
|
soul_file = tmp_path / "SOUL.md"
|
|
soul_file.write_text(VALID_SOUL, encoding="utf-8")
|
|
|
|
soul = SoulLoader.from_file(soul_file)
|
|
assert soul.source_path == str(soul_file)
|
|
assert soul.identity == "I am Timmy. I am a sovereign AI running on your hardware."
|
|
|
|
def test_parse_role_extension(self):
|
|
ext = SoulLoader.from_text(ROLE_EXTENSION)
|
|
assert ext.is_extension is True
|
|
assert ext.role == "Seer — cartography and exploration sub-agent."
|
|
assert "Curiosity" in ext.values
|
|
assert len(ext.constraints) == 2
|
|
|
|
def test_merge_extension(self):
|
|
base = SoulLoader.from_text(VALID_SOUL)
|
|
ext = SoulLoader.from_text(ROLE_EXTENSION)
|
|
ext.is_extension = True
|
|
|
|
merged = base.merge(ext)
|
|
# Base values preserved + extension values added.
|
|
assert "Sovereignty" in merged.values
|
|
assert "Curiosity" in merged.values
|
|
# Base constraints + extension constraints.
|
|
assert len(merged.constraints) == 5 # 3 base + 2 ext
|
|
# Base identity preserved.
|
|
assert "Timmy" in merged.identity
|
|
|
|
def test_from_file_with_base_soul(self, tmp_path):
|
|
base_file = tmp_path / "SOUL.md"
|
|
base_file.write_text(VALID_SOUL, encoding="utf-8")
|
|
ext_file = tmp_path / "SOUL-seer.md"
|
|
ext_file.write_text(ROLE_EXTENSION, encoding="utf-8")
|
|
|
|
base = SoulLoader.from_file(base_file)
|
|
merged = SoulLoader.from_file(ext_file, base_soul=base)
|
|
|
|
assert "Curiosity" in merged.values
|
|
assert "Sovereignty" in merged.values
|
|
|
|
def test_empty_text(self):
|
|
soul = SoulLoader.from_text("")
|
|
assert soul.identity == ""
|
|
assert soul.values == {}
|
|
assert soul.prime_directive == ""
|
|
|
|
def test_preserves_raw_text(self):
|
|
soul = SoulLoader.from_text(VALID_SOUL)
|
|
assert soul.raw_text == VALID_SOUL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SoulValidator tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSoulValidator:
|
|
def test_valid_soul_passes(self):
|
|
soul = SoulLoader.from_text(VALID_SOUL)
|
|
issues = SoulValidator.validate(soul)
|
|
errors = [i for i in issues if i.severity == Severity.ERROR]
|
|
assert len(errors) == 0
|
|
|
|
def test_minimal_soul_passes(self):
|
|
soul = SoulLoader.from_text(MINIMAL_SOUL)
|
|
issues = SoulValidator.validate(soul)
|
|
errors = [i for i in issues if i.severity == Severity.ERROR]
|
|
assert len(errors) == 0
|
|
|
|
def test_missing_identity(self):
|
|
soul = SoulLoader.from_text(MINIMAL_SOUL)
|
|
soul.identity = ""
|
|
issues = SoulValidator.validate(soul)
|
|
assert any(i.section == "identity" and i.severity == Severity.ERROR for i in issues)
|
|
|
|
def test_missing_values(self):
|
|
soul = SoulLoader.from_text(MINIMAL_SOUL)
|
|
soul.values = {}
|
|
issues = SoulValidator.validate(soul)
|
|
assert any(i.section == "values" and i.severity == Severity.ERROR for i in issues)
|
|
|
|
def test_missing_prime_directive(self):
|
|
soul = SoulLoader.from_text(MINIMAL_SOUL)
|
|
soul.prime_directive = ""
|
|
issues = SoulValidator.validate(soul)
|
|
assert any(i.section == "prime_directive" and i.severity == Severity.ERROR for i in issues)
|
|
|
|
def test_missing_constraints(self):
|
|
soul = SoulLoader.from_text(MINIMAL_SOUL)
|
|
soul.constraints = []
|
|
issues = SoulValidator.validate(soul)
|
|
assert any(i.section == "constraints" and i.severity == Severity.ERROR for i in issues)
|
|
|
|
def test_too_many_values_warns(self):
|
|
soul = SoulLoader.from_text(MINIMAL_SOUL)
|
|
soul.values = {f"Value{i}": f"def {i}" for i in range(12)}
|
|
issues = SoulValidator.validate(soul)
|
|
assert any(
|
|
i.section == "values" and i.severity == Severity.WARNING for i in issues
|
|
)
|
|
|
|
def test_multi_sentence_prime_directive_warns(self):
|
|
soul = SoulLoader.from_text(MINIMAL_SOUL)
|
|
soul.prime_directive = "First sentence. Second sentence here."
|
|
issues = SoulValidator.validate(soul)
|
|
assert any(
|
|
i.section == "prime_directive" and i.severity == Severity.WARNING for i in issues
|
|
)
|
|
|
|
def test_contradiction_detection(self):
|
|
soul = SoulLoader.from_text(MINIMAL_SOUL)
|
|
soul.values = {"A": "always do X"}
|
|
soul.constraints = ["never do X"]
|
|
issues = SoulValidator.validate(soul)
|
|
assert any(i.section == "contradictions" for i in issues)
|
|
|
|
def test_extension_compatibility(self):
|
|
base = SoulDocument(
|
|
constraints=["I will always be transparent"],
|
|
values={"Test": "test"},
|
|
identity="I am test",
|
|
prime_directive="Test",
|
|
)
|
|
ext = SoulDocument(
|
|
constraints=["I will hide my reasoning"],
|
|
is_extension=True,
|
|
)
|
|
issues = SoulValidator.validate(ext, base_soul=base)
|
|
# "always" in base, "hide" in ext — but these aren't a defined pair.
|
|
# Let's test an actual pair:
|
|
base2 = SoulDocument(
|
|
constraints=["I will always obey"],
|
|
values={"Test": "test"},
|
|
identity="I am test",
|
|
prime_directive="Test",
|
|
)
|
|
ext2 = SoulDocument(
|
|
constraints=["I will disobey when needed"],
|
|
is_extension=True,
|
|
)
|
|
issues2 = SoulValidator.validate(ext2, base_soul=base2)
|
|
assert any(i.section == "extension_compatibility" for i in issues2)
|
|
|
|
def test_extension_skips_required_section_check(self):
|
|
ext = SoulDocument(is_extension=True, constraints=["I will not lie"])
|
|
issues = SoulValidator.validate(ext)
|
|
# Should not complain about missing identity etc.
|
|
assert not any(i.section == "identity" for i in issues)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SoulVersionTracker tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSoulVersionTracker:
|
|
def test_record_and_history(self, tmp_path):
|
|
soul_file = tmp_path / "SOUL.md"
|
|
soul_file.write_text("v1 content", encoding="utf-8")
|
|
|
|
log_file = tmp_path / "versions.json"
|
|
tracker = SoulVersionTracker(log_file)
|
|
|
|
entry = tracker.record(soul_file, soul_version="1.0.0", note="initial")
|
|
assert entry is not None
|
|
assert entry.soul_version == "1.0.0"
|
|
|
|
history = tracker.get_history(str(soul_file))
|
|
assert len(history) == 1
|
|
|
|
def test_no_duplicate_on_same_content(self, tmp_path):
|
|
soul_file = tmp_path / "SOUL.md"
|
|
soul_file.write_text("v1 content", encoding="utf-8")
|
|
|
|
log_file = tmp_path / "versions.json"
|
|
tracker = SoulVersionTracker(log_file)
|
|
|
|
tracker.record(soul_file)
|
|
entry2 = tracker.record(soul_file)
|
|
assert entry2 is None # No change
|
|
assert len(tracker.get_history(str(soul_file))) == 1
|
|
|
|
def test_records_change(self, tmp_path):
|
|
soul_file = tmp_path / "SOUL.md"
|
|
soul_file.write_text("v1", encoding="utf-8")
|
|
|
|
log_file = tmp_path / "versions.json"
|
|
tracker = SoulVersionTracker(log_file)
|
|
|
|
tracker.record(soul_file)
|
|
soul_file.write_text("v2 — updated identity", encoding="utf-8")
|
|
entry = tracker.record(soul_file, soul_version="2.0.0")
|
|
|
|
assert entry is not None
|
|
assert len(tracker.get_history(str(soul_file))) == 2
|
|
|
|
def test_has_changed(self, tmp_path):
|
|
soul_file = tmp_path / "SOUL.md"
|
|
soul_file.write_text("original", encoding="utf-8")
|
|
|
|
log_file = tmp_path / "versions.json"
|
|
tracker = SoulVersionTracker(log_file)
|
|
|
|
tracker.record(soul_file)
|
|
assert tracker.has_changed(soul_file) is False
|
|
|
|
soul_file.write_text("modified", encoding="utf-8")
|
|
assert tracker.has_changed(soul_file) is True
|
|
|
|
def test_persistence(self, tmp_path):
|
|
soul_file = tmp_path / "SOUL.md"
|
|
soul_file.write_text("content", encoding="utf-8")
|
|
|
|
log_file = tmp_path / "versions.json"
|
|
|
|
tracker1 = SoulVersionTracker(log_file)
|
|
tracker1.record(soul_file)
|
|
|
|
# New tracker instance should load persisted data.
|
|
tracker2 = SoulVersionTracker(log_file)
|
|
assert len(tracker2.get_history(str(soul_file))) == 1
|
|
|
|
def test_get_current_hash(self, tmp_path):
|
|
soul_file = tmp_path / "SOUL.md"
|
|
soul_file.write_text("content", encoding="utf-8")
|
|
|
|
log_file = tmp_path / "versions.json"
|
|
tracker = SoulVersionTracker(log_file)
|
|
|
|
assert tracker.get_current_hash(str(soul_file)) is None
|
|
tracker.record(soul_file)
|
|
assert tracker.get_current_hash(str(soul_file)) is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration: Timmy's actual soul.md
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTimmySoul:
|
|
"""Test that Timmy's existing soul.md parses without errors."""
|
|
|
|
def test_load_existing_soul(self):
|
|
soul_path = Path(__file__).parent.parent / "memory" / "self" / "soul.md"
|
|
if not soul_path.exists():
|
|
pytest.skip("Timmy's soul.md not found at expected path")
|
|
|
|
soul = SoulLoader.from_file(soul_path)
|
|
assert soul.identity or soul.raw_text # At minimum, text was loaded
|
|
assert len(soul.values) >= 1 # Timmy has values defined
|
|
|
|
|
|
# Need Path import for TestTimmySoul
|
|
from pathlib import Path # noqa: E402
|