Files
turnstone/tests/test_tools_schema.py
Patrick Buckley 984a10307e feat(coordinator): MCP tool surface for coordinator sessions (#725)
Coordinator-kind workstreams get the same MCP surface as interactive
sessions — tools, resources, and prompts (read_resource/use_prompt go
dual-kind) — gated per-persona exactly like interactive, with no
separate feature flag.

The console hosts its manager with node parity end to end: boot calls
create_mcp_client inline (same catalog resolution: DB rows, then
mcp.config_path, then this host's config.toml), the admin reload
fan-out lazily constructs and reconciles it under a lock (the node's
unlocked equivalent is #873), per-server refresh/reconnect and the
admin MCP status view cover it under the collector's console
pseudo-node id, and shutdown follows LIFO teardown. Sessions read the
live manager through a per-construction getter — the console
counterpart of the node factory's mcp_ref[0] read; client presence is
the session-level contract, and the kind-aware tool assembly runs the
same listener/prime/rebind skeleton as interactive. bind_acting_user
re-scopes listeners and per-user pools, which is security-critical for
multi-sender coordinators.

The wire-safety status projections move verbatim to core/mcp_utils so
both hosts present one schema (node endpoint bodies byte-identical);
the console's per-server action classification is a pinned COPY of the
node endpoints', with a parity test driving both sides across the
outcome matrix that fails if either drifts.

The shared MCP error card (consent / re-consent / forbidden / operator)
moves to mcp_error.js + mcp_error.css, linked by all three card hosts
and pinned by className→rule and host→link parity tests; the module
joins the whole-file sink-scan and var-ratchet lists. Reload reporting
is honest about the console entry: excluded from the unreached-node
warning's list and denominator, and the toast claims "+ console" only
for a real reconcile, with an explicit note on failure.

The pending-consent badge (#874's console half) ships too: the console
defines the same onConsentDetected seam the node dashboard exposes —
lighting up the shared pane host's existing bridge for hosted
interactive panes — and the coordinator pane threads its card's
detections through the single MCP-error helper. The badge rides the
Admin > MCP Servers rail row, hydrates at boot from the Phase 9
pending-consent endpoint the console already serves, re-syncs to DB
truth when the operator views the MCP panel, and the rail-less
standalone page carries a status-bar chip instead. A coordinator that
hits a consent wall unattended now has a persistent, glanceable signal.

Pre-existing bugs fixed along the way: create_mcp_client returned None
on pool-only installs, leaving any host managerless after restart until
the next admin MCP write; admin_import_mcp_config never scheduled the
reload fan-out (stale catalogs after import); the admin settings UI
rendered the coordinator settings section unordered and unlabeled.
Follow-ups: #873 (node reload double-construct race); #874 narrows to
the admin-MCP-view per-server indicator.
2026-07-20 07:25:56 -07:00

181 lines
6.9 KiB
Python

"""Tests for turnstone.core.tools — JSON auto-loading and schema validation."""
from turnstone.core.tools import (
_META,
PRIMARY_KEY_MAP,
TASK_AGENT_TOOLS,
TASK_AUTO_TOOLS,
TOOLS,
)
class TestToolsSchema:
def test_all_tools_have_function_type(self):
for tool in TOOLS:
assert tool["type"] == "function", f"Tool missing type='function': {tool}"
def test_all_tools_have_name(self):
for tool in TOOLS:
assert "name" in tool["function"], f"Tool missing name: {tool}"
assert isinstance(tool["function"]["name"], str)
def test_all_tools_have_description(self):
for tool in TOOLS:
assert "description" in tool["function"], f"Tool missing description: {tool}"
assert len(tool["function"]["description"]) > 0
def test_all_tools_have_parameters(self):
for tool in TOOLS:
params = tool["function"]["parameters"]
assert params["type"] == "object"
assert "properties" in params
def test_required_fields_exist_in_properties(self):
for tool in TOOLS:
func = tool["function"]
params = func["parameters"]
required = params.get("required", [])
properties = params["properties"]
for field in required:
assert field in properties, (
f"Tool '{func['name']}': required field '{field}' not in properties"
)
def test_tool_names_unique(self):
names = [t["function"]["name"] for t in TOOLS]
assert len(names) == len(set(names)), f"Duplicate tool names: {names}"
def test_task_agent_tools_subset(self):
tool_names = {t["function"]["name"] for t in TOOLS}
task_names = {t["function"]["name"] for t in TASK_AGENT_TOOLS}
assert task_names.issubset(tool_names), (
f"TASK_AGENT_TOOLS has names not in TOOLS: {task_names - tool_names}"
)
def test_task_agent_tools_not_empty(self):
assert len(TASK_AGENT_TOOLS) > 0
class TestToolsMetadata:
"""Validate the metadata extracted from JSON files."""
def test_tool_count(self):
# 31 tool files total: 12 coordinator-only + the rest interactive,
# with memory/skills/notify/read_resource/use_prompt dual-kind.
assert len(TOOLS) == 31
def test_task_agent_tools_count(self):
assert len(TASK_AGENT_TOOLS) == 13
def test_coordinator_tools_count(self):
from turnstone.core.tools import COORDINATOR_TOOLS
assert len(COORDINATOR_TOOLS) == 17
assert {t["function"]["name"] for t in COORDINATOR_TOOLS} == {
"spawn_workstream",
"spawn_batch",
"close_all_children",
"inspect_workstream",
"send_to_workstream",
"close_workstream",
"cancel_workstream",
"delete_workstream",
"list_workstreams",
"list_nodes",
"tasks",
"wait_for_workstream",
# ``memory`` is dual-kind (coordinator + interactive) so
# coords can persist orchestration context for their children
# via the new ``coordinator`` scope.
"memory",
# ``skills`` is dual-kind (replaces legacy ``skill`` +
# ``list_skills``). Read actions (find, get) auto-approve;
# write actions require operator approval + the
# ``model.skills.write`` permission. ``load`` errors on
# coord sessions — coords delegate skill assignment via
# ``spawn_workstream(skill=...)``.
"skills",
# ``notify`` is dual-kind so coordinators can post status
# updates at narrative beats (fan-out complete, batch
# failed, phase done) without spawning a child purely to
# ship a message. Routing logic is session-kind-agnostic.
"notify",
# ``read_resource``/``use_prompt`` are dual-kind (#725):
# coordinators get the full MCP surface — tools, resources,
# prompts — persona-gated like every session. The catalog
# blocks in the system prompt and the name-keyed dispatch
# light up together with these schemas; both tools keep
# auto_approve:false, so coordinator calls prompt like any
# other MCP-backed action.
"read_resource",
"use_prompt",
}
def test_auto_approve_sets_match(self):
expected = {
"read_file",
"search",
"diff_file",
"web_fetch",
"web_search",
"notify",
# Background-shell follow-ups: ``bash_output`` is read-only;
# ``kill_shell`` only signals process groups the session itself
# spawned via an approved bash call — strictly risk-reducing,
# so gating cleanup behind approval adds friction, not safety.
"bash_output",
"kill_shell",
# Coordinator read-only tools (no-mutation, safe to auto-approve):
"inspect_workstream",
"list_workstreams",
"list_nodes",
"wait_for_workstream",
}
assert expected == TASK_AUTO_TOOLS
def test_primary_key_map(self):
expected = {
"bash": "command",
"read_file": "path",
"search": "query",
"write_file": "content",
"edit_file": "old_string",
"web_fetch": "url",
"web_search": "query",
"open_preview": "target",
"task_agent": "prompt",
"bash_output": "id",
"kill_shell": "id",
"memory": "name",
"recall": "query",
"notify": "message",
"watch": "command",
"read_resource": "uri",
"use_prompt": "name",
"skills": "action",
"diff_file": "path_a",
# Coordinator tools:
"spawn_workstream": "initial_message",
"spawn_batch": "children",
"close_all_children": "reason",
"inspect_workstream": "ws_id",
"send_to_workstream": "message",
"close_workstream": "ws_id",
"cancel_workstream": "ws_id",
"delete_workstream": "ws_id",
"tasks": "action",
}
assert expected == PRIMARY_KEY_MAP
def test_no_metadata_in_function_dicts(self):
"""Ensure turnstone metadata keys are stripped from the OpenAI schema."""
meta_keys = {"task_agent", "coordinator", "auto_approve", "primary_key"}
for tool in TOOLS:
func = tool["function"]
leaked = meta_keys & set(func)
assert not leaked, f"Tool '{func['name']}' leaks metadata into function dict: {leaked}"
def test_meta_has_all_tools(self):
tool_names = {t["function"]["name"] for t in TOOLS}
assert set(_META.keys()) == tool_names