* fix(claude-code): fix plugin installation and release workflow - Fix plugin.json author field (string → object) to pass claude plugin validate - Add hindsight-integrations/.claude-plugin/marketplace.json so users can install via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations - Update README and install.sh with correct two-command install flow - Fix release-integration.yml: add explicit package.json check for typescript type and add plugin type for integrations with neither pyproject.toml nor package.json (prevents claude-code from incorrectly falling into the typescript build path) - Add CHANGELOG.md for the claude-code integration * remove install.sh — users install via claude plugin commands directly * test(claude-code): add 116 unit tests for plugin hooks and lib modules * feat(claude-code): user settings.json at CLAUDE_PLUGIN_DATA for stable config Plugin now checks CLAUDE_PLUGIN_DATA/settings.json after the versioned plugin default, giving users a path that persists across updates: ~/.claude/plugins/data/hindsight-memory-hindsight/settings.json Loading order: defaults → plugin settings.json → user settings.json → env vars * fix(claude-code): use ~/.hindsight/claude-code.json for user config Matches the ~/.openclaw/openclaw.json convention. Removes the confusing CLAUDE_PLUGIN_DATA path whose name depends on marketplace+plugin identifiers. * docs(claude-code): add ToS hint for claude-code LLM provider option * fix(claude-code): set author to Hindsight Team in plugin.json * ci: add test-claude-code-integration job to run plugin unit tests
216 lines
7.3 KiB
Python
216 lines
7.3 KiB
Python
"""Tests for lib/client.py — Hindsight REST API client."""
|
|
|
|
import json
|
|
import urllib.error
|
|
from io import BytesIO
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from lib.client import HindsightClient, _validate_api_url
|
|
|
|
|
|
class TestValidateApiUrl:
|
|
def test_valid_http(self):
|
|
assert _validate_api_url("http://localhost:9077") == "http://localhost:9077"
|
|
|
|
def test_valid_https(self):
|
|
assert _validate_api_url("https://api.example.com/") == "https://api.example.com"
|
|
|
|
def test_trailing_slash_stripped(self):
|
|
assert _validate_api_url("http://host:8080/") == "http://host:8080"
|
|
|
|
def test_invalid_scheme_raises(self):
|
|
with pytest.raises(ValueError, match="http or https"):
|
|
_validate_api_url("ftp://host")
|
|
|
|
def test_no_hostname_raises(self):
|
|
with pytest.raises(ValueError):
|
|
_validate_api_url("http://")
|
|
|
|
|
|
class FakeResp:
|
|
def __init__(self, data, status=200):
|
|
self.status = status
|
|
self._body = json.dumps(data).encode()
|
|
|
|
def read(self):
|
|
return self._body
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
pass
|
|
|
|
|
|
class TestHindsightClientInit:
|
|
def test_rejects_non_http_url(self):
|
|
with pytest.raises(ValueError):
|
|
HindsightClient("ftp://bad")
|
|
|
|
def test_stores_token(self):
|
|
c = HindsightClient("http://localhost:9077", api_token="tok123")
|
|
assert c.api_token == "tok123"
|
|
|
|
def test_no_token(self):
|
|
c = HindsightClient("http://localhost:9077")
|
|
assert c.api_token is None
|
|
|
|
|
|
class TestHindsightClientRecall:
|
|
def test_posts_to_correct_path(self):
|
|
c = HindsightClient("http://localhost:9077")
|
|
response_data = {"results": [{"text": "Paris", "type": "world"}]}
|
|
with patch("urllib.request.urlopen", return_value=FakeResp(response_data)):
|
|
resp = c.recall("my-bank", "capital of France")
|
|
assert resp["results"][0]["text"] == "Paris"
|
|
|
|
def test_bank_id_url_encoded(self):
|
|
c = HindsightClient("http://localhost:9077")
|
|
captured = {}
|
|
|
|
def fake_open(req, timeout=None):
|
|
captured["url"] = req.full_url
|
|
return FakeResp({"results": []})
|
|
|
|
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
c.recall("bank with spaces", "query")
|
|
|
|
assert "bank%20with%20spaces" in captured["url"]
|
|
|
|
def test_includes_auth_header_when_token_set(self):
|
|
c = HindsightClient("http://localhost:9077", api_token="mytoken")
|
|
captured = {}
|
|
|
|
def fake_open(req, timeout=None):
|
|
captured["headers"] = dict(req.headers)
|
|
return FakeResp({"results": []})
|
|
|
|
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
c.recall("bank", "query")
|
|
|
|
assert "Authorization" in captured["headers"]
|
|
assert "mytoken" in captured["headers"]["Authorization"]
|
|
|
|
def test_no_auth_header_without_token(self):
|
|
c = HindsightClient("http://localhost:9077")
|
|
captured = {}
|
|
|
|
def fake_open(req, timeout=None):
|
|
captured["headers"] = dict(req.headers)
|
|
return FakeResp({"results": []})
|
|
|
|
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
c.recall("bank", "query")
|
|
|
|
assert "Authorization" not in captured["headers"]
|
|
|
|
def test_http_error_raises_runtime_error(self):
|
|
c = HindsightClient("http://localhost:9077")
|
|
err = urllib.error.HTTPError(
|
|
url="http://localhost:9077/v1/default/banks/b/memories/recall",
|
|
code=500,
|
|
msg="Internal Server Error",
|
|
hdrs={},
|
|
fp=BytesIO(b"server exploded"),
|
|
)
|
|
with patch("urllib.request.urlopen", side_effect=err):
|
|
with pytest.raises(RuntimeError, match="HTTP 500"):
|
|
c.recall("b", "query")
|
|
|
|
def test_sends_budget_and_types(self):
|
|
c = HindsightClient("http://localhost:9077")
|
|
captured = {}
|
|
|
|
def fake_open(req, timeout=None):
|
|
captured["body"] = json.loads(req.data.decode())
|
|
return FakeResp({"results": []})
|
|
|
|
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
c.recall("bank", "query", budget="high", types=["world", "experience"])
|
|
|
|
assert captured["body"]["budget"] == "high"
|
|
assert captured["body"]["types"] == ["world", "experience"]
|
|
|
|
|
|
class TestHindsightClientRetain:
|
|
def test_posts_with_async_true(self):
|
|
c = HindsightClient("http://localhost:9077")
|
|
captured = {}
|
|
|
|
def fake_open(req, timeout=None):
|
|
captured["body"] = json.loads(req.data.decode())
|
|
return FakeResp({"status": "accepted"})
|
|
|
|
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
c.retain("bank", "transcript content", document_id="doc-1", context="claude-code")
|
|
|
|
assert captured["body"]["async"] is True
|
|
assert captured["body"]["items"][0]["content"] == "transcript content"
|
|
assert captured["body"]["items"][0]["context"] == "claude-code"
|
|
|
|
def test_bank_id_encoded_in_retain_path(self):
|
|
c = HindsightClient("http://localhost:9077")
|
|
captured = {}
|
|
|
|
def fake_open(req, timeout=None):
|
|
captured["url"] = req.full_url
|
|
return FakeResp({})
|
|
|
|
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
c.retain("my::bank", "content")
|
|
|
|
assert "my%3A%3Abank" in captured["url"]
|
|
|
|
|
|
class TestHindsightClientHealthCheck:
|
|
def test_returns_true_on_200(self):
|
|
c = HindsightClient("http://localhost:9077")
|
|
with patch("urllib.request.urlopen", return_value=FakeResp({}, status=200)):
|
|
with patch("time.sleep"): # don't actually sleep
|
|
assert c.health_check() is True
|
|
|
|
def test_returns_false_after_retries(self):
|
|
c = HindsightClient("http://localhost:9077")
|
|
with patch("urllib.request.urlopen", side_effect=OSError("refused")):
|
|
with patch("time.sleep"):
|
|
assert c.health_check() is False
|
|
|
|
def test_retries_on_failure(self):
|
|
c = HindsightClient("http://localhost:9077")
|
|
call_count = 0
|
|
|
|
def flaky(*_a, **_kw):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count < 3:
|
|
raise OSError("not yet")
|
|
return FakeResp({}, status=200)
|
|
|
|
with patch("urllib.request.urlopen", side_effect=flaky):
|
|
with patch("time.sleep"):
|
|
result = c.health_check()
|
|
|
|
assert result is True
|
|
assert call_count == 3
|
|
|
|
|
|
class TestHindsightClientSetBankMission:
|
|
def test_patches_config_endpoint(self):
|
|
c = HindsightClient("http://localhost:9077")
|
|
captured = {}
|
|
|
|
def fake_open(req, timeout=None):
|
|
captured["url"] = req.full_url
|
|
captured["method"] = req.method
|
|
captured["body"] = json.loads(req.data.decode())
|
|
return FakeResp({})
|
|
|
|
with patch("urllib.request.urlopen", side_effect=fake_open):
|
|
c.set_bank_mission("my-bank", "I am Claude", retain_mission="Extract facts")
|
|
|
|
assert captured["method"] == "PATCH"
|
|
assert "my-bank" in captured["url"]
|
|
assert captured["body"]["updates"]["reflect_mission"] == "I am Claude"
|
|
assert captured["body"]["updates"]["retain_mission"] == "Extract facts"
|