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,452 @@
1
+ """
2
+ Tests for Story 2.1: DynamoDB Storage Client
3
+ Run: pytest server/storage/tests/test_dynamo.py -v
4
+ """
5
+ import sys
6
+ from pathlib import Path
7
+ from unittest.mock import MagicMock, patch, call
8
+
9
+ import pytest
10
+
11
+ PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
12
+ sys.path.insert(0, str(PLUGIN_DIR))
13
+
14
+ from mcp.server.fastmcp.exceptions import ToolError
15
+ from server.storage.dynamo import DynamoClient, ITEM_SIZE_LIMIT
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Helpers
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def make_client(mock_table=None):
23
+ """Return a DynamoClient with a mocked DynamoDB table."""
24
+ with patch("boto3.Session") as mock_session_cls:
25
+ mock_session = MagicMock()
26
+ mock_session_cls.return_value = mock_session
27
+ resource = MagicMock()
28
+ mock_session.resource.return_value = resource
29
+ table = mock_table or MagicMock()
30
+ resource.Table.return_value = table
31
+ client = DynamoClient(table_name="agent101", user_id="testuser")
32
+ client.table = table # keep reference for assertions
33
+ return client, table
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # AC1: Initialises boto3 on import with configured profile
38
+ # ---------------------------------------------------------------------------
39
+
40
+ def test_init_creates_boto3_session_no_profile():
41
+ with patch("boto3.Session") as mock_session_cls:
42
+ mock_session = MagicMock()
43
+ mock_session_cls.return_value = mock_session
44
+ resource = MagicMock()
45
+ mock_session.resource.return_value = resource
46
+ resource.Table.return_value = MagicMock()
47
+
48
+ DynamoClient(table_name="agent101", user_id="u1")
49
+
50
+ mock_session_cls.assert_called_once_with()
51
+ mock_session.resource.assert_called_once_with("dynamodb")
52
+
53
+
54
+ def test_init_creates_boto3_session_with_profile():
55
+ with patch("boto3.Session") as mock_session_cls:
56
+ mock_session = MagicMock()
57
+ mock_session_cls.return_value = mock_session
58
+ resource = MagicMock()
59
+ mock_session.resource.return_value = resource
60
+ resource.Table.return_value = MagicMock()
61
+
62
+ DynamoClient(table_name="agent101", user_id="u1", profile="myprofile")
63
+
64
+ mock_session_cls.assert_called_once_with(profile_name="myprofile")
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # AC2: put_item writes mandatory base fields for ≤400KB item
69
+ # ---------------------------------------------------------------------------
70
+
71
+ def test_put_item_injects_mandatory_fields():
72
+ client, table = make_client()
73
+ table.get_item.return_value = {} # item doesn't exist yet
74
+ table.put_item.return_value = {}
75
+
76
+ sk = "THREAD#t1#META"
77
+ result = client.put_item(sk=sk, attributes={"Name": "alpha"})
78
+
79
+ assert result["PK"] == "USER#testuser"
80
+ assert result["SK"] == sk
81
+ assert "CreatedAt" in result
82
+ assert "UpdatedAt" in result
83
+ assert isinstance(result["Version"], int)
84
+ assert result["Version"] == 1
85
+ # Verify DynamoDB was actually called
86
+ table.put_item.assert_called_once()
87
+
88
+
89
+ def test_put_item_passes_correct_item_to_dynamo():
90
+ client, table = make_client()
91
+ table.get_item.return_value = {}
92
+ table.put_item.return_value = {}
93
+
94
+ result = client.put_item(sk="THREAD#t1#META", attributes={"Name": "beta"})
95
+
96
+ written = table.put_item.call_args.kwargs["Item"]
97
+ assert written["Name"] == "beta"
98
+ assert written["PK"] == "USER#testuser"
99
+
100
+
101
+ def test_put_item_preserves_created_at_on_update():
102
+ """UpdatedAt changes; CreatedAt stays the same on subsequent writes."""
103
+ client, table = make_client()
104
+ # Simulate existing item
105
+ existing = {
106
+ "PK": "USER#testuser",
107
+ "SK": "THREAD#t1#META",
108
+ "CreatedAt": "2026-01-01T00:00:00Z",
109
+ "UpdatedAt": "2026-01-01T00:00:00Z",
110
+ "Version": 3,
111
+ "Name": "old",
112
+ }
113
+ table.get_item.return_value = {"Item": existing}
114
+ table.put_item.return_value = {}
115
+
116
+ result = client.put_item(sk="THREAD#t1#META", attributes={"Name": "new"})
117
+
118
+ assert result["CreatedAt"] == "2026-01-01T00:00:00Z"
119
+ assert result["Version"] == 4 # incremented
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # AC3: put_item rejects items > 400KB
124
+ # ---------------------------------------------------------------------------
125
+
126
+ def test_put_item_rejects_oversized_item():
127
+ client, table = make_client()
128
+ table.get_item.return_value = {}
129
+
130
+ big_content = "x" * (ITEM_SIZE_LIMIT + 10_000)
131
+ with pytest.raises(ToolError, match="400KB"):
132
+ client.put_item(sk="THREAD#t1#MSG#ts", attributes={"Content": big_content})
133
+
134
+ table.put_item.assert_not_called()
135
+
136
+
137
+ def test_put_item_accepts_item_at_size_limit():
138
+ """Item at exactly ITEM_SIZE_LIMIT should not raise (boundary check)."""
139
+ client, table = make_client()
140
+ table.get_item.return_value = {}
141
+ table.put_item.return_value = {}
142
+
143
+ # A small item well under 400KB should pass
144
+ small = {"Content": "x" * 100}
145
+ result = client.put_item(sk="THREAD#t1#META", attributes=small)
146
+ assert result["Version"] == 1
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # AC4: Thread isolation enforced on get_item, query_thread, delete_item
151
+ # ---------------------------------------------------------------------------
152
+
153
+ def test_get_item_raises_on_isolation_violation():
154
+ client, table = make_client()
155
+ with pytest.raises(ToolError, match="Thread isolation violation"):
156
+ client.get_item(thread_id="t1", sk="THREAD#t2#META")
157
+
158
+
159
+ def test_get_item_passes_when_sk_matches_thread():
160
+ client, table = make_client()
161
+ table.get_item.return_value = {"Item": {"PK": "USER#testuser", "SK": "THREAD#t1#META"}}
162
+
163
+ result = client.get_item(thread_id="t1", sk="THREAD#t1#META")
164
+ assert result is not None
165
+
166
+
167
+ def test_get_item_returns_none_when_not_found():
168
+ client, table = make_client()
169
+ table.get_item.return_value = {} # no "Item" key
170
+
171
+ result = client.get_item(thread_id="t1", sk="THREAD#t1#META")
172
+ assert result is None
173
+
174
+
175
+ def test_query_thread_raises_on_isolation_violation():
176
+ client, table = make_client()
177
+ with pytest.raises(ToolError, match="Thread isolation violation"):
178
+ client.query_thread(thread_id="t1", sk_prefix="THREAD#t2#MSG")
179
+
180
+
181
+ def test_query_thread_passes_correct_prefix():
182
+ client, table = make_client()
183
+ table.query.return_value = {"Items": [{"SK": "THREAD#t1#MSG#001"}]}
184
+
185
+ items = client.query_thread(thread_id="t1", sk_prefix="THREAD#t1#MSG")
186
+ assert len(items) == 1
187
+ table.query.assert_called_once()
188
+
189
+
190
+ def test_delete_item_raises_on_isolation_violation():
191
+ client, table = make_client()
192
+ with pytest.raises(ToolError, match="Thread isolation violation"):
193
+ client.delete_item(thread_id="t1", sk="THREAD#t2#META")
194
+
195
+
196
+ def test_delete_item_calls_dynamo_delete():
197
+ client, table = make_client()
198
+ table.delete_item.return_value = {}
199
+
200
+ client.delete_item(thread_id="t1", sk="THREAD#t1#META")
201
+
202
+ table.delete_item.assert_called_once_with(
203
+ Key={"PK": "USER#testuser", "SK": "THREAD#t1#META"}
204
+ )
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Story 3.3: Sub-agent output and handoff persistence
209
+ # ---------------------------------------------------------------------------
210
+
211
+ def test_put_agent_output_writes_expected_thread_scoped_sk():
212
+ client, table = make_client()
213
+ table.get_item.return_value = {}
214
+ table.put_item.return_value = {}
215
+
216
+ item = client.put_agent_output(
217
+ thread_id="t1",
218
+ agent_id="agent_a",
219
+ output="Agent A completed analysis.",
220
+ task="Analyze risk.",
221
+ )
222
+
223
+ assert item["SK"].startswith("THREAD#t1#AGENT#agent_a#OUT#")
224
+ assert item["ThreadId"] == "t1"
225
+ assert item["AgentId"] == "agent_a"
226
+ assert item["Type"] == "agent_output"
227
+ assert item["Output"] == "Agent A completed analysis."
228
+ assert item["Task"] == "Analyze risk."
229
+
230
+
231
+ def test_put_agent_output_generates_unique_sk_for_rapid_writes():
232
+ client, table = make_client()
233
+ table.get_item.return_value = {}
234
+ table.put_item.return_value = {}
235
+
236
+ first = client.put_agent_output(
237
+ thread_id="t1",
238
+ agent_id="agent_a",
239
+ output="First output.",
240
+ )
241
+ second = client.put_agent_output(
242
+ thread_id="t1",
243
+ agent_id="agent_a",
244
+ output="Second output.",
245
+ )
246
+
247
+ assert first["SK"] != second["SK"]
248
+ assert first["SK"].startswith("THREAD#t1#AGENT#agent_a#OUT#")
249
+ assert second["SK"].startswith("THREAD#t1#AGENT#agent_a#OUT#")
250
+
251
+
252
+ def test_put_agent_handoff_writes_distinct_thread_scoped_sk():
253
+ client, table = make_client()
254
+ table.get_item.return_value = {}
255
+ table.put_item.return_value = {}
256
+ handoff = {"next_agent": "agent_b", "summary": "Carry this forward."}
257
+
258
+ item = client.put_agent_handoff(
259
+ thread_id="t1",
260
+ agent_id="agent_a",
261
+ handoff_state=handoff,
262
+ )
263
+
264
+ assert item["SK"].startswith("THREAD#t1#AGENT#agent_a#HANDOFF#")
265
+ assert item["ThreadId"] == "t1"
266
+ assert item["AgentId"] == "agent_a"
267
+ assert item["Type"] == "agent_handoff"
268
+ assert item["HandoffState"] == handoff
269
+
270
+
271
+ def test_put_agent_handoff_generates_unique_sk_for_rapid_writes():
272
+ client, table = make_client()
273
+ table.get_item.return_value = {}
274
+ table.put_item.return_value = {}
275
+
276
+ first = client.put_agent_handoff(
277
+ thread_id="t1",
278
+ agent_id="agent_a",
279
+ handoff_state={"step": 1},
280
+ )
281
+ second = client.put_agent_handoff(
282
+ thread_id="t1",
283
+ agent_id="agent_a",
284
+ handoff_state={"step": 2},
285
+ )
286
+
287
+ assert first["SK"] != second["SK"]
288
+ assert first["SK"].startswith("THREAD#t1#AGENT#agent_a#HANDOFF#")
289
+ assert second["SK"].startswith("THREAD#t1#AGENT#agent_a#HANDOFF#")
290
+
291
+
292
+ def test_put_agent_state_writes_thread_scoped_state_record():
293
+ client, table = make_client()
294
+ table.get_item.return_value = {}
295
+ table.put_item.return_value = {}
296
+
297
+ item = client.put_agent_state(
298
+ thread_id="t1",
299
+ agent_id="agent_a",
300
+ state={
301
+ "Status": "active",
302
+ "Persona": "analyst",
303
+ "Task": "Analyze risk.",
304
+ "ToolsLoaded": ["ecc/web", "ecc/data"],
305
+ },
306
+ )
307
+
308
+ assert item["SK"] == "THREAD#t1#AGENT#agent_a#STATE"
309
+ assert item["ThreadId"] == "t1"
310
+ assert item["AgentId"] == "agent_a"
311
+ assert item["Type"] == "agent_state"
312
+ assert item["Status"] == "active"
313
+ assert item["Persona"] == "analyst"
314
+
315
+
316
+ def test_query_agent_states_is_scoped_to_thread_prefix():
317
+ client, table = make_client()
318
+ table.query.return_value = {
319
+ "Items": [
320
+ {"SK": "THREAD#t1#AGENT#agent_a#STATE", "ThreadId": "t1"},
321
+ ]
322
+ }
323
+
324
+ result = client.query_agent_states("t1")
325
+
326
+ assert result == [{"SK": "THREAD#t1#AGENT#agent_a#STATE", "ThreadId": "t1"}]
327
+ table.query.assert_called_once()
328
+
329
+
330
+ def test_agent_output_rejects_cross_thread_sk_injection():
331
+ client, table = make_client()
332
+ with pytest.raises(ToolError, match="Invalid agent_id"):
333
+ client.put_agent_output(
334
+ thread_id="t1",
335
+ agent_id="THREAD#t2#AGENT#agent_a",
336
+ output="bad",
337
+ )
338
+ table.put_item.assert_not_called()
339
+
340
+
341
+ def test_switch_thread_suspends_previous_agents_and_resumes_target_agents():
342
+ client, table = make_client()
343
+
344
+ def raw_get_side_effect(Key):
345
+ sk = Key["SK"]
346
+ items = {
347
+ "THREAD#CURRENT#META": {
348
+ "PK": "USER#testuser",
349
+ "SK": "THREAD#CURRENT#META",
350
+ "CurrentThreadId": "thread_alpha",
351
+ "ActiveAt": "2026-05-12T00:00:00Z",
352
+ "CreatedAt": "2026-05-12T00:00:00Z",
353
+ "UpdatedAt": "2026-05-12T00:00:00Z",
354
+ "Version": 1,
355
+ },
356
+ "THREAD#thread_alpha#META": {
357
+ "PK": "USER#testuser",
358
+ "SK": "THREAD#thread_alpha#META",
359
+ "Version": 1,
360
+ },
361
+ "THREAD#thread_beta#META": {
362
+ "PK": "USER#testuser",
363
+ "SK": "THREAD#thread_beta#META",
364
+ "Version": 1,
365
+ },
366
+ }
367
+ item = items.get(sk)
368
+ return {"Item": item} if item else {}
369
+
370
+ table.get_item.side_effect = raw_get_side_effect
371
+
372
+ query_results = [
373
+ {
374
+ "Items": [
375
+ {
376
+ "PK": "USER#testuser",
377
+ "SK": "THREAD#thread_alpha#AGENT#agent_a#STATE",
378
+ "ThreadId": "thread_alpha",
379
+ "AgentId": "agent_a",
380
+ "Status": "active",
381
+ "Version": 1,
382
+ }
383
+ ]
384
+ },
385
+ {
386
+ "Items": [
387
+ {
388
+ "PK": "USER#testuser",
389
+ "SK": "THREAD#thread_beta#AGENT#agent_b#STATE",
390
+ "ThreadId": "thread_beta",
391
+ "AgentId": "agent_b",
392
+ "Status": "suspended",
393
+ "Version": 1,
394
+ }
395
+ ]
396
+ },
397
+ ]
398
+ table.query.side_effect = query_results
399
+
400
+ result = client.switch_thread("thread_beta")
401
+
402
+ assert result["thread_id"] == "thread_beta"
403
+ writes = client._client.transact_write_items.call_args.kwargs["TransactItems"]
404
+ serialised = [w["Put"]["Item"] for w in writes if w["Put"]["Item"]["SK"]["S"].endswith("#STATE")]
405
+ statuses = {item["SK"]["S"]: item["Status"]["S"] for item in serialised}
406
+ assert statuses["THREAD#thread_alpha#AGENT#agent_a#STATE"] == "suspended"
407
+ assert statuses["THREAD#thread_beta#AGENT#agent_b#STATE"] == "active"
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # AC5: Version increments on every write
412
+ # ---------------------------------------------------------------------------
413
+
414
+ def test_version_starts_at_1_for_new_item():
415
+ client, table = make_client()
416
+ table.get_item.return_value = {}
417
+ table.put_item.return_value = {}
418
+
419
+ result = client.put_item(sk="THREAD#t1#META", attributes={})
420
+ assert result["Version"] == 1
421
+
422
+
423
+ def test_version_increments_on_subsequent_writes():
424
+ client, table = make_client()
425
+ existing = {
426
+ "PK": "USER#testuser",
427
+ "SK": "THREAD#t1#META",
428
+ "CreatedAt": "2026-01-01T00:00:00Z",
429
+ "UpdatedAt": "2026-01-01T00:00:00Z",
430
+ "Version": 7,
431
+ }
432
+ table.get_item.return_value = {"Item": existing}
433
+ table.put_item.return_value = {}
434
+
435
+ result = client.put_item(sk="THREAD#t1#META", attributes={})
436
+ assert result["Version"] == 8
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # ISO 8601 date format
441
+ # ---------------------------------------------------------------------------
442
+
443
+ def test_created_at_is_iso_8601_utc():
444
+ import re
445
+ client, table = make_client()
446
+ table.get_item.return_value = {}
447
+ table.put_item.return_value = {}
448
+
449
+ result = client.put_item(sk="THREAD#t1#META", attributes={})
450
+ # Must match YYYY-MM-DDTHH:MM:SSZ
451
+ assert re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", result["CreatedAt"])
452
+ assert re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", result["UpdatedAt"])
@@ -0,0 +1,226 @@
1
+ """
2
+ Tests for Story 1.6: Project JSON Storage Mode
3
+ Run: pytest server/storage/tests/test_json_store.py -v
4
+ """
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ PLUGIN_DIR = Path(__file__).parent.parent.parent.parent
13
+ sys.path.insert(0, str(PLUGIN_DIR))
14
+
15
+ from server.storage.json_store import JsonStore, STORE_VERSION, WARN_THRESHOLD
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Helpers
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def make_store(tmp_path: Path) -> JsonStore:
23
+ return JsonStore(tmp_path / ".agent101" / "threads.json")
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # AC3: File created on first new_thread (absent → created atomically)
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def test_file_created_on_first_new_thread(tmp_path):
31
+ store = make_store(tmp_path)
32
+ assert not store.path.exists()
33
+
34
+ thread = store.new_thread("my first thread")
35
+
36
+ assert store.path.exists()
37
+ assert thread["name"] == "my first thread"
38
+ assert thread["id"].startswith("thread_")
39
+ assert thread["status"] == "active"
40
+ assert thread["messages"] == []
41
+ assert thread["summary"] is None
42
+
43
+
44
+ def test_first_new_thread_writes_correct_schema(tmp_path):
45
+ store = make_store(tmp_path)
46
+ store.new_thread("alpha")
47
+
48
+ data = json.loads(store.path.read_text())
49
+ assert data["version"] == STORE_VERSION
50
+ assert isinstance(data["threads"], list)
51
+ assert len(data["threads"]) == 1
52
+ t = data["threads"][0]
53
+ for key in ("id", "name", "status", "created_at", "updated_at", "messages", "summary"):
54
+ assert key in t, f"Missing key: {key}"
55
+
56
+
57
+ def test_atomic_write_no_tmp_file_left_on_success(tmp_path):
58
+ store = make_store(tmp_path)
59
+ store.new_thread("test")
60
+ tmp = store.path.with_suffix(".tmp")
61
+ assert not tmp.exists()
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # AC2: Tylor tools read/write threads.json
66
+ # ---------------------------------------------------------------------------
67
+
68
+ def test_load_returns_empty_store_when_file_absent(tmp_path):
69
+ store = make_store(tmp_path)
70
+ data = store.load()
71
+ assert data["version"] == STORE_VERSION
72
+ assert data["threads"] == []
73
+
74
+
75
+ def test_multiple_threads_persisted_correctly(tmp_path):
76
+ store = make_store(tmp_path)
77
+ t1 = store.new_thread("alpha")
78
+ t2 = store.new_thread("beta")
79
+ t3 = store.new_thread("gamma")
80
+
81
+ threads = store.list_threads()
82
+ ids = [t["id"] for t in threads]
83
+ assert t1["id"] in ids
84
+ assert t2["id"] in ids
85
+ assert t3["id"] in ids
86
+
87
+
88
+ def test_list_threads_sorted_by_updated_at_desc(tmp_path):
89
+ store = make_store(tmp_path)
90
+ t1 = store.new_thread("first")
91
+ t2 = store.new_thread("second")
92
+ # Update t1 so it has a later updated_at
93
+ store.update_thread(t1["id"], name="first-updated")
94
+
95
+ threads = store.list_threads()
96
+ # t1 (most recently updated) should be first
97
+ assert threads[0]["id"] == t1["id"]
98
+
99
+
100
+ def test_get_thread_returns_correct_thread(tmp_path):
101
+ store = make_store(tmp_path)
102
+ t = store.new_thread("findme")
103
+ found = store.get_thread(t["id"])
104
+ assert found is not None
105
+ assert found["id"] == t["id"]
106
+ assert found["name"] == "findme"
107
+
108
+
109
+ def test_get_thread_returns_none_for_missing(tmp_path):
110
+ store = make_store(tmp_path)
111
+ assert store.get_thread("thread_nonexistent") is None
112
+
113
+
114
+ def test_update_thread_persists_fields(tmp_path):
115
+ store = make_store(tmp_path)
116
+ t = store.new_thread("before")
117
+ store.update_thread(t["id"], name="after", status="archived")
118
+
119
+ found = store.get_thread(t["id"])
120
+ assert found["name"] == "after"
121
+ assert found["status"] == "archived"
122
+
123
+
124
+ def test_update_thread_raises_for_missing_id(tmp_path):
125
+ store = make_store(tmp_path)
126
+ with pytest.raises(KeyError):
127
+ store.update_thread("thread_nonexistent", name="x")
128
+
129
+
130
+ def test_delete_thread_removes_from_store(tmp_path):
131
+ store = make_store(tmp_path)
132
+ t = store.new_thread("delete-me")
133
+ deleted = store.delete_thread(t["id"])
134
+ assert deleted is True
135
+ assert store.get_thread(t["id"]) is None
136
+
137
+
138
+ def test_delete_thread_returns_false_for_missing(tmp_path):
139
+ store = make_store(tmp_path)
140
+ assert store.delete_thread("thread_ghost") is False
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # AC4: 400KB warning emitted (write proceeds, no hard failure)
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def test_large_write_warns_but_succeeds(tmp_path, caplog):
148
+ import logging
149
+ store = make_store(tmp_path)
150
+ t = store.new_thread("big")
151
+
152
+ # Stuff > 400KB of data into messages
153
+ big_messages = [{"role": "user", "content": "x" * 1000}] * 500 # ~500KB
154
+
155
+ with caplog.at_level(logging.WARNING, logger="server.storage.json_store"):
156
+ store.update_thread(t["id"], messages=big_messages)
157
+
158
+ assert store.path.exists() # write succeeded
159
+ assert any("file size limit" in r.message for r in caplog.records)
160
+
161
+
162
+ def test_small_write_no_warning(tmp_path, caplog):
163
+ import logging
164
+ store = make_store(tmp_path)
165
+ with caplog.at_level(logging.WARNING, logger="server.storage.json_store"):
166
+ store.new_thread("tiny")
167
+ assert not any("file size limit" in r.message for r in caplog.records)
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # AC5: JsonStore is mode-agnostic — caller decides when to use it
172
+ # ---------------------------------------------------------------------------
173
+
174
+ def test_json_store_import_does_not_touch_filesystem(tmp_path):
175
+ """Importing JsonStore must not create any files."""
176
+ from server.storage.json_store import JsonStore # noqa: F401 (reimport to check)
177
+ agent101_dir = tmp_path / ".agent101"
178
+ assert not agent101_dir.exists()
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # AC1: install.sh config.json shape (tested via Python logic directly)
183
+ # ---------------------------------------------------------------------------
184
+
185
+ def test_project_mode_config_written_correctly(tmp_path):
186
+ """Simulate the Python snippet from install.sh for project mode."""
187
+ config_path = tmp_path / "config.json"
188
+ plugin_dir = "/project/agent101"
189
+ storage_path = f"{plugin_dir}/.agent101/threads.json"
190
+
191
+ data = {}
192
+ data["storage_mode"] = "project"
193
+ data["storage_path"] = storage_path
194
+ config_path.write_text(json.dumps(data, indent=2))
195
+
196
+ written = json.loads(config_path.read_text())
197
+ assert written["storage_mode"] == "project"
198
+ assert written["storage_path"] == storage_path
199
+ # No AWS keys should be required
200
+ assert "aws_access_key" not in written
201
+ assert "aws_secret_key" not in written
202
+
203
+
204
+ def test_personal_mode_config_written_correctly(tmp_path):
205
+ """Simulate the Python snippet from install.sh for personal mode."""
206
+ config_path = tmp_path / "config.json"
207
+ data = {"storage_mode": "personal"}
208
+ config_path.write_text(json.dumps(data, indent=2))
209
+
210
+ written = json.loads(config_path.read_text())
211
+ assert written["storage_mode"] == "personal"
212
+
213
+
214
+ def test_existing_config_not_overwritten_in_personal_mode(tmp_path):
215
+ """Idempotent: if storage_mode already set, don't overwrite."""
216
+ config_path = tmp_path / "config.json"
217
+ config_path.write_text(json.dumps({"storage_mode": "personal", "custom_key": "keep-me"}))
218
+
219
+ data = json.loads(config_path.read_text())
220
+ if data.get("storage_mode") not in ("personal", "project"):
221
+ data["storage_mode"] = "personal"
222
+ config_path.write_text(json.dumps(data, indent=2))
223
+
224
+ result = json.loads(config_path.read_text())
225
+ assert result["custom_key"] == "keep-me"
226
+ assert result["storage_mode"] == "personal"