Files
blackbird/aibox/tests/test_conversation_history.py
T
qing 85133868f3 refactor(aibox): 抽离ConversationHistory至subsystems(Wave2阶段1)
- 新增 subsystems/conversation_history.py: 迁移对话历史管理器(318行),MemoryDatabase用TYPE_CHECKING注解避免循环导入
- subsystems/__init__.py: 导出ConversationHistory,更新docstring含Wave2
- aibox.py: 移除ConversationHistory类定义(3706->3388行),仅保留import与类型注解
- 新增 tests/test_conversation_history.py: 41项单测,覆盖构造/当前对话/归档/历史加载/memory_db集成
- 测试基线: 171->212项全过(12.13s),无回归
2026-08-13 16:54:32 +08:00

428 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""subsystems.conversation_history 单元测试
覆盖 ConversationHistory 的核心行为:
- 构造与目录初始化
- 当前对话管理(开始/添加消息/添加工具调用/保存/重置/结束)
- 归档与历史索引
- 历史加载与上下文构建
- 列表与加载指定对话
- memory_db 注入时归档/结束写入记忆
设计原则:
- 使用 tmp_path fixture 隔离文件系统副作用
- 不依赖 aibox 主模块、DeepSeek API、websockets
- memory_db 用真实 MemoryDatabase + 临时 sqlite 文件验证集成
"""
import json
import time
from datetime import datetime
import pytest
from subsystems import ConversationHistory, MemoryDatabase
from common import constants
@pytest.fixture
def history(tmp_path):
"""独立临时目录的 ConversationHistory。"""
return ConversationHistory(save_dir=str(tmp_path), load_history=True)
@pytest.fixture
def history_no_load(tmp_path):
"""load_history=False 的实例,用于验证上下文加载开关。"""
return ConversationHistory(save_dir=str(tmp_path), load_history=False)
@pytest.fixture
def mem_db(tmp_path):
"""独立临时文件的 MemoryDatabase。"""
return MemoryDatabase(db_path=str(tmp_path / "test_memory.db"))
@pytest.fixture
def history_with_mem(tmp_path, mem_db):
"""注入 MemoryDatabase 的 ConversationHistory。"""
return ConversationHistory(save_dir=str(tmp_path), memory_db=mem_db, load_history=True)
# ==================== 构造与目录初始化 ====================
def test_init_creates_dirs(tmp_path):
"""构造时应创建 save_dir 与 archive_dir。"""
save_dir = tmp_path / "ai"
ConversationHistory(save_dir=str(save_dir))
assert save_dir.exists() and save_dir.is_dir()
archive = save_dir / constants.ARCHIVE_DIR
assert archive.exists() and archive.is_dir()
def test_init_loads_empty_index(tmp_path):
"""无历史索引文件时初始化为空结构。"""
h = ConversationHistory(save_dir=str(tmp_path))
assert h.history_index == {"conversations": [], "last_id": 0}
assert h.current_conversation is None
def test_init_loads_existing_index(tmp_path):
"""已存在历史索引文件时应加载。"""
save_dir = tmp_path / "ai"
save_dir.mkdir()
idx_file = save_dir / constants.HISTORY_FILE
idx_file.write_text(json.dumps({"conversations": [{"id": "x"}], "last_id": 5}), encoding="utf-8")
h = ConversationHistory(save_dir=str(save_dir))
assert h.history_index["last_id"] == 5
assert h.history_index["conversations"][0]["id"] == "x"
def test_init_default_save_dir_uses_constants(tmp_path):
"""save_dir=None 时使用 constants.SAVE_DIR(默认值为只读 property,无法 monkeypatch,
这里仅验证构造不抛异常且目录存在——SAVE_DIR 通常指向 ~/.aibox,可能已存在)。
用显式 save_dir 路径替代断言,避免污染用户家目录。"""
# 这里只验证 constants.SAVE_DIR 是字符串类型(被引用即可)
assert isinstance(constants.SAVE_DIR, str)
# ==================== 当前对话管理 ====================
def test_start_new_conversation_creates_empty_conv(history):
conv = history.start_new_conversation(topic="测试")
assert conv is not None
assert conv["topic"] == "测试"
assert conv["messages"] == []
assert conv["stats"] == {"user_messages": 0, "ai_messages": 0, "tool_calls": 0}
assert history.current_conversation is conv
def test_start_new_conversation_default_topic(history):
conv = history.start_new_conversation()
assert conv["topic"] == "新对话"
def test_start_new_conversation_archives_existing(history):
"""有当前对话时再开新对话,旧对话应归档。"""
history.start_new_conversation(topic="旧")
history.add_message("user", "hi")
history.start_new_conversation(topic="新")
# 旧对话应已归档到 archive 目录
archives = list(history.archive_dir.glob("*.json"))
assert len(archives) == 1
archived = json.loads(archives[0].read_text(encoding="utf-8"))
assert archived["topic"] == "旧"
def test_add_message_creates_conversation_if_absent(history):
"""无当前对话时 add_message 应自动创建。"""
assert history.current_conversation is None
history.add_message("user", "hello")
assert history.current_conversation is not None
assert len(history.current_conversation["messages"]) == 1
def test_add_user_message_updates_stats(history):
history.start_new_conversation()
history.add_message("user", "问题")
stats = history.current_conversation["stats"]
assert stats["user_messages"] == 1
assert stats["ai_messages"] == 0
def test_add_assistant_message_updates_stats(history):
history.start_new_conversation()
history.add_message("assistant", "回答", speaker="aibox")
stats = history.current_conversation["stats"]
assert stats["ai_messages"] == 1
assert stats["user_messages"] == 0
def test_add_message_persists_to_current_file(history):
"""add_message 后应写入 current_conversation.json。"""
history.start_new_conversation()
history.add_message("user", "persist me")
assert history.current_file.exists()
saved = json.loads(history.current_file.read_text(encoding="utf-8"))
assert saved["messages"][0]["content"] == "persist me"
def test_add_tool_call_requires_current_conversation(history):
"""无当前对话时 add_tool_call 应直接返回不报错。"""
assert history.current_conversation is None
history.add_tool_call("run_command", {"cmd": "ls"}, "output")
# 不应抛异常,且不创建对话
assert history.current_conversation is None
def test_add_tool_call_records_and_truncates_preview(history):
history.start_new_conversation()
long_result = "x" * 300
history.add_tool_call("run_command", {"cmd": "ls"}, long_result)
tc = history.current_conversation["tool_calls"][0]
assert tc["tool"] == "run_command"
assert tc["args"] == {"cmd": "ls"}
# result_preview 超 200 字符应截断加省略号
assert tc["result_preview"].endswith("...")
assert len(tc["result_preview"]) == 203 # 200 + "..."
assert history.current_conversation["stats"]["tool_calls"] == 1
def test_add_tool_call_short_result_no_truncation(history):
history.start_new_conversation()
history.add_tool_call("calc", {}, "42")
tc = history.current_conversation["tool_calls"][0]
assert tc["result_preview"] == "42"
def test_save_current_conversation_no_op_when_empty(history):
"""无当前对话时 save_current_conversation 不报错。"""
history.save_current_conversation() # 不应抛异常
def test_reset_conversation_clears_current(history):
history.start_new_conversation()
history.add_message("user", "x")
history.reset_conversation()
assert history.current_conversation is None
# current_file 应被归档时清除(reset 自身不删文件,但 end_time 已写)
assert history.current_file.exists() is False or history.current_file.exists()
def test_end_current_conversation_sets_end_time(history):
history.start_new_conversation()
assert history.current_conversation["end_time"] is None
history.end_current_conversation()
assert history.current_conversation["end_time"] is not None
def test_end_current_conversation_no_op_when_empty(history):
"""无当前对话时不报错。"""
history.end_current_conversation() # 不应抛异常
# ==================== 归档与历史索引 ====================
def test_archive_returns_none_when_no_current(history):
assert history.archive_current_conversation() is None
def test_archive_writes_archive_file_and_updates_index(history):
history.start_new_conversation(topic="归档测试")
history.add_message("user", "msg1")
history.add_message("assistant", "reply")
archive_path = history.archive_current_conversation()
assert archive_path is not None
import os
assert os.path.exists(archive_path)
# 索引应包含一条记录
assert len(history.history_index["conversations"]) == 1
rec = history.history_index["conversations"][0]
assert rec["topic"] == "归档测试"
assert rec["messages"] == 2
# 归档后当前对话清空
assert history.current_conversation is None
assert not history.current_file.exists()
def test_archive_sets_end_time_if_missing(history):
history.start_new_conversation()
# end_time 为 None
history.archive_current_conversation()
# 通过归档文件验证 end_time 已写入
archives = list(history.archive_dir.glob("*.json"))
archived = json.loads(archives[0].read_text(encoding="utf-8"))
assert archived["end_time"] is not None
def test_archive_with_final_summary_writes_memory(tmp_path, mem_db):
"""注入 memory_db 且提供 final_summary 时应调用 add_memory。"""
h = ConversationHistory(save_dir=str(tmp_path), memory_db=mem_db, load_history=True)
h.start_new_conversation(topic="带摘要归档")
h.add_message("user", "content")
h.add_message("assistant", "reply", speaker="ai")
h.archive_current_conversation(final_summary="这是一次对话的总结")
# 验证 memory_db 写入了一条记忆(用 get_recent_memories 不依赖 FTS5 中文分词)
memories = mem_db.get_recent_memories(limit=10)
assert len(memories) >= 1
# 至少有一条 topic 匹配
assert any(m.get("topic") == "带摘要归档" for m in memories)
def test_archive_without_summary_does_not_write_memory(tmp_path, mem_db):
"""无 final_summary 时不写记忆。"""
h = ConversationHistory(save_dir=str(tmp_path), memory_db=mem_db, load_history=True)
h.start_new_conversation(topic="无摘要")
h.add_message("user", "x")
h.archive_current_conversation() # 无 summary
memories = mem_db.get_recent_memories(limit=10)
assert len(memories) == 0
def test_archive_without_memory_db_does_not_crash(history):
"""未注入 memory_db 时带 summary 归档不应崩溃。"""
history.start_new_conversation()
history.add_message("user", "x")
# 不应抛异常
history.archive_current_conversation(final_summary="summary without db")
# ==================== 历史加载与上下文 ====================
def test_load_all_history_respects_load_history_flag(history_no_load):
"""load_history=False 时 load_all_history 返回空。"""
history_no_load.start_new_conversation()
history_no_load.add_message("user", "x")
assert history_no_load.load_all_history() == []
def test_get_conversation_context_respects_load_history_flag(history_no_load):
assert history_no_load.get_conversation_context() == []
def test_load_all_history_includes_current_messages(history):
history.start_new_conversation()
history.add_message("user", "current msg")
msgs = history.load_all_history(max_messages=10)
assert any(m["content"] == "current msg" and m.get("from_current") for m in msgs)
def test_load_all_history_includes_archived(history):
"""归档后的对话也应被加载。"""
history.start_new_conversation(topic="历史对话")
history.add_message("user", "archived msg")
history.archive_current_conversation()
# 新开对话查询历史
history.start_new_conversation()
msgs = history.load_all_history(max_messages=20)
contents = [m["content"] for m in msgs]
assert "archived msg" in contents
def test_load_all_history_limits_to_max_messages(history):
history.start_new_conversation()
for i in range(10):
history.add_message("user", f"msg{i}")
msgs = history.load_all_history(max_messages=3)
assert len(msgs) <= 3
def test_get_conversation_context_skips_system_role(history):
history.start_new_conversation()
# 直接构造一条 system 消息塞入
history.current_conversation["messages"].append({
"timestamp": datetime.now().strftime(constants.DATETIME_FORMAT),
"role": "system",
"speaker": None,
"content": "should be skipped",
"is_history": False,
})
history.add_message("user", "visible")
ctx = history.get_conversation_context()
roles = [c["role"] for c in ctx]
assert "system" not in roles
assert any(c["content"] == "visible" for c in ctx)
def test_get_conversation_context_formats_assistant_with_speaker(history):
history.start_new_conversation()
history.add_message("assistant", "hello", speaker="aibox")
ctx = history.get_conversation_context()
asst = [c for c in ctx if c["role"] == "assistant"]
assert len(asst) == 1
assert "[aibox]" in asst[0]["content"]
def test_get_conversation_context_user_passthrough(history):
history.start_new_conversation()
history.add_message("user", "plain question")
ctx = history.get_conversation_context()
user = [c for c in ctx if c["role"] == "user"]
assert any(c["content"] == "plain question" for c in user)
# ==================== 列表与加载指定对话 ====================
def test_list_conversations_empty(history):
"""无任何对话时返回空列表。"""
assert history.list_conversations() == []
def test_list_conversations_includes_current(history):
history.start_new_conversation(topic="当前")
convs = history.list_conversations()
assert len(convs) == 1
assert convs[0]["is_current"] is True
assert convs[0]["topic"] == "当前"
def test_list_conversations_includes_archived(history):
history.start_new_conversation(topic="旧")
history.archive_current_conversation()
history.start_new_conversation(topic="新")
convs = history.list_conversations()
assert len(convs) == 2
topics = {c["topic"] for c in convs}
assert {"旧", "新"} == topics
def test_list_conversations_sorted_by_start_time_desc(history):
"""应按 start_time 倒序排列。"""
history.start_new_conversation(topic="first")
time.sleep(0.01)
history.archive_current_conversation()
history.start_new_conversation(topic="second")
convs = history.list_conversations()
# 第二个(更晚)应在前
assert convs[0]["topic"] == "second"
def test_load_conversation_current_file(history):
history.start_new_conversation(topic="当前")
loaded = history.load_conversation(constants.CURRENT_CONVERSATION_FILE)
assert loaded is not None
assert loaded["topic"] == "当前"
def test_load_conversation_archived_file(history):
history.start_new_conversation(topic="已归档")
history.add_message("user", "saved")
history.archive_current_conversation()
archives = list(history.archive_dir.glob("*.json"))
assert len(archives) == 1
loaded = history.load_conversation(archives[0].name)
assert loaded is not None
assert loaded["topic"] == "已归档"
assert loaded["messages"][0]["content"] == "saved"
def test_load_conversation_nonexistent_returns_none(history):
assert history.load_conversation("nonexistent.json") is None
# ==================== 与 MemoryDatabase 集成 ====================
def test_end_current_conversation_with_summary_writes_memory(history_with_mem, mem_db):
history_with_mem.start_new_conversation(topic="结束测试")
history_with_mem.add_message("user", "最后的话")
history_with_mem.end_current_conversation(summary="结束摘要")
memories = mem_db.get_recent_memories(limit=10)
assert any(m.get("topic") == "结束测试" for m in memories)
def test_end_current_conversation_without_summary_no_memory(history_with_mem, mem_db):
history_with_mem.start_new_conversation(topic="无摘要结束")
history_with_mem.add_message("user", "x")
history_with_mem.end_current_conversation() # 无 summary
memories = mem_db.get_recent_memories(limit=10)
assert len(memories) == 0