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,246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 3.2: spawn_agent and list_personas tools
|
|
3
|
+
Run: pytest server/tools/tests/test_agents.py -v
|
|
4
|
+
"""
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
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
|
+
class FakeDb:
|
|
16
|
+
def __init__(self, meta: dict | None) -> None:
|
|
17
|
+
self.meta = meta
|
|
18
|
+
self.agent_outputs = []
|
|
19
|
+
self.agent_handoffs = []
|
|
20
|
+
self.agent_states = []
|
|
21
|
+
|
|
22
|
+
def get_thread_meta(self, thread_id: str) -> dict | None:
|
|
23
|
+
return self.meta
|
|
24
|
+
|
|
25
|
+
def put_agent_output(self, thread_id: str, agent_id: str, output: str, task: str | None = None) -> dict:
|
|
26
|
+
item = {
|
|
27
|
+
"SK": f"THREAD#{thread_id}#AGENT#{agent_id}#OUT#2026-05-12T00:00:00Z",
|
|
28
|
+
"ThreadId": thread_id,
|
|
29
|
+
"AgentId": agent_id,
|
|
30
|
+
"Output": output,
|
|
31
|
+
"Task": task,
|
|
32
|
+
}
|
|
33
|
+
self.agent_outputs.append(item)
|
|
34
|
+
return item
|
|
35
|
+
|
|
36
|
+
def put_agent_handoff(self, thread_id: str, agent_id: str, handoff_state: dict) -> dict:
|
|
37
|
+
item = {
|
|
38
|
+
"SK": f"THREAD#{thread_id}#AGENT#{agent_id}#HANDOFF#2026-05-12T00:00:00Z",
|
|
39
|
+
"ThreadId": thread_id,
|
|
40
|
+
"AgentId": agent_id,
|
|
41
|
+
"HandoffState": handoff_state,
|
|
42
|
+
}
|
|
43
|
+
self.agent_handoffs.append(item)
|
|
44
|
+
return item
|
|
45
|
+
|
|
46
|
+
def put_agent_state(self, thread_id: str, agent_id: str, state: dict) -> dict:
|
|
47
|
+
item = {
|
|
48
|
+
"SK": f"THREAD#{thread_id}#AGENT#{agent_id}#STATE",
|
|
49
|
+
"ThreadId": thread_id,
|
|
50
|
+
"AgentId": agent_id,
|
|
51
|
+
**state,
|
|
52
|
+
}
|
|
53
|
+
self.agent_states.append(item)
|
|
54
|
+
return item
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_list_personas_returns_four_personas_with_summaries_and_categories():
|
|
58
|
+
from server.tools.agents import list_personas
|
|
59
|
+
|
|
60
|
+
result = list_personas()
|
|
61
|
+
|
|
62
|
+
assert set(result.keys()) == {"personas"}
|
|
63
|
+
personas = result["personas"]
|
|
64
|
+
assert {p["name"] for p in personas} == {"analyst", "ceo", "code_agent", "cto"}
|
|
65
|
+
|
|
66
|
+
for persona in personas:
|
|
67
|
+
assert persona["role_summary"]
|
|
68
|
+
assert persona["ecc_tool_categories"]
|
|
69
|
+
assert all(c.startswith("ecc/") for c in persona["ecc_tool_categories"])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_spawn_agent_initializes_known_persona_in_active_thread_with_scoped_tools():
|
|
73
|
+
from server.tools.agents import spawn_agent
|
|
74
|
+
|
|
75
|
+
db = FakeDb({"SK": "THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#META", "Status": "active"})
|
|
76
|
+
|
|
77
|
+
with patch("server.tools.agents._get_db", return_value=db):
|
|
78
|
+
result = spawn_agent(
|
|
79
|
+
persona="code_agent",
|
|
80
|
+
thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
|
|
81
|
+
task="Implement the next story.",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
assert result["agent_id"].startswith("agent_")
|
|
85
|
+
assert result["persona"] == "code_agent"
|
|
86
|
+
assert result["thread_id"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"
|
|
87
|
+
assert result["tools_loaded"] == ["ecc/web", "ecc/data", "ecc/pipeline"]
|
|
88
|
+
assert "Senior implementation engineer" in result["role_prompt"]
|
|
89
|
+
assert "Implement the next story." in result["task"]
|
|
90
|
+
assert result["state_sk"].startswith(f"THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#AGENT#{result['agent_id']}#STATE")
|
|
91
|
+
assert db.agent_states[0]["ThreadId"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"
|
|
92
|
+
assert db.agent_states[0]["Status"] == "active"
|
|
93
|
+
assert db.agent_states[0]["Persona"] == "code_agent"
|
|
94
|
+
assert db.agent_states[0]["ToolsLoaded"] == ["ecc/web", "ecc/data", "ecc/pipeline"]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_spawn_agent_keeps_parallel_thread_agents_scoped_to_own_thread():
|
|
98
|
+
from server.tools.agents import spawn_agent
|
|
99
|
+
|
|
100
|
+
db_alpha = FakeDb({"SK": "THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2#META", "Status": "active"})
|
|
101
|
+
db_beta = FakeDb({"SK": "THREAD#bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb#META", "Status": "active"})
|
|
102
|
+
|
|
103
|
+
with patch("server.tools.agents._get_db", return_value=db_alpha):
|
|
104
|
+
alpha = spawn_agent(
|
|
105
|
+
persona="analyst",
|
|
106
|
+
thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2",
|
|
107
|
+
task="Analyze alpha.",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
with patch("server.tools.agents._get_db", return_value=db_beta):
|
|
111
|
+
beta = spawn_agent(
|
|
112
|
+
persona="cto",
|
|
113
|
+
thread_id="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
114
|
+
task="Analyze beta.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert alpha["thread_id"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2"
|
|
118
|
+
assert beta["thread_id"] == "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
|
119
|
+
assert db_alpha.agent_states[0]["ThreadId"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2"
|
|
120
|
+
assert db_beta.agent_states[0]["ThreadId"] == "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
|
121
|
+
assert "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" not in db_alpha.agent_states[0]["SK"]
|
|
122
|
+
assert "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2" not in db_beta.agent_states[0]["SK"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_spawn_agent_rejects_inactive_thread():
|
|
126
|
+
from server.tools.agents import spawn_agent
|
|
127
|
+
|
|
128
|
+
db = FakeDb({"SK": "THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#META", "Status": "killed"})
|
|
129
|
+
|
|
130
|
+
with pytest.raises(McpError) as excinfo:
|
|
131
|
+
with patch("server.tools.agents._get_db", return_value=db):
|
|
132
|
+
spawn_agent(persona="analyst", thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", task="Analyze market.")
|
|
133
|
+
|
|
134
|
+
assert excinfo.value.error.code == INVALID_PARAMS
|
|
135
|
+
assert "Thread is not active: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1" in excinfo.value.error.message
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_spawn_agent_unknown_persona_raises_invalid_params_with_persona_list():
|
|
139
|
+
from server.tools.agents import spawn_agent
|
|
140
|
+
|
|
141
|
+
with pytest.raises(McpError) as excinfo:
|
|
142
|
+
spawn_agent(persona="designer", thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", task="Make UI.")
|
|
143
|
+
|
|
144
|
+
assert excinfo.value.error.code == INVALID_PARAMS
|
|
145
|
+
message = excinfo.value.error.message
|
|
146
|
+
assert "Unknown persona: designer" in message
|
|
147
|
+
assert "Available personas:" in message
|
|
148
|
+
assert "code_agent" in message
|
|
149
|
+
assert "analyst" in message
|
|
150
|
+
assert "ceo" in message
|
|
151
|
+
assert "cto" in message
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_spawn_agent_rejects_persona_file_outside_allowlist(tmp_path):
|
|
155
|
+
from server.tools import personas as personas_mod
|
|
156
|
+
from server.tools.agents import spawn_agent
|
|
157
|
+
|
|
158
|
+
persona_dir = tmp_path / "personas"
|
|
159
|
+
persona_dir.mkdir()
|
|
160
|
+
(persona_dir / "designer.md").write_text(
|
|
161
|
+
"# Designer\n\n"
|
|
162
|
+
"## Role Description\nDesign role.\n\n"
|
|
163
|
+
"## Communication Style\nDirect.\n\n"
|
|
164
|
+
"## ECC Tool Categories\n- `ecc/web`\n",
|
|
165
|
+
encoding="utf-8",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
with patch.object(personas_mod, "PERSONAS_DIR", persona_dir):
|
|
169
|
+
with pytest.raises(McpError) as excinfo:
|
|
170
|
+
spawn_agent(persona="designer", thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", task="Make UI.")
|
|
171
|
+
|
|
172
|
+
assert excinfo.value.error.code == INVALID_PARAMS
|
|
173
|
+
assert "Unknown persona: designer" in excinfo.value.error.message
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_persist_agent_output_writes_thread_scoped_dynamo_item_and_indexes_memory():
|
|
177
|
+
from server.tools.agents import persist_agent_output
|
|
178
|
+
|
|
179
|
+
db = FakeDb({"SK": "THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#META", "Status": "active"})
|
|
180
|
+
memory = MagicMock()
|
|
181
|
+
memory.index_memory.return_value = "memory-doc-1"
|
|
182
|
+
|
|
183
|
+
with patch("server.tools.agents._get_db", return_value=db), patch(
|
|
184
|
+
"server.tools.agents._get_memory_client", return_value=memory
|
|
185
|
+
):
|
|
186
|
+
result = persist_agent_output(
|
|
187
|
+
thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
|
|
188
|
+
agent_id="agent_a",
|
|
189
|
+
output="Agent A found the launch risk.",
|
|
190
|
+
task="Assess launch risk.",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
assert result["thread_id"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"
|
|
194
|
+
assert result["agent_id"] == "agent_a"
|
|
195
|
+
assert result["output_sk"].startswith("THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#AGENT#agent_a#OUT#")
|
|
196
|
+
assert result["memory_id"] == "memory-doc-1"
|
|
197
|
+
assert db.agent_outputs[0]["ThreadId"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"
|
|
198
|
+
assert db.agent_outputs[0]["Output"] == "Agent A found the launch risk."
|
|
199
|
+
memory.index_memory.assert_called_once_with(
|
|
200
|
+
thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
|
|
201
|
+
fact="Agent A found the launch risk.",
|
|
202
|
+
metadata={
|
|
203
|
+
"source": "agent_output",
|
|
204
|
+
"agent_id": "agent_a",
|
|
205
|
+
"agent_output_sk": result["output_sk"],
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_persist_agent_output_preserves_handoff_state_as_distinct_item():
|
|
211
|
+
from server.tools.agents import persist_agent_output
|
|
212
|
+
|
|
213
|
+
db = FakeDb({"SK": "THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#META", "Status": "active"})
|
|
214
|
+
memory = MagicMock()
|
|
215
|
+
memory.index_memory.return_value = "memory-doc-1"
|
|
216
|
+
handoff = {"next_agent": "agent_b", "summary": "Use risk findings in plan."}
|
|
217
|
+
|
|
218
|
+
with patch("server.tools.agents._get_db", return_value=db), patch(
|
|
219
|
+
"server.tools.agents._get_memory_client", return_value=memory
|
|
220
|
+
):
|
|
221
|
+
result = persist_agent_output(
|
|
222
|
+
thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
|
|
223
|
+
agent_id="agent_a",
|
|
224
|
+
output="Agent A found the launch risk.",
|
|
225
|
+
handoff_state=handoff,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
assert result["handoff_sk"].startswith("THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#AGENT#agent_a#HANDOFF#")
|
|
229
|
+
assert db.agent_outputs[0]["SK"] != db.agent_handoffs[0]["SK"]
|
|
230
|
+
assert db.agent_handoffs[0]["HandoffState"] == handoff
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_persist_agent_output_rejects_cross_thread_agent_id():
|
|
234
|
+
from server.tools.agents import persist_agent_output
|
|
235
|
+
|
|
236
|
+
db = FakeDb({"SK": "THREAD#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1#META", "Status": "active"})
|
|
237
|
+
with pytest.raises(McpError) as excinfo:
|
|
238
|
+
with patch("server.tools.agents._get_db", return_value=db):
|
|
239
|
+
persist_agent_output(
|
|
240
|
+
thread_id="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
|
|
241
|
+
agent_id="THREAD#other#AGENT#agent_a",
|
|
242
|
+
output="bad",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
assert excinfo.value.error.code == INVALID_PARAMS
|
|
246
|
+
assert "Invalid agent_id" in excinfo.value.error.message
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Tests for Story 2.11: thread-scoped code index."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_save_memory_stores_typed_code_index_fact():
|
|
7
|
+
from server.tools.tylor import save_memory
|
|
8
|
+
|
|
9
|
+
memory = MagicMock()
|
|
10
|
+
memory.index_memory.return_value = "doc-1"
|
|
11
|
+
|
|
12
|
+
with patch("server.tools.tylor._get_memory_client", return_value=memory):
|
|
13
|
+
result = save_memory(
|
|
14
|
+
thread_id="t1",
|
|
15
|
+
fact="SignIn: ui/auth.tsx:42 — login form component",
|
|
16
|
+
fact_type="code_index",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
memory.index_memory.assert_called_once_with(
|
|
20
|
+
thread_id="t1",
|
|
21
|
+
fact="SignIn: ui/auth.tsx:42 — login form component",
|
|
22
|
+
metadata={"type": "code_index"},
|
|
23
|
+
)
|
|
24
|
+
assert result == {"status": "saved", "thread_id": "t1", "memory_id": "doc-1", "type": "code_index"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_recall_memory_filters_by_type_at_storage_layer():
|
|
28
|
+
from server.tools.tylor import recall_memory
|
|
29
|
+
|
|
30
|
+
memory = MagicMock()
|
|
31
|
+
memory.search_memory.return_value = [{"content": "SignIn: ui/auth.tsx:42"}]
|
|
32
|
+
|
|
33
|
+
with patch("server.tools.tylor._get_memory_client", return_value=memory):
|
|
34
|
+
result = recall_memory(thread_id="t1", query="SignIn", fact_type="code_index")
|
|
35
|
+
|
|
36
|
+
# storage layer receives `type=` (its own parameter name)
|
|
37
|
+
memory.search_memory.assert_called_once_with(
|
|
38
|
+
thread_id="t1",
|
|
39
|
+
query="SignIn",
|
|
40
|
+
k=5,
|
|
41
|
+
type="code_index",
|
|
42
|
+
)
|
|
43
|
+
assert result["results"][0]["content"].startswith("SignIn")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_code_index_header_injected_on_session_start_with_budget():
|
|
47
|
+
from server.tools.hooks import session_start_message
|
|
48
|
+
|
|
49
|
+
db = MagicMock()
|
|
50
|
+
db.get_current_thread_marker.return_value = {
|
|
51
|
+
"CurrentThreadId": "t1",
|
|
52
|
+
"ActiveAt": "2026-05-13T00:00:00Z",
|
|
53
|
+
}
|
|
54
|
+
db.get_thread_meta.return_value = {"Name": "Frontend", "Status": "active", "MessageCount": 3}
|
|
55
|
+
memory = MagicMock()
|
|
56
|
+
memory.search_memory.return_value = [
|
|
57
|
+
{"content": f"Symbol{i}: src/file{i}.tsx:{i} — component", "created_at": f"2026-05-13T00:00:{i:02d}Z"}
|
|
58
|
+
for i in range(30)
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
def list_threads():
|
|
62
|
+
return {"threads": [{"thread_id": "t1", "name": "Frontend", "status": "active", "message_count": 3}]}
|
|
63
|
+
|
|
64
|
+
message = session_start_message(db=db, list_threads_fn=list_threads, memory_client=memory)
|
|
65
|
+
|
|
66
|
+
assert "[Frontend Thread — Code Index]" in message
|
|
67
|
+
header = message.split("agent101 active thread context:", 1)[0]
|
|
68
|
+
assert len(header.split()) <= 150
|
|
69
|
+
# storage layer receives `type=`
|
|
70
|
+
memory.search_memory.assert_called_once_with(
|
|
71
|
+
thread_id="t1",
|
|
72
|
+
query="code index",
|
|
73
|
+
k=30,
|
|
74
|
+
type="code_index",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_post_tool_use_indexes_read_file_symbol(tmp_path):
|
|
79
|
+
from server.tools.hooks import index_code_file_for_active_thread
|
|
80
|
+
|
|
81
|
+
source = tmp_path / "SignIn.tsx"
|
|
82
|
+
source.write_text("export function SignIn() {\n return <form />\n}\n", encoding="utf-8")
|
|
83
|
+
db = MagicMock()
|
|
84
|
+
db.get_current_thread_marker.return_value = {"CurrentThreadId": "t1"}
|
|
85
|
+
memory = MagicMock()
|
|
86
|
+
memory.index_memory.return_value = "doc-1"
|
|
87
|
+
|
|
88
|
+
result = index_code_file_for_active_thread(str(source), db=db, memory_client=memory,
|
|
89
|
+
project_root=tmp_path)
|
|
90
|
+
|
|
91
|
+
assert result["status"] == "indexed"
|
|
92
|
+
fact = memory.index_memory.call_args.kwargs["fact"]
|
|
93
|
+
assert fact.startswith("SignIn:")
|
|
94
|
+
# path should be relative (just the filename when project_root=tmp_path)
|
|
95
|
+
assert "SignIn.tsx:1" in fact
|
|
96
|
+
assert len(fact.split()) <= 30
|
|
97
|
+
assert memory.index_memory.call_args.kwargs["metadata"]["type"] == "code_index"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_post_tool_use_skips_config_files(tmp_path):
|
|
101
|
+
from server.tools.hooks import index_code_file_for_active_thread
|
|
102
|
+
|
|
103
|
+
config = tmp_path / "vite.config.ts"
|
|
104
|
+
config.write_text("export default {}\n", encoding="utf-8")
|
|
105
|
+
|
|
106
|
+
result = index_code_file_for_active_thread(str(config), db=MagicMock(), memory_client=MagicMock())
|
|
107
|
+
|
|
108
|
+
assert result == {"status": "skipped", "reason": "no_indexable_symbol"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 4.1: ECC tool modules initial five categories.
|
|
3
|
+
Run: pytest server/tools/tests/test_ecc_tools.py -v
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
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
|
+
EXPECTED_GROUPS = {
|
|
15
|
+
"ecc/web": {"web_scrape", "web_fetch"},
|
|
16
|
+
"ecc/data": {"dataset_manager", "data_clean", "data_transform"},
|
|
17
|
+
"ecc/presentation": {"build_pptx", "build_doc"},
|
|
18
|
+
"ecc/diagrams": {"diagram_gen", "flowchart_gen"},
|
|
19
|
+
"ecc/pipeline": {"pipeline_builder", "pipeline_run"},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _registered_tools() -> set[str]:
|
|
24
|
+
from server.tools._mcp import mcp
|
|
25
|
+
|
|
26
|
+
tools = asyncio.run(mcp.list_tools())
|
|
27
|
+
return {tool.name for tool in tools}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize("tool_group,expected_tools", EXPECTED_GROUPS.items())
|
|
31
|
+
def test_load_skill_tools_registers_ecc_group_tools(tool_group, expected_tools):
|
|
32
|
+
from server.tools.registry import load_skill_tools
|
|
33
|
+
|
|
34
|
+
result = load_skill_tools(tool_group)
|
|
35
|
+
|
|
36
|
+
assert result == {
|
|
37
|
+
"tool_group": tool_group,
|
|
38
|
+
"status": "loaded",
|
|
39
|
+
"tools": sorted(expected_tools),
|
|
40
|
+
}
|
|
41
|
+
assert expected_tools <= _registered_tools()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_load_skill_tools_unknown_category_raises_invalid_params():
|
|
45
|
+
from server.tools.registry import load_skill_tools
|
|
46
|
+
|
|
47
|
+
with pytest.raises(McpError) as excinfo:
|
|
48
|
+
load_skill_tools("ecc/unknown")
|
|
49
|
+
|
|
50
|
+
assert excinfo.value.error.code == INVALID_PARAMS
|
|
51
|
+
assert excinfo.value.error.message == "Unknown skill category: ecc/unknown"
|