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.
152 lines
6.4 KiB
Python
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()
|