* feat(hindsight-embed): external API support + OpenClaw fixes Adds comprehensive external API support and fixes critical OpenClaw plugin issues. **External API Support:** - Add HINDSIGHT_EMBED_API_URL to connect to external Hindsight API servers - Add HINDSIGHT_EMBED_API_TOKEN for Bearer token authentication - Add HINDSIGHT_EMBED_API_DATABASE_URL for custom PostgreSQL databases - Skip daemon startup when external API URL is configured - Add 10 comprehensive unit tests for external API scenarios **OpenClaw Plugin Fixes:** - Fix #263: Port mismatch (DEFAULT_PORT 8888 → 8889) - Fix #264: Add daemon recovery after OpenClaw SIGUSR1 restarts - Fix OpenRouter support: Pass HINDSIGHT_API_LLM_BASE_URL to daemon - Fix macOS crashes: Auto-set FORCE_CPU flags for MPS/Metal issues **LLM Configuration Refactor:** - Auto-detect provider from standard env vars (OPENAI_API_KEY, etc.) - Support explicit override via HINDSIGHT_API_LLM_* env vars - Update model defaults (gemini-2.5-flash, openai/gpt-oss-20b) - Remove provider-specific base URL support (only HINDSIGHT_API_LLM_BASE_URL) **Documentation Updates:** - Rewrite OpenClaw integration docs with crystal clear examples - Add external API usage examples - Add OpenRouter free model examples - Update Quick Start with simplified provider setup Closes #263, Closes #264 * docs(openclaw): streamline docs and add config inspection - Remove duplicate/verbose sections (468 → 216 lines) - Add section showing how to check ~/.hindsight/embed config file - Add daemon status checking commands - Keep only essential configuration examples - Consolidate troubleshooting sections * fix(test): update daemon health check port from 8889 to 8888 The test was checking port 8889 but we changed the daemon to use port 8888.
284 lines
12 KiB
Python
284 lines
12 KiB
Python
"""Tests for daemon_client module."""
|
|
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from hindsight_embed import daemon_client
|
|
|
|
|
|
@pytest.fixture
|
|
def config():
|
|
"""Default config for tests."""
|
|
return {
|
|
"llm_api_key": "test-key",
|
|
"llm_provider": "openai",
|
|
"llm_model": "gpt-4o-mini",
|
|
"bank_id": "test-bank",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_cli_binary(tmp_path):
|
|
"""Create a mock CLI binary."""
|
|
cli_path = tmp_path / "hindsight"
|
|
cli_path.write_text("#!/bin/bash\nexit 0")
|
|
cli_path.chmod(0o755)
|
|
return cli_path
|
|
|
|
|
|
class TestRunCli:
|
|
"""Tests for run_cli function."""
|
|
|
|
def test_run_cli_with_external_api_url(self, config, mock_cli_binary, monkeypatch):
|
|
"""Test that external HINDSIGHT_EMBED_API_URL skips daemon startup."""
|
|
# Set up environment with external API URL
|
|
external_api_url = "http://external-api:8000"
|
|
monkeypatch.setenv("HINDSIGHT_EMBED_API_URL", external_api_url)
|
|
|
|
# Mock functions
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=mock_cli_binary)
|
|
mock_ensure_daemon = Mock(return_value=True)
|
|
mock_subprocess_run = Mock(return_value=Mock(returncode=0))
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
patch.object(daemon_client, "ensure_daemon_running", mock_ensure_daemon),
|
|
patch("subprocess.run", mock_subprocess_run),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify daemon was NOT started (since external API URL is set)
|
|
assert mock_ensure_daemon.call_count == 0
|
|
|
|
# Verify CLI was called
|
|
assert mock_subprocess_run.call_count == 1
|
|
call_args = mock_subprocess_run.call_args
|
|
|
|
# Verify environment contains the external API URL
|
|
assert call_args.kwargs["env"]["HINDSIGHT_API_URL"] == external_api_url
|
|
|
|
# Verify exit code
|
|
assert exit_code == 0
|
|
|
|
def test_run_cli_without_external_api_url(self, config, mock_cli_binary, monkeypatch):
|
|
"""Test that without external API URL, daemon is started."""
|
|
# Ensure HINDSIGHT_EMBED_API_URL is not set
|
|
monkeypatch.delenv("HINDSIGHT_EMBED_API_URL", raising=False)
|
|
|
|
# Mock functions
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=mock_cli_binary)
|
|
mock_ensure_daemon = Mock(return_value=True)
|
|
mock_subprocess_run = Mock(return_value=Mock(returncode=0))
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
patch.object(daemon_client, "ensure_daemon_running", mock_ensure_daemon),
|
|
patch("subprocess.run", mock_subprocess_run),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify daemon WAS started (since no external API URL)
|
|
assert mock_ensure_daemon.call_count == 1
|
|
assert mock_ensure_daemon.call_args[0][0] == config
|
|
|
|
# Verify CLI was called
|
|
assert mock_subprocess_run.call_count == 1
|
|
call_args = mock_subprocess_run.call_args
|
|
|
|
# Verify environment contains the local daemon URL
|
|
assert call_args.kwargs["env"]["HINDSIGHT_API_URL"] == daemon_client.DAEMON_URL
|
|
|
|
# Verify exit code
|
|
assert exit_code == 0
|
|
|
|
def test_run_cli_daemon_startup_failure(self, config, mock_cli_binary, monkeypatch):
|
|
"""Test that daemon startup failure is handled properly."""
|
|
# Ensure HINDSIGHT_EMBED_API_URL is not set
|
|
monkeypatch.delenv("HINDSIGHT_EMBED_API_URL", raising=False)
|
|
|
|
# Mock functions - daemon startup fails
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=mock_cli_binary)
|
|
mock_ensure_daemon = Mock(return_value=False) # Daemon fails to start
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
patch.object(daemon_client, "ensure_daemon_running", mock_ensure_daemon),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify daemon startup was attempted
|
|
assert mock_ensure_daemon.call_count == 1
|
|
|
|
# Verify exit code indicates failure
|
|
assert exit_code == 1
|
|
|
|
def test_run_cli_without_cli_binary(self, config, monkeypatch):
|
|
"""Test that missing CLI binary is handled properly."""
|
|
# Ensure HINDSIGHT_EMBED_API_URL is not set
|
|
monkeypatch.delenv("HINDSIGHT_EMBED_API_URL", raising=False)
|
|
|
|
# Mock functions - CLI not installed
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=None) # CLI not found
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify exit code indicates failure
|
|
assert exit_code == 1
|
|
|
|
def test_run_cli_with_api_token(self, config, mock_cli_binary, monkeypatch):
|
|
"""Test that HINDSIGHT_EMBED_API_TOKEN is passed through to the CLI."""
|
|
# Set up environment with external API URL and token
|
|
external_api_url = "http://external-api:8000"
|
|
api_token = "test-bearer-token-12345"
|
|
monkeypatch.setenv("HINDSIGHT_EMBED_API_URL", external_api_url)
|
|
monkeypatch.setenv("HINDSIGHT_EMBED_API_TOKEN", api_token)
|
|
|
|
# Mock functions
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=mock_cli_binary)
|
|
mock_ensure_daemon = Mock(return_value=True)
|
|
mock_subprocess_run = Mock(return_value=Mock(returncode=0))
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
patch.object(daemon_client, "ensure_daemon_running", mock_ensure_daemon),
|
|
patch("subprocess.run", mock_subprocess_run),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify daemon was NOT started (since external API URL is set)
|
|
assert mock_ensure_daemon.call_count == 0
|
|
|
|
# Verify CLI was called
|
|
assert mock_subprocess_run.call_count == 1
|
|
call_args = mock_subprocess_run.call_args
|
|
|
|
# Verify environment contains both the API URL and the API key
|
|
assert call_args.kwargs["env"]["HINDSIGHT_API_URL"] == external_api_url
|
|
assert call_args.kwargs["env"]["HINDSIGHT_API_KEY"] == api_token
|
|
|
|
# Verify exit code
|
|
assert exit_code == 0
|
|
|
|
|
|
class TestStartDaemon:
|
|
"""Tests for _start_daemon function."""
|
|
|
|
def test_start_daemon_respects_database_url_env(self, config, monkeypatch):
|
|
"""Test that HINDSIGHT_EMBED_API_DATABASE_URL is respected if already set."""
|
|
custom_db_url = "postgresql://custom:password@localhost:5432/custom_db"
|
|
monkeypatch.setenv("HINDSIGHT_EMBED_API_DATABASE_URL", custom_db_url)
|
|
|
|
# Mock subprocess.Popen to capture the env
|
|
captured_env = {}
|
|
|
|
def mock_popen(*args, **kwargs):
|
|
captured_env.update(kwargs.get("env", {}))
|
|
# Return a mock process
|
|
mock_proc = MagicMock()
|
|
mock_proc.poll.return_value = None
|
|
return mock_proc
|
|
|
|
# Reduce timeout to 0.1s to avoid waiting
|
|
monkeypatch.setattr(daemon_client, "DAEMON_STARTUP_TIMEOUT", 0.1)
|
|
|
|
# Mock daemon health check to fail immediately (so we don't wait for startup)
|
|
mock_is_running = Mock(return_value=False)
|
|
|
|
with (
|
|
patch("subprocess.Popen", side_effect=mock_popen),
|
|
patch.object(daemon_client, "_is_daemon_running", mock_is_running),
|
|
patch.object(daemon_client, "_find_hindsight_api_command", return_value=["fake-cmd"]),
|
|
):
|
|
# Start daemon (will fail health check, but we just want to verify env)
|
|
daemon_client._start_daemon(config)
|
|
|
|
# Verify the custom database URL was NOT overwritten
|
|
assert captured_env.get("HINDSIGHT_API_DATABASE_URL") == custom_db_url
|
|
|
|
def test_start_daemon_sets_default_database_url(self, config, monkeypatch):
|
|
"""Test that default database URL is set if not already in env."""
|
|
# Ensure HINDSIGHT_EMBED_API_DATABASE_URL is not set
|
|
monkeypatch.delenv("HINDSIGHT_EMBED_API_DATABASE_URL", raising=False)
|
|
|
|
# Mock subprocess.Popen to capture the env
|
|
captured_env = {}
|
|
|
|
def mock_popen(*args, **kwargs):
|
|
captured_env.update(kwargs.get("env", {}))
|
|
# Return a mock process
|
|
mock_proc = MagicMock()
|
|
mock_proc.poll.return_value = None
|
|
return mock_proc
|
|
|
|
# Reduce timeout to 0.1s to avoid waiting
|
|
monkeypatch.setattr(daemon_client, "DAEMON_STARTUP_TIMEOUT", 0.1)
|
|
|
|
# Mock daemon health check to fail immediately (so we don't wait for startup)
|
|
mock_is_running = Mock(return_value=False)
|
|
|
|
with (
|
|
patch("subprocess.Popen", side_effect=mock_popen),
|
|
patch.object(daemon_client, "_is_daemon_running", mock_is_running),
|
|
patch.object(daemon_client, "_find_hindsight_api_command", return_value=["fake-cmd"]),
|
|
):
|
|
# Start daemon (will fail health check, but we just want to verify env)
|
|
daemon_client._start_daemon(config)
|
|
|
|
# Verify the default database URL was set
|
|
assert captured_env.get("HINDSIGHT_API_DATABASE_URL") == "pg0://hindsight-embed"
|
|
|
|
|
|
class TestIsDaemonRunning:
|
|
"""Tests for _is_daemon_running function."""
|
|
|
|
def test_daemon_running_returns_true_on_200(self):
|
|
"""Test that daemon is considered running when health check returns 200."""
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.__enter__.return_value.get.return_value = mock_response
|
|
|
|
with patch("httpx.Client", return_value=mock_client):
|
|
assert daemon_client._is_daemon_running() is True
|
|
|
|
def test_daemon_not_running_returns_false_on_error(self):
|
|
"""Test that daemon is considered not running when health check fails."""
|
|
mock_client = MagicMock()
|
|
mock_client.__enter__.return_value.get.side_effect = Exception("Connection refused")
|
|
|
|
with patch("httpx.Client", return_value=mock_client):
|
|
assert daemon_client._is_daemon_running() is False
|
|
|
|
def test_daemon_not_running_returns_false_on_non_200(self):
|
|
"""Test that daemon is considered not running when health check returns non-200."""
|
|
mock_response = Mock()
|
|
mock_response.status_code = 500
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.__enter__.return_value.get.return_value = mock_response
|
|
|
|
with patch("httpx.Client", return_value=mock_client):
|
|
assert daemon_client._is_daemon_running() is False
|