Files
xiaoli-cli/tests/test_session.py
T
shuiyu67 07490c4d38 feat: opencode 式会话持久化 + /resume 一键恢复
此前只有手动的 /chat save|list|open,关终端就丢上下文。现升级为
opencode 式体验:每轮对话自动落盘、启动提示历史会话、/resume 一键恢复。

- 新增 xcli_core/session.py:Session 数据类(id/title/created_at/updated_at/
  engine/model/messages)+ SessionManager(new/save/list/load/resolve,存
  chat_history/<id>.json)。向后兼容旧 /chat 格式(无 id、用 conversation 键,
  读取时回退用文件名作 id)。标题自动取首条 user 消息前若干字。
- HistoryMixin 升级(cli_history.py):新增 init_session_manager /
  _autosave_session / handle_resume_command。自动保存首轮惰性建会话、
  更新标题与 updated_at;/resume 列出(序号|ID)或按序号|ID|前缀恢复,
  恢复时把 messages 载入 shared_conversation_history 并同步引擎。
- 集成:cli_base.__init__ 在 chat_history_dir 就绪后 init_session_manager;
  cli_core 主循环 1395 行 process_conversation 后调用 _autosave_session;
  新增 /resume|/sessions 路由(/sessions 等同 /resume)。
- 体验:启动时若有历史会话提示 /resume(含最近会话标题);run() 紧凑提示
  与 /help 速查表均补 /resume(列出/恢复/开新会话)。
- 修复 cli_history.py 一处历史 typo:{Style.RESET型} -> {Style.RESET_ALL}
  (旧 load_chat_history 在含消息时会抛 AttributeError)。
- 测试:新增 tests/test_session.py(12 项,覆盖兼容/新建/落盘/排序/解析/
  自动保存/恢复/列表/未知token,全程临时目录不污染真实 config/chat_history)。
  全量 242 passed(原 230)。

真实 CLI 冒烟:启动提示识别到历史会话、/resume 正确列出(含标题/时间/引擎/
条数)、/resume <id> 正确恢复并回显;已清理测试种入文件,chat_history 复原为空。
2026-08-05 17:16:49 +08:00

188 lines
6.7 KiB
Python

"""会话持久化与 /resume 的回归测试。
全程使用临时目录,不触碰真实 chat_history/ 与 config.json。
"""
import os
import json
import tempfile
import shutil
import uuid
import pytest
from xcli_core.session import Session, SessionManager, _make_session_id, _title_from_messages
@pytest.fixture
def tmp_history():
d = tempfile.mkdtemp(prefix="xiaoli_sess_")
yield d
shutil.rmtree(d, ignore_errors=True)
def _write_old_format(history_dir, name, messages):
"""模拟旧 /chat save 写入(无 id 字段、用 conversation 键)。"""
path = os.path.join(history_dir, f"{name}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump({"timestamp": "2026-08-01 10:00:00", "conversation": messages}, f, ensure_ascii=False)
return path
# ── SessionManager 单元 ──
def test_session_id_format():
sid = _make_session_id()
assert len(sid.split("-")) == 3 # YYYYMMDD-HHMMSS-xxxxxx
def test_new_session_basic_fields(tmp_history):
mgr = SessionManager(tmp_history)
s = mgr.new_session(engine="openai", model="xophunyuan7bmt")
assert s.id and s.engine == "openai" and s.model == "xophunyuan7bmt"
assert s.messages == [] and s.title == "(新会话)"
def test_save_then_load_roundtrip(tmp_history):
mgr = SessionManager(tmp_history)
s = mgr.new_session(engine="openai", model="gpt-4o")
msgs = [{"role": "user", "content": "你好"}, {"role": "assistant", "content": "你也好"}]
assert mgr.save(s, msgs, engine="openai", model="gpt-4o") is True
loaded = mgr.load(s.id)
assert loaded is not None
assert loaded.msg_count == 2
assert loaded.messages[0]["content"] == "你好"
assert loaded.engine == "openai" and loaded.model == "gpt-4o"
def test_title_generated_from_first_user_msg(tmp_history):
mgr = SessionManager(tmp_history)
s = mgr.new_session()
msgs = [{"role": "user", "content": "帮我写一个快排函数"}]
mgr.save(s, msgs)
loaded = mgr.load(s.id)
assert "快排" in loaded.title
def test_list_sessions_sorted_desc(tmp_history):
mgr = SessionManager(tmp_history)
# 写入两个会话,并强制 s1 的更新时间更早,制造稳定可排序差
s1 = mgr.new_session(); mgr.save(s1, [{"role": "user", "content": "旧会话A"}])
path1 = os.path.join(tmp_history, f"{s1.id}.json")
with open(path1, "r", encoding="utf-8") as f:
d = json.load(f)
d["updated_at"] = "2020-01-01 00:00:00"
with open(path1, "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False)
s2 = mgr.new_session(); mgr.save(s2, [{"role": "user", "content": "新会话B"}])
sessions = mgr.list_sessions()
assert len(sessions) == 2
# 倒序:更新的在前(updated_at 时间戳更大)
assert sessions[0].id == s2.id
def test_resolve_by_index_and_prefix(tmp_history):
mgr = SessionManager(tmp_history)
s1 = mgr.new_session(); mgr.save(s1, [{"role": "user", "content": "x"}])
s2 = mgr.new_session(); mgr.save(s2, [{"role": "user", "content": "y"}])
sessions = mgr.list_sessions()
by_idx = mgr.resolve("1", sessions)
by_id = mgr.resolve(s2.id, sessions)
by_prefix = mgr.resolve(s2.id[:20], sessions)
assert by_idx is not None and by_idx.id == sessions[0].id
assert by_id is not None and by_id.id == s2.id
assert by_prefix is not None and by_prefix.id == s2.id
def test_compat_old_chat_format(tmp_history):
"""兼容旧 /chat save 写入的文件(无 id、conversation 键)。"""
_write_old_format(tmp_history, "我的旧记录",
[{"role": "user", "content": "旧消息"}])
mgr = SessionManager(tmp_history)
sessions = mgr.list_sessions()
assert len(sessions) == 1
s = sessions[0]
assert s.id == "我的旧记录" # 回退用文件名
assert s.msg_count == 1
assert s.messages[0]["content"] == "旧消息"
# ── 与 CLI 集成(用 stub 复用 HistoryMixin 的会话方法) ──
from xcli_core.cli_history import HistoryMixin as HistoryMixinStubBase
class _Stub(HistoryMixinStubBase):
def __init__(self, history_dir):
self.chat_history_dir = history_dir
self.shared_conversation_history = []
self.current_session = None
self.current_engine = None
self.current_model = None
self.session_manager = SessionManager(history_dir)
def _set_shared_conversation_history(self):
# 真实 AICLI 会把列表同步给所有引擎,这里做成空操作
pass
def test_autosave_creates_session_lazily(tmp_history):
stub = _Stub(tmp_history)
stub.shared_conversation_history = [{"role": "user", "content": "首条"}]
stub._autosave_session()
assert stub.current_session is not None
sessions = stub.session_manager.list_sessions()
assert len(sessions) == 1
assert sessions[0].msg_count == 1
def test_autosave_updates_existing_session(tmp_history):
stub = _Stub(tmp_history)
stub.shared_conversation_history = [{"role": "user", "content": "a"}]
stub._autosave_session() # 建
stub.shared_conversation_history.append({"role": "assistant", "content": "b"})
stub._autosave_session() # 更新
sessions = stub.session_manager.list_sessions()
assert len(sessions) == 1
assert sessions[0].msg_count == 2
def test_handle_resume_lists_and_restores(tmp_history, capsys):
stub = _Stub(tmp_history)
# 先写一个会话
stub.shared_conversation_history = [{"role": "user", "content": "历史问题"}]
stub._autosave_session()
first_id = stub.current_session.id
# 清空当前上下文,模拟新会话
stub.shared_conversation_history = []
stub.current_session = None
# /resume 列出
stub.handle_resume_command("")
out = capsys.readouterr().out
assert "已保存的会话" in out
# /resume <id> 恢复
stub.handle_resume_command(first_id)
assert stub.shared_conversation_history == [{"role": "user", "content": "历史问题"}]
assert stub.current_session.id == first_id
def test_handle_resume_new_clears_current(tmp_history):
stub = _Stub(tmp_history)
stub.shared_conversation_history = [{"role": "user", "content": "x"}]
stub._autosave_session()
assert stub.current_session is not None
stub.handle_resume_command("new")
assert stub.current_session is None
def test_handle_resume_unknown_token(tmp_history, capsys):
stub = _Stub(tmp_history)
# 先写一个会话,使目录非空,才能走到「未找到」分支而非「没有可恢复」
stub.shared_conversation_history = [{"role": "user", "content": "历史"}]
stub._autosave_session()
stub.handle_resume_command("nonexistent-id")
out = capsys.readouterr().out
assert "未找到会话" in out