mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
9826ea15c5
* feat(coordinator): phase 7 — governance + skill metadata + cross-cutting invariants
Combines three stacked sub-PRs into a single coordinator phase-7
shipment against the phase-7 plan doc. The sub-PR structure (0 / A /
B) preserved on individual branches for reviewer drill-down; this
branch is the one reviewers should merge.
## Sub-PR 0 — service-auth boundary invariants
Shared helpers and contracts that lock the console ↔ node service-auth
boundary so later authz surfaces use them by construction.
- ``_effective_user_filter(request)`` in both ``turnstone.console.server``
and ``turnstone.server`` with a shared ``DENY_EMPTY_SUB`` sentinel
on ``turnstone.core.auth``. Three-way return — admin/service
bypass, scoped caller uid, or fail-closed sentinel on blank sub.
Four callsite migrations (``_coordinator_rows``,
``coordinator_children``, ``coordinator_metrics``,
``cluster_ws_live_bulk``).
- ``StorageBackend`` class docstring codifies the tenancy contract
(every list/count/aggregate method must accept ``user_id: str |
None = None`` and push ``WHERE user_id = :user_id`` into SQL) and
the ``_mapping`` row-access contract. New
``turnstone.testing.row_contract`` ships ``assert_row_like()``.
- ``_verify_collector_service_scope`` probes an upstream node at boot
with ``expected_node_id=_scope-probe_``; a 409 proves the scope
gate was passed, a 403/401 sets ``collector_scope_error`` and
causes ``cluster_snapshot`` / ``cluster_events_sse`` to return 503
with a remediation hint. Probe URL allowlist rejects non-http(s)
schemes and 169.254.0.0/16 hosts.
- 4xx log-level floor on ``_NodeDashboardCache.get``,
``_fetch_live_block``, and ``_proxy_sse`` — dotted-hierarchy
prefixes with bounded body previews. ``_bounded_body_preview`` and
``_bounded_stream_preview`` strip control chars.
## Sub-PR A — coordinator governance core
Mid-session governance surface for coordinator workstreams.
- **Trusted-session mode.** New ``coordinator.trust.send``
permission (migration 042). ``ChatSession.set_trust_send`` /
``revoke_tools`` methods with a ``_governance_lock``. ``POST
/v1/api/coordinator/{ws_id}/trust {send: bool}`` double-gated on
``admin.coordinator`` AND ``coordinator.trust.send`` with
``allow_service_bypass=False`` so service tokens can't escalate.
``_prepare_send_to_workstream`` auto-approves sends whose target is
in the coordinator's own subtree; foreign ws_ids still require
approval. ``_is_own_subtree`` checks both ``parent_ws_id`` AND
``user_id`` to defend against cross-tenant row corruption.
- **Audit-layer credential redaction.** ``record_audit`` walks
``detail`` (dicts, lists, tuples, sets, frozensets; keys too)
and routes every string through ``redact_credentials`` + a C0
control-char scrub. New kw-only ``raw_detail=True`` opt-out.
``_has_any_string`` fast-path. Audit action registry extended
with the four new governance sub-prefixes.
- **Mid-session revocation + cascading stop.** ``POST
/v1/api/coordinator/{ws_id}/restrict {revoke: [...]}`` caps 256
entries / 128 chars; ``_prepare_tool`` short-circuits with a
tool-error. ``POST /v1/api/coordinator/{ws_id}/stop_cascade``
cancels the coord's in-flight generation then dispatches
``cancel_workstream`` for every direct child in parallel via
``asyncio.gather`` bounded by ``Semaphore(16)``. Per-child
outcomes split into ``cancelled`` / ``failed`` / ``skipped``
(404 = already-gone rather than dispatch-broken). Both endpoints
apply ``allow_service_bypass=False`` on the admin gate.
- **Shared plumbing.** ``_resolve_coord_session`` helper collapses
the handler prelude three endpoints shared. ``_emit_coord_audit``
wraps ``record_audit`` in a dedicated ``ThreadPoolExecutor``
(``app.state.audit_executor``) so audit bursts don't starve cancel
dispatches. ``_require_json_object`` guards body parsing so non-
object JSON returns 400 instead of 500.
## Sub-PR B — skill metadata governance
- **Description validator (migration 043).** ``prompt_templates``
rows now require a non-empty ``description``. Existing empty rows
get backfilled with a ``"Skill: <name>"`` placeholder on upgrade.
The installer (``admin_skill_discover``) and MCP prompt sync both
synthesise a placeholder when the upstream description is blank
so non-admin write paths satisfy the invariant.
- **Skill kind classifier (migration 044).** New
``prompt_templates.kind`` column (``interactive`` / ``coordinator``
/ ``any``; defaults to ``any``). New
``turnstone.core.skill_kind.SkillKind`` StrEnum is the single
source of truth; Pydantic schemas type ``kind`` as ``SkillKind``
(OpenAPI advertises the enum) and the handler validator catches
the ValueError. ``list_skills_filtered`` gains a
``kinds: list[str] | None = None`` SQL filter.
``CoordinatorClient.list_skills`` defaults to
``kinds=["coordinator", "any"]`` so interactive-only skills are
hidden from the orchestrator.
- **``scan_status`` → ``risk_level`` rename (migration 045).**
Lossless column rename to align with ``IntentVerdict.risk_level``
terminology. Swept storage (both backends + schema + protocol),
handlers, API schemas, tool JSON, generated OpenAPI specs,
TypeScript SDK types, frontend (``governance.js``), tests, and
English prose in ``docs/judge.md`` + ``docs/tools.md``. The
user-facing on-load warning now reads ``has risk level:
{risk_tier}``. Tool JSON's ``risk_level`` enum corrected to the
scanner's actual taxonomy (``safe / low / medium / high /
critical``; was the never-shipped ``clean / flagged / unscanned /
pending``). Historical migration 021 left untouched.
## Migrations
042 (``coordinator.trust.send`` perm — PR A)
043 (description backfill — PR B)
044 (``kind`` column add — PR B)
045 (``scan_status`` → ``risk_level`` rename — PR B)
All four use position-anchored permission strings / host-side
parse-filter-rejoin on downgrade where SQL ``REPLACE`` could
corrupt prefix-overlapping values.
## Verification
- ``ruff check turnstone tests`` clean.
- ``mypy turnstone`` clean on 165 source files.
- ``pytest -m "not live"``: 4431 passed (+85 over the phase-6
baseline). Includes +32 tests in ``tests/test_service_auth_boundary.py``
and +38 in ``tests/test_coordinator_governance.py``; shared fixtures
extracted to ``tests/_coord_test_helpers.py``.
- Generated OpenAPI JSON (``sdk/typescript/openapi-{console,server}.json``)
regenerated via ``sdk/typescript/scripts/generate-types.py``; zero
``scan_status`` occurrences remaining outside the historical
migration 021 and the rename migration 045.
## Security reviews
Both reviews flagged by the phase-7 plan (items 1 + 5, plus 0a's
refuse-to-serve gate) ran through the multi-stage ``/review``
pipeline twice per sub-PR; all confirmed findings landed in-branch.
* fixup(phase-7): CI lint + PR #383 review fixups
Addresses the lint CI failure (ruff format) plus 12 findings from the
two automated PR reviewers.
Copilot:
- ``_sqlite.list_installed_skill_urls`` / ``_postgresql.list_installed_skill_urls``
used positional row indexing (``r[0]``/``r[1]``/``r[2]``) while this
same PR's ``StorageBackend`` class docstring forbids it. Switched
both to ``r._mapping["..."]`` access.
- ``list_skills.json`` previously advertised ``risk_level=""`` as a
filter for unscanned skills, but the implementation treats empty
strings as "no filter". Clarified the tool description to say
omit the filter entirely to include unscanned rows, and added an
explicit ``enum`` on the parameter restricting it to the scanner
tiers. ``_prepare_list_skills`` keeps the ``strip() or None``
normalisation — unscanned filtering now has an unambiguous contract.
- ``test_storage_skills_filtered.test_risk_level_filter`` used the
legacy ``clean`` / ``flagged`` values from the pre-rename column.
Rewritten with the scanner's actual taxonomy (``safe`` / ``high``).
github-code-quality (CodeQL):
- ``test_deny_sentinel_is_singleton`` previously asserted
``cs.DENY_EMPTY_SUB is cs.DENY_EMPTY_SUB`` — an identical-expression
comparison. Rewritten as two separate ``from ... import ... as`` aliases
(``FIRST_READ`` / ``SECOND_READ``) so the identity check is between
distinct bindings.
- ``test_restrict_empty_revoke_is_noop_but_audits`` unpacked ``state``
without using it. Renamed to ``_state``.
- Mixed import styles in ``test_service_auth_boundary.py`` — the
file previously used both ``import turnstone.console.server as cs``
and ``from turnstone.console.server import ...`` for the same
module (same story for ``turnstone.core.auth`` and
``turnstone.server``). Consolidated to the ``from X import Y`` style
used elsewhere in the file; the ``_fetch_live_block`` test now
patches via pytest's ``monkeypatch`` fixture instead of a manual
rebind through a module alias.
CI:
- ``ruff format`` reformatted one line in
``tests/test_coordinator_endpoints.py``.
Verification: ruff check + mypy clean (166 files); 4459 non-live
pytest pass.
* fix(tests): swap asyncio marker for anyio in service-auth boundary tests
PR #383 CI caught that the 13 ``@pytest.mark.asyncio`` decorators I
added in ``test_service_auth_boundary.py`` are an off-convention
choice — the rest of the repo uses ``@pytest.mark.anyio`` (148 sites
vs my 13). The CI environment pulls in ``anyio`` but not
``pytest-asyncio``, so every async test in this one file was failing
with "async def functions are not natively supported". It passed
locally by accident — my dev venv happens to have pytest-asyncio
installed ambiently.
Swapped all 13 marker sites to ``@pytest.mark.anyio``. No functional
change; the tests run under the same default asyncio backend anyio
provides.
Verification: ruff + mypy clean (166 files); 4459 non-live pytest
pass.
494 lines
18 KiB
Python
494 lines
18 KiB
Python
"""Tests for the skill built-in tool."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from turnstone.core.tools import BUILTIN_TOOL_NAMES, PRIMARY_KEY_MAP
|
|
|
|
|
|
class TestToolRegistration:
|
|
"""Verify skill is registered correctly."""
|
|
|
|
def test_in_builtin_tool_names(self) -> None:
|
|
assert "skill" in BUILTIN_TOOL_NAMES
|
|
|
|
def test_not_agent_tool(self) -> None:
|
|
from turnstone.core.tools import AGENT_TOOLS
|
|
|
|
names = {t["function"]["name"] for t in AGENT_TOOLS}
|
|
assert "skill" not in names
|
|
|
|
def test_not_task_agent_tool(self) -> None:
|
|
from turnstone.core.tools import TASK_AGENT_TOOLS
|
|
|
|
names = {t["function"]["name"] for t in TASK_AGENT_TOOLS}
|
|
assert "skill" not in names
|
|
|
|
def test_has_primary_key(self) -> None:
|
|
assert PRIMARY_KEY_MAP.get("skill") == "name"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers — minimal ChatSession mock
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_session(skills: list[dict[str, Any]] | None = None):
|
|
"""Build a minimal ChatSession with stubbed storage."""
|
|
from turnstone.core.session import ChatSession
|
|
|
|
ui = MagicMock()
|
|
session = ChatSession.__new__(ChatSession)
|
|
|
|
# Minimal state required by the methods under test
|
|
session.ui = ui
|
|
session.model = "test-model"
|
|
session._ws_id = "ws-test"
|
|
session._node_id = "node-1"
|
|
session._skill_name = None
|
|
session._skill_content = None
|
|
session._applied_skill_content = None
|
|
session.context_window = 128000
|
|
session._notify_on_complete = "{}"
|
|
session.messages = []
|
|
session._config = {}
|
|
session._tool_error_flags = {}
|
|
|
|
# Stub set_skill to just record the call
|
|
session._set_skill_called: list[str | None] = []
|
|
|
|
def fake_set_skill(name):
|
|
session._set_skill_called.append(name)
|
|
session._skill_name = name
|
|
|
|
session.set_skill = fake_set_skill
|
|
|
|
# Storage mock
|
|
_skills = skills or []
|
|
|
|
def fake_get_skill_by_name(name):
|
|
for s in _skills:
|
|
if s.get("name") == name:
|
|
return s
|
|
return None
|
|
|
|
return session, _skills, fake_get_skill_by_name
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Preparer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPrepareLoadSkill:
|
|
"""Test _prepare_skill validation and item dict shape."""
|
|
|
|
def test_load_valid(self) -> None:
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "load", "name": "code-review"})
|
|
assert item["func_name"] == "skill"
|
|
assert item["action"] == "load"
|
|
assert item["name"] == "code-review"
|
|
assert item["needs_approval"] is True
|
|
assert "execute" in item
|
|
assert "error" not in item
|
|
|
|
def test_load_missing_name(self) -> None:
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "load"})
|
|
assert "error" in item
|
|
assert "name" in item["error"].lower()
|
|
assert item["needs_approval"] is False
|
|
|
|
def test_load_empty_name(self) -> None:
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "load", "name": ""})
|
|
assert "error" in item
|
|
|
|
def test_search_with_query(self) -> None:
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "search", "query": "code review"})
|
|
assert item["action"] == "search"
|
|
assert item["query"] == "code review"
|
|
assert item["needs_approval"] is False
|
|
assert "execute" in item
|
|
|
|
def test_search_without_query(self) -> None:
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "search"})
|
|
assert item["action"] == "search"
|
|
assert item["query"] == ""
|
|
assert item["needs_approval"] is False
|
|
|
|
def test_invalid_action(self) -> None:
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "delete"})
|
|
assert "error" in item
|
|
assert "delete" in item["error"]
|
|
|
|
def test_empty_action(self) -> None:
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": ""})
|
|
assert "error" in item
|
|
|
|
def test_header_for_load(self) -> None:
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "load", "name": "my-skill"})
|
|
assert "my-skill" in item["header"]
|
|
|
|
def test_header_for_search(self) -> None:
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "search", "query": "testing"})
|
|
assert "testing" in item["header"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Executor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExecLoadSkill:
|
|
"""Test _exec_skill execution logic."""
|
|
|
|
def test_load_existing_skill(self) -> None:
|
|
skills = [
|
|
{
|
|
"name": "code-review",
|
|
"description": "Reviews code for quality",
|
|
"content": "# Code Review\nReview all code.",
|
|
"risk_level": "safe",
|
|
"category": "engineering",
|
|
}
|
|
]
|
|
session, _, fake_get = _make_session(skills)
|
|
|
|
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
|
item = session._prepare_skill("call-1", {"action": "load", "name": "code-review"})
|
|
call_id, result = session._exec_skill(item)
|
|
|
|
assert call_id == "call-1"
|
|
assert "code-review" in result
|
|
assert "Reviews code" in result
|
|
assert "safe" in result
|
|
assert session._set_skill_called == ["code-review"]
|
|
|
|
def test_load_nonexistent_skill(self) -> None:
|
|
session, _, fake_get = _make_session([])
|
|
|
|
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
|
item = session._prepare_skill("call-1", {"action": "load", "name": "nope"})
|
|
call_id, result = session._exec_skill(item)
|
|
|
|
assert "not found" in result.lower()
|
|
assert session._set_skill_called == []
|
|
|
|
def test_load_calls_ui_on_tool_result(self) -> None:
|
|
skills = [{"name": "test", "content": "content", "description": "", "risk_level": ""}]
|
|
session, _, fake_get = _make_session(skills)
|
|
|
|
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
|
item = session._prepare_skill("call-1", {"action": "load", "name": "test"})
|
|
session._exec_skill(item)
|
|
|
|
session.ui.on_tool_result.assert_called_once()
|
|
|
|
def test_search_returns_results(self) -> None:
|
|
skills = [
|
|
{
|
|
"name": "code-review",
|
|
"description": "Reviews code",
|
|
"category": "eng",
|
|
"risk_level": "safe",
|
|
"tags": "[]",
|
|
"activation": "named",
|
|
},
|
|
{
|
|
"name": "docs-writer",
|
|
"description": "Writes docs",
|
|
"category": "general",
|
|
"risk_level": "low",
|
|
"tags": "[]",
|
|
"activation": "named",
|
|
},
|
|
]
|
|
mock_storage = MagicMock()
|
|
mock_storage.list_prompt_templates.return_value = skills
|
|
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "search", "query": "code"})
|
|
|
|
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
|
call_id, result = session._exec_skill(item)
|
|
|
|
assert "code-review" in result
|
|
# docs-writer shouldn't match "code" query
|
|
assert "docs-writer" not in result
|
|
|
|
def test_search_empty_query_returns_all(self) -> None:
|
|
skills = [
|
|
{
|
|
"name": f"skill-{i}",
|
|
"description": f"Desc {i}",
|
|
"category": "general",
|
|
"risk_level": "",
|
|
"tags": "[]",
|
|
"activation": "named",
|
|
}
|
|
for i in range(15)
|
|
]
|
|
mock_storage = MagicMock()
|
|
mock_storage.list_prompt_templates.return_value = skills
|
|
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "search"})
|
|
|
|
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
|
call_id, result = session._exec_skill(item)
|
|
|
|
# Should be limited to 10
|
|
assert result.count("skill-") == 10
|
|
|
|
def test_search_no_results(self) -> None:
|
|
mock_storage = MagicMock()
|
|
mock_storage.list_prompt_templates.return_value = []
|
|
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "search", "query": "nonexistent"})
|
|
|
|
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
|
call_id, result = session._exec_skill(item)
|
|
|
|
assert "no skills found" in result.lower()
|
|
|
|
def test_search_includes_risk_level(self) -> None:
|
|
skills = [
|
|
{
|
|
"name": "risky",
|
|
"description": "Risky skill",
|
|
"category": "ops",
|
|
"risk_level": "high",
|
|
"tags": "[]",
|
|
"activation": "named",
|
|
},
|
|
]
|
|
mock_storage = MagicMock()
|
|
mock_storage.list_prompt_templates.return_value = skills
|
|
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "search", "query": "risky"})
|
|
|
|
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
|
call_id, result = session._exec_skill(item)
|
|
|
|
assert "high" in result
|
|
|
|
def test_search_storage_failure_returns_empty(self) -> None:
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "search", "query": "test"})
|
|
|
|
with patch(
|
|
"turnstone.core.storage._registry.get_storage", side_effect=RuntimeError("no storage")
|
|
):
|
|
call_id, result = session._exec_skill(item)
|
|
|
|
assert "no skills found" in result.lower()
|
|
|
|
def test_load_disabled_skill_returns_not_found(self) -> None:
|
|
skills = [
|
|
{
|
|
"name": "disabled-skill",
|
|
"content": "x",
|
|
"description": "",
|
|
"risk_level": "",
|
|
"enabled": False,
|
|
}
|
|
]
|
|
session, _, fake_get = _make_session(skills)
|
|
|
|
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
|
item = session._prepare_skill("call-1", {"action": "load", "name": "disabled-skill"})
|
|
call_id, result = session._exec_skill(item)
|
|
|
|
assert "not found" in result.lower()
|
|
assert session._set_skill_called == []
|
|
|
|
def test_load_already_active_skill(self) -> None:
|
|
skills = [{"name": "active", "content": "x", "description": "", "risk_level": "safe"}]
|
|
session, _, fake_get = _make_session(skills)
|
|
session._skill_name = "active"
|
|
|
|
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
|
item = session._prepare_skill("call-1", {"action": "load", "name": "active"})
|
|
call_id, result = session._exec_skill(item)
|
|
|
|
assert "already active" in result.lower()
|
|
assert session._set_skill_called == []
|
|
|
|
def test_search_filters_disabled(self) -> None:
|
|
skills = [
|
|
{
|
|
"name": "enabled-skill",
|
|
"description": "Good",
|
|
"category": "gen",
|
|
"risk_level": "",
|
|
"tags": "[]",
|
|
"activation": "named",
|
|
"enabled": True,
|
|
},
|
|
{
|
|
"name": "disabled-skill",
|
|
"description": "Bad",
|
|
"category": "gen",
|
|
"risk_level": "",
|
|
"tags": "[]",
|
|
"activation": "named",
|
|
"enabled": False,
|
|
},
|
|
]
|
|
mock_storage = MagicMock()
|
|
mock_storage.list_prompt_templates.return_value = skills
|
|
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "search"})
|
|
|
|
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
|
call_id, result = session._exec_skill(item)
|
|
|
|
assert "enabled-skill" in result
|
|
assert "disabled-skill" not in result
|
|
|
|
def test_search_multi_word_query(self) -> None:
|
|
skills = [
|
|
{
|
|
"name": "code-review",
|
|
"description": "Reviews code for quality",
|
|
"category": "eng",
|
|
"risk_level": "",
|
|
"tags": "[]",
|
|
"activation": "named",
|
|
},
|
|
]
|
|
mock_storage = MagicMock()
|
|
mock_storage.list_prompt_templates.return_value = skills
|
|
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "search", "query": "code review"})
|
|
|
|
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
|
call_id, result = session._exec_skill(item)
|
|
|
|
assert "code-review" in result
|
|
|
|
def test_preparer_load_has_approval_label(self) -> None:
|
|
session, _, _ = _make_session()
|
|
item = session._prepare_skill("call-1", {"action": "load", "name": "my-skill"})
|
|
assert item["approval_label"] == "skill__my-skill"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Skill Catalog Disclosure (Agent Skills standard compliance)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillCatalogDisclosure:
|
|
"""Verify <available-skills> catalog appears in system messages."""
|
|
|
|
def _build_session_with_system_messages(
|
|
self,
|
|
search_skills: list[dict[str, Any]] | None = None,
|
|
) -> Any:
|
|
"""Build a session and call _init_system_messages to get dev_parts."""
|
|
from turnstone.core.session import ChatSession
|
|
|
|
session = ChatSession.__new__(ChatSession)
|
|
ui = MagicMock()
|
|
session.ui = ui
|
|
session.model = "test-model"
|
|
session._ws_id = "ws-test"
|
|
session._node_id = "node-1"
|
|
session._skill_name = None
|
|
session._skill_content = None
|
|
session._skill_resources = {}
|
|
session._applied_skill_content = None
|
|
session.context_window = 128000
|
|
session.messages = []
|
|
session._config = {}
|
|
session.creative_mode = False
|
|
session.instructions = ""
|
|
session.system_messages = []
|
|
session._agent_system_messages = []
|
|
session.reasoning_effort = "medium"
|
|
session._pending_nudge = []
|
|
session._tool_search = None
|
|
session._mcp_client = None
|
|
session._notify_on_complete = "{}"
|
|
session._tool_error_flags = {}
|
|
from turnstone.prompts import ClientType
|
|
|
|
session._tools = []
|
|
session._client_type = ClientType.CLI
|
|
session._username = ""
|
|
session._kind = "interactive"
|
|
|
|
# Memory stubs
|
|
session._memory_config = MagicMock()
|
|
session._memory_config.fetch_limit = 0
|
|
session._user_id = "test-user"
|
|
|
|
with (
|
|
patch(
|
|
"turnstone.core.session.list_skills_by_activation",
|
|
return_value=search_skills or [],
|
|
),
|
|
patch.object(session, "_list_visible_memories", return_value=[]),
|
|
):
|
|
session._init_system_messages()
|
|
|
|
return session
|
|
|
|
def test_catalog_present_with_search_skills(self) -> None:
|
|
skills = [
|
|
{"name": "pdf-processing", "description": "Extract PDF text and forms."},
|
|
{"name": "data-analysis", "description": "Analyze datasets."},
|
|
]
|
|
session = self._build_session_with_system_messages(search_skills=skills)
|
|
content = session.system_messages[0]["content"]
|
|
assert "<available-skills>" in content
|
|
assert "pdf-processing" in content
|
|
assert "data-analysis" in content
|
|
assert "</available-skills>" in content
|
|
|
|
def test_catalog_omitted_when_no_search_skills(self) -> None:
|
|
session = self._build_session_with_system_messages(search_skills=[])
|
|
content = session.system_messages[0]["content"]
|
|
assert "<available-skills>" not in content
|
|
|
|
def test_catalog_capped_at_30(self) -> None:
|
|
skills = [{"name": f"skill-{i:03d}", "description": f"Desc {i}"} for i in range(50)]
|
|
session = self._build_session_with_system_messages(search_skills=skills)
|
|
content = session.system_messages[0]["content"]
|
|
# Should include first 30, not all 50
|
|
assert "skill-029" in content
|
|
assert "skill-030" not in content
|
|
|
|
def test_catalog_escapes_html(self) -> None:
|
|
skills = [
|
|
{"name": "xss-test", "description": "Handle <script> & 'quotes'."},
|
|
]
|
|
session = self._build_session_with_system_messages(search_skills=skills)
|
|
content = session.system_messages[0]["content"]
|
|
assert "<script>" in content
|
|
assert "<script>" not in content.replace("<available-skills>", "").replace(
|
|
"</available-skills>", ""
|
|
).replace("<skill>", "").replace("</skill>", "").replace("<name>", "").replace(
|
|
"</name>", ""
|
|
).replace("<description>", "").replace("</description>", "")
|
|
|
|
def test_catalog_includes_hint(self) -> None:
|
|
skills = [{"name": "test", "description": "Test skill."}]
|
|
session = self._build_session_with_system_messages(search_skills=skills)
|
|
content = session.system_messages[0]["content"]
|
|
assert "/skill" in content
|