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.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
离屏验证:压缩持久化(链上标记点)
|
||||
背景:压缩此前只改内存 agent.state.messages,下一轮提问从 DB 全量重建历史
|
||||
→「压缩等于没有压缩」。现:压缩成功后在链上插标记行(role="compaction",
|
||||
is_ignored=1),保留首条改指到标记;build_api_context 在最后一个标记处截断、
|
||||
以摘要(user 消息)替代之前一切。历史行全部保留,前端切会话渲染不受影响。
|
||||
"""
|
||||
import os, sys, json, tempfile, types
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from core.db_manager import DBManager # noqa: E402
|
||||
from core.agent.types import AgentMessage, AgentConfig, ModelConfig # noqa: E402
|
||||
from core.agent.stream_fn import from_openai_messages, to_openai_messages # noqa: E402
|
||||
from core.agent.compaction import prepare_compaction, CompactionSettings # noqa: E402
|
||||
from core.agent.context import estimate_context_tokens # noqa: E402
|
||||
from core.agent.recovery import AgentRunner, _cut_ids_of # noqa: E402
|
||||
from ui.views.main_window import MainWindow # noqa: E402 纯方法 unbound 调用,不实例化
|
||||
|
||||
RESULTS = []
|
||||
def check(name, cond, extra=""):
|
||||
RESULTS.append((name, bool(cond)))
|
||||
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
|
||||
|
||||
TMP = os.path.join(tempfile.gettempdir(), f"haocode_compact_persist_{os.getpid()}.db")
|
||||
if os.path.exists(TMP):
|
||||
os.remove(TMP)
|
||||
db = DBManager(TMP)
|
||||
sess = db.create_session("压缩持久化测试")
|
||||
sid = sess["id"]
|
||||
|
||||
def add(role, content, parent):
|
||||
return db.add_message(session_id=sid, role=role, content=content, parent_id=parent)
|
||||
|
||||
# 链:r1(user 旧) → r2(assistant 旧) → r3(user 旧) → r4(assistant 保留) → r5(user 保留)
|
||||
r1 = add("user", "旧问题1", None)
|
||||
r2 = add("assistant", "旧回答1", r1["id"])
|
||||
r3 = add("user", "旧问题2", r2["id"])
|
||||
r4 = add("assistant", "保留回答", r3["id"])
|
||||
r5 = add("user", "保留问题", r4["id"])
|
||||
leaf_before = db.get_session_leaf(sid)
|
||||
check("T0.初始叶子=最后一条", leaf_before == r5["id"])
|
||||
|
||||
MARK_SUMMARY = "## 摘要\n上方对话已摘要:用户问了两个旧问题。"
|
||||
|
||||
# ---------- T1:标记插入(原子,不动叶子) ----------
|
||||
mid = db.insert_compaction_mark(sid, MARK_SUMMARY, r3["id"], r4["id"],
|
||||
json.dumps({"path": "pre_prompt"}, ensure_ascii=False))
|
||||
chain = db.get_message_chain(sid)
|
||||
check("T1.标记行已插入链上", mid is not None and any(m["id"] == mid for m in chain))
|
||||
check("T1.链=6行且顺序正确",
|
||||
[m["id"] for m in chain] == [r1["id"], r2["id"], r3["id"], mid, r4["id"], r5["id"]],
|
||||
[m["id"] for m in chain])
|
||||
check("T1.叶子指针不变", db.get_session_leaf(sid) == leaf_before)
|
||||
check("T1.保留首条改指标记", chain[4]["parent_id"] == mid)
|
||||
mark_row = next(m for m in chain if m["id"] == mid)
|
||||
check("T1.标记 role/is_ignored", mark_row["role"] == "compaction" and mark_row["is_ignored"] == 1)
|
||||
|
||||
# ---------- T5:分支一致性(无幻影分支) ----------
|
||||
check("T5.旧N唯一子=标记", [m["id"] for m in db.get_branch_info(r3["id"])] == [mid])
|
||||
check("T5.标记唯一子=保留首条", [m["id"] for m in db.get_branch_info(mid)] == [r4["id"]])
|
||||
|
||||
# ---------- T2:build_api_context 截断 ----------
|
||||
class _Dummy:
|
||||
db = db
|
||||
api = MainWindow.build_api_context(_Dummy(), sid)
|
||||
api_text = json.dumps([m.get("content") for m in api], ensure_ascii=False)
|
||||
check("T2.首条=摘要user消息(带_kind)",
|
||||
api and api[0]["role"] == "user" and api[0]["content"] == MARK_SUMMARY
|
||||
and api[0].get("_kind") == "compaction_summary", api[:1])
|
||||
check("T2.保留行仍在", "保留回答" in api_text and "保留问题" in api_text)
|
||||
check("T2.切点前行消失",
|
||||
"旧问题1" not in api_text and "旧回答1" not in api_text and "旧问题2" not in api_text)
|
||||
check("T2.共3条(摘要+2保留)", len(api) == 3, len(api))
|
||||
|
||||
# ---------- T3:kind 传播 → 二次压缩走迭代摘要 ----------
|
||||
msgs = from_openai_messages(api)
|
||||
check("T3.摘要 kind 还原", msgs and msgs[0].kind == "compaction_summary",
|
||||
[m.kind for m in msgs])
|
||||
prep = prepare_compaction(msgs, CompactionSettings(reserve_tokens=1000,
|
||||
keep_recent_tokens=2))
|
||||
check("T3.走 previous_summary 迭代路径",
|
||||
prep is not None and prep.previous_summary == MARK_SUMMARY)
|
||||
|
||||
# ---------- T4:双标记取最后一个 ----------
|
||||
mid2 = db.insert_compaction_mark(sid, "摘要2", r4["id"], r5["id"])
|
||||
api2 = MainWindow.build_api_context(_Dummy(), sid)
|
||||
api2_text = json.dumps([m.get("content") for m in api2], ensure_ascii=False)
|
||||
check("T4.取最后一个标记", api2[0]["content"] == "摘要2" and len(api2) == 2, len(api2))
|
||||
check("T4.只留第二个切点之后", "保留问题" in api2_text and "保留回答" not in api2_text)
|
||||
|
||||
# ---------- T6:_cut_ids_of 切点计算(单元) ----------
|
||||
A = AgentMessage(role="user", content="x", db_msg_id="a")
|
||||
B = AgentMessage(role="assistant", content="y", db_msg_id="")
|
||||
C = AgentMessage(role="toolResult", content="z", db_msg_id="")
|
||||
S = AgentMessage(role="user", content="SUM", kind="compaction_summary")
|
||||
check("T6.尾巴首条无DB行→first为空(UI跳过插标记)",
|
||||
_cut_ids_of([A, B, C], [S, B, C]) == ("a", ""))
|
||||
B2 = AgentMessage(role="assistant", content="y", db_msg_id="b")
|
||||
check("T6.正常切点", _cut_ids_of([A, B2, C], [S, B2, C]) == ("a", "b"))
|
||||
D = AgentMessage(role="assistant", content="d", db_msg_id="d")
|
||||
check("T6.尾巴不在旧列表→不插", _cut_ids_of([A, B2, C], [S, D]) == ("", ""))
|
||||
check("T6.无之前消息→无切点", _cut_ids_of([B2, C], [S, B2, C]) == ("", ""))
|
||||
|
||||
# T6c:⚠️ 同 id 回放序列(timeline 多条目共享一行 id)—— 曾导致 DB 自环卡死
|
||||
Pm = AgentMessage(role="user", content="p", db_msg_id="p")
|
||||
A1 = AgentMessage(role="assistant", content="a1", db_msg_id="a")
|
||||
A2 = AgentMessage(role="assistant", content="a2", db_msg_id="a")
|
||||
A3 = AgentMessage(role="toolResult", content="a3", db_msg_id="a")
|
||||
Qm = AgentMessage(role="user", content="q", db_msg_id="q")
|
||||
cut = _cut_ids_of([Pm, A1, A2, A3, Qm], [S, A2, A3, Qm])
|
||||
check("T6c.同id回放切点回退到行头(cut_before≠first_retained)",
|
||||
cut == ("p", "a"), cut)
|
||||
cut2 = _cut_ids_of([Pm, A1, A2, A3, Qm], [S, A3, Qm])
|
||||
check("T6c.尾巴起点更深也回退到行头", cut2 == ("p", "a"), cut2)
|
||||
|
||||
# ---------- T10:insert_compaction_mark 自环守卫 ----------
|
||||
bad = db.insert_compaction_mark(sid, "S", r5["id"], r5["id"])
|
||||
check("T10.cut==retained 拒绝插入(防自环)", bad is None)
|
||||
|
||||
# ---------- T11:get_message_chain 环守卫(手工造 2 环) ----------
|
||||
sid2 = db.create_session("环守卫")["id"]
|
||||
x1 = db.add_message(session_id=sid2, role="user", content="x1", parent_id=None)
|
||||
x2 = db.add_message(session_id=sid2, role="assistant", content="x2", parent_id=x1["id"])
|
||||
with db.get_connection() as conn:
|
||||
conn.execute("UPDATE messages SET parent_id=? WHERE id=?", (x1["id"], x2["id"]))
|
||||
conn.execute("UPDATE messages SET parent_id=? WHERE id=?", (x2["id"], x1["id"]))
|
||||
conn.commit()
|
||||
import time as _t
|
||||
t0 = _t.time()
|
||||
chain11 = db.get_message_chain(sid2)
|
||||
dur = _t.time() - t0
|
||||
check("T11.手工2环不死循环(<2s返回)", dur < 2 and 1 <= len(chain11) <= 3, (dur, len(chain11)))
|
||||
|
||||
# ---------- T7:pre_prompt_compaction 事件 payload 端到端 ----------
|
||||
model = ModelConfig(provider="t", name="t", context_window=500,
|
||||
max_tokens=100, api_key="k", base_url="http://x")
|
||||
cfg = AgentConfig(model=model, system_prompt="s", tools=[], tool_context={},
|
||||
compaction_reserve=100, compaction_keep_recent=100)
|
||||
from core.agent.agent import Agent # noqa: E402
|
||||
agent = Agent(cfg)
|
||||
old_msgs = [
|
||||
AgentMessage(role="user", content="旧旧问题", db_msg_id="m1"),
|
||||
AgentMessage(role="assistant", content="旧旧回答", db_msg_id="m2"),
|
||||
AgentMessage(role="user", content="旧上下文 " * 200, db_msg_id="m3"),
|
||||
AgentMessage(role="assistant", content="新回答", db_msg_id="m4"),
|
||||
AgentMessage(role="user", content="新问题", db_msg_id="m5"),
|
||||
]
|
||||
agent.state.messages = old_msgs
|
||||
runner = AgentRunner(agent, summarize_fn=lambda p, s, mt: "摘要X")
|
||||
ok = runner.pre_prompt_compaction()
|
||||
check("T7.压缩被触发", ok is True)
|
||||
ev = runner.compaction_events[-1]
|
||||
new_msgs = agent.state.messages
|
||||
first = new_msgs[1]
|
||||
idx = next(i for i, m in enumerate(old_msgs) if m is first)
|
||||
check("T7.payload cut_before_id=切点前DB行",
|
||||
ev.get("cut_before_id") == old_msgs[idx - 1].db_msg_id, ev)
|
||||
check("T7.payload first_retained_id=尾巴首条DB行",
|
||||
ev.get("first_retained_id") == first.db_msg_id, ev)
|
||||
check("T7.新消息=摘要+尾巴", new_msgs[0].kind == "compaction_summary"
|
||||
and new_msgs[1:] == old_msgs[idx:])
|
||||
|
||||
# ---------- T8:_key 不泄漏到 API ----------
|
||||
api_out = to_openai_messages(old_msgs)
|
||||
check("T8.下划线内部字段不进API",
|
||||
all(not any(k.startswith("_") for k in m.keys()) for m in api_out))
|
||||
|
||||
# ---------- T9:UI 渲染过滤排除 compaction(静态) ----------
|
||||
_mw_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"ui", "views", "main_window.py")
|
||||
with open(_mw_path, "r", encoding="utf-8") as f:
|
||||
_src = f.read()
|
||||
check("T9.渲染过滤含 compaction", 'msg["role"] not in ("system", "compaction")' in _src)
|
||||
|
||||
# ---------- T12:Fix A — 摘要条目 timestamp → P0 失效过期锚点 ----------
|
||||
# 场景(长程会话 92.7k→11k):保留行里 assistant 的入库 usage 是压缩前快照;
|
||||
# 若摘要条目不带 timestamp,显示/压缩判定会锚到过期值(92.7k),而真实
|
||||
# 下一轮输入只有摘要+保留行(~11k)。
|
||||
sid3 = db.create_session("过期锚点失效")["id"]
|
||||
u1 = db.add_message(session_id=sid3, role="user", content="u" * 2000, parent_id=None)
|
||||
a1 = db.add_message(session_id=sid3, role="assistant", content="A" * 40000,
|
||||
parent_id=u1["id"],
|
||||
usage=json.dumps({"input": 92700, "output": 500,
|
||||
"cacheRead": 0, "cacheWrite": 0}))
|
||||
u2 = db.add_message(session_id=sid3, role="user", content="v" * 2000, parent_id=a1["id"])
|
||||
db.add_message(session_id=sid3, role="assistant", content="B" * 40000, parent_id=u2["id"],
|
||||
usage=json.dumps({"input": 92700, "output": 500,
|
||||
"cacheRead": 0, "cacheWrite": 0}))
|
||||
# 把全部行时间戳拨到 3000s 前(模拟它们早于压缩发生)
|
||||
with db.get_connection() as conn:
|
||||
conn.execute("UPDATE messages SET created_at=? WHERE session_id=?",
|
||||
(int(_t.time()) - 3000, sid3))
|
||||
conn.commit()
|
||||
api_ns = MainWindow.build_api_context(_Dummy(), sid3)
|
||||
est_ns = estimate_context_tokens(from_openai_messages(api_ns))
|
||||
check("T12.对照:无标记→锚定入库usage", est_ns.usage_tokens > 0, est_ns.usage_tokens)
|
||||
mid3 = db.insert_compaction_mark(sid3, "摘要", a1["id"], u2["id"])
|
||||
api3 = MainWindow.build_api_context(_Dummy(), sid3)
|
||||
check("T12.摘要条目带timestamp且晚于保留行",
|
||||
api3 and api3[0].get("timestamp", 0) > 0
|
||||
and all(api3[0]["timestamp"] > m.get("timestamp", 0) for m in api3[1:]))
|
||||
est3 = estimate_context_tokens(from_openai_messages(api3))
|
||||
check("T12.有标记→过期锚点失效(usage_tokens=0)", est3.usage_tokens == 0,
|
||||
est3.usage_tokens)
|
||||
check("T12.估算=全量公式(远离过期93.2k)", est3.tokens < 40000, est3.tokens)
|
||||
|
||||
# ---------- T13:Fix B + G1 — 工具行显示不锚点(G1 行块规则单元化) ----------
|
||||
# 场景(任务流会话 40.5k→80k):工具行的入库 usage 是本轮最后一个子请求的
|
||||
# 快照(工具输出未截断),下一轮真实输入=4k 截断回放。🆕 G1 后不再需要
|
||||
# 手动清锚:锚点选择器自动失效含工具行的 usage → 估算=全量公式。
|
||||
sid4 = db.create_session("工具行不锚点")["id"]
|
||||
p1 = db.add_message(session_id=sid4, role="user", content="prompt", parent_id=None)
|
||||
tl = json.dumps([
|
||||
{"t": "text", "text": "code" * 50000},
|
||||
{"t": "tool", "id": "c1", "name": "bash", "args": "{}", "result": "ok"},
|
||||
{"t": "text", "text": "done"},
|
||||
], ensure_ascii=False)
|
||||
db.add_message(session_id=sid4, role="assistant", content="x", parent_id=p1["id"],
|
||||
timeline=tl,
|
||||
usage=json.dumps({"input": 32517, "output": 7999,
|
||||
"cacheRead": 0, "cacheWrite": 0}))
|
||||
api4 = MainWindow.build_api_context(_Dummy(), sid4)
|
||||
check("T13.回放含tool条目(规则条件成立)",
|
||||
any(m.get("role") == "tool" for m in api4))
|
||||
_est4 = estimate_context_tokens(from_openai_messages(api4))
|
||||
check("T13.G1:工具行入库usage自动失效锚点(无需手动清锚)",
|
||||
_est4.usage_tokens == 0, _est4.usage_tokens)
|
||||
check("T13.估算=全量公式(>旧锚点40516,度量截断回放)",
|
||||
_est4.tokens > 40516, _est4.tokens)
|
||||
# G1 精确性:仅失效「含工具活动的行」的 usage,其后的纯文本行锚点保留
|
||||
sid4b = db.create_session("工具行后纯文本行锚点保留")["id"]
|
||||
p1b = db.add_message(session_id=sid4b, role="user", content="prompt", parent_id=None)
|
||||
t1b = db.add_message(session_id=sid4b, role="assistant", content="x", parent_id=p1b["id"],
|
||||
timeline=tl,
|
||||
usage=json.dumps({"input": 32517, "output": 7999,
|
||||
"cacheRead": 0, "cacheWrite": 0}))
|
||||
u1b = db.add_message(session_id=sid4b, role="user", content="q2", parent_id=t1b["id"])
|
||||
_a2b = db.add_message(session_id=sid4b, role="assistant", content="y", parent_id=u1b["id"],
|
||||
usage=json.dumps({"input": 90000, "output": 100,
|
||||
"cacheRead": 0, "cacheWrite": 0}))
|
||||
api4b = MainWindow.build_api_context(_Dummy(), sid4b)
|
||||
_est4b = estimate_context_tokens(from_openai_messages(api4b))
|
||||
check("T13.G1:工具行(旧)usage失效、其后纯文本行(新)usage保留锚点",
|
||||
_est4b.usage_tokens > 0 and _est4b.usage_tokens == 90100,
|
||||
_est4b.usage_tokens)
|
||||
# 纯文本会话:规则条件不成立 → 保留精确锚点(provider 实测值更准)
|
||||
sid5 = db.create_session("文本行锚点")["id"]
|
||||
p2 = db.add_message(session_id=sid5, role="user", content="hi", parent_id=None)
|
||||
db.add_message(session_id=sid5, role="assistant", content="hello", parent_id=p2["id"],
|
||||
usage=json.dumps({"input": 100, "output": 50,
|
||||
"cacheRead": 0, "cacheWrite": 0}))
|
||||
api5 = MainWindow.build_api_context(_Dummy(), sid5)
|
||||
check("T13.纯文本行→无tool条目(保留锚点)",
|
||||
not any(m.get("role") == "tool" for m in api5)
|
||||
and estimate_context_tokens(from_openai_messages(api5)).usage_tokens > 0)
|
||||
|
||||
# ---------- T14:G2 — should_compact 透传 system/tools(无锚点分支度量下一请求) ----------
|
||||
from core.agent.context import should_compact
|
||||
_mc14 = ModelConfig(name="t", context_window=100000)
|
||||
_msgs14 = from_openai_messages(api4) # 工具行 → G1 无锚点
|
||||
_sh_no, _tok_no = should_compact(_msgs14, _mc14, 16384)
|
||||
_sh_yes, _tok_yes = should_compact(_msgs14, _mc14, 16384,
|
||||
system_prompt="S" * 2000,
|
||||
tools=[types.SimpleNamespace(
|
||||
name="bash", description="d" * 1000,
|
||||
parameters={})])
|
||||
check("T14.传入system/tools后估算增大(无锚点分支)",
|
||||
_tok_yes > _tok_no, (_tok_no, _tok_yes))
|
||||
# 阈值边缘:同样内容,带上 system/tools 后跨过阈值 → 触发判定变化
|
||||
_mc_edge = ModelConfig(name="t2", context_window=_tok_no + 16384 + 5)
|
||||
_sh_edge_no, _ = should_compact(_msgs14, _mc_edge, 16384)
|
||||
_sh_edge_yes, _ = should_compact(_msgs14, _mc_edge, 16384,
|
||||
system_prompt="S" * 2000,
|
||||
tools=[types.SimpleNamespace(
|
||||
name="bash", description="d" * 1000,
|
||||
parameters={})])
|
||||
check("T14.阈值边缘:计入system/tools才触发(防低估漏压缩)",
|
||||
_sh_edge_no is False and _sh_edge_yes is True,
|
||||
(_sh_edge_no, _sh_edge_yes))
|
||||
|
||||
failed = [n for n, okk in RESULTS if not okk]
|
||||
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
|
||||
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
|
||||
sys.exit(0 if not failed else 1)
|
||||
Reference in New Issue
Block a user