fleet-memory/hindsight-clients/python/tests/test_main_operations.py
Chris Bartholomew 8114ef440e
fix(python-client): async method parity and server keepalive timeout (#387)
* Fix async method parity and server keepalive timeout

The Python client's async methods were missing parameters available in
their sync counterparts, and the server's default keepalive timeout was
shorter than the client's, causing ServerDisconnectedError on reused
connections.

Server:
- Set uvicorn timeout_keep_alive to 30s (default was 5s). The Python
  client (aiohttp) has a 15s client-side keepalive, so the server must
  hold connections longer to prevent the client from writing to a
  closed socket.

Python client - async method parity:
- arecall(): add trace, query_timestamp, include_entities,
  include_chunks, max_entity_tokens, max_chunk_tokens. Return
  RecallResponse instead of list[RecallResult].
- areflect(): add max_tokens and response_schema.
- acreate_bank(): new async method.
- aset_mission(): new async method.
- adelete_bank(): new async method.

Tests:
- Add test verifying uvicorn keepalive timeout exceeds client default.
- Add async tests for arecall (include_chunks, include_entities, trace,
  full params), areflect (max_tokens, structured output), and
  adelete_bank.

* Fix flaky tag tests by using entity-rich content and asserting on tags

The tag tests were unreliable because:
- Generic content ("Project X meeting notes") was frequently collapsed
  during fact extraction, leaving no memories to recall
- Assertions checked LLM-rewritten text for literal substrings instead
  of checking tags, which is what the tests are actually verifying

Fix: use distinctive, entity-rich content (named people with specific
actions) that reliably survives fact extraction, and assert on tag
membership rather than text content.
2026-02-17 16:00:14 +01:00

883 lines
30 KiB
Python

"""
Tests for Hindsight Python client.
These tests require a running Hindsight API server.
"""
import os
import uuid
from datetime import datetime
import pytest
from hindsight_client import Hindsight
# Test configuration
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
@pytest.fixture
def client():
"""Create a Hindsight client for testing."""
with Hindsight(base_url=HINDSIGHT_API_URL) as client:
yield client
@pytest.fixture
def bank_id():
"""Provide a unique test bank ID for each test."""
return f"test_bank_{uuid.uuid4().hex[:12]}"
class TestRetain:
"""Tests for storing memories."""
def test_retain_single_memory(self, client, bank_id):
"""Test storing a single memory."""
response = client.retain(
bank_id=bank_id,
content="Alice loves artificial intelligence and machine learning",
)
assert response is not None
assert response.success is True
def test_retain_memory_with_context(self, client, bank_id):
"""Test storing a memory with context and timestamp."""
response = client.retain(
bank_id=bank_id,
content="Bob went hiking in the mountains",
timestamp=datetime(2024, 1, 15, 10, 30),
context="outdoor activities",
)
assert response is not None
assert response.success is True
def test_retain_batch_memories(self, client, bank_id):
"""Test storing multiple memories in batch."""
items = [
{"content": "Charlie enjoys reading science fiction books"},
{"content": "Diana is learning to play the guitar", "context": "hobbies"},
{
"content": "Eve completed a marathon last month",
"event_date": datetime(2024, 10, 15),
},
]
response = client.retain_batch(
bank_id=bank_id,
items=items,
)
assert response is not None
assert response.success is True
assert response.items_count == 3
class TestRecall:
"""Tests for searching memories."""
@pytest.fixture(autouse=True)
def setup_memories(self, client, bank_id):
"""Setup: Store some test memories before search tests."""
client.retain_batch(
bank_id=bank_id,
items=[
{"content": "Alice loves programming in Python"},
{"content": "Bob enjoys hiking and outdoor adventures"},
{"content": "Charlie is interested in quantum physics"},
{"content": "Diana plays the violin beautifully"},
],
)
def test_recall_basic(self, client, bank_id):
"""Test basic memory search."""
response = client.recall(
bank_id=bank_id,
query="What does Alice like?",
)
assert response is not None
assert response.results is not None
assert len(response.results) > 0
# Check that at least one result contains relevant information
result_texts = [r.text for r in response.results]
assert any("Alice" in text or "Python" in text or "programming" in text for text in result_texts)
def test_recall_with_max_tokens(self, client, bank_id):
"""Test search with token limit."""
response = client.recall(
bank_id=bank_id,
query="outdoor activities",
max_tokens=1024,
)
assert response is not None
assert response.results is not None
def test_recall_full_featured(self, client, bank_id):
"""Test recall with all features."""
response = client.recall(
bank_id=bank_id,
query="What are people's hobbies?",
types=["world"],
max_tokens=2048,
trace=True,
)
assert response is not None
assert response.results is not None
class TestReflect:
"""Tests for thinking/reasoning operations."""
@pytest.fixture(autouse=True)
def setup_memories(self, client, bank_id):
"""Setup: Store some test memories and bank background."""
client.create_bank(
bank_id=bank_id,
mission="I am a helpful AI assistant interested in technology and science.",
)
client.retain_batch(
bank_id=bank_id,
items=[
{"content": "The Python programming language is great for data science"},
{"content": "Machine learning models can recognize patterns in data"},
{"content": "Neural networks are inspired by biological neurons"},
],
)
def test_reflect_basic(self, client, bank_id):
"""Test basic reflect operation."""
response = client.reflect(
bank_id=bank_id,
query="What do you think about artificial intelligence?",
)
assert response is not None
assert response.text is not None
assert len(response.text) > 0
def test_reflect_with_context(self, client, bank_id):
"""Test reflect with additional context."""
response = client.reflect(
bank_id=bank_id,
query="Should I learn Python?",
context="I'm interested in starting a career in data science",
)
assert response is not None
assert response.text is not None
assert len(response.text) > 0
def test_reflect_with_max_tokens(self, client, bank_id):
"""Test reflect with custom max_tokens parameter."""
response = client.reflect(
bank_id=bank_id,
query="What do you think about Python?",
max_tokens=500,
)
assert response is not None
assert response.text is not None
assert len(response.text) > 0
def test_reflect_with_structured_output(self, client, bank_id):
"""Test reflect with structured output via response_schema.
When response_schema is provided, the response returns structured_output
field parsed according to the provided JSON schema.
"""
from pydantic import BaseModel
# Define schema using Pydantic model
class RecommendationResponse(BaseModel):
recommendation: str
reasons: list[str]
confidence: str | None = None # Optional for LLM flexibility
response = client.reflect(
bank_id=bank_id,
query="What programming language should I learn for data science?",
response_schema=RecommendationResponse.model_json_schema(),
max_tokens=10000,
)
assert response is not None
# Verify structured output is present and can be parsed into model
assert response.structured_output is not None
result = RecommendationResponse.model_validate(response.structured_output)
assert result.recommendation
assert isinstance(result.reasons, list)
class TestListMemories:
"""Tests for listing memories."""
@pytest.fixture(autouse=True)
def setup_memories(self, client, bank_id):
"""Setup: Store some test memories synchronously."""
client.retain_batch(
bank_id=bank_id,
items=[{"content": f"Alice likes topic number {i}"} for i in range(5)],
retain_async=False, # Wait for fact extraction to complete
)
def test_list_all_memories(self, client, bank_id):
"""Test listing all memories."""
response = client.list_memories(bank_id=bank_id)
assert response is not None
assert response.items is not None
assert response.total is not None
assert len(response.items) > 0
def test_list_with_pagination(self, client, bank_id):
"""Test listing with pagination."""
response = client.list_memories(
bank_id=bank_id,
limit=2,
offset=0,
)
assert response is not None
assert response.items is not None
assert len(response.items) <= 2
class TestEndToEndWorkflow:
"""End-to-end workflow tests."""
def test_complete_workflow(self, client):
"""Test a complete workflow: create bank, store, search, reflect."""
workflow_bank_id = "workflow_test_" + datetime.now().strftime("%Y%m%d_%H%M%S")
# 1. Create bank
client.create_bank(
bank_id=workflow_bank_id,
mission="I am a software engineer who loves Python programming.",
)
# 2. Store memories
store_response = client.retain_batch(
bank_id=workflow_bank_id,
items=[
{"content": "I completed a project using FastAPI"},
{"content": "I learned about async programming in Python"},
{"content": "I enjoy working on open source projects"},
],
)
assert store_response.success is True
# 3. Search for relevant memories
search_results = client.recall(
bank_id=workflow_bank_id,
query="What programming technologies do I use?",
)
assert len(search_results.results) > 0
# 4. Generate contextual answer
reflect_response = client.reflect(
bank_id=workflow_bank_id,
query="What are my professional interests?",
)
assert reflect_response.text is not None
assert len(reflect_response.text) > 0
class TestBankStats:
"""Tests for bank statistics endpoint."""
@pytest.fixture(autouse=True)
def setup_memories(self, client, bank_id):
"""Setup: Store some test memories."""
client.retain_batch(
bank_id=bank_id,
items=[
{"content": "Alice likes Python programming"},
{"content": "Bob enjoys hiking in the mountains"},
],
retain_async=False,
)
@pytest.mark.asyncio
async def test_get_bank_stats(self, client, bank_id):
"""Test getting bank statistics."""
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import BanksApi
config = Configuration(host=HINDSIGHT_API_URL)
api_client = ApiClient(config)
api = BanksApi(api_client)
stats = await api.get_agent_stats(bank_id=bank_id)
assert stats is not None
assert stats.bank_id == bank_id
assert stats.total_nodes >= 0
assert stats.total_links >= 0
assert stats.total_documents >= 0
assert isinstance(stats.nodes_by_fact_type, dict)
assert isinstance(stats.links_by_link_type, dict)
class TestOperations:
"""Tests for operations endpoints."""
@pytest.mark.asyncio
async def test_list_operations(self, client, bank_id):
"""Test listing operations."""
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import OperationsApi
# First create an async operation using aretain_batch
await client.aretain_batch(
bank_id=bank_id,
items=[{"content": "Test content for async operation"}],
retain_async=True,
)
config = Configuration(host=HINDSIGHT_API_URL)
api_client = ApiClient(config)
api = OperationsApi(api_client)
response = await api.list_operations(bank_id=bank_id)
assert response is not None
assert response.bank_id == bank_id
assert isinstance(response.operations, list)
class TestDocuments:
"""Tests for document endpoints."""
def test_delete_document(self, client, bank_id):
"""Test deleting a document."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DocumentsApi
# First create a document using sync retain
doc_id = f"test-doc-{uuid.uuid4().hex[:8]}"
client.retain(
bank_id=bank_id,
content="Test document content for deletion",
document_id=doc_id,
)
# Use async API for delete (run in event loop)
async def do_delete():
config = Configuration(host=HINDSIGHT_API_URL)
api_client = ApiClient(config)
api = DocumentsApi(api_client)
# Try to delete
response = await api.delete_document(bank_id=bank_id, document_id=doc_id)
return response
response = asyncio.get_event_loop().run_until_complete(do_delete())
assert response is not None
assert response.success is True
assert response.document_id == doc_id
assert response.memory_units_deleted >= 0
def test_get_document(self, client, bank_id):
"""Test getting a document."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DocumentsApi
# First create a document
doc_id = f"test-doc-{uuid.uuid4().hex[:8]}"
client.retain(
bank_id=bank_id,
content="Test document content for retrieval",
document_id=doc_id,
)
async def do_get():
config = Configuration(host=HINDSIGHT_API_URL)
api_client = ApiClient(config)
api = DocumentsApi(api_client)
return await api.get_document(bank_id=bank_id, document_id=doc_id)
document = asyncio.get_event_loop().run_until_complete(do_get())
assert document is not None
assert document.id == doc_id
assert "Test document content" in document.original_text
class TestEntities:
"""Tests for entity endpoints."""
@pytest.fixture(autouse=True)
def setup_memories(self, client, bank_id):
"""Setup: Store memories that will generate entities."""
client.retain_batch(
bank_id=bank_id,
items=[
{"content": "Alice works at Google as a software engineer"},
{"content": "Bob is friends with Alice and works at Microsoft"},
],
retain_async=False,
)
def test_list_entities(self, client, bank_id):
"""Test listing entities."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import EntitiesApi
async def do_list():
config = Configuration(host=HINDSIGHT_API_URL)
api_client = ApiClient(config)
api = EntitiesApi(api_client)
return await api.list_entities(bank_id=bank_id)
response = asyncio.get_event_loop().run_until_complete(do_list())
assert response is not None
assert response.items is not None
assert isinstance(response.items, list)
# Verify pagination fields
assert response.total is not None
assert response.limit is not None
assert response.offset is not None
assert response.offset == 0
assert response.limit == 100 # default limit
def test_list_entities_with_pagination(self, client, bank_id):
"""Test listing entities with pagination parameters."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import EntitiesApi
async def do_list_paginated():
config = Configuration(host=HINDSIGHT_API_URL)
api_client = ApiClient(config)
api = EntitiesApi(api_client)
# Test with custom limit
response = await api.list_entities(bank_id=bank_id, limit=5, offset=0)
assert response.limit == 5
assert response.offset == 0
assert len(response.items) <= 5
# Test with offset
response_offset = await api.list_entities(bank_id=bank_id, limit=1, offset=1)
assert response_offset.offset == 1
assert response_offset.limit == 1
return response
asyncio.get_event_loop().run_until_complete(do_list_paginated())
def test_get_entity(self, client, bank_id):
"""Test getting a specific entity."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import EntitiesApi
async def do_test():
config = Configuration(host=HINDSIGHT_API_URL)
api_client = ApiClient(config)
api = EntitiesApi(api_client)
# First list entities to get an ID
list_response = await api.list_entities(bank_id=bank_id)
if list_response.items and len(list_response.items) > 0:
entity_id = list_response.items[0].id
# Get the entity
entity = await api.get_entity(bank_id=bank_id, entity_id=entity_id)
return entity_id, entity
return None, None
entity_id, entity = asyncio.get_event_loop().run_until_complete(do_test())
if entity_id:
assert entity is not None
assert entity.id == entity_id
class TestTags:
"""Tests for tags filtering functionality."""
@pytest.fixture(autouse=True)
def setup_memories(self, client, bank_id):
"""Setup: Store memories with different tags."""
client.retain_batch(
bank_id=bank_id,
items=[
{"content": "Alice presented the Q3 roadmap at the Monday standup", "tags": ["project_x", "meetings"]},
{"content": "Bob wrote the architecture document for the new auth system", "tags": ["project_x", "docs"]},
{"content": "Charlie led the sprint planning session for the mobile app", "tags": ["project_y", "meetings"]},
{"content": "Diana announced the company picnic for next Friday", "tags": ["company"]},
{"content": "Eve mentioned she likes pineapple on pizza"}, # no tags
],
retain_async=False,
)
def test_recall_with_tags_any(self, client, bank_id):
"""Test recall with tags using 'any' match (includes untagged)."""
response = client.recall(
bank_id=bank_id,
query="What has everyone been working on?",
tags=["project_x"],
tags_match="any",
max_tokens=16000,
)
assert response is not None
assert response.results is not None
assert len(response.results) > 0
# 'any' mode: results should include items matching the tag or untagged items
result_tags = [set(r.tags) if r.tags else set() for r in response.results]
assert any("project_x" in tags for tags in result_tags)
def test_recall_with_tags_any_strict(self, client, bank_id):
"""Test recall with tags using 'any_strict' match (excludes untagged)."""
response = client.recall(
bank_id=bank_id,
query="What has everyone been working on?",
tags=["project_x"],
tags_match="any_strict",
max_tokens=16000,
)
assert response is not None
assert response.results is not None
assert len(response.results) > 0
# any_strict: every result must have the project_x tag
for r in response.results:
assert r.tags is not None
assert "project_x" in r.tags
def test_recall_with_tags_all_strict(self, client, bank_id):
"""Test recall with tags using 'all_strict' match (AND matching)."""
response = client.recall(
bank_id=bank_id,
query="What has everyone been working on?",
tags=["project_x", "meetings"],
tags_match="all_strict",
max_tokens=16000,
)
assert response is not None
assert response.results is not None
assert len(response.results) > 0
# all_strict: every result must have BOTH tags
for r in response.results:
assert r.tags is not None
assert "project_x" in r.tags
assert "meetings" in r.tags
def test_recall_with_multiple_tags_any(self, client, bank_id):
"""Test recall with multiple tags using 'any_strict' match (OR)."""
response = client.recall(
bank_id=bank_id,
query="What has everyone been working on?",
tags=["project_x", "project_y"],
tags_match="any_strict",
max_tokens=16000,
)
assert response is not None
assert response.results is not None
assert len(response.results) > 0
# any_strict with multiple tags: every result must have at least one of the tags
for r in response.results:
assert r.tags is not None
assert "project_x" in r.tags or "project_y" in r.tags
def test_reflect_with_tags(self, client, bank_id):
"""Test reflect with tags filtering."""
response = client.reflect(
bank_id=bank_id,
query="Summarize project X activities",
tags=["project_x"],
tags_match="any_strict",
)
assert response is not None
assert response.text is not None
assert len(response.text) > 0
def test_retain_with_tags(self, client, bank_id):
"""Test storing a memory with tags."""
response = client.retain(
bank_id=bank_id,
content="Frank deployed the billing microservice to production on Tuesday",
tags=["project_z", "features"],
)
assert response is not None
assert response.success is True
# Verify we can recall it with the tag
recall_response = client.recall(
bank_id=bank_id,
query="What did Frank deploy?",
tags=["project_z"],
tags_match="any_strict",
max_tokens=16000,
)
assert recall_response is not None
assert len(recall_response.results) > 0
# any_strict: every result must have the project_z tag
for r in recall_response.results:
assert r.tags is not None
assert "project_z" in r.tags
def test_retain_batch_with_document_tags(self, client, bank_id):
"""Test batch retain with document-level tags."""
response = client.retain_batch(
bank_id=bank_id,
items=[
{"content": "First item in batch"},
{"content": "Second item in batch"},
],
document_tags=["batch_import", "test_data"],
retain_async=False,
)
assert response is not None
assert response.success is True
assert response.items_count == 2
class TestDeleteBank:
"""Tests for bank deletion."""
def test_delete_bank(self, client):
"""Test deleting a bank."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import BanksApi
# Create a unique bank for this test
bank_id = f"test_bank_delete_{uuid.uuid4().hex[:12]}"
# Create bank with some data
client.create_bank(
bank_id=bank_id,
mission="This bank will be deleted",
)
client.retain(
bank_id=bank_id,
content="Some memory to store",
)
async def do_delete():
config = Configuration(host=HINDSIGHT_API_URL)
api_client = ApiClient(config)
api = BanksApi(api_client)
return await api.delete_bank(bank_id=bank_id)
response = asyncio.get_event_loop().run_until_complete(do_delete())
assert response is not None
assert response.success is True
# Verify bank data is deleted - memories should be gone
memories = client.list_memories(bank_id=bank_id)
assert memories.total == 0
class TestMission:
"""Tests for mission operations."""
def test_set_mission(self, client, bank_id):
"""Test setting a bank's mission."""
response = client.set_mission(
bank_id=bank_id,
mission="Be a helpful PM tracking sprint progress and team capacity",
)
assert response is not None
assert response.bank_id == bank_id
assert response.mission == "Be a helpful PM tracking sprint progress and team capacity"
class TestAsyncRecall:
"""Tests for async recall with full feature parity."""
@pytest.fixture(autouse=True)
async def setup_memories(self, client, bank_id):
"""Setup: Store some test memories before search tests."""
await client.aretain_batch(
bank_id=bank_id,
items=[
{"content": "Alice loves programming in Python"},
{"content": "Bob enjoys hiking and outdoor adventures"},
{"content": "Charlie is interested in quantum physics"},
],
)
@pytest.mark.asyncio
async def test_arecall_returns_recall_response(self, client, bank_id):
"""Test that arecall returns a RecallResponse (not a list)."""
from hindsight_client_api.models.recall_response import RecallResponse
response = await client.arecall(
bank_id=bank_id,
query="What does Alice like?",
)
assert isinstance(response, RecallResponse)
assert response.results is not None
assert len(response.results) > 0
@pytest.mark.asyncio
async def test_arecall_with_include_chunks(self, client, bank_id):
"""Test arecall with include_chunks returns raw text chunks."""
response = await client.arecall(
bank_id=bank_id,
query="programming",
include_chunks=True,
)
assert response is not None
assert response.results is not None
# chunks should be present (may be empty dict if not yet consolidated)
assert response.chunks is not None
@pytest.mark.asyncio
async def test_arecall_with_include_entities(self, client, bank_id):
"""Test arecall with include_entities."""
response = await client.arecall(
bank_id=bank_id,
query="Alice",
include_entities=True,
max_entity_tokens=500,
)
assert response is not None
assert response.results is not None
@pytest.mark.asyncio
async def test_arecall_with_trace(self, client, bank_id):
"""Test arecall with trace enabled."""
response = await client.arecall(
bank_id=bank_id,
query="outdoor activities",
trace=True,
)
assert response is not None
assert response.results is not None
@pytest.mark.asyncio
async def test_arecall_full_featured(self, client, bank_id):
"""Test arecall with all parameters."""
response = await client.arecall(
bank_id=bank_id,
query="What are people's interests?",
types=["world"],
max_tokens=2048,
budget="high",
trace=True,
include_chunks=True,
max_chunk_tokens=4096,
include_entities=True,
max_entity_tokens=500,
)
assert response is not None
assert response.results is not None
assert response.chunks is not None
class TestAsyncReflect:
"""Tests for async reflect with full feature parity."""
@pytest.fixture(autouse=True)
async def setup_memories(self, client, bank_id):
"""Setup: Store some test memories and bank background."""
await client.acreate_bank(
bank_id=bank_id,
mission="I am a helpful AI assistant interested in technology and science.",
)
await client.aretain_batch(
bank_id=bank_id,
items=[
{"content": "The Python programming language is great for data science"},
{"content": "Machine learning models can recognize patterns in data"},
],
)
@pytest.mark.asyncio
async def test_areflect_with_max_tokens(self, client, bank_id):
"""Test areflect with max_tokens parameter."""
response = await client.areflect(
bank_id=bank_id,
query="What do you think about Python?",
max_tokens=500,
)
assert response is not None
assert response.text is not None
assert len(response.text) > 0
@pytest.mark.asyncio
async def test_areflect_with_structured_output(self, client, bank_id):
"""Test areflect with response_schema for structured output."""
from pydantic import BaseModel
class RecommendationResponse(BaseModel):
recommendation: str
reasons: list[str]
confidence: str | None = None
response = await client.areflect(
bank_id=bank_id,
query="What programming language should I learn for data science?",
response_schema=RecommendationResponse.model_json_schema(),
max_tokens=10000,
)
assert response is not None
assert response.structured_output is not None
result = RecommendationResponse.model_validate(response.structured_output)
assert result.recommendation
assert isinstance(result.reasons, list)
class TestAsyncDeleteBank:
"""Tests for async bank deletion."""
@pytest.mark.asyncio
async def test_adelete_bank(self, client):
"""Test deleting a bank using the async method."""
bank_id = f"test_bank_adelete_{uuid.uuid4().hex[:12]}"
# Create bank with some data using async API
await client.acreate_bank(
bank_id=bank_id,
mission="This bank will be deleted via async",
)
await client.aretain(
bank_id=bank_id,
content="Some memory to store",
)
# Delete using async method
response = await client.adelete_bank(bank_id=bank_id)
assert response is not None
assert response.success is True