Spaces:
Configuration error
Configuration error
| import json | |
| import pytest | |
| from agentcache.core import KV, ObservationEvents, ObservationStore, SearchService | |
| from agentcache.search import SearchIndex | |
| def test_observation_events_dataclass(): | |
| """Verify ObservationEvents dataclass has required typed callback lists.""" | |
| events = ObservationEvents() | |
| assert isinstance(events.on_added, list) | |
| assert isinstance(events.on_deleted, list) | |
| assert isinstance(events.on_folder_deleted, list) | |
| def test_observation_store_ingest_and_dedup(tmp_db): | |
| """Verify ObservationStore.ingest happy path, dedup on write, and events firing.""" | |
| kv = tmp_db | |
| bm25 = SearchIndex() | |
| search_svc = SearchService(bm25_index=bm25, kv=kv) | |
| added_events = [] | |
| events = ObservationEvents(on_added=[lambda ev: added_events.append(ev)]) | |
| store = ObservationStore(kv=kv, search_service=search_svc, events=events) | |
| payload1 = { | |
| "folderPath": "src/services", | |
| "agentId": "agent_alpha", | |
| "text": "Extracted ObservationStore module", | |
| "timestamp": "2026-07-24T12:00:00Z", | |
| } | |
| res1 = store.ingest(payload1) | |
| assert "observationId" in res1 | |
| assert "deduplicated" not in res1 | |
| obs_id1 = res1["observationId"] | |
| # Verify event fired | |
| assert len(added_events) == 1 | |
| assert added_events[0]["type"] == "folder_observation" | |
| assert added_events[0]["data"]["id"] == obs_id1 | |
| # Ingest distinct text -> deduplicated must NOT be set | |
| payload_distinct = { | |
| "folderPath": "src/services", | |
| "agentId": "agent_alpha", | |
| "text": "Distinct second observation text", | |
| "timestamp": "2026-07-24T12:01:00Z", | |
| } | |
| res_distinct = store.ingest(payload_distinct) | |
| assert "observationId" in res_distinct | |
| assert "deduplicated" not in res_distinct | |
| # Ingest duplicate text on same folderPath + agentId | |
| res2 = store.ingest(payload1) | |
| assert res2.get("observationId") == obs_id1 | |
| assert res2.get("deduplicated") is True | |
| # SearchService indexed obs1 | |
| search_results = search_svc.search("Extracted ObservationStore", limit=5) | |
| assert len(search_results) == 1 | |
| assert search_results[0]["id"] == obs_id1 | |
| def test_observation_store_max_cap(tmp_db, monkeypatch): | |
| """Verify MAX_OBS_PER_FOLDER cap is enforced during ingest.""" | |
| monkeypatch.setenv("MAX_OBS_PER_FOLDER", "2") | |
| kv = tmp_db | |
| store = ObservationStore(kv=kv) | |
| store.ingest( | |
| { | |
| "folderPath": "src/cap", | |
| "agentId": "agent_beta", | |
| "text": "First item", | |
| "timestamp": "2026-07-24T12:00:00Z", | |
| } | |
| ) | |
| store.ingest( | |
| { | |
| "folderPath": "src/cap", | |
| "agentId": "agent_beta", | |
| "text": "Second item", | |
| "timestamp": "2026-07-24T12:01:00Z", | |
| } | |
| ) | |
| with pytest.raises(ValueError, match="Folder observation limit reached"): | |
| store.ingest( | |
| { | |
| "folderPath": "src/cap", | |
| "agentId": "agent_beta", | |
| "text": "Third item", | |
| "timestamp": "2026-07-24T12:02:00Z", | |
| } | |
| ) | |
| def test_observation_store_manual_dedup(tmp_db): | |
| """Verify ObservationStore.dedup removes duplicate observations across pairs.""" | |
| kv = tmp_db | |
| store = ObservationStore(kv=kv) | |
| obs1 = { | |
| "id": "fobs_dup_1", | |
| "folderPath": "src/dedup", | |
| "agentId": "agent_gamma", | |
| "text": "Duplicated text sample", | |
| "timestamp": "2026-07-24T10:00:00Z", | |
| } | |
| obs2 = { | |
| "id": "fobs_dup_2", | |
| "folderPath": "src/dedup", | |
| "agentId": "agent_gamma", | |
| "text": "Duplicated text sample", | |
| "timestamp": "2026-07-24T11:00:00Z", | |
| } | |
| kv.set( | |
| KV.folders, | |
| "src/dedup:agent_gamma", | |
| {"folderPath": "src/dedup", "agentId": "agent_gamma"}, | |
| ) | |
| kv.set(KV.folder_obs("src/dedup", "agent_gamma"), "fobs_dup_1", obs1) | |
| kv.set(KV.folder_obs("src/dedup", "agent_gamma"), "fobs_dup_2", obs2) | |
| kv.set( | |
| KV.obs_lookup, | |
| "fobs_dup_1", | |
| {"folderPath": "src/dedup", "agentId": "agent_gamma"}, | |
| ) | |
| kv.set( | |
| KV.obs_lookup, | |
| "fobs_dup_2", | |
| {"folderPath": "src/dedup", "agentId": "agent_gamma"}, | |
| ) | |
| res = store.dedup("src/dedup", "agent_gamma") | |
| assert res["success"] is True | |
| assert res["deduplicated"] == 1 | |
| assert res["kept"] == 1 | |
| remaining = kv.list(KV.folder_obs("src/dedup", "agent_gamma")) | |
| assert len(remaining) == 1 | |
| assert remaining[0]["id"] == "fobs_dup_1" | |
| def test_observation_store_forget_full_and_partial(tmp_db): | |
| """Verify ObservationStore.forget partial and full pair deletions and events.""" | |
| kv = tmp_db | |
| bm25 = SearchIndex() | |
| search_svc = SearchService(bm25_index=bm25, kv=kv) | |
| deleted_obs = [] | |
| deleted_folders = [] | |
| events = ObservationEvents( | |
| on_deleted=[lambda ids: deleted_obs.extend(ids)], | |
| on_folder_deleted=[lambda fp, aid: deleted_folders.append((fp, aid))], | |
| ) | |
| store = ObservationStore(kv=kv, search_service=search_svc, events=events) | |
| obs1_id = store.ingest( | |
| { | |
| "folderPath": "src/forget", | |
| "agentId": "agent_forget", | |
| "text": "First item to forget", | |
| "timestamp": "2026-07-24T10:00:00Z", | |
| } | |
| )["observationId"] | |
| store.ingest( | |
| { | |
| "folderPath": "src/forget", | |
| "agentId": "agent_forget", | |
| "text": "Second item to keep initially", | |
| "timestamp": "2026-07-24T11:00:00Z", | |
| } | |
| )["observationId"] | |
| # Explicitly empty observationIds list -> graceful handling of no-op partial delete | |
| res_noop = store.forget( | |
| { | |
| "folderPath": "src/forget", | |
| "agentId": "agent_forget", | |
| "observationIds": [], | |
| } | |
| ) | |
| assert res_noop["success"] is True | |
| assert res_noop["deleted"] == 0 | |
| # 1. Partial deletion | |
| res_part = store.forget( | |
| { | |
| "folderPath": "src/forget", | |
| "agentId": "agent_forget", | |
| "observationIds": [obs1_id], | |
| } | |
| ) | |
| assert res_part["success"] is True | |
| assert res_part["deleted"] == 1 | |
| assert deleted_obs == [obs1_id] | |
| meta = kv.get(KV.folder_meta("src/forget", "agent_forget"), "meta") | |
| assert meta["obsCount"] == 1 | |
| # 2. Full folder pair deletion | |
| res_full = store.forget( | |
| { | |
| "folderPath": "src/forget", | |
| "agentId": "agent_forget", | |
| } | |
| ) | |
| assert res_full["success"] is True | |
| assert res_full["deleted"] == 1 | |
| assert len(deleted_folders) == 1 | |
| assert deleted_folders[0] == ("src/forget", "agent_forget") | |
| remaining_folders = kv.list(KV.folders) | |
| assert len(remaining_folders) == 0 | |
| def test_observation_store_timeline_sorting_and_filtering(tmp_db): | |
| """Verify ObservationStore.timeline sorting descending and timestamp filters.""" | |
| kv = tmp_db | |
| store = ObservationStore(kv=kv) | |
| store.ingest( | |
| { | |
| "folderPath": "src/t1", | |
| "agentId": "agent_t", | |
| "text": "Oldest observation", | |
| "timestamp": "2026-07-24T10:00:00Z", | |
| } | |
| ) | |
| store.ingest( | |
| { | |
| "folderPath": "src/t1", | |
| "agentId": "agent_t", | |
| "text": "Middle observation", | |
| "timestamp": "2026-07-24T12:00:00Z", | |
| } | |
| ) | |
| store.ingest( | |
| { | |
| "folderPath": "src/t2", | |
| "agentId": "agent_t", | |
| "text": "Newest observation", | |
| "timestamp": "2026-07-24T14:00:00Z", | |
| } | |
| ) | |
| # Timeline all | |
| tl_all = store.timeline(limit=10) | |
| assert len(tl_all) == 3 | |
| assert tl_all[0]["text"] == "Newest observation" | |
| assert tl_all[1]["text"] == "Middle observation" | |
| assert tl_all[2]["text"] == "Oldest observation" | |
| # Filter before and after | |
| tl_filtered = store.timeline( | |
| limit=10, | |
| before="2026-07-24T13:00:00Z", | |
| after="2026-07-24T11:00:00Z", | |
| ) | |
| assert len(tl_filtered) == 1 | |
| assert tl_filtered[0]["text"] == "Middle observation" | |
| # Filter folder_path | |
| tl_folder = store.timeline(folder_path="src/t1") | |
| assert len(tl_folder) == 2 | |
| def test_observation_store_rebuild_index_and_backfill_lookup(tmp_db): | |
| """Verify ObservationStore.rebuild_index and backfill_lookup populate missing entries.""" | |
| kv = tmp_db | |
| bm25 = SearchIndex() | |
| search_svc = SearchService(bm25_index=bm25, kv=kv) | |
| store = ObservationStore(kv=kv, search_service=search_svc) | |
| # 1. Backfill test: insert raw observation without lookup entry | |
| kv.set( | |
| KV.folders, | |
| "src/bf:agent_bf", | |
| {"folderPath": "src/bf", "agentId": "agent_bf", "obsCount": 1}, | |
| ) | |
| kv.set( | |
| KV.folder_obs("src/bf", "agent_bf"), | |
| "fobs_bf1", | |
| { | |
| "id": "fobs_bf1", | |
| "folderPath": "src/bf", | |
| "agentId": "agent_bf", | |
| "text": "Unindexed backfill observation", | |
| "timestamp": "2026-07-24T10:00:00Z", | |
| }, | |
| ) | |
| kv.delete(KV.obs_lookup, "fobs_bf1") | |
| assert kv.get(KV.obs_lookup, "fobs_bf1") is None | |
| store.backfill_lookup() | |
| lookup = kv.get(KV.obs_lookup, "fobs_bf1") | |
| assert lookup is not None | |
| assert lookup["folderPath"] == "src/bf" | |
| assert lookup["agentId"] == "agent_bf" | |
| # 2. Rebuild index test: search_svc index is currently 0 | |
| assert search_svc.bm25_size == 0 | |
| count = store.rebuild_index() | |
| assert count == 1 | |
| assert search_svc.bm25_size == 1 | |
| search_res = search_svc.search("Unindexed backfill", limit=5) | |
| assert len(search_res) == 1 | |
| assert search_res[0]["id"] == "fobs_bf1" | |
| def test_http_and_mcp_forget_and_timeline_end_to_end(app_client): | |
| """Verify Flask HTTP timeline & forget routes and MCP tools work end-to-end.""" | |
| client = app_client | |
| # Ingest observation via HTTP | |
| resp1 = client.post( | |
| "/agentcache/agent/observe", | |
| json={ | |
| "folderPath": "src/mcp_test", | |
| "agentId": "agent_mcp", | |
| "text": "Testing MCP tool handlers", | |
| "timestamp": "2026-07-24T15:00:00Z", | |
| }, | |
| ) | |
| assert resp1.status_code == 201 | |
| # Timeline via HTTP POST /agentcache/timeline | |
| resp_tl = client.post( | |
| "/agentcache/timeline", | |
| json={"folderPath": "src/mcp_test", "agentId": "agent_mcp"}, | |
| ) | |
| assert resp_tl.status_code == 200 | |
| data_tl = resp_tl.get_json() | |
| assert len(data_tl["observations"]) == 1 | |
| # Timeline via MCP tool | |
| resp_mcp_tl = client.post( | |
| "/agentcache/mcp/tools", | |
| json={ | |
| "name": "cache_timeline", | |
| "arguments": {"folderPath": "src/mcp_test", "agentId": "agent_mcp"}, | |
| }, | |
| ) | |
| assert resp_mcp_tl.status_code == 200 | |
| mcp_out = resp_mcp_tl.get_json()["content"][0]["text"] | |
| assert "Testing MCP tool handlers" in mcp_out | |
| # Forget via MCP tool | |
| resp_mcp_forget = client.post( | |
| "/agentcache/mcp/tools", | |
| json={ | |
| "name": "cache_forget", | |
| "arguments": {"folderPath": "src/mcp_test", "agentId": "agent_mcp"}, | |
| }, | |
| ) | |
| assert resp_mcp_forget.status_code == 200 | |
| # Post-forget timeline check | |
| resp_tl_post = client.post( | |
| "/agentcache/timeline", | |
| json={"folderPath": "src/mcp_test", "agentId": "agent_mcp"}, | |
| ) | |
| assert resp_tl_post.status_code == 200 | |
| assert len(resp_tl_post.get_json()["observations"]) == 0 | |
| def test_full_lifecycle_e2e_pass(app_client): | |
| """Full integration pass: observe -> search -> timeline -> forget -> rebuild (HTTP & MCP layers).""" | |
| client = app_client | |
| # 1. OBSERVE via MCP | |
| mcp_obs = client.post( | |
| "/agentcache/mcp/tools", | |
| json={ | |
| "name": "agent_observe", | |
| "arguments": { | |
| "folderPath": "src/e2e", | |
| "agentId": "agent_e2e", | |
| "text": "Refactored observation store completely", | |
| "timestamp": "2026-07-24T16:00:00Z", | |
| }, | |
| }, | |
| ) | |
| assert mcp_obs.status_code == 200 | |
| obs_id = json.loads(mcp_obs.get_json()["content"][0]["text"])["observationId"] | |
| # 2. SEARCH via HTTP | |
| search_resp = client.post( | |
| "/agentcache/search", | |
| json={ | |
| "query": "observation store", | |
| "folderPath": "src/e2e", | |
| "agentId": "agent_e2e", | |
| }, | |
| ) | |
| assert search_resp.status_code == 200 | |
| search_data = search_resp.get_json() | |
| assert len(search_data) == 1 | |
| assert search_data[0]["id"] == obs_id | |
| # 3. TIMELINE via MCP | |
| timeline_resp = client.post( | |
| "/agentcache/mcp/tools", | |
| json={ | |
| "name": "cache_timeline", | |
| "arguments": {"folderPath": "src/e2e", "agentId": "agent_e2e"}, | |
| }, | |
| ) | |
| assert timeline_resp.status_code == 200 | |
| assert ( | |
| "Refactored observation store" in timeline_resp.get_json()["content"][0]["text"] | |
| ) | |
| # 4. REBUILD via ObservationStore | |
| import agentcache.app as app_mod | |
| obs_store = app_mod.observation_store | |
| count = obs_store.rebuild_index() | |
| assert count >= 1 | |
| # 5. FORGET via HTTP / MCP | |
| forget_resp = client.post( | |
| "/agentcache/mcp/tools", | |
| json={ | |
| "name": "cache_forget", | |
| "arguments": {"folderPath": "src/e2e", "agentId": "agent_e2e"}, | |
| }, | |
| ) | |
| assert forget_resp.status_code == 200 | |