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,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