agent-Specialization/test/test_conversation_workspace_storage.py
JOJO b7fe5f2b02 feat(memory): improve workspace memory and skill workflows
Implement workspace-scoped conversation storage with legacy migration based on host workspace metadata, active host workspace state protection, and frontend/backend conversation list scoping.

Add proactive recent-conversation prompt support with a configurable personal-space limit, frozen per conversation metadata, empty-conversation filtering, and a dedicated prompt template.

Add conversation_search and conversation_review tools with readable formatted results, workspace-only access, current-conversation exclusion, multi-keyword search, list mode, message/tool counts, read/save review modes, long-review fallback to .agents/review, and frontend detail renderers.

Move internal workspace artifacts under .agents, update upload/skills/compact/review paths, show .agents internal directories in prompt file trees, and adjust versioning ignores.

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

Update memory stamping with conversation/time source metadata and include frontend icon/status/display updates plus tests for workspace conversation storage and skills management.
2026-05-27 03:04:43 +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()