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,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 4.5: /help-agent101 slash command.
|
|
3
|
+
Run: pytest server/tools/tests/test_help_agent101.py -v
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
|
|
11
|
+
SKILLS_DIR = PLUGIN_DIR / "skills"
|
|
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_help_agent101_skill_file_exists_and_describes_live_help_tool():
|
|
22
|
+
path = SKILLS_DIR / "help-agent101" / "SKILL.md"
|
|
23
|
+
assert path.exists()
|
|
24
|
+
text = path.read_text(encoding="utf-8")
|
|
25
|
+
assert text.startswith("---\n")
|
|
26
|
+
assert "name: help-agent101" in text
|
|
27
|
+
assert "help_agent101" in text
|
|
28
|
+
assert "list_registry" in text
|
|
29
|
+
assert "list_personas" in text
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_help_index_lists_commands_tools_registry_personas_and_ecc(tmp_path):
|
|
33
|
+
from server.tools import help as help_mod
|
|
34
|
+
from server.tools import registry as registry_mod
|
|
35
|
+
|
|
36
|
+
registry_path = tmp_path / "registry.json"
|
|
37
|
+
_write_registry(
|
|
38
|
+
registry_path,
|
|
39
|
+
[
|
|
40
|
+
{
|
|
41
|
+
"name": "bmad",
|
|
42
|
+
"trigger_description": "Use when user wants to create a PRD.",
|
|
43
|
+
"keywords": ["prd"],
|
|
44
|
+
"tool_count": 2,
|
|
45
|
+
"module": "server.tools.ecc.web",
|
|
46
|
+
"tools": ["web_fetch", "web_scrape"],
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
with patch.object(registry_mod, "REGISTRY_PATH", registry_path):
|
|
52
|
+
result = help_mod.build_help_index()
|
|
53
|
+
|
|
54
|
+
assert {command["command"] for command in result["slash_commands"]} >= {
|
|
55
|
+
"/new-thread",
|
|
56
|
+
"/switch-thread",
|
|
57
|
+
"/kill-thread",
|
|
58
|
+
"/list-threads",
|
|
59
|
+
"/recall",
|
|
60
|
+
"/add-skill",
|
|
61
|
+
"/open-threads-ui",
|
|
62
|
+
"/set-sandbox",
|
|
63
|
+
"/afk-status",
|
|
64
|
+
}
|
|
65
|
+
assert {tool["name"] for tool in result["tier1_tools"]} >= {
|
|
66
|
+
"new_thread",
|
|
67
|
+
"switch_thread",
|
|
68
|
+
"switch_thread_by_name",
|
|
69
|
+
"kill_thread",
|
|
70
|
+
"recall_memory",
|
|
71
|
+
"list_threads",
|
|
72
|
+
"list_personas",
|
|
73
|
+
"spawn_agent",
|
|
74
|
+
"load_skill_tools",
|
|
75
|
+
"list_registry",
|
|
76
|
+
"help_agent101",
|
|
77
|
+
"set_sandbox",
|
|
78
|
+
"execute_in_sandbox",
|
|
79
|
+
"execute_with_recovery",
|
|
80
|
+
"start_afk",
|
|
81
|
+
"afk_status",
|
|
82
|
+
"pause_afk",
|
|
83
|
+
}
|
|
84
|
+
assert result["registered_skills"] == [
|
|
85
|
+
{
|
|
86
|
+
"name": "bmad",
|
|
87
|
+
"trigger": "Use when user wants to create a PRD.",
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
assert {persona["name"] for persona in result["personas"]} == {
|
|
91
|
+
"analyst",
|
|
92
|
+
"ceo",
|
|
93
|
+
"code_agent",
|
|
94
|
+
"cto",
|
|
95
|
+
}
|
|
96
|
+
assert result["ecc_categories"]["ecc/web"] == ["web_fetch", "web_scrape"]
|
|
97
|
+
assert result["ecc_categories"]["ecc/pipeline"] == [
|
|
98
|
+
"pipeline_builder",
|
|
99
|
+
"pipeline_run",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_help_agent101_reads_registry_fresh_each_invocation(tmp_path):
|
|
104
|
+
from server.tools import help as help_mod
|
|
105
|
+
from server.tools import registry as registry_mod
|
|
106
|
+
|
|
107
|
+
registry_path = tmp_path / "registry.json"
|
|
108
|
+
_write_registry(
|
|
109
|
+
registry_path,
|
|
110
|
+
[
|
|
111
|
+
{
|
|
112
|
+
"name": "alpha",
|
|
113
|
+
"trigger_description": "Use for alpha workflows.",
|
|
114
|
+
"keywords": ["alpha"],
|
|
115
|
+
"tool_count": 1,
|
|
116
|
+
}
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
with patch.object(registry_mod, "REGISTRY_PATH", registry_path):
|
|
121
|
+
first = help_mod.help_agent101()
|
|
122
|
+
_write_registry(
|
|
123
|
+
registry_path,
|
|
124
|
+
[
|
|
125
|
+
{
|
|
126
|
+
"name": "beta",
|
|
127
|
+
"trigger_description": "Use for beta workflows.",
|
|
128
|
+
"keywords": ["beta"],
|
|
129
|
+
"tool_count": 1,
|
|
130
|
+
}
|
|
131
|
+
],
|
|
132
|
+
)
|
|
133
|
+
second = help_mod.help_agent101()
|
|
134
|
+
|
|
135
|
+
assert first["registered_skills"] == [
|
|
136
|
+
{"name": "alpha", "trigger": "Use for alpha workflows."}
|
|
137
|
+
]
|
|
138
|
+
assert second["registered_skills"] == [
|
|
139
|
+
{"name": "beta", "trigger": "Use for beta workflows."}
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_help_agent101_registered_as_tier1_tool():
|
|
144
|
+
import asyncio
|
|
145
|
+
import server.main # noqa: F401
|
|
146
|
+
from server.tools._mcp import mcp
|
|
147
|
+
|
|
148
|
+
tools = asyncio.run(mcp.list_tools())
|
|
149
|
+
assert "help_agent101" in {tool.name for tool in tools}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 2.8: Claude Code lifecycle hooks
|
|
3
|
+
Run: pytest server/tools/tests/test_hooks.py -v
|
|
4
|
+
"""
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
|
|
10
|
+
PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
|
|
11
|
+
sys.path.insert(0, str(PLUGIN_DIR))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeDynamo:
|
|
15
|
+
CURRENT_THREAD_SK = "THREAD#CURRENT#META"
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.puts = []
|
|
19
|
+
self.current = {
|
|
20
|
+
"SK": self.CURRENT_THREAD_SK,
|
|
21
|
+
"CurrentThreadId": "t1",
|
|
22
|
+
"ActiveAt": "2026-05-12T00:00:00Z",
|
|
23
|
+
}
|
|
24
|
+
self.meta = {
|
|
25
|
+
"SK": "THREAD#t1#META",
|
|
26
|
+
"Name": "Build hooks",
|
|
27
|
+
"Status": "active",
|
|
28
|
+
"MessageCount": 4,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def get_current_thread_marker(self):
|
|
32
|
+
return self.current
|
|
33
|
+
|
|
34
|
+
def get_thread_meta(self, thread_id):
|
|
35
|
+
return self.meta if thread_id == "t1" else None
|
|
36
|
+
|
|
37
|
+
def put_item(self, sk, attributes):
|
|
38
|
+
self.puts.append((sk, attributes))
|
|
39
|
+
return {"SK": sk, **attributes}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_session_start_surfaces_active_thread_context():
|
|
43
|
+
from server.tools.hooks import session_start_message
|
|
44
|
+
|
|
45
|
+
db = FakeDynamo()
|
|
46
|
+
|
|
47
|
+
def fake_list_threads():
|
|
48
|
+
return {
|
|
49
|
+
"threads": [
|
|
50
|
+
{
|
|
51
|
+
"thread_id": "t1",
|
|
52
|
+
"name": "Build hooks",
|
|
53
|
+
"status": "active",
|
|
54
|
+
"last_activity": "2026-05-12T00:00:00Z",
|
|
55
|
+
"message_count": 4,
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
message = session_start_message(db=db, list_threads_fn=fake_list_threads)
|
|
61
|
+
|
|
62
|
+
assert "agent101 active thread context" in message
|
|
63
|
+
assert "Build hooks" in message
|
|
64
|
+
assert "t1" in message
|
|
65
|
+
assert "Acknowledge this active thread" in message
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_session_checkpoint_updates_current_thread_meta():
|
|
69
|
+
from server.tools.hooks import checkpoint_current_thread
|
|
70
|
+
|
|
71
|
+
db = FakeDynamo()
|
|
72
|
+
result = checkpoint_current_thread(db=db, now_fn=lambda: "2026-05-12T01:02:03Z")
|
|
73
|
+
|
|
74
|
+
assert result["status"] == "checkpointed"
|
|
75
|
+
assert result["thread_id"] == "t1"
|
|
76
|
+
assert db.puts == [
|
|
77
|
+
(
|
|
78
|
+
"THREAD#t1#META",
|
|
79
|
+
{
|
|
80
|
+
"SK": "THREAD#t1#META",
|
|
81
|
+
"Name": "Build hooks",
|
|
82
|
+
"Status": "active",
|
|
83
|
+
"MessageCount": 4,
|
|
84
|
+
"LastActivity": "2026-05-12T01:02:03Z",
|
|
85
|
+
"CheckpointAt": "2026-05-12T01:02:03Z",
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_kill_thread_trigger_dispatches_background_process_without_waiting():
|
|
92
|
+
from server.tools.hooks import dispatch_kill_thread_summary
|
|
93
|
+
|
|
94
|
+
proc = MagicMock()
|
|
95
|
+
with patch("subprocess.Popen", return_value=proc) as popen:
|
|
96
|
+
result = dispatch_kill_thread_summary(
|
|
97
|
+
"t1",
|
|
98
|
+
project_root=PLUGIN_DIR,
|
|
99
|
+
python_executable="python-test",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
assert result == {"status": "dispatched", "thread_id": "t1"}
|
|
103
|
+
args = popen.call_args.args[0]
|
|
104
|
+
assert args[:4] == [
|
|
105
|
+
"python-test",
|
|
106
|
+
"-m",
|
|
107
|
+
"server.tools.hooks",
|
|
108
|
+
"summarize-thread",
|
|
109
|
+
]
|
|
110
|
+
assert "t1" in args
|
|
111
|
+
assert popen.call_args.kwargs["cwd"] == str(PLUGIN_DIR)
|
|
112
|
+
assert popen.call_args.kwargs["stdout"] == subprocess.DEVNULL
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_hook_shell_scripts_exist_and_are_executable():
|
|
116
|
+
for rel_path in (
|
|
117
|
+
"hooks/session-start.sh",
|
|
118
|
+
"hooks/session-checkpoint.sh",
|
|
119
|
+
"hooks/kill-thread-trigger.sh",
|
|
120
|
+
"hooks/post-tool-use-code-index.sh",
|
|
121
|
+
):
|
|
122
|
+
path = PLUGIN_DIR / rel_path
|
|
123
|
+
assert path.exists()
|
|
124
|
+
assert path.stat().st_mode & 0o111
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 2.7: kill_thread and async Bedrock summarizer
|
|
3
|
+
Run: pytest server/tools/tests/test_kill_thread.py -v
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
9
|
+
|
|
10
|
+
PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
|
|
11
|
+
sys.path.insert(0, str(PLUGIN_DIR))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeDynamo:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.items = {}
|
|
17
|
+
self.puts = []
|
|
18
|
+
self.messages = [
|
|
19
|
+
{"SK": "THREAD#t1#MSG#001", "Role": "user", "Content": "first"},
|
|
20
|
+
{"SK": "THREAD#t1#MSG#002", "Role": "assistant", "Content": "second"},
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
def get_thread_meta(self, thread_id):
|
|
24
|
+
return {
|
|
25
|
+
"SK": f"THREAD#{thread_id}#META",
|
|
26
|
+
"Name": "Test thread",
|
|
27
|
+
"Status": "active",
|
|
28
|
+
"MessageCount": len(self.messages),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def query_thread(self, thread_id, sk_prefix):
|
|
32
|
+
if sk_prefix.endswith("#MSG#"):
|
|
33
|
+
return list(self.messages)
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
def put_item(self, sk, attributes):
|
|
37
|
+
item = {"SK": sk, **attributes}
|
|
38
|
+
self.items[sk] = item
|
|
39
|
+
self.puts.append((sk, attributes))
|
|
40
|
+
return item
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FakeBedrock:
|
|
44
|
+
def __init__(self, text=None, error=None):
|
|
45
|
+
self.text = text
|
|
46
|
+
self.error = error
|
|
47
|
+
self.calls = []
|
|
48
|
+
|
|
49
|
+
def invoke_model(self, **kwargs):
|
|
50
|
+
self.calls.append(kwargs)
|
|
51
|
+
if self.error:
|
|
52
|
+
raise self.error
|
|
53
|
+
body = MagicMock()
|
|
54
|
+
body.read.return_value = (
|
|
55
|
+
b'{"content":[{"type":"text","text":"' + self.text.encode("utf-8") + b'"}]}'
|
|
56
|
+
)
|
|
57
|
+
return {"body": body}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_kill_thread_returns_immediately_without_dispatching_task():
|
|
61
|
+
"""kill_thread is now sync; summarization is dispatched by the PostToolUse hook."""
|
|
62
|
+
from server.tools.tylor import kill_thread
|
|
63
|
+
|
|
64
|
+
with patch("server.tools.tylor._get_db", return_value=FakeDynamo()):
|
|
65
|
+
result = kill_thread("t1")
|
|
66
|
+
|
|
67
|
+
assert result == {
|
|
68
|
+
"status": "killing",
|
|
69
|
+
"thread_id": "t1",
|
|
70
|
+
"message": "Summarization in progress",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_summarize_thread_success_writes_summary_and_marks_thread_killed():
|
|
75
|
+
from server.tools.summarizer import summarize_thread
|
|
76
|
+
|
|
77
|
+
db = FakeDynamo()
|
|
78
|
+
bedrock = FakeBedrock(text="Permanent summary")
|
|
79
|
+
|
|
80
|
+
asyncio.run(
|
|
81
|
+
summarize_thread(
|
|
82
|
+
"t1",
|
|
83
|
+
db=db,
|
|
84
|
+
bedrock_client=bedrock,
|
|
85
|
+
model_id="opus-test",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
summary = db.items["THREAD#t1#SUMMARY"]
|
|
90
|
+
meta = db.items["THREAD#t1#META"]
|
|
91
|
+
assert summary["Summary"] == "Permanent summary"
|
|
92
|
+
assert summary["SummaryType"] == "bedrock_opus"
|
|
93
|
+
assert meta["Status"] == "killed"
|
|
94
|
+
assert bedrock.calls[0]["modelId"] == "opus-test"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_summarize_thread_failure_stores_raw_fallback_and_logs_failure():
|
|
98
|
+
from server.tools.summarizer import summarize_thread
|
|
99
|
+
|
|
100
|
+
db = FakeDynamo()
|
|
101
|
+
bedrock = FakeBedrock(error=RuntimeError("bedrock down"))
|
|
102
|
+
|
|
103
|
+
asyncio.run(
|
|
104
|
+
summarize_thread(
|
|
105
|
+
"t1",
|
|
106
|
+
db=db,
|
|
107
|
+
bedrock_client=bedrock,
|
|
108
|
+
model_id="opus-test",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
summary = db.items["THREAD#t1#SUMMARY"]
|
|
113
|
+
meta = db.items["THREAD#t1#META"]
|
|
114
|
+
failure_logs = [
|
|
115
|
+
item for sk, item in db.items.items()
|
|
116
|
+
if sk.startswith("THREAD#t1#MSG#") and item.get("Role") == "system"
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
assert summary["SummaryType"] == "raw_fallback"
|
|
120
|
+
assert "user: first" in summary["Summary"]
|
|
121
|
+
assert "assistant: second" in summary["Summary"]
|
|
122
|
+
assert "bedrock down" in summary["Error"]
|
|
123
|
+
assert meta["Status"] == "killed"
|
|
124
|
+
assert failure_logs
|
|
125
|
+
assert "bedrock down" in failure_logs[0]["Content"]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 2.3: new_thread & list_threads tools
|
|
3
|
+
Run: pytest server/tools/tests/test_new_thread_list_threads.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
|
+
# Import the tool functions directly for unit testing
|
|
16
|
+
import server.tools.tylor as tylor_mod
|
|
17
|
+
from server.tools.tylor import _validate_name, new_thread, list_threads
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Helpers
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def _make_db_mock(existing_items=None):
|
|
25
|
+
"""Return a mock DynamoClient."""
|
|
26
|
+
mock_db = MagicMock()
|
|
27
|
+
mock_db.query_all.return_value = existing_items or []
|
|
28
|
+
mock_db.put_item.side_effect = lambda sk, attrs: {
|
|
29
|
+
**attrs, "PK": "USER#default", "SK": sk,
|
|
30
|
+
"CreatedAt": "2026-05-12T10:00:00Z",
|
|
31
|
+
"UpdatedAt": "2026-05-12T10:00:00Z",
|
|
32
|
+
"Version": 1,
|
|
33
|
+
}
|
|
34
|
+
return mock_db
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _call_new_thread(name: str, existing_items=None):
|
|
38
|
+
"""Call new_thread with a mocked DynamoClient."""
|
|
39
|
+
mock_db = _make_db_mock(existing_items)
|
|
40
|
+
with patch.object(tylor_mod, "_get_db", return_value=mock_db):
|
|
41
|
+
return new_thread(name), mock_db
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _call_list_threads(items=None):
|
|
45
|
+
"""Call list_threads with a mocked DynamoClient."""
|
|
46
|
+
mock_db = _make_db_mock(items)
|
|
47
|
+
with patch.object(tylor_mod, "_get_db", return_value=mock_db):
|
|
48
|
+
return list_threads(), mock_db
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# AC4: Name length validation
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def test_new_thread_rejects_name_too_short():
|
|
56
|
+
with pytest.raises(ToolError, match="3–64 characters"):
|
|
57
|
+
new_thread("ab")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_new_thread_rejects_name_too_long():
|
|
61
|
+
with pytest.raises(ToolError, match="3–64 characters"):
|
|
62
|
+
new_thread("x" * 65)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_new_thread_accepts_name_at_min_length():
|
|
66
|
+
result, db = _call_new_thread("abc")
|
|
67
|
+
assert result["name"] == "abc"
|
|
68
|
+
db.put_item.assert_called_once()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_new_thread_accepts_name_at_max_length():
|
|
72
|
+
result, db = _call_new_thread("x" * 64)
|
|
73
|
+
assert result["name"] == "x" * 64
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# AC5: Character and whitespace validation
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def test_new_thread_rejects_whitespace_only():
|
|
81
|
+
with pytest.raises(ToolError, match="invalid characters"):
|
|
82
|
+
new_thread(" ")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_new_thread_rejects_only_spaces_exact_min():
|
|
86
|
+
with pytest.raises(ToolError, match="invalid characters"):
|
|
87
|
+
new_thread(" ") # 3 spaces
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_new_thread_rejects_special_characters():
|
|
91
|
+
with pytest.raises(ToolError, match="invalid characters"):
|
|
92
|
+
new_thread("bad@name!")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_new_thread_rejects_slash_characters():
|
|
96
|
+
with pytest.raises(ToolError, match="invalid characters"):
|
|
97
|
+
new_thread("my/thread")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_new_thread_accepts_hyphens_underscores_spaces():
|
|
101
|
+
result, _ = _call_new_thread("my-thread_name test")
|
|
102
|
+
assert result["name"] == "my-thread_name test"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_new_thread_accepts_alphanumeric():
|
|
106
|
+
result, _ = _call_new_thread("Project123")
|
|
107
|
+
assert result["name"] == "Project123"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# AC3: Duplicate name
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def test_new_thread_rejects_duplicate_name():
|
|
115
|
+
existing = [
|
|
116
|
+
{
|
|
117
|
+
"SK": "THREAD#abc123#META",
|
|
118
|
+
"Name": "my-project",
|
|
119
|
+
"Status": "active",
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
with pytest.raises(ToolError, match="already exists"):
|
|
123
|
+
_call_new_thread("my-project", existing_items=existing)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_new_thread_allows_different_name_when_others_exist():
|
|
127
|
+
existing = [
|
|
128
|
+
{
|
|
129
|
+
"SK": "THREAD#abc123#META",
|
|
130
|
+
"Name": "other-project",
|
|
131
|
+
"Status": "active",
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
result, db = _call_new_thread("new-project", existing_items=existing)
|
|
135
|
+
assert result["name"] == "new-project"
|
|
136
|
+
db.put_item.assert_called_once()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# AC1: new_thread returns correct shape
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def test_new_thread_returns_thread_id_name_created_at():
|
|
144
|
+
result, _ = _call_new_thread("alpha")
|
|
145
|
+
assert "thread_id" in result
|
|
146
|
+
assert "name" in result
|
|
147
|
+
assert "created_at" in result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_new_thread_thread_id_is_hex_32chars():
|
|
151
|
+
result, _ = _call_new_thread("beta")
|
|
152
|
+
assert len(result["thread_id"]) == 32
|
|
153
|
+
assert all(c in "0123456789abcdef" for c in result["thread_id"])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_new_thread_writes_correct_sk():
|
|
157
|
+
result, db = _call_new_thread("gamma")
|
|
158
|
+
thread_id = result["thread_id"]
|
|
159
|
+
call_sk = db.put_item.call_args.args[0]
|
|
160
|
+
assert call_sk == f"THREAD#{thread_id}#META"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_new_thread_writes_status_active():
|
|
164
|
+
result, db = _call_new_thread("delta")
|
|
165
|
+
attrs = db.put_item.call_args.args[1]
|
|
166
|
+
assert attrs["Status"] == "active"
|
|
167
|
+
assert attrs["MessageCount"] == 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_new_thread_uniqueness_check_before_write():
|
|
171
|
+
"""query_all must be called before put_item."""
|
|
172
|
+
mock_db = _make_db_mock()
|
|
173
|
+
call_order = []
|
|
174
|
+
mock_db.query_all.side_effect = lambda *a, **kw: call_order.append("query") or []
|
|
175
|
+
mock_db.put_item.side_effect = lambda sk, attrs: call_order.append("write") or {
|
|
176
|
+
**attrs, "SK": sk, "CreatedAt": "2026-05-12T10:00:00Z",
|
|
177
|
+
"UpdatedAt": "2026-05-12T10:00:00Z", "Version": 1,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
with patch.object(tylor_mod, "_get_db", return_value=mock_db):
|
|
181
|
+
new_thread("epsilon")
|
|
182
|
+
|
|
183
|
+
assert call_order.index("query") < call_order.index("write")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# AC2: list_threads returns sorted threads
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def test_list_threads_returns_empty_when_no_threads():
|
|
191
|
+
result, _ = _call_list_threads([])
|
|
192
|
+
assert result == {"threads": []}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_list_threads_returns_correct_shape():
|
|
196
|
+
items = [
|
|
197
|
+
{
|
|
198
|
+
"SK": "THREAD#abc123#META",
|
|
199
|
+
"Name": "project-a",
|
|
200
|
+
"Status": "active",
|
|
201
|
+
"LastActivity": "2026-05-12T10:00:00Z",
|
|
202
|
+
"MessageCount": 3,
|
|
203
|
+
}
|
|
204
|
+
]
|
|
205
|
+
result, _ = _call_list_threads(items)
|
|
206
|
+
threads = result["threads"]
|
|
207
|
+
assert len(threads) == 1
|
|
208
|
+
t = threads[0]
|
|
209
|
+
assert t["thread_id"] == "abc123"
|
|
210
|
+
assert t["name"] == "project-a"
|
|
211
|
+
assert t["status"] == "active"
|
|
212
|
+
assert t["last_activity"] == "2026-05-12T10:00:00Z"
|
|
213
|
+
assert t["message_count"] == 3
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_list_threads_sorted_by_last_activity_descending():
|
|
217
|
+
items = [
|
|
218
|
+
{
|
|
219
|
+
"SK": "THREAD#aaa#META",
|
|
220
|
+
"Name": "old",
|
|
221
|
+
"Status": "active",
|
|
222
|
+
"LastActivity": "2026-05-10T10:00:00Z",
|
|
223
|
+
"MessageCount": 1,
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"SK": "THREAD#bbb#META",
|
|
227
|
+
"Name": "new",
|
|
228
|
+
"Status": "active",
|
|
229
|
+
"LastActivity": "2026-05-12T10:00:00Z",
|
|
230
|
+
"MessageCount": 5,
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"SK": "THREAD#ccc#META",
|
|
234
|
+
"Name": "middle",
|
|
235
|
+
"Status": "active",
|
|
236
|
+
"LastActivity": "2026-05-11T10:00:00Z",
|
|
237
|
+
"MessageCount": 2,
|
|
238
|
+
},
|
|
239
|
+
]
|
|
240
|
+
result, _ = _call_list_threads(items)
|
|
241
|
+
activities = [t["last_activity"] for t in result["threads"]]
|
|
242
|
+
assert activities == sorted(activities, reverse=True)
|
|
243
|
+
assert result["threads"][0]["name"] == "new"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_list_threads_excludes_non_meta_items():
|
|
247
|
+
items = [
|
|
248
|
+
{
|
|
249
|
+
"SK": "THREAD#abc123#META",
|
|
250
|
+
"Name": "real-thread",
|
|
251
|
+
"Status": "active",
|
|
252
|
+
"LastActivity": "2026-05-12T10:00:00Z",
|
|
253
|
+
"MessageCount": 0,
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"SK": "THREAD#abc123#MSG#2026-05-12T10:00:00Z",
|
|
257
|
+
"Content": "a message",
|
|
258
|
+
},
|
|
259
|
+
]
|
|
260
|
+
result, _ = _call_list_threads(items)
|
|
261
|
+
assert len(result["threads"]) == 1
|
|
262
|
+
assert result["threads"][0]["name"] == "real-thread"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_list_threads_thread_id_extracted_correctly():
|
|
266
|
+
items = [
|
|
267
|
+
{
|
|
268
|
+
"SK": "THREAD#deadbeef1234567890abcdef12345678#META",
|
|
269
|
+
"Name": "extract-test",
|
|
270
|
+
"Status": "active",
|
|
271
|
+
"LastActivity": "2026-05-12T10:00:00Z",
|
|
272
|
+
"MessageCount": 0,
|
|
273
|
+
}
|
|
274
|
+
]
|
|
275
|
+
result, _ = _call_list_threads(items)
|
|
276
|
+
assert result["threads"][0]["thread_id"] == "deadbeef1234567890abcdef12345678"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# query_all on DynamoClient
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def test_query_all_method_exists_on_dynamo_client():
|
|
284
|
+
from server.storage.dynamo import DynamoClient
|
|
285
|
+
assert hasattr(DynamoClient, "query_all")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_query_all_no_thread_isolation_check():
|
|
289
|
+
"""query_all must NOT call _assert_thread_isolation."""
|
|
290
|
+
from server.storage.dynamo import DynamoClient
|
|
291
|
+
import inspect
|
|
292
|
+
source = inspect.getsource(DynamoClient.query_all)
|
|
293
|
+
assert "_assert_thread_isolation" not in source
|