mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-27 22:34:51 -06:00
feat: prompt template tech debt — tests, read-only endpoints, double-… (#67)
* feat: prompt template tech debt — tests, read-only endpoints, double-load fix, server creation modal Close test coverage gaps for prompt templates: - Resume with deleted template: verifies graceful degradation (template_content=None, warning logged) - Threading safety: concurrent set_template/init_system_messages with no race conditions - Factory passthrough: template kwarg propagation through WorkstreamManager.create() Add read-only template listing endpoints (read scope, no content exposed): - GET /v1/api/templates — prompt template summaries (name, category, is_default, origin) - GET /v1/api/ws-templates — enabled workstream template summaries (name, description, model) - Available on both server and console; Python + TypeScript SDK methods added - Console creation modal switched from admin endpoint to read-scope endpoint Eliminate double-load inefficiency in workstream creation: - Template validation moved before mgr.create() (no create-then-rollback on invalid template) - template kwarg plumbed through WorkstreamManager.create() and session factory - _SessionFactory Protocol added for proper mypy typing Add workstream creation modal to server web UI: - Name, model, template dropdown, ws_template/profile dropdown - Instrument panel aesthetic: gradient top border, blur backdrop, amber accent - Focus trap, Escape/Enter keyboard handling, loading state, error display - WCAG AA contrast compliance, reduced-motion support * fix: add list_ws_templates SDK methods + regenerate OpenAPI snapshots Add list_ws_templates() to Python SDK (async + sync) and listWsTemplates() to TypeScript SDK for the new GET /v1/api/ws-templates server endpoint. Add WsTemplateSummary + ListWsTemplateSummaryResponse TypeScript types. Regenerate openapi-server.json and openapi-console.json snapshots. Addresses Copilot review feedback on PR #67. * fix: skip template pre-validation when resuming a workstream When resume_ws is set, the request's template field is irrelevant — resume() restores the template from workstream_config. Pre-validating a stale template name would incorrectly return 400 before the resume even runs. Addresses Copilot review feedback on PR #67.
This commit is contained in:
@@ -530,7 +530,9 @@ class TestWorkstreamModelParam:
|
||||
|
||||
captured_alias = None
|
||||
|
||||
def factory(ui: Any, model_alias: str | None = None, ws_id: str | None = None) -> Any:
|
||||
def factory(
|
||||
ui: Any, model_alias: str | None = None, ws_id: str | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
nonlocal captured_alias
|
||||
captured_alias = model_alias
|
||||
mock_session = MagicMock()
|
||||
@@ -544,7 +546,9 @@ class TestWorkstreamModelParam:
|
||||
def test_create_without_model(self) -> None:
|
||||
captured_alias = None
|
||||
|
||||
def factory(ui: Any, model_alias: str | None = None, ws_id: str | None = None) -> Any:
|
||||
def factory(
|
||||
ui: Any, model_alias: str | None = None, ws_id: str | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
nonlocal captured_alias
|
||||
captured_alias = model_alias
|
||||
mock_session = MagicMock()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.session import ChatSession, _render_template
|
||||
@@ -391,3 +392,131 @@ class TestMCPTemplates:
|
||||
session = _make_session(template="mcp__server__code")
|
||||
content = _sys_content(session)
|
||||
assert "MCP_EXPLICIT" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resume with deleted template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResumeDeletedTemplate:
|
||||
def test_resume_with_deleted_template_degrades_gracefully(self, tmp_db, capsys):
|
||||
from turnstone.core.memory import save_message
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "ephemeral-tpl", "EPHEMERAL_CONTENT", is_default=False)
|
||||
|
||||
# Create session with template, save a message so resume has history
|
||||
session1 = _make_session(template="ephemeral-tpl")
|
||||
ws_id = session1.ws_id
|
||||
save_message(ws_id, "user", "hello")
|
||||
assert "EPHEMERAL_CONTENT" in _sys_content(session1)
|
||||
|
||||
# Delete the template from storage
|
||||
db.delete_prompt_template("t1")
|
||||
|
||||
# Resume into a new session
|
||||
session2 = _make_session()
|
||||
resumed = session2.resume(ws_id)
|
||||
|
||||
assert resumed
|
||||
assert session2._template_name == "ephemeral-tpl"
|
||||
assert session2._template_content is None
|
||||
# System message should not contain the deleted template content
|
||||
content = _sys_content(session2)
|
||||
assert "EPHEMERAL_CONTENT" not in content
|
||||
# Warning should be logged via structlog
|
||||
captured = capsys.readouterr()
|
||||
assert "not_found" in captured.out or "not_found" in captured.err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Threading safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTemplateFactoryPassthrough:
|
||||
def test_template_passed_through_workstream_create(self, tmp_db):
|
||||
"""WorkstreamManager.create(template=...) propagates to session factory."""
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.core.workstream import WorkstreamManager
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "factory-tpl", "FACTORY_CONTENT", is_default=False)
|
||||
|
||||
captured_template = None
|
||||
|
||||
def factory(ui, model_alias=None, ws_id=None, *, template=None):
|
||||
nonlocal captured_template
|
||||
captured_template = template
|
||||
return _make_session(template=template)
|
||||
|
||||
mgr = WorkstreamManager(factory)
|
||||
ws = mgr.create(name="test", template="factory-tpl")
|
||||
assert captured_template == "factory-tpl"
|
||||
assert ws.session is not None
|
||||
assert ws.session._template_name == "factory-tpl"
|
||||
assert "FACTORY_CONTENT" in _sys_content(ws.session)
|
||||
|
||||
def test_template_none_uses_defaults(self, tmp_db):
|
||||
"""WorkstreamManager.create() without template passes None."""
|
||||
captured_template = "sentinel"
|
||||
|
||||
def factory(ui, model_alias=None, ws_id=None, *, template=None):
|
||||
nonlocal captured_template
|
||||
captured_template = template
|
||||
return _make_session(template=template)
|
||||
|
||||
from turnstone.core.workstream import WorkstreamManager
|
||||
|
||||
mgr = WorkstreamManager(factory)
|
||||
mgr.create(name="test")
|
||||
assert captured_template is None
|
||||
|
||||
|
||||
class TestTemplateThreadSafety:
|
||||
def test_concurrent_template_and_system_message_init(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "thread-tpl", "THREAD_TEMPLATE", is_default=False)
|
||||
|
||||
session = _make_session(template="thread-tpl")
|
||||
errors: list[Exception] = []
|
||||
stop = threading.Event()
|
||||
iterations = 200
|
||||
|
||||
def init_loop():
|
||||
"""Simulate MCP callback repeatedly calling _init_system_messages."""
|
||||
try:
|
||||
for _ in range(iterations):
|
||||
if stop.is_set():
|
||||
break
|
||||
session._init_system_messages()
|
||||
# system_messages must always be a valid list
|
||||
msgs = session.system_messages
|
||||
assert isinstance(msgs, list)
|
||||
assert len(msgs) > 0
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
t = threading.Thread(target=init_loop, daemon=True)
|
||||
t.start()
|
||||
|
||||
# Main thread toggles template on/off
|
||||
try:
|
||||
for i in range(iterations):
|
||||
if i % 2 == 0:
|
||||
session.set_template("thread-tpl")
|
||||
else:
|
||||
session.set_template(None)
|
||||
finally:
|
||||
stop.set()
|
||||
t.join(timeout=5)
|
||||
|
||||
assert not errors, f"Thread raised: {errors}"
|
||||
# Final state: system_messages is a valid list
|
||||
msgs = session.system_messages
|
||||
assert isinstance(msgs, list)
|
||||
assert len(msgs) > 0
|
||||
|
||||
@@ -20,7 +20,7 @@ class FakeSession:
|
||||
self.messages = []
|
||||
|
||||
|
||||
def _fake_factory(ui, model_alias=None, ws_id=None):
|
||||
def _fake_factory(ui, model_alias=None, ws_id=None, **kwargs):
|
||||
return FakeSession()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user