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,606 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Story 1.1: Plugin Installation & Claude Code Registration
|
|
3
|
+
Run: pytest server/tests/test_install.py -v
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
PLUGIN_DIR = Path(__file__).parent.parent.parent.parent.parent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# AC 3: requirements.txt contains all required packages
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
def test_requirements_txt_exists():
|
|
20
|
+
assert (PLUGIN_DIR / "src" / "agent101" / "server" / "requirements.txt").exists()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_requirements_txt_contains_all_packages():
|
|
24
|
+
content = (PLUGIN_DIR / "src" / "agent101" / "server" / "requirements.txt").read_text()
|
|
25
|
+
required = ["mcp", "boto3", "opensearch-py", "rapidfuzz", "aiohttp", "anthropic", "python-dotenv"]
|
|
26
|
+
missing = [p for p in required if p not in content]
|
|
27
|
+
assert not missing, f"Missing packages: {missing}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# AC 4: registry.json initialized correctly
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def test_registry_json_exists():
|
|
35
|
+
assert (PLUGIN_DIR / "registry.json").exists()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_registry_json_valid_shape():
|
|
39
|
+
data = json.loads((PLUGIN_DIR / "registry.json").read_text())
|
|
40
|
+
assert data["version"] == "1.0"
|
|
41
|
+
assert isinstance(data["skills"], list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_registry_json_not_overwritten_when_present(tmp_path):
|
|
45
|
+
"""init_registry logic must not overwrite existing registry."""
|
|
46
|
+
registry_path = tmp_path / "registry.json"
|
|
47
|
+
existing = '{"version":"1.0","skills":[{"name":"bmad"}]}'
|
|
48
|
+
registry_path.write_text(existing)
|
|
49
|
+
# Simulate idempotent init_registry
|
|
50
|
+
if not registry_path.exists():
|
|
51
|
+
registry_path.write_text('{"version":"1.0","skills":[]}')
|
|
52
|
+
assert registry_path.read_text() == existing
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# AC 5: server/main.py starts without import errors
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def test_server_main_importable():
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
[sys.executable, "-c", "import sys; sys.path.insert(0, '.'); from agent101.server.main import mcp; print(mcp.name)"],
|
|
62
|
+
cwd=str(PLUGIN_DIR),
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
)
|
|
66
|
+
assert result.returncode == 0, f"Import failed:\n{result.stderr}"
|
|
67
|
+
assert "agent101" in result.stdout
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# AC 6: settings.json patching is idempotent
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def test_settings_patch_idempotent(tmp_path):
|
|
75
|
+
"""Applying the MCP entry twice must not create duplicate keys."""
|
|
76
|
+
settings_path = tmp_path / "settings.json"
|
|
77
|
+
settings_path.write_text("{}")
|
|
78
|
+
|
|
79
|
+
def apply_patch(path: Path):
|
|
80
|
+
data = json.loads(path.read_text())
|
|
81
|
+
servers = data.setdefault("mcpServers", {})
|
|
82
|
+
if "agent101" not in servers:
|
|
83
|
+
servers["agent101"] = {
|
|
84
|
+
"command": "python3",
|
|
85
|
+
"args": ["server/main.py"],
|
|
86
|
+
"cwd": str(PLUGIN_DIR),
|
|
87
|
+
}
|
|
88
|
+
path.write_text(json.dumps(data, indent=2))
|
|
89
|
+
|
|
90
|
+
apply_patch(settings_path)
|
|
91
|
+
apply_patch(settings_path)
|
|
92
|
+
|
|
93
|
+
result = json.loads(settings_path.read_text())
|
|
94
|
+
assert len([k for k in result["mcpServers"] if k == "agent101"]) == 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_hooks_patch_idempotent(tmp_path):
|
|
98
|
+
"""Applying the hooks block twice must not duplicate hook commands."""
|
|
99
|
+
settings_path = tmp_path / "settings.json"
|
|
100
|
+
settings_path.write_text("{}")
|
|
101
|
+
|
|
102
|
+
hook_cmd = "/fake/hooks/session-start.sh"
|
|
103
|
+
|
|
104
|
+
def apply_hooks(path: Path):
|
|
105
|
+
data = json.loads(path.read_text())
|
|
106
|
+
hooks = data.setdefault("hooks", {})
|
|
107
|
+
existing_cmds = [h.get("command") for h in hooks.get("SessionStart", [])]
|
|
108
|
+
if hook_cmd not in existing_cmds:
|
|
109
|
+
hooks.setdefault("SessionStart", []).append({"command": hook_cmd})
|
|
110
|
+
path.write_text(json.dumps(data, indent=2))
|
|
111
|
+
|
|
112
|
+
apply_hooks(settings_path)
|
|
113
|
+
apply_hooks(settings_path)
|
|
114
|
+
|
|
115
|
+
result = json.loads(settings_path.read_text())
|
|
116
|
+
assert len(result["hooks"]["SessionStart"]) == 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Story 1.2: AWS Connectivity & Credential Validation
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
import sys as _sys
|
|
123
|
+
_sys.path.insert(0, str(PLUGIN_DIR))
|
|
124
|
+
|
|
125
|
+
import os
|
|
126
|
+
from unittest.mock import MagicMock, patch
|
|
127
|
+
|
|
128
|
+
from agent101.server.validate import (
|
|
129
|
+
check_bedrock,
|
|
130
|
+
check_dynamodb,
|
|
131
|
+
check_opensearch,
|
|
132
|
+
check_platform_key,
|
|
133
|
+
check_s3,
|
|
134
|
+
run_all,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --- AC1: DynamoDB success ---
|
|
139
|
+
|
|
140
|
+
def test_dynamodb_success():
|
|
141
|
+
mock_client = MagicMock()
|
|
142
|
+
mock_client.list_tables.return_value = {"TableNames": []}
|
|
143
|
+
with patch("boto3.client", return_value=mock_client):
|
|
144
|
+
passed, msg = check_dynamodb()
|
|
145
|
+
assert passed
|
|
146
|
+
assert "DynamoDB" in msg
|
|
147
|
+
assert "✓" in msg
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --- AC1: DynamoDB AccessDeniedException ---
|
|
151
|
+
|
|
152
|
+
def test_dynamodb_access_denied():
|
|
153
|
+
from botocore.exceptions import ClientError
|
|
154
|
+
|
|
155
|
+
error_response = {
|
|
156
|
+
"Error": {
|
|
157
|
+
"Code": "AccessDeniedException",
|
|
158
|
+
"Message": "User is not authorized to perform: dynamodb:ListTables on resource: *",
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
mock_client = MagicMock()
|
|
162
|
+
mock_client.list_tables.side_effect = ClientError(error_response, "ListTables")
|
|
163
|
+
|
|
164
|
+
with patch("boto3.client", return_value=mock_client):
|
|
165
|
+
passed, msg = check_dynamodb()
|
|
166
|
+
|
|
167
|
+
assert not passed
|
|
168
|
+
assert "dynamodb:ListTables" in msg
|
|
169
|
+
assert "✗" in msg
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --- AC2: S3 success ---
|
|
173
|
+
|
|
174
|
+
def test_s3_success():
|
|
175
|
+
mock_client = MagicMock()
|
|
176
|
+
mock_client.list_buckets.return_value = {"Buckets": []}
|
|
177
|
+
with patch("boto3.client", return_value=mock_client):
|
|
178
|
+
passed, msg = check_s3()
|
|
179
|
+
assert passed
|
|
180
|
+
assert "S3" in msg
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --- AC2: S3 AccessDenied ---
|
|
184
|
+
|
|
185
|
+
def test_s3_access_denied():
|
|
186
|
+
from botocore.exceptions import ClientError
|
|
187
|
+
|
|
188
|
+
error_response = {
|
|
189
|
+
"Error": {
|
|
190
|
+
"Code": "AccessDenied",
|
|
191
|
+
"Message": "User is not authorized to perform: s3:ListBuckets",
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
mock_client = MagicMock()
|
|
195
|
+
mock_client.list_buckets.side_effect = ClientError(error_response, "ListBuckets")
|
|
196
|
+
|
|
197
|
+
with patch("boto3.client", return_value=mock_client):
|
|
198
|
+
passed, msg = check_s3()
|
|
199
|
+
|
|
200
|
+
assert not passed
|
|
201
|
+
assert "s3:ListBuckets" in msg
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- AC3: Bedrock success ---
|
|
205
|
+
|
|
206
|
+
def test_bedrock_success():
|
|
207
|
+
mock_client = MagicMock()
|
|
208
|
+
mock_client.list_foundation_models.return_value = {"modelSummaries": []}
|
|
209
|
+
with patch("boto3.client", return_value=mock_client):
|
|
210
|
+
passed, msg = check_bedrock()
|
|
211
|
+
assert passed
|
|
212
|
+
assert "Bedrock" in msg
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# --- AC3: Bedrock uses us-east-1 ---
|
|
216
|
+
|
|
217
|
+
def test_bedrock_uses_us_east_1():
|
|
218
|
+
captured = {}
|
|
219
|
+
|
|
220
|
+
def fake_boto3_client(service, **kwargs):
|
|
221
|
+
captured["region"] = kwargs.get("region_name")
|
|
222
|
+
m = MagicMock()
|
|
223
|
+
m.list_foundation_models.return_value = {"modelSummaries": []}
|
|
224
|
+
return m
|
|
225
|
+
|
|
226
|
+
with patch("boto3.client", side_effect=fake_boto3_client):
|
|
227
|
+
check_bedrock()
|
|
228
|
+
|
|
229
|
+
assert captured["region"] == "us-east-1"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# --- AC4: OpenSearch skipped when not configured ---
|
|
233
|
+
|
|
234
|
+
def test_opensearch_skipped_when_not_configured():
|
|
235
|
+
passed, msg = check_opensearch(host="")
|
|
236
|
+
assert passed # skip is not an error
|
|
237
|
+
assert "skipped" in msg
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --- AC4: OpenSearch success ---
|
|
241
|
+
|
|
242
|
+
def test_opensearch_success():
|
|
243
|
+
mock_resp = MagicMock()
|
|
244
|
+
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
|
245
|
+
mock_resp.__exit__ = MagicMock(return_value=False)
|
|
246
|
+
|
|
247
|
+
with patch("urllib.request.urlopen", return_value=mock_resp):
|
|
248
|
+
passed, msg = check_opensearch(host="localhost", port="9200")
|
|
249
|
+
|
|
250
|
+
assert passed
|
|
251
|
+
assert "OpenSearch" in msg
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# --- AC4: OpenSearch failure ---
|
|
255
|
+
|
|
256
|
+
def test_opensearch_failure():
|
|
257
|
+
with patch("urllib.request.urlopen", side_effect=OSError("Connection refused")):
|
|
258
|
+
passed, msg = check_opensearch(host="localhost", port="9200")
|
|
259
|
+
assert not passed
|
|
260
|
+
assert "✗" in msg
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# --- AC7: Platform key warning when absent ---
|
|
264
|
+
|
|
265
|
+
def test_platform_key_warning_when_absent(tmp_path):
|
|
266
|
+
env_backup = os.environ.pop("ANTHROPIC_PLATFORM_AWS_API_KEY", None)
|
|
267
|
+
try:
|
|
268
|
+
passed, msg = check_platform_key(plugin_dir=str(tmp_path))
|
|
269
|
+
assert passed # non-fatal
|
|
270
|
+
assert "not set" in msg or "disabled" in msg
|
|
271
|
+
finally:
|
|
272
|
+
if env_backup is not None:
|
|
273
|
+
os.environ["ANTHROPIC_PLATFORM_AWS_API_KEY"] = env_backup
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# --- AC7: Platform key present ---
|
|
277
|
+
|
|
278
|
+
def test_platform_key_present_via_env():
|
|
279
|
+
os.environ["ANTHROPIC_PLATFORM_AWS_API_KEY"] = "test-key-abc"
|
|
280
|
+
try:
|
|
281
|
+
passed, msg = check_platform_key()
|
|
282
|
+
assert passed
|
|
283
|
+
assert "✓" in msg
|
|
284
|
+
finally:
|
|
285
|
+
del os.environ["ANTHROPIC_PLATFORM_AWS_API_KEY"]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# --- AC8: run_all exits 0 even when all AWS checks fail ---
|
|
289
|
+
|
|
290
|
+
def test_run_all_is_advisory(tmp_path, capsys):
|
|
291
|
+
from botocore.exceptions import NoCredentialsError
|
|
292
|
+
|
|
293
|
+
with (
|
|
294
|
+
patch("server.validate.check_dynamodb", return_value=(False, " ✗ DynamoDB — no creds")),
|
|
295
|
+
patch("server.validate.check_s3", return_value=(False, " ✗ S3 — no creds")),
|
|
296
|
+
patch("server.validate.check_bedrock", return_value=(False, " ✗ Bedrock — no creds")),
|
|
297
|
+
patch("server.validate.check_opensearch", return_value=(True, " ⚠ OpenSearch — skipped")),
|
|
298
|
+
patch("server.validate.check_platform_key", return_value=(True, " ⚠ key not set")),
|
|
299
|
+
):
|
|
300
|
+
error_count = run_all(str(tmp_path))
|
|
301
|
+
|
|
302
|
+
assert error_count == 3 # 3 AWS failures counted
|
|
303
|
+
# Caller (install.sh) always exits 0 — this just returns the count
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
# Story 1.3: DynamoDB Table & S3 Bucket Provisioning
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
from agent101.server.provision import (
|
|
310
|
+
provision_dynamodb,
|
|
311
|
+
provision_s3,
|
|
312
|
+
run_all as provision_run_all,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# --- AC1: DynamoDB created when absent ---
|
|
317
|
+
|
|
318
|
+
def test_dynamodb_created_when_absent():
|
|
319
|
+
from botocore.exceptions import ClientError
|
|
320
|
+
|
|
321
|
+
mock_client = MagicMock()
|
|
322
|
+
# describe_table raises ResourceNotFoundException → table absent
|
|
323
|
+
mock_client.describe_table.side_effect = ClientError(
|
|
324
|
+
{"Error": {"Code": "ResourceNotFoundException", "Message": "not found"}},
|
|
325
|
+
"DescribeTable",
|
|
326
|
+
)
|
|
327
|
+
mock_client.create_table.return_value = {}
|
|
328
|
+
mock_client.get_waiter.return_value = MagicMock(wait=MagicMock())
|
|
329
|
+
mock_client.update_continuous_backups.return_value = {}
|
|
330
|
+
|
|
331
|
+
with patch("boto3.client", return_value=mock_client):
|
|
332
|
+
passed, msg = provision_dynamodb("agent101")
|
|
333
|
+
|
|
334
|
+
assert passed
|
|
335
|
+
assert "created" in msg
|
|
336
|
+
mock_client.create_table.assert_called_once()
|
|
337
|
+
# Verify PITR enabled
|
|
338
|
+
mock_client.update_continuous_backups.assert_called_once()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# --- AC2: DynamoDB skipped when already present with compatible schema ---
|
|
342
|
+
|
|
343
|
+
def test_dynamodb_skipped_when_present_compatible():
|
|
344
|
+
mock_client = MagicMock()
|
|
345
|
+
mock_client.describe_table.return_value = {
|
|
346
|
+
"Table": {
|
|
347
|
+
"KeySchema": [
|
|
348
|
+
{"AttributeName": "PK", "KeyType": "HASH"},
|
|
349
|
+
{"AttributeName": "SK", "KeyType": "RANGE"},
|
|
350
|
+
]
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
with patch("boto3.client", return_value=mock_client):
|
|
355
|
+
passed, msg = provision_dynamodb("agent101")
|
|
356
|
+
|
|
357
|
+
assert passed
|
|
358
|
+
assert "already exists" in msg
|
|
359
|
+
mock_client.create_table.assert_not_called()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# --- AC3: DynamoDB incompatible schema emits warning (non-fatal) ---
|
|
363
|
+
|
|
364
|
+
def test_dynamodb_incompatible_schema_warns():
|
|
365
|
+
mock_client = MagicMock()
|
|
366
|
+
mock_client.describe_table.return_value = {
|
|
367
|
+
"Table": {
|
|
368
|
+
"KeySchema": [
|
|
369
|
+
{"AttributeName": "id", "KeyType": "HASH"},
|
|
370
|
+
]
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
with patch("boto3.client", return_value=mock_client):
|
|
375
|
+
passed, msg = provision_dynamodb("agent101")
|
|
376
|
+
|
|
377
|
+
assert passed # warning — not an error
|
|
378
|
+
assert "incompatible" in msg or "⚠" in msg
|
|
379
|
+
mock_client.create_table.assert_not_called()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# --- AC4: S3 bucket created when absent ---
|
|
383
|
+
|
|
384
|
+
def test_s3_bucket_created_when_absent():
|
|
385
|
+
mock_client = MagicMock()
|
|
386
|
+
mock_client.create_bucket.return_value = {}
|
|
387
|
+
mock_client.put_public_access_block.return_value = {}
|
|
388
|
+
|
|
389
|
+
with patch("boto3.client", return_value=mock_client):
|
|
390
|
+
passed, msg = provision_s3("agent101-blobs-123456789", region="us-east-1")
|
|
391
|
+
|
|
392
|
+
assert passed
|
|
393
|
+
assert "created" in msg
|
|
394
|
+
# us-east-1 must NOT pass CreateBucketConfiguration
|
|
395
|
+
call_kwargs = mock_client.create_bucket.call_args.kwargs
|
|
396
|
+
assert "CreateBucketConfiguration" not in call_kwargs
|
|
397
|
+
mock_client.put_public_access_block.assert_called_once()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# --- AC4: S3 non-us-east-1 passes LocationConstraint ---
|
|
401
|
+
|
|
402
|
+
def test_s3_bucket_passes_location_constraint_outside_us_east_1():
|
|
403
|
+
mock_client = MagicMock()
|
|
404
|
+
mock_client.create_bucket.return_value = {}
|
|
405
|
+
mock_client.put_public_access_block.return_value = {}
|
|
406
|
+
|
|
407
|
+
with patch("boto3.client", return_value=mock_client):
|
|
408
|
+
passed, msg = provision_s3("agent101-blobs-123456789", region="eu-west-1")
|
|
409
|
+
|
|
410
|
+
assert passed
|
|
411
|
+
call_kwargs = mock_client.create_bucket.call_args.kwargs
|
|
412
|
+
assert call_kwargs["CreateBucketConfiguration"]["LocationConstraint"] == "eu-west-1"
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# --- AC5: S3 skipped when already owned by this account ---
|
|
416
|
+
|
|
417
|
+
def test_s3_bucket_skipped_when_already_owned():
|
|
418
|
+
from botocore.exceptions import ClientError
|
|
419
|
+
|
|
420
|
+
mock_client = MagicMock()
|
|
421
|
+
mock_client.create_bucket.side_effect = ClientError(
|
|
422
|
+
{"Error": {"Code": "BucketAlreadyOwnedByYou", "Message": "already yours"}},
|
|
423
|
+
"CreateBucket",
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
with patch("boto3.client", return_value=mock_client):
|
|
427
|
+
passed, msg = provision_s3("agent101-blobs-123456789", region="us-east-1")
|
|
428
|
+
|
|
429
|
+
assert passed
|
|
430
|
+
assert "already exists" in msg
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# --- S3 bucket owned by another account is an error ---
|
|
434
|
+
|
|
435
|
+
def test_s3_bucket_owned_by_other_account_fails():
|
|
436
|
+
from botocore.exceptions import ClientError
|
|
437
|
+
|
|
438
|
+
mock_client = MagicMock()
|
|
439
|
+
mock_client.create_bucket.side_effect = ClientError(
|
|
440
|
+
{"Error": {"Code": "BucketAlreadyExists", "Message": "taken"}},
|
|
441
|
+
"CreateBucket",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
with patch("boto3.client", return_value=mock_client):
|
|
445
|
+
passed, msg = provision_s3("taken-bucket", region="us-east-1")
|
|
446
|
+
|
|
447
|
+
assert not passed
|
|
448
|
+
assert "another account" in msg
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# --- AC6: run_all returns 0 errors on full success ---
|
|
452
|
+
|
|
453
|
+
def test_provision_run_all_success(tmp_path):
|
|
454
|
+
with (
|
|
455
|
+
patch("server.provision.provision_dynamodb", return_value=(True, " ✓ DynamoDB table 'agent101' created")),
|
|
456
|
+
patch("server.provision.provision_s3", return_value=(True, " ✓ S3 bucket 'agent101-blobs-123' created")),
|
|
457
|
+
patch("server.provision._resolve_config", return_value={
|
|
458
|
+
"table_name": "agent101",
|
|
459
|
+
"bucket_name": "agent101-blobs-123",
|
|
460
|
+
"region": "us-east-1",
|
|
461
|
+
}),
|
|
462
|
+
):
|
|
463
|
+
error_count = provision_run_all(str(tmp_path))
|
|
464
|
+
|
|
465
|
+
assert error_count == 0
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# ---------------------------------------------------------------------------
|
|
469
|
+
# Story 1.5: OpenSearch Index Provisioning
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
from agent101.server.provision_opensearch import provision_index, run_all as opensearch_run_all
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# --- AC1: Index absent → created ---
|
|
475
|
+
|
|
476
|
+
def test_opensearch_index_created_when_absent():
|
|
477
|
+
mock_client = MagicMock()
|
|
478
|
+
mock_client.indices.exists.return_value = False
|
|
479
|
+
mock_client.indices.create.return_value = {}
|
|
480
|
+
|
|
481
|
+
with patch("server.provision_opensearch.OpenSearch", return_value=mock_client):
|
|
482
|
+
passed, msg = provision_index("localhost", 9200)
|
|
483
|
+
|
|
484
|
+
assert passed
|
|
485
|
+
assert "Created" in msg
|
|
486
|
+
mock_client.indices.create.assert_called_once()
|
|
487
|
+
# Verify index name and body contain knn_vector settings
|
|
488
|
+
call_kwargs = mock_client.indices.create.call_args.kwargs
|
|
489
|
+
assert call_kwargs["index"] == "agent-memories"
|
|
490
|
+
props = call_kwargs["body"]["mappings"]["properties"]
|
|
491
|
+
assert props["embedding"]["type"] == "knn_vector"
|
|
492
|
+
assert props["embedding"]["dimension"] == 1536
|
|
493
|
+
assert props["embedding"]["method"]["space_type"] == "cosine"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# --- AC2a: Index exists with compatible mapping → skip ---
|
|
497
|
+
|
|
498
|
+
def test_opensearch_index_skipped_when_compatible():
|
|
499
|
+
mock_client = MagicMock()
|
|
500
|
+
mock_client.indices.exists.return_value = True
|
|
501
|
+
mock_client.indices.get_mapping.return_value = {
|
|
502
|
+
"agent-memories": {
|
|
503
|
+
"mappings": {
|
|
504
|
+
"properties": {
|
|
505
|
+
"embedding": {
|
|
506
|
+
"type": "knn_vector",
|
|
507
|
+
"dimension": 1536,
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
with patch("server.provision_opensearch.OpenSearch", return_value=mock_client):
|
|
515
|
+
passed, msg = provision_index("localhost", 9200)
|
|
516
|
+
|
|
517
|
+
assert passed
|
|
518
|
+
assert "already exists" in msg
|
|
519
|
+
mock_client.indices.create.assert_not_called()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# --- AC2b: Index exists with wrong field type → returns (False, msg) ---
|
|
523
|
+
|
|
524
|
+
def test_opensearch_index_incompatible_field_type():
|
|
525
|
+
mock_client = MagicMock()
|
|
526
|
+
mock_client.indices.exists.return_value = True
|
|
527
|
+
mock_client.indices.get_mapping.return_value = {
|
|
528
|
+
"agent-memories": {
|
|
529
|
+
"mappings": {
|
|
530
|
+
"properties": {
|
|
531
|
+
"embedding": {
|
|
532
|
+
"type": "dense_vector", # wrong type
|
|
533
|
+
"dimension": 1536,
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
with patch("server.provision_opensearch.OpenSearch", return_value=mock_client):
|
|
541
|
+
passed, msg = provision_index("localhost", 9200)
|
|
542
|
+
|
|
543
|
+
assert not passed
|
|
544
|
+
assert "not knn_vector" in msg or "incompatible" in msg
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# --- AC2c: Index exists with wrong dimension → returns (False, msg) ---
|
|
548
|
+
|
|
549
|
+
def test_opensearch_index_incompatible_dimension():
|
|
550
|
+
mock_client = MagicMock()
|
|
551
|
+
mock_client.indices.exists.return_value = True
|
|
552
|
+
mock_client.indices.get_mapping.return_value = {
|
|
553
|
+
"agent-memories": {
|
|
554
|
+
"mappings": {
|
|
555
|
+
"properties": {
|
|
556
|
+
"embedding": {
|
|
557
|
+
"type": "knn_vector",
|
|
558
|
+
"dimension": 768, # wrong dimension
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
with patch("server.provision_opensearch.OpenSearch", return_value=mock_client):
|
|
566
|
+
passed, msg = provision_index("localhost", 9200)
|
|
567
|
+
|
|
568
|
+
assert not passed
|
|
569
|
+
assert "dimension" in msg
|
|
570
|
+
assert "768" in msg
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# --- AC3: No host configured → run_all returns 0, prints advisory ---
|
|
574
|
+
|
|
575
|
+
def test_opensearch_run_all_skips_when_no_host(capsys):
|
|
576
|
+
with patch("server.provision_opensearch._resolve_config", return_value={"host": "", "port": 9200}):
|
|
577
|
+
result = opensearch_run_all()
|
|
578
|
+
|
|
579
|
+
assert result == 0
|
|
580
|
+
captured = capsys.readouterr()
|
|
581
|
+
assert "OPENSEARCH_HOST" in captured.out
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# --- AC4: Connection error → advisory (False, msg), run_all still exits 0 ---
|
|
585
|
+
|
|
586
|
+
def test_opensearch_connection_error_is_advisory():
|
|
587
|
+
mock_client = MagicMock()
|
|
588
|
+
mock_client.indices.exists.side_effect = Exception("Connection refused")
|
|
589
|
+
|
|
590
|
+
with patch("server.provision_opensearch.OpenSearch", return_value=mock_client):
|
|
591
|
+
passed, msg = provision_index("localhost", 9200)
|
|
592
|
+
|
|
593
|
+
assert not passed
|
|
594
|
+
assert "OpenSearch error" in msg
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# --- opensearch-py import error → graceful (False, msg) ---
|
|
598
|
+
|
|
599
|
+
def test_opensearch_import_error_handled():
|
|
600
|
+
import server.provision_opensearch as m
|
|
601
|
+
|
|
602
|
+
with patch.object(m, "_OPENSEARCH_AVAILABLE", False):
|
|
603
|
+
passed, msg = m.provision_index("localhost", 9200)
|
|
604
|
+
|
|
605
|
+
assert not passed
|
|
606
|
+
assert "not installed" in msg or "opensearch" in msg.lower()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Isolation gate test for Story 2.4.
|
|
3
|
+
Run: pytest server/tests/test_isolation.py -v
|
|
4
|
+
"""
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
PLUGIN_DIR = Path(__file__).parent.parent.parent.parent.parent
|
|
10
|
+
sys.path.insert(0, str(PLUGIN_DIR))
|
|
11
|
+
|
|
12
|
+
from agent101.server.storage.dynamo import DynamoClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_switch_thread_does_not_touch_message_items():
|
|
16
|
+
"""Zero message bleed: switch_thread only updates marker and thread metadata."""
|
|
17
|
+
with patch("boto3.Session") as mock_session_cls:
|
|
18
|
+
mock_session = MagicMock()
|
|
19
|
+
mock_session_cls.return_value = mock_session
|
|
20
|
+
resource = MagicMock()
|
|
21
|
+
mock_session.resource.return_value = resource
|
|
22
|
+
table = MagicMock()
|
|
23
|
+
resource.Table.return_value = table
|
|
24
|
+
|
|
25
|
+
client = DynamoClient(table_name="agent101", user_id="testuser")
|
|
26
|
+
client.table = table
|
|
27
|
+
|
|
28
|
+
items = {
|
|
29
|
+
"THREAD#CURRENT#META": {
|
|
30
|
+
"PK": "USER#testuser",
|
|
31
|
+
"SK": "THREAD#CURRENT#META",
|
|
32
|
+
"CreatedAt": "2026-05-12T07:00:00Z",
|
|
33
|
+
"UpdatedAt": "2026-05-12T07:00:00Z",
|
|
34
|
+
"Version": 1,
|
|
35
|
+
"CurrentThreadId": "t1",
|
|
36
|
+
"ActiveAt": "2026-05-12T07:00:00Z",
|
|
37
|
+
},
|
|
38
|
+
"THREAD#t1#META": {
|
|
39
|
+
"PK": "USER#testuser",
|
|
40
|
+
"SK": "THREAD#t1#META",
|
|
41
|
+
"CreatedAt": "2026-05-12T06:00:00Z",
|
|
42
|
+
"UpdatedAt": "2026-05-12T06:00:00Z",
|
|
43
|
+
"Version": 1,
|
|
44
|
+
"Name": "thread-one",
|
|
45
|
+
},
|
|
46
|
+
"THREAD#t2#META": {
|
|
47
|
+
"PK": "USER#testuser",
|
|
48
|
+
"SK": "THREAD#t2#META",
|
|
49
|
+
"CreatedAt": "2026-05-12T06:10:00Z",
|
|
50
|
+
"UpdatedAt": "2026-05-12T06:10:00Z",
|
|
51
|
+
"Version": 1,
|
|
52
|
+
"Name": "thread-two",
|
|
53
|
+
},
|
|
54
|
+
"THREAD#t1#MSG#0001": {
|
|
55
|
+
"PK": "USER#testuser",
|
|
56
|
+
"SK": "THREAD#t1#MSG#0001",
|
|
57
|
+
"Content": "message from thread one",
|
|
58
|
+
},
|
|
59
|
+
"THREAD#t2#MSG#0001": {
|
|
60
|
+
"PK": "USER#testuser",
|
|
61
|
+
"SK": "THREAD#t2#MSG#0001",
|
|
62
|
+
"Content": "message from thread two",
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def get_item_side_effect(Key):
|
|
67
|
+
sk = Key["SK"]
|
|
68
|
+
item = items.get(sk)
|
|
69
|
+
return {"Item": item} if item else {}
|
|
70
|
+
|
|
71
|
+
table.get_item.side_effect = get_item_side_effect
|
|
72
|
+
|
|
73
|
+
def transact_write_side_effect(TransactItems):
|
|
74
|
+
for op in TransactItems:
|
|
75
|
+
put = op.get("Put")
|
|
76
|
+
if put:
|
|
77
|
+
item = put["Item"]
|
|
78
|
+
sk = item["SK"]["S"]
|
|
79
|
+
items[sk] = {k: list(v.values())[0] for k, v in item.items()}
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
client._client.transact_write_items.side_effect = transact_write_side_effect
|
|
83
|
+
|
|
84
|
+
client.switch_thread("t2")
|
|
85
|
+
|
|
86
|
+
assert items["THREAD#t1#MSG#0001"]["Content"] == "message from thread one"
|
|
87
|
+
assert items["THREAD#t2#MSG#0001"]["Content"] == "message from thread two"
|
|
88
|
+
assert items["THREAD#CURRENT#META"]["CurrentThreadId"] == "t2"
|
|
89
|
+
assert items["THREAD#t2#META"]["LastActivity"]
|
|
90
|
+
assert items["THREAD#t1#META"]["LastActivity"]
|