Files
turnstone/tests/test_openapi.py
Patrick Buckley 110d44b07e refactor(tools): remove man, math, and plan_agent built-in tools
`man` and `math` duplicated capabilities already reachable through
`bash`; `plan_agent` is better expressed as a `task_agent` running a
planning skill, and carried a large amount of special-case machinery
(plan-review gate, refinement loop, per-kind model routing). Removing
all three shrinks the tool surface and cuts per-call token cost.

Also removed, as dead-once-the-tools-are-gone:
- the `math` sandbox executor (`turnstone.core.sandbox`) and its
  `[sandbox]` extra; the eval analyst now runs bash-only
- the read-only `AGENT_TOOLS` sub-agent tool set and the `agent`
  tool-metadata key (`task_agent`/`TASK_AGENT_TOOLS` retained)
- the plan-review protocol end to end: the `on_plan_review` UI hook,
  `resolve_plan`, `POST /v1/api/plan` + `POST /v1/api/route/plan`,
  the `plan_review`/`plan_resolved` SSE events, and their Python SDK /
  TypeScript SDK / OpenAPI / frontend / Discord+Slack bindings
- the `model.plan_alias` / `model.plan_effort` settings and the
  registry `plan_model` / `plan_effort` routing fields

TOOLS 31->28, TASK_AGENT_TOOLS 13->11; COORDINATOR_TOOLS unchanged.

BREAKING CHANGE: removes the `man`, `math`, `plan_agent` tools, the
plan-review SSE/HTTP/SDK surface, and the plan_* model-routing settings
from the experimental 1.6 line.
2026-05-31 19:54:43 -07:00

213 lines
8.2 KiB
Python

"""Tests for OpenAPI spec generation."""
import json
class TestServerSpec:
"""Validate the generated server OpenAPI spec."""
def test_valid_openapi_version(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
assert spec["openapi"] == "3.1.0"
def test_has_info(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
assert "title" in spec["info"]
assert "version" in spec["info"]
def test_has_all_api_endpoints(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
paths = set(spec["paths"].keys())
expected = {
"/v1/api/workstreams",
"/v1/api/workstreams/{ws_id}",
"/v1/api/workstreams/{ws_id}/history",
"/v1/api/workstreams/{ws_id}/send",
"/v1/api/workstreams/{ws_id}/approve",
"/v1/api/workstreams/{ws_id}/cancel",
"/v1/api/workstreams/{ws_id}/rewind",
"/v1/api/workstreams/{ws_id}/retry",
"/v1/api/workstreams/{ws_id}/close",
"/v1/api/workstreams/{ws_id}/events",
"/v1/api/dashboard",
"/v1/api/workstreams/saved",
"/v1/api/command",
"/v1/api/events/global",
"/v1/api/workstreams/new",
"/v1/api/workstreams/{ws_id}/speech-to-text",
"/v1/api/tts",
"/v1/api/auth/login",
"/v1/api/auth/logout",
"/health",
}
assert expected.issubset(paths), f"Missing: {expected - paths}"
def test_voice_endpoints_documented(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
stt = spec["paths"]["/v1/api/workstreams/{ws_id}/speech-to-text"]["post"]
tts = spec["paths"]["/v1/api/tts"]["post"]
assert "responses" in stt
assert "requestBody" in tts
assert "application/json" in tts["requestBody"]["content"]
schemas = spec["components"]["schemas"]
assert "capabilities" in schemas["AvailableModelInfo"]["properties"]
models_props = schemas["ListAvailableModelsResponse"]["properties"]
assert "stt_default_alias" in models_props
assert "tts_default_alias" in models_props
def test_workstream_history_has_limit_query_param(self):
"""Mirror of the coord-side history limit param test — server now
exposes the same endpoint via the lifted factory."""
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
op = spec["paths"]["/v1/api/workstreams/{ws_id}/history"]["get"]
param_names = [p["name"] for p in op.get("parameters", [])]
assert "ws_id" in param_names
assert "limit" in param_names
def test_schemas_not_empty(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
assert len(spec["components"]["schemas"]) > 0
def test_json_serializable(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
result = json.dumps(spec)
assert len(result) > 100
def test_send_endpoint_has_request_body(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
send = spec["paths"]["/v1/api/workstreams/{ws_id}/send"]["post"]
assert "requestBody" in send
assert "application/json" in send["requestBody"]["content"]
def test_health_endpoint_not_versioned(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
assert "/health" in spec["paths"]
assert "/v1/health" not in spec["paths"]
class TestConsoleSpec:
"""Validate the generated console OpenAPI spec."""
def test_valid_openapi_version(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
assert spec["openapi"] == "3.1.0"
def test_has_cluster_endpoints(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
paths = set(spec["paths"].keys())
expected = {
"/v1/api/cluster/overview",
"/v1/api/cluster/nodes",
"/v1/api/cluster/workstreams",
"/v1/api/cluster/node/{node_id}",
"/v1/api/cluster/workstreams/new",
"/v1/api/cluster/events",
}
assert expected.issubset(paths), f"Missing: {expected - paths}"
def test_json_serializable(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
result = json.dumps(spec)
assert len(result) > 100
def test_nodes_endpoint_has_query_params(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
nodes = spec["paths"]["/v1/api/cluster/nodes"]["get"]
assert "parameters" in nodes
param_names = [p["name"] for p in nodes["parameters"]]
assert "sort" in param_names
assert "limit" in param_names
def test_has_coordinator_endpoints(self):
"""Phase 1-3 coordinator routes must appear in the OpenAPI catalog —
the spec was missing every coordinator endpoint except ``/open``,
so SDK consumers and operators couldn't discover the surface
from /docs. Pin the full set so a future regression that drops
one fails loudly."""
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
paths = set(spec["paths"].keys())
expected = {
"/v1/api/workstreams/new",
"/v1/api/workstreams",
"/v1/api/workstreams/{ws_id}",
"/v1/api/workstreams/{ws_id}/open",
"/v1/api/workstreams/{ws_id}/send",
"/v1/api/workstreams/{ws_id}/approve",
"/v1/api/workstreams/{ws_id}/cancel",
"/v1/api/workstreams/{ws_id}/rewind",
"/v1/api/workstreams/{ws_id}/retry",
"/v1/api/workstreams/{ws_id}/close",
"/v1/api/workstreams/{ws_id}/events",
"/v1/api/workstreams/{ws_id}/history",
"/v1/api/workstreams/{ws_id}/children",
"/v1/api/workstreams/{ws_id}/tasks",
"/v1/api/cluster/ws/{ws_id}/detail",
}
assert expected.issubset(paths), f"Missing: {expected - paths}"
def test_coordinator_create_has_request_body_and_200(self):
"""Coordinator create returns 200 and accepts a body.
Pre-1.5.0 this returned 201 (REST-strict for create); the lifted
``make_create_handler`` factory converges on 200 across both
kinds for response-shape parity with every other shared verb.
"""
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
op = spec["paths"]["/v1/api/workstreams/new"]["post"]
assert "requestBody" in op
assert "application/json" in op["requestBody"]["content"]
assert "200" in op["responses"]
def test_coordinator_history_has_limit_query_param(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
op = spec["paths"]["/v1/api/workstreams/{ws_id}/history"]["get"]
param_names = [p["name"] for p in op.get("parameters", [])]
assert "ws_id" in param_names # auto-added from path
assert "limit" in param_names
def test_coordinator_endpoints_share_tag(self):
"""All coordinator endpoints (including the cluster-inspect one)
live under the same OpenAPI tag so /docs groups them together."""
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
coord_paths = [p for p in spec["paths"] if "/coordinator" in p]
coord_paths.append("/v1/api/cluster/ws/{ws_id}/detail")
for path in coord_paths:
for op in spec["paths"][path].values():
assert "Coordinator" in op.get("tags", []), (
f"{path} missing Coordinator tag (tags={op.get('tags')})"
)