mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
7a06f5e8bc
* refactor(session): make ModelLane the provider boundary (#979) ## Summary This closes the model-lane ownership gap left by #832: `ChatSession` no longer stores raw provider/client handles. `ResolvedModelBinding` now carries the provider, client, model, capabilities, registry generation, and backend-auth configuration as one coherent snapshot. - Atomically rebind existing sessions after model-registry changes while pinning each in-flight send, fallback, judge, output guard, task agent, title, compaction, perception, and voice operation to its initiating principal and binding. - Fence UI publication, canonical trajectory folds, durable writes, streams, retries, child scopes, and judge work by generation. Stop can hand off to a successor without accepting late state; cancelled tools retain typed effect receipts, and concurrent approval batches resolve by exact cycle or call. - Make create, fork, open, close, and delete race-safe with hidden `creating` reservations, incarnation-aware state tails, and an ACL-rechecked transaction that clones checkpoint-bounded history, configuration, project/persona state, and attachment references. - Extend REST/OpenAPI and Python/TypeScript SDK contracts for create/fork inputs, routed-create metadata, live-workstream probes, targeted approvals, and structured cancellation results. - Update architecture, storage, authentication, judge, channel, console, API, and SDK documentation, including regenerated architecture diagrams and OpenAPI artifacts. ## Validation - SQLite suite: 11,188 passed, 9 skipped, 10 deselected - PostgreSQL suite: 11,195 passed, 2 skipped, 10 deselected - Live backend: 3 passed - SSE recovery: 6 passed; browser recovery harness passed all scenarios - Ruff: clean; 595 files correctly formatted - mypy: 243 source files clean - TypeScript: typecheck/build and 35 tests passed - OpenAPI artifacts fresh; all 14 changed diagrams reproduce byte-for-byte - `git diff --check` and Git LFS integrity clean Closes #979. * fix(deps): update nanoid for GHSA-2v37-7h3g-55p8 Refresh the transitive lock entry admitted by PostCSS so the TypeScript security gate no longer resolves the vulnerable custom-generator implementation. Validation: - npm ci - npm audit --audit-level=moderate: 0 vulnerabilities - TypeScript typecheck and build - TypeScript tests: 35 passed * fix(test): assert canonical model registry URLs Replace prefix checks with exact canonical base URL assertions so the tests do not model incomplete URL validation. Validation: tests/test_model_registry.py (185 passed); Ruff check/format; mypy.
458 lines
17 KiB
Python
458 lines
17 KiB
Python
"""Tests for turnstone.sdk.server — server client with mocked HTTP transport."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from turnstone.sdk._types import TurnstoneAPIError
|
|
from turnstone.sdk.server import AsyncTurnstoneServer
|
|
|
|
|
|
def _mock_transport(
|
|
responses: dict[str, httpx.Response] | None = None,
|
|
) -> httpx.MockTransport:
|
|
"""Create a mock transport that routes by method+path."""
|
|
table = responses or {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
key = f"{request.method} {request.url.path}"
|
|
if key in table:
|
|
return table[key]
|
|
return httpx.Response(404, json={"error": "not found"})
|
|
|
|
return httpx.MockTransport(handler)
|
|
|
|
|
|
def _json_response(data: dict, status: int = 200) -> httpx.Response:
|
|
return httpx.Response(status, json=data)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Workstream management
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_list_workstreams():
|
|
transport = _mock_transport(
|
|
{
|
|
"GET /v1/api/workstreams": _json_response(
|
|
{"workstreams": [{"ws_id": "ws1", "name": "test", "state": "idle"}]}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.list_workstreams()
|
|
assert len(resp.workstreams) == 1
|
|
# Row key renamed id → ws_id in the Stage 2 list-verb lift.
|
|
assert resp.workstreams[0].ws_id == "ws1"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard():
|
|
transport = _mock_transport(
|
|
{
|
|
"GET /v1/api/dashboard": _json_response(
|
|
{
|
|
"workstreams": [
|
|
{
|
|
"ws_id": "ws1",
|
|
"name": "demo",
|
|
"state": "idle",
|
|
"tokens": 100,
|
|
"context_ratio": 0.1,
|
|
}
|
|
],
|
|
"aggregate": {
|
|
"total_tokens": 100,
|
|
"total_tool_calls": 5,
|
|
"active_count": 1,
|
|
"total_count": 1,
|
|
},
|
|
}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.dashboard()
|
|
assert resp.aggregate.total_tokens == 100
|
|
assert len(resp.workstreams) == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_create_workstream():
|
|
transport = _mock_transport(
|
|
{"POST /v1/api/workstreams/new": _json_response({"ws_id": "ws_new", "name": "Analysis"})}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.create_workstream(name="Analysis")
|
|
assert resp.ws_id == "ws_new"
|
|
assert resp.name == "Analysis"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_create_workstream_forwards_structured_notify_targets():
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured.update(json.loads(request.content))
|
|
return _json_response({"ws_id": "ws_new", "name": "Analysis"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
await client.create_workstream(
|
|
name="Analysis",
|
|
notify_targets=[{"channel_type": "slack", "channel_id": "C123"}],
|
|
)
|
|
|
|
assert captured["notify_targets"] == [{"channel_type": "slack", "channel_id": "C123"}]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_close_workstream():
|
|
transport = _mock_transport(
|
|
{"POST /v1/api/workstreams/ws1/close": _json_response({"status": "ok"})}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.close_workstream("ws1")
|
|
assert resp.status == "ok"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_close_workstream_sends_valid_json_body():
|
|
"""The interactive close handler reads the body via
|
|
``read_json_or_400`` (``supports_close_reason=True``), so a missing
|
|
or non-JSON body 400s. Regression-lock that the SDK never sends
|
|
an empty body. ``request.json()`` raises ``ValueError`` on empty
|
|
bytes; this handler asserts the SDK actually transmitted a JSON
|
|
object."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["content"] = bytes(request.content)
|
|
captured["body"] = json.loads(request.content) if request.content else None
|
|
return httpx.Response(200, json={"status": "ok"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
# Default call (no reason) — body must still be valid JSON.
|
|
await client.close_workstream("ws1")
|
|
assert captured["body"] == {}
|
|
# With reason — field round-trips.
|
|
await client.close_workstream("ws1", reason="task complete")
|
|
assert captured["body"] == {"reason": "task complete"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_rewind_sends_turns_body():
|
|
"""``rewind()`` must transmit ``{"turns": N}`` — the path-keyed
|
|
rewind handler reads the body via ``read_json_or_400``, so a no-body
|
|
send would 400. Inspect the body, not just that the path answered
|
|
(feedback_mock_transport_body_inspection)."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["path"] = request.url.path
|
|
captured["body"] = json.loads(request.content) if request.content else None
|
|
return httpx.Response(200, json={"status": "ok", "removed": 4})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.rewind("ws1", turns=2)
|
|
assert captured["path"] == "/v1/api/workstreams/ws1/rewind"
|
|
assert captured["body"] == {"turns": 2}
|
|
assert resp.status == "ok"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_retry_posts_to_path_keyed_endpoint():
|
|
transport = _mock_transport(
|
|
{"POST /v1/api/workstreams/ws1/retry": _json_response({"status": "ok", "retried": True})}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.retry("ws1")
|
|
assert resp.status == "ok"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chat interaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_send():
|
|
transport = _mock_transport(
|
|
{"POST /v1/api/workstreams/ws1/send": _json_response({"status": "ok"})}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.send("Hello", "ws1")
|
|
assert resp.status == "ok"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_approve():
|
|
transport = _mock_transport(
|
|
{
|
|
"POST /v1/api/workstreams/ws1/approve": _json_response(
|
|
{"status": "ok", "cycle_id": "cycle-1"}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.approve(ws_id="ws1", approved=True, feedback="looks good")
|
|
assert resp.status == "ok"
|
|
assert resp.cycle_id == "cycle-1"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_cancel_preserves_dropped_snapshot():
|
|
transport = _mock_transport(
|
|
{
|
|
"POST /v1/api/workstreams/ws1/cancel": _json_response(
|
|
{"status": "cancelled", "dropped": {"tool_calls": ["call-1"]}}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.cancel("ws1")
|
|
assert resp.status == "cancelled"
|
|
assert resp.dropped == {"tool_calls": ["call-1"]}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_command():
|
|
transport = _mock_transport({"POST /v1/api/command": _json_response({"status": "ok"})})
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.command(ws_id="ws1", command="/clear")
|
|
assert resp.status == "ok"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# History
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_list_saved_workstreams():
|
|
transport = _mock_transport(
|
|
{
|
|
"GET /v1/api/workstreams/saved": _json_response(
|
|
{
|
|
"workstreams": [
|
|
{
|
|
"ws_id": "s1",
|
|
"title": "test",
|
|
"created": "2024-01-01",
|
|
"updated": "2024-01-02",
|
|
"message_count": 5,
|
|
"state": "idle",
|
|
"kind": "interactive",
|
|
"node_id": "node-1",
|
|
"model_alias": "m1",
|
|
"launch_skill": "news",
|
|
"child_count": 2,
|
|
"context_tokens": 500,
|
|
"context_ratio": 0.5,
|
|
}
|
|
]
|
|
}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.list_saved_workstreams()
|
|
assert len(resp.workstreams) == 1
|
|
ws = resp.workstreams[0]
|
|
# enriched fields deserialize onto the model, incl. kind -> enum
|
|
from turnstone.core.workstream import WorkstreamKind
|
|
|
|
assert ws.model_alias == "m1"
|
|
assert ws.launch_skill == "news"
|
|
assert ws.context_ratio == 0.5
|
|
assert ws.child_count == 2
|
|
assert ws.kind == WorkstreamKind.INTERACTIVE
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_login():
|
|
transport = _mock_transport(
|
|
{"POST /v1/api/auth/login": _json_response({"status": "ok", "role": "full"})}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.login("test_token")
|
|
assert resp.role == "full"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_logout():
|
|
transport = _mock_transport({"POST /v1/api/auth/logout": _json_response({"status": "ok"})})
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.logout()
|
|
assert resp.status == "ok"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Health
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_health():
|
|
transport = _mock_transport(
|
|
{
|
|
"GET /health": _json_response(
|
|
{
|
|
"status": "ok",
|
|
"version": "0.3.0",
|
|
"uptime_seconds": 120.0,
|
|
"model": "gpt-5",
|
|
"workstreams": {"total": 1, "idle": 1},
|
|
}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.health()
|
|
assert resp.status == "ok"
|
|
assert resp.version == "0.3.0"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_api_error_raised():
|
|
transport = _mock_transport(
|
|
{
|
|
"POST /v1/api/workstreams/bad_ws/send": httpx.Response(
|
|
404, json={"error": "Unknown workstream"}
|
|
)
|
|
}
|
|
)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
with pytest.raises(TurnstoneAPIError) as exc_info:
|
|
await client.send("hi", "bad_ws")
|
|
assert exc_info.value.status_code == 404
|
|
assert "Unknown workstream" in exc_info.value.message
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_auth_header_injected():
|
|
"""Verify the Authorization header is set when a token is provided."""
|
|
captured_headers: dict[str, str] = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_headers.update(dict(request.headers))
|
|
return httpx.Response(200, json={"workstreams": []})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
# Manually set auth header since we're injecting the client
|
|
hc.headers["Authorization"] = "Bearer tok_test"
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
await client.list_workstreams()
|
|
assert captured_headers.get("authorization") == "Bearer tok_test"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_request_body_correct():
|
|
"""Verify POST requests send the correct JSON body."""
|
|
captured_body: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_body.update(json.loads(request.content))
|
|
return httpx.Response(200, json={"status": "ok"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
await client.send("Hello world", "ws_123")
|
|
assert captured_body == {"message": "Hello world"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# create_workstream extended params
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_create_workstream_extended_params():
|
|
"""New optional params appear in JSON body only when non-empty."""
|
|
captured_body: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_body.update(json.loads(request.content))
|
|
return httpx.Response(200, json={"ws_id": "ws_ext", "name": "ext"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
await client.create_workstream(
|
|
name="ext",
|
|
judge_model="judge-fast",
|
|
initial_message="hi",
|
|
auto_approve_tools=["read_file", "write_file"],
|
|
user_id="u42",
|
|
ws_id="ws_custom",
|
|
persona="researcher",
|
|
project_id="proj_9",
|
|
)
|
|
assert captured_body["name"] == "ext"
|
|
assert captured_body["judge_model"] == "judge-fast"
|
|
assert captured_body["initial_message"] == "hi"
|
|
assert captured_body["auto_approve_tools"] == ["read_file", "write_file"]
|
|
assert captured_body["user_id"] == "u42"
|
|
assert captured_body["ws_id"] == "ws_custom"
|
|
assert captured_body["persona"] == "researcher"
|
|
assert captured_body["project_id"] == "proj_9"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_create_workstream_omits_empty_params():
|
|
"""Empty-string params should NOT appear in the JSON body."""
|
|
captured_body: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_body.update(json.loads(request.content))
|
|
return httpx.Response(200, json={"ws_id": "ws_min", "name": "min"})
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
await client.create_workstream(name="min")
|
|
assert captured_body == {"name": "min"}
|
|
assert "judge_model" not in captured_body
|
|
assert "initial_message" not in captured_body
|
|
assert "auto_approve_tools" not in captured_body
|
|
assert "user_id" not in captured_body
|
|
assert "ws_id" not in captured_body
|
|
assert "persona" not in captured_body
|
|
assert "project_id" not in captured_body
|