tylor-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.aws-setup.sh +25 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.mcp.json +12 -0
- package/AGENTS.md +93 -0
- package/CLAUDE.md +99 -0
- package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/assets/tylor_logo.png +0 -0
- package/assets/tylor_threads_concept.png +0 -0
- package/bin/tylor.js +23 -0
- package/hooks/kill-thread-trigger.sh +7 -0
- package/hooks/post-tool-use-code-index.sh +7 -0
- package/hooks/session-checkpoint.sh +7 -0
- package/hooks/session-start.sh +7 -0
- package/install.py +401 -0
- package/install.sh +260 -0
- package/package.json +24 -0
- package/pytest.ini +2 -0
- package/registry.json +26 -0
- package/server/.env.example +24 -0
- package/server/__init__.py +0 -0
- package/server/config.py +89 -0
- package/server/main.py +93 -0
- package/server/personas/analyst.md +15 -0
- package/server/personas/ceo.md +14 -0
- package/server/personas/code_agent.md +15 -0
- package/server/personas/cto.md +14 -0
- package/server/provision.py +260 -0
- package/server/provision_opensearch.py +154 -0
- package/server/requirements.txt +26 -0
- package/server/storage/__init__.py +0 -0
- package/server/storage/dynamo.py +399 -0
- package/server/storage/json_store.py +359 -0
- package/server/storage/opensearch.py +194 -0
- package/server/storage/s3.py +96 -0
- package/server/storage/tests/__init__.py +0 -0
- package/server/storage/tests/test_dynamo.py +452 -0
- package/server/storage/tests/test_json_store.py +226 -0
- package/server/storage/tests/test_opensearch.py +270 -0
- package/server/storage/tests/test_s3.py +125 -0
- package/server/tests/__init__.py +0 -0
- package/server/tests/test_install.py +606 -0
- package/server/tests/test_isolation.py +90 -0
- package/server/tests/test_ui_server.py +385 -0
- package/server/tests/test_ui_shader_background.py +52 -0
- package/server/tests/test_ui_story_6_3.py +105 -0
- package/server/tools/__init__.py +0 -0
- package/server/tools/_mcp.py +4 -0
- package/server/tools/agents.py +160 -0
- package/server/tools/ecc/__init__.py +1 -0
- package/server/tools/ecc/data.py +35 -0
- package/server/tools/ecc/diagrams.py +23 -0
- package/server/tools/ecc/pipeline.py +24 -0
- package/server/tools/ecc/presentation.py +24 -0
- package/server/tools/ecc/web.py +23 -0
- package/server/tools/executor.py +880 -0
- package/server/tools/harness.py +330 -0
- package/server/tools/help.py +162 -0
- package/server/tools/hooks.py +357 -0
- package/server/tools/personas.py +110 -0
- package/server/tools/registry.py +195 -0
- package/server/tools/router.py +117 -0
- package/server/tools/skill_installer.py +230 -0
- package/server/tools/summarizer.py +168 -0
- package/server/tools/tests/__init__.py +0 -0
- package/server/tools/tests/test_agents.py +246 -0
- package/server/tools/tests/test_code_index.py +108 -0
- package/server/tools/tests/test_ecc_tools.py +51 -0
- package/server/tools/tests/test_executor.py +584 -0
- package/server/tools/tests/test_help_agent101.py +149 -0
- package/server/tools/tests/test_hooks.py +124 -0
- package/server/tools/tests/test_kill_thread.py +125 -0
- package/server/tools/tests/test_new_thread_list_threads.py +293 -0
- package/server/tools/tests/test_personas.py +52 -0
- package/server/tools/tests/test_recall_memory.py +55 -0
- package/server/tools/tests/test_registry_client.py +308 -0
- package/server/tools/tests/test_router.py +263 -0
- package/server/tools/tests/test_skill_installer.py +174 -0
- package/server/tools/tests/test_switch_thread.py +163 -0
- package/server/tools/tests/test_thread_command_skills.py +54 -0
- package/server/tools/tests/test_thread_resolver.py +165 -0
- package/server/tools/tests/test_tier1_schema.py +296 -0
- package/server/tools/thread_resolver.py +75 -0
- package/server/tools/tylor.py +374 -0
- package/server/tools/ui.py +38 -0
- package/server/ui_server.py +292 -0
- package/server/validate.py +237 -0
- package/skills/add-skill/SKILL.md +37 -0
- package/skills/afk-status/SKILL.md +20 -0
- package/skills/bmad/SKILL.md +14 -0
- package/skills/help-agent101/SKILL.md +48 -0
- package/skills/kill-thread/SKILL.md +35 -0
- package/skills/list-threads/SKILL.md +35 -0
- package/skills/new-thread/SKILL.md +35 -0
- package/skills/recall/SKILL.md +39 -0
- package/skills/run/SKILL.md +33 -0
- package/skills/set-sandbox/SKILL.md +38 -0
- package/skills/switch-thread/SKILL.md +38 -0
- package/ui/claude-logo.png +0 -0
- package/ui/index.html +1314 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 4.3: /add-skill command and auto-generated registry entries.
|
|
3
|
+
Run: pytest server/tools/tests/test_skill_installer.py -v
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from mcp.shared.exceptions import McpError
|
|
10
|
+
from mcp.types import INVALID_PARAMS
|
|
11
|
+
|
|
12
|
+
PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _write_skill_package(root: Path, name: str = "bmad") -> Path:
|
|
16
|
+
package = root / name
|
|
17
|
+
package.mkdir(parents=True)
|
|
18
|
+
(package / "SKILL.md").write_text(
|
|
19
|
+
"---\n"
|
|
20
|
+
f"name: {name}\n"
|
|
21
|
+
"description: Use when user wants to create a PRD, review stories, or run BMAD workflows.\n"
|
|
22
|
+
"---\n\n"
|
|
23
|
+
f"# /{name}\n\n"
|
|
24
|
+
"Use when user wants to create a PRD, review stories, or run BMAD workflows.\n\n"
|
|
25
|
+
"Call `load_skill_tools(\"bmad\")` when this trigger matches.\n",
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
)
|
|
28
|
+
(package / "notes.md").write_text("extra file", encoding="utf-8")
|
|
29
|
+
return package
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_registry(path: Path) -> dict:
|
|
33
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_install_skill_copies_package_and_appends_generated_registry_entry(tmp_path):
|
|
37
|
+
from server.tools.skill_installer import install_skill
|
|
38
|
+
|
|
39
|
+
source = _write_skill_package(tmp_path / "source")
|
|
40
|
+
skills_dir = tmp_path / "skills"
|
|
41
|
+
registry_path = tmp_path / "registry.json"
|
|
42
|
+
registry_path.write_text('{"version":"1.0","skills":[]}', encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
result = install_skill(
|
|
45
|
+
source_path=source,
|
|
46
|
+
skills_dir=skills_dir,
|
|
47
|
+
registry_path=registry_path,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
target = skills_dir / "bmad"
|
|
51
|
+
assert result["name"] == "bmad"
|
|
52
|
+
assert result["installed_to"] == str(target)
|
|
53
|
+
assert (target / "SKILL.md").exists()
|
|
54
|
+
assert (target / "notes.md").read_text(encoding="utf-8") == "extra file"
|
|
55
|
+
|
|
56
|
+
registry = _read_registry(registry_path)
|
|
57
|
+
[entry] = registry["skills"]
|
|
58
|
+
assert entry["name"] == "bmad"
|
|
59
|
+
assert entry["trigger"]
|
|
60
|
+
assert entry["trigger_description"] == entry["trigger"]
|
|
61
|
+
assert {"prd", "review", "stories", "bmad", "workflows"} <= set(entry["keywords"])
|
|
62
|
+
assert entry["tool_count"] == 1
|
|
63
|
+
assert entry["source_path"] == str(source)
|
|
64
|
+
assert entry["installed_date"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_install_skill_requires_skill_markdown(tmp_path):
|
|
68
|
+
from server.tools.skill_installer import install_skill
|
|
69
|
+
|
|
70
|
+
source = tmp_path / "empty-skill"
|
|
71
|
+
source.mkdir()
|
|
72
|
+
|
|
73
|
+
with pytest.raises(McpError) as excinfo:
|
|
74
|
+
install_skill(
|
|
75
|
+
source_path=source,
|
|
76
|
+
skills_dir=tmp_path / "skills",
|
|
77
|
+
registry_path=tmp_path / "registry.json",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
assert excinfo.value.error.code == INVALID_PARAMS
|
|
81
|
+
assert "SKILL.md not found" in excinfo.value.error.message
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_install_skill_duplicate_requires_overwrite_confirmation(tmp_path):
|
|
85
|
+
from server.tools.skill_installer import install_skill
|
|
86
|
+
|
|
87
|
+
source = _write_skill_package(tmp_path / "source")
|
|
88
|
+
skills_dir = tmp_path / "skills"
|
|
89
|
+
registry_path = tmp_path / "registry.json"
|
|
90
|
+
registry_path.write_text(
|
|
91
|
+
json.dumps(
|
|
92
|
+
{
|
|
93
|
+
"version": "1.0",
|
|
94
|
+
"skills": [
|
|
95
|
+
{
|
|
96
|
+
"name": "bmad",
|
|
97
|
+
"trigger": "existing",
|
|
98
|
+
"trigger_description": "existing",
|
|
99
|
+
"keywords": ["existing"],
|
|
100
|
+
"tool_count": 1,
|
|
101
|
+
"installed_date": "2026-05-12",
|
|
102
|
+
"source_path": "/old",
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
),
|
|
107
|
+
encoding="utf-8",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
with pytest.raises(McpError) as excinfo:
|
|
111
|
+
install_skill(
|
|
112
|
+
source_path=source,
|
|
113
|
+
skills_dir=skills_dir,
|
|
114
|
+
registry_path=registry_path,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert excinfo.value.error.code == INVALID_PARAMS
|
|
118
|
+
assert "already exists" in excinfo.value.error.message
|
|
119
|
+
assert "overwrite=True" in excinfo.value.error.message
|
|
120
|
+
|
|
121
|
+
result = install_skill(
|
|
122
|
+
source_path=source,
|
|
123
|
+
skills_dir=skills_dir,
|
|
124
|
+
registry_path=registry_path,
|
|
125
|
+
overwrite=True,
|
|
126
|
+
)
|
|
127
|
+
assert result["status"] == "installed"
|
|
128
|
+
registry = _read_registry(registry_path)
|
|
129
|
+
assert len(registry["skills"]) == 1
|
|
130
|
+
assert registry["skills"][0]["source_path"] == str(source)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_install_skill_preserves_module_and_tools_from_frontmatter(tmp_path):
|
|
134
|
+
from server.tools.skill_installer import install_skill
|
|
135
|
+
|
|
136
|
+
package = tmp_path / "source"
|
|
137
|
+
package.mkdir(parents=True)
|
|
138
|
+
(package / "SKILL.md").write_text(
|
|
139
|
+
"---\n"
|
|
140
|
+
"name: bmad\n"
|
|
141
|
+
"description: Use when user wants to create a PRD.\n"
|
|
142
|
+
"module: server.tools.ecc.web\n"
|
|
143
|
+
"tools: [\"web_fetch\", \"web_scrape\"]\n"
|
|
144
|
+
"---\n\n"
|
|
145
|
+
"# /bmad\n\n"
|
|
146
|
+
"Call `load_skill_tools(\"bmad\")` when this trigger matches.\n",
|
|
147
|
+
encoding="utf-8",
|
|
148
|
+
)
|
|
149
|
+
skills_dir = tmp_path / "skills"
|
|
150
|
+
registry_path = tmp_path / "registry.json"
|
|
151
|
+
registry_path.write_text('{"version":"1.0","skills":[]}', encoding="utf-8")
|
|
152
|
+
|
|
153
|
+
result = install_skill(
|
|
154
|
+
source_path=package,
|
|
155
|
+
skills_dir=skills_dir,
|
|
156
|
+
registry_path=registry_path,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
registry = _read_registry(registry_path)
|
|
160
|
+
entry = registry["skills"][0]
|
|
161
|
+
assert entry["module"] == "server.tools.ecc.web"
|
|
162
|
+
assert entry["tools"] == ["web_fetch", "web_scrape"]
|
|
163
|
+
assert result["name"] == "bmad"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_add_skill_slash_command_file_exists_and_mentions_installer():
|
|
167
|
+
path = PLUGIN_DIR / "skills" / "add-skill" / "SKILL.md"
|
|
168
|
+
assert path.exists()
|
|
169
|
+
text = path.read_text(encoding="utf-8")
|
|
170
|
+
assert "name: add-skill" in text
|
|
171
|
+
assert "/add-skill" in text
|
|
172
|
+
assert "server.tools.skill_installer" in text
|
|
173
|
+
assert "overwrite" in text.lower()
|
|
174
|
+
assert "registry.json" in text
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 2.4: switch_thread tool and DynamoDB atomic write helper.
|
|
3
|
+
Run: pytest server/tools/tests/test_switch_thread.py -v
|
|
4
|
+
"""
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
|
|
12
|
+
sys.path.insert(0, str(PLUGIN_DIR))
|
|
13
|
+
|
|
14
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
15
|
+
from server.storage.dynamo import DynamoClient
|
|
16
|
+
from server.tools.tylor import switch_thread
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_client(mock_table=None):
|
|
20
|
+
with patch("boto3.Session") as mock_session_cls:
|
|
21
|
+
mock_session = MagicMock()
|
|
22
|
+
mock_session_cls.return_value = mock_session
|
|
23
|
+
resource = MagicMock()
|
|
24
|
+
mock_session.resource.return_value = resource
|
|
25
|
+
table = mock_table or MagicMock()
|
|
26
|
+
resource.Table.return_value = table
|
|
27
|
+
client = DynamoClient(table_name="agent101", user_id="testuser")
|
|
28
|
+
client.table = table
|
|
29
|
+
return client, table
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_switch_thread_tool_calls_dynamo_switch():
|
|
33
|
+
mock_db = MagicMock()
|
|
34
|
+
with patch("server.tools.tylor._get_db", return_value=mock_db):
|
|
35
|
+
mock_db.switch_thread.return_value = {
|
|
36
|
+
"thread_id": "t2",
|
|
37
|
+
"status": "switched",
|
|
38
|
+
"switched_at": "2026-05-12T10:00:00Z",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
result = switch_thread("t2")
|
|
42
|
+
|
|
43
|
+
mock_db.switch_thread.assert_called_once_with("t2")
|
|
44
|
+
assert result["thread_id"] == "t2"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_switch_thread_includes_code_index_header_when_available():
|
|
48
|
+
mock_db = MagicMock()
|
|
49
|
+
mock_db.switch_thread.return_value = {
|
|
50
|
+
"thread_id": "t2",
|
|
51
|
+
"status": "switched",
|
|
52
|
+
"switched_at": "2026-05-12T10:00:00Z",
|
|
53
|
+
"name": "Frontend",
|
|
54
|
+
}
|
|
55
|
+
mock_db.get_thread_meta.return_value = {"Name": "Frontend"}
|
|
56
|
+
|
|
57
|
+
with patch("server.tools.tylor._get_db", return_value=mock_db), patch(
|
|
58
|
+
"server.tools.hooks.build_code_index_header",
|
|
59
|
+
return_value="[Frontend Thread — Code Index]\nSignIn: ui/auth.tsx:42",
|
|
60
|
+
):
|
|
61
|
+
result = switch_thread("t2")
|
|
62
|
+
|
|
63
|
+
assert result["code_index_header"].startswith("[Frontend Thread")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_dynamo_client_switch_thread_raises_when_target_missing():
|
|
67
|
+
client, table = make_client()
|
|
68
|
+
table.get_item.return_value = {}
|
|
69
|
+
|
|
70
|
+
with pytest.raises(ToolError, match="Thread not found: missing"):
|
|
71
|
+
client.switch_thread("missing")
|
|
72
|
+
|
|
73
|
+
assert not client._client.transact_write_items.called
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_dynamo_client_switch_thread_writes_marker_and_target_meta():
|
|
77
|
+
client, table = make_client()
|
|
78
|
+
target_meta = {
|
|
79
|
+
"PK": "USER#testuser",
|
|
80
|
+
"SK": "THREAD#t2#META",
|
|
81
|
+
"CreatedAt": "2026-05-12T08:00:00Z",
|
|
82
|
+
"UpdatedAt": "2026-05-12T08:00:00Z",
|
|
83
|
+
"Version": 1,
|
|
84
|
+
"Name": "thread-two",
|
|
85
|
+
}
|
|
86
|
+
current_marker = {
|
|
87
|
+
"PK": "USER#testuser",
|
|
88
|
+
"SK": "THREAD#CURRENT#META",
|
|
89
|
+
"CreatedAt": "2026-05-12T07:00:00Z",
|
|
90
|
+
"UpdatedAt": "2026-05-12T07:00:00Z",
|
|
91
|
+
"Version": 1,
|
|
92
|
+
"CurrentThreadId": "t1",
|
|
93
|
+
"ActiveAt": "2026-05-12T07:00:00Z",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def get_item_side_effect(Key):
|
|
97
|
+
if Key["SK"] == "THREAD#CURRENT#META":
|
|
98
|
+
return {"Item": current_marker}
|
|
99
|
+
if Key["SK"] == "THREAD#t2#META":
|
|
100
|
+
return {"Item": target_meta}
|
|
101
|
+
return {}
|
|
102
|
+
|
|
103
|
+
table.get_item.side_effect = get_item_side_effect
|
|
104
|
+
client._client.transact_write_items.return_value = {}
|
|
105
|
+
|
|
106
|
+
result = client.switch_thread("t2")
|
|
107
|
+
|
|
108
|
+
assert result["thread_id"] == "t2"
|
|
109
|
+
client._client.transact_write_items.assert_called_once()
|
|
110
|
+
|
|
111
|
+
transact_items = client._client.transact_write_items.call_args.kwargs["TransactItems"]
|
|
112
|
+
sks = [item["Put"]["Item"]["SK"]["S"] for item in transact_items]
|
|
113
|
+
assert "THREAD#CURRENT#META" in sks
|
|
114
|
+
assert "THREAD#t2#META" in sks
|
|
115
|
+
assert len(sks) >= 2 # marker + target, previous meta optional depending on current state
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_dynamo_client_switch_thread_transaction_uses_metric_shape():
|
|
119
|
+
client, table = make_client()
|
|
120
|
+
target_meta = {
|
|
121
|
+
"PK": "USER#testuser",
|
|
122
|
+
"SK": "THREAD#t2#META",
|
|
123
|
+
"CreatedAt": "2026-05-12T08:00:00Z",
|
|
124
|
+
"UpdatedAt": "2026-05-12T08:00:00Z",
|
|
125
|
+
"Version": 1,
|
|
126
|
+
"Name": "thread-two",
|
|
127
|
+
}
|
|
128
|
+
current_marker = {
|
|
129
|
+
"PK": "USER#testuser",
|
|
130
|
+
"SK": "THREAD#CURRENT#META",
|
|
131
|
+
"CreatedAt": "2026-05-12T07:00:00Z",
|
|
132
|
+
"UpdatedAt": "2026-05-12T07:00:00Z",
|
|
133
|
+
"Version": 1,
|
|
134
|
+
"CurrentThreadId": "t1",
|
|
135
|
+
"ActiveAt": "2026-05-12T07:00:00Z",
|
|
136
|
+
}
|
|
137
|
+
previous_meta = {
|
|
138
|
+
"PK": "USER#testuser",
|
|
139
|
+
"SK": "THREAD#t1#META",
|
|
140
|
+
"CreatedAt": "2026-05-12T06:00:00Z",
|
|
141
|
+
"UpdatedAt": "2026-05-12T06:00:00Z",
|
|
142
|
+
"Version": 2,
|
|
143
|
+
"Name": "thread-one",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def get_item_side_effect(Key):
|
|
147
|
+
if Key["SK"] == "THREAD#CURRENT#META":
|
|
148
|
+
return {"Item": current_marker}
|
|
149
|
+
if Key["SK"] == "THREAD#t2#META":
|
|
150
|
+
return {"Item": target_meta}
|
|
151
|
+
if Key["SK"] == "THREAD#t1#META":
|
|
152
|
+
return {"Item": previous_meta}
|
|
153
|
+
return {}
|
|
154
|
+
|
|
155
|
+
table.get_item.side_effect = get_item_side_effect
|
|
156
|
+
client._client.transact_write_items.return_value = {}
|
|
157
|
+
|
|
158
|
+
client.switch_thread("t2")
|
|
159
|
+
|
|
160
|
+
transact_items = client._client.transact_write_items.call_args.kwargs["TransactItems"]
|
|
161
|
+
assert any(item["Put"]["Item"]["SK"]["S"] == "THREAD#t1#META" for item in transact_items)
|
|
162
|
+
assert any(item["Put"]["Item"]["SK"]["S"] == "THREAD#CURRENT#META" for item in transact_items)
|
|
163
|
+
assert any(item["Put"]["Item"]["SK"]["S"] == "THREAD#t2#META" for item in transact_items)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 2.9: thread management slash-command skill files
|
|
3
|
+
Run: pytest server/tools/tests/test_thread_command_skills.py -v
|
|
4
|
+
"""
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
|
|
8
|
+
SKILLS_DIR = PLUGIN_DIR / "skills"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
COMMANDS = {
|
|
12
|
+
"new-thread": ["new_thread", "prompt", "thread name", "Created thread", "recovery"],
|
|
13
|
+
"switch-thread": ["list_threads", "switch_thread", "selection", "Switched", "recovery"],
|
|
14
|
+
"kill-thread": ["kill_thread", "Summarization in progress", "Killing", "recovery"],
|
|
15
|
+
"list-threads": ["list_threads", "Active", "message_count", "recovery"],
|
|
16
|
+
"recall": ["recall_memory", "query", "results", "recovery"],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _read_skill(command: str) -> str:
|
|
21
|
+
return (SKILLS_DIR / command / "SKILL.md").read_text()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_all_thread_command_skill_files_exist():
|
|
25
|
+
for command in COMMANDS:
|
|
26
|
+
path = SKILLS_DIR / command / "SKILL.md"
|
|
27
|
+
assert path.exists(), f"Missing {path}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_skill_files_have_frontmatter_name_and_description():
|
|
31
|
+
for command in COMMANDS:
|
|
32
|
+
text = _read_skill(command)
|
|
33
|
+
assert text.startswith("---\n")
|
|
34
|
+
assert f"name: {command}" in text
|
|
35
|
+
assert "description:" in text
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_skill_files_reference_required_tools_and_confirmations():
|
|
39
|
+
for command, required_phrases in COMMANDS.items():
|
|
40
|
+
text = _read_skill(command)
|
|
41
|
+
for phrase in required_phrases:
|
|
42
|
+
assert phrase in text, f"{command} missing {phrase!r}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_switch_thread_requires_listing_before_switching():
|
|
46
|
+
text = _read_skill("switch-thread")
|
|
47
|
+
assert text.index("list_threads") < text.index("switch_thread")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_error_handling_mentions_failed_operation_and_next_steps():
|
|
51
|
+
for command in COMMANDS:
|
|
52
|
+
text = _read_skill(command).lower()
|
|
53
|
+
assert "failed operation" in text
|
|
54
|
+
assert "recovery steps" in text
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 2.10: fuzzy thread name matching
|
|
3
|
+
Run: pytest server/tools/tests/test_thread_resolver.py -v
|
|
4
|
+
"""
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from mcp.shared.exceptions import McpError
|
|
11
|
+
|
|
12
|
+
PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
|
|
13
|
+
sys.path.insert(0, str(PLUGIN_DIR))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
THREADS = [
|
|
17
|
+
{
|
|
18
|
+
"thread_id": "gemma-v2",
|
|
19
|
+
"name": "Gemma Fine-Tuning v2",
|
|
20
|
+
"status": "active",
|
|
21
|
+
"last_activity": "2026-05-12T10:00:00Z",
|
|
22
|
+
"message_count": 5,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"thread_id": "eval",
|
|
26
|
+
"name": "Gemma Eval Run",
|
|
27
|
+
"status": "active",
|
|
28
|
+
"last_activity": "2026-05-11T10:00:00Z",
|
|
29
|
+
"message_count": 2,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"thread_id": "abc",
|
|
33
|
+
"name": "ABC",
|
|
34
|
+
"status": "active",
|
|
35
|
+
"last_activity": "2026-05-10T10:00:00Z",
|
|
36
|
+
"message_count": 1,
|
|
37
|
+
},
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_resolve_thread_name_single_match_uses_extract_with_cutoff():
|
|
42
|
+
from server.tools.thread_resolver import resolve_thread_name
|
|
43
|
+
|
|
44
|
+
with patch("server.tools.thread_resolver.process.extract") as extract:
|
|
45
|
+
extract.return_value = [("Gemma Fine-Tuning v2", 90, 0)]
|
|
46
|
+
result = resolve_thread_name("Gemma Fine", THREADS)
|
|
47
|
+
|
|
48
|
+
extract.assert_called_once()
|
|
49
|
+
assert extract.call_args.kwargs["score_cutoff"] == 70
|
|
50
|
+
assert result == {
|
|
51
|
+
"status": "resolved",
|
|
52
|
+
"thread_id": "gemma-v2",
|
|
53
|
+
"name": "Gemma Fine-Tuning v2",
|
|
54
|
+
"message": "Switching to thread: Gemma Fine-Tuning v2",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_resolve_thread_name_ambiguous_matches_returns_choices():
|
|
59
|
+
from server.tools.thread_resolver import resolve_thread_name
|
|
60
|
+
|
|
61
|
+
result = resolve_thread_name("Gemma", THREADS)
|
|
62
|
+
|
|
63
|
+
assert result["status"] == "ambiguous"
|
|
64
|
+
assert result["message"] == "Did you mean: [1] Gemma Fine-Tuning v2, [2] Gemma Eval Run?"
|
|
65
|
+
assert [match["thread_id"] for match in result["matches"]] == ["gemma-v2", "eval"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_resolve_thread_name_no_match_raises_invalid_request_mcp_error():
|
|
69
|
+
from server.tools.thread_resolver import resolve_thread_name
|
|
70
|
+
|
|
71
|
+
with pytest.raises(McpError) as exc_info:
|
|
72
|
+
resolve_thread_name("Nope", THREADS)
|
|
73
|
+
|
|
74
|
+
error = exc_info.value.error
|
|
75
|
+
assert error.code == -32600
|
|
76
|
+
assert "No thread found matching 'Nope'" in error.message
|
|
77
|
+
assert "run list_threads" in error.message
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_resolve_thread_name_three_character_thread_names_are_matchable():
|
|
81
|
+
from server.tools.thread_resolver import resolve_thread_name
|
|
82
|
+
|
|
83
|
+
result = resolve_thread_name("ABC", THREADS)
|
|
84
|
+
|
|
85
|
+
assert result["status"] == "resolved"
|
|
86
|
+
assert result["thread_id"] == "abc"
|
|
87
|
+
assert result["name"] == "ABC"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_switch_thread_by_name_passes_resolved_thread_id_to_switch_thread():
|
|
91
|
+
import server.tools.tylor as tylor_mod
|
|
92
|
+
|
|
93
|
+
mock_db = MagicMock()
|
|
94
|
+
mock_db.query_all.return_value = [
|
|
95
|
+
{
|
|
96
|
+
"SK": "THREAD#gemma-v2#META",
|
|
97
|
+
"Name": "Gemma Fine-Tuning v2",
|
|
98
|
+
"Status": "active",
|
|
99
|
+
"LastActivity": "2026-05-12T10:00:00Z",
|
|
100
|
+
"MessageCount": 5,
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
mock_db.switch_thread.return_value = {
|
|
104
|
+
"thread_id": "gemma-v2",
|
|
105
|
+
"status": "switched",
|
|
106
|
+
"switched_at": "2026-05-12T10:01:00Z",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
with patch.object(tylor_mod, "_get_db", return_value=mock_db):
|
|
110
|
+
result = tylor_mod.switch_thread_by_name("Gemma")
|
|
111
|
+
|
|
112
|
+
mock_db.switch_thread.assert_called_once_with("gemma-v2")
|
|
113
|
+
assert result["thread_id"] == "gemma-v2"
|
|
114
|
+
assert result["name"] == "Gemma Fine-Tuning v2"
|
|
115
|
+
assert result["message"] == "Switching to thread: Gemma Fine-Tuning v2"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_switch_thread_by_name_ambiguous_match_does_not_switch():
|
|
119
|
+
import server.tools.tylor as tylor_mod
|
|
120
|
+
|
|
121
|
+
mock_db = MagicMock()
|
|
122
|
+
mock_db.query_all.return_value = [
|
|
123
|
+
{
|
|
124
|
+
"SK": "THREAD#gemma-v2#META",
|
|
125
|
+
"Name": "Gemma Fine-Tuning v2",
|
|
126
|
+
"Status": "active",
|
|
127
|
+
"LastActivity": "2026-05-12T10:00:00Z",
|
|
128
|
+
"MessageCount": 5,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"SK": "THREAD#eval#META",
|
|
132
|
+
"Name": "Gemma Eval Run",
|
|
133
|
+
"Status": "active",
|
|
134
|
+
"LastActivity": "2026-05-11T10:00:00Z",
|
|
135
|
+
"MessageCount": 2,
|
|
136
|
+
},
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
with patch.object(tylor_mod, "_get_db", return_value=mock_db):
|
|
140
|
+
result = tylor_mod.switch_thread_by_name("Gemma")
|
|
141
|
+
|
|
142
|
+
mock_db.switch_thread.assert_not_called()
|
|
143
|
+
assert result["status"] == "ambiguous"
|
|
144
|
+
assert "Did you mean" in result["message"]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_switch_thread_by_name_normalizes_unexpected_switch_failures():
|
|
148
|
+
import server.tools.tylor as tylor_mod
|
|
149
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
150
|
+
|
|
151
|
+
mock_db = MagicMock()
|
|
152
|
+
mock_db.query_all.return_value = [
|
|
153
|
+
{
|
|
154
|
+
"SK": "THREAD#gemma-v2#META",
|
|
155
|
+
"Name": "Gemma Fine-Tuning v2",
|
|
156
|
+
"Status": "active",
|
|
157
|
+
"LastActivity": "2026-05-12T10:00:00Z",
|
|
158
|
+
"MessageCount": 5,
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
mock_db.switch_thread.side_effect = RuntimeError("dynamo exploded")
|
|
162
|
+
|
|
163
|
+
with patch.object(tylor_mod, "_get_db", return_value=mock_db):
|
|
164
|
+
with pytest.raises(ToolError, match="SwThread failed"):
|
|
165
|
+
tylor_mod.switch_thread_by_name("Gemma")
|