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