Files
turnstone/tests/test_persona_guards.py
T
Patrick Buckley 0d52b63b50 feat(coordinator): nudge a coordinator that goes idle holding unfinished work
Two nudge classes can fire from one IDLE event, tasks first, each
asserting only its own domain.

idle_tasks (advice) fires when open (pending/in_progress) tasks
exist. The body is a counts opener, the open task ids with statuses,
and typed branches that each end in a runnable tasks(...) or
wait_for_workstream(...) call populated with real server-minted ids.
Everything it says about children is governed by one observed fact:
live children present adds the caveat sentence and the
blocked-on-a-child branch; affirmatively none says nothing about
children at all. Any needs_user row parks the class entirely — at the
fire gate and the drain predicate — because with no task graph an
open task may be gated on a parked one's unanswered question; the
operator's answer is the re-arm. Gated on memory.nudges and on the
persona actually exposing the tasks tool; carries the per-class
cooldown as well as the per-bracket cap. The tasks tool itself gains
the needs_user status and a note field — the typed escalation the
body's branches point at.

idle_children (liveness) fires when children are in a live state —
the wake that lets an idle coordinator collect a finished child's
results. The body is a roster of workstream id prefixes and states,
never names: a child's name is model-authored text and does not enter
a system turn. Cap-only and cooldown-free by design, not gated on
memory.nudges, and it survives an operator Stop.

Fail-closed, event-wide: if any storage read fails while the observer
handles an IDLE event, neither nudge is queued and neither cap is
charged. Both paths run as side-effect-free plans; the commit tail is
storage-free, so no read can fail past the veto point; both drain
predicates drop on a failed read. A path's own fault (a generic
raise) still costs only that path's fire, so one class's bug cannot
strand the other.

Task text is stored verbatim and projected per audience at render:
the model-facing projection deletes angle brackets, the operator
projection keeps them, and both strip newlines and bidi/zero-width
runs. Idle cards render what the model was told, formatted for the
operator, never augmented with content the model did not receive.
2026-07-29 22:11:38 -07:00

1545 lines
66 KiB
Python

"""Guard suite for personas (#683) — the invariants the feature must hold.
Each test pins one of the locked design decisions: personas shape the
persona's own hands (composition + tool visibility) without ever weakening
the approval path, task-agent identity, compaction mechanics, or the
stamped-at-create isolation from later persona edits.
"""
from __future__ import annotations
import json
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from turnstone.core.personas import PersonaSnapshot, snapshot_from_persona
from turnstone.core.session import ChatSession
from turnstone.core.storage import get_storage
from turnstone.core.storage._utils import PERSONA_MUTABLE
from turnstone.core.tools import TASK_AGENT_TOOLS
from turnstone.core.workstream import WorkstreamKind
def _snap(
*,
name: str = "guard",
prompt: str = "",
tools: frozenset[str] | None = None,
mcp: bool = True,
memory: bool = True,
) -> PersonaSnapshot:
return PersonaSnapshot(name=name, prompt=prompt, tools=tools, mcp=mcp, memory=memory)
def _session(mock_openai_client: Any, **kwargs: Any) -> ChatSession:
defaults: dict[str, Any] = dict(
client=mock_openai_client,
model="local-model",
ui=MagicMock(),
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
)
defaults.update(kwargs)
return ChatSession(**defaults)
def _wire_names(session: ChatSession) -> list[str]:
from turnstone.core.providers._protocol import ModelCapabilities
caps = ModelCapabilities(supports_web_search=True)
with patch.object(session, "_get_capabilities", return_value=caps):
tools = session._get_active_tools() or []
return [t.get("function", {}).get("name") for t in tools]
# ---------------------------------------------------------------------------
# Guard 1 — rank guard: a gated tool call under ANY persona still hits the
# normal approval path. Persona visibility shapes what's advertised, never
# what's approved.
# ---------------------------------------------------------------------------
class TestRankGuard:
def test_approval_path_unchanged_under_persona(self, tmp_db, mock_openai_client) -> None:
# bash is IN the persona's allowlist, so it survives the visibility
# filter — but the REAL preparer must still derive needs_approval
# from the tool (not a patched stub), and the gated call must still
# route through ui.approve_tools. Persona visibility shapes what's
# advertised, never what's approved.
session = _session(
mock_openai_client,
persona_snapshot=_snap(tools=frozenset({"bash"})),
)
tc = {
"id": "c1",
"function": {"name": "bash", "arguments": json.dumps({"command": "echo hi"})},
}
# The real _prepare_bash dispatch derives the approval flag.
prepared = session._prepare_tool(tc)
assert prepared["needs_approval"] is True
session.ui.approve_tools.return_value = (True, None)
with (
patch.object(session, "_exec_bash", return_value=("c1", "ok")) as exec_bash,
patch.object(session, "_evaluate_intent"),
patch.object(session, "_emit_state"),
patch.object(session, "_check_cancelled"),
):
session._execute_tools([tc])
session.ui.approve_tools.assert_called_once()
# The item the gate actually saw carried the derived flag.
gated = session.ui.approve_tools.call_args.args[0]
assert gated[0]["needs_approval"] is True
exec_bash.assert_called_once()
# ---------------------------------------------------------------------------
# Guard 2 — empty-toolset persona: NO tools block in the composed prompt,
# zero tool definitions on the wire. (The wire half also lives in
# test_server_live.py::test_empty_toolset_persona_no_tools_on_wire, which
# asserts the provider call itself.)
# ---------------------------------------------------------------------------
class TestEmptyToolset:
def test_prompt_has_no_tools_block_and_wire_is_empty(self, tmp_db, mock_openai_client) -> None:
session = _session(mock_openai_client, persona_snapshot=_snap(tools=frozenset()))
prompt = session.system_messages[0]["content"]
# tools.md's IC block opener — self-suppressed on an empty envelope.
assert "read_file" not in prompt
assert _wire_names(session) == []
# Even with memories IN SCOPE, the "memories in scope" advisory must
# not compose — the empty toolset hides the memory tool, and the
# preamble must never point the model at a tool the wire omits. The
# prior `"You have" not in prompt or ...` disjunction was vacuous
# (no memory was ever in scope, so the branch was unreachable).
fake = [{"memory_id": "m1", "name": "n", "scope": "user", "scope_id": "u", "content": "c"}]
with patch.object(session, "_select_memory_candidates", return_value=(fake, "recency")):
session._init_system_messages()
assert "memories in scope" not in session.system_messages[0]["content"]
def test_base_override_replaces_only_base(self, tmp_db, mock_openai_client) -> None:
session = _session(
mock_openai_client,
persona_snapshot=_snap(prompt="You are a scribe on a guard test.", tools=frozenset()),
)
prompt = session.system_messages[0]["content"]
assert "You are a scribe on a guard test." in prompt
# personas/engineer.md's IC framing is REPLACED...
assert "read before you edit" not in prompt
# ...but CONTEXT still composes (the removed /creative fork dropped it).
assert "Current time:" in prompt or "Session context" in prompt or "User:" in prompt
# ---------------------------------------------------------------------------
# Guard 3 — tool_search escape hatch: included ⇒ soft set (discovered tools
# join the visible wire); omitted ⇒ hard set (the pathway is disabled).
# ---------------------------------------------------------------------------
class TestToolSearchEscape:
def _mcp_client(self) -> MagicMock:
mcp = MagicMock()
mcp.get_tools.return_value = [
{
"type": "function",
"function": {"name": "mcp_widget", "description": "widget", "parameters": {}},
}
]
mcp.resource_count_for_user.return_value = 0
mcp.prompt_count_for_user.return_value = 0
return mcp
def test_included_keeps_pathway_and_unions_discovered(self, tmp_db, mock_openai_client) -> None:
session = _session(
mock_openai_client,
mcp_client=self._mcp_client(),
tool_search="on",
persona_snapshot=_snap(tools=frozenset({"read_file", "tool_search"})),
)
assert session._tool_search is not None
names = _wire_names(session)
assert set(names) == {"read_file", "tool_search"}
# Discovery expands the visible set — the allowlist unions with it.
session._tool_search.expand_visible(["mcp_widget"])
names = _wire_names(session)
assert "mcp_widget" in names
def test_soft_set_survives_global_setting_off(self, tmp_db, mock_openai_client) -> None:
# The authored escape hatch must not silently degrade to a hard set
# on deployments where the global setting/threshold wouldn't have
# constructed a ToolSearchManager.
session = _session(
mock_openai_client,
mcp_client=self._mcp_client(),
tool_search="off",
persona_snapshot=_snap(tools=frozenset({"read_file", "tool_search"})),
)
assert session._tool_search is not None
assert "tool_search" in _wire_names(session)
def test_mid_session_adopt_of_hard_set_drops_tool_search(
self, tmp_db, mock_openai_client
) -> None:
from turnstone.core.memory import register_workstream, save_workstream_config
# Unstamped session with a live ToolSearchManager and a discovered tool.
session = _session(mock_openai_client, mcp_client=self._mcp_client(), tool_search="on")
assert session._tool_search is not None
session._tool_search.expand_visible(["mcp_widget"])
# Adopt a hard-set (scribe-shaped) stamp via non-fork resume.
register_workstream("t" * 32)
from turnstone.core.memory import save_message
save_message("t" * 32, "user", "hi")
save_workstream_config(
"t" * 32, _snap(name="scribe", tools=frozenset(), memory=False).to_config()
)
assert session.resume("t" * 32)
# The pathway is re-gated: no manager, no hint target, no escape hatch.
assert session._tool_search is None
assert _wire_names(session) == []
def test_omitted_is_hard(self, tmp_db, mock_openai_client) -> None:
session = _session(
mock_openai_client,
mcp_client=self._mcp_client(),
tool_search="on",
persona_snapshot=_snap(tools=frozenset({"read_file"})),
)
# The whole pathway is disabled — covers provider-native
# defer_loading mode, which has no synthetic name to filter.
assert session._tool_search is None
assert session._get_deferred_names() is None
assert set(_wire_names(session)) == {"read_file"}
# ...and it stays disabled across an MCP catalog refresh.
session._rebuild_tool_search()
assert session._tool_search is None
def test_soft_set_expansion_recomposes_prompt(self, tmp_db, mock_openai_client) -> None:
# A soft set composed the prompt against the pre-expansion visible
# names, so tool-gated policy segments for a just-discovered tool
# were dropped. Expanding a NEW name via tool_search must recompose
# so the operator's guidance lands with the tool — but a repeat
# (already-expanded) discovery must NOT pay the recompose again.
session = _session(
mock_openai_client,
mcp_client=self._mcp_client(),
tool_search="on",
persona_snapshot=_snap(tools=frozenset({"read_file", "tool_search"})),
)
assert session._tool_search is not None
widget = next(
t
for t in session._tool_search.get_deferred_tools()
if t["function"]["name"] == "mcp_widget"
)
with patch.object(session._tool_search, "search", return_value=[widget]):
with patch.object(session, "_init_system_messages") as recompose:
session._exec_tool_search({"query": "widget", "call_id": "c1"})
recompose.assert_called_once()
# Second discovery of the same name adds nothing new — no recompose.
with patch.object(session, "_init_system_messages") as recompose_again:
session._exec_tool_search({"query": "widget", "call_id": "c2"})
recompose_again.assert_not_called()
def test_legacy_expansion_does_not_recompose(self, tmp_db, mock_openai_client) -> None:
# Without a persona set (_persona_tools is None) the prompt is
# composed against the full catalog, so a tool_search expansion
# needn't recompose — the soft-set recompose is persona-specific.
session = _session(mock_openai_client, mcp_client=self._mcp_client(), tool_search="on")
assert session._tool_search is not None
assert session._persona_tools is None
widget = next(
t
for t in session._tool_search.get_deferred_tools()
if t["function"]["name"] == "mcp_widget"
)
with patch.object(session._tool_search, "search", return_value=[widget]):
with patch.object(session, "_init_system_messages") as recompose:
session._exec_tool_search({"query": "widget", "call_id": "c1"})
recompose.assert_not_called()
# ---------------------------------------------------------------------------
# Guard 4 — memory-off: no recall injection, memory tool hidden, memory
# nudges suppressed; compaction mechanics stay untouched.
# ---------------------------------------------------------------------------
class TestMemoryOff:
def test_memory_levers(self, tmp_db, mock_openai_client) -> None:
session = _session(mock_openai_client, persona_snapshot=_snap(memory=False))
with patch.object(session, "_select_memory_candidates") as select:
session._init_system_messages()
select.assert_not_called()
assert "memory" not in _wire_names(session)
# Memory-directed nudges are suppressed; behavioural nudges stay.
session._memory_config.nudges = True
assert not session._nudges_enabled("start")
assert not session._nudges_enabled("tool_error")
assert session._nudges_enabled("repeat")
assert session._nudges_enabled("compaction_pending")
def test_allowlist_hiding_memory_tool_also_gates_nudges(
self, tmp_db, mock_openai_client
) -> None:
# memory lever ON but the visibility set omits the memory tool —
# nudges directing the model at memory(...) must not fire either.
session = _session(
mock_openai_client,
persona_snapshot=_snap(tools=frozenset({"read_file"}), memory=True),
)
session._memory_config.nudges = True
assert not session._nudges_enabled("start")
assert session._nudges_enabled("repeat")
def test_recall_pointer_gates_on_visibility(self, tmp_db, mock_openai_client) -> None:
# scribe-shaped: empty toolset hides recall — the compaction pointer
# must not direct the model at a tool it can't call.
hidden = _session(mock_openai_client, persona_snapshot=_snap(tools=frozenset()))
assert not hidden._persona_tool_visible("recall")
open_hands = _session(mock_openai_client, persona_snapshot=_snap())
assert open_hands._persona_tool_visible("recall")
@staticmethod
def _drive_advised_compaction(session: ChatSession) -> tuple[Any, Any]:
"""Drive a REAL advised-stop compaction + auto-resume through send().
Mirrors ``test_cooperative_compaction.py``'s end-to-end recipe: the
model pauses mid-task (latching ``_compaction_advised``), the turn is
over-threshold, a real summary is produced via ``_utility_completion``,
and the loop hands a ``compaction_resume`` user turn back. Returns the
``_utility_completion`` and ``_append_user_turn`` spies so callers can
assert the spill happened and which resume-nudge variant fired.
"""
from types import SimpleNamespace
from turnstone.core.trajectory import turns_from_dicts
session.messages = turns_from_dicts(
[
{"role": "user", "content": "do the task"},
{"role": "assistant", "content": "on it"},
]
)
session._msg_tokens = [5, 5]
session._title_generated = True
session.compact_max_tokens = 100
session._system_tokens = 0
summary = SimpleNamespace(content="## Open tasks\nfinish it", finish_reason="stop")
n = {"i": 0}
def stream(*_a: Any, **_k: Any) -> dict[str, str]:
n["i"] += 1
if n["i"] == 1:
session._compaction_advised = True # advisory fired this turn
return {"role": "assistant", "content": "pausing to compact"}
return {"role": "assistant", "content": "all done"}
def est(*_a: Any, **_k: Any) -> int:
return 9_999 if n["i"] <= 1 else 10 # over threshold only on the stop turn
with (
patch.object(session, "_create_stream_with_retry", return_value=iter([])),
patch.object(session, "_stream_response", side_effect=stream),
patch.object(session, "_full_messages", return_value=[]),
patch.object(session, "_update_token_table"),
patch.object(session, "_print_status_line"),
patch.object(session, "_emit_state"),
patch.object(session, "_estimated_prompt_tokens", side_effect=est),
patch.object(session, "_utility_completion", return_value=summary) as uc,
patch.object(session, "_append_user_turn", wraps=session._append_user_turn) as resume,
patch("turnstone.core.session.save_message"),
):
session.send("go")
return uc, resume
def test_real_compaction_recall_pointer_when_recall_visible(
self, tmp_db, mock_openai_client
) -> None:
# memory-off hides only the memory tool — recall stays visible, so an
# actual compaction spills a summary AND the resume nudge points at
# recall (NUDGE_COMPACTION_RESUME).
from tests._session_helpers import make_session
from turnstone.core.metacognition import NUDGE_COMPACTION_RESUME
session = make_session(
client=mock_openai_client,
context_window=10_000,
max_tokens=1_000,
tool_timeout=10,
persona_snapshot=_snap(memory=False),
)
assert session._persona_tool_visible("recall")
uc, resume = self._drive_advised_compaction(session)
assert uc.call_count >= 1 # a real summary was produced (the spill)
resume_calls = [
c for c in resume.call_args_list if c.kwargs.get("source") == "compaction_resume"
]
assert len(resume_calls) == 1
assert resume_calls[0].args[0] == NUDGE_COMPACTION_RESUME
def test_real_compaction_recall_pointer_when_recall_hidden(
self, tmp_db, mock_openai_client
) -> None:
# scribe-shaped empty toolset hides recall — the spill still happens
# but the resume nudge switches to the no-recall variant.
from tests._session_helpers import make_session
from turnstone.core.metacognition import NUDGE_COMPACTION_RESUME_NO_RECALL
session = make_session(
client=mock_openai_client,
context_window=10_000,
max_tokens=1_000,
tool_timeout=10,
persona_snapshot=_snap(tools=frozenset()),
)
assert not session._persona_tool_visible("recall")
uc, resume = self._drive_advised_compaction(session)
assert uc.call_count >= 1
resume_calls = [
c for c in resume.call_args_list if c.kwargs.get("source") == "compaction_resume"
]
assert len(resume_calls) == 1
assert resume_calls[0].args[0] == NUDGE_COMPACTION_RESUME_NO_RECALL
# ---------------------------------------------------------------------------
# Guard 5 — MCP-off is session-wide: no merge into _tools OR _task_tools,
# no listeners, refresh callback inert.
# ---------------------------------------------------------------------------
class TestMcpOff:
def test_session_wide_gate(self, tmp_db, mock_openai_client) -> None:
mcp = MagicMock()
mcp.get_tools.return_value = [
{"type": "function", "function": {"name": "mcp_widget", "parameters": {}}}
]
session = _session(
mock_openai_client,
mcp_client=mcp,
persona_snapshot=_snap(mcp=False),
)
assert session._mcp_client is None
names = {t["function"]["name"] for t in session._tools if "function" in t}
assert "mcp_widget" not in names
task_names = {t["function"]["name"] for t in session._task_tools if "function" in t}
assert "mcp_widget" not in task_names
mcp.add_listener.assert_not_called()
mcp.add_resource_listener.assert_not_called()
mcp.add_prompt_listener.assert_not_called()
# A late catalog change can't re-merge.
session._on_mcp_tools_changed()
names_after = {t["function"]["name"] for t in session._tools if "function" in t}
assert "mcp_widget" not in names_after
def test_memory_off_does_not_touch_task_tools(self, tmp_db, mock_openai_client) -> None:
# The deliberate asymmetry with guard 5: memory-off shapes the
# persona's OWN hands; task agents keep their identity/envelope.
session = _session(mock_openai_client, persona_snapshot=_snap(memory=False))
assert [t["function"]["name"] for t in session._task_tools] == [
t["function"]["name"] for t in TASK_AGENT_TOOLS
]
# ---------------------------------------------------------------------------
# Guard 6 — spawn: persona honored + validated at prep time; omitted means
# the KIND default (resolved server-side at child creation), never the
# parent's persona.
# ---------------------------------------------------------------------------
class TestSpawnPersona:
def _coord_session(self, mock_openai_client: Any) -> ChatSession:
return _session(
mock_openai_client,
kind=WorkstreamKind.COORDINATOR,
user_id="u1",
coord_client=MagicMock(),
)
def test_unknown_persona_is_clean_tool_error(self, tmp_db, mock_openai_client) -> None:
session = self._coord_session(mock_openai_client)
item = session._prepare_spawn_workstream("c1", {"persona": "nope"})
assert item.get("error")
assert "nope" in item["error"]
def test_kind_mismatch_is_clean_tool_error(self, tmp_db, mock_openai_client) -> None:
get_storage().create_persona(
{
"persona_id": "px",
"name": "coord-only",
"base_prompt": "C",
"applies_to_kinds": ["coordinator"],
}
)
session = self._coord_session(mock_openai_client)
item = session._prepare_spawn_workstream("c1", {"persona": "coord-only"})
assert item.get("error")
assert "interactive" in item["error"]
def test_valid_persona_travels_to_spawn_body(self, tmp_db, mock_openai_client) -> None:
get_storage().create_persona(
{
"persona_id": "py",
"name": "scribe",
"base_prompt": "S",
"applies_to_kinds": ["interactive"],
}
)
session = self._coord_session(mock_openai_client)
item = session._prepare_spawn_workstream("c1", {"persona": "scribe"})
assert not item.get("error")
assert item["persona"] == "scribe"
session._coord_client.spawn.return_value = {"ws_id": "w" * 32, "name": "child"}
with patch.object(session, "_report_tool_result"):
session._exec_spawn_workstream(item)
assert session._coord_client.spawn.call_args.kwargs["persona"] == "scribe"
def test_omitted_persona_is_not_inherited(self, tmp_db, mock_openai_client) -> None:
# The parent coordinator has its own persona; the child body must
# NOT carry it — the receiving node resolves the interactive
# default at child-creation time.
session = _session(
mock_openai_client,
kind=WorkstreamKind.COORDINATOR,
user_id="u1",
coord_client=MagicMock(),
persona_snapshot=_snap(name="executive"),
)
item = session._prepare_spawn_workstream("c1", {"initial_message": "go"})
assert item["persona"] == ""
session._coord_client.spawn.return_value = {"ws_id": "w" * 32, "name": "child"}
with patch.object(session, "_report_tool_result"):
session._exec_spawn_workstream(item)
assert session._coord_client.spawn.call_args.kwargs["persona"] == ""
# ---------------------------------------------------------------------------
# Guard 7 — task_agent HAS a persona parameter: a sub-agent's identity comes
# from a persona (default = the built-in task-agent identity), validated at
# prep against the interactive kind. Revises the original "no persona for
# task agents" stance now that personas are first-class on every path.
# ---------------------------------------------------------------------------
def test_task_agent_schema_has_persona_param() -> None:
from turnstone.core.tools import TOOLS
task_agent = next(t for t in TOOLS if t["function"]["name"] == "task_agent")
props = task_agent["function"]["parameters"]["properties"]
assert "persona" in props
# skill= is capability now — the description frames it that way.
assert "capability" in props["skill"]["description"].lower()
# ---------------------------------------------------------------------------
# Guard 8 — immutability: nothing mutates a workstream's persona post-create.
# The stamp is written from constructor attrs; config rewrites re-emit the
# SAME stamp, and the persona row's slug is immutable.
# ---------------------------------------------------------------------------
class TestImmutability:
def test_save_config_reemits_same_stamp(self, tmp_db, mock_openai_client) -> None:
from turnstone.core.memory import load_workstream_config
snap = _snap(name="scribe", prompt="P", tools=frozenset({"read_file"}), memory=False)
session = _session(mock_openai_client, ws_id="w1" * 16, persona_snapshot=snap)
before = {
k: v for k, v in load_workstream_config("w1" * 16).items() if k.startswith("persona")
}
session.temperature = 0.9 # any config-touching change
session._save_config()
after = {
k: v for k, v in load_workstream_config("w1" * 16).items() if k.startswith("persona")
}
assert before == after == snap.to_config()
def test_persona_slug_is_immutable_in_storage(self) -> None:
assert "name" not in PERSONA_MUTABLE
def test_legacy_ws_never_gets_backstamped(self, tmp_db, mock_openai_client) -> None:
from turnstone.core.memory import load_workstream_config
session = _session(mock_openai_client, ws_id="w2" * 16) # no persona
session._save_config()
keys = load_workstream_config("w2" * 16)
assert not any(k.startswith("persona") for k in keys)
# ---------------------------------------------------------------------------
# Guard 9 — the stamp survives rehydrate: SessionManager.open threads it
# pre-construction (the same lane as the saved model alias).
# ---------------------------------------------------------------------------
# (resume()-adoption is covered in test_sessions.py::test_resume_restores_config)
class TestRehydrateThreading:
def test_open_threads_snapshot_into_build_session(self) -> None:
from tests.test_session_manager import FakeAdapter, _make_manager
class RecordingAdapter(FakeAdapter):
def __init__(self) -> None:
super().__init__()
self.last_build_kwargs: dict[str, Any] = {}
def build_session(self, ws: Any, **kwargs: Any) -> Any:
self.last_build_kwargs = dict(kwargs)
return super().build_session(ws, **{"model": kwargs.get("model")})
adapter = RecordingAdapter()
mgr, _, storage = _make_manager(adapter)
ws = mgr.create(user_id="u1", persona="scribe")
ws_id = ws.id
snap = _snap(name="scribe", tools=frozenset(), mcp=False, memory=False)
storage.ws_config[ws_id] = snap.to_config()
mgr.close(ws_id)
reopened = mgr.open(ws_id)
assert reopened is not None
assert reopened.persona == "scribe"
assert adapter.last_build_kwargs["persona_snapshot"] == snap
def test_corrupt_stamp_fails_loudly_never_falls_back(self) -> None:
from tests.test_session_manager import _make_manager
mgr, _, storage = _make_manager()
ws = mgr.create(user_id="u1")
ws_id = ws.id
# Partial stamp = corruption (missing companions).
storage.ws_config[ws_id] = {"persona": "scribe"}
mgr.close(ws_id)
with pytest.raises(ValueError, match="corrupt persona snapshot"):
mgr.open(ws_id)
def test_corrupt_stamp_releases_the_slot(self) -> None:
# The parse raise must unwind exactly like a build_session failure:
# the placeholder slot is released (no max_active pin) and a retry
# raises the SAME loud error instead of "already tracked".
from tests.test_session_manager import _make_manager
mgr, _, storage = _make_manager()
ws = mgr.create(user_id="u1")
ws_id = ws.id
storage.ws_config[ws_id] = {"persona": "scribe"}
mgr.close(ws_id)
with pytest.raises(ValueError, match="corrupt persona snapshot"):
mgr.open(ws_id)
assert mgr.get(ws_id) is None # slot released, not a stuck placeholder
with pytest.raises(ValueError, match="corrupt persona snapshot"):
mgr.open(ws_id) # retry reproduces the loud error, not RuntimeError
def test_stamp_survives_compaction_then_resume(self, tmp_db, mock_openai_client) -> None:
# Compaction rewrites the conversation, never the persona stamp (it
# lives in workstream_config). After a real compaction on a stamped
# workstream, a fresh resume re-adopts all five levers intact.
from types import SimpleNamespace
from turnstone.core.memory import (
register_workstream,
save_message,
save_workstream_config,
)
from turnstone.core.trajectory import turns_from_dicts
snap = _snap(
name="scribe", prompt="P", tools=frozenset({"read_file"}), mcp=False, memory=False
)
ws_id = "w" * 32
register_workstream(ws_id)
save_message(ws_id, "user", "do the thing")
save_message(ws_id, "assistant", "did the thing")
save_workstream_config(ws_id, snap.to_config())
session = _session(mock_openai_client, ws_id=ws_id, persona_snapshot=snap)
session.messages = turns_from_dicts(
[
{"role": "user", "content": "do the thing"},
{"role": "assistant", "content": "did the thing"},
]
)
session._msg_tokens = [5, 5]
session.compact_max_tokens = 100
session._system_tokens = 0
summary = SimpleNamespace(content="## Decisions\ndense", finish_reason="stop")
with patch.object(session, "_utility_completion", return_value=summary):
assert session._compact_messages(auto=True) is True
fresh = _session(mock_openai_client)
assert fresh.resume(ws_id)
assert fresh._persona_name == "scribe"
assert fresh._persona_prompt == "P"
assert fresh._persona_tools == frozenset({"read_file"})
assert fresh._persona_mcp is False
assert fresh._persona_memory is False
# ---------------------------------------------------------------------------
# Guard 9, resume-adoption lane — a mid-session ``resume()`` parses the target
# stamp BEFORE mutating session state: a corrupt stamp leaves this session
# intact; an MCP-on stamp is refused by an MCP-gated-off session; an MCP-off
# stamp narrows the live MCP surface in place.
# ---------------------------------------------------------------------------
class TestResumeAdoption:
@staticmethod
def _mcp() -> MagicMock:
mcp = MagicMock()
mcp.get_tools.return_value = [
{"type": "function", "function": {"name": "mcp_widget", "parameters": {}}}
]
mcp.resource_count_for_user.return_value = 0
mcp.prompt_count_for_user.return_value = 0
return mcp
def test_corrupt_target_stamp_leaves_session_intact(self, tmp_db, mock_openai_client) -> None:
from turnstone.core.memory import (
load_workstream_config,
register_workstream,
save_message,
save_workstream_config,
)
a_id, b_id = "a" * 32, "b" * 32
register_workstream(a_id)
save_message(a_id, "user", "hi from A")
session = _session(mock_openai_client)
assert session.resume(a_id) # this session lives on A
before_msgs = list(session.messages)
register_workstream(b_id)
save_message(b_id, "user", "hi from B")
save_workstream_config(b_id, {"persona": "scribe"}) # partial = corrupt
b_config_before = load_workstream_config(b_id)
with pytest.raises(ValueError, match="corrupt persona snapshot"):
session.resume(b_id)
# Parse-before-mutate: identity + history untouched, still on A.
assert session._ws_id == a_id
assert session.messages == before_msgs
# ...and a later config save writes A's row, never repairs B's stamp
# with a persona the operator never chose for B.
session._save_config()
assert load_workstream_config(b_id) == b_config_before
def test_mcp_on_stamp_refused_when_gated_off(self, tmp_db, mock_openai_client) -> None:
from turnstone.core.memory import (
register_workstream,
save_message,
save_workstream_config,
)
# A real client was withheld by the persona gate → _mcp_gated_off.
session = _session(
mock_openai_client, mcp_client=self._mcp(), persona_snapshot=_snap(mcp=False)
)
assert session._mcp_gated_off is True
b_id = "b" * 32
register_workstream(b_id)
save_message(b_id, "user", "hi")
save_workstream_config(b_id, _snap(name="scribe", mcp=True).to_config())
with pytest.raises(ValueError, match="open the workstream fresh"):
session.resume(b_id)
def test_mcp_off_stamp_narrows_in_place(self, tmp_db, mock_openai_client) -> None:
from turnstone.core.memory import (
register_workstream,
save_message,
save_workstream_config,
)
mcp = self._mcp()
session = _session(mock_openai_client, mcp_client=mcp) # legacy: MCP live
assert session._mcp_client is mcp
assert "mcp_widget" in {t["function"]["name"] for t in session._tools if "function" in t}
b_id = "b" * 32
register_workstream(b_id)
save_message(b_id, "user", "hi")
save_workstream_config(b_id, _snap(name="scribe", mcp=False).to_config())
assert session.resume(b_id)
# Surface dropped in place: client gone, no MCP tools on either set.
assert session._mcp_client is None
assert "mcp_widget" not in {
t["function"]["name"] for t in session._tools if "function" in t
}
assert "mcp_widget" not in {
t["function"]["name"] for t in session._task_tools if "function" in t
}
# ...and the three listeners were deregistered on the way out.
mcp.remove_listener.assert_called()
mcp.remove_resource_listener.assert_called()
mcp.remove_prompt_listener.assert_called()
# ---------------------------------------------------------------------------
# Guard 10 — mandatory prompt policies compose under EVERY persona, including
# empty-toolset ones; tool-gated policies drop with their tool.
# ---------------------------------------------------------------------------
class TestPolicyComposition:
def test_db_policy_rides_on_top_of_override(self) -> None:
from turnstone.prompts import ClientType, SessionContext, compose_system_message
ctx = SessionContext(current_datetime="2026-07-02T10:00", timezone="UTC", username="guard")
policies = [
{"name": "mandatory", "content": "ALWAYS-ON-POLICY", "enabled": True},
{
"name": "gated",
"content": "BASH-GATED-POLICY",
"tool_gate": "bash",
"enabled": True,
},
]
composed = compose_system_message(
ClientType.CLI,
ctx,
frozenset(), # empty visible set — scribe-shaped
db_policies=policies,
base_override="You are a scribe.",
)
assert "You are a scribe." in composed
assert "ALWAYS-ON-POLICY" in composed
assert "BASH-GATED-POLICY" not in composed # its tool is hidden
# ---------------------------------------------------------------------------
# Guard 11 — CLI --persona resolution: seed persona loads; unknown name
# errors clearly at startup; --resume adopts the target's stamp.
# ---------------------------------------------------------------------------
class TestCliPersona:
def test_seed_persona_loads(self, tmp_db) -> None:
from turnstone.cli import resolve_cli_persona_kwargs
storage = get_storage()
storage.create_persona(
{
"persona_id": "p1",
"name": "writer",
"base_prompt": "W",
"tool_allowlist": [],
"mcp_enabled": False,
"applies_to_kinds": ["interactive"],
}
)
kwargs = resolve_cli_persona_kwargs(storage, "writer", None)
assert kwargs["persona"] == "writer"
assert kwargs["persona_snapshot"].tools == frozenset()
def test_unknown_name_exits(self, tmp_db, capsys) -> None:
from turnstone.cli import resolve_cli_persona_kwargs
with pytest.raises(SystemExit):
resolve_cli_persona_kwargs(get_storage(), "nope", None)
assert "not found or disabled" in capsys.readouterr().out
def test_kind_mismatch_exits(self, tmp_db) -> None:
from turnstone.cli import resolve_cli_persona_kwargs
storage = get_storage()
storage.create_persona(
{
"persona_id": "p2",
"name": "exec",
"base_prompt": "E",
"applies_to_kinds": ["coordinator"],
}
)
with pytest.raises(SystemExit):
resolve_cli_persona_kwargs(storage, "exec", None)
def test_resume_adopts_target_stamp(self, tmp_db) -> None:
from turnstone.cli import resolve_cli_persona_kwargs
from turnstone.core.memory import save_workstream_config
snap = _snap(name="scribe", tools=frozenset(), mcp=False, memory=False)
save_workstream_config("t" * 32, snap.to_config())
kwargs = resolve_cli_persona_kwargs(get_storage(), None, "t" * 32)
assert kwargs["persona_snapshot"] == snap
def test_no_default_yields_legacy(self, tmp_db) -> None:
from turnstone.cli import resolve_cli_persona_kwargs
assert resolve_cli_persona_kwargs(get_storage(), None, None) == {}
# ---------------------------------------------------------------------------
# Guard 13 — template independence: edit AND archive the persona after
# creating a workstream from it — the stamp is untouched.
# ---------------------------------------------------------------------------
class TestTemplateIndependence:
def test_edit_and_archive_leave_stamp_alone(self, tmp_db, mock_openai_client) -> None:
from turnstone.core.memory import load_workstream_config
from turnstone.core.personas import snapshot_from_config
storage = get_storage()
storage.create_persona(
{
"persona_id": "p1",
"name": "scribe",
"base_prompt": "ORIGINAL",
"tool_allowlist": [],
"mcp_enabled": False,
"memory_enabled": False,
"applies_to_kinds": ["interactive"],
}
)
row = storage.get_persona("p1")
assert row is not None
session = _session(
mock_openai_client, ws_id="w3" * 16, persona_snapshot=snapshot_from_persona(row)
)
original = session.system_messages[0]["content"]
assert "ORIGINAL" in original
storage.update_persona("p1", base_prompt="EDITED", tool_allowlist=None)
storage.update_persona("p1", enabled=False)
stamped = snapshot_from_config(load_workstream_config("w3" * 16))
assert stamped is not None
assert stamped.prompt == "ORIGINAL"
assert stamped.tools == frozenset()
# A fresh construction from the stamp reproduces the ORIGINAL prompt.
rehydrated = _session(mock_openai_client, ws_id="w3" * 16, persona_snapshot=stamped)
assert "ORIGINAL" in rehydrated.system_messages[0]["content"]
assert "EDITED" not in rehydrated.system_messages[0]["content"]
# ---------------------------------------------------------------------------
# Guard 15 — the collector's delta path carries persona on the rows it
# builds from ws_created events (the saved-list twin lives in
# test_saved_handler_unified.py). Both ws_created builders are exercised
# behaviourally: the poll-diff additions path (_reconcile_node) and the
# SSE-relay path (_apply_delta). Precedent: test_console.py::TestCollectorDelta.
# ---------------------------------------------------------------------------
def _persona_collector() -> Any:
from tests._coord_test_helpers import MockStorage
from turnstone.console.collector import ClusterCollector
return ClusterCollector(storage=MockStorage(), discovery_interval=999)
def test_ws_created_reconcile_lane_carries_persona() -> None:
# Poll-diff additions: a freshly-appeared workstream becomes a ws_created
# event AND is stored on the node snapshot — persona rides both.
from turnstone.console.collector import NodeSnapshot
c = _persona_collector()
node = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
c._nodes["node-a"] = node
pending = c._reconcile_node(
"node-a",
node,
[{"id": "ws1", "name": "n", "state": "idle", "kind": "interactive", "persona": "scribe"}],
)
created = [e for e in pending if e["type"] == "ws_created"]
assert len(created) == 1
assert created[0]["persona"] == "scribe"
assert node.workstreams["ws1"]["persona"] == "scribe"
def test_ws_created_apply_delta_lane_carries_persona() -> None:
# SSE relay: a node's ws_created delta fans out to console listeners AND
# seeds node.workstreams — persona rides the emitted row and the store.
import queue
from turnstone.console.collector import NodeSnapshot
c = _persona_collector()
c._nodes["node-a"] = NodeSnapshot(node_id="node-a", server_url="http://a:8080")
q: queue.Queue[dict[str, Any]] = queue.Queue()
c.register_listener(q)
c._apply_delta(
"node-a",
{"type": "ws_created", "ws_id": "ws1", "name": "new", "persona": "scribe"},
)
event = q.get_nowait()
assert event["type"] == "ws_created"
assert event["persona"] == "scribe"
assert c._nodes["node-a"].workstreams["ws1"]["persona"] == "scribe"
def test_snapshot_roundtrip_via_json() -> None:
# The stamp's config form is plain strings — JSON-safe end to end.
snap = _snap(name="s", prompt="p", tools=frozenset({"a"}), mcp=False, memory=False)
assert json.loads(json.dumps(snap.to_config())) == snap.to_config()
# ---------------------------------------------------------------------------
# Guard 9, fork lane — creating with ``resume_ws`` adopts the SOURCE
# workstream's stamp at construction time (all four levers, including the
# construction-time MCP gate), never the kind default; a corrupt source
# stamp is a loud 400; an unstamped legacy source forks unstamped.
# ---------------------------------------------------------------------------
class TestForkAdoptsStamp:
@pytest.fixture()
def _fork_app(self, tmp_db):
"""The production ``make_create_handler`` over a real SessionManager,
with a session factory that forwards ``persona_snapshot`` (the same
contract the server factory honors)."""
import queue
import threading
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
from turnstone.core.adapters.interactive_adapter import InteractiveAdapter
from turnstone.core.auth import AuthResult
from turnstone.core.session_manager import SessionManager
from turnstone.core.session_routes import SessionEndpointConfig, make_create_handler
from turnstone.server import (
WebUI,
_interactive_create_build_kwargs,
_interactive_create_post_install,
_interactive_create_validate_request,
_interactive_manager_lookup,
_interactive_tenant_check,
)
class _Auth(BaseHTTPMiddleware):
async def dispatch(self, request: Any, call_next: Any) -> Any:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset({"approve"}),
token_source="config",
permissions=frozenset({"read", "write", "approve"}),
)
return await call_next(request)
def _session_factory(ui: Any, model_alias: Any = None, ws_id: Any = None, **kw: Any):
return ChatSession(
client=MagicMock(),
model=model_alias or "test-model",
ui=ui,
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
ws_id=ws_id,
persona_snapshot=kw.get("persona_snapshot"),
)
gq: queue.Queue[dict[str, Any]] = queue.Queue()
WebUI._global_queue = gq
adapter = InteractiveAdapter(
global_queue=gq,
ui_factory=lambda ws: WebUI(
ws_id=ws.id,
user_id=ws.user_id,
kind=ws.kind,
parent_ws_id=ws.parent_ws_id,
),
session_factory=_session_factory,
)
mgr = SessionManager(adapter, storage=get_storage(), max_active=10, event_emitter=adapter)
handler = make_create_handler(
SessionEndpointConfig(
permission_gate=None,
manager_lookup=_interactive_manager_lookup,
tenant_check=_interactive_tenant_check,
not_found_label="Workstream not found",
audit_action_prefix="workstream",
create_supports_attachments=True,
create_supports_user_id_override=True,
create_validate_request=_interactive_create_validate_request,
create_build_kwargs=_interactive_create_build_kwargs,
create_post_install=_interactive_create_post_install,
)
)
app = Starlette(
routes=[
Mount(
"/v1",
routes=[Route("/api/workstreams/new", handler, methods=["POST"])],
)
],
middleware=[Middleware(_Auth)],
)
app.state.workstreams = mgr
app.state.skip_permissions = True
app.state.global_queue = gq
app.state.global_listeners = []
app.state.global_listeners_lock = threading.Lock()
try:
yield TestClient(app, raise_server_exceptions=False), mgr
finally:
# Close the sessions this manager created, then release the
# process-global WebUI queue so it can't leak into the next test
# (precedent: test_coord_rich_ws_state_payload.py teardown).
for ws in mgr.list_all():
mgr.close(ws.id)
WebUI._global_queue = None
def _seed_default(self) -> None:
get_storage().create_persona(
{
"persona_id": "pd",
"name": "engineer",
"base_prompt": "D",
"applies_to_kinds": ["interactive"],
"is_default": True,
}
)
def test_fork_adopts_source_stamp_not_default(self, _fork_app) -> None:
from turnstone.core.memory import register_workstream, save_workstream_config
client, mgr = _fork_app
self._seed_default() # present, and must LOSE to the source stamp
src = "s" * 32
register_workstream(src)
snap = _snap(name="scribe", tools=frozenset(), mcp=False, memory=False)
save_workstream_config(src, snap.to_config())
resp = client.post("/v1/api/workstreams/new", json={"resume_ws": src})
assert resp.status_code == 200, resp.text
ws = mgr.get(resp.json()["ws_id"])
assert ws is not None and ws.session is not None
assert ws.persona == "scribe"
assert ws.session._persona_name == "scribe"
assert ws.session._persona_tools == frozenset()
assert ws.session._persona_mcp is False
assert ws.session._persona_memory is False
def test_corrupt_source_stamp_is_400(self, _fork_app) -> None:
from turnstone.core.memory import register_workstream, save_workstream_config
client, mgr = _fork_app
src = "c" * 32
register_workstream(src)
save_workstream_config(src, {"persona": "scribe"}) # partial = corrupt
resp = client.post("/v1/api/workstreams/new", json={"resume_ws": src})
assert resp.status_code == 400
assert "cannot fork" in resp.json()["error"]
def test_unstamped_legacy_source_forks_unstamped(self, _fork_app) -> None:
from turnstone.core.memory import register_workstream
client, mgr = _fork_app
self._seed_default() # the default must NOT leak onto the fork
src = "l" * 32
register_workstream(src)
resp = client.post("/v1/api/workstreams/new", json={"resume_ws": src})
assert resp.status_code == 200, resp.text
ws = mgr.get(resp.json()["ws_id"])
assert ws is not None and ws.session is not None
assert ws.session._persona_name == "" # unstamped, not the default
assert not ws.persona
# ---------------------------------------------------------------------------
# Guard 6 (receiving side) — a fresh create resolves the body ``persona`` at
# creation time and stamps it: no separate persona permission is required
# (workstreams.create alone suffices); a kind-mismatch / unknown persona is a
# loud 4xx; an omitted persona takes the kind default; a FAILED default lookup
# fails closed (503) rather than silently widening to the stock envelope.
# ---------------------------------------------------------------------------
class TestCreateStampsPersona:
@pytest.fixture()
def _create_app(self, tmp_db):
"""The production ``make_create_handler`` behind the production
permission gate — the caller carries ONLY ``workstreams.create`` (no
service scope, no persona-specific permission), so a successful stamp
proves persona needs no dedicated permission."""
import queue
import threading
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
from turnstone.core.adapters.interactive_adapter import InteractiveAdapter
from turnstone.core.auth import AuthResult
from turnstone.core.session_manager import SessionManager
from turnstone.core.session_routes import SessionEndpointConfig, make_create_handler
from turnstone.server import (
WebUI,
_interactive_create_build_kwargs,
_interactive_create_post_install,
_interactive_create_validate_request,
_interactive_manager_lookup,
_interactive_tenant_check,
)
class _Auth(BaseHTTPMiddleware):
async def dispatch(self, request: Any, call_next: Any) -> Any:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset(), # no service scope — no bypass
token_source="config",
permissions=frozenset({"workstreams.create"}),
)
return await call_next(request)
def _session_factory(ui: Any, model_alias: Any = None, ws_id: Any = None, **kw: Any):
return ChatSession(
client=MagicMock(),
model=model_alias or "test-model",
ui=ui,
instructions=None,
temperature=0.5,
max_tokens=1000,
tool_timeout=10,
ws_id=ws_id,
persona_snapshot=kw.get("persona_snapshot"),
)
gq: queue.Queue[dict[str, Any]] = queue.Queue()
WebUI._global_queue = gq
adapter = InteractiveAdapter(
global_queue=gq,
ui_factory=lambda ws: WebUI(
ws_id=ws.id,
user_id=ws.user_id,
kind=ws.kind,
parent_ws_id=ws.parent_ws_id,
),
session_factory=_session_factory,
)
mgr = SessionManager(adapter, storage=get_storage(), max_active=10, event_emitter=adapter)
handler = make_create_handler(
SessionEndpointConfig(
permission_gate=None,
manager_lookup=_interactive_manager_lookup,
tenant_check=_interactive_tenant_check,
not_found_label="Workstream not found",
audit_action_prefix="workstream",
create_supports_attachments=True,
create_supports_user_id_override=True,
create_validate_request=_interactive_create_validate_request,
create_build_kwargs=_interactive_create_build_kwargs,
create_post_install=_interactive_create_post_install,
),
accepted_permissions=("workstreams.create", "admin.coordinator"),
)
app = Starlette(
routes=[
Mount(
"/v1",
routes=[Route("/api/workstreams/new", handler, methods=["POST"])],
)
],
middleware=[Middleware(_Auth)],
)
app.state.workstreams = mgr
app.state.skip_permissions = True
app.state.global_queue = gq
app.state.global_listeners = []
app.state.global_listeners_lock = threading.Lock()
try:
yield TestClient(app, raise_server_exceptions=False), mgr
finally:
for ws in mgr.list_all():
mgr.close(ws.id)
WebUI._global_queue = None
@staticmethod
def _seed(name: str, kinds: list[str], **extra: Any) -> None:
get_storage().create_persona(
{
"persona_id": "p_" + name,
"name": name,
"base_prompt": f"You are {name}.",
"applies_to_kinds": kinds,
**extra,
}
)
def test_create_stamps_persona_with_create_permission_only(self, _create_app) -> None:
client, mgr = _create_app
self._seed("scribe", ["interactive"], base_prompt="S", tool_allowlist=[], mcp_enabled=False)
resp = client.post("/v1/api/workstreams/new", json={"persona": "scribe"})
assert resp.status_code == 200, resp.text
ws = mgr.get(resp.json()["ws_id"])
assert ws is not None and ws.session is not None
assert ws.persona == "scribe"
assert ws.session._persona_name == "scribe"
def test_create_kind_mismatch_is_400(self, _create_app) -> None:
client, mgr = _create_app
self._seed("orchestrator", ["coordinator"]) # coordinator-only
resp = client.post("/v1/api/workstreams/new", json={"persona": "orchestrator"})
assert resp.status_code == 400
assert "does not apply to kind" in resp.json()["error"]
def test_create_unknown_persona_is_400(self, _create_app) -> None:
client, mgr = _create_app
resp = client.post("/v1/api/workstreams/new", json={"persona": "nonexistent"})
assert resp.status_code == 400
assert "not found or disabled" in resp.json()["error"]
def test_create_omitted_persona_stamps_default(self, _create_app) -> None:
client, mgr = _create_app
self._seed("engineer", ["interactive"], is_default=True)
resp = client.post("/v1/api/workstreams/new", json={"name": "x"})
assert resp.status_code == 200, resp.text
ws = mgr.get(resp.json()["ws_id"])
assert ws is not None and ws.session is not None
assert ws.session._persona_name == "engineer"
def test_create_default_lookup_failure_is_503(self, _create_app) -> None:
# Fail-closed: a storage blip during default resolution must not
# degrade to the stock (unstamped) envelope — the operator may have
# promoted a restricted persona to default, and silently widening it
# is the failure mode this lane guards against.
client, mgr = _create_app
storage = get_storage()
with patch.object(
type(storage), "get_default_persona", side_effect=RuntimeError("db down")
):
resp = client.post("/v1/api/workstreams/new", json={"name": "x"})
assert resp.status_code == 503
assert "persona resolution unavailable" in resp.json()["error"]
def test_create_clean_none_default_is_unstamped_legacy(self, _create_app) -> None:
# No persona in the body and no default configured — a clean ``None``
# (not a lookup failure) still creates, unstamped, byte-identical to a
# legacy pre-persona workstream.
client, mgr = _create_app
resp = client.post("/v1/api/workstreams/new", json={"name": "x"})
assert resp.status_code == 200, resp.text
ws = mgr.get(resp.json()["ws_id"])
assert ws is not None and ws.session is not None
assert ws.session._persona_name == ""
assert not ws.persona
# ---------------------------------------------------------------------------
# Guard 10 — discovery: the calling LLM is TOLD which personas exist. The
# live enabled interactive-kind list rides the `persona` parameter
# description of task_agent / spawn_workstream / spawn_batch, rebuilt from
# the pristine TOOLS base on every render; storage-less sessions keep the
# base text untouched. Resolution is forgiving (case, unique display name)
# but everything downstream carries the canonical slug.
# ---------------------------------------------------------------------------
def _persona_desc(session: ChatSession, tool_name: str) -> str:
tool = next(t for t in session._tools if t.get("function", {}).get("name") == tool_name)
prop = ChatSession._persona_property(tool["function"]["parameters"]["properties"])
assert prop is not None, f"{tool_name} has no persona parameter"
return prop["description"]
def _pristine_persona_desc(tool_name: str) -> str:
from turnstone.core.tools import TOOLS
tool = next(t for t in TOOLS if t["function"]["name"] == tool_name)
prop = ChatSession._persona_property(tool["function"]["parameters"]["properties"])
assert prop is not None, f"{tool_name} has no persona parameter"
return prop["description"]
class TestPersonaDiscovery:
def _seed(self) -> None:
get_storage().create_persona(
{
"persona_id": "p-eng",
"name": "engineer",
"display_name": "Engineer",
"description": "Default engineering identity",
"base_prompt": "E",
"applies_to_kinds": ["interactive"],
"is_default": True,
}
)
get_storage().create_persona(
{
"persona_id": "p-wri",
"name": "writer",
"display_name": "Creative Writer",
"description": "Prose-first writing partner",
"base_prompt": "W",
"applies_to_kinds": ["interactive"],
}
)
def _coord_session(self, mock_openai_client: Any) -> ChatSession:
return _session(
mock_openai_client,
kind=WorkstreamKind.COORDINATOR,
user_id="u1",
coord_client=MagicMock(),
)
def test_task_agent_description_lists_personas(self, tmp_db, mock_openai_client) -> None:
self._seed()
session = _session(mock_openai_client)
desc = _persona_desc(session, "task_agent")
assert desc.startswith(_pristine_persona_desc("task_agent"))
assert "Available personas:" in desc
# Default first, then A→Z, each with its one-line description.
assert desc.index("`engineer` (default)") < desc.index("`writer`")
assert "Prose-first writing partner" in desc
def test_spawn_tools_list_personas_for_coordinators(self, tmp_db, mock_openai_client) -> None:
self._seed()
session = self._coord_session(mock_openai_client)
for tool_name in ("spawn_workstream", "spawn_batch"):
desc = _persona_desc(session, tool_name)
assert desc.startswith(_pristine_persona_desc(tool_name))
assert "Available personas:" in desc
assert "`engineer` (default)" in desc
def test_coordinator_kind_personas_are_not_offered(self, tmp_db, mock_openai_client) -> None:
# Children and sub-agents are always interactive-kind; a
# coordinator-only persona in the list would be a guaranteed error.
self._seed()
get_storage().create_persona(
{
"persona_id": "p-exe",
"name": "executive",
"base_prompt": "X",
"applies_to_kinds": ["coordinator"],
}
)
session = self._coord_session(mock_openai_client)
assert "`executive`" not in _persona_desc(session, "spawn_workstream")
def test_storage_down_keeps_pristine_base(self, tmp_db, mock_openai_client) -> None:
self._seed()
with patch("turnstone.core.storage.is_storage_initialized", return_value=False):
session = _session(mock_openai_client)
assert _persona_desc(session, "task_agent") == _pristine_persona_desc("task_agent")
def test_rerender_is_idempotent_and_tracks_archive(self, tmp_db, mock_openai_client) -> None:
self._seed()
session = _session(mock_openai_client)
session._render_agent_tool_descriptions()
session._render_agent_tool_descriptions()
desc = _persona_desc(session, "task_agent")
assert desc.count("Available personas:") == 1
# Archive one persona; the next render must drop it, not append.
storage = get_storage()
writer = storage.get_persona_by_name("writer")
assert writer is not None
storage.update_persona(writer["persona_id"], enabled=False)
session._render_agent_tool_descriptions()
desc = _persona_desc(session, "task_agent")
assert "`writer`" not in desc
assert desc.count("Available personas:") == 1
def test_large_shelf_drops_prose_keeps_every_name(self, tmp_db, mock_openai_client) -> None:
storage = get_storage()
for i in range(26):
storage.create_persona(
{
"persona_id": f"p-{i:02d}",
"name": f"persona-{i:02d}",
"description": "UNIQUE-PROSE-MARKER",
"base_prompt": "x",
"applies_to_kinds": ["interactive"],
}
)
session = _session(mock_openai_client)
desc = _persona_desc(session, "task_agent")
for i in range(26):
assert f"`persona-{i:02d}`" in desc
assert "UNIQUE-PROSE-MARKER" not in desc
def test_spawn_forgives_case_and_display_name_but_stamps_slug(
self, tmp_db, mock_openai_client
) -> None:
self._seed()
session = self._coord_session(mock_openai_client)
for variant in ("WRITER", "Writer", "Creative Writer"):
item = session._prepare_spawn_workstream("c1", {"persona": variant})
assert not item.get("error"), item.get("error")
assert item["persona"] == "writer"
def test_spawn_batch_rows_land_on_canonical_slug(self, tmp_db, mock_openai_client) -> None:
self._seed()
session = self._coord_session(mock_openai_client)
item = session._prepare_spawn_batch(
"c1",
{
"children": [
{"initial_message": "a", "persona": "WRITER"},
{"initial_message": "b", "persona": "Creative Writer"},
]
},
)
assert not item.get("error"), item.get("error")
personas = [c["persona"] for c in item["children"] if "_error" not in c]
assert personas == ["writer", "writer"]
def test_task_agent_prep_canonicalizes_header_and_stamp(
self, tmp_db, mock_openai_client
) -> None:
self._seed()
session = _session(mock_openai_client)
item = session._prepare_task("t1", {"prompt": "go", "persona": "Writer"})
assert not item.get("error"), item.get("error")
assert item["persona"] == "writer"
assert "persona: writer" in item["header"]
def test_unknown_persona_error_enumerates_live_names(self, tmp_db, mock_openai_client) -> None:
self._seed()
session = self._coord_session(mock_openai_client)
item = session._prepare_spawn_workstream("c1", {"persona": "nope"})
assert item.get("error")
assert "Available for interactive: engineer (default), writer" in item["error"]
class TestNudgeToolVisibility:
"""``_nudges_enabled`` generalises from "the memory tool" to
``NUDGE_REQUIRED_TOOL``, so the class split is pinned at the
ChatSession level and not only inside the observer."""
def test_idle_tasks_gated_on_the_tasks_tool(self, tmp_db, mock_openai_client) -> None:
# Every branch of the idle_tasks body is a tasks(...) call, so a
# persona hiding that tool must suppress it — the same rule the
# memory nudges follow, via one map instead of a hardcoded name.
session = _session(
mock_openai_client,
persona_snapshot=_snap(tools=frozenset({"read_file"}), memory=True),
)
session._memory_config.nudges = True
assert not session._nudges_enabled("idle_tasks")
def test_idle_tasks_allowed_when_tasks_visible(self, tmp_db, mock_openai_client) -> None:
session = _session(
mock_openai_client,
persona_snapshot=_snap(tools=frozenset({"tasks"}), memory=True),
)
session._memory_config.nudges = True
assert session._nudges_enabled("idle_tasks")
def test_idle_children_is_not_tool_gated(self, tmp_db, mock_openai_client) -> None:
# LIVENESS: the body names wait_for_workstream, but the wake
# itself is the point and the body has a non-tool branch beside
# it. A persona hiding the tool must not strand the coordinator.
session = _session(
mock_openai_client,
persona_snapshot=_snap(tools=frozenset({"read_file"}), memory=True),
)
session._memory_config.nudges = True
assert session._nudges_enabled("idle_children")