mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
480a1426b3
* fix(session): fail-closed history-commit handoff (#981) The deleted-workstream discovery is now a terminal, ws_id-keyed latch: keyed conversation commits refuse admission once the durable parent is gone (convergence finalizers and force-abandon are exempt), history handoff refuses to mint a proof token so /history fails closed with a 503 instead of silently wiping the pane, and the SSE stream carries a workstream_gone resync reason. Discarded commits leave a forensic log of commit keys and roles, never content. Conversation rows gain a commit_key (migration 071): keyed saves are idempotent under retry, validated against the full commit identity, and refused when they would cross a workstream deletion. The prune orphan category now requires a NULL alias plus a two-hour updated grace, with cutoffs computed at discovery time and carried into both dialects' rechecks. The mid-turn interjection queue is owner-partitioned with no per-site mode flags: pops take the acting principal's and unowned rows, other participants' rows are structurally retained, and enforcement lives at queue admission plus the shared before_spawn gates. The retraction ledger is bounded by open pop windows: pops open a window atomically with the queue delete, restores close their ids atomically with the ledger consume, every other exit closes through one helper, and misses for unheld ids record nothing. The workstream-gone latch refuses unattended wakes at all three gates (watcher spawn, claim, delivery pre-pop), and the retry dispatcher regained its pre-envelope cancel/error convergence net. Persistence-state reporting derives through the session bound to each UI instead of a registry lookup by id that failed open to healthy during tombstone retention. The dashboard roster no longer re-inserts ghost entries from trailing activity events, the history tool-outcome scan tolerates interleaved non-turn rows, and the shared handoff-deadline handle owns its own retirement. Single-sourced across call sites: keyed-commit row values, attachment save wrappers, tail-truncation and conflict-resolution bodies for both storage dialects; worker-slot lifecycle field sets; the direct-commit admission frame; queued-row layout accessors; the string-aware comment stripper shared by every JS harness suite. Refs #981 #964 * fix(session): sweep handoff fixes to their sibling surfaces The interactive replay loop treated a system row as a tool-batch boundary, so every tool result after an interleaved row vanished from that pane while the coordinator rendered the same history correctly. Only a conversational turn ends the batch window now, matching the shared outcome index. Accepted user turns clear the composer's attachment chips on the same viewer policy that settles optimistic bubbles rather than on having matched a local bubble, so a workstream created with an upload no longer keeps a chip for an attachment the create dispatch already consumed. The coordinator's raced-Stop arm emits the stream-end hook it inherits alongside the idle state, leaving no unfinalized bubble or unflushed tool output. Ending a session surfaces a failure toast when the request never lands or answers with a non-JSON body. The per-second persistence reconcile now probes each session without blocking: a workstream whose generation and handoff locks are held is skipped until the next pass instead of contending the locks every commit needs. The one-shot repair that gates workstream creation at capacity keeps a definite probe — it has no next pass, and the sessions likeliest to be contended are the ones whose unresolved journals emptied its candidate list. Single-sourced: the attachment lane builds its conversation row through the shared commit-identity builder; the ordinary worker exit releases its slot through the lifecycle owner; both operator surfaces snapshot their counters through one non-consuming helper; the replay preamble loses its per-kind wrappers and its config hook; the browser harness suites share one brace walker; and each in-flight history attempt is one record carrying both its abort controller and its deadline. Refs #981 #964
505 lines
18 KiB
Python
505 lines
18 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_send_threads_client_send_id_without_idempotency_semantics():
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured.update(json.loads(request.content))
|
|
return httpx.Response(200, json={"status": "ok"})
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.MockTransport(handler), base_url="http://test"
|
|
) as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
await client.send("Hello", "ws1", client_send_id="browser-send_1")
|
|
|
|
assert captured == {"message": "Hello", "client_send_id": "browser-send_1"}
|
|
|
|
|
|
@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_get_history_preserves_handoff_fields_and_limit():
|
|
captured: dict[str, str] = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["path"] = request.url.path
|
|
captured["limit"] = request.url.params["limit"]
|
|
return _json_response(
|
|
{
|
|
"ws_id": "ws1",
|
|
"messages": [
|
|
{"role": "user", "content": "hi"},
|
|
{"role": "system", "source": "compaction", "content": "summary"},
|
|
],
|
|
"cursor": 0,
|
|
"handoff_token": "epoch.7",
|
|
}
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as hc:
|
|
client = AsyncTurnstoneServer(httpx_client=hc)
|
|
resp = await client.get_history("ws1", limit=42)
|
|
|
|
assert captured == {"path": "/v1/api/workstreams/ws1/history", "limit": "42"}
|
|
assert resp.cursor == 0
|
|
assert resp.handoff_token == "epoch.7"
|
|
assert resp.messages[1]["role"] == "system"
|
|
|
|
|
|
@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
|