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,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 3.1: persona definition files
|
|
3
|
+
Run: pytest server/tools/tests/test_personas.py -v
|
|
4
|
+
"""
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
|
|
8
|
+
PERSONAS_DIR = PLUGIN_DIR / "server" / "personas"
|
|
9
|
+
|
|
10
|
+
EXPECTED_PERSONAS = {
|
|
11
|
+
"code_agent.md": {"ecc/web", "ecc/data", "ecc/pipeline"},
|
|
12
|
+
"analyst.md": {"ecc/web", "ecc/data", "ecc/diagrams"},
|
|
13
|
+
"ceo.md": {"ecc/presentation", "ecc/web"},
|
|
14
|
+
"cto.md": {"ecc/diagrams", "ecc/pipeline"},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read_persona(filename: str) -> str:
|
|
19
|
+
return (PERSONAS_DIR / filename).read_text()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_required_persona_files_exist():
|
|
23
|
+
for filename in EXPECTED_PERSONAS:
|
|
24
|
+
assert (PERSONAS_DIR / filename).exists(), f"Missing persona {filename}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_each_persona_has_required_sections():
|
|
28
|
+
for filename in EXPECTED_PERSONAS:
|
|
29
|
+
text = _read_persona(filename)
|
|
30
|
+
assert "# " in text
|
|
31
|
+
assert "## Role Description" in text
|
|
32
|
+
assert "## Communication Style" in text
|
|
33
|
+
assert "## ECC Tool Categories" in text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_each_persona_declares_expected_ecc_categories():
|
|
37
|
+
for filename, expected_categories in EXPECTED_PERSONAS.items():
|
|
38
|
+
text = _read_persona(filename)
|
|
39
|
+
for category in expected_categories:
|
|
40
|
+
assert f"- `{category}`" in text, f"{filename} missing {category}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_each_persona_has_no_unexpected_ecc_categories():
|
|
44
|
+
allowed = set().union(*EXPECTED_PERSONAS.values())
|
|
45
|
+
for filename in EXPECTED_PERSONAS:
|
|
46
|
+
text = _read_persona(filename)
|
|
47
|
+
declared = {
|
|
48
|
+
line.strip().removeprefix("- `").removesuffix("`")
|
|
49
|
+
for line in text.splitlines()
|
|
50
|
+
if line.strip().startswith("- `ecc/")
|
|
51
|
+
}
|
|
52
|
+
assert declared <= allowed
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 2.5: recall_memory tool.
|
|
3
|
+
Run: pytest server/tools/tests/test_recall_memory.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.tools.tylor import recall_memory
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_recall_memory_calls_search_memory_with_top_k():
|
|
19
|
+
mock_client = MagicMock()
|
|
20
|
+
mock_client.search_memory.return_value = [{"id": "doc1"}]
|
|
21
|
+
|
|
22
|
+
with patch("server.tools.tylor._get_memory_client", return_value=mock_client):
|
|
23
|
+
result = recall_memory(thread_id="t1", query="find relevant facts", top_k=3)
|
|
24
|
+
|
|
25
|
+
mock_client.search_memory.assert_called_once_with(
|
|
26
|
+
thread_id="t1",
|
|
27
|
+
query="find relevant facts",
|
|
28
|
+
k=3,
|
|
29
|
+
type=None,
|
|
30
|
+
)
|
|
31
|
+
assert result == {"results": [{"id": "doc1"}]}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_recall_memory_returns_empty_list_when_no_results():
|
|
35
|
+
mock_client = MagicMock()
|
|
36
|
+
mock_client.search_memory.return_value = []
|
|
37
|
+
|
|
38
|
+
with patch("server.tools.tylor._get_memory_client", return_value=mock_client):
|
|
39
|
+
result = recall_memory(thread_id="t1", query="missing facts")
|
|
40
|
+
|
|
41
|
+
assert result == {"results": []}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_recall_memory_raises_on_empty_query():
|
|
45
|
+
with pytest.raises(ToolError, match="Query must not be empty"):
|
|
46
|
+
recall_memory(thread_id="t1", query=" ")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_recall_memory_propagates_opensearch_errors():
|
|
50
|
+
mock_client = MagicMock()
|
|
51
|
+
mock_client.search_memory.side_effect = ToolError("search failed")
|
|
52
|
+
|
|
53
|
+
with patch("server.tools.tylor._get_memory_client", return_value=mock_client):
|
|
54
|
+
with pytest.raises(ToolError, match="search failed"):
|
|
55
|
+
recall_memory(thread_id="t1", query="search")
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 4.2: two-tier manifest and skill registry client.
|
|
3
|
+
Run: pytest server/tools/tests/test_registry_client.py -v
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _write_registry(path: Path, skills: list[dict]) -> None:
|
|
15
|
+
path.write_text(
|
|
16
|
+
json.dumps({"version": "1.0", "skills": skills}, indent=2),
|
|
17
|
+
encoding="utf-8",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_list_registry_returns_lightweight_skill_index(tmp_path):
|
|
22
|
+
from server.tools import registry as registry_mod
|
|
23
|
+
|
|
24
|
+
registry_path = tmp_path / "registry.json"
|
|
25
|
+
_write_registry(
|
|
26
|
+
registry_path,
|
|
27
|
+
[
|
|
28
|
+
{
|
|
29
|
+
"name": "bmad",
|
|
30
|
+
"trigger_description": "BMad story workflows",
|
|
31
|
+
"keywords": ["story", "review"],
|
|
32
|
+
"tool_count": 2,
|
|
33
|
+
"installed_date": "2026-05-13",
|
|
34
|
+
"module": "server.tools.ecc.web",
|
|
35
|
+
"tools": ["web_fetch", "web_scrape"],
|
|
36
|
+
"schemas": [{"name": "too-heavy"}],
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
with patch.object(registry_mod, "REGISTRY_PATH", registry_path):
|
|
42
|
+
result = registry_mod.list_registry()
|
|
43
|
+
|
|
44
|
+
assert result == {
|
|
45
|
+
"skills": [
|
|
46
|
+
{
|
|
47
|
+
"name": "bmad",
|
|
48
|
+
"trigger_description": "BMad story workflows",
|
|
49
|
+
"keywords": ["story", "review"],
|
|
50
|
+
"tool_count": 2,
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
assert "schemas" not in result["skills"][0]
|
|
55
|
+
assert "module" not in result["skills"][0]
|
|
56
|
+
assert "tools" not in result["skills"][0]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_load_skill_tools_loads_registry_backed_group_without_restart(tmp_path):
|
|
60
|
+
from server.tools import registry as registry_mod
|
|
61
|
+
from server.tools._mcp import mcp
|
|
62
|
+
|
|
63
|
+
registry_path = tmp_path / "registry.json"
|
|
64
|
+
_write_registry(
|
|
65
|
+
registry_path,
|
|
66
|
+
[
|
|
67
|
+
{
|
|
68
|
+
"name": "bmad",
|
|
69
|
+
"trigger_description": "BMad story workflows",
|
|
70
|
+
"keywords": ["story"],
|
|
71
|
+
"tool_count": 2,
|
|
72
|
+
"module": "server.tools.ecc.web",
|
|
73
|
+
"tools": ["web_fetch", "web_scrape"],
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
with patch.object(registry_mod, "REGISTRY_PATH", registry_path):
|
|
79
|
+
result = registry_mod.load_skill_tools("bmad")
|
|
80
|
+
|
|
81
|
+
assert result == {
|
|
82
|
+
"tool_group": "bmad",
|
|
83
|
+
"status": "loaded",
|
|
84
|
+
"tools": ["web_fetch", "web_scrape"],
|
|
85
|
+
}
|
|
86
|
+
tool_names = {tool.name for tool in __import__("asyncio").run(mcp.list_tools())}
|
|
87
|
+
assert {"web_fetch", "web_scrape"} <= tool_names
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_startup_manifest_is_tier1_only_and_lists_under_100ms():
|
|
91
|
+
code = """
|
|
92
|
+
import asyncio, json, time
|
|
93
|
+
import server.main
|
|
94
|
+
from server.tools._mcp import mcp
|
|
95
|
+
start = time.perf_counter()
|
|
96
|
+
tools = asyncio.run(mcp.list_tools())
|
|
97
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
98
|
+
print(json.dumps({"elapsed_ms": elapsed_ms, "tools": sorted(t.name for t in tools)}))
|
|
99
|
+
"""
|
|
100
|
+
completed = subprocess.run(
|
|
101
|
+
[sys.executable, "-c", code],
|
|
102
|
+
cwd=PLUGIN_DIR,
|
|
103
|
+
check=True,
|
|
104
|
+
capture_output=True,
|
|
105
|
+
text=True,
|
|
106
|
+
)
|
|
107
|
+
payload = json.loads(completed.stdout)
|
|
108
|
+
|
|
109
|
+
tier2_tools = {
|
|
110
|
+
"web_scrape",
|
|
111
|
+
"web_fetch",
|
|
112
|
+
"dataset_manager",
|
|
113
|
+
"data_clean",
|
|
114
|
+
"data_transform",
|
|
115
|
+
"build_pptx",
|
|
116
|
+
"build_doc",
|
|
117
|
+
"diagram_gen",
|
|
118
|
+
"flowchart_gen",
|
|
119
|
+
"pipeline_builder",
|
|
120
|
+
"pipeline_run",
|
|
121
|
+
}
|
|
122
|
+
assert payload["elapsed_ms"] < 100
|
|
123
|
+
assert tier2_tools.isdisjoint(payload["tools"])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_detect_registry_skill_match_surfaces_agent101_suggestion(tmp_path):
|
|
127
|
+
from server.tools import registry as registry_mod
|
|
128
|
+
|
|
129
|
+
registry_path = tmp_path / "registry.json"
|
|
130
|
+
_write_registry(
|
|
131
|
+
registry_path,
|
|
132
|
+
[
|
|
133
|
+
{
|
|
134
|
+
"name": "bmad",
|
|
135
|
+
"trigger_description": "Use when user wants to create a PRD or draft requirements.",
|
|
136
|
+
"keywords": ["prd", "requirements"],
|
|
137
|
+
"tool_count": 2,
|
|
138
|
+
"module": "server.tools.ecc.web",
|
|
139
|
+
"tools": ["web_fetch", "web_scrape"],
|
|
140
|
+
}
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
with patch.object(registry_mod, "REGISTRY_PATH", registry_path):
|
|
145
|
+
result = registry_mod.detect_registry_skill("let's draft a PRD for this idea")
|
|
146
|
+
|
|
147
|
+
assert result == {
|
|
148
|
+
"matched": True,
|
|
149
|
+
"skill": "bmad",
|
|
150
|
+
"action": "suggest",
|
|
151
|
+
"message": "You have BMAD in agent101 — want me to use it?",
|
|
152
|
+
"thread_persistence": True,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_auto_load_matching_registry_skill_loads_tools(tmp_path):
|
|
157
|
+
from server.tools import registry as registry_mod
|
|
158
|
+
|
|
159
|
+
registry_path = tmp_path / "registry.json"
|
|
160
|
+
_write_registry(
|
|
161
|
+
registry_path,
|
|
162
|
+
[
|
|
163
|
+
{
|
|
164
|
+
"name": "bmad",
|
|
165
|
+
"trigger_description": "Use when user wants to create a PRD.",
|
|
166
|
+
"keywords": ["prd"],
|
|
167
|
+
"tool_count": 2,
|
|
168
|
+
"module": "server.tools.ecc.web",
|
|
169
|
+
"tools": ["web_fetch", "web_scrape"],
|
|
170
|
+
}
|
|
171
|
+
],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
with patch.object(registry_mod, "REGISTRY_PATH", registry_path):
|
|
175
|
+
result = registry_mod.detect_registry_skill(
|
|
176
|
+
"let's draft a PRD for this idea",
|
|
177
|
+
auto_load=True,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
assert result["matched"] is True
|
|
181
|
+
assert result["action"] == "loaded"
|
|
182
|
+
assert result["skill"] == "bmad"
|
|
183
|
+
assert result["loaded"]["tools"] == ["web_fetch", "web_scrape"]
|
|
184
|
+
assert result["thread_persistence"] is True
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_auto_load_matching_registry_skill_without_tools_falls_back_to_suggestion(tmp_path):
|
|
188
|
+
from server.tools import registry as registry_mod
|
|
189
|
+
|
|
190
|
+
registry_path = tmp_path / "registry.json"
|
|
191
|
+
_write_registry(
|
|
192
|
+
registry_path,
|
|
193
|
+
[
|
|
194
|
+
{
|
|
195
|
+
"name": "bmad",
|
|
196
|
+
"trigger_description": "Use when user wants to create a PRD.",
|
|
197
|
+
"keywords": ["prd"],
|
|
198
|
+
"tool_count": 1,
|
|
199
|
+
"source_path": "/local/skills/bmad",
|
|
200
|
+
}
|
|
201
|
+
],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
with patch.object(registry_mod, "REGISTRY_PATH", registry_path), patch(
|
|
205
|
+
"server.tools.registry.importlib.import_module"
|
|
206
|
+
) as import_module:
|
|
207
|
+
result = registry_mod.detect_registry_skill(
|
|
208
|
+
"let's draft a PRD for this idea",
|
|
209
|
+
auto_load=True,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
assert result == {
|
|
213
|
+
"matched": True,
|
|
214
|
+
"skill": "bmad",
|
|
215
|
+
"action": "suggest",
|
|
216
|
+
"message": "You have BMAD in agent101 — want me to use it?",
|
|
217
|
+
"thread_persistence": True,
|
|
218
|
+
}
|
|
219
|
+
import_module.assert_not_called()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_non_matching_registry_skill_does_not_load_schemas(tmp_path):
|
|
223
|
+
from server.tools import registry as registry_mod
|
|
224
|
+
|
|
225
|
+
registry_path = tmp_path / "registry.json"
|
|
226
|
+
_write_registry(
|
|
227
|
+
registry_path,
|
|
228
|
+
[
|
|
229
|
+
{
|
|
230
|
+
"name": "bmad",
|
|
231
|
+
"trigger_description": "Use when user wants to create a PRD.",
|
|
232
|
+
"keywords": ["prd"],
|
|
233
|
+
"tool_count": 2,
|
|
234
|
+
"module": "server.tools.ecc.web",
|
|
235
|
+
"tools": ["web_fetch", "web_scrape"],
|
|
236
|
+
}
|
|
237
|
+
],
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
with patch.object(registry_mod, "REGISTRY_PATH", registry_path), patch(
|
|
241
|
+
"server.tools.registry.importlib.import_module"
|
|
242
|
+
) as import_module:
|
|
243
|
+
result = registry_mod.detect_registry_skill("show my active threads", auto_load=True)
|
|
244
|
+
|
|
245
|
+
assert result == {"matched": False, "action": "none"}
|
|
246
|
+
import_module.assert_not_called()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_generic_trigger_words_do_not_match_registry_skill(tmp_path):
|
|
250
|
+
from server.tools import registry as registry_mod
|
|
251
|
+
|
|
252
|
+
registry_path = tmp_path / "registry.json"
|
|
253
|
+
_write_registry(
|
|
254
|
+
registry_path,
|
|
255
|
+
[
|
|
256
|
+
{
|
|
257
|
+
"name": "bmad",
|
|
258
|
+
"trigger_description": "Use when user wants to create a PRD.",
|
|
259
|
+
"keywords": ["prd"],
|
|
260
|
+
"tool_count": 2,
|
|
261
|
+
"module": "server.tools.ecc.web",
|
|
262
|
+
"tools": ["web_fetch", "web_scrape"],
|
|
263
|
+
}
|
|
264
|
+
],
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
with patch.object(registry_mod, "REGISTRY_PATH", registry_path), patch(
|
|
268
|
+
"server.tools.registry.importlib.import_module"
|
|
269
|
+
) as import_module:
|
|
270
|
+
result = registry_mod.detect_registry_skill(
|
|
271
|
+
"create a new thread for billing",
|
|
272
|
+
auto_load=True,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
assert result == {"matched": False, "action": "none"}
|
|
276
|
+
import_module.assert_not_called()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_explicit_native_slash_skill_is_not_thread_persistent(tmp_path):
|
|
280
|
+
from server.tools import registry as registry_mod
|
|
281
|
+
|
|
282
|
+
registry_path = tmp_path / "registry.json"
|
|
283
|
+
_write_registry(
|
|
284
|
+
registry_path,
|
|
285
|
+
[
|
|
286
|
+
{
|
|
287
|
+
"name": "bmad",
|
|
288
|
+
"trigger_description": "Use when user wants to create a PRD.",
|
|
289
|
+
"keywords": ["prd"],
|
|
290
|
+
"tool_count": 2,
|
|
291
|
+
"module": "server.tools.ecc.web",
|
|
292
|
+
"tools": ["web_fetch", "web_scrape"],
|
|
293
|
+
}
|
|
294
|
+
],
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
with patch.object(registry_mod, "REGISTRY_PATH", registry_path), patch(
|
|
298
|
+
"server.tools.registry.importlib.import_module"
|
|
299
|
+
) as import_module:
|
|
300
|
+
result = registry_mod.detect_registry_skill("/bmad create a PRD")
|
|
301
|
+
|
|
302
|
+
assert result == {
|
|
303
|
+
"matched": True,
|
|
304
|
+
"skill": "bmad",
|
|
305
|
+
"action": "claude_native",
|
|
306
|
+
"thread_persistence": False,
|
|
307
|
+
}
|
|
308
|
+
import_module.assert_not_called()
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 2.6: Model Router 3-Tier Fallback
|
|
3
|
+
Run: pytest server/tools/tests/test_router.py -v
|
|
4
|
+
"""
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
from unittest.mock import 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
|
+
class FakeMessages:
|
|
17
|
+
def __init__(self, result=None, error=None):
|
|
18
|
+
self.result = result
|
|
19
|
+
self.error = error
|
|
20
|
+
self.calls = []
|
|
21
|
+
|
|
22
|
+
def create(self, **kwargs):
|
|
23
|
+
self.calls.append(kwargs)
|
|
24
|
+
if self.error:
|
|
25
|
+
raise self.error
|
|
26
|
+
return self.result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FakeClient:
|
|
30
|
+
def __init__(self, result=None, error=None):
|
|
31
|
+
self.messages = FakeMessages(result=result, error=error)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AnthropicStyleError(Exception):
|
|
35
|
+
def __init__(self, error_type: str, message: str = "boom"):
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.type = error_type
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_primary_success_returns_response_as_is_without_platform_route():
|
|
41
|
+
from server.tools.router import ModelRouter
|
|
42
|
+
|
|
43
|
+
response = object()
|
|
44
|
+
primary = FakeClient(result=response)
|
|
45
|
+
platform = FakeClient(result={"unused": True})
|
|
46
|
+
router = ModelRouter(primary_client=primary, platform_client=platform)
|
|
47
|
+
|
|
48
|
+
request = {
|
|
49
|
+
"model": "claude-sonnet-4-6",
|
|
50
|
+
"max_tokens": 128,
|
|
51
|
+
"messages": [{"role": "user", "content": "hello"}],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
assert router.create_message(**request) is response
|
|
55
|
+
assert primary.messages.calls == [request]
|
|
56
|
+
assert platform.messages.calls == []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_rate_limit_error_retries_identical_request_via_platform_route():
|
|
60
|
+
from server.tools.router import ModelRouter
|
|
61
|
+
|
|
62
|
+
primary_error = AnthropicStyleError("rate_limit_error", "primary overflow")
|
|
63
|
+
primary = FakeClient(error=primary_error)
|
|
64
|
+
platform_response = {"content": "fallback response"}
|
|
65
|
+
platform = FakeClient(result=platform_response)
|
|
66
|
+
router = ModelRouter(primary_client=primary, platform_client=platform)
|
|
67
|
+
|
|
68
|
+
request = {
|
|
69
|
+
"model": "claude-sonnet-4-6",
|
|
70
|
+
"max_tokens": 128,
|
|
71
|
+
"messages": [{"role": "user", "content": "same request"}],
|
|
72
|
+
"temperature": 0,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
assert router.create_message(**request) == platform_response
|
|
76
|
+
assert primary.messages.calls == [request]
|
|
77
|
+
assert platform.messages.calls == [request]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_context_length_exceeded_does_not_use_platform_route():
|
|
81
|
+
from server.tools.router import ModelRouter
|
|
82
|
+
|
|
83
|
+
primary_error = AnthropicStyleError("context_length_exceeded", "too long")
|
|
84
|
+
primary = FakeClient(error=primary_error)
|
|
85
|
+
platform = FakeClient(result={"unused": True})
|
|
86
|
+
router = ModelRouter(primary_client=primary, platform_client=platform)
|
|
87
|
+
|
|
88
|
+
with pytest.raises(AnthropicStyleError, match="too long"):
|
|
89
|
+
router.create_message(model="claude-sonnet-4-6", max_tokens=128, messages=[])
|
|
90
|
+
|
|
91
|
+
assert platform.messages.calls == []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_non_overflow_primary_error_does_not_use_platform_route():
|
|
95
|
+
from server.tools.router import ModelRouter
|
|
96
|
+
|
|
97
|
+
primary_error = AnthropicStyleError("authentication_error", "bad key")
|
|
98
|
+
primary = FakeClient(error=primary_error)
|
|
99
|
+
platform = FakeClient(result={"unused": True})
|
|
100
|
+
router = ModelRouter(primary_client=primary, platform_client=platform)
|
|
101
|
+
|
|
102
|
+
with pytest.raises(AnthropicStyleError, match="bad key"):
|
|
103
|
+
router.create_message(model="claude-sonnet-4-6", max_tokens=128, messages=[])
|
|
104
|
+
|
|
105
|
+
assert platform.messages.calls == []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_exhausted_routes_raise_mcp_internal_error():
|
|
109
|
+
from server.tools.router import ModelRouter
|
|
110
|
+
|
|
111
|
+
primary = FakeClient(error=AnthropicStyleError("rate_limit_error", "primary limit"))
|
|
112
|
+
platform = FakeClient(error=RuntimeError("platform unavailable"))
|
|
113
|
+
router = ModelRouter(primary_client=primary, platform_client=platform)
|
|
114
|
+
|
|
115
|
+
with pytest.raises(McpError) as exc_info:
|
|
116
|
+
router.create_message(model="claude-sonnet-4-6", max_tokens=128, messages=[])
|
|
117
|
+
|
|
118
|
+
error = exc_info.value.error
|
|
119
|
+
assert error.code == -32603
|
|
120
|
+
assert error.message == "All model routes exhausted"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_overflow_with_null_platform_key_raises_mcp_error_immediately(monkeypatch):
|
|
124
|
+
import server.config as server_config
|
|
125
|
+
from server.tools.router import ModelRouter
|
|
126
|
+
|
|
127
|
+
monkeypatch.setattr(server_config, "config", {"platform_key": None})
|
|
128
|
+
|
|
129
|
+
primary = FakeClient(error=AnthropicStyleError("rate_limit_error", "rate limited"))
|
|
130
|
+
router = ModelRouter(primary_client=primary, platform_client=None)
|
|
131
|
+
|
|
132
|
+
with pytest.raises(McpError) as exc_info:
|
|
133
|
+
router.create_message(model="claude-sonnet-4-6", max_tokens=128, messages=[])
|
|
134
|
+
|
|
135
|
+
error = exc_info.value.error
|
|
136
|
+
assert error.code == -32603
|
|
137
|
+
assert error.message == "All model routes exhausted"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_platform_client_cached_after_first_overflow(monkeypatch):
|
|
141
|
+
import server.config as server_config
|
|
142
|
+
from server.tools.router import ModelRouter
|
|
143
|
+
|
|
144
|
+
monkeypatch.setattr(
|
|
145
|
+
server_config,
|
|
146
|
+
"config",
|
|
147
|
+
{
|
|
148
|
+
"platform_key": "test-key",
|
|
149
|
+
"platform_base_url": "https://aws-external-anthropic.us-east-1.api.aws",
|
|
150
|
+
"platform_workspace_id": None,
|
|
151
|
+
"bedrock_region": "us-east-1",
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
platform_response = {"content": "fallback"}
|
|
156
|
+
primary = FakeClient(error=AnthropicStyleError("rate_limit_error", "rate limited"))
|
|
157
|
+
router = ModelRouter(primary_client=primary, platform_client=None)
|
|
158
|
+
|
|
159
|
+
with patch("anthropic.Anthropic") as anthropic_cls:
|
|
160
|
+
mock_client = anthropic_cls.return_value
|
|
161
|
+
mock_client.messages.create.return_value = platform_response
|
|
162
|
+
router.create_message(model="claude-sonnet-4-6", max_tokens=128, messages=[])
|
|
163
|
+
|
|
164
|
+
assert router.platform_client is mock_client
|
|
165
|
+
assert anthropic_cls.call_count == 1
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_platform_base_url_uses_regional_aws_endpoint():
|
|
169
|
+
from server.tools.router import build_platform_base_url
|
|
170
|
+
|
|
171
|
+
assert (
|
|
172
|
+
build_platform_base_url("us-east-1")
|
|
173
|
+
== "https://aws-external-anthropic.us-east-1.api.aws"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_build_platform_client_uses_key_base_url_and_workspace_header(monkeypatch):
|
|
178
|
+
import server.config as server_config
|
|
179
|
+
from server.tools.router import build_platform_client
|
|
180
|
+
|
|
181
|
+
monkeypatch.setattr(
|
|
182
|
+
server_config,
|
|
183
|
+
"config",
|
|
184
|
+
{
|
|
185
|
+
"platform_key": "test-key",
|
|
186
|
+
"platform_base_url": "https://aws-external-anthropic.us-east-1.api.aws",
|
|
187
|
+
"platform_workspace_id": "wrkspc_test",
|
|
188
|
+
"bedrock_region": "us-east-1",
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
with patch("anthropic.Anthropic") as anthropic_cls:
|
|
193
|
+
build_platform_client()
|
|
194
|
+
|
|
195
|
+
anthropic_cls.assert_called_once_with(
|
|
196
|
+
api_key="test-key",
|
|
197
|
+
base_url="https://aws-external-anthropic.us-east-1.api.aws",
|
|
198
|
+
default_headers={"anthropic-workspace-id": "wrkspc_test"},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_build_platform_client_no_workspace_id_sends_no_header(monkeypatch):
|
|
203
|
+
import server.config as server_config
|
|
204
|
+
from server.tools.router import build_platform_client
|
|
205
|
+
|
|
206
|
+
monkeypatch.setattr(
|
|
207
|
+
server_config,
|
|
208
|
+
"config",
|
|
209
|
+
{
|
|
210
|
+
"platform_key": "test-key",
|
|
211
|
+
"platform_base_url": "https://aws-external-anthropic.us-east-1.api.aws",
|
|
212
|
+
"platform_workspace_id": None,
|
|
213
|
+
"bedrock_region": "us-east-1",
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
with patch("anthropic.Anthropic") as anthropic_cls:
|
|
218
|
+
build_platform_client()
|
|
219
|
+
|
|
220
|
+
anthropic_cls.assert_called_once_with(
|
|
221
|
+
api_key="test-key",
|
|
222
|
+
base_url="https://aws-external-anthropic.us-east-1.api.aws",
|
|
223
|
+
default_headers=None,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_build_platform_client_raises_when_platform_key_missing(monkeypatch):
|
|
228
|
+
import server.config as server_config
|
|
229
|
+
from server.tools.router import build_platform_client
|
|
230
|
+
|
|
231
|
+
monkeypatch.setattr(server_config, "config", {"platform_key": None})
|
|
232
|
+
|
|
233
|
+
with pytest.raises(RuntimeError, match="ANTHROPIC_PLATFORM_AWS_API_KEY not configured"):
|
|
234
|
+
build_platform_client()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_error_type_string_matching_fallback():
|
|
238
|
+
from server.tools.router import _error_type
|
|
239
|
+
|
|
240
|
+
class PlainError(Exception):
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
assert _error_type(PlainError("rate_limit_error in response")) == "rate_limit_error"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_error_type_returns_empty_string_for_unknown_error():
|
|
247
|
+
from server.tools.router import _error_type
|
|
248
|
+
|
|
249
|
+
class PlainError(Exception):
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
assert _error_type(PlainError("something completely different")) == ""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_create_message_convenience_wrapper_success():
|
|
256
|
+
from server.tools.router import create_message
|
|
257
|
+
|
|
258
|
+
response = object()
|
|
259
|
+
primary = FakeClient(result=response)
|
|
260
|
+
request = {"model": "claude-sonnet-4-6", "max_tokens": 128, "messages": []}
|
|
261
|
+
|
|
262
|
+
assert create_message(primary, **request) is response
|
|
263
|
+
assert primary.messages.calls == [request]
|