mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
dbf389783e
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.
474 lines
14 KiB
Python
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
|