Files
Haocode/tests/test_error_persist.py
T
sorrow404null a7412824e0 chore: import original project baseline
Import the pre-repair source tree as the history baseline.
Runtime data (data/), virtualenvs, bytecode caches and logs are
gitignored so local secrets and user state stay out of the repo.
2026-09-17 16:40:01 +08:00

369 lines
18 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.
# -*- coding: utf-8 -*-
"""失败轮次持久化(对照 pimessage_end 无条件入库 → 出错也写会话)+ 回放取舍。
核心诉求:agent 跑了 200 个工具后第 201 步出错,**已完成的工具结果不能白跑** ——
必须入库、必须能进下次上下文,这样"接着执行最后一次"才可能。
覆盖矩阵:
T1 全空失败轮 → 入库留痕(is_ignored=1),回放**跳过**
T2 有工具/正文的失败轮 → 入库 + 回放(工具调用与结果成对)★核心
T3 轮内重试 → 只动内存、**不入库**(不会产生一堆错误行)
T4 孤儿工具(ok=None) → 回放注入合成结果(对照 pi insertSyntheticToolResults
T5 叶子前进 → 不再"时光倒流"parent_id 指向本轮提问
T6 压缩切点交互 → 切点之后照常回放;之前 → 出上下文
T7 旧库自动迁移 → 新列补齐,旧行 stop_reason=NULL 行为不变
T8 copy_session → 复制错误行时携带 stop_reason/error_message/is_ignored
运行: QT_QPA_PLATFORM=offscreen python tests/test_error_persist.py
"""
import os
import sys
import json
import uuid
import sqlite3
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# 铁律:测试不得污染真实 data/chat_history.db
import core.db_manager as _dbm # noqa: E402
_DB_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_errpersist_{os.getpid()}.db")
if os.path.exists(_DB_TMP):
os.remove(_DB_TMP)
_dbm._DEFAULT_DB = _DB_TMP
# 铁律:不得污染真实 data/config.json
_CFG_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_errcfg_{os.getpid()}.json")
with open(_CFG_TMP, "w", encoding="utf-8") as _f:
_f.write('{"providers": {}}')
os.environ["HAOCODE_CONFIG_FILE"] = _CFG_TMP
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
from core.db_manager import DBManager # noqa: E402
from core.agent.recovery import AgentRunner # noqa: E402
from core.agent.types import (AgentConfig, AgentMessage, ModelConfig, # noqa: E402
RetryConfig)
app = QApplication(sys.argv)
ok = True
def check(name, cond, extra=""):
global ok
print((" PASS " if cond else " FAIL ") + name +
("" if cond else f" {extra}"), flush=True)
if not cond:
ok = False
def settle(ms=120):
for _ in range(int(ms / 20) + 1):
app.processEvents()
QTest.qWait(20)
window = MainWindow()
window.show()
settle(300)
def mk_failed_turn(sid, body="", reasoning="", tools=None, err="连接失败: Connection error"):
"""模拟一次「已流出一部分 → 出错」的轮次,返回 (user_msg_id, stream_state)"""
chain = window.db.get_message_chain(sid)
parent = chain[-1]["id"] if chain else None
st = {
"msg_id": f"msg_err_{uuid.uuid4().hex[:10]}",
"parent_id": parent,
"previous_leaf_id": parent,
"content": body,
"reasoning": reasoning,
"timeline": list(tools or []),
"usage": {},
"tl_kind": "text",
"worker": None,
}
window.current_session_id = sid
window._active_streams[sid] = st
window.on_error(sid, err)
settle(120)
return parent, st
def tool_entry(cid, name, args, ok_flag, result):
return {"t": "tool", "id": cid, "name": name, "args": args,
"ok": ok_flag, "result": result}
# ======================================================================
# T1 全空失败轮 → 入库留痕但回放跳过
# ======================================================================
sid1 = window.db.create_session("T1 全空失败")["id"]
uid1 = window.db.add_message(session_id=sid1, role="user", content="开始吧",
parent_id=None)["id"]
mk_failed_turn(sid1, body="", reasoning="", tools=[])
chain1 = window.db.get_message_chain(sid1)
last1 = chain1[-1]
check("T1.1 全空失败轮也入库(对照 pi 无条件 append",
len(chain1) == 2 and last1["role"] == "assistant", f"chain={len(chain1)}")
check("T1.2 标记 stop_reason=error + error_message",
last1.get("stop_reason") == "error" and "Connection error" in (last1.get("error_message") or ""),
f"{last1.get('stop_reason')} / {last1.get('error_message')}")
check("T1.3 全空 → is_ignored=1(只在库里留痕)", int(last1.get("is_ignored") or 0) == 1)
check("T1.4 正文含 ⚠️ 中断说明(供历史/UI 可见)",
"⚠️" in (last1.get("content") or "") and "本轮中断" in (last1.get("content") or ""),
(last1.get("content") or "")[:60])
ctx1 = window.build_api_context(sid1)
check("T1.5 回放**跳过**全空错误行(避免空 assistant 触发服务商 400",
not any(m.get("role") == "assistant" for m in ctx1),
str([m.get("role") for m in ctx1]))
# ======================================================================
# T2 ★核心:200 个工具后第 201 步出错 → 工具结果必须进上下文
# ======================================================================
sid2 = window.db.create_session("T2 工具后出错")["id"]
uid2 = window.db.add_message(session_id=sid2, role="user",
content="帮我建 3 个目录", parent_id=None)["id"]
tools2 = [tool_entry(f"c{i}", "bash", json.dumps({"command": f"mkdir d{i}"}),
True, f"d{i} created") for i in range(1, 4)]
tools2.append(tool_entry("c4", "bash", json.dumps({"command": "mkdir d4"}),
None, "")) # 孤儿:开始了但没结果
mk_failed_turn(sid2, body="正在创建目录…", reasoning="先建目录", tools=tools2)
chain2 = window.db.get_message_chain(sid2)
last2 = chain2[-1]
check("T2.1 有工具/正文的失败轮入库且 is_ignored=0(会回放)",
last2["role"] == "assistant" and int(last2.get("is_ignored") or 0) == 0,
f"ignored={last2.get('is_ignored')}")
check("T2.2 timeline 完整保留 4 个工具条目",
len([e for e in json.loads(last2["timeline"] or "[]")
if e.get("t") == "tool"]) == 4,
str([e.get("t") for e in json.loads(last2["timeline"] or "[]")]))
check("T2.2b timeline 尾部多一条 text 条目(中断说明,回放时才看得到)",
(json.loads(last2["timeline"] or "[]")[-1].get("t") == "text"
and "⚠️" in json.loads(last2["timeline"] or "[]")[-1].get("text", "")),
str(json.loads(last2["timeline"] or "[]")[-1])[:80])
check("T2.3 正文保留 + 尾部中断说明",
"正在创建目录" in last2["content"] and "⚠️" in last2["content"])
ctx2 = window.build_api_context(sid2)
tcs = [tc for m in ctx2 if m.get("role") == "assistant" for tc in (m.get("tool_calls") or [])]
tool_msgs = [m for m in ctx2 if m.get("role") == "tool"]
check("T2.4 ★回放里出现 4 个 tool_call200 个工具的记录没丢)", len(tcs) == 4,
f"tool_calls={len(tcs)}")
check("T2.5 ★每个 tool_call 都有配对的 tool 结果(成对,服务商不会 400)",
len(tool_msgs) == len(tcs) and
{m["tool_call_id"] for m in tool_msgs} == {t["id"] for t in tcs},
f"tool_msgs={len(tool_msgs)}")
check("T2.6 已完成的工具结果原文进上下文",
any("d1 created" in (m.get("content") or "") for m in tool_msgs))
check("T2.7 错误说明文本也进上下文(模型知道上轮为何断)",
any(m.get("role") == "assistant" and "⚠️" in (m.get("content") or "")
for m in ctx2))
check("T2.8 顺序正确:assistant tool_calls 在 tool 结果之前",
[m.get("role") for m in ctx2].index("assistant") <
[m.get("role") for m in ctx2].index("tool"))
# ======================================================================
# T3 轮内重试只动内存、不入库
# ======================================================================
class _FakeState:
def __init__(self, msgs):
self.messages = msgs
class _FakeAgent:
def __init__(self):
self.config = AgentConfig(
model=ModelConfig(name="fake"),
retry=RetryConfig(max_attempts=3, base_delay_ms=1, factor=2.0),
)
self.state = _FakeState([
AgentMessage(role="user", content="hi"),
AgentMessage(role="assistant", stop_reason="error",
error_message="connection error"),
])
fa = _FakeAgent()
runner = AgentRunner(fa, summarize_fn=None)
n_before = len(window.db.get_message_chain(sid2))
retried = runner._prepare_retry(fa.config.retry, reason="connection error")
n_after = len(window.db.get_message_chain(sid2))
check("T3.1 _prepare_retry 生效(可重试错误)", retried is True)
check("T3.2 重试只从**内存**移除坏消息(轮次未结束,不该入库)",
len(fa.state.messages) == 1 and fa.state.messages[-1].role == "user",
str([m.role for m in fa.state.messages]))
check("T3.3 ★重试前后 DB 行数不变(不会堆一堆错误行)", n_before == n_after,
f"{n_before} -> {n_after}")
# 正常成功轮 → 不写 error 列
sid3 = window.db.create_session("T3 正常轮")["id"]
uid3 = window.db.add_message(session_id=sid3, role="user", content="你好",
parent_id=None)["id"]
st3 = {"msg_id": "msg_ok_1", "parent_id": uid3, "previous_leaf_id": uid3,
"content": "你好,我在。", "reasoning": "", "timeline": [], "usage": {},
"tl_kind": "text", "worker": None}
window.current_session_id = sid3
window._active_streams[sid3] = st3
window.on_reply_finished(sid3)
settle(150)
ok_row = window.db.get_message_chain(sid3)[-1]
check("T3.4 正常轮 stop_reason 为空(与错误行可区分)",
not ok_row.get("stop_reason"), f"{ok_row.get('stop_reason')!r}")
# ======================================================================
# T4 孤儿工具 → 合成结果(对照 pi insertSyntheticToolResults
# ======================================================================
sid4 = window.db.create_session("T4 孤儿工具")["id"]
uid4 = window.db.add_message(session_id=sid4, role="user", content="跑个命令",
parent_id=None)["id"]
mk_failed_turn(sid4, body="", tools=[tool_entry("orphan1", "bash", "{}", None, "")])
ctx4 = window.build_api_context(sid4)
tool4 = [m for m in ctx4 if m.get("role") == "tool"]
check("T4.1 孤儿工具也有配对结果", len(tool4) == 1, str(len(tool4)))
check("T4.2 孤儿结果是合成说明(不是空串,避免服务商拒绝)",
tool4 and "未收到" in tool4[0]["content"], tool4[0]["content"] if tool4 else "")
check("T4.3 孤儿工具的 tool_call 同时存在",
any(tc["id"] == "orphan1" for m in ctx4 if m.get("role") == "assistant"
for tc in (m.get("tool_calls") or [])))
# ======================================================================
# T5 叶子前进(不再时光倒流)
# ======================================================================
check("T5.1 会话叶子 = 错误行(叶子前进,不再回退到提问)",
window.db.get_session_leaf(sid2) == last2["id"],
f"leaf={window.db.get_session_leaf(sid2)} last={last2['id']}")
check("T5.2 错误行 parent_id 指向本轮提问",
last2["parent_id"] == uid2, f"{last2['parent_id']} vs {uid2}")
check("T5.3 链上顺序 = [user, assistant(error)]",
[m["role"] for m in chain2] == ["user", "assistant"],
str([m["role"] for m in chain2]))
check("T5.4 下次提问可接着链(叶子非空 → 可继续)",
window.db.get_session_leaf(sid2) is not None)
# ======================================================================
# T6 压缩切点交互
# ======================================================================
sid6 = window.db.create_session("T6 压缩交互")["id"]
u6 = window.db.add_message(session_id=sid6, role="user", content="老问题",
parent_id=None)["id"]
mk_failed_turn(sid6, body="中途断了", tools=[])
err6 = window.db.get_message_chain(sid6)[-1]
u6b = window.db.add_message(session_id=sid6, role="user", content="新问题",
parent_id=err6["id"])["id"]
a6 = window.db.add_message(session_id=sid6, role="assistant", content="新回答",
parent_id=u6b)["id"]
window.db.insert_compaction_mark(sid6, "【摘要】老问题与中断", err6["id"], u6b,
json.dumps({"path": "test", "before": 1, "after": 1}))
ctx6 = window.build_api_context(sid6)
flat6 = json.dumps(ctx6, ensure_ascii=False)
check("T6.1 切点之前的错误行 → 不进上下文(已出上下文)",
"中途断了" not in flat6 and "⚠️" not in flat6)
check("T6.2 摘要进上下文 + 切点之后照常",
"【摘要】老问题与中断" in flat6 and "新回答" in flat6)
# ======================================================================
# T7 旧库自动迁移(无新列 → 补齐;旧行 stop_reason=NULL
# ======================================================================
_OLD = os.path.join(tempfile.gettempdir(), f"haocode_old_schema_{os.getpid()}.db")
if os.path.exists(_OLD):
os.remove(_OLD)
_c = sqlite3.connect(_OLD)
_c.executescript("""
CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER);
CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT,
reasoning TEXT, is_ignored BOOLEAN, created_at INTEGER,
attachment_metadata TEXT, parent_id TEXT, timeline TEXT, usage TEXT);
""")
_c.execute("INSERT INTO sessions VALUES ('s1','旧会话',1,1)")
_c.execute("INSERT INTO messages (id,session_id,role,content,created_at,parent_id) "
"VALUES ('m1','s1','user','你好',1,NULL)")
_c.commit()
_c.close()
d_old = DBManager(db_path=_OLD)
cols = [r[1] for r in d_old.get_connection().execute("PRAGMA table_info(messages)")]
check("T7.1 旧库自动补上 stop_reason / error_message 列",
"stop_reason" in cols and "error_message" in cols, str(cols))
row_old = d_old.get_message_chain("s1")
check("T7.2 旧行读出来 stop_reason 为空(行为与升级前一致)",
row_old and not row_old[-1].get("stop_reason"),
str(row_old[-1].get("stop_reason") if row_old else "no row"))
check("T7.3 旧库可正常写入错误行(新列可用)",
d_old.add_message(session_id="s1", role="assistant", content="⚠️ 中断",
parent_id="m1", stop_reason="error",
error_message="boom")["id"] is not None)
check("T7.4 旧库错误行可读回",
d_old.get_message_chain("s1")[-1].get("error_message") == "boom")
# ======================================================================
# T8 copy_session 携带新列
# ======================================================================
copy8 = window.db.copy_session(sid2)
chain8 = window.db.get_message_chain(copy8["id"])
last8 = chain8[-1]
check("T8.1 复制后错误行保留 stop_reason/error_message",
last8.get("stop_reason") == "error" and "Connection error" in (last8.get("error_message") or ""),
f"{last8.get('stop_reason')} / {last8.get('error_message')}")
check("T8.2 复制后 timeline 工具条目一致",
len(json.loads(last8["timeline"] or "[]")) == len(json.loads(last2["timeline"] or "[]")))
check("T8.3 复制后 is_ignored 一致",
int(last8.get("is_ignored") or 0) == int(last2.get("is_ignored") or 0))
check("T8.4 复制后链条角色一致",
[m["role"] for m in chain8] == [m["role"] for m in chain2])
# ======================================================================
# T9 ★数据安全:追加新列绝不得触发「旧数据链表化重构」
# _upgrade_schema 里 upgraded=True 会把每个会话的消息按时间拍平成线性链,
# 直接毁掉树状分支(用户的 12 个分支点!)。新列迁移必须走旁路。
# ======================================================================
_BR = os.path.join(tempfile.gettempdir(), f"haocode_branch_{os.getpid()}.db")
if os.path.exists(_BR):
os.remove(_BR)
_bc = sqlite3.connect(_BR)
_bc.executescript("""
CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, created_at INTEGER, updated_at INTEGER,
has_messages BOOLEAN DEFAULT 0, sort_order INTEGER DEFAULT 0,
is_starred BOOLEAN DEFAULT 0, current_leaf_msg_id TEXT, mode TEXT);
CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT,
reasoning TEXT, is_ignored BOOLEAN, created_at INTEGER,
attachment_metadata TEXT, parent_id TEXT, timeline TEXT, usage TEXT);
""")
_bc.execute("INSERT INTO sessions VALUES ('s9','分支会话',1,1,1,0,0,'m5',NULL)")
# 链 u1(1) → a1(2) → u2(3) → {a2(4), a2b(5)} ← u2 下两个孩子 = 分支点
for _mid, _role, _ct, _par in [("m1", "user", 1, None), ("m2", "assistant", 2, "m1"),
("m3", "user", 3, "m2"), ("m4", "assistant", 4, "m3"),
("m5", "assistant", 5, "m3")]:
_bc.execute("INSERT INTO messages (id,session_id,role,content,created_at,parent_id) "
"VALUES (?,?,?,?,?,?)", (_mid, "s9", _role, _mid, _ct, _par))
_bc.commit()
_bc.close()
d_b = DBManager(db_path=_BR)
_cols_b = [r[1] for r in d_b.get_connection().execute("PRAGMA table_info(messages)")]
check("T9.1 旧库自动追加 stop_reason / error_message 列",
"stop_reason" in _cols_b and "error_message" in _cols_b, str(_cols_b))
_rows_b = {r["id"]: r for r in d_b.get_connection().execute(
"SELECT id, parent_id FROM messages WHERE session_id='s9'")}
check("T9.2 ★分支结构未被拍平(a2b.parent 仍指向分支点 u2",
_rows_b["m5"]["parent_id"] == "m3",
f"m5.parent={_rows_b['m5']['parent_id']}(拍平后会变成 m4")
check("T9.3 叶子节点未被改写", d_b.get_session_leaf("s9") == "m5",
str(d_b.get_session_leaf("s9")))
check("T9.4 原有行内容未被改动",
all(_rows_b[k]["parent_id"] == v for k, v in
[("m2", "m1"), ("m3", "m2"), ("m4", "m3")]),
str({k: _rows_b[k]["parent_id"] for k in _rows_b}))
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
for _p in (_DB_TMP, _CFG_TMP, _OLD, _BR):
try:
if os.path.exists(_p):
os.remove(_p)
except Exception:
pass
sys.exit(0 if ok else 1)