mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
9be155b97a
* Quality overhaul: code tooling, CI/CD, architecture diagrams, UI redesign, and legacy cleanup - Add ruff (lint+format) and mypy (strict) with zero errors across 37 source files - Add GitHub Actions CI (lint, typecheck, test matrix 3.11/3.12/3.13) and PyPI publish workflow - Create 12 PlantUML architecture diagrams with PNG renders covering all subsystems - Refresh README and docs with badges, diagram links, and current descriptions - Refactor test_server_live.py with mock streaming helpers for deterministic CI testing - Update dependencies to current versions (openai>=2.24, httpx>=0.28, redis>=7.2) Console dashboard: - Move state indicators from top cards to fixed bottom status bar with cluster metrics - Replace flat 50-node list with hostname-prefix grouped nodes (expand/collapse, up to 1000) - Apply "Instrument Panel" visual redesign: IBM Plex Mono + Outfit fonts, warm amber accent, LED glow state indicators, deep charcoal surfaces, WCAG AA contrast compliance - Add render cache, stale indicator, active filter highlight, loading states Server web UI: - Apply matching Instrument Panel aesthetic for visual consistency with console - Fix branding (pcode → turnstone), extract inline styles to CSS classes - Rename pcode localStorage keys and history state to turnstone Legacy cleanup: - Remove persona-model-specific --persona flag and /persona slash command - Remove model_identity from chat_template_kwargs (vLLM-specific mechanism) - Refactor plan agent to use standard developer message instead of model_identity - Remove dead code (unused date/has_tools variables, noqa suppressions) * Fix CI typecheck: add mypy overrides for optional sympy/numpy imports The math sandbox optionally imports sympy and numpy at runtime (try/except ImportError). In CI these packages are not installed, so mypy raises import-not-found rather than import-untyped. Add mypy overrides to ignore missing imports for these optional dependencies. * Fix Copilot review findings: ARIA role, status bar cache, and pulse opacity - Change #node-table from role="tree" to role="list" and group elements from role="treeitem" to role="listitem" (proper ARIA semantics) - Include currentView and currentFilter.state in renderStatusBar cache key so active pill highlight updates when switching views - Align pulse animation to 0.35 opacity (already applied in CSS)
224 lines
6.6 KiB
Python
224 lines
6.6 KiB
Python
"""Tests for turnstone.mq.protocol message serialization."""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from turnstone.mq.protocol import (
|
|
AckEvent,
|
|
ApprovalRequestEvent,
|
|
ApproveMessage,
|
|
CloseWorkstreamMessage,
|
|
CommandMessage,
|
|
ContentEvent,
|
|
CreateWorkstreamMessage,
|
|
ErrorEvent,
|
|
HealthMessage,
|
|
HealthResponseEvent,
|
|
InboundMessage,
|
|
InfoEvent,
|
|
ListNodesMessage,
|
|
ListWorkstreamsMessage,
|
|
NodeListEvent,
|
|
OutboundEvent,
|
|
PlanFeedbackMessage,
|
|
PlanReviewEvent,
|
|
ReasoningEvent,
|
|
SendMessage,
|
|
StateChangeEvent,
|
|
StatusEvent,
|
|
StreamEndEvent,
|
|
ToolInfoEvent,
|
|
ToolResultEvent,
|
|
TurnCompleteEvent,
|
|
WorkstreamClosedEvent,
|
|
WorkstreamCreatedEvent,
|
|
WorkstreamListEvent,
|
|
WorkstreamRenameEvent,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Inbound message round-trip tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
INBOUND_TYPES = [
|
|
(
|
|
SendMessage,
|
|
{
|
|
"message": "hello",
|
|
"ws_id": "abc",
|
|
"auto_approve": True,
|
|
"auto_approve_tools": ["bash"],
|
|
},
|
|
),
|
|
(
|
|
ApproveMessage,
|
|
{"ws_id": "abc", "request_id": "r1", "approved": True, "feedback": "ok"},
|
|
),
|
|
(
|
|
PlanFeedbackMessage,
|
|
{"ws_id": "abc", "request_id": "r2", "feedback": "looks good"},
|
|
),
|
|
(CommandMessage, {"ws_id": "abc", "command": "/clear"}),
|
|
(
|
|
CreateWorkstreamMessage,
|
|
{"name": "test-ws", "auto_approve": False, "auto_approve_tools": ["read_file"]},
|
|
),
|
|
(CloseWorkstreamMessage, {"ws_id": "abc"}),
|
|
(ListWorkstreamsMessage, {}),
|
|
(HealthMessage, {}),
|
|
(ListNodesMessage, {}),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("cls,kwargs", INBOUND_TYPES)
|
|
def test_inbound_round_trip(cls, kwargs):
|
|
msg = cls(**kwargs)
|
|
raw = msg.to_json()
|
|
parsed = json.loads(raw)
|
|
|
|
# type field matches
|
|
assert parsed["type"] == msg.type
|
|
|
|
# correlation_id auto-generated
|
|
assert len(msg.correlation_id) == 12
|
|
assert parsed["correlation_id"] == msg.correlation_id
|
|
|
|
# timestamp present
|
|
assert msg.timestamp > 0
|
|
|
|
# Deserialize back
|
|
restored = InboundMessage.from_json(raw)
|
|
assert type(restored) is cls
|
|
assert restored.type == msg.type
|
|
assert restored.correlation_id == msg.correlation_id
|
|
|
|
# Check custom fields
|
|
for k, v in kwargs.items():
|
|
assert getattr(restored, k) == v
|
|
|
|
|
|
def test_inbound_unknown_type():
|
|
with pytest.raises(ValueError, match="Unknown inbound"):
|
|
InboundMessage.from_json('{"type": "nonexistent"}')
|
|
|
|
|
|
def test_inbound_extra_fields_ignored():
|
|
raw = json.dumps({"type": "send", "message": "hi", "extra_field": 42})
|
|
msg = InboundMessage.from_json(raw)
|
|
assert isinstance(msg, SendMessage)
|
|
assert msg.message == "hi"
|
|
assert not hasattr(msg, "extra_field")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Outbound event round-trip tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
OUTBOUND_TYPES = [
|
|
(AckEvent, {"status": "ok", "detail": "done"}),
|
|
(ContentEvent, {"text": "hello world"}),
|
|
(ReasoningEvent, {"text": "thinking..."}),
|
|
(ToolInfoEvent, {"items": [{"name": "bash", "preview": "ls"}]}),
|
|
(ApprovalRequestEvent, {"items": [{"name": "bash", "needs_approval": True}]}),
|
|
(ToolResultEvent, {"name": "bash", "output": "file.txt"}),
|
|
(PlanReviewEvent, {"content": "# Plan\n\nStep 1: ..."}),
|
|
(StatusEvent, {"prompt_tokens": 100, "completion_tokens": 50, "pct": 0.42}),
|
|
(StateChangeEvent, {"state": "thinking"}),
|
|
(TurnCompleteEvent, {}),
|
|
(StreamEndEvent, {}),
|
|
(WorkstreamCreatedEvent, {"name": "test-ws"}),
|
|
(WorkstreamClosedEvent, {}),
|
|
(WorkstreamListEvent, {"workstreams": [{"id": "abc", "name": "ws"}]}),
|
|
(WorkstreamRenameEvent, {"name": "renamed"}),
|
|
(HealthResponseEvent, {"data": {"status": "ok"}}),
|
|
(ErrorEvent, {"message": "something broke"}),
|
|
(InfoEvent, {"message": "heads up"}),
|
|
(
|
|
NodeListEvent,
|
|
{"nodes": [{"node_id": "server-12", "server_url": "http://x:8080"}]},
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("cls,kwargs", OUTBOUND_TYPES)
|
|
def test_outbound_round_trip(cls, kwargs):
|
|
event = cls(ws_id="ws1", correlation_id="c1", **kwargs)
|
|
raw = event.to_json()
|
|
parsed = json.loads(raw)
|
|
|
|
assert parsed["type"] == event.type
|
|
assert parsed["ws_id"] == "ws1"
|
|
assert parsed["correlation_id"] == "c1"
|
|
|
|
restored = OutboundEvent.from_json(raw)
|
|
assert type(restored) is cls
|
|
assert restored.ws_id == "ws1"
|
|
assert restored.correlation_id == "c1"
|
|
|
|
for k, v in kwargs.items():
|
|
assert getattr(restored, k) == v
|
|
|
|
|
|
def test_outbound_unknown_type_falls_back():
|
|
raw = json.dumps({"type": "future_event", "ws_id": "x"})
|
|
event = OutboundEvent.from_json(raw)
|
|
assert isinstance(event, OutboundEvent)
|
|
assert event.ws_id == "x"
|
|
|
|
|
|
def test_send_message_defaults():
|
|
msg = SendMessage(message="hello")
|
|
assert msg.ws_id == ""
|
|
assert msg.auto_approve is False
|
|
assert msg.auto_approve_tools == []
|
|
assert msg.name == ""
|
|
assert msg.target_node == ""
|
|
assert len(msg.correlation_id) == 12
|
|
|
|
|
|
def test_create_workstream_with_tools():
|
|
msg = CreateWorkstreamMessage(
|
|
name="ci-runner",
|
|
auto_approve=False,
|
|
auto_approve_tools=["bash", "read_file", "search"],
|
|
)
|
|
raw = msg.to_json()
|
|
restored = InboundMessage.from_json(raw)
|
|
assert restored.auto_approve_tools == ["bash", "read_file", "search"]
|
|
assert restored.name == "ci-runner"
|
|
|
|
|
|
def test_send_message_target_node():
|
|
msg = SendMessage(message="check disk", target_node="server-12")
|
|
raw = msg.to_json()
|
|
restored = InboundMessage.from_json(raw)
|
|
assert isinstance(restored, SendMessage)
|
|
assert restored.target_node == "server-12"
|
|
assert restored.message == "check disk"
|
|
|
|
|
|
def test_create_workstream_target_node():
|
|
msg = CreateWorkstreamMessage(name="debug-ws", target_node="gpu-node-3")
|
|
raw = msg.to_json()
|
|
restored = InboundMessage.from_json(raw)
|
|
assert isinstance(restored, CreateWorkstreamMessage)
|
|
assert restored.target_node == "gpu-node-3"
|
|
assert restored.name == "debug-ws"
|
|
|
|
|
|
def test_list_nodes_round_trip():
|
|
msg = ListNodesMessage()
|
|
raw = msg.to_json()
|
|
restored = InboundMessage.from_json(raw)
|
|
assert isinstance(restored, ListNodesMessage)
|
|
|
|
|
|
def test_node_list_event_round_trip():
|
|
nodes = [{"node_id": "a", "server_url": "http://a:8080"}]
|
|
event = NodeListEvent(nodes=nodes, correlation_id="c1")
|
|
raw = event.to_json()
|
|
restored = OutboundEvent.from_json(raw)
|
|
assert isinstance(restored, NodeListEvent)
|
|
assert restored.nodes == nodes
|