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:
2026-09-17 16:40:01 +08:00
commit a7412824e0
124 changed files with 26747 additions and 0 deletions
View File
+77
View File
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""用「备份 ZIP 里的真实旧库」验证:迁移新列后旧数据必须 100% 原样保留(只读对比)。
用法: python tests/check_db_migration.py <backup.zip>
"""
import os
import sys
import json
import shutil
import sqlite3
import tempfile
import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
ZIP = sys.argv[1] if len(sys.argv) > 1 else r"D:/haocode_backup_20260916_1102.zip"
OUT = os.path.join(tempfile.gettempdir(), "haocode_from_backup.db")
if os.path.exists(OUT):
os.remove(OUT)
with zipfile.ZipFile(ZIP) as z:
name = [n for n in z.namelist()
if os.path.basename(n) == "chat_history.db"][0]
with z.open(name) as src, open(OUT, "wb") as dst:
shutil.copyfileobj(src, dst)
print("从备份取出:", name, os.path.getsize(OUT), "bytes")
def fp(path):
c = sqlite3.connect(path)
c.row_factory = sqlite3.Row
cols = [r[1] for r in c.execute("PRAGMA table_info(messages)")]
d = {
"messages_rows": c.execute("SELECT COUNT(*) FROM messages").fetchone()[0],
"sessions_rows": c.execute("SELECT COUNT(*) FROM sessions").fetchone()[0],
"msg_sum": list(c.execute(
"SELECT COUNT(*), SUM(LENGTH(COALESCE(content,''))), "
"SUM(LENGTH(COALESCE(timeline,''))) FROM messages").fetchone()[:]),
"parents": [tuple(r) for r in c.execute(
"SELECT id, parent_id FROM messages ORDER BY id")],
"leaves": [tuple(r) for r in c.execute(
"SELECT id, current_leaf_msg_id FROM sessions ORDER BY id")],
"branches": sorted((r["parent_id"], r["n"]) for r in c.execute(
"SELECT parent_id, COUNT(*) n FROM messages "
"WHERE parent_id IS NOT NULL GROUP BY parent_id HAVING n>1")),
"roles": sorted((r["role"], r["n"]) for r in c.execute(
"SELECT role, COUNT(*) n FROM messages GROUP BY role")),
"has_new_cols": all(x in cols for x in ("stop_reason", "error_message")),
}
c.close()
return d
before = fp(OUT)
print("备份库(迁移前): 消息=%d 会话=%d 分支点=%d 含新列=%s" % (
before["messages_rows"], before["sessions_rows"],
len(before["branches"]), before["has_new_cols"]))
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = OUT
d = _dbm.DBManager(db_path=OUT) # ← 触发迁移
after = fp(OUT)
print("迁移后 : 消息=%d 会话=%d 分支点=%d 含新列=%s" % (
after["messages_rows"], after["sessions_rows"],
len(after["branches"]), after["has_new_cols"]))
print()
keys = ("messages_rows", "sessions_rows", "msg_sum", "parents",
"leaves", "branches", "roles")
for k in keys:
print((" OK " if before[k] == after[k] else " DIFF ") + "%-14s" % k)
allok = all(before[k] == after[k] for k in keys)
print()
print("每个会话叶子:", [(r[0][:16], (r[1] or "")[:16]) for r in after["leaves"][:4]], "...")
print(">>> 结论:", "✅ 旧库数据 100% 原样保留(只多两个空列)"
if allok else "❌ 有改动!")
os.remove(OUT)
sys.exit(0 if allok else 1)
+48
View File
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""代理侧调试注入 CLI(与运行中的 app 通过文件通信)
用法:
python tests/debug_inject.py "备注内容" # 注入 [AGENT] 日志行
python tests/debug_inject.py --show # 打开独立调试窗口
python tests/debug_inject.py --hide # 关闭独立调试窗口
python tests/debug_inject.py --read [N] # 读取会话日志最后 N 行(默认 50)
"""
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from core import debug_log as dl
def main():
args = sys.argv[1:]
if not args:
print(__doc__)
return 1
if args[0] == "--show":
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
f.write("show")
print(f"已请求打开调试窗口 -> {dl.DEBUG_CMD_PATH}")
return 0
if args[0] == "--hide":
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
f.write("hide")
print(f"已请求关闭调试窗口 -> {dl.DEBUG_CMD_PATH}")
return 0
if args[0] == "--read":
n = int(args[1]) if len(args) > 1 else 50
if not os.path.exists(dl.DEBUG_LOG_PATH):
print("(会话日志尚不存在)")
return 0
with open(dl.DEBUG_LOG_PATH, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
print(f"===== {os.path.basename(dl.DEBUG_LOG_PATH)} 最后 {min(n, len(lines))} 行 =====")
for l in lines[-n:]:
print(l)
return 0
# 普通文本 → 注入 [AGENT]
dl.debug_log(args[0], "AGENT")
print(f"已注入 [AGENT]: {args[0]}")
return 0
if __name__ == "__main__":
sys.exit(main())
+85
View File
@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
r"""真实 API 测试:opencode-go / deepseek-v4-flash + 完整 agent 循环(工具执行)
运行: C:\Users\14890\miniconda3\envs\haocode\python.exe -u tests/diag_live_agent.py
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
from core.agent import (Agent, AgentConfig, ModelConfig, RetryConfig) # noqa: E402
from core.agent.recovery import AgentRunner # noqa: E402
from core.agent.stream_fn import openai_stream # noqa: E402
from core.agent.tools import default_tools # noqa: E402
MODEL_NAME = "deepseek-v4-flash"
cfg = json.load(open(os.path.join(ROOT, "data", "config.json"), encoding="utf-8"))
prov = cfg["providers"]["opencode-go"]
model = ModelConfig(
provider="opencode-go", name=MODEL_NAME,
context_window=int(prov.get("model_contexts", {}).get(MODEL_NAME, 1000000)),
max_tokens=4096, temperature=0.3,
api_key=prov["api_key"], base_url=prov["base_url"],
)
with open(os.path.join(ROOT, "SYSTEM_PROMPT.md"), encoding="utf-8") as f:
system_prompt = f.read()
agent_cfg = AgentConfig(
model=model,
tools=default_tools(),
system_prompt=system_prompt,
tool_context={"cwd": ROOT},
retry=RetryConfig(max_attempts=2, base_delay_ms=1000),
)
agent = Agent(agent_cfg)
agent.set_stream_fn(openai_stream)
def show(e):
if e.type == "message_update" and e.assistant_message_event is not None:
ev = e.assistant_message_event
if ev.type == "text_delta":
print(" [正文] " + ev.text, end="", flush=True)
elif ev.type == "thinking_delta":
print(" [思考] " + ev.text, end="", flush=True)
elif ev.type == "toolcall_delta":
print(f" [tc] {ev.tool_call_field}={ev.tool_call_delta!r}",
end="", flush=True)
elif e.type == "tool_execution_start" and e.tool_call is not None:
print(f"\n [工具开始] {e.tool_call.name} 参数={e.tool_call.arguments}")
elif e.type == "tool_execution_update" and e.arg:
print(" [工具输出] " + str(e.arg), end="", flush=True)
elif e.type == "tool_execution_end" and e.tool_call is not None:
c = e.result.content if e.result else ""
if not isinstance(c, str):
c = "".join(x.get("text", "") for x in c if isinstance(x, dict))
print(f"\n [工具结束] ok={not e.is_error} 结果={c[:200]!r}")
elif e.type == "agent_end":
print(f"\n [agent_end] stop_reason={e.stop_reason} "
f"error={getattr(e.error, 'message', None)}")
agent.subscribe(show)
runner = AgentRunner(agent)
print("=" * 60)
print(f"实时测试: {model.base_url} / {MODEL_NAME}")
print(f"system prompt: {len(system_prompt)} 字符, tools: {len(default_tools())}")
print("=" * 60)
runner.run("请用 bash 工具执行命令: echo hello-from-haocode && ls,然后告诉我输出结果。")
print("\n===== 最终消息链 =====")
for m in agent.state.messages:
tc = f" tool_calls={[t.name for t in m.tool_calls]}" if m.tool_calls else ""
print(f"- {m.role}: {(m.content or '')[:100]!r}{tc}")
ok = any(m.role == "toolResult" for m in agent.state.messages)
print("\n===== 结论:", "✅ 真实 tool_call 被发出并执行" if ok
else "❌ 没有工具执行(可能供应商不支持 tools API,检查是否走了文字兜底)", "=====")
sys.exit(0 if ok else 1)
+186
View File
@@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
"""E2E onscreen:真实可见窗口 + 真实 API + 流式过程中多点采样 DOM。
复现用户环境(非 offscreen,rAF 行为与真实窗口一致)。
运行: python tests/diag_live_onscreen.py (会在桌面弹出窗口)
"""
import json
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.pop("QT_QPA_PLATFORM", None) # onscreen
os.environ.setdefault("HAOCODE_RENDER", "software")
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from core.agent.types import AssistantMessageEvent # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
window = None
results = []
SNAP_BUF_JS = (
"(function() {"
" var w = document.getElementById('msg-e2e-text');"
" if (!w) return 'no-wrapper';"
" var segs = w.querySelectorAll('.md-segment');"
" var out = [];"
" for (var i = 0; i < segs.length; i++) {"
" out.push('buf:' + ((segs[i].__buf || '').length)"
" + '/dom:' + ((segs[i].textContent || '').length));"
" }"
" return out.join(' ') || 'no-segs';"
"})()"
)
SNAP_JS = (
"(function() {"
" var w = document.getElementById('msg-e2e-text');"
" if (!w) return 'no-wrapper';"
" var segs = w.querySelectorAll('.md-segment');"
" var n = 0;"
" for (var i = 0; i < segs.length; i++)"
" if ((segs[i].textContent || '').trim()) n++;"
" return 'segs=' + segs.length + ' nonempty=' + n +"
" ' chip=' + !!w.querySelector('.tool-chip') +"
" ' streaming=' + w.classList.contains('streaming');"
"})()"
)
FINAL_JS = """
(function() {
try {
var w = document.getElementById('msg-e2e-text');
if (!w) return JSON.stringify({error: 'no wrapper'});
var tl = w.querySelector('.reply-content');
var blocks = Array.prototype.map.call(tl.children,
function(el) { return el.className.split(' ')[0]; });
var segs = w.querySelectorAll('.md-segment');
var segTexts = Array.prototype.map.call(segs,
function(x) { return (x.textContent || '').slice(0, 80); });
var chip = w.querySelector('.tool-chip');
return JSON.stringify({blocks: blocks, segTexts: segTexts,
chip: !!chip, streaming: w.classList.contains('streaming')});
} catch (e) { return JSON.stringify({error: String(e)}); }
})()
"""
def check(name, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {name}"
+ (f" [{detail}]" if detail and not ok else ""))
results.append(ok)
def step1():
global window
window = MainWindow()
window.show()
QTimer.singleShot(3500, step2)
def step2():
from core.llm_engine import AgentWorker
import core.llm_engine as le
db = window.db
sess = db.create_session("E2E onscreen 测试")
sid = sess["id"]
mid = "msg-e2e-text"
user_row = db.add_message(sid, "user", "测试问题", None)
window.current_session_id = sid
window.chat_bridge.create_message(mid, "assistant", "", "E2E")
window._active_streams[sid] = {
"msg_id": mid, "content": "", "reasoning": "",
"timeline": [], "tl_kind": None,
"parent_id": user_row["id"], "branch_info": None, "worker": None,
"previous_leaf_id": user_row["id"],
}
real_stream = le.openai_stream
def logging_stream(context, model, signal, max_tokens, tools=None):
for kind, payload in real_stream(context, model, signal,
max_tokens, tools):
t = getattr(payload, "type", kind)
if t in ("text_delta", "thinking_delta", "toolcall_delta"):
txt = str(getattr(payload, "text", ""))[:25]
print(f" [stream] {t} {txt!r}", flush=True)
else:
print(f" [stream] {kind}/{t}", flush=True)
yield kind, payload
le.openai_stream = logging_stream
worker = AgentWorker(provider_name=window.current_provider,
model_name=window.current_model,
openai_messages=[{"role": "user",
"content": "运行命令 echo hello-from-onscreen "
"并告诉我输出内容"}],
enable_tools=True)
worker.reasoning_received.connect(lambda t: window.on_reasoning_received(sid, t))
worker.chunk_received.connect(lambda t: window.on_chunk_received(sid, t))
worker.tool_execution_started.connect(
lambda cid, name, args: window._on_tool_started(sid, cid, name, args))
worker.tool_execution_updated.connect(
lambda cid, text: window._on_tool_updated(sid, cid, text))
worker.tool_execution_finished.connect(
lambda cid, name, ok, text: window._on_tool_finished(sid, cid, name, ok, text))
worker.error_occurred.connect(
lambda err: (print(" [worker error]", err, flush=True),
window.on_error(sid, err)))
worker.finished.connect(lambda: window.on_reply_finished(sid))
window._active_streams[sid]["worker"] = worker
worker.start()
print("worker started(窗口已可见,观察屏幕)...", flush=True)
def snapshot(tag):
window.browser.page().runJavaScript(
SNAP_JS,
lambda v, tag=tag: print(f" [snapshot {tag}] {v}", flush=True))
window.browser.page().runJavaScript(
SNAP_BUF_JS,
lambda v, tag=tag: print(f" [snapshot {tag} BUF] {v}", flush=True))
QTimer.singleShot(8000, lambda: snapshot("t+8s"))
QTimer.singleShot(15000, lambda: snapshot("t+15s"))
QTimer.singleShot(25000, lambda: snapshot("t+25s"))
QTimer.singleShot(45000, step3)
def step3():
def got(res):
d = json.loads(str(res))
if "error" in d:
check("E2E DOM", False, d["error"])
finish()
return
print(f" blocks = {d['blocks']}")
print(f" segTexts = {d['segTexts']}")
print(f" chip={d['chip']} streaming={d['streaming']}")
check("live 正文段非空", any(t.strip() for t in d["segTexts"]),
str(d["segTexts"]))
check("最终回答含命令输出",
any("hello-from-onscreen" in t for t in d["segTexts"]),
str(d["segTexts"]))
check("工具 chip 存在", d["chip"])
check("streaming 已收尾", not d["streaming"])
finish()
window.browser.page().runJavaScript(FINAL_JS, got)
def finish():
try:
window.db.delete_session(window.current_session_id)
except Exception:
pass
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
f"{sum(results)}/{len(results)} =====")
app.quit()
QTimer.singleShot(500, step1)
app.exec()
+187
View File
@@ -0,0 +1,187 @@
# -*- coding: utf-8 -*-
"""E2E live 测试:真实 AgentWorker + 假流式 + 真实 MainWindow 信号链路。
验证 live 时正文 md-segment 是否有内容(用户报告的 bug)。
运行: QT_QPA_PLATFORM=offscreen python tests/diag_live_text.py
"""
import json
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from core.agent.types import (AssistantMessageEvent, AgentMessage, ToolCall) # noqa: E402
from core.agent import stream_fn as sf # noqa: E402
from core.agent.tools import default_tools # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
window = None
results = []
CALL_STATE = {"n": 0}
def check(name, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {name}"
+ (f" [{detail}]" if detail and not ok else ""))
results.append(ok)
# ---- 假 stream_fn:第 1 轮 思考+文本+bash 工具调用;第 2 轮 最终回答 ----
def fake_stream(context, model, signal, max_tokens, tools=None):
CALL_STATE["n"] += 1
n = CALL_STATE["n"]
if n == 1:
yield ("event", AssistantMessageEvent(type="thinking_delta", text="我先看看"))
yield ("event", AssistantMessageEvent(type="thinking_delta", text="目录。"))
yield ("event", AssistantMessageEvent(type="text_delta", text="我来执行命令"))
yield ("event", AssistantMessageEvent(type="toolcall_delta",
tool_call_index=0, tool_call_field="id", tool_call_delta="call-e2e-1"))
yield ("event", AssistantMessageEvent(type="toolcall_delta",
tool_call_index=0, tool_call_field="name", tool_call_delta="bash"))
yield ("event", AssistantMessageEvent(type="toolcall_delta",
tool_call_index=0, tool_call_field="arguments",
tool_call_delta='{"command": "echo e2e-ok"}'))
time.sleep(0.3)
yield ("final", AgentMessage(
role="assistant", stop_reason="stop",
tool_calls=[ToolCall(id="call-e2e-1", name="bash",
raw_arguments='{"command": "echo e2e-ok"}')]))
else:
for tok in ["最终", "回答", ":任务", "完成。"]:
yield ("event", AssistantMessageEvent(type="text_delta", text=tok))
time.sleep(0.05)
yield ("final", AgentMessage(role="assistant", stop_reason="stop"))
def step1():
global window
window = MainWindow()
QTimer.singleShot(3500, step2)
def step2():
from core.llm_engine import AgentWorker
db = window.db
sess = db.create_session("E2E 正文测试")
sid = sess["id"]
mid = "msg-e2e-text"
user_row = db.add_message(sid, "user", "测试问题", None)
window.current_session_id = sid
window.chat_bridge.create_message(mid, "assistant", "", "E2E")
window._active_streams[sid] = {
"msg_id": mid, "content": "", "reasoning": "",
"timeline": [], "tl_kind": None,
"parent_id": user_row["id"], "branch_info": None, "worker": None,
"previous_leaf_id": user_row["id"],
}
# 真实 API + 流事件日志(定位 text_delta 是否到达)
import core.llm_engine as le
real_stream = le.openai_stream
def logging_stream(context, model, signal, max_tokens, tools=None):
n_ev = 0
for kind, payload in real_stream(context, model, signal,
max_tokens, tools):
t = getattr(payload, "type", kind)
txt = str(getattr(payload, "text", ""))[:30]
if t not in ("text_delta", "thinking_delta", "toolcall_delta"):
print(f" [stream] {kind}/{t}", flush=True)
else:
n_ev += 1
if n_ev <= 6 or n_ev % 20 == 0:
print(f" [stream] {t} {txt!r}", flush=True)
yield kind, payload
le.openai_stream = logging_stream
worker = AgentWorker(provider_name=window.current_provider,
model_name=window.current_model,
openai_messages=[{"role": "user",
"content": "运行命令 echo hello-from-diag 并告诉我输出"}],
enable_tools=True)
# 与 send_message 相同的信号连接
worker.reasoning_received.connect(lambda t: window.on_reasoning_received(sid, t))
worker.chunk_received.connect(lambda t: window.on_chunk_received(sid, t))
worker.tool_execution_started.connect(
lambda cid, name, args: window._on_tool_started(sid, cid, name, args))
worker.tool_execution_updated.connect(
lambda cid, text: window._on_tool_updated(sid, cid, text))
worker.tool_execution_finished.connect(
lambda cid, name, ok, text: window._on_tool_finished(sid, cid, name, ok, text))
worker.error_occurred.connect(lambda err: (print(' [worker error]', err), window.on_error(sid, err)))
worker.finished.connect(lambda: window.on_reply_finished(sid))
window._active_streams[sid]["worker"] = worker
worker.start()
print("worker started, 等待流结束...")
QTimer.singleShot(40000, step3)
def step3():
# live DOM 检查(不重载 DB
js = """
(function() {
try {
var w = document.getElementById('msg-e2e-text');
if (!w) return JSON.stringify({error: 'no wrapper'});
var tl = w.querySelector('.reply-content');
var blocks = Array.prototype.map.call(tl.children,
function(el) { return el.className.split(' ')[0]; });
var segs = w.querySelectorAll('.md-segment');
var segTexts = Array.prototype.map.call(segs,
function(x) { return x.textContent; });
var thinks = w.querySelectorAll('.think-content');
var thinkTexts = Array.prototype.map.call(thinks,
function(x) { return x.textContent; });
var chip = w.querySelector('.tool-chip');
return JSON.stringify({blocks: blocks, segTexts: segTexts,
thinkTexts: thinkTexts, chip: !!chip,
streaming: w.classList.contains('streaming')});
} catch (e) { return JSON.stringify({error: String(e)}); }
})()
"""
def got(res):
d = json.loads(str(res))
if "error" in d:
check("E2E DOM", False, d["error"])
finish()
return
print(f" blocks = {d['blocks']}")
print(f" segTexts = {d['segTexts']}")
print(f" thinkTexts = {d['thinkTexts']}")
print(f" chip = {d['chip']} streaming={d['streaming']}")
check("live 正文段有内容(至少一段非空)",
any((t or "").strip() for t in d["segTexts"]),
str(d["segTexts"]))
check("live 最终回答含命令输出",
any("hello-from-diag" in (t or "") for t in d["segTexts"]),
str(d["segTexts"]))
check("live 思考段有内容(或模型未思考)",
True, str(d["thinkTexts"])[:80])
check("live 工具 chip 存在", d["chip"])
check("streaming 已收尾", not d["streaming"])
# 时间线入库检查
st = window._active_streams.get(sid := window.current_session_id)
tl = window.db.get_session(window.current_session_id) if False else None
finish()
window.browser.page().runJavaScript(js, got)
def finish():
try:
window.db.delete_session(window.current_session_id)
except Exception:
pass
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
f"{sum(results)}/{len(results)} =====")
app.quit()
QTimer.singleShot(500, step1)
app.exec()
+75
View File
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""问题 3 取证:右侧栏 bash 层「参数/输出」框的滚动条 QSS 现状(离屏截图 + 几何/样式打印)"""
import os
import sys
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q3_{os.getpid()}.db")
_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q3_{os.getpid()}.json")
open(_cfg, "w", encoding="utf-8").write('{"providers": {}}')
os.environ["HAOCODE_CONFIG_FILE"] = _cfg
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
w = MainWindow()
w.resize(1400, 950)
w.show()
for _ in range(40):
app.processEvents()
QTest.qWait(20)
p = w.bash_panel
p.expand_btn.click()
for _ in range(30):
app.processEvents()
QTest.qWait(20)
# 造一个「已完成 + 长输出」的层(长输出会把垂直/水平滚动条都逼出来)
long_cmd = "python train.py --epochs 300 --batch-size 64 --lr 0.0001 --data /data/detr/annotations.json"
long_out = "\n".join(
f"[{i:04d}] epoch loss=0.{i % 9}{i % 7} lr=0.0001 mAP=0.{40 + i % 20} "
f"very_long_tail_to_force_horizontal_scrollbar_{i}"
for i in range(120))
p.on_started("q3_lay", "bash", {"command": long_cmd})
p.on_finished("q3_lay", "bash", True, long_out)
for _ in range(20):
app.processEvents()
QTest.qWait(20)
lay = p._layers["q3_lay"]
lay.toggle() # 展开层 → 显示「参数」「输出」两块
for _ in range(20):
app.processEvents()
QTest.qWait(20)
for nm, box in (("参数 arg_box", lay.arg_box), ("输出 out_box", lay.out_box)):
vb = box.verticalScrollBar()
hb = box.horizontalScrollBar()
print(f"--- {nm}")
print(f" objectName={box.objectName()} 尺寸={box.width()}x{box.height()}")
print(f" 垂直滚动条: 可见={vb.isVisible()} 宽={vb.width()} 需要={vb.maximum() > 0}")
print(f" 水平滚动条: 可见={hb.isVisible()} 高={hb.height()} 需要={hb.maximum() > 0}")
print(f" box 自身 stylesheet = {box.styleSheet()!r}")
print(f" box 背景角色 = {box.palette().base().color().name()}")
img = lay.grab().toImage()
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q3_layer.png")
img.save(out)
print("\n层截图 ->", out, img.width(), "x", img.height())
# 再抓取面板整块(看滚动条在面板里的观感)
img2 = p.grab().toImage()
out2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q3_panel.png")
img2.save(out2)
print("面板截图 ->", out2, img2.width(), "x", img2.height())
os.remove(_cfg)
+74
View File
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""问题 5 取证:会话改名覆盖层(RenameOverlay)的"聚光灯"遮罩现状"""
import os
import sys
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q5_{os.getpid()}.db")
_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q5_{os.getpid()}.json")
open(_cfg, "w", encoding="utf-8").write('{"providers": {}}')
os.environ["HAOCODE_CONFIG_FILE"] = _cfg
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from ui.views.main_window import MainWindow, RenameOverlay, SessionContextPopup # noqa: E402
app = QApplication(sys.argv)
w = MainWindow()
w.resize(1300, 900)
w.show()
for _ in range(40):
app.processEvents()
QTest.qWait(20)
sid = w.db.create_session("聚光灯测试会话")["id"]
w.load_messages_to_web(sid)
for _ in range(20):
app.processEvents()
QTest.qWait(20)
print("=== SessionContextPopup 是否为独立顶层窗口 ===")
print(" windowFlags 含 Qt.Popup ?", bool(SessionContextPopup(sid, "", w).windowFlags() & 0x80000000))
pop = SessionContextPopup(sid, "聚光灯测试会话", w)
print(" isWindow() =", pop.isWindow(), " windowType =", pop.windowFlags())
print()
print("=== RenameOverlay 现状 ===")
w._rename_session(sid)
for _ in range(25):
app.processEvents()
QTest.qWait(20)
ovs = [c for c in w.children() if isinstance(c, RenameOverlay)]
if not ovs:
print(" ✗ 没找到 RenameOverlay")
else:
ov = ovs[0]
print(" 父对象 =", ov.parent().__class__.__name__)
print(" isWindow() =", ov.isWindow(), " (False = 主窗口的子控件,只覆盖客户区)")
print(" 几何 =", ov.geometry())
print(" 主窗口 rect =", w.rect(), " 主窗口 frameGeometry =", w.frameGeometry())
print(" WA_TranslucentBackground =", ov.testAttribute(
__import__('PyQt6.QtCore', fromlist=['Qt']).Qt.WidgetAttribute.WA_TranslucentBackground))
print(" graphicsEffect =", type(ov.graphicsEffect()).__name__ if ov.graphicsEffect() else None)
print(" 遮罩色 =", ov._overlay_color.getRgb())
print(" form 几何 =", ov.form.geometry(), " form 是否在遮罩内 =",
ov.rect().contains(ov.form.geometry()))
# 看遮罩是否盖住了标题栏 / 是否超出窗口
print(" 遮罩 top-left(全局) =", ov.mapToGlobal(ov.rect().topLeft()),
" bottom-right(全局) =", ov.mapToGlobal(ov.rect().bottomRight()))
print(" 主窗口(全局) =", w.mapToGlobal(w.rect().topLeft()), w.mapToGlobal(w.rect().bottomRight()))
out = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q5_overlay.png")
ov.grab().save(out)
print(" 覆盖层截图 ->", out, ov.width(), "x", ov.height())
out2 = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_tmp_q5_window.png")
w.grab().save(out2)
print(" 主窗口截图 ->", out2, w.width(), "x", w.height())
os.remove(_cfg)
+90
View File
@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""问题 1 取证:长会话渲染的真实 DOM 规模与耗时(离屏,临时库)"""
import os
import sys
import time
import json
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q1_{os.getpid()}.db")
_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q1_{os.getpid()}.json")
open(_cfg, "w", encoding="utf-8").write('{"providers": {}}')
os.environ["HAOCODE_CONFIG_FILE"] = _cfg
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
N = int(sys.argv[1]) if len(sys.argv) > 1 else 200
app = QApplication(sys.argv)
w = MainWindow()
w.resize(1200, 850)
w.show()
for _ in range(30):
app.processEvents()
QTest.qWait(20)
sid = w.db.create_session(f"长会话渲染取证 {N}")["id"]
parent = None
# 造 N 轮(每轮 user + assistantassistant 带代码块 + 工具时间线,贴近真实负载)
for i in range(N // 2):
parent = w.db.add_message(session_id=sid, role="user",
content=f"{i} 个问题:帮我看看这段代码\n```python\nprint({i})\n```",
parent_id=parent)["id"]
tl = json.dumps([
{"t": "think", "text": "分析中…" * 20},
{"t": "text", "text": f"### 回答 {i}\n\n要点如下:\n\n- 第一条\n- 第二条\n\n```python\nfor j in range(10):\n print(j)\n```\n"},
{"t": "tool", "id": f"c{i}", "name": "bash", "args": '{"command":"echo hi"}',
"ok": True, "result": "hi\n" * 30},
], ensure_ascii=False)
parent = w.db.add_message(session_id=sid, role="assistant",
content=f"回答 {i}:见代码块与工具结果。",
parent_id=parent, timeline=tl)["id"]
chain = w.db.get_message_chain(sid)
print(f"库内消息条数 = {len(chain)}")
t0 = time.time()
w.load_messages_to_web(sid)
for _ in range(80):
app.processEvents()
QTest.qWait(25)
dt = time.time() - t0
holder = {}
done = []
def got(res):
holder["dom"] = res
done.append(1)
w.browser.page().runJavaScript(
"JSON.stringify({wrappers: document.querySelectorAll('.message-wrapper').length,"
" nodes: document.getElementsByTagName('*').length,"
" height: document.scrollingElement.scrollHeight,"
" codeBlocks: document.querySelectorAll('.code-block-wrapper').length,"
" katex: document.querySelectorAll('.katex').length})", got)
for _ in range(60):
app.processEvents()
QTest.qWait(25)
if done:
break
print(f"渲染耗时 ≈ {dt:.2f}s")
print("DOM 统计 =", holder.get("dom"))
if holder.get("dom"):
d = json.loads(holder["dom"])
print(f" → 消息节点 {d['wrappers']} 个 / 全 DOM 节点 {d['nodes']} 个 / "
f"页面总高 {d['height']}px / 代码块 {d['codeBlocks']}")
print(f" → 平均每条消息 {d['nodes'] // max(1, d['wrappers'])} 个 DOM 节点")
os.remove(_cfg)
+118
View File
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
"""公式渲染验收消息注入:新建「公式渲染验收」会话,写入用户原始样本 + 正/负例
运行: PYTHONIOENCODING=utf-8 python tests/inject_math_demo.py
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from core.db_manager import DBManager # noqa: E402
USER_MSG = """帮我看看这个推导,公式渲染对不对?
上面是推导结果:
[
P_4=\\operatorname{BRF}(M_4,M_5)
]
[
P_3=\\operatorname{BRF}(S_3,P_4)
]
其中:
[
\\operatorname{BRF}(L,H)
L+
\\Gamma(L,\\operatorname{Up}(H))
\\odot
\\Phi(\\operatorname{Up}(H))
]
"""
ASSISTANT_MSG = r"""## 公式渲染验收
### 1) 你原始样本(单括号块,应渲染为独立居中公式)
上面是推导结果:
[
P_4=\operatorname{BRF}(M_4,M_5)
]
[
P_3=\operatorname{BRF}(S_3,P_4)
]
其中:
[
\operatorname{BRF}(L,H)
L+
\Gamma(L,\operatorname{Up}(H))
\odot
\Phi(\operatorname{Up}(H))
]
### 2) 标准定界符
行内混合:能量公式 $E=mc^2$ 出现在句子中间;再来一个 $x_i^2 + y_j^2 = z_{ij}^2$。
行内括号形式:\(\alpha + \beta = \gamma\)
双美元块:
$$
\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
$$
方括号块:
\[
\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6}
\]
带矩阵与希腊字母:
[
\begin{pmatrix} a & b \\ c & d \end{pmatrix}
\begin{pmatrix} x \\ y \end{pmatrix}
=
\begin{pmatrix} ax+by \\ cx+dy \end{pmatrix}
]
### 3) 反例(不应渲染成公式)
- 编号引用:见[1]和[2]的说明。
- 链接:[KaTeX 官网](https://katex.org)
- 列表内容:[a, b] 只是一个数组。
- 货币:价格 $1,000 and $2,000 之间。
- 代码块:
```python
price = "$5"
pattern = r"$x + y$"
arr[0] = 1
```
- 行内代码:使用 `$z$` 表示变量。
### 4) 复杂嵌套(流式增量渲染路径同样适用)
$$
f(x) = \sum_{k=0}^{n} \binom{n}{k} x^k (1-x)^{n-k}
$$
"""
def main():
db = DBManager()
sess = db.create_session("公式渲染验收")
sid = sess["id"]
parent = sess.get("current_leaf_msg_id")
m1 = db.add_message(sid, "user", USER_MSG, parent_id=parent)
db.add_message(sid, "assistant", ASSISTANT_MSG, parent_id=m1["id"])
db.mark_session_has_messages(sid)
print(f"已注入会话: {sid}")
print(f"标题: 公式渲染验收")
print(f"用户消息 {len(USER_MSG)}c / 助手消息 {len(ASSISTANT_MSG)}c")
return 0
if __name__ == "__main__":
sys.exit(main())
+33
View File
@@ -0,0 +1,33 @@
"""离线测试 harness(无 pytest 依赖):
运行: conda run -n haocode python tests/run_tests.py
"""
import importlib.util
import os
import sys
import traceback
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
spec = importlib.util.spec_from_file_location(
"test_agent_core", os.path.join(os.path.dirname(__file__), "test_agent_core.py"))
mod = importlib.util.module_from_spec(spec)
# 替换 pytest 依赖后加载
import types
_py_stub = types.ModuleType("pytest")
sys.modules["pytest"] = _py_stub
spec.loader.exec_module(mod)
tests = [(n, f) for n, f in sorted(vars(mod).items())
if n.startswith("test_") and callable(f)]
passed = failed = 0
for name, fn in tests:
try:
fn()
print(f" PASS {name}")
passed += 1
except Exception:
print(f" FAIL {name}")
traceback.print_exc()
failed += 1
print(f"\n===== {passed} passed, {failed} failed / {len(tests)} =====")
sys.exit(1 if failed else 0)
+614
View File
@@ -0,0 +1,614 @@
# -*- coding: utf-8 -*-
"""右侧任务面板 · offscreen 端到端
链路:顶部按钮(原「导出」位置)→ BashPanel 展开/收起 → tool_execution_* 事件
→ 层(运行中/已完成)→ 单击展开(参数 + 输出)→ 会话切换刷新
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_bash_panel.py
"""
import os
import sys
import json
import tempfile
import shutil
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" # 绕过 AMD 核显 context lost
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_bashpanel_{os.getpid()}.db")
if os.path.exists(_DB_TMP):
os.remove(_DB_TMP)
_dbm._DEFAULT_DB = _DB_TMP
# 铁律:宽度记录会写 config.json → 测试指向临时配置,绝不碰真实 data/config.json
_CFG_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_cfg_{os.getpid()}.json")
with open(_CFG_TMP, "w", encoding="utf-8") as _f:
_f.write('{"providers": {}, "mode_switch": true}')
os.environ["HAOCODE_CONFIG_FILE"] = _CFG_TMP
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from PyQt6 import QtCore, QtWidgets # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
from ui.views.bash_panel import BashLayer, LIVE_BUF_CAP, LAYER_LIMIT # noqa: E402
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=400):
for _ in range(int(ms / 20) + 1):
app.processEvents()
QTest.qWait(20)
window = MainWindow()
window.show()
panel = window.bash_panel
def px(widget, x, y):
"""取控件自身坐标系下 (x,y) 的渲染像素"""
img = widget.grab().toImage()
c = img.pixelColor(x, y)
return (c.red(), c.green(), c.blue())
# ======================================================================
# 1) 外壳:默认收起 52px、顶部按钮存在且尺寸与左侧收起按钮一致
# ======================================================================
check("P1.1 面板已创建并挂进主布局", panel is not None and panel.parent() is not None)
check("P1.2 默认收起 52px", panel.width() == 52 and panel.collapsed is True,
f"w={panel.width()} collapsed={panel.collapsed}")
check("P1.3 「导出」按钮已移除", not hasattr(window, "btn_export"))
# 🆕 开关只在右栏内部(与左侧栏一致),主界面顶部【不得】有任何面板按钮
check("P1.4 主界面顶部【没有】面板开关按钮", not hasattr(window, "btn_panel"))
def visible_toggles():
"""全窗口可见的面板开关按钮(tooltip 含“任务面板”)"""
return [b for b in window.findChildren(QtWidgets.QPushButton)
if "任务面板" in (b.toolTip() or "") and b.isVisible()]
check("P1.5 收起态:可见开关恒为 1 个", len(visible_toggles()) == 1, str(len(visible_toggles())))
check("P1.6 收起态开关 = 面板内的 expand_btn(居中)",
visible_toggles()[0] is panel.expand_btn)
check("P1.7 expand_btn 34×34、icon 18×18(与左侧栏收起页按钮同规格)",
panel.expand_btn.width() == 34 and panel.expand_btn.height() == 34
and panel.expand_btn.iconSize() == QtCore.QSize(18, 18)
and panel.expand_btn.width() == window.collapse_expand_btn.width(),
f"{panel.expand_btn.width()}x{panel.expand_btn.height()} vs 左 {window.collapse_expand_btn.width()}")
check("P1.8 expand_btn 与左侧按钮同 objectName(继承同一套 QSS",
panel.expand_btn.objectName() == window.collapse_expand_btn.objectName() == "collapse_btn")
_x = panel.expand_btn.mapTo(panel, QtCore.QPoint(0, 0)).x()
check("P1.9 expand_btn 水平居中于 52px 栏内",
abs((_x + 34 / 2) - panel.width() / 2) <= 1.5, f"center={_x + 17} panel_half={panel.width()/2}")
# 展开(用栏内按钮,不用主界面按钮)
panel.expand_btn.click()
settle(500)
check("P1.10 点击收起页按钮 → 展开到 260px", panel.width() == 260 and panel.collapsed is False,
f"w={panel.width()} collapsed={panel.collapsed}")
check("P1.11 展开态:可见开关仍恒为 1 个(不叠加)",
len(visible_toggles()) == 1, str(len(visible_toggles())))
check("P1.12 展开态开关 = 面板标题行右上角的 fold_btn",
visible_toggles()[0] is panel.fold_btn)
check("P1.13 fold_btn 28×28、icon 16×16(与左侧栏收起按钮同规格)",
panel.fold_btn.width() == 28 and panel.fold_btn.height() == 28
and panel.fold_btn.iconSize() == QtCore.QSize(16, 16)
and panel.fold_btn.width() == window.collapse_btn.width(),
f"{panel.fold_btn.width()}x{panel.fold_btn.height()} vs 左 {window.collapse_btn.width()}")
check("P1.14 fold_btn 与左侧按钮同 objectName(同一套 QSS",
panel.fold_btn.objectName() == window.collapse_btn.objectName() == "collapse_btn")
_fx = panel.fold_btn.mapTo(panel, QtCore.QPoint(0, 0)).x()
check("P1.15 fold_btn 位于标题行右端(右侧留白 ≤14px)",
panel.width() - (_fx + 28) <= 14, f"right_gap={panel.width() - (_fx + 28)}")
check("P1.16 展开后两栏同时可见(不互斥)",
(not panel.sec_running.isHidden()) and (not panel.sec_done.isHidden()),
f"run_hidden={panel.sec_running.isHidden()} done_hidden={panel.sec_done.isHidden()}")
check("P1.17 两栏为上下排列(分隔线可拖动)",
panel.splitter.orientation() == QtCore.Qt.Orientation.Vertical)
check("P1.18 默认两栏提示语",
panel.sec_running.hint.text() == "暂无正在运行的 bash"
and panel.sec_done.hint.text() == "本会话还没有已完成的 bash",
f"{panel.sec_running.hint.text()!r} / {panel.sec_done.hint.text()!r}")
# 🐛 已修:QWidget 子类样式表背景 + 底色/分隔线继承左侧
check("P1.19 样式表背景已启用(WA_StyledBackground",
panel.testAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground))
check("P1.20 面板底色 = 左侧栏底色 (#f7f8fa)",
px(panel, 30, 400) == (0xf7, 0xf8, 0xfa), str(px(panel, 30, 400)))
check("P1.21 面板左侧分隔线 = 左侧栏分隔线 (#ececec)",
px(panel, 0, 400) == (0xec, 0xec, 0xec), str(px(panel, 0, 400)))
check("P1.22 左侧栏对照:底色+分隔线",
px(window.sidebar, 30, 400) == (0xf7, 0xf8, 0xfa)
and px(window.sidebar, 259, 400) == (0xec, 0xec, 0xec),
f"{px(window.sidebar, 30, 400)} {px(window.sidebar, 259, 400)}")
check("P1.23 标题行与左侧栏标题行垂直对齐(y 相等、高 36)",
panel.header.mapTo(window, QtCore.QPoint(0, 0)).y()
== window.sidebar_header.mapTo(window, QtCore.QPoint(0, 0)).y()
and panel.header.height() == window.sidebar_header.height() == 36,
f"panel_y={panel.header.mapTo(window, QtCore.QPoint(0, 0)).y()} "
f"left_y={window.sidebar_header.mapTo(window, QtCore.QPoint(0, 0)).y()}")
# ======================================================================
# 2) 事件驱动:运行中 → 实时输出 → 读秒 → 完成归位
# ======================================================================
sid = window.current_session_id
if not sid:
window.on_new_chat_clicked()
sid = window.current_session_id
window._on_tool_started(sid, "c1", "bash", json.dumps({"command": "echo hi"}, ensure_ascii=False))
check("P2.1 bash 事件 → 运行中栏出现 1 层", panel.layer_ids("running") == ["c1"],
str(panel.layer_ids("running")))
check("P2.2 已完成栏仍为空", panel.layer_ids("done") == [], str(panel.layer_ids("done")))
lay = panel._layers["c1"]
check("P2.3 层头显示 bash + 运行中标签",
lay.name.text() == "bash" and lay.tag.text() == "运行中", lay.tag.text())
check("P2.4 层头显示命令预览", "echo hi" in lay.cmd.text(), lay.cmd.text())
window._on_tool_updated(sid, "c1", "hi\n")
window._on_tool_updated(sid, "c1", "second\n")
check("P2.5 实时输出被累积", lay._live == "hi\nsecond\n", repr(lay._live))
window._on_tool_timed(sid, "c1", 3, 120)
check("P2.6 读秒显示 3/120s", lay.meta.text() == "3/120s", lay.meta.text())
# 非 bash 工具不登记
window._on_tool_started(sid, "c9", "read", json.dumps({"path": "x"}))
check("P2.7 非 bash 工具不登记", "c9" not in panel._layers)
# 完成 → 归位到已完成栏
final_text = "$ echo hi\nhi\n[exit 0] (0.1s)"
window._on_tool_finished(sid, "c1", "bash", True, final_text)
check("P2.8 完成后从运行中移除", panel.layer_ids("running") == [], str(panel.layer_ids("running")))
check("P2.9 完成后进入已完成栏", panel.layer_ids("done") == ["c1"], str(panel.layer_ids("done")))
check("P2.10 已完成标签 + 耗时(从 (0.1s) 解析)",
lay.tag.text() == "已完成" and lay.meta.text() == "0.1s", f"{lay.tag.text()} {lay.meta.text()}")
check("P2.11 已完成层输出 = 进入上下文的原文", lay._final == final_text)
# ======================================================================
# 3) 单击展开 → 参数 + 输出
# ======================================================================
check("P3.1 初始为收起态", lay.expanded is False and lay.body.isHidden())
QTest.mouseClick(lay.head, QtCore.Qt.MouseButton.LeftButton,
QtCore.Qt.KeyboardModifier.NoModifier,
QtCore.QPoint(10, lay.head.height() // 2))
settle(120)
check("P3.2 单击(非双击)即展开", lay.expanded is True and not lay.body.isHidden())
check("P3.3 参数区显示完整命令", lay.arg_box.toPlainText() == "echo hi",
repr(lay.arg_box.toPlainText()))
check("P3.4 输出区标题标明「进入上下文」", lay.lbl_out.text() == "输出(进入上下文)",
lay.lbl_out.text())
check("P3.5 输出区显示上下文原文", lay.out_box.toPlainText() == final_text)
QTest.mouseClick(lay.head, QtCore.Qt.MouseButton.LeftButton,
QtCore.Qt.KeyboardModifier.NoModifier,
QtCore.QPoint(10, lay.head.height() // 2))
settle(120)
check("P3.6 再点一次收起", lay.expanded is False and lay.body.isHidden())
# 运行中的层展开 → 实时输出
window._on_tool_started(sid, "c2", "bash", json.dumps({"command": "ping -t x"}))
window._on_tool_updated(sid, "c2", "line-A\n")
lay2 = panel._layers["c2"]
lay2.toggle()
check("P3.7 运行中层输出标题 = 输出(实时)", lay2.lbl_out.text() == "输出(实时)",
lay2.lbl_out.text())
check("P3.8 运行中层实时内容可见", "line-A" in lay2.out_box.toPlainText())
window._on_tool_updated(sid, "c2", "line-B\n")
check("P3.9 展开状态下新输出即时可见(无需重新展开)",
"line-B" in lay2.out_box.toPlainText(), repr(lay2.out_box.toPlainText()))
window._on_tool_finished(sid, "c2", "bash", False, "$ ping -t x\nline-A\n[exit 1] (0.5s)")
check("P3.10 失败层标签为「失败」", lay2.tag.text() == "失败", lay2.tag.text())
# 实时缓冲上限
big = BashLayer("cbuf", "x")
for _ in range(260):
big.append_live("y" * 1000)
check("P3.11 实时缓冲截到 200KB 内", len(big._live) <= LIVE_BUF_CAP, str(len(big._live)))
check("P3.12 截断被标注", big._live_truncated is True)
# ======================================================================
# 4) 压缩切点 → 已出上下文标注
# ======================================================================
sid2 = window.db.create_session("面板压缩测试")["id"]
leaf = window.db.get_session_leaf(sid2)
tl1 = json.dumps([{"t": "tool", "id": "old1", "name": "bash",
"args": json.dumps({"command": "old-cmd"}),
"ok": True, "result": "$ old-cmd\nold\n[exit 0] (0.1s)"}],
ensure_ascii=False)
m_sys = leaf
m_u1 = window.db.add_message(sid2, "user", "q1", m_sys)["id"]
m_a1 = window.db.add_message(sid2, "assistant", "a1", m_u1, timeline=tl1)["id"]
m_u2 = window.db.add_message(sid2, "user", "q2", m_a1)["id"]
tl2 = json.dumps([{"t": "tool", "id": "new1", "name": "bash",
"args": json.dumps({"command": "new-cmd"}),
"ok": True, "result": "$ new-cmd\nnew\n[exit 0] (0.1s)"}],
ensure_ascii=False)
m_a2 = window.db.add_message(sid2, "assistant", "a2", m_u2, timeline=tl2)["id"]
window.db.insert_compaction_mark(sid2, "【摘要】前文略", cut_before_id=m_a1,
first_retained_id=m_u2)
window.load_messages_to_web(sid2)
settle(300)
check("P4.1 切换会话后面板跟随刷新", set(panel.layer_ids()) == {"old1", "new1"},
str(panel.layer_ids()))
check("P4.2 压缩切点之前的层标注「已出上下文」",
panel._layers["old1"].tag.text() == "已出上下文", panel._layers["old1"].tag.text())
check("P4.3 切点之后的层仍为「已完成」",
panel._layers["new1"].tag.text() == "已完成", panel._layers["new1"].tag.text())
check("P4.4 已完成层不截断输出(原文保留)",
panel._layers["old1"]._final.endswith("[exit 0] (0.1s)"))
# ======================================================================
# 5) 层数上限
# ======================================================================
sid3 = window.db.create_session("面板层数上限")["id"]
leaf3 = window.db.get_session_leaf(sid3)
entries = [{"t": "tool", "id": f"many{i}", "name": "bash",
"args": json.dumps({"command": f"cmd{i}"}),
"ok": True, "result": f"$ cmd{i}\nok\n[exit 0] (0.1s)"} for i in range(35)]
window.db.add_message(sid3, "assistant", "批量", leaf3,
timeline=json.dumps(entries, ensure_ascii=False))
window.load_messages_to_web(sid3)
settle(300)
check("P5.1 只渲染最近 30 层", panel.sec_done.count.text() == str(LAYER_LIMIT),
panel.sec_done.count.text())
check("P5.2 有「仅显示最近 N 层」提示", "仅显示最近 30 层" in panel.sec_done.hint.text(),
panel.sec_done.hint.text())
check("P5.3 面板内部仍保留全部 35 层(只是不渲染)",
len(panel.layer_ids("done")) == 35, str(len(panel.layer_ids("done"))))
# ======================================================================
# 6) 不自动收缩(resizeEvent 不影响右侧面板)+ 手动切换
# ======================================================================
was = panel.collapsed
window.resize(920, 700)
settle(200)
check("P6.1 窗口变小不触发面板自动收缩", panel.collapsed == was,
f"before={was} after={panel.collapsed}")
window.resize(1400, 800)
settle(200)
check("P6.2 窗口变大也不触发面板自动展开/收缩", panel.collapsed == was)
panel.fold_btn.click()
settle(500)
check("P6.3 只有点栏内按钮才收起", panel.width() == 52 and panel.collapsed is True)
check("P6.4 收起后可见开关切回 expand_btn(恒为 1 个)",
len(visible_toggles()) == 1 and visible_toggles()[0] is panel.expand_btn)
panel.expand_btn.click()
settle(500)
check("P6.5 收起页按钮再点一次即可展开", panel.width() == 260 and panel.collapsed is False)
check("P6.6 全窗口仍只有 1 个可见面板开关(且主界面顶部没有)",
len(visible_toggles()) == 1 and not hasattr(window, "btn_panel"))
# ======================================================================
# 7) 会话切回空会话 → 面板清空
# ======================================================================
sid4 = window.db.create_session("面板空会话")["id"]
window.load_messages_to_web(sid4)
settle(250)
check("P7.1 切到空会话后面板清空", panel.layer_ids() == [], str(panel.layer_ids()))
# ======================================================================
# 8) 🆕 左边缘拖拽调宽 → 记录 → 下次展开自动恢复(含最小宽度)
# ======================================================================
from ui.views.bash_panel import (PANEL_W_DEFAULT, PANEL_W_MIN, PANEL_W_MAX, # noqa: E402
BashPanel, load_panel_width)
if panel.collapsed:
panel.expand_btn.click()
settle(400)
check("P8.1 拖拽手柄存在、宽 4px、贴在最左",
panel._handle.width() == 4 and panel._handle.x() == 0,
f"w={panel._handle.width()} x={panel._handle.x()}")
check("P8.2 手柄覆盖面板全高", panel._handle.height() == panel.height(),
f"{panel._handle.height()} vs {panel.height()}")
check("P8.3 手柄光标 = 水平拖拽",
panel._handle.cursor().shape() == QtCore.Qt.CursorShape.SizeHorCursor)
check("P8.4 最小宽度已设定(%d px" % PANEL_W_MIN,
PANEL_W_MIN == 200 and panel.W_MIN == PANEL_W_MIN)
def drag_to(width):
"""模拟真实拖拽:按下(记右边界)→ 鼠标移到目标位置 → 松手"""
right = panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x()
panel._drag_begin(right)
panel._drag_to_global_x(right - width)
panel._commit_drag_width()
settle(120)
# 8.1 正常拖宽 → 立即生效 + 落盘
cfg_before = json.load(open(_CFG_TMP, encoding="utf-8"))
drag_to(340)
check("P8.5 拖到 340px 立即生效", panel.width() == 340 and panel.collapsed is False,
f"w={panel.width()}")
cfg_after = json.load(open(_CFG_TMP, encoding="utf-8"))
check("P8.6 已落盘 config.json['bash_panel_width'] = 340",
cfg_after.get("bash_panel_width") == 340, str(cfg_after.get("bash_panel_width")))
check("P8.7 落盘不破坏其它键(providers/mode_switch 保留)",
"providers" in cfg_after and cfg_after.get("mode_switch") is True)
# 8.2 收起再展开 → 回到记录值(不是默认 260)
panel.fold_btn.click()
settle(450)
check("P8.8 收起仍为 52px", panel.width() == 52 and panel.collapsed is True, f"w={panel.width()}")
check("P8.9 收起条居中按钮不被 4px 手柄遮住", panel.expand_btn.x() >= panel._handle.width(),
f"btn_x={panel.expand_btn.x()}")
panel.expand_btn.click()
settle(450)
check("P8.10 再展开 = 记录的 340px(不是默认 %d" % PANEL_W_DEFAULT,
panel.width() == 340 and panel.W_EXPAND == 340, f"w={panel.width()} W_EXPAND={panel.W_EXPAND}")
# 8.3 最小宽度夹紧
win_half = int(window.width() * 0.5)
drag_to(120)
check("P8.11 拖到 120px → 被夹到最小 200px", panel.width() == PANEL_W_MIN, f"w={panel.width()}")
check("P8.12 夹紧后仍是展开态(不会误判为收起)",
panel.collapsed is False and panel.width() > panel.W_COLLAPSE)
# 8.4 上限夹紧
drag_to(5000)
exp_hi = min(PANEL_W_MAX, max(PANEL_W_MIN, win_half))
check("P8.13 拖到 5000px → 被夹到上限(≤%d" % exp_hi, panel.width() == exp_hi,
f"w={panel.width()} exp={exp_hi}")
# 8.5 从收起态直接拖开 → 自动进展开态
drag_to(PANEL_W_MIN)
panel.fold_btn.click()
settle(450)
check("P8.14 已收起 52px", panel.width() == 52)
panel._drag_begin(panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x())
panel._drag_to_global_x(panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x() + 100)
check("P8.15 从收起态向外拖 → 立即进入展开态(不为 0/负数)",
panel.collapsed is False and panel.width() >= PANEL_W_MIN, f"w={panel.width()}")
panel._commit_drag_width()
settle(120)
# 8.6 持久化:新实例直接读出记录值
drag_to(300)
check("P8.16 记录 300px 后,新实例读出同一值",
load_panel_width() == 300, str(load_panel_width()))
_p2 = BashPanel()
check("P8.17 新面板 W_EXPAND 即上次记录值(重启后自动恢复)",
_p2.W_EXPAND == 300, str(_p2.W_EXPAND))
_p2.deleteLater()
# 8.7 只拖不松手不写盘
with open(_CFG_TMP, "w", encoding="utf-8") as _f:
json.dump({"providers": {}, "mode_switch": True}, _f)
right = panel.mapToGlobal(QtCore.QPoint(panel.width(), 0)).x()
panel._drag_begin(right)
panel._drag_to_global_x(right - 420)
check("P8.18 拖动过程中不写盘(只在松手时记录)",
json.load(open(_CFG_TMP, encoding="utf-8")).get("bash_panel_width") is None)
panel._commit_drag_width()
check("P8.19 松手才落盘",
json.load(open(_CFG_TMP, encoding="utf-8")).get("bash_panel_width") == panel.width(),
str(json.load(open(_CFG_TMP, encoding="utf-8")).get("bash_panel_width")))
# ======================================================================
# 9) 🆕 两栏折叠/展开:只收「下面填充的 bash 层」,头部行高恒定(抽屉动画)
# ======================================================================
if panel.collapsed:
panel.expand_btn.click()
settle(400)
# 造数据:运行中 1 层 + 已完成 2 层
panel.on_started("p9_run", "bash", {"command": "python long_task.py"})
panel.on_timed("p9_run", 3, 120)
panel.on_started("p9_d1", "bash", {"command": "echo a"})
panel.on_finished("p9_d1", "bash", True, "a")
panel.on_started("p9_d2", "bash", {"command": "echo b"})
panel.on_finished("p9_d2", "bash", True, "b")
settle(300)
sr, sd = panel.sec_running, panel.sec_done
check("P9.0 两栏都有内容(运行中1 / 已完成2)",
len(panel.layer_ids("running")) == 1 and len(panel.layer_ids("done")) == 2,
f"{panel.layer_ids('running')} / {panel.layer_ids('done')}")
head_h = sr.head_height()
total0 = sum(panel.splitter.sizes())
check("P9.1 头部行高 = 24px 量级(收起/展开恒定的那一行)",
16 <= head_h <= 40, f"head_h={head_h}")
check("P9.2 最小高已被重写为「只剩头部行」(bug 根因解除)",
sr.minimumSizeHint().height() == head_h and sd.minimumSizeHint().height() == sd.head_height(),
f"{sr.minimumSizeHint().height()} vs head {head_h}")
h_run_before = sr.height()
h_done_before = sd.height()
# ---- 收起「运行中」-----
sr.head.clicked.emit()
settle(120) # 只等一半 → 应处于动画中间态
mid_run = sr.height()
settle(400)
check("P9.3 收起「运行中」→ 该栏只剩头部高(空白归零)",
abs(sr.height() - head_h) <= 2, f"h={sr.height()} head={head_h}")
check("P9.4 splitter sizes[0] == 头部高",
abs(panel.splitter.sizes()[0] - head_h) <= 2, str(panel.splitter.sizes()))
check("P9.5 「已完成」栏吃掉让出的空间",
sd.height() > h_done_before + 50, f"{h_done_before} -> {sd.height()}")
check("P9.6 让出空间 = 两栏高度之和不变",
abs((sr.height() + sd.height()) - (h_run_before + h_done_before)) <= 2,
f"{h_run_before}+{h_done_before} vs {sr.height()}+{sd.height()}")
check("P9.7 动画是渐变(中间帧介于首末之间)",
head_h < mid_run < h_run_before, f"mid={mid_run} ({head_h}, {h_run_before})")
check("P9.8 收起后内容区不可见(scroll.isVisible() == False",
not sr.scroll.isVisible() and not sr.body.isVisible())
check("P9.9 收起后头部行仍可见且高度不变",
sr.head.isVisible() and abs(sr.head.height() - head_h) <= 1, f"head={sr.head.height()}")
check("P9.10 收起态:栏头箭头变 ▸", sr.chev.text() == "")
check("P9.11 收起态:中间拖动分隔条被禁用",
panel.splitter.handle(1).isEnabled() is False)
# ---- 再展开 ----
sr.head.clicked.emit()
settle(120)
mid2 = sr.height()
settle(400)
check("P9.12 再展开 → 恢复原高度(±3)",
abs(sr.height() - h_run_before) <= 3, f"{sr.height()} vs {h_run_before}")
check("P9.13 展开也是渐变", head_h <= mid2 <= h_run_before, f"mid={mid2}")
check("P9.14 展开后内容可见 / 箭头回 ▾ / 分隔条恢复可拖",
sr.scroll.isVisible() and sr.chev.text() == "" and panel.splitter.handle(1).isEnabled())
check("P9.15 折叠不影响层数据与实时缓冲",
len(panel.layer_ids("running")) == 1 and len(panel.layer_ids("done")) == 2
and panel._layers["p9_run"].ok is None)
# ---- 「已完成」栏单独收起同样生效 ----
sd.head.clicked.emit()
settle(500)
check("P9.16 收起「已完成」→ 该栏只剩头部高",
abs(sd.height() - sd.head_height()) <= 2, f"h={sd.height()} head={sd.head_height()}")
# 🆕 新口径:收起「已完成」不再让「运行中」吃掉空间,而是保持它的高度、
# 余量进底部留白(栏头紧跟上方内容,不被顶到底部)
check("P9.17 收起「已完成」→ 「运行中」高度保持不变(不再吃空间)",
abs(sr.height() - h_run_before) <= 3, f"{sr.height()} vs {h_run_before}")
sd.head.clicked.emit()
settle(500)
check("P9.18 展开恢复", abs(sd.height() - h_done_before) <= 3, f"{sd.height()} vs {h_done_before}")
# ---- 两栏都收起 → 两个头部相邻在顶部 ----
sr.head.clicked.emit(); settle(400)
sd.head.clicked.emit(); settle(400)
sizes2 = panel.splitter.sizes()
check("P9.19 两栏都收起 → sizes = [头部高, 头部高, 余量]",
abs(sizes2[0] - head_h) <= 2 and abs(sizes2[1] - head_h) <= 2
and abs(sum(sizes2) - (panel.splitter.height() - 2 * panel.splitter.handleWidth())) <= 3,
str(sizes2))
check("P9.20 两栏都收起时两个栏头相邻(间隔 = 分隔条宽)",
abs(sd.mapTo(panel, QtCore.QPoint(0, 0)).y()
- (sr.mapTo(panel, QtCore.QPoint(0, 0)).y() + sr.height())
- panel.splitter.handleWidth()) <= 2,
f"sec2_y={sd.mapTo(panel, QtCore.QPoint(0, 0)).y()} sec1_y={sr.mapTo(panel, QtCore.QPoint(0, 0)).y()}")
# ---- 连点 3 次 → 收敛到终态 ----
for _s in (sr, sd): # 先确保两栏都展开
if _s.folded:
_s.head.clicked.emit()
settle(500)
h_unfolded = sr.height()
sr.head.clicked.emit(); sr.head.clicked.emit(); sr.head.clicked.emit()
settle(700)
check("P9.21 未收起时连点 3 次 → 终态为收起,且只剩头部高",
sr.folded is True and abs(sr.height() - head_h) <= 2,
f"folded={sr.folded} h={sr.height()}")
sr.head.clicked.emit()
settle(700)
check("P9.22 连点后仍能正常展开并恢复原高度",
sr.folded is False and abs(sr.height() - h_unfolded) <= 3,
f"h={sr.height()} vs {h_unfolded}")
# ---- 面板尺寸变化后仍钉在头部高 ----
sr.head.clicked.emit()
settle(500)
sd_h_before = sd.head_height()
sizes_now = panel.splitter.sizes()
panel.splitter.setSizes([sizes_now[1], sizes_now[0]]) # 模拟被外部拉大/拉小
settle(120)
panel._repin_fold() # resizeEvent 里的重钉
settle(120)
check("P9.23 尺寸变化后收起栏仍只占头部高(_repin_fold 生效)",
abs(sr.height() - head_h) <= 2, f"h={sr.height()} head={head_h}")
sr.head.clicked.emit()
settle(500)
check("P9.24 收尾:两栏均展开,头部行高不变",
(not sr.folded) and (not sd.folded)
and sr.head_height() == head_h and sd.head_height() == sd_h_before,
f"{sr.head_height()}/{sd.head_height()} vs {head_h}/{sd_h_before}")
# ======================================================================
# 10) 🆕 两个实测 bug 的回归测试
# bug1: 收起后 head 被拉伸成巨条(实测 793px)
# bug2: 收起「已完成」→ 栏头被顶到面板最底部
# ======================================================================
def sec_y(s):
return s.mapTo(panel, QtCore.QPoint(0, 0)).y()
# ---- bug1: 头部行高【硬固定】(任意组合下都不被拉伸)----
combos = [(False, False), (True, False), (False, True), (True, True)]
bad = []
for want_r, want_d in combos:
for s, want in ((sr, want_r), (sd, want_d)):
if s.folded != want:
s.head.clicked.emit()
settle(450)
if sr.head.height() != sr.head_height() or sd.head.height() != sd.head_height():
bad.append((want_r, want_d, sr.head.height(), sd.head.height()))
check("P9.25 四种折叠组合下 head.height() 恒等于 head_height()(不再被拉伸)",
not bad, str(bad))
check("P9.26 两栏都收起时「已完成」head 高 == 24(修复前实测 793",
sr.folded and sd.folded and sd.head.height() == sd.head_height() == head_h,
f"folded=({sr.folded},{sd.folded}) head={sd.head.height()}")
_sp = panel.splitter.sizes()
check("P9.27 两栏都收起 → 余量进底部留白(spacer)",
_sp[2] > 100 and abs(sum(_sp) - (panel.splitter.height() - 2 * panel.splitter.handleWidth())) <= 3,
str(_sp))
check("P9.28 spacer 上方的分隔条不可拖且透明",
panel.splitter.handle(2).isEnabled() is False
and "transparent" in (panel.splitter.handle(2).styleSheet() or ""),
panel.splitter.handle(2).styleSheet())
# ---- bug2: 收起「已完成」→ 栏头紧跟「运行中」内容,而不是落到面板底部 ----
for s in (sr, sd):
if s.folded:
s.head.clicked.emit()
settle(450)
h_run_keep = sr.height()
sd.head.clicked.emit()
settle(500)
sp_y = sd.mapTo(panel, QtCore.QPoint(0, 0)).y()
check("P9.29 收起「已完成」→ 栏头紧跟在「运行中」下方(不是面板底部)",
abs(sp_y - (sec_y(sr) + sr.height() + panel.splitter.handleWidth())) <= 2,
f"done_y={sp_y} run_bottom={sec_y(sr) + sr.height()}")
check("P9.30 收起「已完成」→ 栏头远离面板底部(留白在它下面)",
sp_y < panel.height() - 100, f"done_y={sp_y} panel_h={panel.height()}")
check("P9.31 收起「已完成」→ 「运行中」高度保持记录值",
abs(sr.height() - h_run_keep) <= 3, f"{sr.height()} vs {h_run_keep}")
check("P9.32 收起「已完成」→ spacer 吸收余量",
panel.splitter.sizes()[2] > 100, str(panel.splitter.sizes()))
# ---- 展开恢复 ----
sd.head.clicked.emit()
settle(550)
check("P9.33 展开「已完成」→ 恢复记录高度",
abs(sd.height() - h_done_before) <= 4, f"{sd.height()} vs {h_done_before}")
check("P9.34 展开后 spacer 归零(已完成重新填满到底部)",
panel.splitter.sizes()[2] <= 2, str(panel.splitter.sizes()))
# ---- 不变量:任何折叠态下 sizes 之和 == 可用高度 ----
ok_sum = True
for want_r, want_d in combos:
for s, want in ((sr, want_r), (sd, want_d)):
if s.folded != want:
s.head.clicked.emit()
settle(450)
avail = panel.splitter.height() - 2 * panel.splitter.handleWidth()
if abs(sum(panel.splitter.sizes()) - avail) > 3:
ok_sum = False
check("P9.35 不变量:任意折叠态下 sizes 之和 == splitter 可用高度", ok_sum)
for s in (sr, sd):
if s.folded:
s.head.clicked.emit()
settle(450)
check("P9.36 收尾:两栏均展开", (not sr.folded) and (not sd.folded))
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
if os.path.exists(_DB_TMP):
os.remove(_DB_TMP)
if os.path.exists(_CFG_TMP):
os.remove(_CFG_TMP)
sys.exit(0 if ok else 1)
+156
View File
@@ -0,0 +1,156 @@
"""会话复制 · UI 入口端到端(offscreen
链路:弹窗「📋 复制」按钮 → action_triggered("copy") → on_session_action →
DBManager.copy_session → 侧边栏重建 + 自动切到副本
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_copy_session.py
"""
import os
import sys
import tempfile
import shutil
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" # 绕过 AMD 核显 context lost
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# 铁律:测试不得污染真实 data/chat_history.db → 默认 DB 路径重定向到临时文件
import core.db_manager as _dbm # noqa: E402
_DB_TMP = os.path.join(tempfile.gettempdir(), f"haocode_test_copy_ui_{os.getpid()}.db")
if os.path.exists(_DB_TMP):
os.remove(_DB_TMP)
_dbm._DEFAULT_DB = _DB_TMP
_FS_TMP = tempfile.mkdtemp(prefix="hocode_copy_ui_files_") # 临时附件根
from PyQt6.QtWidgets import QApplication, QPushButton # noqa: E402
from PyQt6 import QtCore # noqa: E402
from ui.views.main_window import MainWindow, SessionContextPopup # noqa: E402
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 chain_sig(sid):
return [(m["role"], m["content"]) for m in window.db.get_message_chain(sid)]
window = MainWindow()
window.db.files_root = _FS_TMP
# ======================================================================
# 1) 准备一个带内容的源会话(直接走 DB 层,不触发真实 API 请求)
# ======================================================================
window.on_new_chat_clicked()
src = window.current_session_id
for role, text in [("user", "帮我看看这个文件"), ("assistant", "好的,我先读一下")]:
window.db.add_message(src, role, text, window.db.get_session_leaf(src))
window.db.mark_session_has_messages(src)
window.rebuild_sidebar()
src_title = [s for s in window.db.get_all_sessions() if s["id"] == src][0]["title"]
check("源会话已就绪(2 条消息)", len(window.db.get_message_chain(src)) == 3,
str(len(window.db.get_message_chain(src))))
# 图片附件(验证 UI 链路里附件也被深拷贝)
os.makedirs(os.path.join(_FS_TMP, "data", "attachments"), exist_ok=True)
png = os.path.join(_FS_TMP, "data", "attachments", "ui_src.png")
with open(png, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\nUI-IMAGE-BYTES")
meta = ('{"user_text":"看图","attachments":[{"type":"image","size_kb":0.1,'
'"local_path":"data/attachments/ui_src.png"}]}')
window.db.add_message(src, "user", "以及这张图", window.db.get_session_leaf(src),
attachment_metadata=meta)
# ======================================================================
# 2) 弹窗里存在「复制」按钮,且点击后发出 ("copy", sid)
# ======================================================================
popup = SessionContextPopup(src, False, window)
btns = popup.findChildren(QPushButton)
texts = [b.text() for b in btns]
check("弹窗含「📋 复制」按钮", any("复制" in t for t in texts), str(texts))
check("按钮顺序 编辑/星标/复制/删除",
len(texts) == 4 and "编辑" in texts[0] and "星标" in texts[1]
and "复制" in texts[2] and "删除" in texts[3], str(texts))
got = []
popup.action_triggered.connect(lambda a, s: got.append((a, s)))
copy_btn = [b for b in btns if "复制" in b.text()][0]
copy_btn.click()
check("点击复制按钮发出 copy 动作", got == [("copy", src)], str(got))
# ======================================================================
# 3) on_session_action("copy") → 真实克隆 + 侧边栏刷新 + 切到副本
# ======================================================================
n_before = len(window.db.get_all_sessions())
window.on_session_action("copy", src)
sessions = window.db.get_all_sessions()
check("会话数 +1", len(sessions) == n_before + 1, f"{n_before}{len(sessions)}")
rows = [s for s in sessions if s["title"] == src_title + " (副本)"]
check("副本标题 = 源标题 + ' (副本)'", len(rows) == 1, str([s["title"] for s in sessions]))
copy_id = rows[0]["id"] if rows else None
if copy_id:
check("副本不是源(ID 不同)", copy_id != src)
check("消息链完全一致", chain_sig(src) == chain_sig(copy_id))
check("已自动切到副本", window.current_session_id == copy_id, str(window.current_session_id))
check("侧边栏含副本项",
any(window.history_list.item(i).data(QtCore.Qt.ItemDataRole.UserRole) == copy_id
for i in range(window.history_list.count())))
check("副本排在列表顶部(sort_order 最小)",
sessions[0]["id"] == copy_id, sessions[0]["title"])
check("副本不带星标", rows[0]["is_starred"] == 0)
check("副本 mode 跟随源(未锁定为 None)",
rows[0]["mode"] == window.db.get_session_mode(src))
# 附件深拷贝:新旧文件同时存在,且副本 metadata 指向新文件
import json as _json
c_rows = [m for m in window.db.get_message_chain(copy_id) if m.get("attachment_metadata")]
c_meta = _json.loads(c_rows[-1]["attachment_metadata"])
new_rel = c_meta["attachments"][0]["local_path"]
check("副本附件已改名", new_rel != "data/attachments/ui_src.png", new_rel)
check("新旧图片文件同时存在",
os.path.isfile(png) and os.path.isfile(os.path.join(_FS_TMP, new_rel)))
check("副本图片内容一致",
open(os.path.join(_FS_TMP, new_rel), "rb").read()
== open(png, "rb").read())
# 删副本后源完好(UI 链路下的删除隔离)
window.db.delete_session(copy_id)
check("删副本后源消息链不变", chain_sig(src) == [
("system", "你是一个优秀的助手!"), ("user", "帮我看看这个文件"),
("assistant", "好的,我先读一下"), ("user", "以及这张图")])
check("删副本后源图片仍在", os.path.isfile(png))
# ======================================================================
# 4) 生成中拒绝复制
# ======================================================================
n_before2 = len(window.db.get_all_sessions())
window._active_streams[src] = {"worker": None}
window.on_session_action("copy", src)
check("会话生成中 → 拒绝复制(无新会话)",
len(window.db.get_all_sessions()) == n_before2)
window._active_streams.pop(src, None)
# 5) 源不存在 → 静默失败,不崩
try:
window.on_session_action("copy", "sess_not_exist_zzz")
check("源不存在时不抛异常", True)
except Exception as e:
check("源不存在时不抛异常", False, str(e))
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====", flush=True)
shutil.rmtree(_FS_TMP, ignore_errors=True)
if os.path.exists(_DB_TMP):
os.remove(_DB_TMP)
sys.exit(0 if ok else 1)
+280
View File
@@ -0,0 +1,280 @@
# -*- coding: utf-8 -*-
"""四项 UI 增强回归测试(真实 WebEngine 页面):
1. rAF 卡死模拟(AMD 核显/合成器不出帧)→ 40ms 兜底仍实时渲染流式正文
2. 深度思考默认收起 + .streaming-think 蓝色动画类;收尾后标签还原
3. bash chip 耗时徽章([exit 0] (1.2s)+ 超时徽章(命令超时(>120s))
4. 长结果:默认尾部 4000 字 + 展开按钮;点击展开全文/再点收起;
收起状态下 finish 后结果仍可见(修复「结束后展开为空」)
运行: python tests/smoke_live_guard.py
"""
import json
import os
import sys
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" # 绕过 AMD 核显 context lost
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
window = None
results = []
def check(name, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {name}"
+ (f" [{detail}]" if detail and not ok else ""))
results.append(ok)
# Phase 0: rAF + 所有页面定时器全灭(极端节流)→
# 内容必须上屏:同步通道(token 内)+ Qt 看门狗(200msforceRenderNow
JS_PHASE_0A = r"""
(function() {
var out = {};
window.__realRAF = window.requestAnimationFrame;
window.__realCAF = window.cancelAnimationFrame;
window.__realTO = window.setTimeout;
window.__realCTO = window.clearTimeout;
window.__realSI = window.setInterval;
window.__realCSI = window.clearInterval;
window.__fakeRafId = 0;
window.__fakeTimerId = 1000;
window.requestAnimationFrame = function() { return ++window.__fakeRafId; };
window.cancelAnimationFrame = function(){};
window.setTimeout = function() { return ++window.__fakeTimerId; };
window.clearTimeout = function(){};
window.setInterval = function() { return ++window.__fakeTimerId; };
window.clearInterval = function(){};
var m0 = 'lg-sync-' + Date.now();
window.__lgM0 = m0;
createMessage(m0, 'assistant', '', 'LG');
appendReasoning(m0, 'sync think ');
appendToken(m0, 'sync text one ');
appendToken(m0, 'two');
var segs = document.querySelectorAll('#' + m0 + ' .md-segment');
var t = '';
for (var i = 0; i < segs.length; i++) t += segs[i].textContent || '';
out.textAtBurst = t;
return JSON.stringify(out);
})()
"""
JS_PHASE_0B = r"""
(function() {
var out = {};
var m0 = window.__lgM0;
var segs = document.querySelectorAll('#' + m0 + ' .md-segment');
var t = '';
for (var i = 0; i < segs.length; i++) t += segs[i].textContent || '';
var tcs = document.querySelectorAll('#' + m0 + ' .think-content');
var th = '';
for (var i = 0; i < tcs.length; i++) th += tcs[i].textContent || '';
out.text = t;
out.think = th;
// 恢复页面定时器
window.requestAnimationFrame = window.__realRAF;
window.cancelAnimationFrame = window.__realCAF;
window.setTimeout = window.__realTO;
window.clearTimeout = window.__realCTO;
window.setInterval = window.__realSI;
window.clearInterval = window.__realCSI;
finishMessage(m0);
return JSON.stringify(out);
})()
"""
# Phase A: 模拟 rAF 永不触发 + 流式 token;断言此刻 DOM 尚空(卡死生效)
JS_PHASE_A = r"""
(function() {
var out = {};
window.__realRAF = window.requestAnimationFrame;
window.__realCAF = window.cancelAnimationFrame;
// 模拟真实卡死:rAF 返回真实挂起 ID(truthy)但回调永不被调用
window.__fakeRafId = 0;
window.requestAnimationFrame = function(cb) { window.__rafCalls = (window.__rafCalls||0)+1; return ++window.__fakeRafId; };
window.cancelAnimationFrame = function(){};
var mid = 'lg-' + Date.now();
window.__lgMid = mid;
createMessage(mid, 'assistant', '', 'LG');
appendReasoning(mid, 'deep ');
appendReasoning(mid, 'thinking...');
appendToken(mid, 'live text one ');
appendToken(mid, 'two ');
appendToken(mid, 'three');
var segs = document.querySelectorAll('#' + mid + ' .md-segment');
var domLen = 0;
for (var i = 0; i < segs.length; i++)
domLen += (segs[i].textContent || '').length;
out.rafCalls = window.__rafCalls || 0;
out.domLenBeforeTimers = domLen;
// 思考块状态(默认收起 + 蓝色动画类)
var block = document.querySelector('#' + mid + ' .think-block');
out.thinkOpen = block ? block.open : null;
out.thinkStreaming = block ? block.classList.contains('streaming-think') : false;
out.thinkLabel = block ? (block.querySelector('.think-label')||{}).textContent : null;
return JSON.stringify(out);
})()
"""
# Phase Btimers 跑过之后): 兜底应已渲染正文 + 收尾 + 其余特性
JS_PHASE_B = r"""
(function() {
var out = {};
var mid = window.__lgMid;
var segs = document.querySelectorAll('#' + mid + ' .md-segment');
var domText = '';
for (var i = 0; i < segs.length; i++) domText += segs[i].textContent || '';
out.liveTextRendered = domText.indexOf('live text one two three') !== -1;
// 收尾:思考块标签还原 + 动画类移除
finishMessage(mid);
var block = document.querySelector('#' + mid + ' .think-block');
out.thinkLabelAfter = block ? (block.querySelector('.think-label')||{}).textContent : null;
out.thinkStreamingAfter = block ? block.classList.contains('streaming-think') : false;
// 恢复 rAF
window.requestAnimationFrame = window.__realRAF;
window.cancelAnimationFrame = window.__realCAF;
// ---- 耗时 / 超时徽章 ----
var mid2 = 'lg-timing-' + Date.now();
createMessage(mid2, 'assistant', '', 'LG');
toolExecutionStarted(mid2, 'call-ok', 'bash', JSON.stringify({command:'echo hi'}));
toolExecutionFinished(mid2, 'call-ok', 'bash', true, '$ echo hi\nhi\n[exit 0] (1.2s)');
toolExecutionStarted(mid2, 'call-tmo', 'bash', JSON.stringify({command:'sleep 999'}));
toolExecutionFinished(mid2, 'call-tmo', 'bash', false, '命令超时(>120s)已终止');
var w2 = document.getElementById(mid2);
var chipOk = w2.querySelector('[data-call-id="call-ok"]');
var chipTmo = w2.querySelector('[data-call-id="call-tmo"]');
out.durBadge = chipOk ? (chipOk.querySelector('.tool-chip-time')||{}).textContent : null;
out.tmoBadge = chipTmo ? (chipTmo.querySelector('.tool-chip-timeout')||{}).textContent : null;
// ---- 收起状态下 finish:结果必须已写入(展开可见) ----
var livePre = chipOk ? chipOk.querySelector('.tool-chip-live') : null;
out.resultWhileCollapsed = livePre ? (livePre.textContent || '').indexOf('hi') !== -1 : false;
// ---- 长结果:尾部预览 + 展开/收起 ----
var longRes = 'L'.repeat(1000) + ' MIDDLE-MARKER ' + 'R'.repeat(5000); // 6015 字,标记在头部(预览窗外)
toolExecutionStarted(mid2, 'call-long', 'read', JSON.stringify({path:'/big.txt'}));
toolExecutionFinished(mid2, 'call-long', 'read', true, longRes);
var chipLong = w2.querySelector('[data-call-id="call-long"]');
var pre = chipLong ? chipLong.querySelector('.tool-chip-live') : null;
var btn = chipLong ? chipLong.querySelector('.tool-expand-btn') : null;
out.longHasBtn = !!btn;
out.longPreviewLen = pre ? (pre.textContent || '').length : 0;
out.longPreviewIsTail = pre ? (pre.textContent || '').lastIndexOf('R') > (pre.textContent||'').lastIndexOf('MIDDLE-MARKER') : false;
out.longPreviewHasMarker = pre ? (pre.textContent || '').indexOf('MIDDLE-MARKER') !== -1 : true;
if (btn) {
btn.click();
out.expandedHasMarker = pre ? (pre.textContent || '').indexOf('MIDDLE-MARKER') !== -1 : false;
out.expandedLen = pre ? (pre.textContent || '').length : 0;
out.fullClass = pre ? pre.classList.contains('tool-chip-full') : false;
btn.click();
out.collapseBackLen = pre ? (pre.textContent || '').length : 0;
}
return JSON.stringify(out);
})()
"""
def step1():
global window
print("[step1] creating MainWindow...", flush=True)
window = MainWindow()
window.show()
print("[step1] shown, scheduling step2", flush=True)
QTimer.singleShot(3000, step2)
def step2():
print("[step2] phase 0: kill all page timers + burst tokens", flush=True)
# 注册为当前会话的活跃流 → Qt 看门狗(200ms)会对它 forceRenderNow
window.current_session_id = "fake-sync-sid"
window._active_streams["fake-sync-sid"] = {
"msg_id": None, "content": "pending", "reasoning": "",
"timeline": [], "tl_kind": None, "parent_id": None,
"branch_info": None, "worker": None, "previous_leaf_id": None,
}
window.browser.page().runJavaScript(JS_PHASE_0A, on_p0a)
def on_p0a(res):
a = json.loads(str(res))
print(f" phase0 burst 瞬间: text={a['textAtBurst']!r}")
# 把 JS 里生成的 mid 告诉看门狗(msg_id 需一致才会 forceRenderNow
# —— 简化:直接改 JS 端记录的 mid 对应的 buffer 即可,这里用 runJavaScript 对齐
window.browser.page().runJavaScript(
"window.__lgM0", lambda r: _align_and_wait(str(r)))
def _align_and_wait(mid):
mid = mid.strip().strip('"')
window._active_streams["fake-sync-sid"]["msg_id"] = mid
print("[phase0] 等待 Qt 看门狗渲染 (400ms) ...", flush=True)
QTimer.singleShot(400, on_p0b)
def on_p0b():
window.browser.page().runJavaScript(JS_PHASE_0B, on_p0)
def on_p0(res):
p = json.loads(str(res))
print(f" phase0 400ms 后: text={p['text']!r} think={p['think']!r}")
check("定时器全灭: burst 内思考同步上屏", "sync think" in p["think"], str(p))
check("定时器全灭: 正文经同步通道/看门狗上屏",
"sync text one two" in p["text"], str(p))
window._active_streams.pop("fake-sync-sid", None)
window.browser.page().runJavaScript(JS_PHASE_A, on_a)
def on_a(res):
print("[on_a] got result:", str(res)[:120], flush=True)
a = json.loads(str(res))
print(f" phaseA: rafCalls={a['rafCalls']} domLenBefore={a['domLenBeforeTimers']}")
# v7 起:首 token 插入前已同步渲染(:empty 布局失效修复的副作用——首字必现),
# 故此刻 DOM 可能已有首 token 内容;本断言只验证 rAF 被调用但从未触发。
check("rAF 被卡死(0 帧触发)", a["rafCalls"] > 0, str(a))
check("思考块默认收起", a["thinkOpen"] is False)
check("思考块带蓝色动画类 streaming-think", a["thinkStreaming"])
QTimer.singleShot(300, step3)
def step3():
window.browser.page().runJavaScript(JS_PHASE_B, on_b)
def on_b(res):
b = json.loads(str(res))
check("rAF 卡死时兜底仍实时渲染正文", b["liveTextRendered"], str(b))
check("收尾后思考标签还原为『已完成深度思考』",
b["thinkLabelAfter"] == "已完成深度思考", str(b["thinkLabelAfter"]))
check("收尾后移除 streaming-think 动画类", b["thinkStreamingAfter"] is False)
check("耗时徽章 ⏱ 1.2s", b["durBadge"] == "⏱ 1.2s", str(b["durBadge"]))
check("超时徽章 ⏱ 超时 120s", b["tmoBadge"] == "⏱ 超时 120s", str(b["tmoBadge"]))
check("收起状态下 finish 后结果仍可见(修复展开为空)", b["resultWhileCollapsed"])
check("长结果有展开按钮", b["longHasBtn"])
check("长结果默认只显示尾部 4002 字", b["longPreviewLen"] == 4002,
str(b["longPreviewLen"]))
check("尾部预览不含中段标记(标记在头部)", b["longPreviewIsTail"]
and b["longPreviewHasMarker"] is False)
check("点击展开全文(含中段标记 + full 类)",
b["expandedHasMarker"] and b["fullClass"]
and b["expandedLen"] > 5000, str(b))
check("再点收回到尾部预览", b["collapseBackLen"] == 4002,
str(b["collapseBackLen"]))
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
f"{sum(results)}/{len(results)} =====")
app.quit()
QTimer.singleShot(500, step1)
app.exec()
+42
View File
@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""验证人肉 debug 埋点链路:
Python [正文]/[思考] 打印 + JS console 桥 → [JS] 打印 全部出现在控制台
"""
import os, sys
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PyQt6.QtWidgets import QApplication
import PyQt6.QtWebEngineWidgets # noqa
from PyQt6.QtCore import QTimer
from ui.views.main_window import MainWindow
app = QApplication(sys.argv)
window = MainWindow()
window.resize(1280, 800)
window.show()
MID = "manual-debug-test"
def step1():
# 模拟一次真实的 send→token→finish 生命周期
window.chat_bridge.create_message(MID, "assistant", "", "Test")
window.chat_bridge.append_reasoning(MID, "这是思考内容A")
window.chat_bridge.append_reasoning(MID, "思考B")
window.chat_bridge.append_token(MID, "这是正文第一段。")
window.chat_bridge.append_token(MID, "正文第二段来了。")
window.chat_bridge.tool_execution_started(MID, "call-xyz", "bash", "echo hi")
window.chat_bridge.tool_execution_finished(MID, "call-xyz", "bash", True, "hi\n[exit 0] (0.1s)")
QTimer.singleShot(800, step2)
def step2():
window.chat_bridge.finish_message(MID)
QTimer.singleShot(1200, done)
def done():
app.quit()
QTimer.singleShot(2000, step1)
app.exec()
print("===== 测试结束(上方应看到 [生命周期]/[JS] 打印)=====")
+199
View File
@@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
"""中途切会话回归:agent 流式进行中切走 → 切回 → 时间线必须完整
(用户报告:切走再切回,正文/思考那一段显示不出来)
运行: python tests/smoke_midswitch.py
"""
import json
import os
import sys
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" # 绕过 AMD 核显 context lost
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
window = None
results = []
S = {} # 测试状态
def check(name, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {name}"
+ (f" [{detail}]" if detail and not ok else ""))
results.append(ok)
CHECK_JS = r"""
(function() {
var mid = window.__msMid;
var w = document.getElementById(mid);
if (!w) return JSON.stringify({error: 'wrapper missing'});
var tl = w.querySelector('.reply-content');
var blocks = Array.prototype.map.call(tl.children, function(el) {
return el.className.split(' ')[0];
});
var texts = Array.prototype.map.call(
w.querySelectorAll('.md-segment'),
function(s) { return s.textContent || ''; });
var thinks = Array.prototype.map.call(
w.querySelectorAll('.think-content'),
function(s) { return s.textContent || ''; });
return JSON.stringify({blocks: blocks, texts: texts, thinks: thinks,
streaming: w.classList.contains('streaming')});
})()
"""
def fire(session, fn, *args):
"""模拟 worker 信号到达(UI 线程直接调 handler)"""
getattr(window, fn)(*([session] + list(args)))
def step1():
global window
window = MainWindow()
window.show()
QTimer.singleShot(3000, step2)
def step2():
db = window.db
sessA = db.create_session("切走测试A")
sessB = db.create_session("切走测试B")
A, B = sessA["id"], sessB["id"]
db.add_message(B, "user", "B 的问题", None)
# ---- 会话 A 开始流式(手动模拟事件到达)----
user_row = db.add_message(A, "user", "A 的问题", None)
mid = "msg-midswitch"
window.current_session_id = A
window.chat_bridge.create_message(mid, "assistant", "", "SW")
window._active_streams[A] = {
"msg_id": mid, "content": "", "reasoning": "",
"timeline": [], "tl_kind": None,
"parent_id": user_row["id"], "branch_info": None, "worker": None,
"previous_leaf_id": user_row["id"],
}
S.update(A=A, B=B, mid=mid)
window.browser.page().runJavaScript(f"window.__msMid = '{mid}';")
# 阶段 1A 前台):think1 + text1 + 工具
fire(A, "on_reasoning_received", "思考第一")
fire(A, "on_reasoning_received", "段内容")
fire(A, "on_chunk_received", "工具前正文")
fire(A, "_on_tool_started", "call-ms", "bash", '{"command":"echo ms"}')
QTimer.singleShot(800, switch_away)
def switch_away():
"""切到 BA 的流继续在后台跑"""
print("[test] 切换到 B ...", flush=True)
window.load_messages_to_web(S["B"])
# 阶段 2A 后台):think2 + text2(只应累积,不进 JS)
QTimer.singleShot(600, lambda: (
fire(S["A"], "on_reasoning_received", "思考第二"),
fire(S["A"], "on_chunk_received", "工具后正文"),
print("[test] 后台 token 已累积", flush=True),
QTimer.singleShot(600, switch_back)
))
def switch_back():
"""切回 A:应重建 wrapper + 恢复时间线 + 后续无缝续流"""
print("[test] 切回 A ...", flush=True)
window.load_messages_to_web(S["A"])
# ★ 切回后立即检查:恢复的正文/思考必须已同步上屏(不等 rAF/timer)
def check_right_away():
window.browser.page().runJavaScript(CHECK_JS, lambda res: (
print("[切回后立即]", str(res), flush=True),
check("切回后立即: 后台正文已同步上屏",
"工具后正文" in json.loads(str(res)).get("texts", [""] + [json.loads(str(res))["texts"][0] if json.loads(str(res)).get("texts") else ""])[0] or "工具后正文" in "".join(json.loads(str(res)).get("texts", []))),
QTimer.singleShot(300, phase3)
))
def phase3():
fire(S["A"], "on_chunk_received", " 续流正文")
QTimer.singleShot(600, finish_it)
QTimer.singleShot(400, check_right_away)
def finish_it():
window.on_reply_finished(S["A"])
QTimer.singleShot(400, step_check)
DIAG_JS = r"""
(function() {
var wrappers = Array.prototype.map.call(
document.querySelectorAll('.message-wrapper'),
function(w) { return w.id + ':' + w.className.split(' ')[1]; });
return JSON.stringify({wrappers: wrappers,
bufKeys: Object.keys(window.messageBuffer || {})});
})()
"""
def step_check():
def diag(res):
print("[diag]", str(res), flush=True)
window.browser.page().runJavaScript(CHECK_JS, on_check)
window.browser.page().runJavaScript(DIAG_JS, diag)
def on_check(res):
d = json.loads(str(res))
if "error" in d:
check("切回后 wrapper 存在", False, d["error"])
done()
return
blocks = d["blocks"]
texts = "".join(d["texts"])
thinks = "".join(d["thinks"])
print(f" blocks = {blocks}")
print(f" texts = {d['texts']}")
print(f" thinks = {d['thinks']}")
check("切回后 wrapper 存在", True)
check("块顺序与事件顺序一致: think→text→chip→think→text",
[b for b in blocks if b in
("think-block", "tool-chip", "md-segment")] ==
["think-block", "md-segment", "tool-chip", "think-block", "md-segment"],
str(blocks))
check("正文完整(切走前+后台+切回后)",
"工具前正文" in texts and "工具后正文" in texts
and "续流正文" in texts, str(d["texts"]))
check("思考完整(切走前+后台)",
"思考第一段内容" in thinks and "思考第二" in thinks,
str(d["thinks"]))
check("streaming 已收尾", not d["streaming"])
# 入库验证
row = window.db.get_message_chain(S["A"])
asst = [m for m in row if m["role"] == "assistant"]
ok_db = False
if asst:
tl = asst[-1].get("timeline")
if tl:
tl_list = json.loads(tl)
joined = "".join(e.get("text", "") for e in tl_list if e["t"] == "text")
ok_db = ("工具前正文" in joined and "工具后正文" in joined
and "续流正文" in joined)
check("DB 时间线完整(三段正文都在)", ok_db)
done()
def done():
try:
window.db.delete_session(S["A"])
window.db.delete_session(S["B"])
except Exception:
pass
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
f"{sum(results)}/{len(results)} =====")
app.quit()
QTimer.singleShot(500, step1)
app.exec()
+94
View File
@@ -0,0 +1,94 @@
"""offscreen 模式切换功能测试:DB 持久化 + 首条消息锁定 + worker 分派
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_mode.py
"""
import os
import sys
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" # 绕过 AMD 核显 context lost
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# 铁律:测试不得污染真实 data/chat_history.db → DBManager 默认路径重定向到临时文件
import tempfile as _tf # noqa: E402
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = os.path.join(_tf.gettempdir(), f"haocode_test_smoke_mode_{os.getpid()}.db")
from PyQt6.QtWidgets import QApplication # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402 (QtWebEngine 须先于 QApplication import 完成)
app = QApplication(sys.argv)
ok = True
def check(name, cond):
global ok
print((" PASS " if cond else " FAIL ") + name)
if not cond:
ok = False
window = MainWindow()
# 本套件验证「默认锁定」语义:显式置 mode_switch=false,不受本机 data/config.json 影响
# (仅内存,不会写回配置文件)
window.config_data["mode_switch"] = False
window._refresh_mode_button()
# 1) 新建会话 → 未锁定
window.on_new_chat_clicked()
sid = window.current_session_id
check("新会话未锁定 (mode=None)", window._get_current_mode() is None)
check("按钮显示待选 Chat", "Chat" in window.btn_mode.text() and "🔒" not in window.btn_mode.text())
# 2) 浮动弹窗开/关 + 选择
window.show_mode_popup()
check("弹窗打开", window.mode_popup.isVisible())
window._select_mode("worker")
check("选 worker 后按钮更新", "Worker" in window.btn_mode.text())
check("选择后弹窗自动关闭", not window.mode_popup.isVisible())
# 3) 首条发送 → 锁定
window._lock_session_mode()
check("发送后锁定 worker", window.db.get_session_mode(sid) == "worker")
check("按钮显示锁定", "🔒" in window.btn_mode.text())
# 4) 锁定后不可改
window._select_mode("chat")
check("锁定后选择无效", window.db.get_session_mode(sid) == "worker")
# 5) worker 分派
w1 = window._create_stream_worker([], "worker")
w2 = window._create_stream_worker([], "chat")
from core.llm_engine import AgentWorker, ChatWorker # noqa: E402
check("worker 模式 → AgentWorker", isinstance(w1, AgentWorker))
check("chat 模式 → ChatWorker", isinstance(w2, ChatWorker))
check("ChatWorker 有基础信号", all(hasattr(w2, s) for s in
("chunk_received", "reasoning_received", "error_occurred")))
check("ChatWorker 无工具信号(chat 模式不暴露)",
not hasattr(w2, "tool_execution_started"))
# 6) 切回新会话 → 解锁
window.on_new_chat_clicked()
check("新会话恢复可选", window._get_current_mode() is None and "🔒" not in window.btn_mode.text())
# 7) 旧会话(无 mode 列值)→ 默认 chat 路径
window.load_messages_to_web(sid)
check("切回已锁定会话显示锁定", window._get_current_mode() == "worker" and "🔒" in window.btn_mode.text())
# 8) mode_switch=true → 中途切换放行(旧锁定会话解锁 + 选择立即落库)
window.config_data["mode_switch"] = True
window._refresh_mode_button()
check("开关开启后旧会话解锁显示", "🔒" not in window.btn_mode.text())
window._select_mode("chat")
check("开关开启后中途切换生效", window.db.get_session_mode(sid) == "chat")
window.config_data["mode_switch"] = False
try:
window.close()
except Exception:
pass
app.quit()
print("\n===== " + ("ALL PASS" if ok else "HAS FAILURES") + " =====")
sys.exit(0 if ok else 1)
+201
View File
@@ -0,0 +1,201 @@
"""offscreen 冒烟测试:主窗口实例化 + 核心链路(不启动真实 LLM)
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_offscreen.py
"""
import os
import sys
import json
import time
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" # 绕过 AMD 核显 context lost # 软渲染,离屏最稳
# 铁律:测试不得污染真实 data/chat_history.db → DBManager 默认路径重定向到临时文件
import tempfile as _tf # noqa: E402
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = os.path.join(_tf.gettempdir(), f"haocode_test_smoke_offscreen_{os.getpid()}.db")
import ctypes # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
# QtWebEngine 必须在 QApplication 创建前 import
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
results = []
def check(name, fn):
try:
fn()
print(f" PASS {name}")
results.append(True)
except Exception as e:
import traceback
traceback.print_exc()
print(f" FAIL {name}: {e}")
results.append(False)
window = None
page_ready = {"ok": False}
def on_page_load_progress(v):
pass
def on_js_console(level, msg, line, src):
pass
def try_load():
global window
try:
window = MainWindow()
page_ready["ok"] = True
except Exception:
import traceback
traceback.print_exc()
finish(False)
return
# MainWindow 构造完成时 HTML/JS 已就绪(loadFinished 可能早于连接)
QTimer.singleShot(6000, run_checks) # 等 JS 引擎 + 历史渲染完成
def run_checks():
# 1) 窗口已创建
check("MainWindow 实例化", lambda: (_ for _ in ()).throw(AssertionError("no window")) if window is None else None)
# 2) DB 链路
def db_chain():
assert window.db is not None
sessions = window.db.get_all_sessions()
assert isinstance(sessions, list)
check("DB 会话列表", db_chain)
# 3) build_api_contextP0 修复验证:不再有 reasoning 字段)
def ctx_build():
sid = window.current_session_id
if not sid:
return
msgs = window.build_api_context(sid)
assert isinstance(msgs, list)
for m in msgs:
assert "reasoning" not in m, f"reasoning 字段仍在 API payload 里: {m.get('role')}"
check("build_api_context 无 reasoning 字段", ctx_build)
# 4) CJK token 估算
def est():
n = window._estimate_token_count([
{"role": "user", "content": "你好,世界!这是一段中文测试。"},
{"role": "assistant", "content": "hello world " * 20},
])
assert isinstance(n, int) and n > 0
check("CJK token 估算", est)
# 5) AgentWorker 可构造 + 信号齐全
def worker():
from core.llm_engine import AgentWorker, TitleWorker
w = AgentWorker(window.current_provider, window.current_model,
[{"role": "user", "content": "x"}])
for sig in ("chunk_received", "reasoning_received", "error_occurred",
"tool_execution_started", "tool_execution_updated",
"tool_execution_finished", "context_compacted"):
assert hasattr(w, sig), sig
t = TitleWorker(window.current_provider, window.current_model,
[{"role": "user", "content": "x"}])
assert hasattr(t, "chunk_received")
check("AgentWorker/TitleWorker 构造", worker)
# 6) bridge 工具方法
def bridge():
b = window.chat_bridge
for m in ("tool_execution_started", "tool_execution_updated",
"tool_execution_finished", "show_note"):
assert hasattr(b, m), m
check("ChatBridge 工具事件方法", bridge)
# 7) 离屏渲染 JS 就绪
check("Web 页面加载完成", lambda: (_ for _ in ()).throw(AssertionError("page not ready")) if not page_ready["ok"] else None)
# 8) 🌟 KaTeX 公式渲染(真实页面上下文:资源加载 + [...] 供应商格式 + 行内 $)
def _run_js(js, timeout_s=10):
result = {"val": None, "done": False}
def on_ret(val):
if isinstance(val, str):
try:
val = json.loads(val)
except Exception:
pass
result["val"] = val
result["done"] = True
if hasattr(window.browser, "execute_js_async"): # WebView2 路径(cb 收 JSON 字符串)
window.browser.execute_js_async(js, on_ret)
else: # QtWebEngine 路径(cb 收 Python 对象)
window.browser.page().runJavaScript(js, on_ret)
t0 = time.time()
while not result["done"] and time.time() - t0 < timeout_s:
app.processEvents()
time.sleep(0.05)
assert result["done"], "JS 执行超时"
return result["val"]
def katex_render():
# 等页面 JS 就绪(WV2 冷启动可能慢;app.js 就绪时置 window.jsReady=true
for _ in range(30):
if _run_js("window.jsReady === true ? 1 : 0", timeout_s=3) == 1:
break
time.sleep(0.5)
else:
raise AssertionError("页面 JS 未就绪(jsReady")
ver = _run_js("typeof katex !== 'undefined' ? katex.version : null")
assert ver, "katex 全局缺失(资源加载失败?)"
js = ("(function(){var s = '结果:" + chr(92) + "n[" + chr(92) + "nP_4=" + chr(92)*2 + "operatorname{BRF}(M_4,M_5)" + chr(92) + "n]" + chr(92) + "n" + chr(92) + "n行内 $E=mc^2$ 结束。';"
"var html = safeHtml(marked.parse(s));return {"
"has: html.indexOf('katex') !== -1,"
"disp: (html.match(/katex-display/g) || []).length,"
'inline: (html.match(/class=\"katex\"/g) || []).length,'
"leaked: html.indexOf('@@K') !== -1,"
"vis: html.indexOf('katex-html') !== -1,"
"tex: html.indexOf('\\operatorname{BRF}') !== -1};})()")
r = _run_js(js)
assert isinstance(r, dict), f"意外返回: {r!r}"
assert r.get("has"), "未产生 KaTeX HTML"
assert r.get("disp", 0) >= 1, "块公式未渲染为 katex-display"
assert r.get("inline", 0) >= 1, "行内公式未渲染"
assert not r.get("leaked"), "占位符泄漏"
assert r.get("vis"), "缺少 katex-html 可视层(KaTeX 未真正渲染)"
assert r.get("tex"), "tex 未正确传入(转义错误)"
print(f" [info] KaTeX {ver} | display={r.get('disp')} inline={r.get('inline')}")
check("KaTeX 公式渲染([...] 供应商格式 + 行内 $)", katex_render)
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====")
try:
window.close()
except Exception:
pass
app.quit()
def finish(ok=None):
app.quit()
QTimer.singleShot(500, try_load)
QTimer.singleShot(90000, finish) # 总超时
app.exec()
sys.exit(0 if all(results) else 1)
+297
View File
@@ -0,0 +1,297 @@
# -*- coding: utf-8 -*-
"""时间线持久化冒烟测试(真实 DB + 真实 WebEngine 页面):
1. 流式事件 → stream_state 时间线累积(思考/文本/工具 按序)
2. 入库(timeline 列)→ 切会话重载 → DOM 时间线还原(工具气泡不丢)
3. build_api_context 从时间线重建完整 API 链(assistant+tool_calls+tool
4. 切回进行中的会话:restoreStreamingTimeline 续流
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_persist.py
"""
import json
import os
import sys
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" # 绕过 AMD 核显 context lost
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
results = []
window = None
test = {"sid": None, "mid": None, "done": False}
def check(name, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {name}"
+ (f" [{detail}]" if detail and not ok else ""))
results.append(ok)
def finish():
if test["done"]:
return
test["done"] = True
# 清理测试会话
try:
if test["sid"] and window:
window.db.delete_session(test["sid"])
except Exception:
pass
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
f"{sum(results)}/{len(results)} =====")
app.quit()
# JS: 校验重载后的时间线 DOM
JS_VERIFY_RELOAD = r"""
(function() {
var out = {};
try {
var mid = '__MID__';
var wrapper = document.getElementById(mid);
if (!wrapper) return JSON.stringify({error: 'wrapper missing'});
var tl = wrapper.querySelector('.reply-content');
out.blocks = Array.prototype.map.call(tl.children, function(el) {
return el.className.split(' ')[0];
});
var chip = wrapper.querySelector('.tool-chip');
var st = chip ? chip.querySelector('.tool-chip-status') : null;
out.chipStatus = st ? st.textContent : null;
out.chipOk = st ? st.classList.contains('ok') : false;
out.chipCallId = chip ? chip.getAttribute('data-call-id') : null;
var segs = wrapper.querySelectorAll('.md-segment');
out.lastSegText = segs.length ? segs[segs.length - 1].textContent : '';
out.thinkCount = wrapper.querySelectorAll('.think-block').length;
out.thinkChevron = !!wrapper.querySelector('.think-block .chev');
} catch (e) {
out.error = String(e);
}
return JSON.stringify(out);
})()
"""
# JS: 续流测试
JS_RESUME_A = r"""
(function() {
try {
var mid = '__MID__';
createMessage(mid, 'assistant', '', 'Resume');
restoreStreamingTimeline(mid, '__TLJSON__');
// 续流:接着最后一段文本写;思考新开一段
appendToken(mid, '(续流文本)');
appendReasoning(mid, '续思考内容');
return 'resumeA-done';
} catch (e) {
return 'resumeA-err:' + String(e);
}
})()
"""
JS_RESUME_B = r"""
(function() {
try {
var mid = '__MID__';
finishMessage(mid);
var wrapper = document.getElementById(mid);
var tl = wrapper.querySelector('.reply-content');
var out = {};
out.blocks = Array.prototype.map.call(tl.children, function(el) {
return el.className.split(' ')[0];
});
var segs = wrapper.querySelectorAll('.md-segment');
out.lastSegText = segs.length ? segs[segs.length - 1].textContent : '';
out.thinkCount = wrapper.querySelectorAll('.think-block').length;
out.chipCount = wrapper.querySelectorAll('.tool-chip').length;
wrapper.parentNode.removeChild(wrapper);
return JSON.stringify(out);
} catch (e) {
return 'resumeB-err:' + String(e);
}
})()
"""
def run_checks():
db = window.db
# ---------- 建测试会话 + 模拟流式事件累积 ----------
sess = db.create_session("持久化测试会话")
sid = sess["id"]
test["sid"] = sid
mid = "msg-persist-test"
test["mid"] = mid
user_row = db.add_message(sid, "user", "测试问题", None)
user_id = user_row["id"]
window.current_session_id = sid
window.chat_bridge.create_message(mid, "assistant", "", "PersistTest")
st = {
"msg_id": mid, "content": "", "reasoning": "",
"timeline": [], "tl_kind": None,
"parent_id": user_id, "branch_info": None, "worker": None,
"previous_leaf_id": user_id,
}
window._active_streams[sid] = st
# 事件序列:思考 → 文本 → 工具 → 思考 → 文本
window.on_reasoning_received(sid, "先看一下")
window.on_reasoning_received(sid, "目录结构。")
window.on_chunk_received(sid, "我来执行")
window._on_tool_started(sid, "call-p1", "bash", '{"command": "echo hi"}')
window._on_tool_updated(sid, "call-p1", "hi\n")
window._on_tool_finished(sid, "call-p1", "bash", True,
"$ echo hi\nhi\n[exit 0]")
window.on_reasoning_received(sid, "输出正常。")
window.on_chunk_received(sid, "任务完成。")
tl = st["timeline"]
check("时间线累积: 5 段", len(tl) == 5, str(tl))
check("时间线类型序 think/text/tool/think/text",
[e["t"] for e in tl] == ["think", "text", "tool", "think", "text"],
str([e["t"] for e in tl]))
tool_e = tl[2] if len(tl) > 2 else {}
check("工具条目定格 ok+result",
tool_e.get("ok") is True and "[exit 0]" in tool_e.get("result", ""),
str(tool_e))
check("聚合 content/reasoning 正确",
st["content"] == "我来执行任务完成。"
and st["reasoning"] == "先看一下目录结构。输出正常。",
f"{st['content']!r} / {st['reasoning']!r}")
tl_json = json.dumps(tl, ensure_ascii=False)
# ---------- 入库 ----------
row = db.add_message(sid, "assistant", st["content"], user_id,
reasoning=st["reasoning"], msg_id=mid,
timeline=tl_json)
check("DB timeline 列写入", row.get("timeline") == tl_json)
# ---------- 切走再切回(重载) ----------
del window._active_streams[sid] # 模拟流已结束(先于重载,避免 live-restore 重复渲染)
window.load_messages_to_web(sid, show_loading=False)
def verify_reload(res):
data = json.loads(str(res))
if "error" in data:
check("重载 DOM 校验", False, data["error"])
finish()
return
expected = ["think-block", "md-segment", "tool-chip",
"think-block", "md-segment"]
check("重载: 时间线块序还原", data["blocks"] == expected,
str(data["blocks"]))
check("重载: 工具气泡保留且成功",
data["chipOk"] and data["chipStatus"] == "✓ 完成"
and data["chipCallId"] == "call-p1",
f"{data['chipStatus']} {data['chipCallId']}")
check("重载: 思考块 x2 + 末段文本",
data["thinkCount"] == 2 and "任务完成。" in data["lastSegText"],
f"think={data['thinkCount']} seg={data['lastSegText']!r}")
check("重载: SVG 箭头", data["thinkChevron"])
# ---------- build_api_context 重建 ----------
api = window.build_api_context(sid)
roles = [m["role"] for m in api]
check("API 链: user→assistant→tool→assistant",
roles == ["user", "assistant", "tool", "assistant"],
str(roles))
asst1 = api[1] if len(api) > 1 else {}
check("API: 首条 assistant 带 tool_calls",
asst1.get("content") == "我来执行"
and len(asst1.get("tool_calls", [])) == 1
and asst1["tool_calls"][0]["function"]["name"] == "bash",
str(asst1)[:200])
check("API: tool 消息 tool_call_id 对齐",
len(api) > 2 and api[2].get("tool_call_id") == "call-p1"
and "[exit 0]" in api[2].get("content", ""),
str(api[2] if len(api) > 2 else {})[:200])
check("API: 末条 assistant = 最终回答",
len(api) > 3 and api[3].get("content") == "任务完成。")
check("API: 无 reasoning 字段",
all("reasoning" not in m for m in api))
# ---------- 切回进行中会话: 续流 ----------
tljs = json.dumps(tl_json)
js_a = (JS_RESUME_A
.replace("'__MID__'", "'msg-resume-test'")
.replace("restoreStreamingTimeline(mid, '__TLJSON__')",
f"restoreStreamingTimeline(mid, {tljs})"))
js_b = JS_RESUME_B.replace("'__MID__'", "'msg-resume-test'")
def verify_resume_data(d2):
if "error" in d2:
check("续流 DOM 校验", False, d2["error"])
finish()
return
expected2 = ["think-block", "md-segment", "tool-chip",
"think-block", "md-segment", "think-block"]
check("续流: 块序正确", d2["blocks"] == expected2,
str(d2["blocks"]))
check("续流: 文本并入末段",
"任务完成。(续流文本)" in d2["lastSegText"],
d2["lastSegText"])
check("续流: 思考 x3 + 工具 x1",
d2["thinkCount"] == 3 and d2["chipCount"] == 1,
f"think={d2['thinkCount']} chip={d2['chipCount']}")
finish()
def run_b():
def got_b(v):
vstr = "" if v is None else str(v)
print(f" [resume B] {vstr[:120]}")
if vstr.startswith("resumeB-err"):
check("续流 finishMessage", False, vstr)
finish()
return
try:
d2 = json.loads(vstr)
except Exception:
check("续流 DOM 校验", False, repr(vstr)[:120])
finish()
return
verify_resume_data(d2)
window.browser.page().runJavaScript(js_b, got_b)
def run_a():
def got_a(v):
vstr = "" if v is None else str(v)
print(f" [resume A] {vstr[:120]}")
if vstr.startswith("resumeA-err"):
check("续流 restore", False, vstr)
finish()
return
QTimer.singleShot(300, run_b)
window.browser.page().runJavaScript(js_a, got_a)
QTimer.singleShot(300, run_a)
window.browser.page().runJavaScript(
JS_VERIFY_RELOAD.replace("'__MID__'", f"'{mid}'"), verify_reload)
def main():
global window
try:
window = MainWindow()
except Exception:
import traceback
traceback.print_exc()
app.quit()
return
QTimer.singleShot(6000, run_checks)
QTimer.singleShot(45000, lambda: (check("超时", False, "45s 未完成"),
finish()) if not test["done"] else None)
app.exec()
if __name__ == "__main__":
main()
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""验证 probeStream 探针在真实 WebEngine 页返回正确数据"""
import os, sys
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PyQt6.QtWidgets import QApplication
import PyQt6.QtWebEngineWidgets # noqa
from PyQt6.QtCore import QTimer
from ui.views.main_window import MainWindow
PASS = 0
FAIL = 0
def check(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name} {detail}")
app = QApplication(sys.argv)
window = MainWindow()
window.resize(1280, 800)
window.show()
PROBE_JS = r"""
(function() {
var m = 'probe-test-' + Date.now();
createMessage(m, 'assistant', '', 'LG');
appendReasoning(m, '思考探针内容');
appendToken(m, '正文探针 ');
appendToken(m, '第二段');
// 同步强制渲染
if (typeof forceRenderNow === 'function') forceRenderNow(m);
var res = probeStream(m);
finishMessage(m);
return res;
})()
"""
def step1():
window.browser.page().runJavaScript(PROBE_JS, on_probe)
def on_probe(res):
import json
d = json.loads(str(res))
print(" probe:", str(d)[:300])
check("probe 返回缓冲段", len(d.get("segs", [])) >= 1, str(d))
if d.get("segs"):
s0 = d["segs"][0]
check("缓冲长度>0", s0["b"] > 0, str(s0))
check("DOM 已写入", s0["d"] > 0, str(s0))
check("思考段探针", len(d.get("thinks", [])) >= 1, str(d))
check("无 JS 异常", "err" not in d, str(d))
print(f"===== {'ALL PASS' if FAIL == 0 else 'HAS FAILURES'}: {PASS}/{PASS+FAIL} =====")
app.quit()
QTimer.singleShot(1500, step1)
app.exec()
sys.exit(1 if FAIL else 0)
+127
View File
@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""用 DB 里用户真实消息内容回放流式渲染,验证新路径是否正确。
每个 chunk 走独立 runJavaScript(模拟 Python 逐 token 推送的真实路径)。"""
import os, sys, json, sqlite3
# 不加 --disable-gpu:复现真实应用的 GPU 渲染环境
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = os.environ.get("QTWEBENGINE_CHROMIUM_FLAGS", "")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PyQt6.QtWidgets import QApplication
import PyQt6.QtWebEngineWidgets # noqa
from PyQt6.QtCore import QTimer
from ui.views.main_window import MainWindow
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASS = 0
FAIL = 0
def check(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name} {detail}")
def esc(s):
return s.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
# 取用户真实消息
conn = sqlite3.connect(os.path.join(ROOT, "data", "chat_history.db"))
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT content, timeline FROM messages WHERE id LIKE 'msg-99235c06%' "
"ORDER BY created_at DESC LIMIT 1").fetchone()
assert row, "DB 中找不到测试消息"
timeline = json.loads(row["timeline"] or "[]")
print(f"消息: content={len(row['content'])}c, timeline={len(timeline)}")
MID = "repro-real"
# 构造回放脚本序列:think/text 按 20 字符切块,每批 20 个 chunk 一次 JS 调用
CHUNK = 20
BATCH = 20
steps = [] # JS 片段列表
for e in timeline:
t = e.get("t")
txt = e.get("text", "")
if t in ("think", "text") and txt:
for i in range(0, len(txt), CHUNK):
fn = "appendReasoning" if t == "think" else "appendToken"
steps.append(f"{fn}('{MID}', '{esc(txt[i:i+CHUNK])}');")
steps_batched = [chr(10).join(steps[i:i+BATCH]) for i in range(0, len(steps), BATCH)]
app = QApplication(sys.argv)
window = MainWindow()
window.resize(1280, 800)
window.show()
state = {"idx": 0, "probed": False}
def next_batch():
"""一次送一批(20 个 chunk 拼接在一个 JS 任务里)"""
if state["idx"] >= len(steps_batched):
QTimer.singleShot(300, mid_probe)
return
bi = state["idx"]
js = steps_batched[bi]
total = len(steps_batched)
def done(r, bi=bi):
state["idx"] = bi + 1
# 过半时探针一次
if not state["probed"] and bi + 1 >= total // 2:
state["probed"] = True
window.browser.page().runJavaScript(
f"probeStream('{MID}')", on_mid_probe)
else:
QTimer.singleShot(0, next_batch)
window.browser.page().runJavaScript(js, done)
def on_mid_probe(res):
d = json.loads(str(res))
print(" [过半探针]", str(d)[:400])
check("中途: 缓冲已累积", any(s["b"] > 0 for s in d.get("segs", [])), str(d))
check("中途: DOM 已写入部分正文",
any(s["d"] > 0 for s in d.get("segs", [])), str(d))
check("中途: 思考 DOM 已写入",
any(t["d"] > 0 for t in d.get("thinks", [])), str(d))
finish_seq()
def mid_probe():
window.browser.page().runJavaScript(f"probeStream('{MID}')", on_mid_probe)
def finish_seq():
window.browser.page().runJavaScript(
f"finishMessage('{MID}'); probeStream('{MID}')", on_final)
def on_final(res):
d = json.loads(str(res))
print(" [最终探针]", str(d)[:400])
tot_b = sum(s["b"] for s in d.get("segs", []))
tot_d = sum(s["d"] for s in d.get("segs", []))
check("最终: 正文缓冲完整", tot_b > 500, f"b={tot_b}")
check("最终: 正文 DOM 完整", tot_d > 500, f"d={tot_d}")
check("最终: 思考 DOM 完整",
sum(t["d"] for t in d.get("thinks", [])) > 500,
str([t['d'] for t in d.get('thinks', [])]))
# DOM 实际包含关键子串
window.browser.page().runJavaScript(
"(function(){ var w = document.getElementById('" + MID + "');"
" return w ? w.textContent.length : -1; })()", on_text)
def on_text(res):
n = int(str(res) or 0)
check("DOM 总文本量正常", n > 1000, f"total={n}")
print(f"===== {'ALL PASS' if FAIL == 0 else 'HAS FAILURES'}: {PASS}/{PASS+FAIL} =====")
app.quit()
def start():
window.browser.page().runJavaScript(
f"createMessage('{MID}', 'assistant', '', 'Real');",
lambda r: QTimer.singleShot(200, next_batch))
QTimer.singleShot(1500, start)
app.exec()
sys.exit(1 if FAIL else 0)
+191
View File
@@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
"""时间线 DOM 冒烟测试(真实 WebEngine 页面内执行):
1. 流式事件序列 → 思考/正文/工具按事件顺序穿插
2. 工具 chip 按 call_id 对号入座 + ok 状态(修复反转 bug)
3. SVG 箭头存在、流式光标移除
4. 历史消息静态路径不受影响
运行: QT_QPA_PLATFORM=offscreen python tests/smoke_timeline.py
"""
import os
import sys
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" # 绕过 AMD 核显 context lost
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402 (QtWebEngine 已先导入)
app = QApplication(sys.argv)
results = []
window = None
done = {"ok": False}
def check(name, ok, detail=""):
print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" [{detail}]" if detail and not ok else ""))
results.append(ok)
def js(s):
return s
JS_SIM = js(r"""
(function() {
var out = {};
try {
// ---- 1. 流式时间线 ----
var mid = 'tl-test-' + Date.now();
createMessage(mid, 'assistant', '', 'TL');
var wrapper = document.getElementById(mid);
appendReasoning(mid, 'think part 1 ');
appendReasoning(mid, 'more thinking');
appendToken(mid, 'before tool text ');
toolExecutionStarted(mid, 'call-1', 'bash', JSON.stringify({command: 'echo hi'}));
toolExecutionUpdated(mid, 'call-1', 'hi\n');
toolExecutionFinished(mid, 'call-1', 'bash', true, '$ echo hi\nhi\n[exit 0]');
appendToken(mid, ' after tool');
appendReasoning(mid, ' second thinking');
appendToken(mid, ' final answer');
finishMessage(mid);
var tl = wrapper.querySelector('.reply-content');
out.blocks = Array.prototype.map.call(tl.children, function(el) {
return el.className.split(' ')[0];
});
var chip = wrapper.querySelector('.tool-chip');
var status = chip ? chip.querySelector('.tool-chip-status') : null;
out.chipStatusText = status ? status.textContent : null;
out.chipOk = status ? status.classList.contains('ok') : false;
out.chipBrief = chip ? (chip.querySelector('.tool-chip-brief') || {}).textContent : null;
out.chipCallId = chip ? chip.getAttribute('data-call-id') : null;
out.thinkChevron = !!wrapper.querySelector('.think-block .chev');
out.chipChevron = !!wrapper.querySelector('.tool-chip .chev');
out.typingRemoved = !wrapper.querySelector('.streaming-typing');
out.streamClassOff = !wrapper.classList.contains('streaming');
out.thinkBlocks = wrapper.querySelectorAll('.think-block').length;
out.mdSegs = wrapper.querySelectorAll('.md-segment').length;
// ---- 2. 历史消息静态路径 ----
var mid2 = 'tl-hist-' + Date.now();
createMessage(mid2, 'assistant', 'hello **world**', 'TL');
insertThinkBlock(mid2, 'history thinking');
finishMessage(mid2);
var w2 = document.getElementById(mid2);
var content2 = w2.querySelector('.message-content');
out.histOrder = Array.prototype.map.call(content2.children, function(el) {
return el.className.split(' ')[0];
});
out.histBold = w2.querySelector('.reply-content strong') !== null;
out.histThinkChevron = !!w2.querySelector('.think-block .chev');
out.histThinkLabel = (w2.querySelector('.think-label') || {}).textContent;
// ---- 3. 失败工具 chip(反转修复验证)----
var mid3 = 'tl-fail-' + Date.now();
createMessage(mid3, 'assistant', '', 'TL');
toolExecutionStarted(mid3, 'call-x', 'bash', '{"command":"false"}');
toolExecutionFinished(mid3, 'call-x', 'bash', false, 'boom');
var w3 = document.getElementById(mid3);
var st3 = w3.querySelector('.tool-chip-status');
out.failChipFail = st3 ? st3.classList.contains('fail') : false;
out.failChipText = st3 ? st3.textContent : null;
finishMessage(mid3);
// 清理
[mid, mid2, mid3].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.parentNode.removeChild(el);
});
} catch (e) {
out.error = String(e) + ' | ' + (e.stack || '').split('\n')[1];
}
return JSON.stringify(out);
})()
""")
def run_js_check():
page = window.browser.page() if hasattr(window, "browser") else None
if page is None:
# 找 QWebEnginePage
from PyQt6.QtWebEngineWidgets import QWebEngineView
views = window.findChildren(QWebEngineView)
page = views[0].page() if views else None
if page is None:
check("页面句柄", False, "no page")
finish()
return
def on_result(res):
try:
import json
data = json.loads(str(res))
except Exception as e:
check("JS 执行", False, f"bad json: {res} ({e})")
finish()
return
if "error" in data:
check("JS 执行", False, data["error"])
finish()
return
# 期望时间线: think → text → chip → text → think → text
expected = ["think-block", "md-segment", "tool-chip",
"md-segment", "think-block", "md-segment"]
check("时间线块顺序(思考/正文/工具穿插)", data["blocks"] == expected,
str(data["blocks"]))
check("两个思考段 + 三个正文段",
data["thinkBlocks"] == 2 and data["mdSegs"] == 3,
f"think={data['thinkBlocks']} segs={data['mdSegs']}")
check("工具 chip 成功状态(非反转)",
data["chipOk"] and data["chipStatusText"] == "✓ 完成",
f"{data['chipStatusText']} ok={data['chipOk']}")
check("chip 摘要=结果首行", data["chipBrief"] == "$ echo hi",
str(data["chipBrief"]))
check("chip call_id 贯通", data["chipCallId"] == "call-1",
str(data["chipCallId"]))
check("SVG 箭头(思考+工具)", data["thinkChevron"] and data["chipChevron"])
check("流式光标已移除 + streaming 类移除",
data["typingRemoved"] and data["streamClassOff"])
check("失败 chip 标 ✗", data["failChipFail"] and data["failChipText"] == "✗ 失败",
f"{data['failChipText']}")
check("历史消息: 思考在正文前",
data["histOrder"].index("think-block") < data["histOrder"].index("reply-content"),
str(data["histOrder"]))
check("历史消息: markdown 正常解析", data["histBold"])
check("历史消息: 思考块带 SVG 箭头+标签",
data["histThinkChevron"] and data["histThinkLabel"] == "已完成深度思考",
str(data["histThinkLabel"]))
finish()
page.runJavaScript(JS_SIM, on_result)
def finish():
done["ok"] = True
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
f"{sum(results)}/{len(results)} =====")
app.quit()
def main():
global window
try:
window = MainWindow()
except Exception:
import traceback
traceback.print_exc()
app.quit()
return
QTimer.singleShot(6000, run_js_check)
QTimer.singleShot(25000, lambda: (check("超时", False, "25s 未完成"), finish())
if not done["ok"] else None)
app.exec()
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
"""
tool_bash 实时输出(增量流)单测 —— 纯函数级,无 UI 依赖
本轮改动核心:communicate(timeout=1) 阻塞式收集
→ stdout/stderr reader 线程 + 队列 + 主循环抽干回调 on_update
本测试锁死「改动没有破坏任何既有语义」+「输出真的是实时的」。
覆盖:
T1 增量流:on_update 被多次调用(不是结束时一次性给)
T2 实时性:第一块输出到达时刻 << 总耗时(证明是流式而非收尾补发)
T3 上下文文本格式完全不变($ cmd / stdout / [stderr] / [exit N] (Ns)
T4 stderr 实时流带 [stderr] 分隔标记,且最终文本仍有 [stderr] 段
T5 每 1 秒读秒回调 on_timer 仍然工作
T6 超时:杀进程树 + 「命令超时(>Ns)已终止」+ 实际耗时远小于命令时长
T7 中止:AbortSignal.aborted → 「操作已中止」
T8 50KB 截断仍然生效
T9 退出非 0 → is_error,且 exit code 写入 details
T10 空命令 → 参数校验错误
T11 无输出命令:不产生 on_update,但读秒正常
T12 最终文本 = 全部 stdout 拼接(不丢不重)
运行: PYTHONIOENCODING=utf-8 python tests/test_bash_stream.py
"""
import os
import re
import sys
import time
import tempfile
import shutil
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.agent.tools import tool_bash # noqa: E402
from core.agent.types import AbortSignal # noqa: E402
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 = tempfile.mkdtemp(prefix="hocode_bashstream_")
PY = sys.executable
def run(script=None, timeout=None, abort_after=None, shell_script=None):
"""把脚本写成临时文件再执行(避开 cmd 引号地狱)。
返回 (result, updates, first_t, dur, timer_calls)"""
path = None
if script is not None:
path = os.path.join(_TMP, f"s_{int(time.time() * 1000000) % 10**9}.py")
with open(path, "w", encoding="utf-8") as f:
f.write(script)
cmd = f'"{PY}" -u "{path}"'
else:
cmd = shell_script
updates = []
first_t = [None]
t0 = time.time()
def on_update(text):
if first_t[0] is None:
first_t[0] = time.time() - t0
updates.append(text)
timers = []
def on_timer(elapsed, total):
timers.append((elapsed, total))
sig = AbortSignal()
if abort_after is not None:
import threading
def _aborter():
time.sleep(abort_after)
sig.abort("test")
threading.Thread(target=_aborter, daemon=True).start()
args = {"command": cmd}
if timeout is not None:
args["timeout"] = timeout
res = tool_bash("call_test", args, sig, on_update,
{"cwd": _TMP, "shell": True, "on_timer": on_timer})
dur = time.time() - t0
return res, updates, first_t[0], dur, timers
def text_of(res):
c = res.content
if isinstance(c, str):
return c
return "".join(x.get("text", "") for x in c if isinstance(x, dict))
# ======================================================================
try:
# ---------- T1/T2/T3/T12: 流式 + 上下文格式 ----------
res, ups, first_t, dur, timers = run(
"import sys, time\n"
"for i in range(3):\n"
" print('LINE%d' % i, flush=True)\n"
" time.sleep(0.5)\n", timeout=30)
txt = text_of(res)
check("T1.1 增量流被多次回调(>=3 块)", len(ups) >= 3, f"updates={len(ups)} {ups!r}")
check("T1.2 每块都是本次输出的一部分", all(u.strip() for u in ups), repr(ups[:3]))
check("T2.1 第一块在 1s 内到达(实时,不是收尾补发)",
first_t is not None and first_t < 1.0, f"first_t={first_t}")
check("T2.2 第一块明显早于总耗时",
first_t is not None and (dur - first_t) > 0.6, f"first={first_t} dur={dur:.2f}")
check("T3.1 上下文文本以 $ 命令开头", txt.startswith("$ "), repr(txt[:60]))
check("T3.2 含 [exit 0] 与耗时",
re.search(r"\[exit 0\] \(\d+\.\d+s\)", txt) is not None, repr(txt[-60:]))
check("T12.1 三行输出全部进入最终文本,不丢不重",
all(f"LINE{i}" in txt for i in range(3)) and txt.count("LINE0") == 1, repr(txt))
check("T3.3 is_error=False", res.is_error is False)
check("T3.4 details 带 exit_code", res.details.get("exit_code") == 0, str(res.details))
# ---------- T4: stderr 实时 + 最终段 ----------
res, ups, first_t, dur, timers = run(
"import sys\nprint('OUT1', flush=True)\nprint('ERR1', file=sys.stderr, flush=True)\n",
timeout=30)
txt = text_of(res)
joined = "".join(ups)
check("T4.1 实时流里有 [stderr] 分隔标记", "[stderr]" in joined, repr(joined))
check("T4.2 最终文本含 [stderr] 段与内容",
"[stderr]" in txt and "ERR1" in txt, repr(txt))
check("T4.3 stdout 内容也在最终文本里", "OUT1" in txt, repr(txt))
# ---------- T5/T11: 读秒 ----------
res, ups, first_t, dur, timers = run(
"import time\ntime.sleep(2.6)\nprint('done', flush=True)\n", timeout=30)
check("T5.1 长命令期间读秒回调(>=2 次)", len(timers) >= 2, f"timers={timers}")
check("T5.2 读秒总量正确", all(t[1] == 30 for t in timers), str(timers))
res, ups, first_t, dur, timers = run(
"import time\ntime.sleep(1.5)\n", timeout=30)
check("T11.1 无输出命令不产生 on_update", len(ups) == 0, repr(ups))
check("T11.2 无输出命令仍有读秒", len(timers) >= 1, str(timers))
# ---------- T6: 超时杀树 ----------
res, ups, first_t, dur, timers = run(
"import time\nprint('start', flush=True)\ntime.sleep(30)\n", timeout=2)
txt = text_of(res)
check("T6.1 超时文案", "命令超时(>2s)已终止" in txt, repr(txt))
check("T6.2 真的提前返回(耗时应 <10s)", dur < 10, f"dur={dur:.2f}")
check("T6.3 超时结果是 is_error", res.is_error is True)
# ---------- T7: 中止 ----------
res, ups, first_t, dur, timers = run(
"import time\ntime.sleep(30)\n", timeout=60, abort_after=1.5)
txt = text_of(res)
check("T7.1 中止文案", "操作已中止" in txt, repr(txt))
check("T7.2 中止后迅速返回(<8s", dur < 8, f"dur={dur:.2f}")
# ---------- T8: 50KB 截断 ----------
res, ups, first_t, dur, timers = run(
"import sys\n"
"line = 'X' * 100 + '\\n'\n"
"for _ in range(1200):\n"
" sys.stdout.write(line)\n"
"sys.stdout.flush()\n", timeout=30)
txt = text_of(res)
check("T8.1 超过 50KB 触发截断标记", "[输出超过 50KB 已截断]" in txt, repr(txt[-80:]))
check("T8.2 截断后字节数 <= 50KB+余量",
len(txt.encode("utf-8")) <= 50 * 1024 + 200, str(len(txt.encode("utf-8"))))
# ---------- T9: 非 0 退出 ----------
res, ups, first_t, dur, timers = run("import sys\nsys.exit(3)\n", timeout=30)
txt = text_of(res)
check("T9.1 exit 3 写入文本", "[exit 3]" in txt, repr(txt))
check("T9.2 is_error=True", res.is_error is True)
check("T9.3 details.exit_code=3", res.details.get("exit_code") == 3, str(res.details))
# ---------- T10: 空命令 ----------
res = tool_bash("c", {"command": " "}, AbortSignal(), None,
{"cwd": _TMP, "shell": True})
check("T10.1 空命令被拒", "command 不能为空" in text_of(res), repr(text_of(res)))
# ---------- T13: 秒级短命令仍正常(回归:不因线程化而变慢/丢输出) ----------
res, ups, first_t, dur, timers = run(shell_script="echo hello_from_cmd")
txt = text_of(res)
check("T13.1 短命令输出正确", "hello_from_cmd" in txt, repr(txt))
check("T13.2 短命令耗时 <3s", dur < 3, f"dur={dur:.2f}")
check("T13.3 短命令实时流也拿到了输出", "hello_from_cmd" in "".join(ups), repr(ups))
finally:
shutil.rmtree(_TMP, ignore_errors=True)
failed = [n for n, ok in RESULTS if not ok]
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)
+287
View File
@@ -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"]])
# ---------- T2build_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))
# ---------- T3kind 传播 → 二次压缩走迭代摘要 ----------
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)
# ---------- T10insert_compaction_mark 自环守卫 ----------
bad = db.insert_compaction_mark(sid, "S", r5["id"], r5["id"])
check("T10.cut==retained 拒绝插入(防自环)", bad is None)
# ---------- T11get_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)))
# ---------- T7pre_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))
# ---------- T9UI 渲染过滤排除 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)
# ---------- T12Fix 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)
# ---------- T13Fix 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)
# ---------- T14G2 — 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)
+336
View File
@@ -0,0 +1,336 @@
# -*- coding: utf-8 -*-
"""
会话复制(DBManager.copy_session)单测
—— 全程临时 DB + 临时附件目录,绝不触碰真实 data/chat_history.db
覆盖:
T1 线性会话复制(消息数/顺序/内容一致,ID 全新)
T2 parent_id 链重建正确(首条 parent 为 None,链走通)
T3 分叉会话整树复制(分支数一致)
T4 压缩标记原样复制(数量/内容/切点关系/时间戳晚于全部保留行)
T5 current_leaf_msg_id 重映射且真实存在
T6 源会话零改动(全列快照比对)
T7 删副本 → 源完好;删源 → 副本完好
T8 图片附件物理复制到新路径(新旧文件同时存在、内容相同)
T9 文本附件原样保留(不产生新文件、metadata 逐字节相同)
T10 title / is_starred / mode / has_messages / sort_order 语义
T11 不存在的 session_id → None,且无残留
T12 副本再复制 → 标题 (副本 2)
T13 外键无违规 + 无孤儿 parent_id
T14 get_message_chain(源) 与 副本 的 (role, content, is_ignored) 序列完全相同
运行: PYTHONIOENCODING=utf-8 python tests/test_copy_session.py
"""
import os
import sys
import json
import uuid
import time
import tempfile
import shutil
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.db_manager import DBManager # noqa: E402
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 = tempfile.mkdtemp(prefix="hocode_copysess_")
_ATT = os.path.join(_TMP, "data", "attachments")
os.makedirs(_ATT, exist_ok=True)
db = DBManager(os.path.join(_TMP, "data", "chat_history.db"))
db.files_root = _TMP # 附件根指向临时目录
# ======================================================================
# 工具函数
# ======================================================================
def mk_session(title, msgs, mode=None, starred=0, has_messages=1, att_meta=None):
"""建一个线性会话。msgs=[(role, content)]att_meta={index: json_string}
返回 (session_id, [msg_id])"""
sid = "sess_" + uuid.uuid4().hex[:12]
now = int(time.time())
with db.get_connection() as conn:
cur = conn.cursor()
cur.execute(
"INSERT INTO sessions (id,title,created_at,updated_at,has_messages,sort_order,"
"is_starred,current_leaf_msg_id,mode) VALUES (?,?,?,?,?,?,?,?,?)",
(sid, title, now, now, has_messages, db.get_min_sort_order(), starred, None, mode))
prev, ids = None, []
for i, (role, content) in enumerate(msgs):
mid = ("comp_" if role == "compaction" else "msg_") + uuid.uuid4().hex[:16]
meta = (att_meta or {}).get(i)
cur.execute(
"INSERT INTO messages (id,session_id,role,content,reasoning,is_ignored,"
"created_at,attachment_metadata,parent_id,timeline,usage) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(mid, sid, role, content, "思考内容", 0, now - 1000 + i, meta, prev,
'{"t":1}', '{"input":10,"output":5}'))
ids.append(mid)
prev = mid
cur.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (prev, sid))
conn.commit()
return sid, ids
def add_child(session_id, parent_id, role, content, ts):
"""在指定 parent 下挂一个子消息(构造分叉用)"""
mid = "msg_" + uuid.uuid4().hex[:16]
with db.get_connection() as conn:
conn.execute(
"INSERT INTO messages (id,session_id,role,content,reasoning,is_ignored,"
"created_at,attachment_metadata,parent_id,timeline,usage) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(mid, session_id, role, content, "", 0, ts, None, parent_id, None, None))
conn.commit()
return mid
def rows_of(sid):
with db.get_connection() as conn:
return [dict(r) for r in conn.execute(
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, rowid ASC",
(sid,)).fetchall()]
def sess_of(sid):
with db.get_connection() as conn:
r = conn.execute("SELECT * FROM sessions WHERE id = ?", (sid,)).fetchone()
return dict(r) if r else None
def sess_count():
with db.get_connection() as conn:
return conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
def chain_sig(sid):
"""(role, content, is_ignored) 序列"""
return [(m["role"], m["content"], m["is_ignored"])
for m in db.get_message_chain(sid)]
# ======================================================================
try:
# ---------------- T1/T2: 线性会话 ----------------
src, src_ids = mk_session("线性会话", [
("system", "sys"), ("user", "u1"), ("assistant", "a1"),
("user", "u2"), ("assistant", "a2")])
n_before = sess_count()
cpy = db.copy_session(src)
check("T1.1 返回新会话 dict 且 id 不同", cpy and cpy["id"] != src)
check("T1.2 会话数 +1", sess_count() == n_before + 1, f"{n_before}->{sess_count()}")
s_rows, c_rows = rows_of(src), rows_of(cpy["id"])
check("T1.3 消息数一致", len(s_rows) == len(c_rows) == 5, f"{len(s_rows)}/{len(c_rows)}")
check("T1.4 ID 全新无交集",
not (set(r["id"] for r in s_rows) & set(r["id"] for r in c_rows)))
check("T1.5 role+content 序列一致",
[(r["role"], r["content"]) for r in s_rows] ==
[(r["role"], r["content"]) for r in c_rows])
check("T1.6 reasoning/timeline/usage 原样复制",
all(s["reasoning"] == c["reasoning"] and s["timeline"] == c["timeline"]
and s["usage"] == c["usage"] for s, c in zip(s_rows, c_rows)))
# T2: parent 链重建
idmap = {s["id"]: c["id"] for s, c in zip(s_rows, c_rows)}
ok_chain = c_rows[0]["parent_id"] is None
for s, c in list(zip(s_rows, c_rows))[1:]:
ok_chain = ok_chain and c["parent_id"] == idmap.get(s["parent_id"])
check("T2.1 parent_id 全量重映射", ok_chain)
check("T2.2 副本链首 parent 为 None(未指回源 ID", c_rows[0]["parent_id"] is None)
check("T2.3 副本链里不含任何源 ID",
not (set(idmap.keys()) & set(r["parent_id"] for r in c_rows if r["parent_id"])))
# ---------------- T3: 分叉会话整树复制 ----------------
sid3, ids3 = mk_session("分叉会话", [("system", "s"), ("user", "q")])
base = ids3[-1]
add_child(sid3, base, "assistant", "br1", int(time.time()) + 10)
add_child(sid3, base, "assistant", "br2", int(time.time()) + 11)
copy3 = db.copy_session(sid3)
c3 = rows_of(copy3["id"])
src_branches = len(db.get_branch_info(base))
c_base = [r for r in c3 if r["role"] == "user"][0]["id"]
check("T3.1 源有 2 个分支", src_branches == 2, str(src_branches))
check("T3.2 副本分支数一致", len(db.get_branch_info(c_base)) == 2,
str(len(db.get_branch_info(c_base))))
check("T3.3 副本消息总数一致", len(c3) == len(rows_of(sid3)) == 4)
check("T3.4 分支内容一致",
sorted(b["content"] for b in db.get_branch_info(c_base)) == ["br1", "br2"])
# ---------------- T4: 压缩标记 ----------------
sid4, ids4 = mk_session("压缩会话", [
("system", "s"), ("user", "u1"), ("assistant", "a1"),
("user", "u2"), ("assistant", "a2")])
mark = db.insert_compaction_mark(sid4, "【摘要】前面聊了 u1/a1",
cut_before_id=ids4[2], first_retained_id=ids4[3])
copy4 = db.copy_session(sid4)
c4 = rows_of(copy4["id"])
s4 = rows_of(sid4)
c_marks = [r for r in c4 if r["role"] == "compaction"]
s_marks = [r for r in s4 if r["role"] == "compaction"]
check("T4.1 标记存在且已复制", len(s_marks) == 1 and len(c_marks) == 1)
check("T4.2 摘要内容一致", c_marks and c_marks[0]["content"] == s_marks[0]["content"])
m4 = {s["id"]: c["id"] for s, c in zip(s4, c4)}
check("T4.3 标记 parent 指向副本的 cut_before",
c_marks[0]["parent_id"] == m4.get(ids4[2]))
check("T4.4 first_retained 的 parent 指向副本标记",
[r for r in c4 if r["id"] == m4.get(ids4[3])][0]["parent_id"] == c_marks[0]["id"])
check("T4.5 标记 id 带 comp_ 前缀", c_marks[0]["id"].startswith("comp_"))
retained = [r for r in c4 if r["created_at"] is not None
and r["id"] != c_marks[0]["id"]]
check("T4.6 标记时间戳晚于全部其它行",
all(c_marks[0]["created_at"] >= r["created_at"] for r in retained))
check("T4.7 标记 is_ignored 保持 1", c_marks[0]["is_ignored"] == 1)
# created_at 相对次序必须与源完全一致(兄弟排序依赖它)
s_order = [i for i, _ in sorted(enumerate([r["created_at"] for r in s4]),
key=lambda x: x[1])]
c_order = [i for i, _ in sorted(enumerate([r["created_at"] for r in c4]),
key=lambda x: x[1])]
check("T4.8 created_at 相对次序与源一致", s_order == c_order, f"{s_order} vs {c_order}")
# ---------------- T5: 叶子重映射 ----------------
check("T5.1 副本叶子非空且不是源叶子",
cpy["current_leaf_msg_id"] and cpy["current_leaf_msg_id"] != sess_of(src)["current_leaf_msg_id"])
check("T5.2 副本叶子真实存在于副本",
cpy["current_leaf_msg_id"] in [r["id"] for r in c_rows])
check("T5.3 副本叶子对应源叶子的内容",
[r for r in c_rows if r["id"] == cpy["current_leaf_msg_id"]][0]["content"]
== s_rows[-1]["content"])
# ---------------- T6: 源会话零改动 ----------------
src_snapshot = (sess_of(src), [tuple(sorted(r.items())) for r in rows_of(src)])
_ = db.copy_session(src)
src_after = (sess_of(src), [tuple(sorted(r.items())) for r in rows_of(src)])
check("T6.1 复制两次后源 sessions 行不变", src_snapshot[0] == src_after[0])
check("T6.2 复制两次后源 messages 全列不变", src_snapshot[1] == src_after[1])
# ---------------- T7: 双向删除隔离 ----------------
sid7, _ = mk_session("隔离A", [("system", "s"), ("user", "x")])
c7 = db.copy_session(sid7)
db.delete_session(c7["id"])
check("T7.1 删副本后源仍在且消息完整",
sess_of(sid7) is not None and len(rows_of(sid7)) == 2)
# 方向二另起一对,避免与方向一互相干扰
sid7b, _ = mk_session("隔离B", [("system", "s"), ("user", "y")])
c7b = db.copy_session(sid7b)
db.delete_session(sid7b)
check("T7.2 删源后副本仍在且消息完整",
sess_of(c7b["id"]) is not None and len(rows_of(c7b["id"])) == 2,
f'cpy sess={sess_of(c7b["id"]) is not None} rows={len(rows_of(c7b["id"]))}')
# ---------------- T8: 图片附件物理复制 ----------------
png_src = os.path.join(_ATT, "img_src_test.png")
with open(png_src, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n" + b"FAKEIMAGEDATA" * 8)
img_meta = json.dumps({"user_text": "看看这张图",
"attachments": [{"type": "image", "size_kb": 0.2,
"local_path": "data/attachments/img_src_test.png"}]},
ensure_ascii=False)
sid8, _ = mk_session("图片会话", [("system", "s"), ("user", "带图")],
att_meta={1: img_meta})
c8 = db.copy_session(sid8)
m8 = [r for r in rows_of(c8["id"]) if r["attachment_metadata"]][0]
meta8 = json.loads(m8["attachment_metadata"])
new_rel = meta8["attachments"][0]["local_path"]
new_abs = os.path.join(_TMP, new_rel)
check("T8.1 local_path 已改写为新文件",
new_rel != "data/attachments/img_src_test.png", new_rel)
check("T8.2 新旧文件同时存在",
os.path.isfile(png_src) and os.path.isfile(new_abs))
check("T8.3 新文件内容与源一致",
open(new_abs, "rb").read() == open(png_src, "rb").read())
check("T8.4 user_text 等其它字段保留", meta8["user_text"] == "看看这张图")
check("T8.5 源 metadata 未被改动",
json.loads([r for r in rows_of(sid8) if r["attachment_metadata"]][0]
["attachment_metadata"])["attachments"][0]["local_path"]
== "data/attachments/img_src_test.png")
# 缺文件容错:metadata 保留原路径,不抛异常
bad_meta = json.dumps({"user_text": "x", "attachments": [
{"type": "image", "local_path": "data/attachments/does_not_exist.png"}]},
ensure_ascii=False)
sid8b, _ = mk_session("缺文件", [("system", "s"), ("user", "y")], att_meta={1: bad_meta})
c8b = db.copy_session(sid8b)
bad_after = [r for r in rows_of(c8b["id"]) if r["attachment_metadata"]][0]
check("T8.6 源文件缺失时保留原路径且不失败",
json.loads(bad_after["attachment_metadata"])["attachments"][0]["local_path"]
== "data/attachments/does_not_exist.png")
# ---------------- T9: 文本附件原样 ----------------
txt_meta = json.dumps({"user_text": "", "attachments": [
{"type": "text", "size_kb": 1.0, "lines": 3, "content": "aaa\nbbb\nccc"}]},
ensure_ascii=False)
sid9, _ = mk_session("文本附件", [("system", "s"), ("user", "t")], att_meta={1: txt_meta})
n_files_before = len(os.listdir(_ATT))
c9 = db.copy_session(sid9)
t9 = [r for r in rows_of(c9["id"]) if r["attachment_metadata"]][0]
check("T9.1 文本附件 metadata 逐字节相同",
t9["attachment_metadata"] == txt_meta, t9["attachment_metadata"][:60])
check("T9.2 文本附件不产生新文件", len(os.listdir(_ATT)) == n_files_before)
# ---------------- T10: 字段语义 ----------------
sid10, _ = mk_session("语义检查", [("system", "s"), ("user", "m")],
mode="worker", starred=1)
min_order_before = db.get_min_sort_order()
c10 = db.copy_session(sid10)
check("T10.1 标题 = 原名 + ' (副本)'", c10["title"] == "语义检查 (副本)", c10["title"])
check("T10.2 副本不带星标", c10["is_starred"] == 0, str(c10["is_starred"]))
check("T10.3 mode 跟随源", c10["mode"] == "worker", str(c10["mode"]))
check("T10.4 has_messages 跟随源", c10["has_messages"] == sess_of(sid10)["has_messages"])
check("T10.5 sort_order 置顶", c10["sort_order"] == min_order_before,
f'{c10["sort_order"]} vs {min_order_before}')
check("T10.6 源仍是星标且未被改标题",
sess_of(sid10)["is_starred"] == 1 and sess_of(sid10)["title"] == "语义检查")
check("T10.7 created_at/updated_at = 当前时间",
abs(c10["updated_at"] - int(time.time())) <= 5)
# 无消息的会话(只有 system 行)也能复制
sid10b, ids10b = mk_session("孤儿", [("system", "only")], has_messages=0)
c10b = db.copy_session(sid10b)
check("T10.8 仅 system 行的会话可复制",
c10b and len(rows_of(c10b["id"])) == 1 and c10b["current_leaf_msg_id"] is not None)
# ---------------- T12: 副本再复制 → (副本 2) ----------------
c12 = db.copy_session(c10["id"])
check("T12.1 再复制标题递增为 (副本 2)", c12["title"] == "语义检查 (副本 2)", c12["title"])
c12b = db.copy_session(c12["id"])
check("T12.2 第三次复制为 (副本 3)", c12b["title"] == "语义检查 (副本 3)", c12b["title"])
c12c = db.copy_session(sid10, new_title="自定义标题")
check("T12.3 可显式指定标题", c12c["title"] == "自定义标题", c12c["title"])
# ---------------- T11: 不存在的会话 ----------------
n11 = sess_count()
check("T11.1 不存在 → None", db.copy_session("sess_not_exist_xxx") is None)
check("T11.2 无残留(会话数不变)", sess_count() == n11)
# ---------------- T13: 外键 / 孤儿 ----------------
bad_fk = 0
orphans = 0
with db.get_connection() as conn:
bad_fk = len(conn.execute("PRAGMA foreign_key_check").fetchall())
for r in rows_of(cpy["id"]):
if r["parent_id"] and r["parent_id"] not in [x["id"] for x in c_rows]:
orphans += 1
check("T13.1 外键无违规", bad_fk == 0, str(bad_fk))
check("T13.2 无孤儿 parent_id", orphans == 0, str(orphans))
# ---------------- T14: 链签名一致(端到端) ----------------
check("T14.1 线性会话链签名一致", chain_sig(src) == chain_sig(cpy["id"]))
check("T14.2 分叉会话链签名一致", chain_sig(sid3) == chain_sig(copy3["id"]))
check("T14.3 压缩会话链签名一致(含标记行)", chain_sig(sid4) == chain_sig(copy4["id"]))
finally:
shutil.rmtree(_TMP, ignore_errors=True)
failed = [n for n, ok in RESULTS if not ok]
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)
+139
View File
@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
"""独立调试器窗口 + 调试日志协议 回归测试(离屏,临时文件,不碰真实 DB/日志)
运行: PYTHONIOENCODING=utf-8 QT_QPA_PLATFORM=offscreen python tests/test_debug_window.py
"""
import sys, os, re, tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
_TMPDIR = tempfile.mkdtemp(prefix="haocode_dbg_")
os.environ["HAOCODE_DEBUG_LOG"] = os.path.join(_TMPDIR, "debug_session.log")
os.environ["HAOCODE_DEBUG_CMD"] = os.path.join(_TMPDIR, "debug_window.cmd")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
from core import debug_log as dl
PASS, FAIL = 0, 0
def check(name, cond, extra=""):
global PASS, FAIL
if cond:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name} {extra}")
def read_log():
if not os.path.exists(dl.DEBUG_LOG_PATH):
return ""
with open(dl.DEBUG_LOG_PATH, "r", encoding="utf-8") as f:
return f.read()
LINE_RE = re.compile(r"^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] "
r"\[(USER|AGENT|APP|SYS)\] .+$")
# ============ T1: debug_log 三方写入 + 行格式 ============
print("T1: debug_log 行格式与三方 TAG")
dl.debug_log("hello app", "APP")
dl.debug_log("代理注入一条备注", "AGENT")
dl.debug_log("用户观察到标签 40.5k", "USER")
lines = [l for l in read_log().splitlines() if l]
check("三行全部写入", len(lines) == 3, lines)
check("行格式 [ts] [TAG] msg",
all(LINE_RE.match(l) for l in lines), lines)
check("TAG 顺序 APP/AGENT/USER",
[re.search(r"\[(USER|AGENT|APP|SYS)\]", l).group(1) for l in lines]
== ["APP", "AGENT", "USER"])
# ============ T2: poll_debug_cmd 控制协议 ============
print("T2: poll_debug_cmd 消费语义")
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
f.write("show\n")
check("show 被识别", dl.poll_debug_cmd() == "show")
check("文件被消费(再读为 None", dl.poll_debug_cmd() is None)
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
f.write(" HIDE ")
check("hide 大小写/空白容忍", dl.poll_debug_cmd() == "hide")
with open(dl.DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
f.write("garbage")
check("非法内容忽略", dl.poll_debug_cmd() is None)
# ============ T3-T6: DebugWindow 行为(离屏) ============
print("T3: DebugWindow 用户输入 → [USER] 落盘")
from PyQt6.QtWidgets import QApplication
app = QApplication.instance() or QApplication(sys.argv)
import ui.views.debug_window as dw
# 应用日志 tab 指向临时文件(不读真实 diag.log)
_tmp_diag = os.path.join(_TMPDIR, "diag.log")
dw._APP_LOGS[:] = [("DIAG", _tmp_diag)]
win = dw.DebugWindow()
check("窗口标题独立", win.windowTitle() == "Haocode 调试器")
win._input.setText(" 发送后标签跳到 80k ")
win._on_submit()
check("输入框被清空", win._input.text() == "")
check("[USER] 已落盘且去首尾空白",
any(l.endswith("[USER] 发送后标签跳到 80k") for l in read_log().splitlines()))
print("T4: 首次 tick 记 [SYS] 开启事件")
win._tick()
check("[SYS] 调试窗口开启 已写入",
any("[SYS] 调试窗口开启" in l for l in read_log().splitlines()))
print("T5: 实时 tail 会话日志 + 应用日志")
dl.debug_log("tick 前注入的 APP 事件", "APP")
dl.debug_log("AGENT: 现在检查 compaction_diag.log", "AGENT")
win._tick()
txt = win._view_session.toPlainText()
check("会话视图含 APP 事件", "tick 前注入的 APP 事件" in txt, txt[-300:])
check("会话视图含 AGENT 注入", "AGENT: 现在检查 compaction_diag.log" in txt)
with open(_tmp_diag, "w", encoding="utf-8") as f:
f.write("[12:00:00.000] DIAG_TEST_LINE\n")
win._tick()
check("应用日志视图含 DIAG tail",
"DIAG_TEST_LINE" in win._view_app.toPlainText())
check("应用日志带文件头", "[DIAG]" in win._view_app.toPlainText())
print("T6: 暂停显示 + 截断重置")
win._chk_pause.setChecked(True) # 暂停
dl.debug_log("暂停期间的行不应上屏", "APP")
win._tick()
check("暂停期间不上屏", "暂停期间的行不应上屏"
not in win._view_session.toPlainText())
# 文件截断(模拟「清空会话日志」)→ 偏移重置,新行仍可读取
win._chk_pause.setChecked(False)
with open(dl.DEBUG_LOG_PATH, "w", encoding="utf-8") as f:
f.write("[2026-01-01 00:00:00.000] [SYS] 截断后新内容\n")
win._tick()
check("截断后偏移重置、新行上屏",
"截断后新内容" in win._view_session.toPlainText())
win.close()
# ============ T4: 调试窗口随程序启动(autostart_debug_window ============
print("T4: 调试窗口随程序启动")
p = dl.DEBUG_CMD_PATH
if os.path.exists(p):
os.remove(p)
# 显式 false → 不写控制文件
check("T4.autostart=False 不写控制文件",
dl.autostart_debug_window({"debug_window_autostart": False}) is False
and not os.path.exists(p))
# 缺省(键不存在)→ 默认开
check("T4.缺省(无键)写入 show",
dl.autostart_debug_window({}) is True and os.path.exists(p))
check("T4.轮询消费 show",
dl.poll_debug_cmd() == "show" and not os.path.exists(p))
# 显式 true
check("T4.autostart=true 写入 show",
dl.autostart_debug_window({"debug_window_autostart": True}) is True)
check("T4.轮询再消费 show",
dl.poll_debug_cmd() == "show")
print(f"\n===== {PASS} PASS / {FAIL} FAIL =====")
sys.exit(1 if FAIL else 0)
+368
View File
@@ -0,0 +1,368 @@
# -*- coding: utf-8 -*-
"""失败轮次持久化(对照 pi:message_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_call(200 个工具的记录没丢)", 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)
+87
View File
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""
file_reader 单元测试(标准库 unittest,零额外依赖)
运行:在项目根目录执行 python -m unittest discover tests -v
或直接 python tests/test_file_attach.py
"""
import os
import sys
import tempfile
import unittest
# 保证直接运行(python tests/xxx.py)时也能 import 到项目根下的包
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from ui.views.system_tools.file_reader import BINARY_EXTS, read_text_file # noqa: E402
class ReadTextFileTest(unittest.TestCase):
"""read_text_file:编码探测 / 二进制探测 / 大小守卫"""
def _write(self, data: bytes, suffix: str = ".txt") -> str:
fd, path = tempfile.mkstemp(suffix=suffix)
with os.fdopen(fd, "wb") as f:
f.write(data)
self.addCleanup(os.remove, path)
return path
def test_utf8_file(self):
path = self._write("你好,世界\nhello".encode("utf-8"))
content, enc, size_kb, lines = read_text_file(path)
self.assertIn("你好,世界", content)
self.assertIn("hello", content)
self.assertEqual(lines, 2)
self.assertIn(enc, ("utf-8-sig", "utf-8"))
self.assertGreater(size_kb, 0)
def test_utf8_bom_file(self):
path = self._write(b"\xef\xbb\xbf" + "带BOM".encode("utf-8"))
content, enc, _, _ = read_text_file(path)
self.assertEqual(content, "带BOM") # utf-8-sig 会吃掉 BOM
self.assertEqual(enc, "utf-8-sig")
def test_gbk_file_falls_back_to_gb18030(self):
path = self._write("中文GBK内容".encode("gbk"))
content, enc, _, _ = read_text_file(path)
self.assertEqual(content, "中文GBK内容")
self.assertEqual(enc, "gb18030")
def test_binary_file_rejected(self):
path = self._write(b"\x00\x01\x02\x03binary-payload")
with self.assertRaises(ValueError):
read_text_file(path)
def test_oversize_file_rejected(self):
path = self._write(b"a" * 100)
with self.assertRaises(ValueError):
read_text_file(path, max_bytes=10)
def test_latin1_fallback_never_fails(self):
# 0xFF 既非合法 UTF-8 也非合法 GB18030 引导字节,应由 latin-1 兜底
path = self._write(b"\xff\xfe\xfd plain text")
content, enc, _, _ = read_text_file(path)
self.assertEqual(enc, "latin-1")
self.assertIn("plain text", content)
class BinaryExtBlacklistTest(unittest.TestCase):
"""黑名单分类:Word/Excel 等被拒,常见代码/文本文件放行"""
def test_office_and_binary_blocked(self):
for ext in (".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".zip", ".exe", ".dll", ".mp4", ".ttf", ".db", ".psd"):
self.assertIn(ext, BINARY_EXTS, f"{ext} 应在黑名单中")
def test_pdf_not_blacklisted(self):
# PDF 改由专用分支(pdf_reader)处理,不再走二进制黑名单
self.assertNotIn(".pdf", BINARY_EXTS, ".pdf 应由 PDF 专用分支处理,不应在黑名单中")
def test_text_and_code_allowed(self):
for ext in (".py", ".js", ".ts", ".java", ".c", ".cpp", ".go", ".rs",
".md", ".txt", ".json", ".yaml", ".html", ".css", ".sql",
".sh", ".csv", ".log", ".ipynb", ""):
self.assertNotIn(ext, BINARY_EXTS, f"{ext} 不应在黑名单中")
if __name__ == "__main__":
unittest.main()
+123
View File
@@ -0,0 +1,123 @@
// -*- coding: utf-8 -*-
// 公式管线单测:从 app.js 中切出【真实】公式段 + computeSafeLen,在沙箱里执行
// 运行: node tests/test_math_extract.js
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const src = fs.readFileSync(path.join(__dirname, '..', 'ui', 'web', 'app.js'), 'utf8');
// ---- 切出公式段(公式渲染 section 起点 → 全局事件委托 section 之前)----
const secStart = src.indexOf('// ==================== 🌟 公式渲染');
const secEnd = src.indexOf('// ==================== 全局事件委托');
if (secStart < 0 || secEnd < 0 || secEnd <= secStart) {
console.error('FAIL 无法切出公式段 (start=%d end=%d)', secStart, secEnd);
process.exit(1);
}
let mathCode = src.slice(secStart, secEnd);
// ---- 切出 computeSafeLen 函数(行首锚定,避开文档注释里的同名行)----
var _m = src.match(/^function computeSafeLen\(s, stableLen\) \{$/m);
if (!_m) { console.error('FAIL 无法切出 computeSafeLen'); process.exit(1); }
var csStart = _m.index; // 注意:indexOf 会命中文档注释里的同名行,必须用正则的 index
const csEnd = src.indexOf('// 初始化/获取某个容器 div 的增量渲染状态');
if (csStart < 0 || csEnd < 0) { console.error('FAIL 无法切出 computeSafeLen'); process.exit(1); }
mathCode += '\n' + src.slice(csStart, csEnd);
// ---- 沙箱 ----
const sandbox = {
console,
escapeHtml: (t) => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'),
katex: { renderToString: (tex, opts) => '<KATEX d=' + (opts.displayMode ? 1 : 0) + '>' + tex + '</KATEX>' },
marked: { parse: (t) => '<MD>' + String(t), setOptions() {}, bind: null },
};
sandbox.marked.parse.bind = function () { return sandbox.marked.parse; };
vm.createContext(sandbox);
vm.runInContext(mathCode, sandbox, { filename: 'app.js-math-section' });
const extractMath = sandbox.extractMath;
const restoreMath = sandbox.restoreMath;
const findUnclosedMathFrom = sandbox.findUnclosedMathFrom;
const computeSafeLen = sandbox.computeSafeLen;
let pass = 0, fail = 0;
function check(name, cond, extra) {
if (cond) { pass++; console.log('PASS ' + name); }
else { fail++; console.log('FAIL ' + name + (extra !== undefined ? ' | ' + extra : '')); }
}
function itemsOf(text) { return extractMath(text).items; }
// ============ 1) 用户原始样本(部分供应商单括号格式)============
const userSample = '上面是推导结果:\n[\nP_4=\\operatorname{BRF}(M_4,M_5)\n]\n\n[\nP_3=\\operatorname{BRF}(S_3,P_4)\n]\n\n其中:\n[\n\\operatorname{BRF}(L,H)\nL+\n\\Gamma(L,\\operatorname{Up}(H))\n\\odot\n\\Phi(\\operatorname{Up}(H))\n]\n';
{
const ex = extractMath(userSample);
check('用户样本: 抽出 3 个块公式', ex.items.length === 3, 'got=' + ex.items.length);
check('用户样本: 全是 display', ex.items.every(i => i.display));
check('用户样本: 占位符已就位', /@@K[BI]MA/.test(ex.md));
check('用户样本: 无残留括号([ ] 已吞)', !/[\[\]]/.test(ex.md), JSON.stringify(ex.md));
const html = restoreMath(ex.md, ex.items);
check('用户样本: 恢复出 3 个 KATEX 块', (html.match(/<KATEX d=1>/g) || []).length === 3);
check('用户样本: tex 内容正确', ex.items[0].tex.includes('P_4=\\operatorname{BRF}(M_4,M_5)'));
}
// ============ 2) 标准定界符 ============
check('$$ 块', itemsOf('a $$x^2+y^2$$ b').length === 1);
check('$$ 块 display', itemsOf('$$x$$')[0].display === true);
check('\\[ \\] 块', itemsOf('\\[E=mc^2\\]').length === 1);
check('行内 $x^2$', itemsOf('能量 $E=mc^2$ 著名').length === 1);
check('行内非 display', itemsOf('$x^2$')[0].display === false);
check('\\( \\) 行内', itemsOf('\\(a+b\\)').length === 1);
check('多公式同段', itemsOf('$a^2$ 和 $$b^2$$ 及 $c$').length === 3);
// ============ 3) 防误伤 ============
check('编号 [1] 不抽', itemsOf('见[1]参考').length === 0);
check('链接 [text](url) 不抽', itemsOf('[text](http://a.b) 和 [\nfoo\n](x)').length === 0);
check('列表 [a, b] 不抽', itemsOf('[a, b] 数组').length === 0);
check('货币 $1,000 and $2,000 不抽', itemsOf('价格 $1,000 and $2,000').length === 0);
check('单词 $a$ 按变量公式(有意取舍)', itemsOf('花 $a$ 朵').length === 1);
check('多词 $a b$ 不抽', itemsOf('这 $a b$ 个').length === 0);
check('未闭合 $x 不抽', itemsOf('价格 $x 未闭合').length === 0);
check('代码块内 $y$ [0] 不抽', itemsOf('```\nx = $y$\narr[0]\n```').length === 0);
check('行内代码 `$z$` 不抽', itemsOf('使用 `$z$` 变量').length === 0);
check('未闭合围栏内不抽', itemsOf('```\n$w$ 流式中').length === 0);
// ============ 4) 混合定位 ============
{
const ex = extractMath('前文\n\n$$\na=b\n$$\n\n后文 $c$ 尾');
check('混合: 2 公式', ex.items.length === 2);
check('混合: 占位符顺序', ex.md.indexOf('@@KBMA0@@') < ex.md.indexOf('@@KMIA1@@'));
}
// ============ 5) findUnclosedMathFrom ============
check('无公式 → 0', findUnclosedMathFrom('hello\nworld\n') === 0);
check('未闭合 $$ → 位置', findUnclosedMathFrom('a\n$$x+y\n') === 2);
check('闭合 $$ → 0', findUnclosedMathFrom('a\n$$x+y\n$$\n') === 0);
check('未闭合 [ 行 → 位置', findUnclosedMathFrom('a\n[\nx=1\n') === 2);
check('闭合 [..] → 0', findUnclosedMathFrom('a\n[\nx=1\n]\n') === 0);
check('链接 [t](u) 不算开 → 0', findUnclosedMathFrom('a\n[t](u)\n') === 0);
check('行内未闭合 $ → 位置', findUnclosedMathFrom('cost $5 plus') === 5);
check('代码围栏内忽略', findUnclosedMathFrom('```\n$$x\n```\n') === 0);
// ============ 6) computeSafeLen 公式感知回退 ============
{
// “para1\n\n” = 7 字符(5 + 两个换行)。未闭合 [ 块在边界之后 → 稳定区止于 7
const s1 = 'para1\n\n[\nx=1\n';
check('流式: 未闭合 [ 前缀止于空行', computeSafeLen(s1, 0) === 7, 'got=' + computeSafeLen(s1, 0));
check('流式: 增量无进展返回 0', computeSafeLen(s1, 7) === 0);
// 闭合后 → 整个块可固化
const s2 = 'para1\n\n[\nx=1\n]\n\n';
check('流式: 闭合后可固化全部', computeSafeLen(s2, 0) === s2.length, 'got=' + computeSafeLen(s2, 0));
// 关键回退:块边界(空行)在 未闭合 $$ 之后 → 必须回退到 $$ 起点
const s3 = 'a b\n\n$$x +\ny z\n\n';
check('流式: 未闭合 $$ 触发回退到起点', computeSafeLen(s3, 0) === 5, 'got=' + computeSafeLen(s3, 0));
// 闭合 $$ 后不再回退
const s3b = 'a b\n\n$$x +\ny z$$\n\n';
check('流式: 闭合 $$ 可固化全部', computeSafeLen(s3b, 0) === s3b.length, 'got=' + computeSafeLen(s3b, 0));
// 链接不触发回退(candidate 到链接后的空行 15;若误判未闭合会回退到 7)
const s4 = 'para1\n\n[t](u)\n\ndone\n';
check('流式: 链接不触发回退', computeSafeLen(s4, 0) === 15, 'got=' + computeSafeLen(s4, 0));
}
console.log('\n===== ' + pass + ' passed, ' + fail + ' failed =====');
process.exit(fail ? 1 : 0);
+92
View File
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
"""tools/builtin_tools/pdf_reader.py 的单元测试。
用 PyMuPDF 现场生成含文本与内嵌图片的测试 PDF,验证文本结构化提取、
图片提取落地,以及异常分支(文件不存在 / 超过大小上限)。
需在装有 PyMuPDF 的 haocode 环境运行::
python -m unittest discover tests
"""
import os
import sys
import tempfile
import unittest
# 让 tests 目录能 import 到项目根目录下的 tools 包
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
try:
import pymupdf
except ImportError: # 兼容旧版导入名
import fitz as pymupdf # type: ignore
from tools.builtin_tools.pdf_reader import (
extract_pdf_text,
extract_pdf_images,
)
def _make_png_bytes() -> bytes:
"""生成一张 8x8 红色小图的 PNG 字节流。"""
pix = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 8, 8), 0)
pix.set_rect(pix.irect, (255, 0, 0))
data = pix.tobytes("png")
pix = None
return data
def _build_sample_pdf(path: str) -> None:
"""造一个 2 页 PDF:第 1 页含文本 + 图片,第 2 页仅文本。"""
doc = pymupdf.open()
p1 = doc.new_page()
p1.insert_text((72, 72), "Hello PDF page one")
p1.insert_image(pymupdf.Rect(72, 100, 172, 200), stream=_make_png_bytes())
p2 = doc.new_page()
p2.insert_text((72, 72), "Second page text here")
doc.save(path)
doc.close()
class PdfReaderTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.dir = self.tmp.name
self.pdf = os.path.join(self.dir, "sample.pdf")
_build_sample_pdf(self.pdf)
def test_extract_text_structure(self):
text, pages = extract_pdf_text(self.pdf)
self.assertEqual(pages, 2)
self.assertIn("[第 1 页]", text)
self.assertIn("[第 2 页]", text)
self.assertIn("Hello PDF page one", text)
self.assertIn("Second page text here", text)
def test_extract_images(self):
out_dir = os.path.join(self.dir, "imgs")
imgs = extract_pdf_images(self.pdf, out_dir)
self.assertGreaterEqual(len(imgs), 1)
im = imgs[0]
for key in ("page", "index", "abs_path", "mime", "size_kb", "width", "height"):
self.assertIn(key, im)
self.assertEqual(im["page"], 1)
self.assertEqual(im["mime"], "image/png")
self.assertTrue(os.path.isfile(im["abs_path"]))
self.assertGreater(os.path.getsize(im["abs_path"]), 0)
def test_missing_file(self):
with self.assertRaises(ValueError):
extract_pdf_text(os.path.join(self.dir, "nope.pdf"))
def test_oversize(self):
# 用一个极小的上限触发超大分支
with self.assertRaises(ValueError):
extract_pdf_text(self.pdf, max_bytes=10)
if __name__ == "__main__":
unittest.main()
+115
View File
@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
"""
离屏验证:思考/压缩气泡内代码朴素渲染(去"紫+边框"+ 正文代码块不受影响
背景:压缩摘要充满代码,finishTimelineMessage 对全部 pre code 做 hljs 高亮后,
.code-block-wrapper 的浅色语法色(#a626a4 紫)+ 1px 边框 + 语言栏让定格后的
压缩气泡观感"紫色带边框"。修复:.think-content 作用域内代码统一朴素灰、
无边框、无头部、无 min-width;正文气泡代码块保持原样。
"""
import os, sys, json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ["QT_QPA_PLATFORM"] = "offscreen"
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("HAOCODE_RENDER", "software")
from PyQt6.QtWidgets import QApplication # noqa: E402 铁律:WebEngine 先于 QApplication 创建导入
from PyQt6.QtCore import QTimer, QUrl # noqa: E402
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
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)
GRAY = "rgb(119, 119, 119)" # #777
PURPLE = "rgb(166, 38, 164)" # #a626a4(正文代码块关键字色,保持)
SUMMARY = ("## 摘要\n说明文字。\n```python\nimport os\ndef run():\n return \"x\"\n```\n"
"另外 `flowkit` 与 `read_file` 是工具名。")
TEST_JS = r"""
(function(){
try {
var out = {};
var SUM = __SUMMARY__;
// ---------- 压缩气泡:定格 + finish(触发全量 hljs 兜底)后探测 ----------
createMessage('m1','assistant','','Assistant');
compactionStarted('m1','pre_prompt');
compactionFinished('m1',{summary:SUM,before:100,after:50,duration_ms:10,path:'pre_prompt'});
finishMessage('m1');
(function(){
var block = document.querySelector('#m1 .compaction-think');
if (!block) { out.m1 = 'NO-BLOCK'; return; }
var wrap = block.querySelector('.code-block-wrapper');
if (!wrap) { out.m1 = 'NO-WRAP'; return; }
var cs = getComputedStyle(wrap);
out.m1_border = cs.borderTopWidth;
out.m1_minw = cs.minWidth;
var hdr = wrap.querySelector('.code-header');
out.m1_header_display = hdr ? getComputedStyle(hdr).display : 'NO-HDR';
var kw = wrap.querySelector('.hljs-keyword');
out.m1_kw_color = kw ? getComputedStyle(kw).color : 'NO-KW-SPAN';
var pre = wrap.querySelector('pre code');
out.m1_pre_color = pre ? getComputedStyle(pre).color : 'NO-PRE';
var ic = block.querySelector('p code');
out.m1_inline_color = ic ? getComputedStyle(ic).color : 'NO-INLINE';
})();
// ---------- 对照组:正文消息的代码块保持原样(边框+语法色) ----------
createMessage('m2','assistant','','Assistant');
appendToken('m2','前文。\n```python\nimport os\ndef run():\n return "x"\n```\n后文。');
finishMessage('m2');
(function(){
var w = document.querySelector('#m2 .code-block-wrapper');
if (!w) { out.m2 = 'NO-WRAP'; return; }
var cs = getComputedStyle(w);
out.m2_border = cs.borderTopWidth;
var hdr = w.querySelector('.code-header');
out.m2_header_display = hdr ? getComputedStyle(hdr).display : 'NO-HDR';
var kw = w.querySelector('.hljs-keyword');
out.m2_kw_color = kw ? getComputedStyle(kw).color : 'NO-KW-SPAN';
})();
return JSON.stringify(out);
} catch(e) { return 'JSERR:' + e.message + ' @' + ((e.stack||'').split('\n')[1]||''); }
})()
""".replace("__SUMMARY__", json.dumps(SUMMARY, ensure_ascii=False))
app = QApplication(sys.argv)
_js = {"v": None}
view = QWebEngineView(); view.resize(1000, 700)
_index = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "ui", "web", "index.html"))
def _run():
view.page().runJavaScript(TEST_JS, lambda v: _js.update(v=v))
view.loadFinished.connect(lambda ok: QTimer.singleShot(1500, _run))
QTimer.singleShot(9000, app.quit)
view.load(QUrl.fromLocalFile(_index))
app.exec()
try:
r = json.loads(_js["v"])
except Exception:
r = None
print(f"DOM 结果解析失败: {_js['v']!r}", flush=True)
check("T.DOM 返回", isinstance(r, dict), repr(_js["v"])[:300])
if isinstance(r, dict):
# 压缩气泡内:朴素化
check("T.压缩气泡代码块边框=0", r.get("m1_border") == "0px", r)
check("T.压缩气泡代码块min-width=0", r.get("m1_minw") == "0px", r)
check("T.压缩气泡代码头部=隐藏", r.get("m1_header_display") == "none", r)
check("T.压缩气泡关键字色=#777(去紫)", r.get("m1_kw_color") == GRAY, r.get("m1_kw_color"))
check("T.压缩气泡pre代码色=#777", r.get("m1_pre_color") == GRAY, r.get("m1_pre_color"))
check("T.压缩气泡内联代码色=#777(去粉)", r.get("m1_inline_color") == GRAY, r.get("m1_inline_color"))
# 正文对照:保持原样
check("T.正文代码块边框=1px(不变)", r.get("m2_border") == "1px", r)
check("T.正文代码头部=可见(不变)", r.get("m2_header_display") not in ("none", "NO-HDR"), r)
check("T.正文关键字色=#a626a4(不变)", r.get("m2_kw_color") == PURPLE, r.get("m2_kw_color"))
failed = [n for n, ok in RESULTS if not ok]
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)
+255
View File
@@ -0,0 +1,255 @@
# -*- coding: utf-8 -*-
"""
四大工具参数层单测(纯函数级,无 DB/无 UI 依赖)
覆盖本轮修复:
T1-T9 参数归一化 + JSON Schema 校验(全错误上报 / null 归一化 / 轻量强制转换 / bool 漏洞)
T10-T13 edit 参数预处理(legacy 单条 / edits 为 JSON 字符串)
T14-T19 edit 区间规划(唯一性 / 重叠检测 / 空 oldText
T20-T25 read 参数钳制(limit 负数漏洞 / 越界 / 空文件)
T26-T29 write + edit 原子写(无临时文件残留)
T30-T31 before 钩子改参后重新校验
运行: PYTHONIOENCODING=utf-8 python tests/test_tool_params.py
"""
import os
import sys
import json
import glob
import tempfile
import shutil
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.agent.tools import (validate_json_schema, normalize_and_coerce, # noqa: E402
_prepare_edit_args, _plan_edits,
tool_read, tool_write, tool_edit,
execute_tool_call, PreparedToolCall)
from core.agent.types import (AgentTool, AgentToolResult, AbortSignal, # noqa: E402
ToolCall, AgentMessage)
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)
# ======================================================================
# 测试用 schema(与 default_tools() 一致)
# ======================================================================
S_READ = {"type": "object",
"properties": {"path": {"type": "string"},
"offset": {"type": "integer"},
"limit": {"type": "integer"}},
"required": ["path"]}
S_BASH = {"type": "object",
"properties": {"command": {"type": "string"},
"timeout": {"type": "number"}},
"required": ["command"]}
S_EDIT = {"type": "object",
"properties": {
"path": {"type": "string"},
"edits": {"type": "array",
"items": {"type": "object",
"properties": {"oldText": {"type": "string"},
"newText": {"type": "string"}},
"required": ["oldText"]}}},
"required": ["path", "edits"]}
SG = AbortSignal()
# ======================================================================
# T1-T9 校验层
# ======================================================================
def err_of(args, schema):
return validate_json_schema(normalize_and_coerce(args, schema), schema)
check("T1.缺必填参数", err_of({"offset": 1}, S_READ) == "缺少必填参数: path",
err_of({"offset": 1}, S_READ))
check("T2.string 类型错误", "应为 string" in (err_of({"path": 123}, S_READ) or ""),
err_of({"path": 123}, S_READ))
check("T3.bool 不得冒充 integer(旧版漏洞)",
"应为 integer" in (err_of({"path": "a", "offset": True}, S_READ) or ""),
err_of({"path": "a", "offset": True}, S_READ))
check("T4.bool 不得冒充 number(旧版漏洞)",
"应为 number" in (err_of({"command": "x", "timeout": True}, S_BASH) or ""),
err_of({"command": "x", "timeout": True}, S_BASH))
check("T5.整数 1.5 拒绝", "期望 integer" in (err_of({"path": "a", "offset": 1.5}, S_READ) or ""),
err_of({"path": "a", "offset": 1.5}, S_READ))
check("T6.多余参数宽容(与 pi 一致)", err_of({"path": "a", "lines": 10}, S_READ) is None,
err_of({"path": "a", "lines": 10}, S_READ))
_err2 = err_of({"path": 123, "offset": "bad"}, S_READ) or ""
check("T7.多错全量上报(不再只报第 1 个)",
_err2.count(";") >= 1 and "path" in _err2 and "offset" in _err2, _err2)
check("T8.嵌套必填字段",
err_of({"path": "a", "edits": [{"newText": "y"}]}, S_EDIT)
== "参数 edits[0] 缺少必填字段 oldText",
err_of({"path": "a", "edits": [{"newText": "y"}]}, S_EDIT))
check("T9.数组元素类型错误定位到下标",
"edits[1]" in (err_of({"path": "a", "edits": [{"oldText": "x"}, {"oldText": 5}]}, S_EDIT) or ""),
err_of({"path": "a", "edits": [{"oldText": "x"}, {"oldText": 5}]}, S_EDIT))
# ---- 归一化 / 强制转换 ----
check("T10.可选字段 null 被删除(不再误报类型错)",
normalize_and_coerce({"path": "a", "offset": None}, S_READ) == {"path": "a"})
check("T11.数字字符串被转换 \"30\"→30",
normalize_and_coerce({"command": "x", "timeout": "30"}, S_BASH)["timeout"] == 30)
check("T12.integer 字段收 \"1.5\" 保持原值(交由校验报错)",
normalize_and_coerce({"path": "a", "offset": "1.5"}, S_READ)["offset"] == "1.5")
_orig = {"path": "a", "offset": None}
normalize_and_coerce(_orig, S_READ)
check("T13.归一化不修改入参", _orig == {"path": "a", "offset": None}, _orig)
# ======================================================================
# T14-T15 edit 参数预处理
# ======================================================================
check("T14.legacy 单条形式 {oldText,newText} → edits[]",
_prepare_edit_args({"path": "a", "oldText": "x", "newText": "y"})
== {"path": "a", "edits": [{"oldText": "x", "newText": "y"}]})
check("T15.edits 为 JSON 字符串 → 解析为数组",
_prepare_edit_args({"path": "a", "edits": '[{"oldText":"x"}]'})
== {"path": "a", "edits": [{"oldText": "x"}]})
# ======================================================================
# T16-T19 edit 区间规划
# ======================================================================
_e, _sp = _plan_edits("hello world", [{"oldText": "world", "newText": "there"}])
check("T16.唯一命中 → 规划成功", _e is None and _sp[0][0] == 6, (_e, _sp))
check("T17.未命中 → 报错", "未找到匹配文本" in (_plan_edits("abc", [{"oldText": "zz"}])[0] or ""),
_plan_edits("abc", [{"oldText": "zz"}])[0])
check("T18.重复命中 → 报错", "匹配到 2 处" in (_plan_edits("foo foo", [{"oldText": "foo"}])[0] or ""),
_plan_edits("foo foo", [{"oldText": "foo"}])[0])
# 旧版会:两条对原始内容各自唯一 → 校验通过 → 应用时第 2 条已找不到(静默 no-op,仍报“已应用 2 处”)
# 现在必须判定为重叠并整批拒绝
_e2 = _plan_edits("foo bar", [{"oldText": "foo bar", "newText": "foo BAR"},
{"oldText": "bar", "newText": "baz"}])[0]
check("T19.区间重叠被检出(旧版静默 no-op 第 2 条)", "重叠" in (_e2 or ""), _e2)
check("T19b.嵌套重叠被检出",
"重叠" in (_plan_edits("abc", [{"oldText": "abc", "newText": "Z"},
{"oldText": "ab", "newText": "Q"}])[0] or ""))
check("T19d.原文件不存在的 oldText 被拒(对齐 pi:对原始快照匹配)",
"未找到匹配文本" in (_plan_edits("abc", [{"oldText": "ab", "newText": "aX"},
{"oldText": "aXb", "newText": "ZZ"}])[0] or ""))
check("T19c.空 oldText 被拒(旧版空文件会静默插入)",
"不能为空" in (_plan_edits("", [{"oldText": "", "newText": "INJ"}])[0] or ""),
_plan_edits("", [{"oldText": "", "newText": "INJ"}])[0])
# ======================================================================
# T20-T25 read 参数钳制(临时文件,不碰项目数据)
# ======================================================================
_TMP = tempfile.mkdtemp(prefix="haocode_tp_")
CTX = {"cwd": _TMP}
def _mk(name, text):
p = os.path.join(_TMP, name)
with open(p, "w", encoding="utf-8") as f:
f.write(text)
return p
try:
_mk("lines.txt", "".join(f"L{i}\n" for i in range(1, 11))) # 10 行
_mk("empty.txt", "")
r = tool_read("t", {"path": "lines.txt", "limit": -5}, SG, None, CTX)
_t20 = r.as_text()
check("T20.limit 负数不再读全文件(旧版返回 10 行减 5)",
"L1" in _t20 and "L2" not in _t20, _t20.replace("\n", " | ")[:120])
r = tool_read("t", {"path": "lines.txt", "limit": 0}, SG, None, CTX)
check("T21.limit=0 钳到 1(旧版返回空块 + 无意义脚注)",
"L1" in r.as_text() and "L2" not in r.as_text(), r.as_text().replace("\n", " | ")[:120])
r = tool_read("t", {"path": "lines.txt", "offset": 999}, SG, None, CTX)
check("T22.offset 越界 → 明确提示(旧版'已显示 999998 行'",
"超出文件范围" in r.as_text() and "共 10 行" in r.as_text(), r.as_text())
r = tool_read("t", {"path": "empty.txt"}, SG, None, CTX)
check("T23.空文件 → 明确提示", "文件为空" in r.as_text(), r.as_text())
r = tool_read("t", {"path": ""}, SG, None, CTX)
check("T24.path 为空 → 'path 不能为空'(旧版报'文件不存在: <cwd>'",
r.is_error and "不能为空" in r.as_text(), r.as_text())
r = tool_read("t", {"path": "lines.txt", "offset": 3, "limit": 2}, SG, None, CTX)
check("T25.正常行窗口不变(offset=3,limit=2 → L3,L4",
"L3" in r.as_text() and "L4" in r.as_text() and "L5" not in r.as_text()
and "offset=5" in r.as_text(), r.as_text().replace("\n", " | ")[:140])
# ==================================================================
# T26-T29 原子写 / edit 端到端
# ==================================================================
r = tool_write("t", {"path": "w.txt", "content": "a\nb\n"}, SG, None, CTX)
left = glob.glob(os.path.join(_TMP, ".hocode_w_*"))
check("T26.write 成功且无临时文件残留", not r.is_error and not left, (r.as_text(), left))
tool_write("t", {"path": "w2.txt", "content": "foo bar"}, SG, None, CTX)
r = tool_edit("t", {"path": "w2.txt",
"edits": [{"oldText": "foo bar", "newText": "foo BAR"},
{"oldText": "bar", "newText": "baz"}]}, SG, None, CTX)
check("T27.重叠 edit 整批拒绝且文件未被改动",
r.is_error and "重叠" in r.as_text(), r.as_text())
r = tool_edit("t", {"path": "w.txt",
"edits": [{"oldText": "a", "newText": "A"},
{"oldText": "b", "newText": "B"}]}, SG, None, CTX)
with open(os.path.join(_TMP, "w.txt"), encoding="utf-8") as f:
after = f.read()
check("T28.多条不重叠 edit 一次性应用(对原始快照)",
not r.is_error and after == "A\nB\n", (r.as_text(), repr(after)))
r = tool_edit("t", {"path": "w.txt", "edits": [{"oldText": "", "newText": "X"}]}, SG, None, CTX)
with open(os.path.join(_TMP, "w.txt"), encoding="utf-8") as f:
after = f.read()
check("T29.空 oldText 被拒且文件未变", r.is_error and after == "A\nB\n",
(r.as_text(), repr(after)))
# ==================================================================
# T30-T31 before 钩子改参后重新校验
# ==================================================================
def _echo(args):
return AgentToolResult.text(f"echo:{json.dumps(args, sort_keys=True)}")
def _echo5(tool_call_id, args, signal, on_update, ctx):
return _echo(args)
class _Cfg:
tool_context = {"cwd": _TMP}
before_tool_call = None
after_tool_call = None
etool = AgentTool(name="echo", description="d", parameters=S_READ, execute=_echo5)
tc = ToolCall(id="c1", name="echo", arguments={"path": "a"})
cfg_bad = _Cfg()
cfg_bad.before_tool_call = lambda payload, sig: {"args": {"path": 123}}
res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}),
AgentMessage(role="assistant", content=""), cfg_bad, SG, None)
check("T30.before 钩子返回非法参数 → 拒绝执行(pi 同款重校验)",
res.is_error and "校验失败" in res.as_text(), res.as_text())
cfg_ok = _Cfg()
cfg_ok.before_tool_call = lambda payload, sig: {"args": {"path": "b", "offset": "7"}}
res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}),
AgentMessage(role="assistant", content=""), cfg_ok, SG, None)
check("T31.before 钩子改参后归一化生效(\"7\"→7",
not res.is_error and '"offset": 7' in res.as_text(), res.as_text())
cfg_blk = _Cfg()
cfg_blk.before_tool_call = lambda payload, sig: {"block": True, "reason": "nope"}
res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}),
AgentMessage(role="assistant", content=""), cfg_blk, SG, None)
check("T32.before 钩子 block 语义不变", res.is_error and "nope" in res.as_text(), res.as_text())
finally:
shutil.rmtree(_TMP, ignore_errors=True)
failed = [n for n, ok in RESULTS if not ok]
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)
+129
View File
@@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
"""
WebView2 守卫单测 —— 锁死 T0 级事故不再复发
事故链(已发生):
每个 MainWindow()(含 offscreen 测试)→ get_environment()
→ 无条件 `taskkill /F /IM msedgewebview2.exe`
→ 把【正在运行的生产 app】的 WebView2 浏览器进程一并杀掉
→ 它的 controller 变 disposedset_bounds 报 0x8007139F
→ DOM 照渲染但视觉层永久空白(“选中会话不渲染核心内容”)
覆盖:
T1 无头环境(QT_QPA_PLATFORM=offscreen)→ 不启用 WebView2
T2 HAOCODE_FORCE_QTWEBENGINE=1 → 不启用
T3 桌面平台(QT_QPA_PLATFORM=windows)→ 允许
T4 单实例锁:持有者独占;【另一个进程】拿不到(跨进程互斥,是真守卫)
T5 offscreen 下 get_environment() 直接返回 None(根本不碰共享 profile
T6 同一进程重复 acquire 幂等返回 True(不会把自己锁死)
运行: PYTHONIOENCODING=utf-8 python tests/test_wv2_guard.py
"""
import os
import sys
import tempfile
import subprocess
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 铁律:测试用独立锁文件,不得与正在运行的 app 争 data/app_instance.lock
# (否则断言会依赖“app 是否在跑”)
_LOCK_TMP = os.path.join(tempfile.gettempdir(), "haocode_wv2guard_%d.lock" % os.getpid())
os.environ["HAOCODE_INSTANCE_LOCK_FILE"] = _LOCK_TMP
if os.path.exists(_LOCK_TMP):
try:
os.remove(_LOCK_TMP)
except Exception:
pass
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)
import core.webview2 as W # noqa: E402
_ORIG = dict(os.environ)
try:
# ---------------- T1/T2/T3: 环境守卫 ----------------
os.environ["QT_QPA_PLATFORM"] = "offscreen"
os.environ.pop("HAOCODE_FORCE_QTWEBENGINE", None)
check("T1.1 offscreen → 不允许 WebView2", W._wv2_allowed_here() is False)
os.environ["QT_QPA_PLATFORM"] = "windows"
check("T1.2 windows → 允许 WebView2", W._wv2_allowed_here() is True)
for p in ("minimal", "minimalegl", "vnc", "wayland", "embedded", "offscreen"):
os.environ["QT_QPA_PLATFORM"] = p
if W._wv2_allowed_here() is not False:
check(f"T1.3 平台 {p} 应被拒绝", False, p)
break
else:
check("T1.3 非 windows 平台全部拒绝", True)
os.environ["QT_QPA_PLATFORM"] = "windows"
os.environ["HAOCODE_FORCE_QTWEBENGINE"] = "1"
check("T2.1 强制回退开关生效", W._wv2_allowed_here() is False)
os.environ.pop("HAOCODE_FORCE_QTWEBENGINE", None)
# ---------------- T4: 跨进程单实例互斥(核心) ----------------
os.environ["QT_QPA_PLATFORM"] = "windows"
W._INSTANCE_LOCK["fh"] = None # 从头开始,避免受本进程历史影响
first = W.acquire_instance_lock()
check("T4.1 首个 acquires 成功", first is True, str(first))
check("T4.2 同进程重复 acquire 幂等为 True",
W.acquire_instance_lock() is True)
code = ("import sys; sys.path.insert(0, r'%s');"
"import core.webview2 as W; print(W.acquire_instance_lock())"
% os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
r = subprocess.run([sys.executable, "-c", code], capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=60)
got = (r.stdout or "").strip().splitlines()
got = got[-1] if got else ""
check("T4.3 另一进程拿不到锁(互斥生效)", got == "False", f"stdout={got!r} err={(r.stderr or '')[:120]}")
# ---------------- T5: offscreen 下 get_environment 直接 None ----------------
os.environ["QT_QPA_PLATFORM"] = "offscreen"
W._env = None
env = W.get_environment(None)
check("T5.1 offscreen 下 get_environment → None(不碰共享 profile", env is None)
check("T5.2 被守卫拦下后 _env 仍为空", W._env is None)
# ---------------- T6: 已有实例在跑时 → 不启用 WebView2 ----------------
# 此时本进程仍持有锁;用子进程模拟“后来的实例”
code2 = ("import sys; sys.path.insert(0, r'%s');"
"import core.webview2 as W; print(W.get_environment(None) is None)"
% os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
r2 = subprocess.run([sys.executable, "-c", code2], capture_output=True,
text=True, encoding="utf-8", errors="replace",
env={**os.environ, "QT_QPA_PLATFORM": "windows"},
timeout=60)
out2 = (r2.stdout or "").strip().splitlines()
out2 = out2[-1] if out2 else ""
check("T6.1 已有实例持锁 → 后来的实例拿到 None(回落 QtWebEngine,不 taskkill",
out2 == "True", f"stdout={out2!r} err={(r2.stderr or '')[:160]}")
finally:
try:
fh = W._INSTANCE_LOCK.get("fh")
if fh is not None:
fh.close()
except Exception:
pass
try:
if os.path.exists(_LOCK_TMP):
os.remove(_LOCK_TMP)
except Exception:
pass
os.environ.clear()
os.environ.update(_ORIG)
failed = [n for n, ok in RESULTS if not ok]
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)
+186
View File
@@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
"""会话模式弹窗 · 可视化调参工具
运行: C:\\Users\\14890\\miniconda3\\envs\\haocode\\python.exe tests\\tune_mode_popup.py
- 左侧:调参窗(拖动/修改参数 → 右侧弹窗实时重渲染)
- 右侧:真实的 SessionModePopup 本体(独立窗口,点调参窗不会消失)
- 满意后点「确定」→ 参数 JSON 写入 data/mode_popup_tune.json → 自动退出
"""
import os
import sys
import json
import traceback
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("HAOCODE_RENDER", "software")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QFormLayout, QHBoxLayout, QSpinBox,
QPushButton, QCheckBox, QMessageBox,
)
from PyQt6.QtCore import Qt # noqa: E402
from PyQt6.QtGui import QPixmap # noqa: E402
from ui.views.main_window import SessionModePopup, _ModeRow, _popup_svg_path # noqa: E402
DEFAULTS = {
"icon_size": 14, # 图标尺寸
"name_font": 12, # 名称字号
"desc_font": 12, # 描述字号
"row_margin_v": 8, # 行上下内边距
"row_margin_h": 10, # 行左右内边距
"icon_text_gap": 12, # 图标-文字间距
"name_desc_gap": 4, # 名称/描述行间距
"row_radius": 8, # 行圆角
"popup_width": 320, # 弹窗宽度
"container_margin": 4, # 容器边距
}
RANGES = {
"icon_size": (8, 48), "name_font": (10, 20), "desc_font": (9, 18),
"row_margin_v": (2, 20), "row_margin_h": (4, 24), "icon_text_gap": (4, 24),
"name_desc_gap": (0, 8), "row_radius": (0, 16), "popup_width": (240, 420),
"container_margin": (0, 16),
}
CN = {
"icon_size": "图标尺寸 (px)",
"name_font": "名称字号 (px)",
"desc_font": "描述字号 (px)",
"row_margin_v": "行上下内边距 (px)",
"row_margin_h": "行左右内边距 (px)",
"icon_text_gap": "图标-文字间距 (px)",
"name_desc_gap": "名称/描述行距 (px)",
"row_radius": "行圆角 (px)",
"popup_width": "弹窗宽度 (px)",
"container_margin": "容器边距 (px)",
}
FONT_STACK = '"HarmonyOS Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", sans-serif'
class Tuner:
def __init__(self):
self.app = QApplication(sys.argv)
self.params = dict(DEFAULTS)
# ---- 弹窗本体(真实类):去掉 Popup 标志 → 独立普通无边框窗,不随失焦消失 ----
self.popup = SessionModePopup(None)
self.popup.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint)
self.rows = self.popup.container.findChildren(_ModeRow)
icon_by_mode = {m: _popup_svg_path(ic) for m, _, _, ic in SessionModePopup.MODES}
for r in self.rows:
r._tune_pix = QPixmap(icon_by_mode[r.mode]) # 原始分辨率,重缩不糊
screen = self.app.primaryScreen().availableGeometry()
self.px = screen.center().x() - 300
self.py = screen.center().y() - 200
self.popup.move(self.px, self.py)
self.popup.show()
# ---- 调参窗 ----
self.win = QWidget()
self.win.setWindowTitle("会话模式弹窗调参 —— 调好后点「确定」")
form = QFormLayout(self.win)
form.setSpacing(8)
self.spins = {}
for key, (lo, hi) in RANGES.items():
sp = QSpinBox()
sp.setRange(lo, hi)
sp.setValue(self.params[key])
sp.valueChanged.connect(lambda v, k=key: self.on_change(k, v))
self.spins[key] = sp
form.addRow(CN[key], sp)
self.chk_selected = QCheckBox("显示选中态(预览)")
self.chk_selected.toggled.connect(lambda _: self.apply_all())
form.addRow(self.chk_selected)
btns = QHBoxLayout()
b_reset = QPushButton("重置默认")
b_reset.clicked.connect(self.reset)
b_ok = QPushButton("✅ 确定(写入日志并退出)")
b_ok.setStyleSheet("font-weight: bold;")
b_ok.clicked.connect(self.finish)
btns.addWidget(b_reset)
btns.addWidget(b_ok)
form.addRow(btns)
self.win.resize(360, self.win.sizeHint().height())
self.win.move(self.px - 380, self.py)
self.win.show()
self.apply_all()
# ---------- 实时渲染 ----------
def on_change(self, k, v):
self.params[k] = v
self.apply_all()
def apply_all(self):
p = self.params
popup = self.popup
popup.setFixedWidth(p["popup_width"])
cl = popup.container.layout()
cl.setContentsMargins(p["container_margin"], p["container_margin"],
p["container_margin"], p["container_margin"])
sel = self.chk_selected.isChecked()
for r in self.rows:
r.lbl_icon.setFixedSize(p["icon_size"], p["icon_size"])
r.lbl_icon.setPixmap(r._tune_pix.scaled(
p["icon_size"], p["icon_size"],
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation))
lay = r.layout()
lay.setContentsMargins(p["row_margin_h"], p["row_margin_v"],
p["row_margin_h"] + 2, p["row_margin_v"])
lay.setSpacing(p["icon_text_gap"])
txt = lay.itemAt(1).layout()
if txt is not None:
txt.setSpacing(p["name_desc_gap"])
name_color = "#1a73e8" if sel else "#333333"
r.lbl_name.setStyleSheet(
f"font-family: {FONT_STACK}; font-size: {p['name_font']}px;"
f"font-weight: bold; color: {name_color}; background: transparent;")
r.lbl_desc.setStyleSheet(
f"font-family: {FONT_STACK}; font-size: {p['desc_font']}px;"
f"color: #888888; background: transparent;")
bg = "#e8f0fe" if sel else "transparent"
r.setStyleSheet(f"background-color: {bg}; border-radius: {p['row_radius']}px;")
self.app.processEvents()
popup.adjust_popup_height()
popup.adjustSize()
def reset(self):
self.params = dict(DEFAULTS)
for k, sp in self.spins.items():
sp.blockSignals(True)
sp.setValue(self.params[k])
sp.blockSignals(False)
self.apply_all()
# ---------- 确定 → 写日志 ----------
def finish(self):
log = os.path.join(os.path.dirname(__file__), "..", "data", "mode_popup_tune.json")
os.makedirs(os.path.dirname(log), exist_ok=True)
with open(log, "w", encoding="utf-8") as f:
json.dump(self.params, f, ensure_ascii=False, indent=2)
print("TUNE_LOG=" + os.path.abspath(log), flush=True)
print(json.dumps(self.params, ensure_ascii=False), flush=True)
self.app.quit()
def main():
try:
Tuner()
QApplication.instance().exec()
except Exception:
err = traceback.format_exc()
try:
with open(os.path.join(os.path.dirname(__file__), "..", "data",
"tune_error.log"), "w", encoding="utf-8") as f:
f.write(err)
except OSError:
pass
QMessageBox.critical(None, "调参工具启动失败", err)
sys.exit(1)
if __name__ == "__main__":
main()
+399
View File
@@ -0,0 +1,399 @@
# -*- coding: utf-8 -*-
"""模型选择弹窗 · 可视化调参工具
运行: C:\\Users\\14890\\miniconda3\\envs\\haocode\\python.exe tests\\tune_model_popup.py
- 左侧:调参窗(改参数 → 右侧弹窗实时重渲染)
- 右侧:真实的 ModelSelectPopup 本体(独立窗口,点调参窗不会消失)
- 满意后点「确定」→ 参数 JSON 写入 data/model_popup_tune.json → 自动退出
"""
import os
import sys
import json
import traceback
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
os.environ.setdefault("HAOCODE_RENDER", "software")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QFormLayout, QHBoxLayout, QSpinBox,
QPushButton, QCheckBox, QLabel, QMessageBox, QComboBox,
)
from PyQt6.QtCore import Qt, QSize # noqa: E402
from PyQt6.QtGui import QPixmap, QIcon, QPainter, QFont, QFontDatabase # noqa: E402
from ui.views.main_window import ModelSelectPopup, MainWindow, _popup_svg_path # noqa: E402 (先于 QApplication)
DEFAULTS = {
# —— 字体 ——
"font_family": "Microsoft YaHei", # 字体族(下拉选择;调参确认值)
# —— 模型行 ——
"item_font": 13, # 模型名字号(经 list_widget.setFont 生效)
"model_item_h": 27, # 模型行高
"model_icon": 16, # 模型图标尺寸
"model_icon_pad": 34, # 模型行左边距(图标透明左垫,仅作用模型行)
# —— 供应商行 ——
"header_height": 38, # 供应商行高(= widget 高;默认 38 = 当前代码实际行高)
"header_font": 13, # 供应商名字号
"count_font": 10, # 数量字号
"provider_svg": "provider.svg", # 供应商图标(3 个 SVG 可切换)
"header_icon": 16, # 供应商图标尺寸
"header_icon_pad": 0, # 供应商图标水平位置(左侧留白,仅推图标与后续内容)
"header_icon_pad_v": 1, # 供应商图标垂直位置(正=下移,负=上移)
"arrow_size": 13, # 展开/收起箭头尺寸
"header_hpad_l": 3, # 供应商行左边距(头部按钮左 margin)
# —— 整体 ——
"popup_width": 340, # 弹窗宽度
"container_vmargin": 4, # 容器上下边距
}
RANGES = {
"item_font": (10, 20),
"model_item_h": (18, 40), "model_icon": (8, 28),
"model_icon_pad": (0, 48), "header_height": (20, 60), "header_font": (9, 18),
"count_font": (8, 16), "header_icon": (10, 28), "header_icon_pad": (0, 30),
"header_icon_pad_v": (-10, 10),
"arrow_size": (8, 20),
"header_hpad_l": (0, 20),
"popup_width": (260, 420), "container_vmargin": (0, 12),
}
CN = {
"font_family": "字体(下拉选择)",
"item_font": "模型名字号 (px)",
"model_item_h": "模型行高 (px)",
"model_icon": "模型图标尺寸 (px)", "model_icon_pad": "模型行左边距 (px)",
"header_height": "供应商行高 (px)", "header_font": "供应商名字号 (px)",
"count_font": "数量字号 (px)", "header_icon": "供应商图标尺寸 (px)",
"provider_svg": "供应商图标(SVG 切换)",
"header_icon_pad": "供应商图标水平位置 (px)",
"header_icon_pad_v": "供应商图标垂直位置 (px,正=下移)",
"arrow_size": "箭头尺寸 (px)", "header_hpad_l": "供应商行左边距 (px)",
"popup_width": "弹窗宽度 (px)",
"container_vmargin": "容器上下边距 (px)",
}
SECTIONS = [
("字体", ["font_family"]),
("模型行", ["item_font", "model_item_h", "model_icon", "model_icon_pad"]),
("供应商行", ["header_height", "header_font", "count_font",
"provider_svg", "header_icon", "header_icon_pad",
"header_icon_pad_v", "arrow_size", "header_hpad_l"]),
("整体", ["popup_width", "container_vmargin"]),
]
# 供应商图标候选(文件名, 显示名)
PROVIDER_SVGS = [
("provider.svg", "图标 1 · 服务器"),
("provider2.svg", "图标 2 · 二级服务器"),
("provider3.svg", "图标 3 · 服务器(细线)"),
]
FONT_STACK = '"HarmonyOS Sans SC", "Microsoft YaHei", "Noto Sans SC", sans-serif'
QSS_TMPL = """
* {
font-family: FONT_STACK;
}
#popup_container {
background-color: #ffffff;
border: 1px solid #dcdcdc;
border-radius: 12px;
}
#group_toggle_btn { background: transparent; border: none; }
#group_toggle_btn:hover { background-color: #f2f5f9; border-radius: 6px; }
#model_list {
border: none;
background: transparent;
outline: none;
}
#model_list::item {
font-family: FONT_STACK;
color: #333333;
font-weight: normal;
/* 实测本 Qt/PyQt6 构建下 ::item 仅 background-color/color 生效;
font-size/font 缩写/font-family/margin/padding/border-radius 全部无效——
字号走 setFont,行高走 sizeHint,边距走图标透明垫/按钮 margin */
}
#model_list::item:hover {
background-color: #f0f4f9;
color: #111111;
}
#model_list::item:selected {
background-color: #e8f0fe;
color: #1a73e8;
font-weight: bold;
}
QScrollBar:vertical {
border: none;
background: transparent;
width: 5px;
margin: 12px 2px;
}
QScrollBar::handle:vertical {
background: #d0d0d0;
min-height: 20px;
border-radius: 3px;
}
QScrollBar::handle:vertical:hover {
background: #a0a0a0;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px;
}
"""
class Tuner:
def __init__(self):
self.app = QApplication(sys.argv)
self.params = dict(DEFAULTS)
# ---- 弹窗本体(真实类 + 真实 config + 真实祖先上下文) ----
# ⚠️ 保真关键:实际 App 中弹窗是 MainWindow 的子部件,会继承其全局样式表
# QListWidget::item 的 padding/margin/选中蓝条等);若 parent=None 渲染,
# 就会出“调试与实际不符”的矛盾 → 借 MainWindow.setup_stylesheet 把真实
# 样式表套到宿主上,弹窗挂在宿主下渲染(与生产同构)
self._host = QWidget()
MainWindow.setup_stylesheet(self._host)
self._host.resize(1, 1)
self._host.move(-200, -200)
self._host.show()
cfg_path = os.path.join(os.path.dirname(__file__), "..", "data", "config.json")
with open(cfg_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
self.popup = ModelSelectPopup(self._host, cfg)
self.popup.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint)
self.list = self.popup.list_widget
self._tag_headers()
screen = self.app.primaryScreen().availableGeometry()
self.px = screen.center().x() - 200
self.py = screen.center().y() - 300
self.popup.move(self.px, self.py)
self.popup.show()
# ---- 调参窗 ----
self.win = QWidget()
self.win.setWindowTitle("模型选择弹窗调参 —— 调好后点「确定」")
form = QFormLayout(self.win)
form.setSpacing(7)
self.spins = {}
self.combos = {}
self.broken = set() # 用户标记的“不生效”参数
for title, keys in SECTIONS:
form.addRow(QLabel(f"—— {title} ——"))
for key in keys:
mark = QPushButton("不生效")
mark.setCheckable(True)
mark.setFixedWidth(58)
mark.setCursor(Qt.CursorShape.PointingHandCursor)
mark.setStyleSheet("""
QPushButton { font-size: 11px; color: #888888; border: 1px solid #cccccc;
border-radius: 4px; background: #fafafa; }
QPushButton:checked { color: #ffffff; background: #d93025; border-color: #d93025; }
""")
mark.toggled.connect(lambda on, k=key: self.on_mark(k, on))
row = QHBoxLayout()
row.setSpacing(4)
if key == "font_family":
combo = QComboBox()
fams = ["HarmonyOS Sans SC", "Microsoft YaHei", "Noto Sans SC",
"Segoe UI", "Arial", "Consolas", "SimHei", "PingFang SC"]
seen = set()
for fam in fams + sorted(QFontDatabase.families()):
if fam and fam not in seen:
seen.add(fam)
combo.addItem(fam)
combo.setCurrentText(self.params[key])
combo.currentTextChanged.connect(lambda v, k=key: self.on_change(k, v))
self.combos[key] = combo
row.addWidget(combo, 1)
elif key == "provider_svg":
combo = QComboBox()
for fname, cname in PROVIDER_SVGS:
combo.addItem(cname, fname)
combo.setCurrentIndex(max(combo.findData(self.params[key]), 0))
combo.currentIndexChanged.connect(
lambda i, k=key, c=combo:
self.on_change(k, c.itemData(i) or "provider.svg"))
self.combos[key] = combo
row.addWidget(combo, 1)
else:
lo, hi = RANGES[key]
sp = QSpinBox()
sp.setRange(lo, hi)
sp.setValue(self.params[key])
sp.valueChanged.connect(lambda v, k=key: self.on_change(k, v))
self.spins[key] = sp
row.addWidget(sp, 1)
row.addWidget(mark)
form.addRow(CN[key], row)
self.chk_selected = QCheckBox("显示选中行(预览)")
self.chk_selected.toggled.connect(self.on_toggle_selected)
form.addRow(self.chk_selected)
btns = QHBoxLayout()
b_reset = QPushButton("重置默认")
b_reset.clicked.connect(self.reset)
b_ok = QPushButton("✅ 确定(写入日志并退出)")
b_ok.setStyleSheet("font-weight: bold;")
b_ok.clicked.connect(self.finish)
btns.addWidget(b_reset)
btns.addWidget(b_ok)
form.addRow(btns)
self.win.resize(380, min(self.win.sizeHint().height(), screen.height() - 80))
self.win.move(max(20, self.px - 400), self.py)
self.win.show()
self.apply_all()
# ---------- 启动时给头部标签打角色标签(此时尺寸还是默认值,判定可靠) ----------
def _tag_headers(self):
for g in self.popup._groups:
hwd = self.list.itemWidget(g["header_item"])
for lbl in hwd.findChildren(QLabel):
txt = lbl.text()
if txt == "":
# 默认: 供应商图标 16×16, 箭头 12×12 → 启动时按尺寸区分(此后不再变判定基准)
lbl.setProperty("_role", "icon" if lbl.size().width() >= 14 else "chevron")
elif txt.isdigit():
lbl.setProperty("_role", "count")
else:
lbl.setProperty("_role", "name")
# ---------- 实时渲染 ----------
def on_mark(self, k, on):
if on:
self.broken.add(k)
else:
self.broken.discard(k)
def on_change(self, k, v):
self.params[k] = v
self.apply_all()
def _rebuild_icons(self):
p = self.params
pop = self.popup
pop._provider_pixmap = QPixmap(_popup_svg_path(p.get("provider_svg", "provider.svg"))).scaled(
p["header_icon"], p["header_icon"],
Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
pop._arrow_expanded = QIcon(_popup_svg_path("chevron_down.svg")).pixmap(p["arrow_size"], p["arrow_size"])
pop._arrow_collapsed = QIcon(_popup_svg_path("chevron_right.svg")).pixmap(p["arrow_size"], p["arrow_size"])
m = QPixmap(_popup_svg_path("model.svg")).scaled(
p["model_icon"], p["model_icon"],
Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
pad_w = p["model_icon"] + p["model_icon_pad"]
_pad = QPixmap(pad_w, p["model_icon"])
_pad.fill(Qt.GlobalColor.transparent)
_pp = QPainter(_pad)
_pp.drawPixmap(p["model_icon_pad"], 0, m)
_pp.end()
pop._model_icon = QIcon(_pad)
# 关键:item 持有 QIcon 的拷贝,类属性替换后必须逐个 setIcon 才会更新(实测)
for g in pop._groups:
for it in g["model_items"]:
it.setIcon(pop._model_icon)
self.list.setIconSize(QSize(pad_w, p["model_icon"]))
def apply_all(self):
p = self.params
pop = self.popup
family = p["font_family"]
stack = f'"{family}", "Microsoft YaHei", "Noto Sans SC", sans-serif'
pop.setFixedWidth(p["popup_width"])
pop.container_layout.setContentsMargins(0, p["container_vmargin"], 0, p["container_vmargin"])
self._rebuild_icons()
for g in pop._groups:
hwd = self.list.itemWidget(g["header_item"])
hwd.setFixedHeight(p["header_height"])
g["header_item"].setSizeHint(QSize(0, p["header_height"])) # 行高 = widget 高
btns = hwd.findChildren(QPushButton)
if btns:
bl = btns[0].layout()
if bl is not None:
bl.setContentsMargins(p["header_hpad_l"], 0, 8, 0)
for lbl in hwd.findChildren(QLabel):
role = lbl.property("_role")
if role == "icon":
pad = p["header_icon_pad"]
pv = p["header_icon_pad_v"]
# 2 倍 margin:抵消 label 行内居中吸收的一半增高 → 1:1 位移
top, bot = 2 * max(pv, 0), 2 * max(-pv, 0)
lbl.setFixedSize(p["header_icon"] + pad, p["header_icon"] + top + bot)
lbl.setContentsMargins(pad, top, 0, bot)
lbl.setPixmap(pop._provider_pixmap)
elif role == "chevron":
lbl.setFixedSize(p["arrow_size"], p["arrow_size"])
lbl.setPixmap(pop._arrow_expanded if g["expanded"] else pop._arrow_collapsed)
elif role == "name":
lbl.setStyleSheet(
f"font-family: {stack}; color: #555555; font-weight: normal;"
f"font-size: {p['header_font']}px; letter-spacing: 1px; background: transparent;")
elif role == "count":
lbl.setStyleSheet(
f"font-family: {stack}; color: #888888;"
f"font-size: {p['count_font']}px; font-weight: normal; background: transparent;")
for it in g["model_items"]:
it.setSizeHint(QSize(0, p["model_item_h"]))
pop.setStyleSheet(QSS_TMPL
.replace("FONT_STACK", stack))
# 模型行字体:widget 级 setFontQSS ::item 的 font-size/缩写都不可靠,实测)
_f = self.list.font()
_f.setPixelSize(p["item_font"])
_f.setFamily(family)
self.list.setFont(_f)
self.app.processEvents()
pop.adjust_popup_height()
pop.adjustSize()
def on_toggle_selected(self, on):
if on:
for g in self.popup._groups:
if g["model_items"] and not g["model_items"][0].isHidden():
self.list.setCurrentItem(g["model_items"][0])
break
else:
self.list.clearSelection()
def reset(self):
self.params = dict(DEFAULTS)
for k, sp in self.spins.items():
sp.blockSignals(True)
sp.setValue(self.params[k])
sp.blockSignals(False)
for k, cb in self.combos.items():
cb.blockSignals(True)
cb.setCurrentText(self.params[k])
cb.blockSignals(False)
self.apply_all()
# ---------- 确定 → 写日志 ----------
def finish(self):
log = os.path.join(os.path.dirname(__file__), "..", "data", "model_popup_tune.json")
os.makedirs(os.path.dirname(log), exist_ok=True)
payload = dict(self.params)
payload["not_working"] = sorted(self.broken) # 用户标记的不生效参数
with open(log, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print("TUNE_LOG=" + os.path.abspath(log), flush=True)
print(json.dumps(payload, ensure_ascii=False), flush=True)
self.app.quit()
def main():
try:
Tuner()
QApplication.instance().exec()
except Exception:
err = traceback.format_exc()
try:
with open(os.path.join(os.path.dirname(__file__), "..", "data",
"tune_error.log"), "w", encoding="utf-8") as f:
f.write(err)
except OSError:
pass
QMessageBox.critical(None, "调参工具启动失败", err)
sys.exit(1)
if __name__ == "__main__":
main()
+151
View File
@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
"""公式渲染端到端验证:真实会话消息 → 真实前端管线 → DOM 断言
运行: PYTHONIOENCODING=utf-8 QT_QPA_PLATFORM=offscreen python tests/verify_math_render.py
铁律:不污染真实 DB —— 先把 data/chat_history.db 复制到临时文件,再指向副本。
"""
import os
import sys
import json
import time
import shutil
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"
_REAL_DB = os.path.join(os.path.dirname(__file__), "..", "data", "chat_history.db")
_TMP_DB = os.path.join(tempfile.gettempdir(), f"haocode_verify_math_{os.getpid()}.db")
shutil.copyfile(_REAL_DB, _TMP_DB)
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = _TMP_DB
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
results = []
win = {"w": None, "sid": None}
JS_QUERY = r"""(function(){
var scope = document.getElementById('chat-container') || document.body;
var q = function(s){ return scope.querySelectorAll(s).length; };
// 只取助手消息(user 消息按设计是纯文本,本来就不渲染公式)
var asst = scope.querySelectorAll('.message-wrapper.assistant');
var asstScope = asst.length ? asst[asst.length - 1] : scope;
// 可见文本:剔除 katex-mathmlCSS 视觉隐藏的 MathML 层,内部保留原始 tex 属正常)
var clone = asstScope.cloneNode(true);
clone.querySelectorAll('.katex-mathml').forEach(function(e){ e.parentNode.removeChild(e); });
var visTxt = clone.textContent || '';
var codeTxt = '';
asstScope.querySelectorAll('pre, code').forEach(function(e){ codeTxt += e.textContent + '\n'; });
return {
katex: q('.katex'),
display: q('.katex-display'),
mathml: q('.katex-mathml'),
vislayer: q('.katex-html'),
leakedPlaceholder: visTxt.indexOf('@@K') !== -1,
rawBackslash: visTxt.indexOf('\\operatorname') !== -1,
bareBracketFormula: visTxt.indexOf('P_4=\\operatorname') !== -1,
codeHasDollar: codeTxt.indexOf('$x + y$') !== -1,
codeHasArr: codeTxt.indexOf('arr[0]') !== -1
};
})()"""
def check(name, cond, extra=""):
print((" PASS " if cond else " FAIL ") + name + ((" | " + str(extra)) if extra else ""))
results.append(bool(cond))
def run_js(js, timeout_s=15):
r = {"v": None, "d": False}
def cb(val):
if isinstance(val, str):
try:
val = json.loads(val)
except Exception:
pass
r["v"] = val
r["d"] = True
b = win["w"].browser
if hasattr(b, "execute_js_async"):
b.execute_js_async(js, cb)
else:
b.page().runJavaScript(js, cb)
t0 = time.time()
while not r["d"] and time.time() - t0 < timeout_s:
app.processEvents()
time.sleep(0.05)
return r["v"]
def boot():
w = MainWindow()
win["w"] = w
sid = None
for s in w.db.get_all_sessions():
if s.get("title") == "公式渲染验收":
sid = s["id"]
break
if not sid:
print("FAIL 未找到「公式渲染验收」会话(先跑 tests/inject_math_demo.py")
app.quit()
return
win["sid"] = sid
print(f"会话: {sid}")
w.load_messages_to_web(sid)
QTimer.singleShot(9000, phase_check)
def phase_check():
for _ in range(40):
if run_js("window.jsReady === true ? 1 : 0", timeout_s=3) == 1:
break
time.sleep(0.5)
r = run_js(JS_QUERY)
if not isinstance(r, dict):
print(f"FAIL DOM 查询失败: {r!r}")
app.quit()
return
print("\n DOM 统计: " + json.dumps(r, ensure_ascii=False))
check("KaTeX 渲染出公式(.katex > 0", r["katex"] > 0, f"katex={r['katex']}")
check("块公式 7 个(.katex-display == 7", r["display"] == 7, f"display={r['display']}")
check("可视层存在(.katex-html > 0", r["vislayer"] > 0, f"vislayer={r['vislayer']}")
check("无占位符泄漏", not r["leakedPlaceholder"])
check("可见文本无原始 tex 残留", not r["rawBackslash"])
check("无裸括号公式残留", not r["bareBracketFormula"])
check("代码块内 $x + y$ 保持原样", r["codeHasDollar"])
check("代码块内 arr[0] 保持原样", r["codeHasArr"])
try:
shot_path = os.path.join(os.path.dirname(__file__), "_tmp_math_render.png")
win["w"].browser.grab().save(shot_path)
print(f" 截图: {shot_path}")
except Exception as e:
print(f" 截图失败: {e}")
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====")
try:
win["w"].close()
except Exception:
pass
app.quit()
QTimer.singleShot(400, boot)
QTimer.singleShot(120000, app.quit)
app.exec()
try:
os.remove(_TMP_DB)
except Exception:
pass
sys.exit(0 if all(results) else 1)
+84
View File
@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""on-screen 布局验证:正文 .md-segment 在真实窗口中的 offsetHeight。
offscreen 没有布局(一切 h=0),必须在真实窗口验证。
PASS 条件:流式中 + finish 后 正文段 h > 0。
"""
import os, sys
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PyQt6.QtWidgets import QApplication
import PyQt6.QtWebEngineWidgets # noqa
from PyQt6.QtCore import QTimer
from ui.views.main_window import MainWindow
app = QApplication(sys.argv)
w = MainWindow()
w.resize(1000, 700)
w.show()
AUDIT = {}
def js(expr, cb=None):
if cb:
w.browser.page().runJavaScript(expr, cb)
else:
w.browser.page().runJavaScript(expr)
def on_ready(_res):
js(f"createMessage('vtest', 'assistant');")
QTimer.singleShot(300, phase_stream)
def phase_stream():
js("appendReasoning('vtest', '这是思考第一段内容。');")
body = "测试!测试!测试!\n\n这是第二段正文,用于验证布局高度。"
import json as _json
for ch in body:
js("appendToken('vtest', " + _json.dumps(ch) + ");")
QTimer.singleShot(1500, phase_mid_check)
def phase_mid_check(_r=None):
js("""(function(){
var seg = document.querySelector('#vtest .md-segment');
var tc = document.querySelector('#vtest .think-content');
return JSON.stringify({
mid: true,
segH: seg ? seg.offsetHeight : -1,
segConnected: seg ? seg.isConnected : null,
segRect: seg ? Math.round(seg.getBoundingClientRect().height) : -1,
tcH: tc ? tc.offsetHeight : -1
});
})()""", on_mid)
def on_mid(res):
AUDIT["mid"] = res
js("finishMessage('vtest');")
QTimer.singleShot(1200, phase_finish_check)
def phase_finish_check(_r=None):
js("""(function(){
var seg = document.querySelector('#vtest .md-segment');
return JSON.stringify({
fin: true,
segH: seg ? seg.offsetHeight : -1,
segText: seg ? seg.textContent.length : -1,
segDisplay: seg ? getComputedStyle(seg).display : null
});
})()""", on_finish)
def on_finish(res):
AUDIT["fin"] = res
print("MID =", AUDIT.get("mid"))
print("FIN =", AUDIT.get("fin"))
try:
import json
m, f = json.loads(AUDIT["mid"]), json.loads(AUDIT["fin"])
ok = m["segH"] > 0 and m["tcH"] > 0 and f["segH"] > 0 and f["segText"] > 20
print("===== " + ("PASS: 正文段真实布局高度正常" if ok else "FAIL: 正文段高度异常") + f" mid.segH={m['segH']} fin.segH={f['segH']} =====")
except Exception as e:
print("===== FAIL: 解析异常", e, "=====")
app.quit()
QTimer.singleShot(2500, lambda: js("document.readyState", on_ready))
QTimer.singleShot(30000, app.quit)
app.exec()