mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
2f7f70825b
* feat: wire prompt templates into session startup with full creation-path support
Prompt templates (prompt_templates table) now have runtime effect:
- is_default=true templates auto-apply as system message content,
concatenated in name order before user instructions
- Per-workstream template selection via --template CLI flag, template
field on POST /v1/api/workstreams/new, console creation modal dropdown,
scheduled task config, and channel adapter config
- {{model}}, {{ws_id}}, {{node_id}} variable substitution via single-pass
regex (prevents cross-variable injection)
- /template slash command for runtime switching, persisted across resume
- set_template() public API on ChatSession
Security hardening:
- MCP sync resets is_default=False on content update (prevents compromised
server from injecting defaults)
- 32KB content cap on template create/update + defensive truncation
- Template existence validation returns 400 before workstream creation
- Single-pass regex eliminates cross-variable expansion
Template field plumbed through all creation paths: CLI, server API, MQ
protocol/bridge, console backend, scheduler dispatch, channel router,
MQ client. Migration 010 adds template column to scheduled_tasks.
Frontend: console workstream modal template dropdown, scheduler
create/edit template field, governance template UI variables auto-detected
from content (read-only display replaces editable input). Focus trap and
Enter-key accessibility fixes in workstream modal.
Docs: governance.md template runtime section, api-reference.md template
field, governance + MCP architecture diagrams updated.
Python + TypeScript SDKs, Pydantic schemas all updated. 29 new tests.
* fix: address PR #47 review feedback
- Defer template validation until after resume_ws — a bad template name
no longer 400s when resume would have ignored it anyway
- Add template field to OpenAPI JSON specs (openapi-server.json,
openapi-console.json) for SDK/docs consistency
- Validate template existence in schedule create and update endpoints —
reject unknown template names with 400 instead of allowing schedules
that would silently fail at dispatch time
240 lines
7.2 KiB
Python
240 lines
7.2 KiB
Python
"""Tests for turnstone.mq.protocol message serialization."""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from turnstone.mq.protocol import (
|
|
AckEvent,
|
|
ApprovalRequestEvent,
|
|
ApproveMessage,
|
|
CancelMessage,
|
|
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, {}),
|
|
(CancelMessage, {"ws_id": "abc"}),
|
|
]
|
|
|
|
|
|
@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, {"call_id": "call_123", "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_create_workstream_template_field():
|
|
msg = CreateWorkstreamMessage(name="ws", template="code-review")
|
|
assert msg.template == "code-review"
|
|
raw = msg.to_json()
|
|
restored = InboundMessage.from_json(raw)
|
|
assert isinstance(restored, CreateWorkstreamMessage)
|
|
assert restored.template == "code-review"
|
|
|
|
|
|
def test_create_workstream_template_default_empty():
|
|
msg = CreateWorkstreamMessage(name="ws")
|
|
assert msg.template == ""
|
|
|
|
|
|
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
|