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.
Files changed (101) hide show
  1. package/.aws-setup.sh +25 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.mcp.json +12 -0
  4. package/AGENTS.md +93 -0
  5. package/CLAUDE.md +99 -0
  6. package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
  7. package/LICENSE +21 -0
  8. package/README.md +146 -0
  9. package/assets/tylor_logo.png +0 -0
  10. package/assets/tylor_threads_concept.png +0 -0
  11. package/bin/tylor.js +23 -0
  12. package/hooks/kill-thread-trigger.sh +7 -0
  13. package/hooks/post-tool-use-code-index.sh +7 -0
  14. package/hooks/session-checkpoint.sh +7 -0
  15. package/hooks/session-start.sh +7 -0
  16. package/install.py +401 -0
  17. package/install.sh +260 -0
  18. package/package.json +24 -0
  19. package/pytest.ini +2 -0
  20. package/registry.json +26 -0
  21. package/server/.env.example +24 -0
  22. package/server/__init__.py +0 -0
  23. package/server/config.py +89 -0
  24. package/server/main.py +93 -0
  25. package/server/personas/analyst.md +15 -0
  26. package/server/personas/ceo.md +14 -0
  27. package/server/personas/code_agent.md +15 -0
  28. package/server/personas/cto.md +14 -0
  29. package/server/provision.py +260 -0
  30. package/server/provision_opensearch.py +154 -0
  31. package/server/requirements.txt +26 -0
  32. package/server/storage/__init__.py +0 -0
  33. package/server/storage/dynamo.py +399 -0
  34. package/server/storage/json_store.py +359 -0
  35. package/server/storage/opensearch.py +194 -0
  36. package/server/storage/s3.py +96 -0
  37. package/server/storage/tests/__init__.py +0 -0
  38. package/server/storage/tests/test_dynamo.py +452 -0
  39. package/server/storage/tests/test_json_store.py +226 -0
  40. package/server/storage/tests/test_opensearch.py +270 -0
  41. package/server/storage/tests/test_s3.py +125 -0
  42. package/server/tests/__init__.py +0 -0
  43. package/server/tests/test_install.py +606 -0
  44. package/server/tests/test_isolation.py +90 -0
  45. package/server/tests/test_ui_server.py +385 -0
  46. package/server/tests/test_ui_shader_background.py +52 -0
  47. package/server/tests/test_ui_story_6_3.py +105 -0
  48. package/server/tools/__init__.py +0 -0
  49. package/server/tools/_mcp.py +4 -0
  50. package/server/tools/agents.py +160 -0
  51. package/server/tools/ecc/__init__.py +1 -0
  52. package/server/tools/ecc/data.py +35 -0
  53. package/server/tools/ecc/diagrams.py +23 -0
  54. package/server/tools/ecc/pipeline.py +24 -0
  55. package/server/tools/ecc/presentation.py +24 -0
  56. package/server/tools/ecc/web.py +23 -0
  57. package/server/tools/executor.py +880 -0
  58. package/server/tools/harness.py +330 -0
  59. package/server/tools/help.py +162 -0
  60. package/server/tools/hooks.py +357 -0
  61. package/server/tools/personas.py +110 -0
  62. package/server/tools/registry.py +195 -0
  63. package/server/tools/router.py +117 -0
  64. package/server/tools/skill_installer.py +230 -0
  65. package/server/tools/summarizer.py +168 -0
  66. package/server/tools/tests/__init__.py +0 -0
  67. package/server/tools/tests/test_agents.py +246 -0
  68. package/server/tools/tests/test_code_index.py +108 -0
  69. package/server/tools/tests/test_ecc_tools.py +51 -0
  70. package/server/tools/tests/test_executor.py +584 -0
  71. package/server/tools/tests/test_help_agent101.py +149 -0
  72. package/server/tools/tests/test_hooks.py +124 -0
  73. package/server/tools/tests/test_kill_thread.py +125 -0
  74. package/server/tools/tests/test_new_thread_list_threads.py +293 -0
  75. package/server/tools/tests/test_personas.py +52 -0
  76. package/server/tools/tests/test_recall_memory.py +55 -0
  77. package/server/tools/tests/test_registry_client.py +308 -0
  78. package/server/tools/tests/test_router.py +263 -0
  79. package/server/tools/tests/test_skill_installer.py +174 -0
  80. package/server/tools/tests/test_switch_thread.py +163 -0
  81. package/server/tools/tests/test_thread_command_skills.py +54 -0
  82. package/server/tools/tests/test_thread_resolver.py +165 -0
  83. package/server/tools/tests/test_tier1_schema.py +296 -0
  84. package/server/tools/thread_resolver.py +75 -0
  85. package/server/tools/tylor.py +374 -0
  86. package/server/tools/ui.py +38 -0
  87. package/server/ui_server.py +292 -0
  88. package/server/validate.py +237 -0
  89. package/skills/add-skill/SKILL.md +37 -0
  90. package/skills/afk-status/SKILL.md +20 -0
  91. package/skills/bmad/SKILL.md +14 -0
  92. package/skills/help-agent101/SKILL.md +48 -0
  93. package/skills/kill-thread/SKILL.md +35 -0
  94. package/skills/list-threads/SKILL.md +35 -0
  95. package/skills/new-thread/SKILL.md +35 -0
  96. package/skills/recall/SKILL.md +39 -0
  97. package/skills/run/SKILL.md +33 -0
  98. package/skills/set-sandbox/SKILL.md +38 -0
  99. package/skills/switch-thread/SKILL.md +38 -0
  100. package/ui/claude-logo.png +0 -0
  101. 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")