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
2399 lines
89 KiB
Python
2399 lines
89 KiB
Python
"""Tests for the skills foundation feature.
|
|
|
|
Covers storage operations, session runtime wiring, skill search,
|
|
admin API endpoints, and MCP sync integration.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware import Middleware
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.routing import Mount, Route
|
|
from starlette.testclient import TestClient
|
|
|
|
if TYPE_CHECKING:
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
|
|
from turnstone.core.auth import AuthResult
|
|
from turnstone.core.session import ChatSession
|
|
from turnstone.core.skill_search import SkillSearchManager
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class NullUI:
|
|
"""UI adapter that discards all output."""
|
|
|
|
def on_turn_start(self):
|
|
pass
|
|
|
|
def on_turn_committed(self):
|
|
pass
|
|
|
|
def on_thinking_start(self):
|
|
pass
|
|
|
|
def on_thinking_stop(self):
|
|
pass
|
|
|
|
def on_reasoning_token(self, text):
|
|
pass
|
|
|
|
def on_content_token(self, text):
|
|
pass
|
|
|
|
def on_stream_end(self):
|
|
pass
|
|
|
|
def approve_tools(self, items):
|
|
return True, None
|
|
|
|
def on_tool_result(self, call_id, name, output, **kwargs):
|
|
pass
|
|
|
|
def on_tool_output_chunk(self, call_id, chunk):
|
|
pass
|
|
|
|
def on_status(self, usage, context_window, effort):
|
|
pass
|
|
|
|
def on_info(self, message):
|
|
pass
|
|
|
|
def on_error(self, message):
|
|
pass
|
|
|
|
def on_state_change(self, state):
|
|
pass
|
|
|
|
def on_rename(self, name):
|
|
pass
|
|
|
|
def on_output_warning(self, call_id, assessment):
|
|
pass
|
|
|
|
def record_output_assessment(
|
|
self,
|
|
call_id,
|
|
assessment,
|
|
*,
|
|
tier="heuristic",
|
|
reasoning="",
|
|
judge_model="",
|
|
latency_ms=0,
|
|
confidence=0.0,
|
|
):
|
|
pass
|
|
|
|
|
|
def _make_session(**kwargs):
|
|
from turnstone.core.memory import register_workstream
|
|
|
|
defaults = dict(
|
|
client=MagicMock(),
|
|
model="test-model",
|
|
ui=NullUI(),
|
|
instructions=None,
|
|
temperature=0.5,
|
|
max_tokens=4096,
|
|
tool_timeout=30,
|
|
)
|
|
defaults.update(kwargs)
|
|
session = ChatSession(**defaults)
|
|
# Mirror production's parent-before-conversation ordering. Skill slash
|
|
# commands persist keyed SYSTEM rows and must retain the orphan-write
|
|
# protection exercised by the storage layer.
|
|
register_workstream(session.ws_id, user_id=kwargs.get("user_id"))
|
|
return session
|
|
|
|
|
|
def _sys_content(session: ChatSession) -> str:
|
|
"""Full prompt prefix: identity system message + any skill context message."""
|
|
assert session.system_messages
|
|
return "\n".join(m["content"] for m in session.system_messages)
|
|
|
|
|
|
def _create_template(db, template_id, name, content, **kwargs):
|
|
"""Helper to create a prompt template in storage."""
|
|
db.create_prompt_template(
|
|
template_id=template_id,
|
|
name=name,
|
|
category=kwargs.get("category", "general"),
|
|
content=content,
|
|
variables=kwargs.get("variables", "[]"),
|
|
is_default=kwargs.get("is_default", False),
|
|
org_id=kwargs.get("org_id", ""),
|
|
created_by=kwargs.get("created_by", "test"),
|
|
origin=kwargs.get("origin", "manual"),
|
|
mcp_server=kwargs.get("mcp_server", ""),
|
|
readonly=kwargs.get("readonly", False),
|
|
description=kwargs.get("description", ""),
|
|
tags=kwargs.get("tags", "[]"),
|
|
source_url=kwargs.get("source_url", ""),
|
|
version=kwargs.get("version", "1.0.0"),
|
|
author=kwargs.get("author", ""),
|
|
activation=kwargs.get("activation", "named"),
|
|
token_estimate=kwargs.get("token_estimate", 0),
|
|
model=kwargs.get("model", ""),
|
|
auto_approve=kwargs.get("auto_approve", False),
|
|
temperature=kwargs.get("temperature"),
|
|
reasoning_effort=kwargs.get("reasoning_effort", ""),
|
|
max_tokens=kwargs.get("max_tokens"),
|
|
token_budget=kwargs.get("token_budget", 0),
|
|
agent_max_turns=kwargs.get("agent_max_turns"),
|
|
notify_on_complete=kwargs.get("notify_on_complete", "{}"),
|
|
enabled=kwargs.get("enabled", True),
|
|
allowed_tools=kwargs.get("allowed_tools", "[]"),
|
|
priority=kwargs.get("priority", 0),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture()
|
|
def db(tmp_path):
|
|
"""Create a fresh SQLite backend for each test."""
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Storage tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillStorage:
|
|
def test_create_skill_with_new_fields(self, db):
|
|
"""Create a prompt template with all skill fields and verify storage."""
|
|
db.create_prompt_template(
|
|
template_id="s1",
|
|
name="code-review",
|
|
category="engineering",
|
|
content="You are a code reviewer.",
|
|
variables="[]",
|
|
is_default=False,
|
|
org_id="",
|
|
created_by="admin",
|
|
description="Reviews code for quality and correctness",
|
|
tags='["code", "review"]',
|
|
activation="search",
|
|
author="alice",
|
|
version="2.0.0",
|
|
token_estimate=500,
|
|
)
|
|
tpl = db.get_prompt_template("s1")
|
|
assert tpl is not None
|
|
assert tpl["template_id"] == "s1"
|
|
assert tpl["name"] == "code-review"
|
|
assert tpl["category"] == "engineering"
|
|
assert tpl["content"] == "You are a code reviewer."
|
|
assert tpl["description"] == "Reviews code for quality and correctness"
|
|
assert tpl["tags"] == '["code", "review"]'
|
|
assert tpl["activation"] == "search"
|
|
assert tpl["author"] == "alice"
|
|
assert tpl["version"] == "2.0.0"
|
|
assert tpl["token_estimate"] == 500
|
|
|
|
def test_list_skills_by_activation(self, db):
|
|
"""Filter templates by activation mode."""
|
|
_create_template(db, "s1", "default-skill", "D", activation="default", is_default=True)
|
|
_create_template(db, "s2", "search-skill", "S", activation="search")
|
|
_create_template(db, "s3", "named-skill", "N", activation="named")
|
|
_create_template(db, "s4", "another-search", "S2", activation="search")
|
|
|
|
defaults = db.list_skills_by_activation("default")
|
|
assert len(defaults) == 1
|
|
assert defaults[0]["name"] == "default-skill"
|
|
|
|
search = db.list_skills_by_activation("search")
|
|
assert len(search) == 2
|
|
names = {s["name"] for s in search}
|
|
assert names == {"another-search", "search-skill"}
|
|
|
|
def test_activation_is_default_sync_on_create(self, db):
|
|
"""Creating with activation='default' sets is_default=True."""
|
|
db.create_prompt_template(
|
|
template_id="s1",
|
|
name="auto-default",
|
|
category="general",
|
|
content="content",
|
|
activation="default",
|
|
)
|
|
tpl = db.get_prompt_template("s1")
|
|
assert tpl is not None
|
|
assert tpl["activation"] == "default"
|
|
assert tpl["is_default"] is True
|
|
|
|
def test_activation_is_default_sync_on_update(self, db):
|
|
"""Updating activation syncs is_default and vice versa."""
|
|
_create_template(db, "s1", "skill", "content", activation="named")
|
|
tpl = db.get_prompt_template("s1")
|
|
assert tpl is not None
|
|
assert tpl["is_default"] is False
|
|
|
|
# Setting activation to default should set is_default
|
|
db.update_prompt_template("s1", activation="default")
|
|
tpl = db.get_prompt_template("s1")
|
|
assert tpl is not None
|
|
assert tpl["is_default"] is True
|
|
assert tpl["activation"] == "default"
|
|
|
|
# Setting is_default to False should set activation to named
|
|
db.update_prompt_template("s1", is_default=False)
|
|
tpl = db.get_prompt_template("s1")
|
|
assert tpl is not None
|
|
assert tpl["is_default"] is False
|
|
assert tpl["activation"] == "named"
|
|
|
|
# Setting is_default to True should set activation to default
|
|
db.update_prompt_template("s1", is_default=True)
|
|
tpl = db.get_prompt_template("s1")
|
|
assert tpl is not None
|
|
assert tpl["is_default"] is True
|
|
assert tpl["activation"] == "default"
|
|
|
|
def test_tags_json_roundtrip(self, db):
|
|
"""Tags stored as JSON string parse back correctly."""
|
|
tags = ["code", "review", "python"]
|
|
_create_template(db, "s1", "tagged", "content", tags=json.dumps(tags))
|
|
tpl = db.get_prompt_template("s1")
|
|
assert tpl is not None
|
|
parsed = json.loads(tpl["tags"])
|
|
assert parsed == tags
|
|
|
|
def test_token_estimate_stored(self, db):
|
|
"""token_estimate is persisted and returned."""
|
|
_create_template(db, "s1", "estimated", "content", token_estimate=500)
|
|
tpl = db.get_prompt_template("s1")
|
|
assert tpl is not None
|
|
assert tpl["token_estimate"] == 500
|
|
|
|
def test_get_skill_by_name(self, db):
|
|
"""get_skill_by_name works as alias for get_prompt_template_by_name."""
|
|
_create_template(db, "s1", "my-skill", "skill content")
|
|
tpl = db.get_skill_by_name("my-skill")
|
|
assert tpl is not None
|
|
assert tpl["template_id"] == "s1"
|
|
assert tpl["name"] == "my-skill"
|
|
|
|
def test_get_skill_by_name_nonexistent(self, db):
|
|
"""get_skill_by_name returns None for missing names."""
|
|
assert db.get_skill_by_name("nonexistent") is None
|
|
|
|
def test_new_fields_have_defaults(self, db):
|
|
"""Creating a template without new params uses sensible defaults."""
|
|
db.create_prompt_template(
|
|
template_id="s1",
|
|
name="old-style",
|
|
category="general",
|
|
content="Hello",
|
|
)
|
|
tpl = db.get_prompt_template("s1")
|
|
assert tpl is not None
|
|
assert tpl["description"] == ""
|
|
assert tpl["tags"] == "[]"
|
|
assert tpl["activation"] == "named"
|
|
assert tpl["author"] == ""
|
|
assert tpl["version"] == "1.0.0"
|
|
assert tpl["token_estimate"] == 0
|
|
assert tpl["source_url"] == ""
|
|
|
|
def test_list_skills_by_activation_empty(self, db):
|
|
"""Querying a nonexistent activation returns empty list."""
|
|
result = db.list_skills_by_activation("nonexistent")
|
|
assert result == []
|
|
|
|
def test_list_skills_by_activation_ordered_by_name(self, db):
|
|
"""Results are ordered by name ascending when priority is equal."""
|
|
_create_template(db, "s2", "beta-search", "B", activation="search")
|
|
_create_template(db, "s1", "alpha-search", "A", activation="search")
|
|
results = db.list_skills_by_activation("search")
|
|
assert len(results) == 2
|
|
assert results[0]["name"] == "alpha-search"
|
|
assert results[1]["name"] == "beta-search"
|
|
|
|
def test_list_skills_by_activation_ordered_by_priority(self, db):
|
|
"""Results are ordered by priority ascending, then name."""
|
|
_create_template(db, "s1", "style", "S", activation="default", priority=20)
|
|
_create_template(db, "s2", "safety", "F", activation="default", priority=10)
|
|
_create_template(db, "s3", "tone", "T", activation="default", priority=10)
|
|
results = db.list_skills_by_activation("default")
|
|
assert len(results) == 3
|
|
assert results[0]["name"] == "safety"
|
|
assert results[1]["name"] == "tone"
|
|
assert results[2]["name"] == "style"
|
|
|
|
def test_priority_default_is_zero(self, db):
|
|
"""Priority defaults to 0 when not specified."""
|
|
_create_template(db, "s1", "skill", "content")
|
|
tpl = db.get_prompt_template("s1")
|
|
assert tpl is not None
|
|
assert tpl["priority"] == 0
|
|
|
|
def test_priority_roundtrip(self, db):
|
|
"""Priority can be set on create and retrieved."""
|
|
_create_template(db, "s1", "skill", "content", priority=42)
|
|
tpl = db.get_prompt_template("s1")
|
|
assert tpl is not None
|
|
assert tpl["priority"] == 42
|
|
|
|
def test_priority_update(self, db):
|
|
"""Priority can be updated."""
|
|
_create_template(db, "s1", "skill", "content", priority=10)
|
|
db.update_prompt_template("s1", priority=99)
|
|
tpl = db.get_prompt_template("s1")
|
|
assert tpl is not None
|
|
assert tpl["priority"] == 99
|
|
|
|
def test_list_default_templates_ordered_by_priority(self, db):
|
|
"""list_default_templates() respects priority ordering."""
|
|
_create_template(db, "s1", "beta", "b", activation="default", priority=10)
|
|
_create_template(db, "s2", "alpha", "a", activation="default", priority=5)
|
|
_create_template(db, "s3", "gamma", "g", activation="default", priority=1)
|
|
|
|
results = db.list_default_templates()
|
|
assert len(results) == 3
|
|
assert results[0]["name"] == "gamma"
|
|
assert results[1]["name"] == "alpha"
|
|
assert results[2]["name"] == "beta"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1b. Skill resource storage tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillResources:
|
|
def test_create_and_list_resources(self, db):
|
|
_create_template(db, "s1", "my-skill", "content")
|
|
db.create_skill_resource("r1", "s1", "scripts/search.py", "import requests")
|
|
db.create_skill_resource("r2", "s1", "references/api.md", "# API Docs")
|
|
resources = db.list_skill_resources("s1")
|
|
assert len(resources) == 2
|
|
assert resources[0]["path"] == "references/api.md" # ordered by path
|
|
assert resources[1]["path"] == "scripts/search.py"
|
|
|
|
def test_get_resource_by_path(self, db):
|
|
_create_template(db, "s1", "my-skill", "content")
|
|
db.create_skill_resource("r1", "s1", "scripts/helper.py", "def main(): pass")
|
|
r = db.get_skill_resource("s1", "scripts/helper.py")
|
|
assert r is not None
|
|
assert r["content"] == "def main(): pass"
|
|
assert r["content_type"] == "text/plain"
|
|
|
|
def test_get_resource_not_found(self, db):
|
|
_create_template(db, "s1", "my-skill", "content")
|
|
assert db.get_skill_resource("s1", "nonexistent.py") is None
|
|
|
|
def test_delete_skill_resources(self, db):
|
|
_create_template(db, "s1", "my-skill", "content")
|
|
db.create_skill_resource("r1", "s1", "a.py", "code1")
|
|
db.create_skill_resource("r2", "s1", "b.py", "code2")
|
|
count = db.delete_skill_resources("s1")
|
|
assert count == 2
|
|
assert db.list_skill_resources("s1") == []
|
|
|
|
def test_resources_scoped_to_skill(self, db):
|
|
_create_template(db, "s1", "skill-a", "content a")
|
|
_create_template(db, "s2", "skill-b", "content b")
|
|
db.create_skill_resource("r1", "s1", "script.py", "code-a")
|
|
db.create_skill_resource("r2", "s2", "script.py", "code-b")
|
|
assert len(db.list_skill_resources("s1")) == 1
|
|
assert len(db.list_skill_resources("s2")) == 1
|
|
assert db.get_skill_resource("s1", "script.py")["content"] == "code-a"
|
|
|
|
def test_content_type_stored(self, db):
|
|
_create_template(db, "s1", "my-skill", "content")
|
|
db.create_skill_resource("r1", "s1", "template.json", "{}", content_type="application/json")
|
|
r = db.get_skill_resource("s1", "template.json")
|
|
assert r["content_type"] == "application/json"
|
|
|
|
def test_list_empty(self, db):
|
|
assert db.list_skill_resources("nonexistent") == []
|
|
|
|
def test_delete_empty(self, db):
|
|
assert db.delete_skill_resources("nonexistent") == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Session runtime tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillSessionRuntime:
|
|
def test_skill_param_alone(self, tmp_db):
|
|
"""skill= works on its own."""
|
|
from turnstone.core.storage import get_storage
|
|
|
|
db = get_storage()
|
|
_create_template(db, "s1", "my-skill", "SKILL_ONLY_CONTENT")
|
|
|
|
session = _make_session(skill="my-skill")
|
|
content = _sys_content(session)
|
|
assert "SKILL_ONLY_CONTENT" in content
|
|
assert session._skill_name == "my-skill"
|
|
|
|
def test_set_skill_method(self, tmp_db):
|
|
"""session.set_skill() activates the skill."""
|
|
from turnstone.core.storage import get_storage
|
|
|
|
db = get_storage()
|
|
_create_template(db, "s1", "dynamic-skill", "DYNAMIC_SKILL_CONTENT")
|
|
|
|
session = _make_session()
|
|
content_before = _sys_content(session)
|
|
assert "DYNAMIC_SKILL_CONTENT" not in content_before
|
|
|
|
session.set_skill("dynamic-skill")
|
|
assert session._skill_name == "dynamic-skill"
|
|
content_after = _sys_content(session)
|
|
assert "DYNAMIC_SKILL_CONTENT" in content_after
|
|
|
|
def test_skill_slash_command(self, tmp_db):
|
|
"""The /skill command sets the active skill."""
|
|
from turnstone.core.storage import get_storage
|
|
|
|
db = get_storage()
|
|
_create_template(db, "s1", "slash-skill", "SLASH_SKILL_CONTENT")
|
|
|
|
ui = NullUI()
|
|
ui.on_info = MagicMock()
|
|
session = _make_session(ui=ui)
|
|
session.handle_command("/skill slash-skill")
|
|
assert session._skill_name == "slash-skill"
|
|
content = _sys_content(session)
|
|
assert "SLASH_SKILL_CONTENT" in content
|
|
ui.on_info.assert_called_once()
|
|
assert "slash-skill" in ui.on_info.call_args[0][0]
|
|
|
|
def test_skill_slash_command_clear(self, tmp_db):
|
|
"""The /skill clear command clears the active skill."""
|
|
from turnstone.core.storage import get_storage
|
|
|
|
db = get_storage()
|
|
_create_template(db, "s1", "clearable", "CLEARABLE_CONTENT")
|
|
_create_template(db, "s2", "default-one", "DEFAULT_CONTENT", is_default=True)
|
|
|
|
ui = NullUI()
|
|
ui.on_info = MagicMock()
|
|
session = _make_session(ui=ui, skill="clearable")
|
|
assert "CLEARABLE_CONTENT" in _sys_content(session)
|
|
|
|
session.handle_command("/skill clear")
|
|
assert session._skill_name is None
|
|
assert "DEFAULT_CONTENT" in _sys_content(session)
|
|
assert "CLEARABLE_CONTENT" not in _sys_content(session)
|
|
|
|
def test_skill_slash_command_show(self, tmp_db):
|
|
"""The /skill command with no arg shows current skill."""
|
|
from turnstone.core.storage import get_storage
|
|
|
|
db = get_storage()
|
|
_create_template(db, "s1", "current-skill", "content")
|
|
|
|
ui = NullUI()
|
|
ui.on_info = MagicMock()
|
|
session = _make_session(ui=ui, skill="current-skill")
|
|
session.handle_command("/skill")
|
|
ui.on_info.assert_called_once()
|
|
assert "current-skill" in ui.on_info.call_args[0][0]
|
|
|
|
def test_skill_slash_command_not_found(self, tmp_db):
|
|
"""The /skill command with unknown name shows error."""
|
|
ui = NullUI()
|
|
ui.on_error = MagicMock()
|
|
session = _make_session(ui=ui)
|
|
session.handle_command("/skill nonexistent")
|
|
ui.on_error.assert_called_once()
|
|
assert "not found" in ui.on_error.call_args[0][0].lower()
|
|
|
|
def test_skill_saved_in_config(self, tmp_db):
|
|
"""_save_config() includes both 'skill' and 'template' keys."""
|
|
from turnstone.core.memory import load_workstream_config
|
|
from turnstone.core.storage import get_storage
|
|
|
|
db = get_storage()
|
|
_create_template(db, "s1", "persist-skill", "PERSIST_CONTENT")
|
|
|
|
session = _make_session(skill="persist-skill")
|
|
config = load_workstream_config(session.ws_id)
|
|
assert config["skill"] == "persist-skill"
|
|
|
|
def test_skill_resumed_from_config(self, tmp_db):
|
|
"""Resume reads 'skill' key with precedence over 'template'."""
|
|
from turnstone.core.memory import save_message
|
|
from turnstone.core.storage import get_storage
|
|
|
|
db = get_storage()
|
|
_create_template(db, "s1", "resume-skill", "RESUMED_SKILL_CONTENT")
|
|
|
|
session1 = _make_session(skill="resume-skill")
|
|
ws_id = session1.ws_id
|
|
save_message(ws_id, "user", "hello")
|
|
|
|
session2 = _make_session()
|
|
assert session2._skill_name is None
|
|
resumed = session2.resume(ws_id)
|
|
assert resumed
|
|
assert session2._skill_name == "resume-skill"
|
|
content = _sys_content(session2)
|
|
assert "RESUMED_SKILL_CONTENT" in content
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Skill search tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillSearch:
|
|
def test_search_returns_relevant_results(self):
|
|
"""SkillSearchManager finds skills matching the query."""
|
|
skills = [
|
|
{
|
|
"name": "code-review",
|
|
"description": "Review code quality",
|
|
"tags": '["code"]',
|
|
"content": "You review code.",
|
|
"category": "eng",
|
|
},
|
|
{
|
|
"name": "summarize",
|
|
"description": "Summarize documents",
|
|
"tags": '["docs"]',
|
|
"content": "You summarize.",
|
|
"category": "writing",
|
|
},
|
|
{
|
|
"name": "debug-helper",
|
|
"description": "Help debug issues",
|
|
"tags": '["code", "debug"]',
|
|
"content": "You help debug.",
|
|
"category": "eng",
|
|
},
|
|
]
|
|
mgr = SkillSearchManager(skills)
|
|
results = mgr.search("code review")
|
|
assert len(results) > 0
|
|
names = [r["name"] for r in results]
|
|
assert "code-review" in names
|
|
|
|
def test_search_empty_query_returns_empty(self):
|
|
"""Searching with empty string returns no results."""
|
|
skills = [
|
|
{
|
|
"name": "skill-one",
|
|
"description": "A skill",
|
|
"tags": "[]",
|
|
"content": "content",
|
|
"category": "general",
|
|
},
|
|
]
|
|
mgr = SkillSearchManager(skills)
|
|
results = mgr.search("")
|
|
assert results == []
|
|
|
|
def test_search_no_skills_returns_empty(self):
|
|
"""SkillSearchManager with no skills returns empty on any query."""
|
|
mgr = SkillSearchManager([])
|
|
results = mgr.search("anything")
|
|
assert results == []
|
|
|
|
def test_tags_boost_relevance(self):
|
|
"""Skills with matching tags should rank higher."""
|
|
skills = [
|
|
{
|
|
"name": "generic-tool",
|
|
"description": "A generic tool for various tasks",
|
|
"tags": "[]",
|
|
"content": "Does many things.",
|
|
"category": "general",
|
|
},
|
|
{
|
|
"name": "python-linter",
|
|
"description": "Lint code",
|
|
"tags": '["python", "lint"]',
|
|
"content": "You lint code.",
|
|
"category": "eng",
|
|
},
|
|
]
|
|
mgr = SkillSearchManager(skills)
|
|
results = mgr.search("python lint")
|
|
assert len(results) > 0
|
|
# The python-linter should appear first because tags match
|
|
assert results[0]["name"] == "python-linter"
|
|
|
|
def test_count_property(self):
|
|
"""The count property returns the number of indexed skills."""
|
|
skills = [
|
|
{"name": "a", "description": "", "tags": "[]", "content": "x", "category": ""},
|
|
{"name": "b", "description": "", "tags": "[]", "content": "y", "category": ""},
|
|
{"name": "c", "description": "", "tags": "[]", "content": "z", "category": ""},
|
|
]
|
|
mgr = SkillSearchManager(skills)
|
|
assert mgr.count == 3
|
|
|
|
def test_count_empty(self):
|
|
"""Empty manager has count 0."""
|
|
mgr = SkillSearchManager([])
|
|
assert mgr.count == 0
|
|
|
|
def test_search_uses_content_prefix(self):
|
|
"""Search indexes first 500 chars of content for matching."""
|
|
skills = [
|
|
{
|
|
"name": "obscure-name",
|
|
"description": "generic",
|
|
"tags": "[]",
|
|
"content": "kubernetes cluster management and orchestration",
|
|
"category": "devops",
|
|
},
|
|
]
|
|
mgr = SkillSearchManager(skills)
|
|
results = mgr.search("kubernetes")
|
|
assert len(results) == 1
|
|
assert results[0]["name"] == "obscure-name"
|
|
|
|
def test_search_limit(self):
|
|
"""Search respects the limit parameter."""
|
|
skills = [
|
|
{
|
|
"name": f"skill-{i}",
|
|
"description": "common common common",
|
|
"tags": "[]",
|
|
"content": "common content",
|
|
"category": "",
|
|
}
|
|
for i in range(10)
|
|
]
|
|
mgr = SkillSearchManager(skills)
|
|
results = mgr.search("common", limit=3)
|
|
assert len(results) <= 3
|
|
|
|
def test_tags_as_list(self):
|
|
"""Tags can be passed as actual list (not just JSON string)."""
|
|
skills = [
|
|
{
|
|
"name": "list-tags",
|
|
"description": "has list tags",
|
|
"tags": ["python", "code"],
|
|
"content": "content",
|
|
"category": "",
|
|
},
|
|
]
|
|
mgr = SkillSearchManager(skills)
|
|
results = mgr.search("python")
|
|
assert len(results) == 1
|
|
assert results[0]["name"] == "list-tags"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. API tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
|
"""Inject an admin auth result with admin.skills permission."""
|
|
|
|
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
request.state.auth_result = AuthResult(
|
|
user_id="test-user",
|
|
scopes=frozenset({"approve"}),
|
|
token_source="config",
|
|
permissions=frozenset({"read", "write", "approve", "admin.skills"}),
|
|
)
|
|
return await call_next(request)
|
|
|
|
|
|
@pytest.fixture()
|
|
def api_storage(tmp_path):
|
|
return SQLiteBackend(str(tmp_path / "api_test.db"))
|
|
|
|
|
|
@pytest.fixture()
|
|
def api_client(api_storage):
|
|
from turnstone.console.server import (
|
|
admin_create_skill,
|
|
admin_delete_skill,
|
|
admin_list_skills,
|
|
admin_unlock_skill,
|
|
admin_update_skill,
|
|
)
|
|
|
|
routes = [
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route("/api/admin/skills", admin_list_skills),
|
|
Route("/api/admin/skills", admin_create_skill, methods=["POST"]),
|
|
Route("/api/admin/skills/{skill_id}", admin_update_skill, methods=["PUT"]),
|
|
Route("/api/admin/skills/{skill_id}", admin_delete_skill, methods=["DELETE"]),
|
|
Route(
|
|
"/api/admin/skills/{skill_id}/unlock",
|
|
admin_unlock_skill,
|
|
methods=["POST"],
|
|
),
|
|
],
|
|
),
|
|
]
|
|
app = Starlette(
|
|
routes=routes,
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.auth_storage = api_storage
|
|
return TestClient(app)
|
|
|
|
|
|
class TestSkillAPI:
|
|
def test_list_skills_endpoint(self, api_client, api_storage):
|
|
"""GET /v1/api/admin/skills returns correct schema."""
|
|
_create_template(
|
|
api_storage,
|
|
"s1",
|
|
"skill-a",
|
|
"content A",
|
|
description="Desc A",
|
|
tags='["tag1"]',
|
|
activation="search",
|
|
)
|
|
_create_template(
|
|
api_storage, "s2", "skill-b", "content B", description="Desc B", activation="named"
|
|
)
|
|
|
|
resp = api_client.get("/v1/api/admin/skills")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "skills" in data
|
|
skills = data["skills"]
|
|
assert len(skills) == 2
|
|
# Verify schema fields are present
|
|
for skill in skills:
|
|
assert "template_id" in skill
|
|
assert "name" in skill
|
|
assert "description" in skill
|
|
assert "tags" in skill
|
|
assert "activation" in skill
|
|
assert "token_estimate" in skill
|
|
assert "author" in skill
|
|
assert "version" in skill
|
|
assert "origin" in skill
|
|
assert "readonly" in skill
|
|
# Check tags are parsed as list
|
|
s_a = next(s for s in skills if s["name"] == "skill-a")
|
|
assert s_a["tags"] == ["tag1"]
|
|
assert s_a["activation"] == "search"
|
|
|
|
def test_create_skill_endpoint(self, api_client, api_storage):
|
|
"""POST /v1/api/admin/skills creates with new fields."""
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "new-skill",
|
|
"content": "You are a helpful skill.",
|
|
"category": "custom",
|
|
"description": "A new custom skill",
|
|
"tags": ["test", "custom"],
|
|
"activation": "search",
|
|
"author": "bob",
|
|
"version": "1.2.0",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["name"] == "new-skill"
|
|
assert data["description"] == "A new custom skill"
|
|
assert data["activation"] == "search"
|
|
assert data["author"] == "bob"
|
|
assert data["version"] == "1.2.0"
|
|
|
|
def test_create_skill_computes_token_estimate(self, api_client, api_storage):
|
|
"""Token estimate is computed from content length on creation."""
|
|
content = "x" * 400 # 400 chars -> 100 tokens (400 // 4)
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "estimated-skill",
|
|
"content": content,
|
|
"description": "estimation test",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["token_estimate"] == 100
|
|
|
|
def test_create_skill_requires_name(self, api_client):
|
|
"""Creating without name returns 400."""
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"content": "some content", "description": "desc"},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "name" in resp.json()["error"].lower()
|
|
|
|
def test_create_skill_requires_content(self, api_client):
|
|
"""Creating without content returns 400."""
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"name": "no-content", "description": "desc"},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "content" in resp.json()["error"].lower()
|
|
|
|
def test_create_skill_requires_description(self, api_client):
|
|
"""Creating without description returns 400 — empty descriptions
|
|
break discoverability in list_skills."""
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"name": "no-desc", "content": "some content"},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "description" in resp.json()["error"].lower()
|
|
|
|
def test_create_skill_rejects_blank_description(self, api_client):
|
|
"""An all-whitespace description is treated the same as empty."""
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "blank-desc",
|
|
"content": "some content",
|
|
"description": " \t ",
|
|
},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "description" in resp.json()["error"].lower()
|
|
|
|
def test_update_skill_rejects_blanking_description(self, api_client, api_storage):
|
|
"""An update cannot blank out the description — operators must
|
|
supply a non-empty replacement or omit the field."""
|
|
_create_template(api_storage, "s1", "keep-desc", "content", description="existing desc")
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={"description": " "},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "description" in resp.json()["error"].lower()
|
|
|
|
def test_create_skill_default_kind_is_any(self, api_client):
|
|
"""Skills without an explicit ``kind`` default to ``any`` so
|
|
pre-upgrade catalogs keep showing up on both interactive and
|
|
coordinator sides."""
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "kind-default",
|
|
"content": "content",
|
|
"description": "no explicit kind",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["kind"] == "any"
|
|
|
|
def test_create_skill_accepts_explicit_kind(self, api_client):
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "kind-coord",
|
|
"content": "content",
|
|
"description": "coord only",
|
|
"kind": "coordinator",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["kind"] == "coordinator"
|
|
|
|
def test_create_skill_rejects_invalid_kind(self, api_client):
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "kind-bad",
|
|
"content": "content",
|
|
"description": "bad kind",
|
|
"kind": "nonsense",
|
|
},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "kind" in resp.json()["error"].lower()
|
|
|
|
def test_update_skill_kind_round_trip(self, api_client, api_storage):
|
|
_create_template(api_storage, "s1", "kind-upd", "content", description="initial")
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={"kind": "interactive"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["kind"] == "interactive"
|
|
|
|
def test_update_skill_rejects_invalid_kind(self, api_client, api_storage):
|
|
_create_template(api_storage, "s1", "kind-upd-bad", "content", description="initial")
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={"kind": "bogus"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_update_skill_endpoint(self, api_client, api_storage):
|
|
"""PUT /v1/api/admin/skills/{id} updates new fields."""
|
|
_create_template(api_storage, "s1", "update-me", "old content", description="old desc")
|
|
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={
|
|
"description": "updated desc",
|
|
"tags": ["updated"],
|
|
"activation": "default",
|
|
"author": "charlie",
|
|
"version": "2.0.0",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["description"] == "updated desc"
|
|
assert data["activation"] == "default"
|
|
assert data["is_default"] is True
|
|
assert data["author"] == "charlie"
|
|
assert data["version"] == "2.0.0"
|
|
|
|
def test_update_skill_not_found(self, api_client):
|
|
"""Updating nonexistent skill returns 404."""
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/nonexistent",
|
|
json={"description": "new"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
def test_update_skill_readonly_spec_fields_rejected(self, api_client, api_storage):
|
|
"""Updating spec fields on a readonly skill returns 400 (filtered to nothing)."""
|
|
_create_template(
|
|
api_storage,
|
|
"s1",
|
|
"mcp-skill",
|
|
"mcp content",
|
|
origin="mcp",
|
|
mcp_server="srv",
|
|
readonly=True,
|
|
)
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={"description": "hacked", "content": "evil"},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "runtime config" in resp.json()["error"].lower()
|
|
|
|
def test_update_skill_readonly_runtime_config_allowed(self, api_client, api_storage):
|
|
"""Runtime config fields can be updated on a readonly (installed) skill."""
|
|
_create_template(
|
|
api_storage,
|
|
"s1",
|
|
"installed-skill",
|
|
"external content",
|
|
origin="source",
|
|
readonly=True,
|
|
)
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={"model": "gpt-5", "temperature": 0.5, "enabled": False},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["model"] == "gpt-5"
|
|
assert data["temperature"] == 0.5
|
|
assert data["enabled"] is False
|
|
# Spec fields must remain unchanged
|
|
assert data["content"] == "external content"
|
|
|
|
def test_update_skill_readonly_hidden_from_menu_allowed(self, api_client, api_storage):
|
|
"""``hidden_from_menu`` is in SKILL_RUNTIME_CONFIG_FIELDS so an admin
|
|
can hide/unhide an installed (readonly) skill from the user picker
|
|
without unlocking the row. Pins the load-bearing invariant the
|
|
``skill_field_validation`` comment depends on — a future refactor
|
|
that drops the field from runtime-config would silently break
|
|
admin's ability to toggle this on installed skills."""
|
|
_create_template(
|
|
api_storage,
|
|
"s1",
|
|
"installed-skill",
|
|
"external content",
|
|
origin="source",
|
|
readonly=True,
|
|
)
|
|
# Default after install: visible. Verified via direct storage
|
|
# read rather than a GET — the api_client fixture doesn't wire
|
|
# the GET-by-id route.
|
|
assert api_storage.get_prompt_template("s1")["hidden_from_menu"] is False
|
|
|
|
# Hide.
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={"hidden_from_menu": True},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["hidden_from_menu"] is True
|
|
# Spec/content fields untouched.
|
|
assert resp.json()["content"] == "external content"
|
|
|
|
# Unhide round-trips back.
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={"hidden_from_menu": False},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["hidden_from_menu"] is False
|
|
|
|
def test_create_skill_hidden_from_menu_string_rejected(self, api_client):
|
|
"""``hidden_from_menu`` is bool-typed at the API boundary —
|
|
a malformed client sending the string ``"false"`` (which Python
|
|
truthiness would silently coerce to ``True``, flipping the flag
|
|
opposite to intent) must be rejected with a 400. Copilot review
|
|
on PR #577 caught the loose ``bool()`` cast."""
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "loose-bool",
|
|
"content": "...",
|
|
"description": "x",
|
|
"hidden_from_menu": "false",
|
|
},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "boolean" in resp.json()["error"].lower()
|
|
|
|
def test_create_skill_hidden_from_menu_int_zero_and_one_accepted(self, api_client, api_storage):
|
|
"""Strict-bool parse accepts canonical JSON ``true``/``false``
|
|
AND integer ``0``/``1`` — the latter for clients that serialise
|
|
Postgres-style. Other integers fall to 400."""
|
|
# int 1 → True
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "intbool-true",
|
|
"content": "...",
|
|
"description": "x",
|
|
"hidden_from_menu": 1,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["hidden_from_menu"] is True
|
|
|
|
# int 0 → False (defaults check).
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "intbool-false",
|
|
"content": "...",
|
|
"description": "x",
|
|
"hidden_from_menu": 0,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["hidden_from_menu"] is False
|
|
|
|
# int 2 → 400.
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "intbool-ambiguous",
|
|
"content": "...",
|
|
"description": "x",
|
|
"hidden_from_menu": 2,
|
|
},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_update_skill_readonly_mixed_body_filters_spec(self, api_client, api_storage):
|
|
"""When JS sends all fields for a readonly skill, spec fields are silently dropped."""
|
|
_create_template(
|
|
api_storage,
|
|
"s1",
|
|
"installed",
|
|
"original content",
|
|
origin="source",
|
|
readonly=True,
|
|
)
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
# Simulate what the browser form submits: every field present
|
|
json={
|
|
"name": "hacked",
|
|
"content": "evil content",
|
|
"description": "tampered",
|
|
"model": "gpt-5",
|
|
"enabled": False,
|
|
"token_budget": 50000,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
# Config fields updated
|
|
assert data["model"] == "gpt-5"
|
|
assert data["enabled"] is False
|
|
assert data["token_budget"] == 50000
|
|
# Spec fields unchanged
|
|
assert data["name"] == "installed"
|
|
assert data["content"] == "original content"
|
|
assert data["description"] == ""
|
|
|
|
def test_update_skill_recomputes_token_estimate(self, api_client, api_storage):
|
|
"""Updating content recomputes token_estimate."""
|
|
_create_template(api_storage, "s1", "recompute", "short", token_estimate=1)
|
|
new_content = "y" * 800 # 800 // 4 = 200
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={"content": new_content},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["token_estimate"] == 200
|
|
|
|
def test_update_skill_notify_on_complete_empty_string_normalises_to_array(
|
|
self, api_client, api_storage
|
|
):
|
|
"""Empty / whitespace ``notify_on_complete`` is normalised to ``"[]"``.
|
|
|
|
Defends against a regression where ``if nc and nc != "[]":`` would
|
|
short-circuit on a blank value, skip JSON validation, and persist a
|
|
non-JSON empty string into storage.
|
|
"""
|
|
_create_template(api_storage, "s1", "norm-empty", "x")
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={"notify_on_complete": ""},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert api_storage.get_prompt_template("s1")["notify_on_complete"] == "[]"
|
|
|
|
def test_update_skill_notify_on_complete_legacy_object_normalises(
|
|
self, api_client, api_storage
|
|
):
|
|
"""The legacy ``"{}"`` sentinel coerces to ``"[]"`` on write."""
|
|
_create_template(api_storage, "s1", "norm-obj", "x")
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={"notify_on_complete": "{}"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert api_storage.get_prompt_template("s1")["notify_on_complete"] == "[]"
|
|
|
|
def test_update_skill_notify_on_complete_rejects_non_array_json(self, api_client, api_storage):
|
|
"""Valid JSON that isn't an array is a 400 — was previously accepted."""
|
|
_create_template(api_storage, "s1", "reject-obj", "x")
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={"notify_on_complete": '{"channel": "discord"}'},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "array" in resp.json()["error"].lower()
|
|
|
|
def test_delete_skill_endpoint(self, api_client, api_storage):
|
|
"""DELETE /v1/api/admin/skills/{id} deletes the skill."""
|
|
_create_template(api_storage, "s1", "deletable", "content")
|
|
|
|
resp = api_client.delete("/v1/api/admin/skills/s1")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "ok"
|
|
|
|
# Verify deletion
|
|
assert api_storage.get_prompt_template("s1") is None
|
|
|
|
def test_delete_skill_not_found(self, api_client):
|
|
"""Deleting nonexistent skill returns 404."""
|
|
resp = api_client.delete("/v1/api/admin/skills/missing")
|
|
assert resp.status_code == 404
|
|
|
|
def test_delete_skill_readonly_allowed(self, api_client, api_storage):
|
|
"""Deleting a readonly (installed) skill is allowed — uninstall."""
|
|
_create_template(
|
|
api_storage,
|
|
"s1",
|
|
"mcp-readonly",
|
|
"content",
|
|
origin="mcp",
|
|
mcp_server="srv",
|
|
readonly=True,
|
|
)
|
|
resp = api_client.delete("/v1/api/admin/skills/s1")
|
|
assert resp.status_code == 200
|
|
|
|
def test_unlock_skill_flips_readonly(self, api_client, api_storage):
|
|
"""POST /unlock on a readonly skill flips readonly to False."""
|
|
_create_template(
|
|
api_storage,
|
|
"s1",
|
|
"installed",
|
|
"upstream content",
|
|
origin="source",
|
|
source_url="https://skills.sh/skills/owner/repo/leaf",
|
|
readonly=True,
|
|
)
|
|
|
|
resp = api_client.post("/v1/api/admin/skills/s1/unlock")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["readonly"] is False
|
|
# Origin preserved so the UI can still surface "Customized from upstream".
|
|
assert data["origin"] == "source"
|
|
assert data["source_url"] == "https://skills.sh/skills/owner/repo/leaf"
|
|
|
|
def test_unlock_skill_persists(self, api_client, api_storage):
|
|
"""Unlock side-effects survive a fresh load from storage."""
|
|
_create_template(api_storage, "s1", "installed", "x", origin="source", readonly=True)
|
|
|
|
api_client.post("/v1/api/admin/skills/s1/unlock")
|
|
|
|
row = api_storage.get_prompt_template("s1")
|
|
assert row is not None
|
|
assert bool(row["readonly"]) is False
|
|
|
|
def test_unlock_skill_snapshots_pre_unlock_state(self, api_client, api_storage):
|
|
"""Pre-unlock skill state is snapshotted to skill_versions for rollback."""
|
|
_create_template(
|
|
api_storage, "s1", "installed", "upstream content", origin="source", readonly=True
|
|
)
|
|
assert api_storage.list_skill_versions("s1") == []
|
|
|
|
api_client.post("/v1/api/admin/skills/s1/unlock")
|
|
|
|
versions = api_storage.list_skill_versions("s1")
|
|
assert len(versions) == 1
|
|
assert versions[0]["version"] == 1
|
|
|
|
def test_unlock_skill_versions_after_existing_history(self, api_client, api_storage):
|
|
"""Unlock picks the next version number based on max(version), not list length.
|
|
|
|
Defends against regressions where len(list)+1 racing with a concurrent
|
|
update could produce a (skill_id, version) collision. Seeds an
|
|
out-of-order version (3) and verifies unlock chooses 4, not 2.
|
|
"""
|
|
import json as _json
|
|
|
|
_create_template(
|
|
api_storage, "s1", "installed", "v0 content", origin="source", readonly=True
|
|
)
|
|
api_storage.create_skill_version(
|
|
skill_id="s1", version=3, snapshot=_json.dumps({"v": 3}), changed_by="prior"
|
|
)
|
|
|
|
resp = api_client.post("/v1/api/admin/skills/s1/unlock")
|
|
assert resp.status_code == 200
|
|
|
|
versions = sorted(api_storage.list_skill_versions("s1"), key=lambda v: v["version"])
|
|
assert [v["version"] for v in versions] == [3, 4]
|
|
|
|
def test_unlock_skill_already_unlocked(self, api_client, api_storage):
|
|
"""Unlocking an already-unlocked skill returns 400."""
|
|
_create_template(api_storage, "s1", "local", "content", origin="manual", readonly=False)
|
|
|
|
resp = api_client.post("/v1/api/admin/skills/s1/unlock")
|
|
|
|
assert resp.status_code == 400
|
|
assert "already unlocked" in resp.json()["error"].lower()
|
|
|
|
def test_unlock_skill_not_found(self, api_client):
|
|
"""Unlocking a nonexistent skill returns 404."""
|
|
resp = api_client.post("/v1/api/admin/skills/missing/unlock")
|
|
assert resp.status_code == 404
|
|
|
|
def test_unlock_then_edit_spec_fields_succeeds(self, api_client, api_storage):
|
|
"""After unlock, PUT can edit spec fields the readonly gate previously rejected."""
|
|
_create_template(
|
|
api_storage,
|
|
"s1",
|
|
"installed",
|
|
"upstream content",
|
|
description="upstream description",
|
|
origin="source",
|
|
readonly=True,
|
|
)
|
|
|
|
api_client.post("/v1/api/admin/skills/s1/unlock")
|
|
|
|
resp = api_client.put(
|
|
"/v1/api/admin/skills/s1",
|
|
json={
|
|
"name": "customized",
|
|
"content": "tuned content",
|
|
"description": "tuned description",
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["name"] == "customized"
|
|
assert data["content"] == "tuned content"
|
|
assert data["description"] == "tuned description"
|
|
# Origin still records that this came from upstream.
|
|
assert data["origin"] == "source"
|
|
|
|
def test_skill_field_on_workstream_create(self, api_storage):
|
|
"""Console workstream creation accepts 'skill' field in request body."""
|
|
# This test verifies the server code that reads body.get("skill", "")
|
|
# by importing and checking the function exists with proper handling
|
|
from turnstone.console.server import create_workstream
|
|
|
|
# Verify the handler function is importable and callable
|
|
assert callable(create_workstream)
|
|
|
|
def test_create_skill_activation_default_sync(self, api_client, api_storage):
|
|
"""Creating with activation=default auto-sets is_default."""
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "auto-default",
|
|
"content": "auto default content",
|
|
"description": "activation default",
|
|
"activation": "default",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["activation"] == "default"
|
|
assert data["is_default"] is True
|
|
|
|
def test_create_skill_is_default_derives_activation(self, api_client, api_storage):
|
|
"""Creating with is_default=true and no activation sets activation=default."""
|
|
resp = api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "default-derived",
|
|
"content": "derived content",
|
|
"description": "is_default derived",
|
|
"is_default": True,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["is_default"] is True
|
|
assert data["activation"] == "default"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. MCP sync tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMCPSyncSkillFields:
|
|
def test_mcp_sync_sets_activation_named(self):
|
|
"""Synced MCP prompts get activation='named'."""
|
|
from turnstone.core.mcp_client import MCPClientManager
|
|
|
|
mgr = MCPClientManager({})
|
|
storage = MagicMock()
|
|
storage.get_prompt_template_by_name.return_value = None
|
|
storage.list_prompt_templates_by_origin.return_value = []
|
|
storage.create_prompt_template.return_value = None
|
|
mgr.set_storage(storage)
|
|
|
|
mgr._prompts = [
|
|
{
|
|
"name": "mcp__test__greeting",
|
|
"original_name": "greeting",
|
|
"server": "test",
|
|
"description": "Say hello",
|
|
"arguments": [],
|
|
},
|
|
]
|
|
|
|
mgr.sync_prompts_to_storage()
|
|
|
|
storage.create_prompt_template.assert_called_once()
|
|
call_kwargs = storage.create_prompt_template.call_args[1]
|
|
assert call_kwargs["activation"] == "named"
|
|
|
|
def test_mcp_sync_sets_token_estimate(self):
|
|
"""Token estimate is computed from content on sync."""
|
|
from turnstone.core.mcp_client import MCPClientManager
|
|
|
|
mgr = MCPClientManager({})
|
|
storage = MagicMock()
|
|
storage.get_prompt_template_by_name.return_value = None
|
|
storage.list_prompt_templates_by_origin.return_value = []
|
|
storage.create_prompt_template.return_value = None
|
|
mgr.set_storage(storage)
|
|
|
|
mgr._prompts = [
|
|
{
|
|
"name": "mcp__srv__prompt",
|
|
"original_name": "prompt",
|
|
"server": "srv",
|
|
"description": "A prompt with known description",
|
|
"arguments": [
|
|
{"name": "arg1", "description": "First argument", "required": True},
|
|
],
|
|
},
|
|
]
|
|
|
|
mgr.sync_prompts_to_storage()
|
|
|
|
call_kwargs = storage.create_prompt_template.call_args[1]
|
|
# token_estimate should be len(content) // 4 where content is the generated description
|
|
assert "token_estimate" in call_kwargs
|
|
assert isinstance(call_kwargs["token_estimate"], int)
|
|
assert call_kwargs["token_estimate"] > 0
|
|
|
|
def test_mcp_sync_update_sets_token_estimate(self):
|
|
"""Token estimate is recomputed on MCP sync update."""
|
|
from turnstone.core.mcp_client import MCPClientManager
|
|
|
|
mgr = MCPClientManager({})
|
|
storage = MagicMock()
|
|
storage.get_prompt_template_by_name.return_value = {
|
|
"template_id": "existing-id",
|
|
"name": "mcp__srv__prompt",
|
|
"origin": "mcp",
|
|
"mcp_server": "srv",
|
|
"readonly": True,
|
|
}
|
|
storage.list_prompt_templates_by_origin.return_value = []
|
|
storage.update_prompt_template.return_value = True
|
|
mgr.set_storage(storage)
|
|
|
|
mgr._prompts = [
|
|
{
|
|
"name": "mcp__srv__prompt",
|
|
"original_name": "prompt",
|
|
"server": "srv",
|
|
"description": "Updated description",
|
|
"arguments": [],
|
|
},
|
|
]
|
|
|
|
mgr.sync_prompts_to_storage()
|
|
|
|
call_kwargs = storage.update_prompt_template.call_args[1]
|
|
assert "token_estimate" in call_kwargs
|
|
assert isinstance(call_kwargs["token_estimate"], int)
|
|
|
|
def test_mcp_sync_does_not_set_default_activation(self):
|
|
"""MCP sync never creates with activation='default' (security)."""
|
|
from turnstone.core.mcp_client import MCPClientManager
|
|
|
|
mgr = MCPClientManager({})
|
|
storage = MagicMock()
|
|
storage.get_prompt_template_by_name.return_value = None
|
|
storage.list_prompt_templates_by_origin.return_value = []
|
|
storage.create_prompt_template.return_value = None
|
|
mgr.set_storage(storage)
|
|
|
|
mgr._prompts = [
|
|
{
|
|
"name": "mcp__srv__prompt",
|
|
"original_name": "prompt",
|
|
"server": "srv",
|
|
"description": "Any prompt",
|
|
"arguments": [],
|
|
},
|
|
]
|
|
|
|
mgr.sync_prompts_to_storage()
|
|
|
|
call_kwargs = storage.create_prompt_template.call_args[1]
|
|
# MCP prompts should never be auto-applied as defaults
|
|
assert call_kwargs["activation"] != "default"
|
|
assert call_kwargs["is_default"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Session config application tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillSessionConfigApplication:
|
|
"""Test that session config fields stored on skills round-trip correctly."""
|
|
|
|
def test_skill_with_model_override(self, db):
|
|
"""Skill with model= stores and returns the value."""
|
|
_create_template(db, "s1", "model-skill", "content")
|
|
db.update_prompt_template("s1", model="test-model")
|
|
tpl = db.get_skill_by_name("model-skill")
|
|
assert tpl is not None
|
|
assert tpl["model"] == "test-model"
|
|
|
|
def test_skill_with_temperature(self, db):
|
|
"""Skill with temperature= stores and returns the value."""
|
|
db.create_prompt_template(
|
|
template_id="s1",
|
|
name="temp-skill",
|
|
category="general",
|
|
content="content",
|
|
temperature=0.3,
|
|
)
|
|
tpl = db.get_skill_by_name("temp-skill")
|
|
assert tpl is not None
|
|
assert tpl["temperature"] == 0.3
|
|
|
|
def test_skill_with_token_budget(self, db):
|
|
"""Skill with token_budget= stores and returns the value."""
|
|
db.create_prompt_template(
|
|
template_id="s1",
|
|
name="budget-skill",
|
|
category="general",
|
|
content="content",
|
|
token_budget=5000,
|
|
)
|
|
tpl = db.get_skill_by_name("budget-skill")
|
|
assert tpl is not None
|
|
assert tpl["token_budget"] == 5000
|
|
|
|
def test_skill_with_auto_approve(self, db):
|
|
"""Skill with auto_approve=True stores as True."""
|
|
db.create_prompt_template(
|
|
template_id="s1",
|
|
name="approve-skill",
|
|
category="general",
|
|
content="content",
|
|
auto_approve=True,
|
|
)
|
|
tpl = db.get_skill_by_name("approve-skill")
|
|
assert tpl is not None
|
|
assert tpl["auto_approve"] is True
|
|
|
|
def test_skill_with_allowed_tools(self, db):
|
|
"""Skill with allowed_tools stores and parses correctly."""
|
|
allowed = '["bash","read_file"]'
|
|
db.create_prompt_template(
|
|
template_id="s1",
|
|
name="tools-skill",
|
|
category="general",
|
|
content="content",
|
|
allowed_tools=allowed,
|
|
)
|
|
tpl = db.get_skill_by_name("tools-skill")
|
|
assert tpl is not None
|
|
assert tpl["allowed_tools"] == allowed
|
|
parsed = json.loads(tpl["allowed_tools"])
|
|
assert parsed == ["bash", "read_file"]
|
|
|
|
def test_skill_with_reasoning_effort(self, db):
|
|
"""Skill with reasoning_effort= stores and returns the value."""
|
|
db.create_prompt_template(
|
|
template_id="s1",
|
|
name="effort-skill",
|
|
category="general",
|
|
content="content",
|
|
reasoning_effort="high",
|
|
)
|
|
tpl = db.get_skill_by_name("effort-skill")
|
|
assert tpl is not None
|
|
assert tpl["reasoning_effort"] == "high"
|
|
|
|
def test_disabled_skill_not_in_summary(self, db):
|
|
"""A disabled skill is listed but with enabled=False."""
|
|
db.create_prompt_template(
|
|
template_id="s1",
|
|
name="disabled-skill",
|
|
category="general",
|
|
content="content",
|
|
enabled=False,
|
|
)
|
|
templates = db.list_prompt_templates()
|
|
match = [t for t in templates if t["name"] == "disabled-skill"]
|
|
assert len(match) == 1
|
|
assert match[0]["enabled"] is False
|
|
|
|
def test_session_config_fields_roundtrip(self, db):
|
|
"""All session config fields round-trip through storage."""
|
|
db.create_prompt_template(
|
|
template_id="s1",
|
|
name="full-config-skill",
|
|
category="general",
|
|
content="You are a full config skill.",
|
|
model="gpt-5",
|
|
auto_approve=True,
|
|
temperature=0.7,
|
|
reasoning_effort="high",
|
|
max_tokens=2048,
|
|
token_budget=10000,
|
|
agent_max_turns=5,
|
|
notify_on_complete='{"channel":"discord"}',
|
|
enabled=True,
|
|
allowed_tools='["bash","read_file","write_file"]',
|
|
)
|
|
tpl = db.get_skill_by_name("full-config-skill")
|
|
assert tpl is not None
|
|
assert tpl["model"] == "gpt-5"
|
|
assert tpl["auto_approve"] is True
|
|
assert tpl["temperature"] == 0.7
|
|
assert tpl["reasoning_effort"] == "high"
|
|
assert tpl["max_tokens"] == 2048
|
|
assert tpl["token_budget"] == 10000
|
|
assert tpl["agent_max_turns"] == 5
|
|
assert tpl["notify_on_complete"] == '{"channel":"discord"}'
|
|
assert tpl["enabled"] is True
|
|
parsed_tools = json.loads(tpl["allowed_tools"])
|
|
assert parsed_tools == ["bash", "read_file", "write_file"]
|
|
|
|
def test_license_compatibility_roundtrip(self, db):
|
|
"""Agent Skills spec fields license and compatibility round-trip."""
|
|
db.create_prompt_template(
|
|
template_id="spec1",
|
|
name="spec-fields-skill",
|
|
category="general",
|
|
content="Spec test.",
|
|
skill_license="Apache-2.0",
|
|
compatibility="Requires git, docker, jq",
|
|
)
|
|
tpl = db.get_skill_by_name("spec-fields-skill")
|
|
assert tpl is not None
|
|
assert tpl["license"] == "Apache-2.0"
|
|
assert tpl["compatibility"] == "Requires git, docker, jq"
|
|
|
|
def test_license_compatibility_default_empty(self, db):
|
|
"""license and compatibility default to empty string."""
|
|
db.create_prompt_template(
|
|
template_id="spec2",
|
|
name="no-spec-fields",
|
|
category="general",
|
|
content="No spec fields.",
|
|
)
|
|
tpl = db.get_skill_by_name("no-spec-fields")
|
|
assert tpl is not None
|
|
assert tpl["license"] == ""
|
|
assert tpl["compatibility"] == ""
|
|
|
|
def test_update_license_compatibility(self, db):
|
|
"""license and compatibility can be updated."""
|
|
db.create_prompt_template(
|
|
template_id="spec3",
|
|
name="updatable-spec",
|
|
category="general",
|
|
content="Test.",
|
|
)
|
|
db.update_prompt_template("spec3", license="MIT")
|
|
db.update_prompt_template("spec3", compatibility="Python 3.11+")
|
|
tpl = db.get_prompt_template("spec3")
|
|
assert tpl is not None
|
|
assert tpl["license"] == "MIT"
|
|
assert tpl["compatibility"] == "Python 3.11+"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. Migration behavior tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillMigrationBehaviors:
|
|
"""Test storage behaviors that validate migration 021 data migration correctness."""
|
|
|
|
def test_profile_category_skill_with_session_config(self, db):
|
|
"""Migrated ws_templates get category='profile' with session config."""
|
|
_create_template(
|
|
db,
|
|
"s1",
|
|
"my-profile",
|
|
"Profile content",
|
|
category="profile",
|
|
model="gpt-5",
|
|
temperature=0.3,
|
|
token_budget=5000,
|
|
auto_approve=True,
|
|
)
|
|
skill = db.get_prompt_template_by_name("my-profile")
|
|
assert skill is not None
|
|
assert skill["category"] == "profile"
|
|
assert skill["model"] == "gpt-5"
|
|
assert skill["temperature"] == 0.3
|
|
assert skill["token_budget"] == 5000
|
|
assert skill["auto_approve"] is True
|
|
|
|
def test_skill_versions_roundtrip(self, db):
|
|
"""Migrated version history is readable."""
|
|
_create_template(db, "s1", "versioned", "V1 content")
|
|
db.create_skill_version("s1", 1, '{"content": "V1 content"}', "admin")
|
|
db.create_skill_version("s1", 2, '{"content": "V2 content"}', "admin")
|
|
versions = db.list_skill_versions("s1")
|
|
assert len(versions) == 2
|
|
assert versions[0]["version"] == 2 # DESC order
|
|
assert versions[1]["version"] == 1
|
|
|
|
def test_name_collision_would_use_prefix(self, db):
|
|
"""If a skill name already exists, migrated data would get skills- prefix."""
|
|
_create_template(db, "s1", "deploy-bot", "Original skill")
|
|
# Simulate what migration would do for a ws_template also named "deploy-bot"
|
|
_create_template(
|
|
db,
|
|
"s2",
|
|
"skills-deploy-bot",
|
|
"Migrated from ws_template",
|
|
category="profile",
|
|
model="gpt-5",
|
|
)
|
|
assert db.get_prompt_template_by_name("deploy-bot") is not None
|
|
assert db.get_prompt_template_by_name("skills-deploy-bot") is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. Admin endpoint integration tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture()
|
|
def full_api_storage(tmp_path):
|
|
return SQLiteBackend(str(tmp_path / "full_api_test.db"))
|
|
|
|
|
|
@pytest.fixture()
|
|
def full_api_client(full_api_storage):
|
|
from turnstone.console.server import (
|
|
admin_create_skill,
|
|
admin_delete_skill,
|
|
admin_get_skill,
|
|
admin_list_skill_versions,
|
|
admin_list_skills,
|
|
admin_update_skill,
|
|
list_skills_summary,
|
|
)
|
|
|
|
routes = [
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route("/api/skills", list_skills_summary),
|
|
Route("/api/admin/skills", admin_list_skills),
|
|
Route("/api/admin/skills", admin_create_skill, methods=["POST"]),
|
|
Route("/api/admin/skills/{skill_id}", admin_get_skill),
|
|
Route("/api/admin/skills/{skill_id}", admin_update_skill, methods=["PUT"]),
|
|
Route(
|
|
"/api/admin/skills/{skill_id}",
|
|
admin_delete_skill,
|
|
methods=["DELETE"],
|
|
),
|
|
Route("/api/admin/skills/{skill_id}/versions", admin_list_skill_versions),
|
|
],
|
|
),
|
|
]
|
|
app = Starlette(
|
|
routes=routes,
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.auth_storage = full_api_storage
|
|
return TestClient(app)
|
|
|
|
|
|
class TestSkillAdminEndpoints:
|
|
"""Integration tests for skill admin API endpoints."""
|
|
|
|
def test_create_skill_via_api(self, full_api_client):
|
|
"""POST /v1/api/admin/skills creates a skill and returns correct shape."""
|
|
resp = full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "test-skill",
|
|
"content": "You are a test skill.",
|
|
"category": "testing",
|
|
"description": "A test skill",
|
|
"tags": ["test"],
|
|
"activation": "named",
|
|
"model": "gpt-5",
|
|
"temperature": 0.5,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["name"] == "test-skill"
|
|
assert data["content"] == "You are a test skill."
|
|
assert data["category"] == "testing"
|
|
assert data["description"] == "A test skill"
|
|
assert data["tags"] == ["test"]
|
|
assert data["activation"] == "named"
|
|
assert data["model"] == "gpt-5"
|
|
assert data["temperature"] == 0.5
|
|
assert "template_id" in data
|
|
|
|
def test_create_skill_duplicate_name_409(self, full_api_client):
|
|
"""POST with existing name returns 409."""
|
|
full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"name": "dup-skill", "content": "content", "description": "first"},
|
|
)
|
|
resp = full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "dup-skill",
|
|
"content": "other content",
|
|
"description": "second",
|
|
},
|
|
)
|
|
assert resp.status_code == 409
|
|
|
|
def test_get_skill_via_api(self, full_api_client):
|
|
"""GET /v1/api/admin/skills/{id} returns full skill data."""
|
|
create_resp = full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "get-me",
|
|
"content": "content here",
|
|
"description": "desc",
|
|
},
|
|
)
|
|
skill_id = create_resp.json()["template_id"]
|
|
|
|
resp = full_api_client.get(f"/v1/api/admin/skills/{skill_id}")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["template_id"] == skill_id
|
|
assert data["name"] == "get-me"
|
|
assert data["content"] == "content here"
|
|
assert data["description"] == "desc"
|
|
|
|
def test_update_skill_via_api(self, full_api_client):
|
|
"""PUT with new fields updates the skill."""
|
|
create_resp = full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"name": "update-me", "content": "old content", "description": "pre"},
|
|
)
|
|
skill_id = create_resp.json()["template_id"]
|
|
|
|
resp = full_api_client.put(
|
|
f"/v1/api/admin/skills/{skill_id}",
|
|
json={
|
|
"description": "new desc",
|
|
"temperature": 0.8,
|
|
"model": "gpt-5",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["description"] == "new desc"
|
|
assert data["temperature"] == 0.8
|
|
assert data["model"] == "gpt-5"
|
|
|
|
def test_delete_skill_via_api(self, full_api_client):
|
|
"""DELETE removes the skill and subsequent GET returns 404."""
|
|
create_resp = full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"name": "delete-me", "content": "content", "description": "doomed"},
|
|
)
|
|
skill_id = create_resp.json()["template_id"]
|
|
|
|
resp = full_api_client.delete(f"/v1/api/admin/skills/{skill_id}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "ok"
|
|
|
|
get_resp = full_api_client.get(f"/v1/api/admin/skills/{skill_id}")
|
|
assert get_resp.status_code == 404
|
|
|
|
def test_list_skills_summary_excludes_disabled(self, full_api_client, full_api_storage):
|
|
"""GET /v1/api/skills excludes disabled skills."""
|
|
full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"name": "enabled-skill", "content": "content", "description": "on"},
|
|
)
|
|
create_resp = full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"name": "disabled-skill", "content": "content", "description": "off"},
|
|
)
|
|
skill_id = create_resp.json()["template_id"]
|
|
full_api_client.put(
|
|
f"/v1/api/admin/skills/{skill_id}",
|
|
json={"enabled": False},
|
|
)
|
|
|
|
resp = full_api_client.get("/v1/api/skills")
|
|
assert resp.status_code == 200
|
|
names = [s["name"] for s in resp.json()["skills"]]
|
|
assert "enabled-skill" in names
|
|
assert "disabled-skill" not in names
|
|
|
|
def test_list_skills_summary_excludes_hidden_from_menu(self, full_api_client, full_api_storage):
|
|
"""GET /v1/api/skills excludes skills with ``hidden_from_menu=true``.
|
|
|
|
The admin Skills tab (``/v1/api/admin/skills``) still returns them
|
|
— the filter only applies to the user-facing picker.
|
|
"""
|
|
full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"name": "visible-skill", "content": "content", "description": "v"},
|
|
)
|
|
full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": "hidden-skill",
|
|
"content": "content",
|
|
"description": "h",
|
|
"hidden_from_menu": True,
|
|
},
|
|
)
|
|
|
|
# Picker filters out the hidden one.
|
|
resp = full_api_client.get("/v1/api/skills")
|
|
assert resp.status_code == 200
|
|
names = [s["name"] for s in resp.json()["skills"]]
|
|
assert "visible-skill" in names
|
|
assert "hidden-skill" not in names
|
|
|
|
# Admin tab still surfaces it.
|
|
admin_resp = full_api_client.get("/v1/api/admin/skills")
|
|
assert admin_resp.status_code == 200
|
|
admin_names = [s["name"] for s in admin_resp.json()["skills"]]
|
|
assert "hidden-skill" in admin_names
|
|
|
|
def test_skill_version_history_via_api(self, full_api_client):
|
|
"""GET /v1/api/admin/skills/{id}/versions returns version history."""
|
|
create_resp = full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"name": "versioned-skill", "content": "v1 content", "description": "v1"},
|
|
)
|
|
skill_id = create_resp.json()["template_id"]
|
|
|
|
# Update to create a version snapshot
|
|
full_api_client.put(
|
|
f"/v1/api/admin/skills/{skill_id}",
|
|
json={"content": "v2 content"},
|
|
)
|
|
|
|
resp = full_api_client.get(f"/v1/api/admin/skills/{skill_id}/versions")
|
|
assert resp.status_code == 200
|
|
versions = resp.json()["versions"]
|
|
assert len(versions) == 1
|
|
|
|
def test_create_skill_invalid_temperature_400(self, full_api_client):
|
|
"""POST with temperature=5 returns 400."""
|
|
resp = full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"name": "bad-temp", "content": "content", "temperature": 5},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "temperature" in resp.json()["error"].lower()
|
|
|
|
def test_create_skill_invalid_activation_400(self, full_api_client):
|
|
"""POST with activation='bogus' returns 400."""
|
|
resp = full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"name": "bad-act", "content": "content", "activation": "bogus"},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "activation" in resp.json()["error"].lower()
|
|
|
|
def test_list_skills_pagination(self, full_api_client):
|
|
"""Pagination with limit and offset works, total is independent of limit."""
|
|
for i in range(5):
|
|
full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={
|
|
"name": f"page-skill-{i}",
|
|
"content": f"content {i}",
|
|
"description": f"desc {i}",
|
|
},
|
|
)
|
|
# Limit
|
|
resp = full_api_client.get("/v1/api/admin/skills?limit=2")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data["skills"]) == 2
|
|
assert data["total"] >= 5
|
|
|
|
# Offset
|
|
resp2 = full_api_client.get("/v1/api/admin/skills?limit=2&offset=2")
|
|
assert resp2.status_code == 200
|
|
data2 = resp2.json()
|
|
assert len(data2["skills"]) == 2
|
|
assert data2["total"] == data["total"]
|
|
# Different skills returned
|
|
names1 = {s["name"] for s in data["skills"]}
|
|
names2 = {s["name"] for s in data2["skills"]}
|
|
assert names1.isdisjoint(names2)
|
|
|
|
# No limit returns all
|
|
resp3 = full_api_client.get("/v1/api/admin/skills")
|
|
assert resp3.status_code == 200
|
|
assert len(resp3.json()["skills"]) >= 5
|
|
|
|
def test_create_skill_invalid_token_budget_type_400(self, full_api_client):
|
|
"""Non-numeric token_budget returns 400."""
|
|
resp = full_api_client.post(
|
|
"/v1/api/admin/skills",
|
|
json={"name": "bad-budget", "content": "c", "token_budget": "abc"},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "integer" in resp.json()["error"].lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. Skill session config applied to workstream via server handler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillConfigAppliedToWorkstream:
|
|
"""Verify that skill session config fields are applied to the ChatSession
|
|
when a workstream is created via the server ``create_workstream`` handler.
|
|
"""
|
|
|
|
@pytest.fixture()
|
|
def _ws_app(self, tmp_path):
|
|
"""Build a minimal Starlette app with the real ``create_workstream``
|
|
handler, a real ``SessionManager``, and a temp SQLite storage
|
|
backend. Returns ``(TestClient, SessionManager, storage)``.
|
|
"""
|
|
import queue
|
|
import threading
|
|
|
|
import turnstone.core.storage._registry as _reg
|
|
from turnstone.core.adapters.interactive_adapter import InteractiveAdapter
|
|
from turnstone.core.session_manager import SessionManager
|
|
from turnstone.core.session_routes import (
|
|
SessionEndpointConfig,
|
|
make_create_handler,
|
|
)
|
|
from turnstone.server import (
|
|
WebUI,
|
|
_interactive_create_build_kwargs,
|
|
_interactive_create_post_install,
|
|
_interactive_create_pre_commit,
|
|
_interactive_create_prepare_install,
|
|
_interactive_create_validate_request,
|
|
_interactive_manager_lookup,
|
|
_interactive_tenant_check,
|
|
)
|
|
|
|
storage = SQLiteBackend(str(tmp_path / "ws_test.db"))
|
|
|
|
# Inject the test storage as the global singleton so that
|
|
# get_storage() / get_skill_by_name() resolve against it.
|
|
old_storage = _reg._storage
|
|
_reg._storage = storage
|
|
|
|
def _session_factory(
|
|
ui: Any, model_alias: Any = None, ws_id: Any = None, **kwargs: Any
|
|
) -> ChatSession:
|
|
return ChatSession(
|
|
client=MagicMock(),
|
|
model=model_alias or "test-model",
|
|
ui=ui,
|
|
instructions=None,
|
|
temperature=0.5,
|
|
max_tokens=4096,
|
|
tool_timeout=30,
|
|
ws_id=ws_id,
|
|
skill=kwargs.get("skill"),
|
|
)
|
|
|
|
gq: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
WebUI._global_queue = gq
|
|
adapter = InteractiveAdapter(
|
|
global_queue=gq,
|
|
ui_factory=lambda ws: WebUI(
|
|
ws_id=ws.id,
|
|
user_id=ws.user_id,
|
|
kind=ws.kind,
|
|
parent_ws_id=ws.parent_ws_id,
|
|
),
|
|
session_factory=_session_factory,
|
|
)
|
|
mgr = SessionManager(adapter, storage=storage, max_active=10, event_emitter=adapter)
|
|
|
|
# Build the same lifted create handler the production app
|
|
# mounts so this fixture exercises the make_create_handler
|
|
# factory rather than a parallel pre-lift body.
|
|
_test_cfg = SessionEndpointConfig(
|
|
permission_gate=None,
|
|
manager_lookup=_interactive_manager_lookup,
|
|
tenant_check=_interactive_tenant_check,
|
|
not_found_label="Workstream not found",
|
|
audit_action_prefix="workstream",
|
|
create_supports_attachments=True,
|
|
create_supports_user_id_override=True,
|
|
create_validate_request=_interactive_create_validate_request,
|
|
create_build_kwargs=_interactive_create_build_kwargs,
|
|
create_pre_commit=_interactive_create_pre_commit,
|
|
create_post_install=_interactive_create_post_install,
|
|
create_prepare_install=_interactive_create_prepare_install,
|
|
)
|
|
_test_create_handler = make_create_handler(_test_cfg)
|
|
routes = [
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route(
|
|
"/api/workstreams/new",
|
|
_test_create_handler,
|
|
methods=["POST"],
|
|
),
|
|
],
|
|
),
|
|
]
|
|
app = Starlette(
|
|
routes=routes,
|
|
middleware=[Middleware(_InjectAuthMiddleware)],
|
|
)
|
|
app.state.workstreams = mgr
|
|
app.state.skip_permissions = True
|
|
app.state.global_queue = gq
|
|
app.state.global_listeners = []
|
|
app.state.global_listeners_lock = threading.Lock()
|
|
|
|
client = TestClient(app, raise_server_exceptions=False)
|
|
|
|
yield client, mgr, storage
|
|
|
|
# Restore original storage singleton.
|
|
_reg._storage = old_storage
|
|
|
|
def test_create_lift_400s_on_malformed_notify_targets(self, _ws_app):
|
|
"""Regression for the lifted create handler — malformed
|
|
``notify_targets`` returns 400 from the validator (pre-create
|
|
gate), not 500 from a post_install raise."""
|
|
client, mgr, storage = _ws_app
|
|
|
|
resp = client.post(
|
|
"/v1/api/workstreams/new",
|
|
json={"name": "x", "notify_targets": "{not json"},
|
|
)
|
|
assert resp.status_code == 400, resp.text
|
|
body = resp.json()
|
|
assert "error" in body
|
|
# The workstream must NOT have been created — the validator
|
|
# gates BEFORE mgr.create, so storage stays clean.
|
|
assert len(list(storage.list_workstreams())) == 0
|
|
|
|
def test_session_receives_temperature(self, _ws_app):
|
|
"""Skill temperature overrides the session default."""
|
|
client, mgr, storage = _ws_app
|
|
_create_template(storage, "s1", "warm-skill", "Be warm.", temperature=0.9, enabled=True)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "warm-skill"})
|
|
assert resp.status_code == 200
|
|
ws_id = resp.json()["ws_id"]
|
|
ws = mgr.get(ws_id)
|
|
assert ws is not None and ws.session is not None
|
|
assert ws.session.temperature == 0.9
|
|
|
|
def test_session_receives_max_tokens(self, _ws_app):
|
|
"""Skill max_tokens overrides the session default."""
|
|
client, mgr, storage = _ws_app
|
|
_create_template(storage, "s1", "token-skill", "Be concise.", max_tokens=1024, enabled=True)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "token-skill"})
|
|
assert resp.status_code == 200
|
|
ws = mgr.get(resp.json()["ws_id"])
|
|
assert ws is not None and ws.session is not None
|
|
assert ws.session.max_tokens == 1024
|
|
|
|
def test_session_receives_token_budget(self, _ws_app):
|
|
"""Skill token_budget is applied to the session."""
|
|
client, mgr, storage = _ws_app
|
|
_create_template(
|
|
storage, "s1", "budget-skill", "Stay on budget.", token_budget=50000, enabled=True
|
|
)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "budget-skill"})
|
|
assert resp.status_code == 200
|
|
ws = mgr.get(resp.json()["ws_id"])
|
|
assert ws is not None and ws.session is not None
|
|
assert ws.session._token_budget == 50000
|
|
|
|
def test_session_receives_reasoning_effort(self, _ws_app):
|
|
"""Skill reasoning_effort is applied to the session."""
|
|
client, mgr, storage = _ws_app
|
|
_create_template(
|
|
storage, "s1", "effort-skill", "Think hard.", reasoning_effort="high", enabled=True
|
|
)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "effort-skill"})
|
|
assert resp.status_code == 200
|
|
ws = mgr.get(resp.json()["ws_id"])
|
|
assert ws is not None and ws.session is not None
|
|
assert ws.session.reasoning_effort == "high"
|
|
|
|
def test_session_receives_agent_max_turns(self, _ws_app):
|
|
"""Skill agent_max_turns is applied to the session."""
|
|
client, mgr, storage = _ws_app
|
|
_create_template(
|
|
storage, "s1", "turns-skill", "Few turns.", agent_max_turns=3, enabled=True
|
|
)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "turns-skill"})
|
|
assert resp.status_code == 200
|
|
ws = mgr.get(resp.json()["ws_id"])
|
|
assert ws is not None and ws.session is not None
|
|
assert ws.session.agent_max_turns == 3
|
|
|
|
def test_auto_approve_set_on_ui(self, _ws_app):
|
|
"""Skill auto_approve=True propagates to the WebUI."""
|
|
from turnstone.server import WebUI
|
|
|
|
client, mgr, storage = _ws_app
|
|
_create_template(
|
|
storage, "s1", "approve-skill", "Auto approve.", auto_approve=True, enabled=True
|
|
)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "approve-skill"})
|
|
assert resp.status_code == 200
|
|
ws = mgr.get(resp.json()["ws_id"])
|
|
assert ws is not None
|
|
assert isinstance(ws.ui, WebUI)
|
|
assert ws.ui.auto_approve is True
|
|
|
|
def test_allowed_tools_set_on_ui(self, _ws_app):
|
|
"""Skill allowed_tools are parsed and set as auto_approve_tools on the UI."""
|
|
from turnstone.server import WebUI
|
|
|
|
client, mgr, storage = _ws_app
|
|
_create_template(
|
|
storage,
|
|
"s1",
|
|
"tools-skill",
|
|
"Restricted tools.",
|
|
allowed_tools='["bash", "read_file"]',
|
|
enabled=True,
|
|
)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "tools-skill"})
|
|
assert resp.status_code == 200
|
|
ws = mgr.get(resp.json()["ws_id"])
|
|
assert ws is not None
|
|
assert isinstance(ws.ui, WebUI)
|
|
assert ws.ui.auto_approve_tools == {"bash", "read_file"}
|
|
|
|
def test_skill_model_overrides_resolved_model(self, _ws_app):
|
|
"""Skill model field overrides the default session model."""
|
|
client, mgr, storage = _ws_app
|
|
_create_template(
|
|
storage, "s1", "model-skill", "Use specific model.", model="gpt-5", enabled=True
|
|
)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "model-skill"})
|
|
assert resp.status_code == 200
|
|
ws = mgr.get(resp.json()["ws_id"])
|
|
assert ws is not None and ws.session is not None
|
|
assert ws.session.model == "gpt-5"
|
|
|
|
def test_all_session_config_fields_applied(self, _ws_app):
|
|
"""All session config fields from a skill are applied together."""
|
|
from turnstone.server import WebUI
|
|
|
|
client, mgr, storage = _ws_app
|
|
_create_template(
|
|
storage,
|
|
"s1",
|
|
"full-skill",
|
|
"Full config skill.",
|
|
model="gpt-5",
|
|
temperature=0.8,
|
|
reasoning_effort="high",
|
|
max_tokens=2048,
|
|
token_budget=100000,
|
|
agent_max_turns=10,
|
|
auto_approve=True,
|
|
allowed_tools='["bash", "write_file", "read_file"]',
|
|
notify_on_complete='{"channel": "discord"}',
|
|
enabled=True,
|
|
)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "full-skill"})
|
|
assert resp.status_code == 200
|
|
ws = mgr.get(resp.json()["ws_id"])
|
|
assert ws is not None and ws.session is not None
|
|
sess = ws.session
|
|
assert sess.model == "gpt-5"
|
|
assert sess.temperature == 0.8
|
|
assert sess.reasoning_effort == "high"
|
|
assert sess.max_tokens == 2048
|
|
assert sess._token_budget == 100000
|
|
assert sess.agent_max_turns == 10
|
|
assert sess._notify_on_complete == '{"channel": "discord"}'
|
|
assert sess._applied_skill_id == "s1"
|
|
assert sess._applied_skill_content == "Full config skill."
|
|
assert isinstance(ws.ui, WebUI)
|
|
assert ws.ui.auto_approve is True
|
|
assert ws.ui.auto_approve_tools == {"bash", "write_file", "read_file"}
|
|
|
|
def test_disabled_skill_returns_400(self, _ws_app):
|
|
"""Creating a workstream with a disabled skill returns 400."""
|
|
client, _mgr, storage = _ws_app
|
|
_create_template(storage, "s1", "disabled-skill", "Disabled.", enabled=False)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "disabled-skill"})
|
|
assert resp.status_code == 400
|
|
assert "disabled" in resp.json()["error"].lower()
|
|
|
|
def test_unknown_skill_returns_400(self, _ws_app):
|
|
"""Creating a workstream with a nonexistent skill returns 400."""
|
|
client, _mgr, _storage = _ws_app
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "no-such-skill"})
|
|
assert resp.status_code == 400
|
|
assert "not found" in resp.json()["error"].lower()
|
|
|
|
def test_zero_token_budget_is_noop(self, _ws_app):
|
|
"""Skill with token_budget=0 — handler skips budget application (> 0 guard)."""
|
|
client, mgr, storage = _ws_app
|
|
_create_template(
|
|
storage, "s1", "no-budget-skill", "No budget.", token_budget=0, enabled=True
|
|
)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "no-budget-skill"})
|
|
assert resp.status_code == 200
|
|
ws = mgr.get(resp.json()["ws_id"])
|
|
assert ws is not None and ws.session is not None
|
|
# Budget stays at default (0) — the handler's > 0 guard prevents application
|
|
assert ws.session._token_budget == 0
|
|
|
|
def test_empty_allowed_tools_is_noop(self, _ws_app):
|
|
"""Skill with allowed_tools='[]' — handler skips (empty check)."""
|
|
client, mgr, storage = _ws_app
|
|
_create_template(
|
|
storage, "s1", "no-tools-skill", "No tools.", allowed_tools="[]", enabled=True
|
|
)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "no-tools-skill"})
|
|
assert resp.status_code == 200
|
|
ws = mgr.get(resp.json()["ws_id"])
|
|
assert ws is not None
|
|
# auto_approve_tools stays at default (empty set)
|
|
assert ws.ui.auto_approve_tools == set()
|
|
|
|
def test_skill_lineage_in_workstreams_table(self, _ws_app):
|
|
"""skill_id and skill_version columns are populated in the workstreams table."""
|
|
import sqlalchemy as sa
|
|
|
|
from turnstone.core.storage._schema import workstreams
|
|
|
|
client, _mgr, storage = _ws_app
|
|
_create_template(storage, "s1", "lineage-skill", "Track me.", enabled=True)
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={"skill": "lineage-skill"})
|
|
assert resp.status_code == 200
|
|
ws_id = resp.json()["ws_id"]
|
|
|
|
with storage._engine.connect() as conn:
|
|
row = conn.execute(
|
|
sa.select(workstreams.c.skill_id, workstreams.c.skill_version).where(
|
|
workstreams.c.ws_id == ws_id
|
|
)
|
|
).fetchone()
|
|
assert row is not None
|
|
assert row[0] == "s1"
|
|
assert row[1] == 1
|
|
|
|
def test_no_skill_lineage_when_no_skill(self, _ws_app):
|
|
"""Workstream without a skill has empty skill_id and zero skill_version."""
|
|
import sqlalchemy as sa
|
|
|
|
from turnstone.core.storage._schema import workstreams
|
|
|
|
client, _mgr, storage = _ws_app
|
|
|
|
resp = client.post("/v1/api/workstreams/new", json={})
|
|
assert resp.status_code == 200
|
|
ws_id = resp.json()["ws_id"]
|
|
|
|
with storage._engine.connect() as conn:
|
|
row = conn.execute(
|
|
sa.select(workstreams.c.skill_id, workstreams.c.skill_version).where(
|
|
workstreams.c.ws_id == ws_id
|
|
)
|
|
).fetchone()
|
|
assert row is not None
|
|
assert row[0] == ""
|
|
assert row[1] == 0
|