agent-Specialization/test/test_conversation_workspace_storage.py
JOJO 398ee6e760 feat(memory): refactor proactive memory and conversation recall
Refactor conversation persistence into workspace-scoped storage with host workspace legacy migration, current workspace filtering, and active host workspace state protection.

Add proactive recent-conversation prompt injection with personal-space controls, configurable item count, empty-conversation filtering, and frozen per-conversation prompt metadata to keep request caches stable.

Introduce conversation_search and conversation_review tools for current-workspace history: multi-keyword search, list mode, current conversation exclusion, message/tool counts, formatted model output, read/save review modes, long-review fallback, and .agents/review storage.

Move internal workspace artifacts under .agents, including uploads, skills, compact results, and reviews; update prompt file-tree visibility, compression output, upload paths, and hidden versioning exclusions.

Add create_skill workflow and a Skills tool category, move read_skill into Skills, validate/归档 skill folders, support host global skills and Docker/API private skills, and sync private skills into .agents/skills and skill prompts.

Update frontend tool icons, statuses, and expanded renderers for conversation recall and skills workflows; add workspace conversation and skills-manager tests.
2026-05-27 03:09:42 +08:00

152 lines
6.4 KiB
Python

from __future__ import annotations
import json
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from utils.conversation_manager import ConversationManager
def _write_conversation(path: Path, conv_id: str, project_path: str) -> None:
payload = {
"id": conv_id,
"title": f"title {conv_id}",
"created_at": "2026-05-26T10:00:00",
"updated_at": "2026-05-26T10:00:00",
"messages": [{"role": "user", "content": "hello"}],
"todo_list": None,
"metadata": {
"project_path": project_path,
"project_relative_path": None,
"thinking_mode": False,
"run_mode": "fast",
"model_key": None,
"has_images": False,
"has_videos": False,
"total_messages": 1,
"total_tools": 0,
"status": "active",
},
"token_statistics": {
"total_input_tokens": 0,
"total_output_tokens": 0,
"total_tokens": 0,
"current_context_tokens": 0,
"updated_at": "2026-05-26T10:00:00",
},
}
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
class ConversationWorkspaceStorageTest(unittest.TestCase):
def test_legacy_conversations_are_migrated_and_scoped_by_workspace(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
data_dir = root / "data"
conversations_dir = data_dir / "conversations"
conversations_dir.mkdir(parents=True)
ws1_path = root / "workspace1"
ws1_child = ws1_path / "child"
ws2_path = root / "workspace2"
other_path = root / "outside"
ws1_child.mkdir(parents=True)
ws2_path.mkdir(parents=True)
other_path.mkdir(parents=True)
host_workspaces = root / "host_workspaces.json"
host_workspaces.write_text(
json.dumps(
{
"workspaces": [
{"workspace_id": "workspace1", "label": "W1", "path": str(ws1_path)},
{"workspace_id": "workspace2", "label": "W2", "path": str(ws2_path)},
]
},
ensure_ascii=False,
),
encoding="utf-8",
)
_write_conversation(conversations_dir / "conv_ws1.json", "conv_ws1", str(ws1_child))
_write_conversation(conversations_dir / "conv_ws2.json", "conv_ws2", str(ws2_path))
_write_conversation(conversations_dir / "conv_unmatched.json", "conv_unmatched", str(other_path))
with patch("utils.conversation_manager.HOST_WORKSPACES_FILE", str(host_workspaces)):
manager_ws1 = ConversationManager(base_dir=str(data_dir), project_path=str(ws1_path))
self.assertTrue((conversations_dir / "workspace1" / "conv_ws1.json").exists())
self.assertTrue((conversations_dir / "workspace2" / "conv_ws2.json").exists())
self.assertTrue((conversations_dir / "conv_unmatched.json").exists())
listed_ids = {item["id"] for item in manager_ws1.get_conversation_list(limit=20)["conversations"]}
self.assertEqual(listed_ids, {"conv_ws1"})
self.assertIsNotNone(manager_ws1.load_conversation("conv_ws1"))
self.assertIsNone(manager_ws1.load_conversation("conv_ws2"))
self.assertIsNone(manager_ws1.load_conversation("conv_unmatched"))
created_id = manager_ws1.create_conversation(project_path=str(ws1_path), initial_messages=[])
self.assertTrue((conversations_dir / "workspace1" / f"{created_id}.json").exists())
self.assertFalse((conversations_dir / f"{created_id}.json").exists())
def test_search_accepts_three_keywords_and_excludes_current_conversation(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
data_dir = root / "data"
workspace = root / "workspace"
workspace.mkdir(parents=True)
manager = ConversationManager(base_dir=str(data_dir), project_path=str(workspace))
empty_id = manager.create_conversation(project_path=str(workspace), initial_messages=[])
time.sleep(0.002)
current_id = manager.create_conversation(
project_path=str(workspace),
initial_messages=[{"role": "user", "content": "当前对话包含 alpha"}],
)
time.sleep(0.002)
beta_id = manager.create_conversation(
project_path=str(workspace),
initial_messages=[{"role": "user", "content": "旧对话包含 beta"}],
)
time.sleep(0.002)
gamma_id = manager.create_conversation(
project_path=str(workspace),
initial_messages=[{"role": "user", "content": "旧对话包含 gamma"}],
)
time.sleep(0.002)
delta_id = manager.create_conversation(
project_path=str(workspace),
initial_messages=[{"role": "user", "content": "旧对话包含 delta"}],
)
results = manager.search_conversation_summaries(
keywords=["alpha", "beta", "gamma", "delta"],
exclude_conversation_id=current_id,
limit=10,
)
result_ids = {item["id"] for item in results}
self.assertNotIn(current_id, result_ids)
self.assertIn(beta_id, result_ids)
self.assertIn(gamma_id, result_ids)
self.assertNotIn(delta_id, result_ids)
self.assertNotIn(empty_id, result_ids)
beta_result = next(item for item in results if item["id"] == beta_id)
self.assertEqual(beta_result.get("total_messages"), 1)
self.assertEqual(beta_result.get("total_tools"), 0)
listed = manager.search_conversation_summaries(limit=10)
listed_ids = {item["id"] for item in listed}
self.assertNotIn(empty_id, listed_ids)
self.assertIn(beta_id, listed_ids)
recent = manager.get_recent_conversation_summaries(limit=10)
recent_ids = {item["id"] for item in recent}
self.assertNotIn(empty_id, recent_ids)
if __name__ == "__main__":
unittest.main()