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