Files
turnstone/tests/test_prompts.py
T
Patrick Buckley dbf389783e refactor(personas): file-backed built-in prompts, explicit source column
Built-in persona base prompts move from inline DB text / base.md into
prompts/personas/<slug>.md — code-owned, PR-reviewable, drift-proof.
base.md / base_coordinator.md become personas/engineer.md / orchestrator.md.

Prompt source is now explicit in storage instead of inferred in app logic:
a new base_prompt_file column plus CHECK (base_prompt IS NOT NULL OR
base_prompt_file IS NOT NULL) — two nullable columns, never both empty.
Resolution is a coalesce (base_prompt else load(base_prompt_file)), frozen
into the workstream stamp at creation. base_prompt_file marks a persona as
built-in (code-only, un-archivable); an operator override on a built-in is
allowed and wins over the file. "Inherit the kind default" is a
workstream-creation act (is_default), not a persona-row state.

Migration 063:
- seeds reference their file (base_prompt NULL); no runtime file reads —
  the backfill's frozen prompt text is inlined as a point-in-time snapshot
  so migration history stays self-contained and reproducible.
- every existing workstream is stamped by kind (creative -> writer, else
  the kind default), set-based (INSERT..SELECT via temp tables) with the
  persona column added after the bulk writes to shorten its lock window.

Storage guards (both backends): operators must supply base_prompt;
built-ins can't be archived or have base_prompt_file set via the API;
clearing an operator persona's only source is rejected.

Follow-ups reviewed alongside (#756): soft-set visibility docstring scoped
to per-process; _apply_persona_snapshot / _current_persona_snapshot own the
stamp round-trip; spawn approval-header args (skill/name/target_node)
flattened+capped like persona; server-side tool injection generalized to
replace-only (client-def gated, incl. the xAI include forwarding). Seed
copy revised (researcher soft; de-costumed prose; engineer de-biased).
New test_schema_parity asserts create_all matches the alembic head.

Closes #683 groundwork; ruff + strict mypy clean, full suite green.
2026-07-03 00:29:26 -07:00

474 lines
14 KiB
Python

"""Tests for the system message composition harness (turnstone.prompts)."""
from __future__ import annotations
import pytest
from turnstone.prompts import (
ClientType,
SessionContext,
compose_system_message,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_VALID_CTX = SessionContext(
current_datetime="2026-03-31T14:22:00-07:00",
timezone="PDT",
username="sarah.chen",
)
_ALL_TOOLS: frozenset[str] = frozenset({"web_search", "read_file", "bash"})
_NO_TOOLS: frozenset[str] = frozenset()
# ---------------------------------------------------------------------------
# 1. Assembly smoke test per client type
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("ct", [ClientType.WEB, ClientType.CLI, ClientType.CHAT])
def test_smoke_all_client_types(ct: ClientType) -> None:
result = compose_system_message(
client_type=ct,
context=_VALID_CTX,
available_tools=_ALL_TOOLS,
)
# BASE content present
assert "software engineer" in result
# CONTEXT present
assert "sarah.chen" in result
assert "2026-03-31" in result
def test_smoke_web_has_mermaid() -> None:
result = compose_system_message(
client_type=ClientType.WEB,
context=_VALID_CTX,
available_tools=_ALL_TOOLS,
)
assert "Mermaid" in result
assert "KaTeX" in result
def test_smoke_cli_no_mermaid() -> None:
result = compose_system_message(
client_type=ClientType.CLI,
context=_VALID_CTX,
available_tools=_ALL_TOOLS,
)
assert "Mermaid" not in result or "Do not use" in result
def test_smoke_chat_no_tables() -> None:
result = compose_system_message(
client_type=ClientType.CHAT,
context=_VALID_CTX,
available_tools=_ALL_TOOLS,
)
assert "Do not use them" in result
# ---------------------------------------------------------------------------
# 2. Required field validation
# ---------------------------------------------------------------------------
def test_missing_current_datetime() -> None:
ctx = SessionContext(current_datetime="", timezone="PDT", username="alice")
with pytest.raises(ValueError, match="current_datetime"):
compose_system_message(ClientType.CLI, ctx, _NO_TOOLS)
def test_missing_timezone() -> None:
ctx = SessionContext(
current_datetime="2026-03-31T14:22:00-07:00",
timezone="",
username="alice",
)
with pytest.raises(ValueError, match="timezone"):
compose_system_message(ClientType.CLI, ctx, _NO_TOOLS)
def test_missing_username() -> None:
ctx = SessionContext(
current_datetime="2026-03-31T14:22:00-07:00",
timezone="PDT",
username="",
)
with pytest.raises(ValueError, match="username"):
compose_system_message(ClientType.CLI, ctx, _NO_TOOLS)
# ---------------------------------------------------------------------------
# 3. Unknown client type rejection
# ---------------------------------------------------------------------------
def test_unknown_client_type() -> None:
with pytest.raises(ValueError, match="Unknown client_type"):
compose_system_message("tablet", _VALID_CTX, _NO_TOOLS) # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# 4. Missing policy file
# ---------------------------------------------------------------------------
def test_missing_policy_file() -> None:
with pytest.raises(FileNotFoundError, match="nonexistent"):
compose_system_message(
ClientType.CLI,
_VALID_CTX,
_ALL_TOOLS,
policies=["nonexistent"],
)
# ---------------------------------------------------------------------------
# 5. Module isolation — BASE must be environment-agnostic
# ---------------------------------------------------------------------------
def test_base_module_isolation() -> None:
# EVERY built-in persona file is a BASE module (compose_system_message loads
# it as the base), so all must be environment-agnostic — not just engineer.
from turnstone.prompts import _PROMPTS_DIR, _load
persona_files = sorted(p.name for p in (_PROMPTS_DIR / "personas").glob("*.md"))
assert persona_files, "expected built-in persona base files under prompts/personas/"
for fname in persona_files:
base = _load(f"personas/{fname}")
for forbidden in ("Mermaid", "KaTeX", "terminal", "monospace", "Slack", "Discord"):
assert forbidden not in base, f"BASE {fname} must not contain '{forbidden}'"
# ---------------------------------------------------------------------------
# 6. ENV mutual exclusion
# ---------------------------------------------------------------------------
def test_env_mutual_exclusion() -> None:
web = compose_system_message(ClientType.WEB, _VALID_CTX, _ALL_TOOLS)
# Web should have Mermaid.js but not "No diagram rendering" from CLI
assert "Mermaid" in web
assert "No diagram rendering" not in web
# ---------------------------------------------------------------------------
# 7. Policy tool gating — file-based (negative case)
# ---------------------------------------------------------------------------
def test_file_policy_gated_out() -> None:
"""web_search policy excluded when web_search tool is not available."""
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
frozenset({"read_file"}), # no web_search
policies=["web_search"],
)
assert "Web Search Policy" not in result
# ---------------------------------------------------------------------------
# 8. Policy tool gating — positive case
# ---------------------------------------------------------------------------
def test_file_policy_gated_in() -> None:
"""web_search policy included when web_search tool is available."""
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
frozenset({"web_search"}),
policies=["web_search"],
)
assert "Web Search Policy" in result
# ---------------------------------------------------------------------------
# 9. Unconditional policy not gated
# ---------------------------------------------------------------------------
def test_unconditional_policy() -> None:
"""A DB policy with no tool_gate is always included."""
db = [
{
"name": "custom_rule",
"content": "## Custom Rule\nAlways be polite.",
"tool_gate": "",
"priority": 0,
"enabled": True,
}
]
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_NO_TOOLS,
db_policies=db,
)
assert "Always be polite" in result
# ---------------------------------------------------------------------------
# 10. DB policy override
# ---------------------------------------------------------------------------
def test_db_policy_overrides_file() -> None:
"""DB policy with same name as file policy wins."""
db = [
{
"name": "web_search",
"content": "## DB Web Search Override\nCustom content.",
"tool_gate": "web_search",
"priority": 0,
"enabled": True,
}
]
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_ALL_TOOLS,
policies=["web_search"],
db_policies=db,
)
assert "DB Web Search Override" in result
assert "Use local tools" not in result # original file content
# ---------------------------------------------------------------------------
# 11. DB-only policy
# ---------------------------------------------------------------------------
def test_db_only_policy() -> None:
"""DB policy not in explicit list is still included."""
db = [
{
"name": "extra_rule",
"content": "## Extra\nDo not share secrets.",
"tool_gate": "",
"priority": 5,
"enabled": True,
}
]
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_NO_TOOLS,
db_policies=db,
)
assert "Do not share secrets" in result
# ---------------------------------------------------------------------------
# 12. Disabled DB policy
# ---------------------------------------------------------------------------
def test_disabled_db_policy() -> None:
"""DB policy with enabled=False is skipped."""
db = [
{
"name": "disabled_rule",
"content": "## Disabled\nThis should not appear.",
"tool_gate": "",
"priority": 0,
"enabled": False,
}
]
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_NO_TOOLS,
db_policies=db,
)
assert "This should not appear" not in result
# ---------------------------------------------------------------------------
# 13. ISO 8601 validation
# ---------------------------------------------------------------------------
def test_invalid_iso_datetime() -> None:
ctx = SessionContext(
current_datetime="not-a-date",
timezone="PDT",
username="alice",
)
with pytest.raises(ValueError, match="not valid ISO 8601"):
compose_system_message(ClientType.CLI, ctx, _NO_TOOLS)
# ---------------------------------------------------------------------------
# 14. DB policy priority ordering
# ---------------------------------------------------------------------------
def test_db_policy_priority_ordering() -> None:
"""DB-only policies are assembled in priority order (ascending)."""
db = [
{
"name": "second",
"content": "SECOND_MARKER",
"tool_gate": "",
"priority": 10,
"enabled": True,
},
{
"name": "first",
"content": "FIRST_MARKER",
"tool_gate": "",
"priority": 1,
"enabled": True,
},
]
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_NO_TOOLS,
db_policies=db,
)
first_pos = result.index("FIRST_MARKER")
second_pos = result.index("SECOND_MARKER")
assert first_pos < second_pos
# ---------------------------------------------------------------------------
# 15. TOOLS module excluded when no tools available
# ---------------------------------------------------------------------------
def test_tools_excluded_when_no_tools() -> None:
"""TOOLS module is not included when available_tools is empty."""
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_NO_TOOLS,
)
assert "TOOL PATTERNS" not in result
def test_coordinator_kind_selects_coord_tools() -> None:
"""kind='coordinator' swaps in tools_coordinator.md with the right patterns."""
coord_tools = frozenset(
{
"spawn_workstream",
"send_to_workstream",
"inspect_workstream",
"close_workstream",
"list_workstreams",
"list_nodes",
"list_skills",
"tasks",
}
)
result = compose_system_message(
ClientType.WEB,
_VALID_CTX,
coord_tools,
kind="coordinator",
)
# Coordinator tool patterns are present.
assert "spawn_workstream" in result
assert "inspect_workstream" in result
assert "tasks" in result
# IC tool patterns are NOT present — the model must not be instructed
# to call tools it doesn't have.
for phantom in (
"read_file",
"edit_file",
"write_file",
"bash",
"plan_agent",
"web_fetch",
"web_search",
):
assert phantom not in result, (
f"coordinator prompt must not advertise phantom tool {phantom!r}"
)
def test_coordinator_kind_uses_orchestrator_base() -> None:
"""kind='coordinator' swaps in personas/orchestrator.md."""
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
frozenset({"spawn_workstream"}),
kind="coordinator",
)
# IC-framing phrases from personas/engineer.md should NOT appear.
for ic_phrase in ("read before you edit", "commits you make"):
assert ic_phrase not in result, f"coordinator base leaked IC framing: {ic_phrase!r}"
# Orchestrator-framing phrases from personas/orchestrator.md should appear.
assert "orchestrate" in result
assert "delegate" in result
def test_coordinator_kind_skips_env_block() -> None:
"""Coordinators don't render rich output, so the ENV block is omitted.
Regression-locks the orchestration-vs-rendering split: a coordinator
composing a system message with any client_type must not pick up the
user-facing formatting principles (Mermaid / KaTeX / chat platform
quirks). client_type still validates — only the loaded content is
skipped.
"""
for ct in (ClientType.WEB, ClientType.CLI, ClientType.CHAT):
result = compose_system_message(
ct,
_VALID_CTX,
frozenset({"spawn_workstream"}),
kind="coordinator",
)
for env_phrase in ("Output Environment", "Available rendering", "Formatting principles"):
assert env_phrase not in result, f"coordinator on {ct} leaked ENV phrase {env_phrase!r}"
def test_interactive_kind_default_still_loads_ic_tools() -> None:
"""Default kind='interactive' still loads tools.md (no regression)."""
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_ALL_TOOLS,
)
assert "read_file" in result
assert "bash" in result
def test_tools_included_when_tools_available() -> None:
"""TOOLS module is included when available_tools is non-empty."""
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_ALL_TOOLS,
)
assert "TOOL PATTERNS" in result
def test_session_kind_in_context_interactive() -> None:
"""Default interactive kind appears next to the user line."""
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
_ALL_TOOLS,
)
assert "Session kind:** interactive" in result
def test_session_kind_in_context_coordinator() -> None:
"""Coordinator kind appears in the context block."""
result = compose_system_message(
ClientType.CLI,
_VALID_CTX,
frozenset({"spawn_workstream"}),
kind="coordinator",
)
assert "Session kind:** coordinator" in result