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,385 @@
1
+ """
2
+ server/tests/test_ui_server.py — Tests for Story 6.1: Local UI Server.
3
+
4
+ Tests:
5
+ - GET / serves index.html (200) or 404 if missing
6
+ - GET /api/threads returns JSON array
7
+ - GET /api/threads/{id}/messages returns JSON array
8
+ - WS /ws/threads: initial payload on connect
9
+ - WS broadcast: all clients receive update
10
+ - Port-in-use: server starts gracefully, ui_available = False
11
+ """
12
+ import asyncio
13
+ import json
14
+ import socket
15
+ import pytest
16
+ from unittest.mock import patch, MagicMock, AsyncMock
17
+ from pathlib import Path
18
+
19
+ from aiohttp.test_utils import AioHTTPTestCase
20
+ from aiohttp import web
21
+
22
+
23
+ # ── Fixture helpers ──────────────────────────────────────────────────────────
24
+
25
+ MOCK_THREADS = [
26
+ {"thread_id": "abc123", "name": "Backend", "status": "active",
27
+ "last_activity": "2026-05-13T10:00:00Z", "message_count": 5},
28
+ {"thread_id": "def456", "name": "Frontend", "status": "idle",
29
+ "last_activity": "2026-05-13T09:00:00Z", "message_count": 2},
30
+ ]
31
+
32
+ MOCK_MESSAGES = [
33
+ {"role": "user", "content": "Hello", "timestamp": "2026-05-13T09:00:00Z"},
34
+ {"role": "assistant", "content": "Hi there", "timestamp": "2026-05-13T09:01:00Z"},
35
+ ]
36
+
37
+
38
+ def _mock_list_threads():
39
+ return {"threads": MOCK_THREADS}
40
+
41
+
42
+ def _mock_fetch_messages(thread_id, limit=50):
43
+ if thread_id == "abc123":
44
+ return MOCK_MESSAGES
45
+ return []
46
+
47
+
48
+ # ── Test: REST endpoints ──────────────────────────────────────────────────────
49
+
50
+ class TestRestEndpoints(AioHTTPTestCase):
51
+
52
+ async def get_application(self):
53
+ # Patch storage calls before building the app
54
+ with patch("server.ui_server._fetch_threads", return_value=[
55
+ {"id": t["thread_id"], "title": t["name"], "status": t["status"],
56
+ "created_at": t["last_activity"], "message_count": t["message_count"]}
57
+ for t in MOCK_THREADS
58
+ ]):
59
+ from ..ui_server import _make_app
60
+ return _make_app()
61
+
62
+ async def test_get_threads_returns_json_array(self):
63
+ with patch("server.ui_server._fetch_threads", return_value=[
64
+ {"id": "abc123", "title": "Backend", "status": "active",
65
+ "created_at": "2026-05-13T10:00:00Z", "message_count": 5}
66
+ ]):
67
+ resp = await self.client.get("/api/threads")
68
+ assert resp.status == 200
69
+ data = await resp.json()
70
+ assert "projects" in data
71
+ thread = data["projects"][0]["threads"][0]
72
+ assert thread["id"] == "abc123"
73
+ assert thread["title"] == "Backend"
74
+
75
+ async def test_get_messages_returns_json_array(self):
76
+ with patch("server.ui_server._fetch_messages", return_value=MOCK_MESSAGES):
77
+ resp = await self.client.get("/api/threads/abc123/messages")
78
+ assert resp.status == 200
79
+ data = await resp.json()
80
+ assert isinstance(data, list)
81
+ assert data[0]["role"] == "user"
82
+
83
+ async def test_get_index_404_when_missing(self):
84
+ with patch.object(Path, "exists", return_value=False):
85
+ resp = await self.client.get("/")
86
+ assert resp.status == 404
87
+
88
+
89
+ # ── Test: WebSocket ────────────────────────────────────────────────────────────
90
+
91
+ @pytest.mark.asyncio
92
+ async def test_websocket_initial_payload():
93
+ """Client receives full thread list on connect."""
94
+ from ..ui_server import _make_app, WsManager
95
+ import aiohttp
96
+ from aiohttp.test_utils import TestServer, TestClient
97
+
98
+ app = _make_app()
99
+
100
+ with patch("server.ui_server._fetch_threads", return_value=[
101
+ {"id": "abc123", "title": "Backend", "status": "active",
102
+ "created_at": "2026-05-13T10:00:00Z", "message_count": 5}
103
+ ]):
104
+ async with TestClient(TestServer(app)) as client:
105
+ ws = await client.ws_connect("/ws/threads")
106
+ msg = await asyncio.wait_for(ws.receive(), timeout=2.0)
107
+ data = json.loads(msg.data)
108
+ assert data["type"] == "thread_update"
109
+ assert "projects" in data
110
+ assert data["projects"][0]["threads"][0]["id"] == "abc123"
111
+ assert "seq" in data
112
+ await ws.close()
113
+
114
+
115
+ @pytest.mark.asyncio
116
+ async def test_websocket_broadcast_reaches_all_clients():
117
+ """After broadcast(), all connected clients receive the message."""
118
+ from ..ui_server import WsManager
119
+ import aiohttp
120
+ from aiohttp.test_utils import TestServer, TestClient
121
+ from ..ui_server import _make_app
122
+
123
+ app = _make_app()
124
+
125
+ with patch("server.ui_server._fetch_threads", return_value=[]):
126
+ async with TestClient(TestServer(app)) as client:
127
+ ws1 = await client.ws_connect("/ws/threads")
128
+ ws2 = await client.ws_connect("/ws/threads")
129
+
130
+ # Drain initial payloads
131
+ await asyncio.wait_for(ws1.receive(), timeout=1.0)
132
+ await asyncio.wait_for(ws2.receive(), timeout=1.0)
133
+
134
+ # Broadcast a custom payload
135
+ from ..ui_server import ws_manager
136
+ await ws_manager.broadcast({"type": "thread_update", "threads": [{"id": "x1"}]})
137
+
138
+ msg1 = await asyncio.wait_for(ws1.receive(), timeout=1.0)
139
+ msg2 = await asyncio.wait_for(ws2.receive(), timeout=1.0)
140
+
141
+ d1 = json.loads(msg1.data)
142
+ d2 = json.loads(msg2.data)
143
+ assert d1["threads"][0]["id"] == "x1"
144
+ assert d2["threads"][0]["id"] == "x1"
145
+ assert d1["seq"] == d2["seq"]
146
+
147
+ await ws1.close()
148
+ await ws2.close()
149
+
150
+
151
+ # ── Test: Port-in-use ─────────────────────────────────────────────────────────
152
+
153
+ @pytest.mark.asyncio
154
+ async def test_port_in_use_sets_ui_unavailable():
155
+ """When port 8765 is occupied, start_ui_server returns None and ui_available=False."""
156
+ import server.ui_server as ui_mod
157
+
158
+ # Occupy the port
159
+ blocker = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
160
+ blocker.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
161
+ try:
162
+ blocker.bind(("localhost", 8765))
163
+ blocker.listen(1)
164
+
165
+ runner = await ui_mod.start_ui_server()
166
+ assert runner is None
167
+ assert ui_mod.ui_available is False
168
+ finally:
169
+ blocker.close()
170
+ # Reset for other tests
171
+ ui_mod.ui_available = False
172
+
173
+
174
+ # ── Test: WsManager ────────────────────────────────────────────────────────────
175
+
176
+ @pytest.mark.asyncio
177
+ async def test_ws_manager_drops_closed_clients():
178
+ """WsManager removes clients that fail to receive."""
179
+ from ..ui_server import WsManager
180
+
181
+ mgr = WsManager()
182
+
183
+ # Mock a closed WebSocket
184
+ dead_ws = MagicMock()
185
+ dead_ws.closed = True
186
+
187
+ mgr.connect(dead_ws)
188
+ assert mgr.count == 1
189
+
190
+ await mgr.broadcast({"type": "test"})
191
+ # Dead client should be dropped
192
+ assert mgr.count == 0
193
+
194
+
195
+ @pytest.mark.asyncio
196
+ async def test_ws_manager_sequence_increments():
197
+ """Each broadcast increments the seq counter."""
198
+ from ..ui_server import WsManager
199
+ import server.ui_server as ui_mod
200
+
201
+ ui_mod._seq = 0
202
+ mgr = WsManager()
203
+
204
+ received = []
205
+ live_ws = MagicMock()
206
+ live_ws.closed = False
207
+
208
+ async def fake_send(text):
209
+ received.append(json.loads(text))
210
+
211
+ live_ws.send_str = fake_send
212
+ mgr.connect(live_ws)
213
+
214
+ await mgr.broadcast({"type": "thread_update", "threads": []})
215
+ await mgr.broadcast({"type": "thread_update", "threads": []})
216
+
217
+ assert received[0]["seq"] == 1
218
+ assert received[1]["seq"] == 2
219
+
220
+
221
+ # ── Story 6.2: API shape tests ────────────────────────────────────────────────
222
+
223
+ FULL_MOCK_THREADS = [
224
+ {"id": "abc123def456abc123def456abc12345", "title": "Backend",
225
+ "status": "active", "created_at": "2026-05-13T10:00:00Z", "message_count": 5},
226
+ {"id": "def456abc123def456abc123def45678", "title": "Frontend",
227
+ "status": "idle", "created_at": "2026-05-13T09:00:00Z", "message_count": 2},
228
+ ]
229
+
230
+
231
+ @pytest.mark.asyncio
232
+ async def test_api_threads_returns_all_five_fields():
233
+ """GET /api/threads returns {projects:[{id,name,threads:[...]}]} grouped by project."""
234
+ from ..ui_server import _make_app
235
+ from aiohttp.test_utils import TestServer, TestClient
236
+
237
+ app = _make_app()
238
+ with patch("server.ui_server._fetch_threads", return_value=FULL_MOCK_THREADS):
239
+ async with TestClient(TestServer(app)) as client:
240
+ resp = await client.get("/api/threads")
241
+ assert resp.status == 200
242
+ data = await resp.json()
243
+ assert "projects" in data
244
+ all_threads = [t for p in data["projects"] for t in p["threads"]]
245
+ assert len(all_threads) == 2
246
+ for t in all_threads:
247
+ assert "id" in t
248
+ assert "title" in t
249
+ assert "status" in t
250
+ assert "created_at" in t
251
+ assert "message_count" in t
252
+
253
+
254
+ @pytest.mark.asyncio
255
+ async def test_api_threads_returns_empty_list_not_null():
256
+ """GET /api/threads with no threads returns {projects:[]} not null."""
257
+ from ..ui_server import _make_app
258
+ from aiohttp.test_utils import TestServer, TestClient
259
+
260
+ app = _make_app()
261
+ with patch("server.ui_server._fetch_threads", return_value=[]):
262
+ async with TestClient(TestServer(app)) as client:
263
+ resp = await client.get("/api/threads")
264
+ assert resp.status == 200
265
+ data = await resp.json()
266
+ assert "projects" in data
267
+ assert data["projects"] == []
268
+
269
+
270
+ @pytest.mark.asyncio
271
+ async def test_api_thread_messages_valid_id_returns_array():
272
+ """GET /api/threads/{id}/messages with valid hex id returns array."""
273
+ from ..ui_server import _make_app
274
+ from aiohttp.test_utils import TestServer, TestClient
275
+
276
+ app = _make_app()
277
+ valid_id = "abc123def456abc123def456abc12345"
278
+ mock_msgs = [
279
+ {"role": "user", "content": "Hello", "timestamp": "2026-05-13T09:00:00Z"},
280
+ ]
281
+ with patch("server.ui_server._fetch_messages", return_value=mock_msgs):
282
+ async with TestClient(TestServer(app)) as client:
283
+ resp = await client.get(f"/api/threads/{valid_id}/messages")
284
+ assert resp.status == 200
285
+ data = await resp.json()
286
+ assert isinstance(data, list)
287
+ assert data[0]["role"] == "user"
288
+
289
+
290
+ # ── Story 6.4: message shape tests ───────────────────────────────────────────
291
+
292
+ @pytest.mark.asyncio
293
+ async def test_api_thread_messages_returns_role_content_timestamp():
294
+ """GET /api/threads/{id}/messages returns [{role, content, timestamp}]."""
295
+ from ..ui_server import _make_app
296
+ from aiohttp.test_utils import TestServer, TestClient
297
+
298
+ app = _make_app()
299
+ valid_id = "abc123def456abc123def456abc12345"
300
+ mock_msgs = [
301
+ {"role": "user", "content": "Hello", "timestamp": "2026-05-13T09:00:00Z"},
302
+ {"role": "assistant", "content": "Hi there", "timestamp": "2026-05-13T09:01:00Z"},
303
+ ]
304
+ with patch("server.ui_server._fetch_messages", return_value=mock_msgs):
305
+ async with TestClient(TestServer(app)) as client:
306
+ resp = await client.get(f"/api/threads/{valid_id}/messages")
307
+ assert resp.status == 200
308
+ data = await resp.json()
309
+ assert isinstance(data, list)
310
+ for msg in data:
311
+ assert "role" in msg
312
+ assert "content" in msg
313
+ assert "timestamp" in msg
314
+
315
+
316
+ @pytest.mark.asyncio
317
+ async def test_api_thread_messages_before_param_accepted():
318
+ """GET /api/threads/{id}/messages?before=<ts> is accepted (pagination)."""
319
+ from ..ui_server import _make_app
320
+ from aiohttp.test_utils import TestServer, TestClient
321
+
322
+ app = _make_app()
323
+ valid_id = "abc123def456abc123def456abc12345"
324
+ with patch("server.ui_server._fetch_messages", return_value=[]):
325
+ async with TestClient(TestServer(app)) as client:
326
+ resp = await client.get(
327
+ f"/api/threads/{valid_id}/messages?before=2026-05-13T09:00:00Z"
328
+ )
329
+ assert resp.status == 200
330
+
331
+
332
+ # ── Story 6.5: Live state sync tests ─────────────────────────────────────────
333
+
334
+ @pytest.mark.asyncio
335
+ async def test_ws_thread_update_broadcasts_to_all_clients():
336
+ """After a thread state change, all WS clients receive thread_update."""
337
+ from ..ui_server import _make_app, ws_manager
338
+ from aiohttp.test_utils import TestServer, TestClient
339
+ import server.ui_server as ui_mod
340
+
341
+ app = _make_app()
342
+ with patch("server.ui_server._fetch_threads", return_value=[
343
+ {"id": "abc123", "title": "Backend", "status": "active",
344
+ "created_at": "2026-05-13T10:00:00Z", "message_count": 5}
345
+ ]):
346
+ async with TestClient(TestServer(app)) as client:
347
+ ws1 = await client.ws_connect("/ws/threads")
348
+ ws2 = await client.ws_connect("/ws/threads")
349
+ # Drain initial payloads
350
+ await asyncio.wait_for(ws1.receive(), timeout=1.0)
351
+ await asyncio.wait_for(ws2.receive(), timeout=1.0)
352
+
353
+ # Simulate thread status change broadcast
354
+ await ws_manager.broadcast({
355
+ "type": "thread_update",
356
+ "threads": [{"id": "abc123", "title": "Backend", "status": "killed",
357
+ "created_at": "2026-05-13T10:00:00Z", "message_count": 5}]
358
+ })
359
+
360
+ m1 = await asyncio.wait_for(ws1.receive(), timeout=1.0)
361
+ m2 = await asyncio.wait_for(ws2.receive(), timeout=1.0)
362
+ d1 = json.loads(m1.data)
363
+ d2 = json.loads(m2.data)
364
+ assert d1["type"] == "thread_update"
365
+ assert d1["threads"][0]["status"] == "killed"
366
+ assert d2["threads"][0]["status"] == "killed"
367
+ await ws1.close(); await ws2.close()
368
+
369
+
370
+ @pytest.mark.asyncio
371
+ async def test_ws_initial_payload_has_thread_update_type():
372
+ """Initial WS payload on connect uses type=thread_update."""
373
+ from ..ui_server import _make_app
374
+ from aiohttp.test_utils import TestServer, TestClient
375
+
376
+ app = _make_app()
377
+ with patch("server.ui_server._fetch_threads", return_value=[]):
378
+ async with TestClient(TestServer(app)) as client:
379
+ ws = await client.ws_connect("/ws/threads")
380
+ msg = await asyncio.wait_for(ws.receive(), timeout=2.0)
381
+ data = json.loads(msg.data)
382
+ assert data["type"] == "thread_update"
383
+ assert "projects" in data
384
+ assert isinstance(data["projects"], list)
385
+ await ws.close()
@@ -0,0 +1,52 @@
1
+ """Static checks for the Thread Visualizer background layers."""
2
+ from pathlib import Path
3
+
4
+
5
+ UI_HTML = Path(__file__).parent.parent.parent.parent / "ui" / "index.html"
6
+
7
+
8
+ def _html() -> str:
9
+ return UI_HTML.read_text(encoding="utf-8")
10
+
11
+
12
+ def test_thread_visualizer_does_not_use_fluid_shader_background():
13
+ html = _html()
14
+ assert "function initShaderBackground()" not in html
15
+ assert "getContext('webgl'" not in html
16
+ assert "precision highp float;" not in html
17
+ assert "gl.drawArrays(gl.TRIANGLES, 0, 6)" not in html
18
+
19
+
20
+ def test_fluid_shader_fallback_and_pointer_interaction_were_removed():
21
+ html = _html()
22
+ assert "function drawStaticShaderFallback()" not in html
23
+ assert "shaderPointer" not in html
24
+ assert "window.addEventListener('pointermove'" not in html
25
+
26
+
27
+ def test_old_particle_background_was_removed():
28
+ html = _html()
29
+ assert "Background particle net" not in html
30
+ assert "const DOTS = Array.from" not in html
31
+ assert "function drawBg()" not in html
32
+
33
+
34
+ def test_sparkles_background_canvas_is_layered_behind_thread_graph():
35
+ html = _html()
36
+ assert '<canvas id="sparkles-canvas" aria-hidden="true"></canvas>' in html
37
+ assert "#sparkles-canvas" in html
38
+ assert "mix-blend-mode:screen" in html
39
+ assert "#graph-svg { position:fixed; inset:0; z-index:1; pointer-events:none; }" in html
40
+ assert "/* bubble nodes appended to body at z-index:3 */" in html
41
+
42
+
43
+ def test_sparkles_background_has_particles_without_title_component():
44
+ html = _html()
45
+ assert "const SPARKLE_COUNT = 95" in html
46
+ assert "function makeSparkle()" in html
47
+ assert "function drawSparkle(ctx, x, y, r, opacity, color)" in html
48
+ assert "function drawSparkles(now)" in html
49
+ assert "sparklesCtx.globalCompositeOperation = 'lighter'" in html
50
+ assert "requestAnimationFrame(drawSparkles)" in html
51
+ assert "SparklesCore" not in html
52
+ assert "sparkles-title" not in html
@@ -0,0 +1,105 @@
1
+ """Static checks for Story 6.3 silk thread SVG animation."""
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+
7
+ UI_HTML = Path(__file__).parent.parent.parent.parent / "ui" / "index.html"
8
+
9
+
10
+ def _html() -> str:
11
+ return UI_HTML.read_text(encoding="utf-8")
12
+
13
+
14
+ def test_silk_curve_css_and_gradient_defs_present():
15
+ html = _html()
16
+ assert ".thread-link" in html
17
+ assert "stroke:rgba(139,92,246,0.35)" in html
18
+ assert "stroke-width:1.5" in html
19
+ assert "fill:none" in html
20
+ assert "stroke-linecap:round" in html
21
+ assert "id=\"active-thread-gradient\"" in html
22
+ assert "stop-color=\"#8b5cf6\"" in html
23
+ assert "stop-color=\"#22d3ee\"" in html
24
+ assert ".thread-link.status-active" in html
25
+ assert "opacity:.6" in html
26
+ assert ".thread-link.status-killed" in html
27
+ assert "opacity:.15" in html
28
+
29
+
30
+ def test_silk_path_uses_cubic_bezier_with_perpendicular_offset_and_tick_updates():
31
+ html = _html()
32
+ assert "function silkPath(t, now=performance.now())" in html
33
+ assert "function threadAmplitude(status)" in html
34
+ assert "const wave = Math.sin(now * 0.0014 + (t._phase || 0)) * threadAmplitude(t.status)" in html
35
+ assert "const offset = dist * 0.22 + wave" in html
36
+ assert "const px = -dy / dist" in html
37
+ assert "const py = dx / dist" in html
38
+ assert "return `M ${CX} ${CY} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${t.x} ${t.y}`" in html
39
+ assert "renderSilkThreads()" in html
40
+ assert ".attr('d', d => silkPath(d, now))" in html
41
+
42
+
43
+ def test_thread_bubbles_are_static_left_lanes_with_animated_thread_physics():
44
+ html = _html()
45
+ assert "function layoutThreadLanes()" in html
46
+ assert "const laneX = Math.max(170, Math.min(260, W * 0.2))" in html
47
+ assert "wrap.style.left = t.x + 'px'" in html
48
+ assert "wrap.style.top = t.y + 'px'" in html
49
+ assert "function animateThreadPhysics(now)" in html
50
+ assert "requestAnimationFrame(animateThreadPhysics)" in html
51
+ assert "d3.forceSimulation" not in html
52
+ assert "d3.forceCenter" not in html
53
+
54
+
55
+ def test_silk_pulse_dots_use_animate_motion_after_two_seconds():
56
+ html = _html()
57
+ assert ".silk-dot" in html
58
+ assert ".attr('r', 4)" in html
59
+ assert ".attr('fill', 'rgba(255,255,255,0.6)')" in html
60
+ assert ".append('animateMotion')" in html
61
+ assert ".attr('repeatCount', 'indefinite')" in html
62
+ assert "randomDuration(t)" in html
63
+ assert "Math.random()*4 + 3" in html
64
+ assert "randomBegin(t, index)" in html
65
+ assert "setTimeout(() => graphSvg.classed('silk-ready', true), 2000)" in html
66
+
67
+
68
+ def test_silk_threads_render_paths_and_motion_dots_in_browser():
69
+ try:
70
+ from playwright.sync_api import sync_playwright
71
+ except ImportError:
72
+ pytest.skip("playwright is not installed")
73
+
74
+ with sync_playwright() as p:
75
+ try:
76
+ browser = p.chromium.launch(headless=True)
77
+ except Exception as exc:
78
+ pytest.skip(f"chromium is unavailable in this environment: {exc}")
79
+ page = browser.new_page()
80
+ errors = []
81
+ page.on("pageerror", lambda exc: errors.append(str(exc)))
82
+ page.route(
83
+ "**/api/threads",
84
+ lambda route: route.fulfill(
85
+ status=200,
86
+ content_type="application/json",
87
+ body=(
88
+ '[{"id":"active-one","title":"Active Thread","status":"active",'
89
+ '"created_at":"2026-05-13T00:00:00Z","message_count":3},'
90
+ '{"id":"idle-two","title":"Idle Thread","status":"idle",'
91
+ '"created_at":"2026-05-13T00:00:00Z","message_count":1}]'
92
+ ),
93
+ ),
94
+ )
95
+ page.route("**/ws/threads", lambda route: route.abort())
96
+ page.goto(UI_HTML.resolve().as_uri(), wait_until="domcontentloaded", timeout=10000)
97
+ page.wait_for_function("document.querySelectorAll('path.thread-link').length >= 2")
98
+ page.wait_for_function("document.querySelectorAll('circle.silk-dot animateMotion').length >= 2")
99
+ page.wait_for_timeout(2100)
100
+
101
+ assert page.locator("#graph-svg.silk-ready").count() == 1
102
+ assert page.locator("path.thread-link.status-active").count() >= 1
103
+ assert page.locator("path.thread-link.status-idle").count() >= 1
104
+ assert errors == []
105
+ browser.close()
File without changes
@@ -0,0 +1,4 @@
1
+ """Shared FastMCP singleton — imported by all tool modules to register @mcp.tool() decorators."""
2
+ from mcp.server.fastmcp import FastMCP
3
+
4
+ mcp = FastMCP("agent101")