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 复原为空。
This commit is contained in:
1 parent
1383d32f8b
commit
07490c4d38
6 files changed
+480
-2
No files matched your search
@@ -0,0 +1,187 @@
|
||||
"""会话持久化与 /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
|
||||
Reference in new issue
Block a user