"""Tests for turnstone.core.session — ChatSession construction."""
import base64
import contextlib
import json
import subprocess
import time
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from turnstone.core.session import _IMAGE_EXTENSIONS, _IMAGE_SIZE_CAP, ChatSession
from turnstone.core.trajectory import (
Turn,
dicts_from_turns,
turn_from_dict,
turn_to_dict,
turns_from_dicts,
)
class NullUI:
"""UI adapter that discards all output. Used for testing."""
def on_turn_start(self):
pass
def on_turn_committed(self):
pass
def on_thinking_start(self):
pass
def on_thinking_stop(self):
pass
def on_reasoning_token(self, text):
pass
def on_content_token(self, text):
pass
def on_stream_end(self):
pass
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
pass
def on_status(self, usage, context_window, effort):
pass
def on_info(self, message):
pass
def on_error(self, message):
pass
def on_system_turn(self, content, source, meta=None):
pass
def on_state_change(self, state):
pass
def on_rename(self, name):
pass
def on_output_warning(self, call_id, assessment):
pass
def record_output_assessment(
self,
call_id,
assessment,
*,
tier="heuristic",
reasoning="",
judge_model="",
latency_ms=0,
confidence=0.0,
):
pass
def _make_session(
mock_openai_client=None,
instructions=None,
**kwargs,
):
"""Helper to construct a ChatSession with minimal setup."""
client = mock_openai_client or MagicMock()
defaults = dict(
client=client,
model="test-model",
ui=NullUI(),
instructions=instructions,
temperature=0.5,
max_tokens=4096,
tool_timeout=30,
)
defaults.update(kwargs)
return ChatSession(**defaults)
@contextlib.contextmanager
def _send_with_mocks(session, responses, mock_execute, **extra_patches):
"""Stand up the mock context that the queued-message ``send()`` tests share.
Six tests in ``TestMetacognitiveBuffers`` previously inlined the
same nine ``patch.object`` / ``patch`` declarations. Extracting
the ctxmgr keeps each test focused on its scenario (responses +
execute behaviour + assertions) rather than re-asserting the
common mock surface.
Yields the ``save_message`` MagicMock so callers that need to
assert on persistence can ``... as save_msg`` over the helper.
Extra per-test patches (e.g. wrapping ``_collect_advisories``) ride
via ``**extra_patches`` — keyword name maps to attribute on the
session, value is the ``side_effect`` to inject.
"""
from unittest.mock import patch as _patch
def mock_stream(_msgs):
return iter([])
def mock_response(_stream, _gen):
return responses.pop(0)
with contextlib.ExitStack() as stack:
stack.enter_context(
_patch.object(session, "_create_stream_with_retry", side_effect=mock_stream)
)
stack.enter_context(_patch.object(session, "_stream_response", side_effect=mock_response))
stack.enter_context(_patch.object(session, "_execute_tools", side_effect=mock_execute))
for attr, side_effect in extra_patches.items():
stack.enter_context(_patch.object(session, attr, side_effect=side_effect))
stack.enter_context(_patch.object(session, "_full_messages", return_value=[]))
stack.enter_context(_patch.object(session, "_update_token_table"))
stack.enter_context(_patch.object(session, "_print_status_line"))
stack.enter_context(_patch.object(session, "_emit_state"))
stack.enter_context(_patch.object(session, "_visible_memory_count", return_value=0))
stack.enter_context(_patch.object(session, "_apply_post_execute_advisories"))
save_msg = stack.enter_context(_patch("turnstone.core.session.save_message"))
yield save_msg
def _capturing_thread_cls():
"""Return a no-op ``threading.Thread`` stand-in plus the list it records
each constructed thread's ``target`` into.
Patched over ``session.threading.Thread`` so a test can assert WHICH
callable was scheduled (e.g. ``_generate_title``) without the thread
actually running — ``start()`` is a no-op, so no background LLM call
fires.
"""
started: list = []
class _CaptureThread:
def __init__(self, *a, target=None, **kw):
started.append(target)
def start(self):
pass
return _CaptureThread, started
def _user_pending(session) -> list[tuple[str, str]]:
"""Return user-channel queued nudges as ``(type, text)`` tuples.
Replaces direct introspection of the legacy
``_pending_user_advisories`` list with a non-mutating
:meth:`NudgeQueue.pending` lookup filtered to the user channel.
"""
return session._nudge_queue.pending("user")
def _tool_pending(session) -> list[tuple[str, str]]:
"""Return tool-channel queued nudges as ``(type, text)`` tuples."""
return session._nudge_queue.pending("tool")
def _run_exec_search(session, capture_return):
"""Patch ``_search_capture`` to ``capture_return`` and run ``_exec_search``.
Returns the formatted output string. The fixed call args
(``call_id``/``pattern``/``path``) are deliberately uniform across the
line-truncation tests — only the captured stdout/rc/stderr/capped tuple
varies between cases.
"""
with patch.object(session, "_search_capture", return_value=capture_return):
_, output = session._exec_search(
{
"call_id": "test_call",
"pattern": "test_pattern",
"path": "/workspace/turnstone",
}
)
return output
class TestChatSessionConstruction:
def test_system_messages_created(self, tmp_db):
session = _make_session()
assert len(session.system_messages) >= 1
# At least one system message
roles = [m["role"] for m in session.system_messages]
assert "system" in roles
def test_instructions_appended_to_system_message(self, tmp_db):
session = _make_session(instructions="Always be concise.")
sys_msgs = [m for m in session.system_messages if m["role"] == "system"]
assert len(sys_msgs) >= 1
assert "Always be concise." in sys_msgs[0]["content"]
def test_full_messages_returns_system_plus_conversation(self, tmp_db):
session = _make_session()
# Initially no conversation messages
full = session._full_messages()
assert len(full) == len(session.system_messages)
# Add a user message
session.messages.append(turn_from_dict({"role": "user", "content": "hello"}))
full = session._full_messages()
assert len(full) == len(session.system_messages) + 1
assert full[-1]["role"] == "user"
def test_msg_char_count_content_only(self, tmp_db):
session = _make_session()
msg = {"role": "assistant", "content": "hello world"}
# "hello world" (11) + "assistant" (9) = 20
assert session._msg_char_count(msg) == 20
def test_msg_char_count_with_tool_calls(self, tmp_db):
session = _make_session()
msg = {
"role": "assistant",
"content": "hi",
"tool_calls": [
{
"id": "tc_1",
"function": {
"name": "bash",
"arguments": '{"command": "ls"}',
},
}
],
}
# "hi" (2) + "tc_1" (4) + "bash" (4) + '{"command": "ls"}' (17) + "assistant" (9) = 36
assert session._msg_char_count(msg) == 36
def test_msg_char_count_none_content(self, tmp_db):
session = _make_session()
msg = {"role": "assistant", "content": None}
# len("assistant") = 9
assert session._msg_char_count(msg) == 9
def test_reasoning_effort_stored(self, tmp_db):
session = _make_session(reasoning_effort="high")
assert session.reasoning_effort == "high"
def test_default_reasoning_effort(self, tmp_db):
session = _make_session()
assert session.reasoning_effort == "medium"
# ---------------------------------------------------------------------------
# Tests — _exec_task (optional skill substitutes the hardcoded identity)
# ---------------------------------------------------------------------------
class TestTaskExec:
"""Tests for _exec_task: optional skill= replaces the default persona,
but operating guidance (one-shot, tool-use over narration, no follow-ups)
is always preserved."""
@staticmethod
def _capture_exec_messages(session, item):
"""Run _exec_task with _run_agent patched; return system message text."""
captured: dict = {}
def fake_run_agent(messages, **kwargs):
captured["messages"] = list(messages)
return "done"
with patch.object(session, "_run_agent", side_effect=fake_run_agent):
session._exec_task(item)
return captured["messages"][0].text
def test_known_skill_renders_into_system_message(self, tmp_db) -> None:
"""Validated skill content (with template vars resolved) replaces
the default '# Task Agent' persona, but the operating guidance
(the numbered list) is preserved — those are sub-agent semantics
that a persona should layer on top of, not replace.
Covers the full prepare→exec round-trip so a future regression
in either half (skill not stored on the item, or exec ignoring it)
is caught."""
session = _make_session()
skill = {
"name": "research",
"content": "# Research Agent\nws={{ws_id}} model={{model}} node={{node_id}}",
}
with patch("turnstone.core.session.get_skill_by_name", return_value=skill):
item = session._prepare_task("c1", {"prompt": "investigate X", "skill": "research"})
# Item carries the minimized projection — name/content/risk_level
# only — not the raw prompt_templates row.
assert item["skill"] == {
"name": "research",
"content": skill["content"],
"risk_level": "",
}
assert item.get("needs_approval") is True
assert "skill: research" in item["header"]
sys_msg = self._capture_exec_messages(session, item)
# Skill persona rendered with template vars resolved
assert "# Research Agent" in sys_msg
assert f"ws={session._ws_id}" in sys_msg
assert f"model={session.model}" in sys_msg
# Default persona is gone — skill substitutes for it.
assert "# Task Agent" not in sys_msg
assert "autonomous task agent with full tool access" not in sys_msg
# Operating guidance survives regardless of skill.
assert ChatSession._TASK_OPERATING_GUIDANCE in sys_msg
def test_omitted_skill_uses_hardcoded_identity(self, tmp_db) -> None:
"""Regression guard: without skill=, the default '# Task Agent'
persona AND the operating guidance both appear verbatim.
Pins the no-skill path so the substitution branch can't
accidentally swallow the default case."""
session = _make_session()
item = session._prepare_task("c1", {"prompt": "do x"})
assert item["skill"] is None
assert "skill:" not in item["header"]
sys_msg = self._capture_exec_messages(session, item)
assert ChatSession._TASK_DEFAULT_IDENTITY in sys_msg
assert ChatSession._TASK_OPERATING_GUIDANCE in sys_msg
# Default-persona literals also present (sanity check on the constant).
assert "# Task Agent" in sys_msg
assert "autonomous task agent with full tool access" in sys_msg
@pytest.mark.parametrize("skill_value", ["", " ", "\t\n"])
def test_prepare_task_empty_or_whitespace_skill_treated_as_omitted(
self, tmp_db, skill_value
) -> None:
"""Documented contract: ``skill=""`` (and whitespace-only) behaves
identically to omitting the skill arg. LLMs sometimes echo empty
strings rather than omit the field; this pins the documented
behavior so a future refactor of the ``(args.get("skill") or "").strip()``
chokepoint can't quietly diverge."""
session = _make_session()
item = session._prepare_task("c1", {"prompt": "do x", "skill": skill_value})
assert item.get("needs_approval") is True
assert item["skill"] is None
assert "skill:" not in item["header"]
def test_prepare_task_unknown_skill_returns_error(self, tmp_db) -> None:
"""Unknown skill name → clean error item, no approval needed.
Skill validation lives in _prepare_task so an LLM passing a
bogus name fails fast at approval time rather than at exec."""
session = _make_session()
with patch("turnstone.core.session.get_skill_by_name", return_value=None):
item = session._prepare_task("c1", {"prompt": "do x", "skill": "ghost"})
assert item.get("needs_approval") is False
assert "unknown skill 'ghost'" in item["error"]
assert "skills(action='find'" in item["error"]
def test_prepare_task_disabled_skill_returns_error(self, tmp_db) -> None:
"""Disabled skill → distinct error, mirrors the enabled gate that
``_exec_skills_load`` and ``_exec_skills_find`` already apply.
Distinct from the unknown-skill phrasing so the LLM's recovery
path can tell 'not found' from 'quarantined'."""
session = _make_session()
disabled_skill = {
"name": "retired",
"content": "# Retired",
"enabled": False,
}
with patch("turnstone.core.session.get_skill_by_name", return_value=disabled_skill):
item = session._prepare_task("c1", {"prompt": "do x", "skill": "retired"})
assert item.get("needs_approval") is False
assert "is disabled" in item["error"]
# Distinct wording from the unknown-skill error, so the LLM can
# tell them apart at recovery time.
assert "unknown skill" not in item["error"]
def test_prepare_task_high_risk_skill_surfaces_in_header(self, tmp_db, caplog) -> None:
"""High/critical risk skills surface the tier in the approval header
and emit a structured warning, mirroring the signal ``_load_skills``
emits for session-level skills (session.py:1336)."""
import logging
session = _make_session()
risky_skill = {
"name": "danger",
"content": "# Danger",
"enabled": True,
"risk_level": "critical",
}
with (
caplog.at_level(logging.WARNING, logger="turnstone.core.session"),
patch("turnstone.core.session.get_skill_by_name", return_value=risky_skill),
):
item = session._prepare_task("c1", {"prompt": "do x", "skill": "danger"})
assert item.get("needs_approval") is True
assert "skill: danger" in item["header"]
assert "risk: critical" in item["header"]
warning_seen = any("high_risk_skill" in r.getMessage() for r in caplog.records)
assert warning_seen, "expected task_agent.high_risk_skill warning"
def test_prepare_task_normal_risk_skill_omits_tier_from_header(self, tmp_db) -> None:
"""Header only surfaces high/critical — low/medium/safe skills don't
pollute the approval line."""
session = _make_session()
ok_skill = {
"name": "research",
"content": "# Research",
"enabled": True,
"risk_level": "low",
}
with patch("turnstone.core.session.get_skill_by_name", return_value=ok_skill):
item = session._prepare_task("c1", {"prompt": "do x", "skill": "research"})
assert "skill: research" in item["header"]
assert "risk:" not in item["header"]
def test_evaluate_intent_projects_skill_for_task_agent(self, tmp_db, monkeypatch) -> None:
"""Judge projection includes the skill name so heuristic arg_patterns
can match on it and the audit row records which persona was chosen.
Mirrors the long-standing ``spawn_workstream`` projection at
session.py:4603 — without it, policy rules targeting risky
skills via ``task_agent`` silently no-op."""
session = _make_session()
fake_verdict = MagicMock()
fake_verdict.to_dict.return_value = {"verdict_id": "v0", "tier": "heuristic"}
fake_judge = MagicMock()
fake_judge.evaluate.side_effect = lambda items, *_a, **_kw: [fake_verdict] * len(items)
monkeypatch.setattr(session, "_ensure_judge", lambda: fake_judge)
skill = {"name": "research", "content": "# Research", "enabled": True}
with patch("turnstone.core.session.get_skill_by_name", return_value=skill):
item = session._prepare_task("c1", {"prompt": "investigate X", "skill": "research"})
session._evaluate_intent([item])
fa = item["func_args"]
assert fa["skill"] == "research"
assert fa["prompt"] == "investigate X"
def test_evaluate_intent_projects_empty_skill_when_omitted(self, tmp_db, monkeypatch) -> None:
"""Symmetric regression guard: no-skill case projects skill="" so
the func_args shape is stable across both branches (the judge can
always read ``func_args["skill"]`` without a KeyError)."""
session = _make_session()
fake_verdict = MagicMock()
fake_verdict.to_dict.return_value = {"verdict_id": "v0", "tier": "heuristic"}
fake_judge = MagicMock()
fake_judge.evaluate.side_effect = lambda items, *_a, **_kw: [fake_verdict] * len(items)
monkeypatch.setattr(session, "_ensure_judge", lambda: fake_judge)
item = session._prepare_task("c1", {"prompt": "do x"})
session._evaluate_intent([item])
fa = item["func_args"]
assert fa["skill"] == ""
assert fa["prompt"] == "do x"
def test_evaluate_intent_drops_superseded_generation_verdict(self, tmp_db, monkeypatch) -> None:
"""A prior turn's judge daemon (still running because
cancel_on_approval defaults False) must NOT deliver verdicts to the
live surfaces once a newer turn has superseded it — otherwise a model
that reuses a call_id across turns could ride a stale ``approve``
into a wrongful Smart Approval of a different call. The superseded
verdict is NOT lost, though: it routes to the persist-only audit
hook so ``intent_verdicts`` still records the judge's ruling."""
session = _make_session()
session.ui.on_intent_verdict = MagicMock()
session.ui.on_superseded_intent_verdict = MagicMock()
fake_verdict = MagicMock()
fake_verdict.to_dict.return_value = {"verdict_id": "v0", "call_id": "c1", "tier": "llm"}
captured: list[Any] = []
fake_judge = MagicMock()
fake_judge.evaluate.side_effect = lambda items, *_a, **kw: (
captured.append(kw.get("callback")) or [fake_verdict] * len(items)
)
monkeypatch.setattr(session, "_ensure_judge", lambda: fake_judge)
item = {"call_id": "c1", "func_name": "bash", "needs_approval": True, "command": "ls"}
session._evaluate_intent([dict(item)]) # generation A
session._evaluate_intent([dict(item)]) # generation B supersedes A
callback_a, callback_b = captured[0], captured[1]
# A's late verdict: withheld from the live surfaces, persisted for audit.
callback_a(fake_verdict)
session.ui.on_intent_verdict.assert_not_called()
session.ui.on_superseded_intent_verdict.assert_called_once_with(
{"verdict_id": "v0", "call_id": "c1", "tier": "llm"}
)
# B's verdict (the current generation) is delivered normally.
callback_b(fake_verdict)
session.ui.on_intent_verdict.assert_called_once()
session.ui.on_superseded_intent_verdict.assert_called_once() # unchanged
def test_superseded_verdict_skips_persist_on_display_only_ui(self, tmp_db, monkeypatch) -> None:
"""Display-only UIs (CLI / eval) don't define the persist-only hook;
the superseded path must degrade to a plain drop, not raise."""
session = _make_session()
session.ui = SimpleNamespace(on_intent_verdict=MagicMock()) # no superseded hook
fake_verdict = MagicMock()
fake_verdict.to_dict.return_value = {"verdict_id": "v0", "call_id": "c1", "tier": "llm"}
captured: list[Any] = []
fake_judge = MagicMock()
fake_judge.evaluate.side_effect = lambda items, *_a, **kw: (
captured.append(kw.get("callback")) or [fake_verdict] * len(items)
)
monkeypatch.setattr(session, "_ensure_judge", lambda: fake_judge)
item = {"call_id": "c1", "func_name": "bash", "needs_approval": True, "command": "ls"}
session._evaluate_intent([dict(item)]) # generation A
session._evaluate_intent([dict(item)]) # generation B supersedes A
captured[0](fake_verdict) # must not raise
session.ui.on_intent_verdict.assert_not_called()
def _drive_gate(self, session, monkeypatch, *, cancel_on_approval: bool):
"""Run one needs_approval bash item through ``_execute_tools`` with a
stubbed judge + approval gate; return the cancel event the judge
daemon would be watching."""
from unittest.mock import PropertyMock
from turnstone.core.judge import JudgeConfig
captured: dict[str, Any] = {}
fake_verdict = MagicMock()
fake_verdict.to_dict.return_value = {
"verdict_id": "v0",
"call_id": "c1",
"tier": "heuristic",
}
fake_judge = MagicMock()
def _eval(items, *_a, **kw):
captured["event"] = kw.get("cancel_event")
return [fake_verdict] * len(items)
fake_judge.evaluate.side_effect = _eval
monkeypatch.setattr(session, "_ensure_judge", lambda: fake_judge)
cfg = JudgeConfig(enabled=True, cancel_on_approval=cancel_on_approval)
item = {
"call_id": "c1",
"func_name": "bash",
"needs_approval": True,
"command": "ls",
"execute": lambda _it: "ok",
}
with (
patch.object(type(session), "_judge_cfg", new_callable=PropertyMock, return_value=cfg),
patch.object(session, "_safe_prepare_tool", return_value=item),
patch.object(session.ui, "approve_tools", return_value=(True, None)),
):
session._execute_tools(
[{"id": "c1", "type": "function", "function": {"name": "bash", "arguments": "{}"}}]
)
return captured["event"]
def test_gate_resolution_keeps_judge_running_by_default(self, tmp_db, monkeypatch) -> None:
"""cancel_on_approval=False (the default): resolving the approval
gate must NOT fire the judge's abort signal — the daemon runs every
item to completion so each call lands a real LLM verdict, exactly
what the setting's help text promises. An unconditional set in the
gate's ``finally`` used to degrade every still-queued item to a
llm_fallback row the instant the operator approved."""
session = _make_session()
event = self._drive_gate(session, monkeypatch, cancel_on_approval=False)
assert event is not None
assert not event.is_set()
# The supersede path still aborts unconditionally: the next batch
# fires the previous generation's event before spawning its own.
session._judge_cancel_event = event
self._drive_gate(session, monkeypatch, cancel_on_approval=False)
assert event.is_set()
def test_gate_resolution_cancels_judge_when_opted_in(self, tmp_db, monkeypatch) -> None:
"""cancel_on_approval=True: the gate's ``finally`` fires the abort
signal as soon as the approval resolves, trading verdict
completeness for inference savings."""
session = _make_session()
event = self._drive_gate(session, monkeypatch, cancel_on_approval=True)
assert event is not None
assert event.is_set()
# ---------------------------------------------------------------------------
# Per-call model override on task_agent
# ---------------------------------------------------------------------------
class TestAgentModelOverride:
"""Tests for the optional `model` arg on the task_agent tool."""
@staticmethod
def _registry():
from turnstone.core.model_registry import ModelConfig, ModelRegistry
return ModelRegistry(
models={
"default": ModelConfig("default", "x", "x", "m"),
"smart": ModelConfig("smart", "x", "x", "m"),
"fast": ModelConfig("fast", "x", "x", "m"),
},
default="default",
)
# ---- _prepare_task ----
def test_prepare_task_extracts_model_override(self, tmp_db) -> None:
session = _make_session(registry=self._registry(), model_alias="default")
item = session._prepare_task("c1", {"prompt": "do x", "model": "fast"})
assert item["model_override"] == "fast"
def test_prepare_task_missing_model_arg_means_no_override(self, tmp_db) -> None:
session = _make_session(registry=self._registry(), model_alias="default")
item = session._prepare_task("c1", {"prompt": "do x"})
assert item["model_override"] is None
def test_prepare_task_unknown_model_returns_error(self, tmp_db) -> None:
session = _make_session(registry=self._registry(), model_alias="default")
item = session._prepare_task("c1", {"prompt": "do x", "model": "bogus"})
assert item.get("needs_approval") is False
assert "error" in item
assert "unknown model alias 'bogus'" in item["error"]
assert "default" not in item["error"]
def test_prepare_task_default_model_rejected(self, tmp_db) -> None:
"""``model="default"`` is rejected even when the alias exists in the
registry — passing it explicitly would bypass the operator-configured
per-role ``task_alias``. The LLM should reach the default by omitting
``model=`` instead."""
session = _make_session(registry=self._registry(), model_alias="default")
item = session._prepare_task("c1", {"prompt": "do x", "model": "default"})
assert item.get("needs_approval") is False
assert "'default' is not a selectable model alias" in item["error"]
# ---- tool description rendering ----
@staticmethod
def _agent_tool(session, name):
"""Return the task_agent dict from the main tool set."""
for t in session._tools:
fn = t.get("function") or {}
if fn.get("name") == name:
return t
return None
def test_render_injects_alias_list_into_descriptions(self, tmp_db) -> None:
session = _make_session(registry=self._registry(), model_alias="default")
tool = self._agent_tool(session, "task_agent")
assert tool is not None, "task_agent missing from session tools"
desc = tool["function"]["parameters"]["properties"]["model"]["description"]
for alias in ("smart", "fast"):
assert f"`{alias}`" in desc, f"alias {alias} missing from {desc!r}"
# ``default`` is intentionally hidden — see
# ``test_render_omits_default_alias_from_description``.
assert "`default`" not in desc
def test_render_no_op_without_registry(self, tmp_db) -> None:
"""No registry → leave the placeholder description untouched."""
session = _make_session() # no registry
task_tool = self._agent_tool(session, "task_agent")
assert task_tool is not None
desc = task_tool["function"]["parameters"]["properties"]["model"]["description"]
assert "No alternative aliases configured" in desc
def test_refresh_picks_up_new_aliases(self, tmp_db) -> None:
"""Adding a new model and calling refresh_agent_tool_schemas updates
the description without requiring a fresh session."""
from turnstone.core.model_registry import ModelConfig
reg = self._registry()
session = _make_session(registry=reg, model_alias="default")
# Mutate the registry to add a new alias (simulates admin model add
# followed by sync-to-nodes / internal_model_reload).
new_models = dict(reg.models)
new_models["bigboi"] = ModelConfig("bigboi", "x", "x", "m")
reg.reload(new_models, reg.default, reg.fallback, reg.agent_model)
session.refresh_agent_tool_schemas()
task_tool = self._agent_tool(session, "task_agent")
assert task_tool is not None
desc = task_tool["function"]["parameters"]["properties"]["model"]["description"]
assert "`bigboi`" in desc
def test_render_omits_default_alias_from_description(self, tmp_db) -> None:
"""The ``default`` alias is filtered from the LLM-facing alias list.
Reading "default" as English ("use the default") and passing it
explicitly bypasses the operator-configured per-role plan_alias /
task_alias. The LLM should reach the per-role default by omitting
``model=`` instead.
"""
from turnstone.core.model_registry import ModelConfig, ModelRegistry
reg = ModelRegistry(
models={
"default": ModelConfig("default", "x", "x", "m"),
"gh200": ModelConfig("gh200", "x", "x", "m"),
"opus-4.7": ModelConfig("opus-4.7", "x", "x", "m"),
},
default="default",
)
session = _make_session(registry=reg, model_alias="default")
tool = self._agent_tool(session, "task_agent")
assert tool is not None
desc = tool["function"]["parameters"]["properties"]["model"]["description"]
assert "`gh200`" in desc
assert "`opus-4.7`" in desc
assert "`default`" not in desc
def test_render_falls_back_to_base_when_only_default_alias(self, tmp_db) -> None:
"""Single-CLI-model registries (only ``default`` in registry) leave
the base description untouched — the LLM sees ``"No alternative
aliases configured"`` rather than an empty alias list."""
from turnstone.core.model_registry import ModelConfig, ModelRegistry
reg = ModelRegistry(
models={"default": ModelConfig("default", "x", "x", "m")},
default="default",
)
session = _make_session(registry=reg, model_alias="default")
task_tool = self._agent_tool(session, "task_agent")
assert task_tool is not None
desc = task_tool["function"]["parameters"]["properties"]["model"]["description"]
assert "No alternative aliases configured" in desc
def test_refresh_into_only_default_resets_to_base(self, tmp_db) -> None:
"""A reload that drops the registry to only ``default`` must clear
stale alias names from the previously-rendered tool descriptions —
not return early and leave them in place."""
from turnstone.core.model_registry import ModelConfig, ModelRegistry
reg = ModelRegistry(
models={
"default": ModelConfig("default", "x", "x", "m"),
"smart": ModelConfig("smart", "x", "x", "m"),
"fast": ModelConfig("fast", "x", "x", "m"),
},
default="default",
)
session = _make_session(registry=reg, model_alias="default")
# Sanity: initial render carries the non-default aliases.
task_tool = self._agent_tool(session, "task_agent")
assert task_tool is not None
desc = task_tool["function"]["parameters"]["properties"]["model"]["description"]
assert "`smart`" in desc and "`fast`" in desc
# Reload the registry down to only ``default`` (admin removed
# every other model definition).
reg.reload({"default": ModelConfig("default", "x", "x", "m")}, "default")
session.refresh_agent_tool_schemas()
task_tool = self._agent_tool(session, "task_agent")
assert task_tool is not None
desc = task_tool["function"]["parameters"]["properties"]["model"]["description"]
assert "`smart`" not in desc, f"stale alias survived reload: {desc!r}"
assert "`fast`" not in desc, f"stale alias survived reload: {desc!r}"
assert "No alternative aliases configured" in desc
def test_module_level_constants_not_mutated(self, tmp_db) -> None:
"""Rendering must not pollute the module-level TOOLS list shared
across all sessions."""
from turnstone.core.tools import TOOLS
# Construct purely for the side effect of rendering on init.
_make_session(registry=self._registry(), model_alias="default")
for t in TOOLS:
fn = t.get("function") or {}
if fn.get("name") != "task_agent":
continue
desc = fn["parameters"]["properties"]["model"]["description"]
assert "No alternative aliases configured" in desc, (
f"module-level {fn['name']} description was mutated to: {desc!r}"
)
# ---------------------------------------------------------------------------
# Vision / image support
# ---------------------------------------------------------------------------
class TestImageExtensions:
"""Test _IMAGE_EXTENSIONS constant and detection logic."""
def test_common_image_extensions(self):
for ext in (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".ico"):
assert ext in _IMAGE_EXTENSIONS, f"{ext} should be in _IMAGE_EXTENSIONS"
def test_svg_excluded(self):
assert ".svg" not in _IMAGE_EXTENSIONS
def test_text_extensions_excluded(self):
for ext in (".py", ".txt", ".json", ".md", ".rs", ".go"):
assert ext not in _IMAGE_EXTENSIONS
class TestExecReadImage:
"""Test _exec_read_image method."""
def _make_png(self, path: str, size: int = 100) -> None:
"""Write a minimal valid-ish PNG header to a file."""
# 8-byte PNG signature + enough bytes to reach target size
header = b"\x89PNG\r\n\x1a\n"
with open(path, "wb") as f:
f.write(header + b"\x00" * max(0, size - len(header)))
def test_image_returns_content_parts(self, tmp_db, tmp_path):
"""read_file on a PNG with vision support returns content parts."""
img = tmp_path / "test.png"
self._make_png(str(img))
session = _make_session()
mock_caps = MagicMock()
mock_caps.supports_vision = True
with patch.object(session._provider, "get_capabilities", return_value=mock_caps):
item = {"call_id": "c1", "path": str(img), "offset": None, "limit": None}
call_id, output = session._exec_read_file(item)
assert call_id == "c1"
assert isinstance(output, list)
assert len(output) == 2
assert output[0]["type"] == "text"
assert "test.png" in output[0]["text"]
assert output[1]["type"] == "image_url"
url = output[1]["image_url"]["url"]
assert url.startswith("data:image/png;base64,")
# Verify base64 round-trip
b64part = url.split(",", 1)[1]
decoded = base64.b64decode(b64part)
assert decoded == img.read_bytes()
def test_no_vision_returns_text(self, tmp_db, tmp_path):
"""read_file on image with non-vision model returns text description."""
img = tmp_path / "photo.jpg"
self._make_png(str(img), size=2048)
session = _make_session()
mock_caps = MagicMock()
mock_caps.supports_vision = False
with patch.object(session._provider, "get_capabilities", return_value=mock_caps):
item = {"call_id": "c2", "path": str(img), "offset": None, "limit": None}
call_id, output = session._exec_read_file(item)
assert call_id == "c2"
assert isinstance(output, str)
assert "does not support vision" in output
assert "photo.jpg" in output
def test_oversized_image_returns_error(self, tmp_db, tmp_path):
"""Images exceeding _IMAGE_SIZE_CAP return an error string."""
img = tmp_path / "huge.png"
# Write slightly over the cap
with open(img, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * _IMAGE_SIZE_CAP)
session = _make_session()
mock_caps = MagicMock()
mock_caps.supports_vision = True
with patch.object(session._provider, "get_capabilities", return_value=mock_caps):
item = {"call_id": "c3", "path": str(img), "offset": None, "limit": None}
call_id, output = session._exec_read_file(item)
assert call_id == "c3"
assert isinstance(output, str)
assert "exceeds" in output
def test_missing_image_returns_error(self, tmp_db, tmp_path):
"""read_file on non-existent image returns error."""
session = _make_session()
mock_caps = MagicMock()
mock_caps.supports_vision = True
with patch.object(session._provider, "get_capabilities", return_value=mock_caps):
item = {
"call_id": "c4",
"path": str(tmp_path / "nope.png"),
"offset": None,
"limit": None,
}
call_id, output = session._exec_read_file(item)
assert isinstance(output, str)
assert "not found" in output
def test_svg_read_as_text(self, tmp_db, tmp_path):
"""SVG files are read as text, not as images."""
svg = tmp_path / "icon.svg"
svg.write_text('')
session = _make_session()
item = {"call_id": "c5", "path": str(svg), "offset": None, "limit": None}
call_id, output = session._exec_read_file(item)
assert isinstance(output, str)
assert "