init repo

This commit is contained in:
2026-09-17 16:30:02 +08:00
commit 0ba53cc7e3
163 changed files with 33634 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
# 仓库协作规则
haocode 是面向 Windows 和 Linux 的 PyQt6 桌面 AI Agent 客户端。Windows 首选 WebView2,失败时回退 QtWebEngineLinux 只使用 QtWebEngine。`core/agent/` 保持无 GUI,并继续与 pi 对齐。
## 权威交接入口
处理修复、跨平台、测试、项目结构或交接任务前,必须先读 `docs/agent-handoff/README.md`,再按其中的触发条件读取对应文档。`readme.md``Frame.md``ARCHITECTURE.md` 只作历史背景,不能未经核验就当作事实源。
`data/config.json` 是不透明的本机密钥文件。Agent 不得打开、读取、搜索、打印、复制、编辑它,也不得让宽范围内容搜索包含它。测试必须使用临时配置。
## 项目结构
- `main.py`:入口、UTF-8 标准输出保护、Chromium 参数和 `MainWindow` 创建。
- `core/`:无 GUI 后端,包括 SQLite 会话树、Qt 工作线程、WebView2 后端和日志。
- `core/agent/`:与 pi 对齐的循环、流式处理、重试/压缩恢复,以及 `read`/`bash`/`write`/`edit` 工具;可直接进行纯 Python 测试。
- `ui/views/`PyQt6 窗口与桥接。`main_window.py`(约 6000 行)是当前中心,还包括 `bash_panel.py``chat_bridge.py``wv2_view.py``custom_web_page.py``debug_window.py``system_tools/`
- `ui/web/`:离线网页层,包括 `index.html``app.js``style.css` 及 vendored marked/DOMPurify/KaTeX/highlight.js。
- `data/`:源码运行时数据,包括不透明的 `config.json`、自动创建的 `chat_history.db``attachments/`
- `tools/builtin_tools/pdf_reader.py``svg/`(图标)、`SYSTEM_PROMPT.md`(每次请求重新读取,修改后无需重启)。
- `vendor/webview2/` 和根目录 `WebView2Loader.dll`:Windows 运行依赖,**不得删除**。
- `tests/`:测试、smoke、诊断和人工验证脚本。
## 常用命令
- `pip install -r requirements.txt`:安装当前依赖;Linux 依赖规范化见交接任务清单。
- `python main.py`:从源码运行。Windows 可从 WebView2 回退 QtWebEngineLinux 使用 QtWebEngine。
- `python tests/run_tests.py`:只运行当前 agent-core 聚合,不会发现整个测试目录。
- `node tests/test_math_extract.js`:运行前端公式提取测试。
- `python tests/check_db_migration.py <db>`:只读检查数据库迁移完整性。
## 编码规范
- 以 Python 3.10 语法为基线,使用 4 空格缩进、`snake_case` 函数/文件名和 `PascalCase` 类名;目标运行矩阵为 Python 3.103.12。仓库未配置 formatter/linter,修改时匹配周边风格。
- 注释和文档使用中文;路径、命令和 API 标识符保持原文。
- `requirements.txt` 只列源码确实导入的包;依赖变更必须有意为之。
- `core/agent/` 必须保持无 GUI,确保可离屏测试。
## 测试规则
仓库不使用 pytest;测试是可直接运行的 Python/Node 脚本。`tests/run_tests.py` 当前只加载 agent-core 套件。`test_*.py` 表示聚焦自动化测试,`smoke_*.py` 表示离屏集成测试,`diag_*`/`verify_*`/`tune_*` 默认表示诊断或人工脚本,除非文件自身另有说明。
```bat
set PYTHONIOENCODING=utf-8
set QT_QPA_PLATFORM=offscreen :: only for smoke_* GUI tests
python tests/test_tool_params.py
```
跨平台聚合入口(不改变任何独立命令):`python tests/run_all.py --group logic|offscreen|all`。每个条目独立子进程 + 临时目录 + 显式超时;依赖/平台不适用项打印明确 SKIP 理由;任一失败则退出码非零。默认不跑 `diag_*`/`verify_*`/`tune_*`/真实 API/真实桌面脚本。
导入 `MainWindow` 前,必须把 `core.db_manager._DEFAULT_DB` 指向临时数据库,并把 `HAOCODE_CONFIG_FILE` 指向临时配置。当前仍有代码绕过该变量;对应任务完成前,测试还必须 patch 相关模块缓存的路径,并证明真实运行时文件未被访问或修改。
## 调试铁律
- **所有调试命令、测试与诊断脚本执行都必须设置显式的最长耗时预算(timeout)**。
- 若中途因超时跳出,先定位卡点,再允许延长预算重跑;**严禁不设超时让它无限卡死**。
## 变更粒度
当前快照没有 Git 历史。不得初始化 Git 或伪造提交;每批修改仍须保持可独立提交。未来提交信息使用简短祈使句并注明区域,例如 `core/agent: fix compaction cut-point`
## Agent 注意事项
- 运行时可能在根目录写出 `compaction_diag.log``stream_diag.log``diag_shot_*.png` 等诊断产物。除非当前任务明确负责清理,否则不得删除。
- shell 行为尚未规范化。目标契约是 Windows `cmd.exe`、Linux `/bin/bash -lc`;修改前先读 `docs/agent-handoff/PLATFORM_PLAN.md`
- 本阶段源码运行继续把数据放在项目 `data/` 下;打包路径和 AppData/XDG 迁移延后。
- 不得删除 `vendor/webview2/` 或根目录 `WebView2Loader.dll`
+238
View File
@@ -0,0 +1,238 @@
# 项目文件架构
> [!WARNING]
> **历史资料,不是当前事实源。** 本文仅保留旧架构与故障记录。处理修复、跨平台、测试、项目结构或交接任务时,先读 [`docs/agent-handoff/README.md`](docs/agent-handoff/README.md)。
> 仅描述目录与文件的基础组织,不涉及具体实现细节。
```
GeekAgent-Studio/
├── main.py # 程序入口
├── requirements.txt # 依赖清单
├── untitled.ui # Qt Designer 界面文件
├── ui/ # 表现层(桌面窗口 + 本地 Web 渲染)
│ ├── views/ # PyQt6 窗口与组件逻辑
│ │ └── system_tools/ # 系统级工具(全局热键、截屏等)
│ ├── web/ # 本地 HTML/JS/CSS 渲染层
│ │ └── highlight/ # 本地代码高亮库
│ └── assets/ # 静态资源(样式表、图标)
│ └── icons/
├── core/ # 控制与大脑层(后端核心)
├── agents/ # Agent 逻辑与执行器
├── workspace/ # 智能文件系统与代码编辑
├── tools/ # 动态 Skill / Tool 生态
│ ├── builtin_tools/ # 内置基础工具
│ └── dynamic_skills/ # 动态生成的工具脚本
├── data/ # 本地数据与缓存
│ ├── attachments/ # 聊天附件
│ └── .agent_history/ # 文件修改备份
├── svg/ # 界面图标资源
└── tests/ # 测试用例
```
---
## 流式显示诊断体系(2026-07 新增,重要)
**历史教训**`dist/GeekAgent-Studio/` 是 PyInstaller 打包产物,`ui/web` 被冻结在
`_internal/ui/web/` 里——**改源码不影响 exe**。修 UI 问题后必须重新打包
`python -m PyInstaller geekagent.spec --noconfirm`),否则用户跑 exe 永远看不到修复。
**链路诊断日志**`stream_diag.log`,应用根目录,每次启动清空):
- Python 侧:`APP_START` / `FRONTEND_VER <前端版本戳>` / `CHUNK`(前 3 条 + 每 50 条
+ 会话不匹配时全记)/ `THINK` / `RESTORE` / `JS_DIAG`(回复完成时抓 JS 事件环)
- JS 侧:`window.__APP_VER` 版本戳 + `__diag` 事件环(createMessage / appendToken
采样 / tokenDOM 缓冲长度 vs DOM 长度 / NO_WRAPPER 告警 / restore / history / finish
- 读日志即可回答:token 到了吗?wrapper 在吗?缓冲有内容吗?DOM 写进去了吗?
**助手正文透明气泡**(用户方案):`.message.assistant .md-segment` 浅灰半透明底 +
圆角边框,流式中/完成后视觉一致;正文段元素在首个 token 时同步创建,气泡即时出现。
---
## Agent 核心(core/agent)— pi 1:1 移植
```
core/agent/
├── types.py # AgentMessage(kind 标记压缩摘要) / AgentConfig / 事件 / ToolCall / ToolResult
├── context.py # token 估算(usage 锚定 + CJK 感知)/ 输出钳制 / 压缩触发公式
├── stream_fn.py # openai_stream:流式 + tools 序列化 + OpenAI⇄pi 消息转换
├── tools.py # bash(shell=True) / read / write / edit + 文本兜底解析器
├── loop.py # run_loop:系统提示注入 → 流式 → 工具批执行 → 截断保护
├── agent.py # Agent:事件订阅/发布(subscriber 异常静默,pi 语义)
├── compaction.py # pi harness 压缩算法 1:1(切点/断轮/迭代摘要/文件附录)
└── recovery.py # 429/上下文溢出/截断 重试与恢复(pi 精确语义)
```
- 每次发送 → 新 `AgentWorker`core/llm_engine.pyQThread 胶水层);
多轮工具循环在一次发送内部闭环,UI 线程只收 Qt 信号。
- `stream_fn` 协议:`stream_fn(context, model, signal, max_tokens, tools=None)`
`tools` 必须序列化进 OpenAI 请求体(否则模型只能"文字扮演"工具调用)。
- `SYSTEM_PROMPT.md`(项目根):worker 模式每次请求头部注入,不入历史、不受压缩影响。
### 上下文管理与压缩(2026-07-21 升级为 pi harness 原版算法 1:1
对照 `packages/agent/src/harness/compaction/compaction.ts` + `utils.ts` 逐函数移植:
- **触发公式**`shouldCompact: tokens > contextWindow reserveTokens`
`reserveTokens=16384``AgentConfig.compaction_reserve`),替换旧 40% 启发式。
- **token 估算**usage 锚定 1:1——最后一条有效 assistant(非 aborted/error 且
totalTokens>0)的精确值 + 其后消息逐条估算;无 usage 则全部逐条。
逐条估算用 CJK 感知版(唯一已声明偏差:pi 是 chars/4)。
- **切分点** `find_cut_point`:从尾部回扫累计 token 至 `keepRecentTokens=20000`
取该位置起第一个有效切点(user/assistanttoolResult 不可切);
切点非 user → **断轮**:轮起点前的历史与轮前缀**分两次 LLM 摘要**,
拼接 `{history}\n\n---\n\n**Turn Context (split turn):**\n\n{prefix}`
- **迭代式更新**:上次压缩摘要(`kind="compaction_summary"` 消息)不重摘,
作为 `<previous-summary>``UPDATE_SUMMARIZATION_PROMPT`pi 四套提示词逐字移植)。
- **文件操作附录**:摘要范围内 read/write/edit 工具的 path 提取,
摘要尾部追加 `<read-files>/<modified-files>`1:1 utils.ts)。
- **摘要预算**`maxTokens = min(0.8×reserve, model.maxTokens)`;轮前缀 `0.5×reserve`
- **对话序列化**`[User]:` / `[Assistant thinking]:` / `[Assistant]:` /
`[Assistant tool calls]:` / `[Tool result]:`(超 2000 字符截断)逐字一致。
- **唯一已声明偏差**:摘要 LLM 调用失败时降级为机械摘录(pi 返回错误),
桌面应用优先不丢上下文。
- 架构级差异:会话重启后 agent 内存态不持久化(DB 只存时间线),
压缩摘要随之丢失、上下文从零重建——与 pi 的 compaction 条目落盘不同(见桌面文档)。
## 时间线持久化(2026-07 新增)
agent 模式的完整事件时间线按序落库,切会话/重载后 1:1 还原:
```
messages.timeline (JSON, 可空)
[
{"t":"think", "text":"…"},
{"t":"text", "text":"…"},
{"t":"tool", "id":"call_x", "name":"bash", "args":"{…}",
"ok":true, "result":"…"} # ok:null = 仍在执行
]
```
- **累积**`MainWindow._active_streams[session]["timeline"]`
reasoning/chunk/tool 三组 Qt 信号按到达顺序累积。
- **入库**:正常结束 / 中止 两条路径都带 `timeline``db.add_message`
- **重载**`load_messages_to_web` 对带 timeline 的 assistant 行调
`renderTimelineHistory`(静态时间线:思考折叠、chip 定格、文本完整渲染)。
- **切回进行中会话**`restoreStreamingTimeline` 按流式状态恢复,
后续 token 无缝续流(续接紧邻的进行中块,否则新开一段)。
- **API 上下文重建**`build_api_context` 对带 timeline 的行重建完整链
assistant+tool_calls → tool 消息),模型跨轮次工具记忆不丢。
- 旧消息(无 timeline 列值)走原有聚合渲染,向后兼容。
## 流式渲染鲁棒性 + agent 运行期 UX(本轮新增)
### 1. 流式内容可见性 —— 不依赖 rAF / 页面定时器
根因:正文/思考内容原来只在 `requestAnimationFrame` 回调里写入 DOM
rAF 依赖合成器 BeginFrame,在 GPU 上下文丢失(本机 AMD 核显实测会
周期性 context lost)/窗口隐藏/页面被判定后台(rAF 停发 + timer
钳制 1Hz)等环境下,内容永久空白(思考块/工具 chip 是同步插入 DOM
的所以看得见)。
对策(三层,均不依赖页面帧/定时器):
1. **同步渲染通道** `syncRenderThrottled``appendToken`/`appendReasoning`
在同一 JS 任务内 30ms 节流直接写 DOMtoken 由 Python runJavaScript
送达,必被执行);
2. **恢复路径同步上屏**`renderTimelineEntries(liveMode)` 结束即
`syncRenderThrottled(msgId, 0)` —— 中途切回会话,恢复的正文/思考
不等任何帧立即显示;
3. **Qt 看门狗**`MainWindow._render_watchdog`200ms):对当前会话
活跃流 `forceRenderNow(msgId)`(幂等,尾部无变化时近零开销)。
另有:`doStreamingRender` 逐段 try/catch(解析异常 → 全量重解析);
rAF 通道保留作平滑优化 + 40ms setTimeout 兜底;收尾取消全部定时器。
回归测试:`smoke_live_guard.py` Phase 0rAF+全部页面定时器 kill →
burst 后 400ms 内容必现)+ Phase A(仅 rAF 卡死 → 40ms 兜底);
`smoke_midswitch.py`(流式中切走→切回:块顺序/正文/思考/DB 完整 +
切回后立即同步可见)。
### 2. 深度思考:默认收起 + 进行中蓝色动画
- 流式/恢复/历史三条路径的思考块一律 `open=false`
- 进行中块带 `.streaming-think`:标签蓝色呼吸 + 省略号动画(CSS
`think-breathe` / `think-dots`);
- 收尾移除动画类,标签还原「已完成深度思考」。
### 3. bash chip:耗时 / 命中超时徽章
- bash 工具结果自带 `[exit N] (X.Xs)``命令超时(>Ns)已终止`
- `_parseToolTiming` 解析后在摘要行插入 `⏱ X.Xs`(灰)与
`⏱ 超时 Ns`(红)徽章 —— 收起状态也能看到;
- 实时(toolExecutionFinished)与恢复/历史(buildToolChip)两条路径同逻辑。
### 4. 工具结果:展开必显示 + 长结果尾部预览 + 展开按钮
- 修复旧 bug`toolExecutionFinished` 原来只在 `chip.open` 时写结果,
收起状态下执行完 → 事后展开为空;现在无条件写入;
- 结果 > 4000 字:默认只显示尾部 4000 字(前缀 `…`),正文上方出现
「⬆ 展开完整输出(共 N 字)」按钮,再点收起;`chip.__fullResult` 存全文;
- 信号链路扩容:`tool_execution_finished` 携带 `text[:20000]`(原 800);
- API 回灌限制:`build_api_context` 重建 tool 消息时截断 4000 字,
防止长输出撑爆模型上下文。
---
## 流式正文不显示:深度调试与根因(2026-07)
### 现象链
旧版(逐 token 全量 innerHTML 同步重绘)正文可见但乱序 → 时间线重构后(缓冲+rAF 增量渲染)正文流式期不显示,结束后/切回后可见。
### 诊断数据(用户环境 stream_diag.log,前端 v2
- `FRONTEND_VER 20260721-v2` ✓ 最新前端在跑
- CHUNK/THINK 逐 token 到达 UI 线程,会话匹配 ✓
- JS_DIAG 完成时刻环形缓冲 = 思考事件以 ~95/s 流入 → **JS 线程活着,token 在被处理**
- 结论:不是 JS 没跑,是**屏幕没有把 DOM 变化画出来**
### 根因(本机复现)
AMD 核显 GPU 上下文周期性丢失:
-`--enable-gpu-rasterization --ignore-gpu-blocklist`(旧默认)离屏实测:
连续 `SharedImageStub: context already lost` 错误 → **页面 JS 处理/Qt 定时器全部停摆**
-`--disable-gpu` 后同样代码 100% 正常(真实消息内容回放探针 b/d 双满)
- 用户屏幕上的部分停摆:DOM 已写入,但 GPU 光栅化产物无法合成上屏 → 正文不可见
### 修复
1. **main.py 默认改为 `--disable-gpu`**(全 CPU 软渲染,彻底绕开 GPU 上下文丢失;
旧 GPU 模式保留为 `HAOCODE_RENDER=gpu`
2. 保留双保险渲染通道(同步节流渲染 + 200ms Qt 看门狗 forceRenderNow
3. v3 探针:`probeStream()` 每 2s 写入 `PROBE` 日志(缓冲长度/DOM 长度/offsetHeight/opacity/display),
`dumpDiag` 改为按类型摘要 —— 若再异常可一次性定位断点
### 最终根因(2026-07-21 v7,推翻上述 GPU 主因结论)
人肉 debugtoken 级日志 + 屏幕截图 + 逐层盒模型探针 + 页面内对照/克隆实验)锁定:
**Chromium 布局失效 bug:空 `.md-segment` 先入文档 → 匹配 `:empty{display:none}`
→ 30ms 后写入内容 → 引擎未重新触发布局 → 盒永久 0x0(正文不可见)。**
证据链:
1. token 日志:正文 `段buf``dom`textContent)同步增长(71c 全在 DOM),但 `h=0`
2. 像素分析 `diag_shot_*.png`:完成时刻屏幕上也无正文(切会话才可见 = DB 重渲路径)
3. 逐层探针:seg 的祖先链全部存活(reply-content w=119/h=75),唯 seg 子树 w=0/h=0
4. 对照实验:同容器新建的 `.md-segment`(带内容)h=24 正常;原始流式段 h=0
5. 克隆实验:原节点的**完整克隆 h=24**;把原节点**重新 appendChild 一次即恢复 h=24**
—— 纯布局状态腐坏,与 CSS 规则无关
为什么切会话重渲正常:`renderTimelineHistory` 创建节点时**先填充 innerHTML 再插入**
从不以空节点进文档,`:empty` 从未匹配。
### v7 修复(真正根因)
1. **appendToken / renderTimelineEntries(live)**:新正文段先 `mdStateOf + renderMarkdownStreaming`
同步渲染、**带内容再 insertBefore**(永不空节点入文档)
2. `.md-stable/.md-tail``display:contents``block`(规避 contents 布局风险,加内层边距补偿)
3. 周期性流式截图改为 `HAOCODE_SHOT=1` 可选 —— 实测 `QWidget.grab()` 强制出帧会
阻塞渲染器主线程 1-2s,会干扰正文 token 处理(诊断干扰项)
4. 保留 `--disable-gpu` 默认(对离屏测试环境仍有必要)与双通道渲染 + 看门狗
5. 新增 `tests/verify_onscreen.py`:**真实窗口真实布局**验证(offscreen 无布局,
此类 bug 离屏测不出来;本 bug 即由此漏检 3 轮)
6. v6 起 JS 端带渲染器主线程心跳(dt>1.5s 告警)+ finish 后正文段体检日志
+288
View File
@@ -0,0 +1,288 @@
# Frame —— haocode 逐目录 / 逐文件说明
> 本文件是 `readme.md` 的下钻版:**每个文件夹、每个文件到底负责什么**,
> 以及关键的类 / 函数名(方便直接搜索定位)。
> 阅读建议:先看 `readme.md` 的架构总览,再回来看这里的目录细节。
---
## 0. 一眼看清分层
| 层 | 目录 | 职责 | 能否单独跑 |
|---|---|---|---|
| 入口 | `main.py` | 环境准备 → QApplication → MainWindow | — |
| 表现层 | `ui/views/` | PyQt6 窗口、控件、事件编排 | 需 GUI |
| 渲染层 | `ui/web/` | HTML/JS/CSS(离线三库:marked / dompurify / KaTeX + highlight.js | 浏览器可单独开 |
| 引擎层 | `core/llm_engine.py` | 把 agent 核心包成 Qt 线程,发信号给 UI | 需 PyQt6 |
| Agent 核心 | `core/agent/` | 与 pi 1:1 的循环 / 流式 / 重试 / 压缩 / 工具 | ✅ 纯 Python 可测 |
| 存储层 | `core/db_manager.py` | SQLite:会话树、消息、压缩标记、附件 | ✅ 纯 Python 可测 |
| 外部集成 | `core/webview2.py``vendor/webview2/` | WebView2 原生子窗口 + .NET SDK | 需 Windows |
| 资源 | `svg/``ui/web/katex|highlight``data/` | 图标 / 前端库 / 配置 | — |
---
## 1. 根目录文件
| 文件 | 说明 |
|---|---|
| `main.py` (111 行) | **程序入口**。① 顶部先做 stdout/stderr UTF-8 保护(打包成 exe 后 GBK 环境遇 emoji 会崩,见 readme 第六节);② 设置 `QTWEBENGINE_CHROMIUM_FLAGS`(默认 `--disable-gpu` 全 CPU 软渲染,规避 AMD 核显 GPU 上下文丢失导致"DOM 变了但屏幕不刷新");③ `QApplication` + 字体的 DPI/pointSize 防御;④ `MainWindow()` + `show()` + `app.exec()`。 |
| `SYSTEM_PROMPT.md` (~3400 tokens) | **agent 系统提示词**。0 身份 / 1 运行环境(含 §1.1 cmd.exe 陷阱表)/ 2 可用工具 / 3 工具参数与执行语义(read、bash、write、edit 四条铁律、执行顺序与并发)/ 4 调用方式 / 5 失败与异常处理(错误串→动作对照表)/ 6 工作方式 / 7 安全红线 / 8 会话与上下文。**每次请求由 `load_system_prompt()` 重新读取,改完不用重启**;只在 `enable_tools=True`(worker 模式)时注入,且**不进历史、不参与压缩**。 |
| `requirements.txt` | 依赖清单(只列代码真实 import 的包,AST 扫描核对过)。 |
| `haocode.spec` | **PyInstaller 打包配置**onedir)。关键点:把 `ui/web``svg``vendor/webview2``SYSTEM_PROMPT.md``data/config.json``WebView2Loader.dll` 收集进 `_internal/`(因为代码里普遍用 `dirname(__file__)` 上溯定位项目根,冻结后根 = `_internal`);**刻意不含** `data/chat_history.db``data/attachments/``console=False`;带 `runtime_hooks`。 |
| `pyi_rth_trace.py` | 打包**运行时钩子**:把冻结版 stdout/stderr 重定向到 exe 同级 `haocode.log`(GUI 版没有控制台,没有它启动异常会完全静默)。 |
| `WebView2Loader.dll` | WebView2 加载器,供"以工作目录为基准"的兜底加载路径使用(首选路径是 `vendor/webview2/webview2loader_x64.dll`)。 |
| `ARCHITECTURE.md` / `readme_our.md` / `黑边两现象分析报告.md` | **历史文档**(早期架构、早期设计稿、窗口缩放黑边问题分析)。其中提到的 `agents/``workspace/``ui/assets/``untitled.ui` 等目录/文件**已在交接版移除**,最新结构以 `readme.md` + 本文件为准。 |
| `_out.txt` / `_t0.txt` | 文本示例文件(按交接要求保留)。 |
---
## 2. `core/` —— 后端核心
### 2.1 `core/db_manager.py` (715 行) —— SQLite 存储层
**表结构**
```sql
sessions(id, title, created_at, updated_at, has_messages, sort_order, is_starred,
current_leaf_msg_id, mode) -- mode: 'chat' | 'worker' | NULL(未发过消息)
messages(id, session_id, role, content, reasoning, is_ignored, created_at,
attachment_metadata, parent_id, timeline, usage,
stop_reason, error_message)
```
- **会话是链表树**`messages.parent_id` 指向上一条,`sessions.current_leaf_msg_id` 记录当前叶子。
`get_message_chain()` 从叶子向上回溯再反转 → 得到"当前激活时间线"(绕开所有废弃分支)。
- `role` 取值:`user` / `assistant` / `system` / **`compaction`**(压缩标记行,`content` 是摘要)。
- `timeline`assistant 行的**事件时间线 JSON**`{"t":"think"|"text"|"tool", ...}`),
工具条目形如 `{"t":"tool","id","name","args","ok","result"}``ok=None` 表示开始了但没拿到结果。
- `usage`:本轮精确 token 用量(`{"input","output","cacheRead",...}`),供显示与压缩估算做**锚点**。
- `stop_reason` / `error_message`:失败轮次标记(`'error'`),NULL = 正常行。
**关键方法**
| 方法 | 作用 |
|---|---|
| `add_message(...)` | 插一条消息并把会话叶子前推(`stop_reason`/`error_message` 为可选新参数) |
| `get_message_chain(session_id)` | 取当前激活链(含环检测保护) |
| `get_branch_info(parent_id)` | 某父节点下的所有子分支(UI 的 `1/3` 分支切换) |
| `update_session_leaf(session_id, leaf)` | 手动改叶子(分支切换、错误回退用) |
| `insert_compaction_mark(session_id, summary, cut_before_id, first_retained_id, meta)` | 插压缩标记行 |
| `copy_session(...)` | 整会话深拷贝:消息 id 重映射、parent 重连、**附件物理复制改名**、标题 `(副本 N)` 递增 |
| `_upgrade_schema(cursor)` | 旧库自动补列。⚠️ **注意**:旧的 `upgraded=True` 分支会触发"链表化重构"(把树按时间拍平成线性链)→ 新增列**绝不能**置这个标志(源码里有警告注释) |
| 冻结态库路径(文件头) | `sys.frozen` 时优先 `exe/../../data/chat_history.db`(存在才共用),否则 `exe 同级/data/chat_history.db` |
### 2.2 `core/llm_engine.py` (446 行) —— 引擎层(Qt 线程桥)
| 名字 | 说明 |
|---|---|
| `load_system_prompt()` / `_load_config()` / `_provider_info()` | 读 `SYSTEM_PROMPT.md``data/config.json`(缺失返回兜底短提示词 / 空配置) |
| `AgentWorker(QThread)` | **worker 模式**:组装 `AgentConfig`model / system_prompt / tools / retry=3 次 2s×2)→ `agent.set_stream_fn(openai_stream)``AgentRunner(agent, summarize_fn=..., 回调...)``runner.run(last_user)`。信号:`chunk_received` / `reasoning_received` / `tool_execution_started|updated|timed|finished` / `context_compacted` / `compaction_started` / `retry_scheduled` / `retry_finished` / `usage_updated` / `error_occurred` / `finished` |
| `_bridge(event)` | `AgentEvent` → Qt 信号(在工作线程内 emit,跨线程 queued 投递) |
| `_make_summarize_fn(model)` | 压缩用的**非流式** LLM 调用(`prompt_text, system_prompt, max_tokens → str` |
| `ChatWorker(QThread)` | **chat 模式**:单次流式,无工具、无重试、无压缩(快速问答) |
| `TitleWorker(QThread)` | 会话自动命名 |
| `abort()` / `cancel()` | 中断:置 `AbortSignal` → agent 循环在 chunk 边界收尾为 `stop_reason="aborted"` |
### 2.3 `core/webview2.py` (406 行) —— WebView2 集成
| 名字 | 说明 |
|---|---|
| `get_environment(app)` | 返回 WebView2 环境或 `None`None → UI 侧回落 QtWebEngine)。**两道守卫**:① `_wv2_allowed_here()` 无头/测试环境直接不启用;② `acquire_instance_lock()` 抢不到单实例锁(已有实例在跑)就跳过,**并且跳过 `taskkill`**(否则会把正在运行的兄弟实例的浏览器进程杀掉 → 它的 controller 变 disposed → **DOM 正常但视觉层永久空白**,这就是历史 T0 事故) |
| `acquire_instance_lock()` | `msvcrt.locking``data/app_instance.lock``HAOCODE_INSTANCE_LOCK_FILE` 可覆盖路径,供测试隔离);返回 True=唯一实例 / False=已有实例 / None=平台不支持 |
| `Wv2Session` | 一个 WebView2 实例:创建 controller、找子窗口 hwnd`find_child`)、`set_bounds`/`set_visible`/`navigate`/`execute_js`/`close` |
| `_pump_wait(op, app, timeout)` | 等待异步 COM 操作时泵 Qt 事件(避免 UI 卡死) |
| 环境变量 | `HAOCODE_FORCE_QTWEBENGINE=1` 强制回落;`QT_QPA_PLATFORM=offscreen` 自动不启用 WebView2 |
### 2.4 `core/debug_log.py` (78 行) —— 调试事件总线
`debug_log(msg, tag)` 写内存环形缓冲 + 落盘;`poll_debug_cmd()` 取调试窗口输入的命令;
`autostart_debug_window(cfg)``config.json: debug_window_autostart` 决定是否开调试窗口。
### 2.5 `core/agent/` —— pi 1:1 agent 核心(9 文件)
| 文件 | 行数 | 关键名字 | 说明 |
|---|---|---|---|
| `types.py` | 331 | `AgentMessage` `ToolCall` `AgentConfig` `ModelConfig` `RetryConfig` `AgentEvent` `AbortSignal` `new_id` | 全部数据结构(dataclass)。`AgentMessage``content/reasoning/tool_calls/stop_reason/error_message/usage/timestamp` |
| `agent.py` | 204 | `Agent``subscribe`/`prompt`/`continue_`/`steer`/`follow_up`/`abort`/`set_stream_fn`) | agent 对象:状态机 + 事件订阅 + 输入队列(steering=轮中插话,followUp=队列尾续跑) |
| `loop.py` | 482 | `run_loop` `_stream_turn` `execute_tool_calls` `_execute_parallel` `_execute_sequential` `_should_terminate_batch` | **主循环**:注入 system prompt → 输出预算钳制 → 流式一轮 → 工具批(含 length 截断保护:参数可能残缺则一律不执行)→ 轮末钩子(`prepare_next_turn` / `should_stop_after_turn`)→ 停止判定 |
| `stream_fn.py` | 438 | `openai_stream` `to_openai_messages` `from_openai_messages` `classify_error` `_parse_tool_call` `_pick_reasoning` `_pick_usage` | OpenAI 兼容流式调用(180s 超时、SDK 层 `max_retries=0`——重试统一交给 recovery 层)+ 异常分类(rate_limit/timeout/connection/server_error/overload/auth/overflow+ 工具调用增量拼装(JSON 解析失败兜底 `{}` + 保留 raw |
| `recovery.py` | 519 | `AgentRunner``run` `pre_prompt_compaction` `compact_if_needed` `_handle_post_agent_run` `_prepare_retry` `_remove_last_bad_assistant` `_do_compaction`)、`is_context_overflow` `is_retryable_assistant_error` `is_recoverable_length` `compute_retry_delay_ms` `compact_diag_log` | **三路决策**:① 上下文溢出 → 压缩恢复(只试一次)② 可重试错误(429/5xx/超时/断连…且非配额耗尽)→ 移除坏消息 + 退避(2s→4s→8s,最多 3 次)③ 否则停。另含**轮中主动压缩** `compact_if_needed`(单条巨型工具输出不再依赖"失败一次"再兜底;同 run 连败 2 次即止损) |
| `compaction.py` | 637 | `compact_context` `should_compact` `CompactionSettings` `find_cut_point(s)` `serialize_conversation` `FileOperations` | 上下文压缩:按 pi harness 原版算法找**有效切点**(不切开工具调用对)→ 摘要化前半段 → 保留尾巴 → 产出切点 id(供 UI 落库成 `compaction` 标记行) |
| `context.py` | 341 | `estimate_context_tokens` `estimate_message_tokens` `calculate_context_tokens` `_find_last_usage` `clamp_max_tokens_to_context` `clamp_outputs_to_context` | token 计量与预算钳制:优先用**最近一次真实 usage 做锚点**,无锚点才按字符估算(含图片按比例计) |
| `tools.py` | 873 | `tool_read` `tool_bash` `tool_write` `tool_edit` `default_tools` `prepare_tool_call` `parse_text_tool_calls` | 四个工具的实现与参数校验。`tool_bash` 用「Popen + 读线程 + 队列」实现**实时输出**(`on_update`)、每秒读秒(`on_timer`)、超时 `taskkill /F /T` 杀进程树、50KB 截断;`tool_edit` 按「原文件快照定位 + 唯一匹配 + 非重叠 + 全有或全无」改文件 |
---
## 3. `ui/` —— 表现层
### 3.1 `ui/views/main_window.py` (6019 行) —— 主窗口(全项目核心)
单文件承载了绝大部分 UI 与编排逻辑,按区块读:
| 区块(搜关键字) | 说明 |
|---|---|
| `MainWindow.__init__` | 组装:DBManager、左侧栏、聊天区、工具栏、输入区、右侧任务面板、调试窗口 autostart、全局热键 |
| `setup_ui()` | 三栏布局 `main_layout = [sidebar | chat_area | bash_panel]`;顶部工具栏(`历史` 按钮) |
| `setup_stylesheet()` | 全量 QSS`#sidebar` / `#right_sidebar` / `#bl_*` / `#top_tool_btn` / 气泡…) |
| `get_svg_path()` / 侧边栏折叠动画 | `sidebarWidth` 属性 + `QPropertyAnimation`;窗口过窄时自动折叠 |
| 输入区与附件 | `_on_image_pasted` / `_on_files_dropped` / `_on_long_text_pasted` / `AttachmentPreviewOverlay`(独立顶层窗口,避免被 WebView 遮挡) |
| `send_message()` | ① 若本会话在生成 → 走**中断**分支;② 建 `_active_streams[session_id]` 流状态;③ 落库用户消息;④ 起 `AgentWorker`/`ChatWorker` 并接线全部信号 |
| `_active_streams` | 每会话流式状态:`msg_id / parent_id / previous_leaf_id / content / reasoning / timeline / tl_kind / usage / worker` |
| `on_chunk_received` / `on_reasoning_received` | 累积正文/思考 + 维护时间线(`t:text` / `t:think` 分段)+ 定时刷新上下文标签 |
| `_on_tool_started/updated/timed/finished` | 工具事件 → 时间线条目 + 前端 chip + 右侧任务面板转发 |
| `_on_context_compacted` | 压缩完成 → 同一气泡原地定格(摘要全文)→ **插 `compaction` 标记行入库** |
| `on_reply_finished` | 正常收尾:三维全空则不入库;否则落库 assistant 行(正文/思考/时间线/usage),叶子前推 |
| `on_error``_persist_failed_stream` | **失败轮次也入库**(对照 pi`message_end` 无条件持久化)——本轮已完成的工具结果/正文/思考全部保留,尾部追加 `> ⚠️ [本轮中断] …`(并额外作为一条 timeline 文本条目,因为带 timeline 的行回放不读 content);完全空的一轮才 `is_ignored=1` 只留痕不回放 |
| `_persist_interrupted_stream` | 用户主动中断:有内容则入库,全空则回退叶子 |
| `build_api_context(session_id)` | **DB → API 上下文**:处理压缩标记(标记之前只发摘要)、附件转 base64(图片走视觉接口)、回放 assistant 的 timeline(重建 `tool_calls` + `tool` 结果,结果截断 4000 字)、跳过 `is_ignored` 与全空行、孤儿工具补合成结果、错误行照常回放 |
| `load_messages_to_web(session_id)` | 切会话:清空前端 → 按链渲染历史(含时间线回放)→ 刷新右侧面板 |
| 模式切换 | `mode_switch` 配置 + `chat`/`worker` 两种 worker`sessions.mode` 锁定已发过消息的会话模式 |
| 右侧面板接线 | `bash_panel.set_session(...)` + 四个 tool 事件转发(仅当前会话) |
| 关闭清理 | `closeEvent`:中断所有 worker、持久化进行中的回复、清理后台任务 |
### 3.2 `ui/views/bash_panel.py` (944 行) —— 右侧任务面板
| 名字 | 说明 |
|---|---|
| `BashPanel(QWidget)` | 外壳:宽 260 / 收起 52(`panelWidth` 属性 + 260ms InOutCubic 动画);**开关按钮在栏内**(收起态=栏正中 34×34;展开态=标题行右上角 28×28,两按钮分居 QStackedWidget 两页,任何时刻只有一个可见) |
| `_ResizeHandle` | 左边缘 4px 拖拽调宽(最小 200px),松手把宽度写入 `config.json: bash_panel_width`,下次展开自动恢复 |
| `_Section` | 一栏 = 头部行(24px **硬固定**+ `body`(滚动区 + 提示语);折叠只收 `body` 的高度 |
| `_layout_targets()` | 两栏折叠/展开的统一布局策略:**层从顶部堆叠,余量只由"展开着的已完成"吸收;已完成收起时余量进 `spacer` 空白占位**(避免头部被顶到面板底部) |
| `BashLayer` | 一层 = 一次 bash 执行:头部(状态点/名称/耗时/状态标签/命令预览/箭头)+ 展开后「参数」「输出」两块;运行中实时输出(200KB 上限,超出标注)、完成后显示**进入上下文的原文**、压缩切点之前的层标「已出上下文」 |
| 常量 | `PANEL_W_DEFAULT/MIN/MAX``FOLD_MS=200``FOLD_STEP=16``LAYER_LIMIT=30``LIVE_BUF_CAP=200KB``_SPLIT_ORIENTATION`Vertical,改 Horizontal 即左右并排) |
### 3.3 `ui/views/` 其余文件
| 文件 | 行数 | 说明 |
|---|---|---|
| `chat_bridge.py` | 188 | `ChatBridge(QObject)`**Python → JS 单向桥**QWebChannel)。每个方法 = 一个前端函数:`create_message` / `append_token` / `append_reasoning` / `finish_message` / `tool_execution_started|updated|timed|finished` / `restore_streaming_timeline` / `render_timeline_history` / `show_note` / `show_error` / `compaction_started` / `compaction_finished`。用 `json.dumps` 转义全部文本,杜绝注入 |
| `wv2_view.py` | 217 | `WebView2View(QWidget)`:把 WebView2 原生子窗口包成"看起来像 QWebEngineView"的控件——提供 `page()``_PageShim.runJavaScript`)、`setUrl``grab``attach_bridge``sync_bounds`(父窗口移动/缩放时同步原生子窗口位置),从而让上层渲染代码**两条渲染路径共用一套调用** |
| `custom_web_page.py` | 65 | `CustomWebPage(QWebEnginePage)`QtWebEngine 回落路径,`acceptNavigationRequest` 限制只允许本地 file:// |
| `debug_window.py` | 214 | `DebugWindow(QWidget)`:两个页签「对话消息 / 应用日志」+ 命令输入框;`_TailReader` 增量读日志文件 |
### 3.4 `ui/views/system_tools/`
| 文件 | 说明 |
|---|---|
| `file_reader.py` | `read_text_file(path, max_bytes)`:文本/代码文件读取;带**二进制黑名单**Word/Excel/PPT/压缩包/可执行/媒体),PDF 不在黑名单里(交给 `tools/builtin_tools/pdf_reader.py` |
| `global_hotkey.py` | `GlobalHotkeyThread(QThread)``RegisterHotKey` 注册全局热键(默认 Ctrl+Shift+S 截屏),失败只打印不影响主流程 |
| `screen_capture.py` | `ScreenCaptureOverlay(QWidget)`:全屏遮罩框选区域 → 截屏 → 转成图片附件(`screenshot_captured` 信号) |
### 3.5 `ui/web/` —— 本地渲染层(完全离线)
| 文件 | 行数 | 说明 |
|---|---|---|
| `index.html` | 82 | 页面骨架 + 引入本地 `marked` / `dompurify` / `highlight` / `katex`(无任何 CDN |
| `app.js` | 2111 | **前端全部逻辑**`createMessage` / `appendToken`(稳定前缀增量渲染 + rAF 批量)/ `updateMessage` / `finishMessage` / 时间线渲染(思考块、工具 chip、压缩气泡)/ `marked.parse` 统一拦截点(先抽公式占位符再渲染,最后 KaTeX 回填)/ 自动滚动与"贴底"判定 / 分支切换 `1/3` |
| `style.css` | 902 | 全部样式:气泡(assistant 85% 宽、透明正文)、思考块、工具 chip、紧凑模式、公式块、错误提示条 |
| `marked.min.js` / `dompurify.min.js` | — | Markdown 渲染 + HTML 消毒 |
| `highlight/` | — | highlight.js + atom-one-dark 主题 |
| `katex/` | — | KaTeX 0.16.11js + css + woff2 字体) |
---
## 4. `tools/`
| 文件 | 说明 |
|---|---|
| `builtin_tools/pdf_reader.py` | **PDF 专用解析**`extract_pdf_text` / `extract_pdf_images`),基于 PyMuPDF。被 `ui/views/main_window.py` 直接 import(文本模式抽文字、图片模式抽内嵌图片为 PNG 再送视觉接口),并有专门的 `PDFExtractWorker` 线程避免大文件卡 UI |
| `__init__.py` / `builtin_tools/__init__.py` | 包结构标记(**必须保留**,主窗口按 `tools.builtin_tools.pdf_reader` 路径 import |
> 交接版已删除该目录下**全空且零引用**的 `registry.py` / `conda_env.py` / `file_ops.py` / `web_search.py`。
---
## 5. `svg/` —— 图标(18 个)
`panel.svg`(左侧栏开关)/ `panel_right.svg`(右侧任务面板开关)/ `chevron_down|right.svg`(弹窗箭头)/
`mode_chat.svg` / `mode_worker.svg`(模式)/ `model.svg` / `provider*.svg`(模型弹窗)/ `send*.svg` / `stop*.svg` /
`upload.svg` / `cross.svg` / `check.svg` / `main.svg`。全部经 `MainWindow.get_svg_path()` 按项目根解析。
---
## 6. `data/` —— 运行时数据
| 文件 | 说明 |
|---|---|
| `config.json` | 供应商/模型/开关(**交接版已清空 api_key**)。关键键:`providers``default_provider/model``temperature``debug_window_autostart``mode_switch``webview_backend`,运行中还会被写入 `bash_panel_width`(面板宽度记忆) |
| `chat_history.db` | 聊天主库(**交接版不含**,首次运行自动创建空库 + 初始对话) |
| `attachments/` | 附件目录(图片/PDF,**交接版不含**;运行时按 `<项目根>/data/attachments/` 存放) |
---
## 7. `vendor/webview2/`
WebView2 的 .NET SDK(随仓库提供,不走 pip):
`net462_Microsoft.Web.WebView2.Core.dll``clr.AddReference` 加载)、`webview2loader_x64.dll`loader)、`sdk.nupkg`(原始包)。
---
## 8. `tests/` —— 测试与调试工具
### 8.1 正式测试套件(交接后应保持全绿)
| 文件 | 内容 |
|---|---|
| `run_tests.py` | **离线 harness**(自带用例收集 + pytest stub),跑 `test_agent_core.py` |
| `test_agent_core.py` | agent 核心:流式增量、工具批、重试、压缩、读秒、超时杀树 |
| `test_tool_params.py` | 四个工具的参数校验与错误串(35 项) |
| `test_compaction_persist.py` | 压缩标记入库 + 上下文截断行为 |
| `test_copy_session.py` | 会话复制(消息/分支/附件深拷贝/标题递增/叶子)(54 项) |
| `test_bash_stream.py` | bash 实时输出、每秒读秒、超时杀进程树、50KB 截断、非零退出 |
| `test_error_persist.py` | **失败轮次入库 + 回放取舍**(T1–T9,含旧库迁移不得破坏分支的安全用例) |
| `test_wv2_guard.py` | WebView2 双守卫(无头环境 / 跨进程单实例锁) |
| `test_debug_window.py` | 调试窗口事件与日志 |
| `test_think_code_neutral.py` | 思考块/代码块内容中性化 |
| `test_file_attach.py` / `test_pdf_reader.py` | 附件类型判定 / PDF 文本与图片解析 |
| `test_math_extract.js` | 前端公式提取逻辑(Node 运行,39 项) |
| `smoke_offscreen.py` | 主窗口离屏冒烟(8 项) |
| `smoke_mode.py` | chat ↔ worker 模式切换(16 项) |
| `smoke_copy_session.py` | 复制会话 UI 全链路 |
| `smoke_bash_panel.py` | 右侧任务面板全链路(含折叠动画/布局策略/拖拽调宽,116 项) |
| `check_db_migration.py` | 只读工具:校验库迁移后旧数据 100% 原样(消息数/父指针/叶子/分支点逐项比对) |
### 8.2 真机 / 真 API 工具(按需使用,不属于回归)
`smoke_live_guard.py``smoke_midswitch.py``smoke_persist.py``smoke_probe.py``smoke_repro_real.py`
`smoke_timeline.py``smoke_manual.py``diag_live_agent.py``diag_live_onscreen.py``diag_live_text.py`
`verify_onscreen.py``verify_math_render.py``inject_math_demo.py``debug_inject.py`
`tune_mode_popup.py``tune_model_popup.py`(两个弹窗调参工具)。
### 8.3 写测试的两条铁律
1. 不得污染真实库:测试开始时把 `core.db_manager._DEFAULT_DB` 指向临时文件(**必须在 import MainWindow 之前**
2. 不得污染真实配置:把 `HAOCODE_CONFIG_FILE` 指向临时文件(会写 `config.json` 的功能)
> 交接版已清除 81 个开发期临时物(`_tmp*` 脚本 / `_tmp_resize_vis/` 截图 / `_tmp_*.log`)。
---
## 9. 一次提问的完整生命周期(把上面所有文件串起来)
```
① 用户输入(ui/views/main_window.py: send_message
└─ 落库 user 行(core/db_manager.add_message)→ 建 _active_streams[sid] 流状态
② 组装上下文(main_window.build_api_context
└─ 取当前激活链 → 压缩标记截断 → 附件转 base64 → timeline 回放成 tool_calls/tool 消息
③ 起引擎(core/llm_engine.AgentWorker.run
└─ AgentConfig(model, system_prompt=SYSTEM_PROMPT.md, tools=default_tools(), retry=3×2s)
+ AgentRunner(summarize_fn=..., on_retry_* / on_compaction_* 回调)
④ agent 核心(core/agent/loop.run_loop
├─ 轮首/轮中压缩检查(recovery.compact_if_needed → compaction.compact_context
├─ 流式一轮(stream_fn.openai_streamtext/reasoning/toolcall 增量 + usage + stop_reason
├─ 出错 → recovery._handle_post_agent_run:溢出→压缩重试 / 可重试→退避重试 / 否则停
└─ 有 tool_calls → execute_tool_callstools.py 的四个工具,并行/串行按批次规则)
⑤ 事件回流 UIllm_engine._bridge → Qt 信号 → main_window 的 on_* 处理器)
└─ chat_bridge 调前端 app.js:增量渲染 / 时间线 chip / 思考块 / 读秒 / 压缩气泡
└─ 同时转发给 bash_panel(右侧任务面板实时更新)
⑥ 收尾落库(main_window.on_reply_finished 或 on_error→_persist_failed_stream
└─ assistant 行 = 正文 + 思考 + timeline(工具) + usage(+stop_reason),叶子前推
└─ 压缩发生 → 另插 role='compaction' 标记行
⑦ 下一次提问回到 ②,此时模型能看到上一轮的正文、工具调用与结果(失败轮次亦然)
```
+157
View File
@@ -0,0 +1,157 @@
# haocode Agent 系统提示词
## 0. 身份
你是 **haocode**(worker 模式)的本地智能体,运行在用户本机的 PyQt6 桌面应用内,
通过内嵌浏览器界面与用户对话。你具备代码理解与修改能力,并能调用工具直接操作
本机文件与 shell。你的目标是把用户交代的任务**真正做完**(改文件、跑命令、验证结果),
而不是只给出建议或描述该怎么做。
- 回答语言:**简体中文**;代码、命令、路径、标识符保持原文。
- 你在一个多轮循环里工作:可以连续调用工具、观察结果、再决定下一步,
直到任务完成或确实需要用户决策为止。
## 1. 运行环境
- 当前工作目录:**haocode 项目根目录**。所有相对路径都相对它解析;
每条 bash 命令都以它作为工作目录启动。
- Python:直接用 `python`3.10 环境,PyQt6、openai 已装)。
- 前端是本地网页(`ui/web/`),改动前端文件后需重启应用才生效。
{{SHELL_PLATFORM_SECTION}}
## 2. 可用工具
| 工具 | 用途 | 关键约束 |
|---|---|---|
| `read` | 读取**文本**文件(带行号) | 单次 ≤2000 行 / 50KB;大文件用 `offset`/`limit` 分页;**不要用于图片或二进制** |
| `bash` | 执行 shell 命令 | 默认 120 秒超时(上限 600);输出 50KB 截断;**同批有它则整批串行** |
| `write` | 新建或**完整覆盖**文件 | 自动创建父目录;原子写入;只用于新建或整体重写 |
| `edit` | 精确文本替换 | `oldText` 必须与**原文件**逐字符一致且唯一;各条区间不得重叠;**整批全有或全无** |
看文件优先用 `read`(不要用 `cat`);探查目录、搜索内容用 `bash``ls``grep`)。
## 3. 工具参数与执行语义
### 3.1 read
| 参数 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `path` | string | ✅ | — | 文件路径(相对路径基于项目根) |
| `offset` | integer | | 1 | 起始行号,**从 1 开始** |
| `limit` | integer | | 2000 | 最多读取行数,**上限 2000**(传更大按 2000 |
输出格式:每行 `%6d\t原文`,即「6 位右对齐行号 + TAB + 原始内容」,末尾附
`[已显示 a–b 行,共 N 行](还有 M 行未显示,用 offset=X 继续)`
- 只读文本(UTF-8,非法字节被替换为 )。**读图片或二进制只会得到乱码,不会报错**。
- 文件可能超过 2000 行时:先用小 `limit` 看结构,再用 `offset` 分页读到目标位置。
- ⚠️ **把读到的内容用作 `edit` 的 `oldText` 时,必须去掉行号与 TAB 前缀**,只保留文件原文。
### 3.2 bash
| 参数 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `command` | string | ✅ | — | 要执行的命令(平台 shell 语法见第 1.1 节) |
| `timeout` | number | | 120 | 超时秒数,**上限 600**(传更大按 600 |
- 返回值:`$ 命令` + stdout + `[stderr]` + `[exit N] (耗时)`;**退出码非 0 时结果视为失败**。
- 超时:到期会**杀掉整棵进程树**并返回 `命令超时(>Ns)已终止`
长任务(全量测试、构建、下载)请显式传 `timeout`;短查询不必传。
- 输出超过 50KB 会被截断 → 用 `-n` / `head` 或更精确的命令收窄输出后再逐步放宽。
- 需要等待的场景直接跑命令并设好 `timeout`,不要用反复 `sleep` 试探。
### 3.3 write
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `path` | string | ✅ | 目标文件路径;父目录自动创建 |
| `content` | string | ✅ | 完整文件内容(覆盖写,**不是追加**) |
- 只用于**新建文件**或**整体重写**;局部改动一律用 `edit`(避免丢掉未读到的内容)。
- 写入是原子的(临时文件 + 替换),不会留下半截文件。
- 覆盖已存在文件前,先确认你确实要丢弃它原有的其余内容。
### 3.4 edit
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `path` | string | ✅ | 目标文件路径 |
| `edits` | array | ✅ | 替换列表,每项 `{oldText, newText}``oldText` 必填,`newText` 省略视为删除 |
四条铁律:
1. `oldText` 必须与文件原文**逐字符完全一致**(含空格、缩进、换行),且在**原文件**中**唯一**。
2. 每条 edit 都对照**原始文件**定位,**不是**「上一条改完之后」的结果 → **不要**写重叠或嵌套的
edit;同一处或相邻的多处改动请**合并进同一条** edit。
3. `oldText` 尽量**短但保证唯一**;**不要**用一大段未改动的内容去「连接」两处相距很远的改动。
4. 一次调用传入全部 edit`edits[]`),**不要**为同一个文件连续发多次 `edit`
- 整批是**全有或全无**:任何一条未命中、不唯一或区间重叠 → 文件一个字节都不会被修改。
- 删除文本就把 `newText` 写成 `""`;要改多个文件就分多次调用(不同 `path`)。
### 3.5 执行顺序与并发
- 同一批里只要有一个 `bash`,**整批串行**执行(bash 是 sequential 工具)。
- `read` / `write` / `edit` 会**并发**执行 → **同一批次内不要对同一个文件发多个 `edit`/`write`**
(先后顺序不保证)。
- 无依赖的多个动作尽量放在同一批连续调用(减少往返);有依赖关系时再分轮次。
## 4. 工具调用方式
- 通过 **function calling**(请求里的 `tools` 参数)调用工具。**只有 function call 会被执行。**
- **严禁**在正文里写 `<bash>...</bash>``<read>...</read>` 之类的伪工具调用——
那些只是文本,`write`/`edit` 绝不会被执行。
- 参数必须是**正确的 JSON 类型**:`offset`/`limit`/`timeout` 传数字(不要传 `"10"`);
`edits` 传数组(不要传 JSON 字符串);不要给用不到的参数传 `null`
- 一次需要多个动作就连续发多次 function call,**全部完成后再**统一总结。
## 5. 工具失败与异常处理
| 工具返回 | 含义与你的动作 |
|---|---|
| `文件不存在: <绝对路径>` | 路径写错了。用 `bash``ls` 确认真实路径,**不要猜** |
| `path 不能为空` / `command 不能为空` | 参数缺失,补齐后重试 |
| `[起始行 offset=N 超出文件范围,该文件共 M 行]` | 用 M 以内的 offset 重读 |
| `[文件为空(0 行)]` | 文件确实为空 → 用 `write` |
| `命令超时(>Ns)已终止` | 收窄命令范围,或显式传更大的 `timeout`(≤600)后重试 |
| `参数校验失败: …;收到的参数: …` | 按提示修正类型/必填项(注意别传数字字符串) |
| `第 i 条 edit 匹配到 N 处(要求唯一)` | 加长 `oldText` 上下文使其唯一 |
| `第 i 条 edit 未找到匹配文本` | 先 `read` 确认原文;检查是否把行号+TAB 前缀带进了 `oldText` |
| `edits[i] 与 edits[j] 区域重叠` | 合并成一条 edit,或改成互不相交的改动 |
| `工具调用 X 未执行(length):…` | 上一轮回复被输出长度截断 → 缩短回答或拆分步骤重试 |
| `操作已中止 (Operation aborted)` | 用户主动中止 → 停止动作,简要说明当前进度 |
连续两次同样的失败就**停下来向用户说明卡点**,不要用相似命令反复硬试。
## 6. 工作方式
1. **先看清再动手**:改代码前先 `read` / `bash` 确认现状;不凭空猜路径、函数名、行号。
2. **小步快跑**:一次做一个明确改动;改完立刻用 `bash` 验证(编译、测试、脚本)。
3. **验证要真实**:说「已完成」之前必须有工具输出作证据(命令结果 / 测试结果)。
4. **推荐流程**:定位(`grep` / `ls`)→ 精读(`read`)→ 改动(`edit` / `write`)→ 验证(`bash`)。
5. **范围克制**:只做用户要求的事;不顺手重构、不批量格式化、不改无关文件。
6. **不谎报**:没跑过的命令不说「已运行」;没读到的内容不说「文件里是…」;失败就照实说失败。
7. **输出克制**:结论先行、简洁;长内容用列表/表格;不复述用户原话;涉及文件时写清路径。
8. **连续行动**:一个任务需要多步就直接连续调用工具,中间不必停下来征求同意。
9. **该停就停**:遇到破坏性操作、路径歧义、多种方案取舍等需要用户决策的情况,先问再做。
## 7. 安全红线(必须遵守)
- **禁止**删除或覆盖项目外的文件;**禁止** `rm -rf``del /s`、格式化等破坏性命令
(除非用户在本轮明确要求并给出路径)。
- **禁止**读取或输出 `data/config.json` 中的 API 密钥等敏感内容
(可以确认文件存在,但不要展示内容)。
- **禁止**在当前 Python 环境安装或卸载包;**禁止**修改系统目录、注册表、环境变量。
- 网络请求只允许用户已配置的 API 端点;不要主动上传数据或抓取外部内容。
- 涉及用户数据(`data/*.db`)默认只读;除用户明确要求,不要写入或迁移数据。
## 8. 会话与上下文
- **上下文可能被自动压缩**:压缩后你看到的是摘要加最近若干轮。以摘要为准继续,
不要假装记得已被压缩掉的细节;需要时重新用工具查证。
- **输出可能被截断**:若收到「未执行(length)」类提示,说明上一轮输出触达长度上限,
请缩短回答或把任务拆成更小的步骤。
- **工具调用与结果会留在历史里**:不要重复已经做过的探查,也不要凭「上一轮我大概做过什么」
下结论而不看返回结果。
- 用户可能随时中止(`操作已中止`):如实说明当前进度,不要继续动作。
Binary file not shown.
View File
+58
View File
@@ -0,0 +1,58 @@
"""
core/agent —— pi agent 核心框架的 Python 1:1 重构
==================================================
模块对照(pi-main → 本包):
packages/agent/src/types.ts → types.py 数据模型(消息/事件/工具/配置)
packages/agent/src/agent-loop.ts → loop.py 核心循环(runLoop 1:1
packages/agent/src/agent.ts → agent.py Agent 状态机(prompt/steer/followUp/abort
packages/ai/src/api/*.ts → stream_fn.py OpenAI 兼容流式(vLLM+ 输出预算钳制
packages/ai/src/api/simple-options→ context.py token 估算 + clampMaxTokensToContext
packages/agent/src/compaction.ts → compaction.py 上下文压缩(切分/摘要/替换)
agent-session.ts 后置恢复逻辑 → recovery.py 重试退避 + 溢出/截断压缩恢复
packages/agent/src/tools/*.ts → tools.py 工具管线 + 内置 read/bash/write/edit
使用示例(最小闭环):
from core.agent import Agent, AgentConfig, ModelConfig
from core.agent.stream_fn import openai_stream
from core.agent.recovery import AgentRunner
from core.agent.tools import default_tools
cfg = AgentConfig(model=ModelConfig(...), tools=default_tools())
agent = Agent(cfg)
agent.set_stream_fn(openai_stream)
runner = AgentRunner(agent)
result = runner.run("你好")
"""
from .types import (AgentConfig, AgentError, AgentEvent, AgentMessage,
AgentState, AgentTool, AgentToolResult, AbortSignal,
AssistantMessageEvent, ModelConfig, RetryConfig, RunResult,
ToolCall, new_id)
from .agent import Agent
from .context import (CONTEXT_SAFETY_TOKENS, calculate_context_tokens,
clamp_max_tokens_to_context,
estimate_context_tokens, estimate_message_tokens,
should_compact)
from .compaction import (CompactionSettings,
DEFAULT_COMPACTION_SETTINGS, compact_context,
find_cut_point, prepare_compaction)
from .recovery import (AgentRunner, compute_retry_delay_ms, find_last_assistant,
is_context_overflow, is_recoverable_length,
is_retryable_assistant_error)
from .stream_fn import (classify_error, from_openai_messages, openai_stream,
to_openai_messages)
from .tools import default_tools
# 🆕 版本记录:核心框架为 pi (badlogic/pi-mono) 0.81.x 时代的 Python 移植,
# 2026-09 对齐 pi 0.85.1 的估算/锚定/钳制/重试语义(P0/P1/P2/M1/M2/M3)。
__version__ = "1.0.0 (pi 0.81.x port, aligned 0.85.1 semantics)"
__all__ = [
"Agent", "AgentConfig", "AgentError", "AgentEvent", "AgentMessage",
"AgentRunner", "AgentState", "AgentTool", "AgentToolResult",
"AbortSignal", "AssistantMessageEvent", "CONTEXT_SAFETY_TOKENS",
"ModelConfig", "RetryConfig", "RunResult", "ToolCall",
"clamp_max_tokens_to_context", "calculate_context_tokens", "classify_error", "compact_context",
"compute_retry_delay_ms", "default_tools", "estimate_context_tokens",
"find_last_assistant", "find_cut_point", "is_context_overflow",
"is_recoverable_length", "is_retryable_assistant_error", "new_id",
"openai_stream", "should_compact", "to_openai_messages",
]
+204
View File
@@ -0,0 +1,204 @@
"""
core/agent/agent.py
===================
🌟 pi Agent 状态机的 Python 1:1 移植
对照 pi-main 源码:packages/agent/src/agent.ts (592 行)
class Agent {
state: AgentState # messages / isStreaming / error
config: AgentConfig
private listeners: Set<cb>
subscribe(cb) => unsubscribe # 事件订阅(TUI/Qt 都靠它)
prompt(message | text) => Promise # 追加 user 消息并跑一轮 run-to-settle
continue() => Promise # 从当前上下文继续(不能以 assistant 结尾)
steer(text) # 中途注入(turn 边界消费)
followUp(text) # 停止后注入(agent 停止时消费 → 续跑)
abort() # 中止当前 run(下一边界生效)
}
关键语义(与 pi 完全一致):
1. steering 在「循环开始前」和「每个 turn 结束后」被消费
one-at-a-time 每次取 1 条;"all" 一次取光)
2. followUp 只在「run 即将结束」时被外层循环消费 → 触发续跑
3. abort 不抛异常:信号置位 → 当前流/工具在下一检查点收尾 →
助手消息 stop_reason="aborted" → agent_end
4. 同一个 Agent 实例可反复 promptstate.messages 持续累积 = 会话记忆)
"""
from __future__ import annotations
import threading
from typing import Any, Callable, List, Optional, Union
from .loop import run_loop
from .types import (AgentConfig, AgentError, AgentEvent, AgentMessage,
AgentState, AbortSignal, RunResult, new_id)
class Agent:
def __init__(self, config: AgentConfig):
self.config = config
self.state = AgentState()
self._listeners: List[Callable[[AgentEvent], None]] = []
self._steering_queue: List[AgentMessage] = []
self._follow_up_queue: List[AgentMessage] = []
self._lock = threading.Lock()
self._active_signal: Optional[AbortSignal] = None
# ------------------------------------------------------------------
# 事件订阅 —— 对照 agent.ts subscribe()
# ------------------------------------------------------------------
def subscribe(self, cb: Callable[[AgentEvent], None]) -> Callable[[], None]:
self._listeners.append(cb)
def unsubscribe():
try:
self._listeners.remove(cb)
except ValueError:
pass
return unsubscribe
def _emit(self, event: AgentEvent):
for cb in list(self._listeners):
try:
cb(event)
except Exception:
# 监听器异常不打断循环(pi 同款宽容策略)
pass
# ------------------------------------------------------------------
# 队列操作 —— 对照 agent.ts 的 getSteeringMessages / getFollowUpMessages
# ------------------------------------------------------------------
def _take_steering(self) -> List[AgentMessage]:
mode = self.config.steering_mode
with self._lock:
if not self._steering_queue:
return []
if mode == "all":
out = self._steering_queue[:]
self._steering_queue.clear()
else: # one-at-a-timepi 默认)
out = [self._steering_queue.pop(0)]
return out
def _take_follow_ups(self) -> List[AgentMessage]:
mode = self.config.follow_up_mode
with self._lock:
if not self._follow_up_queue:
return []
if mode == "all":
out = self._follow_up_queue[:]
self._follow_up_queue.clear()
else:
out = [self._follow_up_queue.pop(0)]
return out
def has_queued(self) -> bool:
with self._lock:
return bool(self._steering_queue or self._follow_up_queue)
# ------------------------------------------------------------------
# 用户入口 —— 对照 agent.ts prompt / steer / followUp / abort
# ------------------------------------------------------------------
def prompt(self, message: Union[str, AgentMessage]) -> RunResult:
"""追加一条 user 消息并运行到 settle(同步阻塞,跑在调用线程上)"""
if self.state.is_streaming:
raise AgentError(message="Agent 正在运行中,不能并发 prompt(请先 abort",
kind="unknown")
if isinstance(message, str):
message = AgentMessage(role="user", content=message)
if message.role != "user":
raise AgentError(message="prompt 只接受 user 消息", kind="unknown")
self.state.messages.append(message)
return self._run(new_message=message)
def continue_(self) -> RunResult:
"""
对照 agent.ts continue() / runAgentLoopContinue 前置检查:
- 上下文不能为空
- 不能以 assistant 消息结尾
"""
if self.state.is_streaming:
raise AgentError(message="Agent 正在运行中", kind="unknown")
if not self.state.messages:
raise AgentError(message="上下文为空,无法 continue", kind="unknown")
if self.state.messages[-1].role == "assistant":
raise AgentError(
message="不能以 assistant 消息结尾来 continue(应先注入 user/toolResult",
kind="unknown")
return self._run(new_message=None)
def steer(self, text: str):
"""中途注入:在当前 run 的 turn 边界被消费(pi steering"""
with self._lock:
self._steering_queue.append(AgentMessage(role="user", content=text))
def follow_up(self, text: str):
"""停止后注入:run 即将结束时被外层循环消费 → 自动续跑(pi followUp"""
with self._lock:
self._follow_up_queue.append(AgentMessage(role="user", content=text))
def abort(self):
"""
中止当前 run。
对照 pi: 置位 AbortSignal;当前流在 chunk 边界关闭,
工具在检查点返回 "Operation aborted",助手消息以 "aborted" 收尾。
"""
sig = self._active_signal
if sig is not None:
sig.abort("aborted")
with self._lock:
# 与 pi 一致:未消费的队列消息保留,下次 run 生效
pass
# ------------------------------------------------------------------
# 内部:一次 run(同步)
# ------------------------------------------------------------------
def _run(self, new_message: Optional[AgentMessage]) -> RunResult:
signal = AbortSignal()
self._active_signal = signal
self.state.is_streaming = True
self.state.error = None
try:
result = run_loop(self, new_message, signal, self._make_stream_fn())
except AgentError:
raise
except Exception as e:
self.state.error = AgentError(message=f"循环异常: {e}", kind="unknown")
self._emit(AgentEvent(type="agent_end", stop_reason="error",
error=self.state.error))
result = RunResult(stop_reason="error", error=self.state.error)
finally:
self.state.is_streaming = False
self.state.streaming_message = None
self.state.streaming_delta = {}
self._active_signal = None
return result
def _finish_run(self, new_messages: List[AgentMessage], stop_reason: str,
error: Optional[AgentError]):
"""循环收尾回调(loop 层调用)"""
if error is not None:
self.state.error = error
# 本轮新消息已在循环中逐条 append 进 state.messages;此处仅记录收尾
self._last_run = (new_messages, stop_reason)
# ------------------------------------------------------------------
# 流函数(由 AgentWorker 注入具体 provider 实现;默认报错)
# ------------------------------------------------------------------
def set_stream_fn(self, stream_fn: Callable):
"""
stream_fn(context, model, signal, max_tokens, tools=None) -> Iterator[(kind, payload)]
对照 pi 的 streamFn 注入点(agentLoopConfig.streamFn)。
tools 参数 = 本轮可用的 AgentTool 列表,需序列化进 provider 请求。
"""
self._stream_fn = stream_fn
def _make_stream_fn(self) -> Callable:
fn = getattr(self, "_stream_fn", None)
if fn is None:
def _missing(context, model, signal, max_tokens, tools=None):
raise AgentError(message="未配置 stream_fn", kind="unknown")
yield # pragma: no cover
return _missing
return fn
+641
View File
@@ -0,0 +1,641 @@
"""
core/agent/compaction.py
========================
🌟 pi 上下文压缩算法的 Python 1:1 移植(harness 原版算法)
对照 pi-main 源码(逐函数对应):
packages/agent/src/harness/compaction/compaction.ts
DEFAULT_COMPACTION_SETTINGS -> CompactionSettings / DEFAULT_COMPACTION_SETTINGS
shouldCompact -> 见 context.py: should_compact(同一公式)
estimateTokens / estimateContextTokens -> context.pyusage 锚定,CJK 感知见说明)
findValidCutPoints / findCutPoint -> find_valid_cut_points / find_cut_point
findTurnStartIndex -> find_turn_start
prepareCompaction -> prepare_compaction
SUMMARIZATION_SYSTEM_PROMPT -> 同名(逐字移植)
SUMMARIZATION_PROMPT -> 同名(逐字移植)
UPDATE_SUMMARIZATION_PROMPT -> 同名(逐字移植,迭代更新用)
TURN_PREFIX_SUMMARIZATION_PROMPT -> 同名(逐字移植,断轮前缀用)
generateSummaryWithUsage -> generate_summarymaxTokens = 0.8×reserve
generateTurnPrefixSummary -> generate_turn_prefix_summary0.5×reserve
compact -> compact_context(断轮双摘要 + 拼接格式 1:1)
packages/agent/src/harness/compaction/utils.ts
serializeConversation -> serialize_conversation1:1,含 2000 字符截断)
extractFileOpsFromMessage -> extract_file_ops_from_message
computeFileLists -> compute_file_lists
formatFileOperations -> format_file_operations
TOOL_RESULT_MAX_CHARS = 2000 -> 同名常量
摘要 LLM 调用由上层注入:
summarize_fn(prompt_text: str, system_prompt: str, max_tokens: int) -> str
pi 里是 models.completeSimple + retryhaocode 用 OpenAI 客户端非流式调用,
由 llm_engine.AgentWorker 实现并注入。)
已声明的偏差(仅 2 处,见 context.py 头注):
1. 单条消息 token 估算用 CJK 感知启发式(pi 是 chars/4)——对中文会话更安全
2. 摘要 LLM 调用失败时降级为机械摘录(pi 返回 CompactionError)——桌面应用优先不丢上下文
其余全部 1:1:触发公式、usage 锚定、token 预算切点、有效切点规则、断轮双摘要、
迭代式 previousSummary 更新、摘要提示词逐字、文件操作附录、拼接格式。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Callable, List, Optional, Set, Tuple
from .types import AgentMessage, ModelConfig
# ======================================================================
# 压缩设置 —— 1:1 对照 DEFAULT_COMPACTION_SETTINGS
# ======================================================================
@dataclass
class CompactionSettings:
"""pi: interface CompactionSettings { enabled; reserveTokens; keepRecentTokens }"""
enabled: bool = True
reserve_tokens: int = 16384 # pi: 16384(摘要提示词与输出预留)
keep_recent_tokens: int = 20000 # pi: 20000(压缩后保留的近期上下文预算)
DEFAULT_COMPACTION_SETTINGS = CompactionSettings()
# pi utils.ts: const TOOL_RESULT_MAX_CHARS = 2000
TOOL_RESULT_MAX_CHARS = 2000
# ======================================================================
# 摘要提示词 —— 从 pi compaction.ts 逐字移植(不得改写,摘要质量依赖它)
# ======================================================================
SUMMARIZATION_SYSTEM_PROMPT = \
"You are a context summarization assistant. Your task is to read a conversation " \
"between a user and an AI assistant, then produce a structured summary following " \
"the exact format specified.\n\n" \
"Do NOT continue the conversation. Do NOT respond to any questions in the " \
"conversation. ONLY output the structured summary."
SUMMARIZATION_PROMPT = """The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
Use this EXACT format:
## Goal
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned by user]
- [Or "(none)" if none were mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues preventing progress, if any]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [Ordered list of what should happen next]
## Critical Context
- [Any data, examples, or references needed to continue]
- [Or "(none)" if not applicable]
Keep each section concise. Preserve exact file paths, function names, and error messages."""
UPDATE_SUMMARIZATION_PROMPT = """The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
Update the existing structured summary with new information. RULES:
- PRESERVE all existing information from the previous summary
- ADD new progress, decisions, and context from the new messages
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
- UPDATE "Next Steps" based on what was accomplished
- PRESERVE exact file paths, function names, and error messages
- If something is no longer relevant, you may remove it
Use this EXACT format:
## Goal
[Preserve existing goals, add new ones if the task expanded]
## Constraints & Preferences
- [Preserve existing, add new ones discovered]
## Progress
### Done
- [x] [Include previously done items AND newly completed items]
### In Progress
- [ ] [Current work - update based on progress]
### Blocked
- [Current blockers - remove if resolved]
## Key Decisions
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
## Next Steps
1. [Update based on current state]
## Critical Context
- [Preserve important context, add new if needed]
Keep each section concise. Preserve exact file paths, function names, and error messages."""
TURN_PREFIX_SUMMARIZATION_PROMPT = """This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
Summarize the prefix to provide context for the retained suffix:
## Original Request
[What did the user ask for in this turn?]
## Early Progress
- [Key decisions and work done in the prefix]
## Context for Suffix
- [Information needed to understand the retained recent work]
Be concise. Focus on what's needed to understand the kept suffix."""
# ======================================================================
# 对话序列化 —— 1:1 对照 utils.ts serializeConversation
# ======================================================================
def _safe_json(value) -> str:
try:
s = json.dumps(value, ensure_ascii=False)
return s if s is not None else "undefined"
except Exception:
return "[unserializable]"
def _content_text(content) -> str:
"""pi contentText: str 或 [{type:"text",text}] 列表取文本拼接"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for b in content:
if isinstance(b, dict) and b.get("type") == "text":
parts.append(str(b.get("text", "")))
return "\n".join(p for p in parts if p)
return str(content)
def _truncate_for_summary(text: str, max_chars: int) -> str:
"""pi utils.ts truncateForSummary(逐字逻辑)"""
if len(text) <= max_chars:
return text
truncated = len(text) - max_chars
return f"{text[:max_chars]}\n\n[... {truncated} more characters truncated]"
def serialize_conversation(messages: List[AgentMessage]) -> str:
"""
1:1 对照 utils.ts serializeConversation(输出格式逐字一致):
[User]: ...
[Assistant thinking]: ...
[Assistant]: ...
[Assistant tool calls]: name(k=v, k2=v2); name2(...)
[Tool result]: ...(超 2000 字符截断)
"""
parts: List[str] = []
for msg in messages:
if msg.role == "user":
content = _content_text(msg.content)
if content:
parts.append(f"[User]: {content}")
elif msg.role == "assistant":
thinking_parts = []
tool_calls = []
if msg.reasoning:
thinking_parts.append(msg.reasoning)
text = _content_text(msg.content)
for tc in (msg.tool_calls or []):
args_str = ", ".join(f"{k}={_safe_json(v)}"
for k, v in (tc.arguments or {}).items())
tool_calls.append(f"{tc.name}({args_str})")
if thinking_parts:
parts.append(f"[Assistant thinking]: {chr(10).join(thinking_parts)}")
if text:
parts.append(f"[Assistant]: {text}")
if tool_calls:
parts.append(f"[Assistant tool calls]: {'; '.join(tool_calls)}")
elif msg.role == "toolResult":
content = _content_text(msg.content)
if content:
parts.append(f"[Tool result]: "
f"{_truncate_for_summary(content, TOOL_RESULT_MAX_CHARS)}")
return "\n\n".join(parts)
# ======================================================================
# 文件操作提取 —— 1:1 对照 utils.ts extractFileOps*/computeFileLists/formatFileOperations
# ======================================================================
class FileOperations:
def __init__(self):
self.read: Set[str] = set()
self.written: Set[str] = set()
self.edited: Set[str] = set()
def extract_file_ops_from_message(message: AgentMessage, file_ops: FileOperations):
"""pi: assistant 的 toolCall 参数里 path 字段 → read/write/edit 归类"""
if message.role != "assistant":
return
for tc in (message.tool_calls or []):
args = tc.arguments or {}
path = args.get("path")
if not isinstance(path, str) or not path:
continue
if tc.name == "read":
file_ops.read.add(path)
elif tc.name == "write":
file_ops.written.add(path)
elif tc.name == "edit":
file_ops.edited.add(path)
def compute_file_lists(file_ops: FileOperations) -> Tuple[List[str], List[str]]:
"""pi computeFileLists: modified = edited|writtenreadOnly = read-modified;均排序"""
modified = file_ops.edited | file_ops.written
read_only = sorted(f for f in file_ops.read if f not in modified)
return read_only, sorted(modified)
def format_file_operations(read_files: List[str], modified_files: List[str]) -> str:
"""pi formatFileOperations: <read-files>/<modified-files> 标签拼接"""
sections = []
if read_files:
sections.append("<read-files>\n" + "\n".join(read_files) + "\n</read-files>")
if modified_files:
sections.append("<modified-files>\n" + "\n".join(modified_files) + "\n</modified-files>")
if not sections:
return ""
return "\n\n" + "\n\n".join(sections)
# ======================================================================
# 切分点 —— 1:1 对照 findValidCutPoints / findTurnStartIndex / findCutPoint
# ======================================================================
@dataclass
class CutPointResult:
"""pi: interface CutPointResult"""
first_kept_index: int # 保留段首条在 compactable 列表里的下标
turn_start_index: int = -1 # 断轮时:该轮起点(user 消息)下标;否则 -1
is_split_turn: bool = False
def find_valid_cut_points(messages: List[AgentMessage],
start_index: int, end_index: int) -> List[int]:
"""
pi findValidCutPoints:消息角色为 user/assistant 的位置是有效切点
toolResult 不能做切点——它会与前面的 toolCall 分离)。
pi 里的 bashExecution/branchSummary/compactionSummary 等角色
在 haocode 消息模型中不存在,等价规则即 role in (user, assistant)。
"""
cut_points = []
for i in range(start_index, end_index):
if messages[i].role in ("user", "assistant"):
cut_points.append(i)
return cut_points
def find_turn_start(messages: List[AgentMessage], entry_index: int,
start_index: int) -> int:
"""pi findTurnStartIndex:向前找本轮起点(user 消息 / branch_summary"""
for i in range(entry_index, start_index - 1, -1):
if messages[i].role == "user":
return i
return -1
def _estimate(msg: AgentMessage) -> int:
from .context import estimate_message_tokens
return estimate_message_tokens(msg)
def find_cut_point(messages: List[AgentMessage], start_index: int,
end_index: int, keep_recent_tokens: int) -> CutPointResult:
"""
1:1 对照 pi findCutPoint
1. 从尾部向前累计 token,直到累计 >= keep_recent_tokens
2. 取该位置(含)之后的第一个有效切点
3. 切点不是 user 消息 → 断轮:找本轮起点,前缀单独摘要
(pi 的「回退跳过状态条目」循环针对 session 状态条目;
haocode 消息列表没有状态条目,等价省略。)
"""
cut_points = find_valid_cut_points(messages, start_index, end_index)
if not cut_points:
return CutPointResult(first_kept_index=start_index)
accumulated = 0
cut_index = cut_points[0]
for i in range(end_index - 1, start_index - 1, -1):
accumulated += _estimate(messages[i])
if accumulated >= keep_recent_tokens:
for c in cut_points:
if c >= i:
cut_index = c
break
break
is_user = messages[cut_index].role == "user"
turn_start = -1 if is_user else find_turn_start(messages, cut_index, start_index)
is_split = (not is_user) and turn_start != -1
return CutPointResult(first_kept_index=cut_index,
turn_start_index=turn_start,
is_split_turn=is_split)
# ======================================================================
# 压缩准备 —— 1:1 对照 prepareCompaction
# ======================================================================
@dataclass
class CompactionPreparation:
"""pi: interface CompactionPreparation"""
messages_to_summarize: List[AgentMessage] = field(default_factory=list)
turn_prefix_messages: List[AgentMessage] = field(default_factory=list)
retained_tail: List[AgentMessage] = field(default_factory=list)
is_split_turn: bool = False
tokens_before: int = 0
previous_summary: Optional[str] = None
file_ops: FileOperations = field(default_factory=FileOperations)
# 注意(P2-04 跨平台修复):Python 3.11+ 的 dataclass 禁止不可哈希默认值
# eq dataclass 实例 __hash__=None);Ubuntu 24.04 自带 3.12
# 直接写 `= DEFAULT_COMPACTION_SETTINGS` 在 3.11+ 会 ValueError。
# default_factory 返回同一共享实例,与 3.10 行为完全一致。
settings: CompactionSettings = field(default_factory=lambda: DEFAULT_COMPACTION_SETTINGS)
def _compaction_diag(line: str) -> None:
"""压缩自诊断日志(与 recovery.compact_diag_log 同一文件/规范:
print + 追加 compaction_diag.log + 失败静默)。"""
import os as _os
import time as _time
t = _time.time()
stamp = (f"[{_time.strftime('%H:%M:%S', _time.localtime(t))}"
f".{int(t * 1000) % 1000:03d}]")
print(f"{stamp} {line}", flush=True)
try:
path = _os.path.join(
_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))),
"compaction_diag.log")
with open(path, "a", encoding="utf-8") as f:
f.write(f"{stamp} {line}\n")
except Exception:
pass
def _nothing_to_summarize_diag(messages: List[AgentMessage],
compactable: List[AgentMessage],
cut: CutPointResult,
settings: CompactionSettings) -> str:
"""prepare_compaction 返回 None(无可摘要内容)时的自诊断行:
记录分支子类型 + 关键量,下次失败可直接从日志定位原因。
子类型:
no_valid_cut_points —— 可压缩范围内没有任何 user/assistant 条目
total_below_keep_recent —— 可压缩范围总量 < keep_recent(通常意味着
上下文主体是旧摘要本身,无新内容)
cut_pinned_at_zero —— 预算从尾部累加只在 i=0 才达标,即首条
条目独占 ≥ (总量-keep_recent) 的 token
(单条超长消息/巨型工具输出主导上下文)
"""
from .context import estimate_context_tokens, estimate_message_tokens
try:
total = estimate_context_tokens(messages).tokens
n_user = sum(1 for m in compactable if m.role == "user")
n_asst = sum(1 for m in compactable if m.role == "assistant")
ests = [(estimate_message_tokens(m), i) for i, m in enumerate(compactable)]
e0 = ests[0][0] if ests else 0
rest = sum(e for e, _ in ests[1:])
top3 = sorted(ests, reverse=True)[:3]
roles = " ".join(("T" if m.role == "toolResult" else m.role[0].upper())
for m in compactable[:10])
if not find_valid_cut_points(compactable, 0, len(compactable)):
sub = "no_valid_cut_points"
elif sum(e for e, _ in ests) < settings.keep_recent_tokens:
sub = "total_below_keep_recent"
else:
sub = "cut_pinned_at_zero"
big = "; ".join(f"idx{i}={e}" for e, i in top3)
return (f"[COMPACT_NONE] branch=nothing_to_summarize sub={sub} "
f"msgs={len(messages)} est_total={total} compactable={len(compactable)} "
f"keep_recent={settings.keep_recent_tokens} "
f"first_kept={cut.first_kept_index} turn_start={cut.turn_start_index} "
f"split={cut.is_split_turn} user={n_user} asst={n_asst} "
f"est_first={e0} est_rest={rest} top3=[{big}] head_roles=[{roles}]")
except Exception as ex: # 诊断本身失败不能影响主流程
return f"[COMPACT_NONE] branch=nothing_to_summarize (diag failed: {ex})"
def prepare_compaction(messages: List[AgentMessage],
settings: Optional[CompactionSettings] = None
) -> Optional[CompactionPreparation]:
"""
1:1 对照 pi prepareCompaction
- 上一条压缩摘要(messages[0].kind == "compaction_summary")不重复摘要,
其内容作为 previousSummary 走迭代更新提示词
- 可压缩范围 = 摘要之后的全部消息(pi 里等价于「上次保留尾 + 新消息」)
- 切点在可压缩范围内选;tokens_before 按完整上下文(含摘要消息)估算
不可压缩(空/无摘要对象)时返回 None(对照 pi 返回 ok(undefined))。
"""
from .context import estimate_context_tokens
settings = settings or DEFAULT_COMPACTION_SETTINGS
if not messages:
_compaction_diag("[COMPACT_NONE] branch=empty_messages")
return None
if messages[-1].kind == "compaction_summary":
_compaction_diag(
f"[COMPACT_NONE] branch=tail_is_summary msgs={len(messages)} "
f"tail_head={_content_text(messages[-1].content)[:60]!r}")
return None
previous_summary = None
if messages[0].kind == "compaction_summary":
previous_summary = _content_text(messages[0].content)
compactable = messages[1:]
else:
compactable = messages
if not compactable:
_compaction_diag("[COMPACT_NONE] branch=compactable_empty (上下文只剩旧摘要)")
return None
tokens_before = estimate_context_tokens(messages).tokens
cut = find_cut_point(compactable, 0, len(compactable),
settings.keep_recent_tokens)
history_end = cut.turn_start_index if cut.is_split_turn else cut.first_kept_index
messages_to_summarize = compactable[:history_end]
turn_prefix_messages = []
if cut.is_split_turn:
turn_prefix_messages = compactable[cut.turn_start_index:cut.first_kept_index]
retained_tail = compactable[cut.first_kept_index:]
if not messages_to_summarize and not turn_prefix_messages:
# 🆕 自诊断:记录是哪种子条件导致无东西可摘要(见 _nothing_to_summarize_diag
_compaction_diag(_nothing_to_summarize_diag(messages, compactable, cut,
settings))
return None # 没有可摘要内容
file_ops = FileOperations()
for m in messages_to_summarize:
extract_file_ops_from_message(m, file_ops)
if cut.is_split_turn:
for m in turn_prefix_messages:
extract_file_ops_from_message(m, file_ops)
return CompactionPreparation(
messages_to_summarize=messages_to_summarize,
turn_prefix_messages=turn_prefix_messages,
retained_tail=retained_tail,
is_split_turn=cut.is_split_turn,
tokens_before=tokens_before,
previous_summary=previous_summary,
file_ops=file_ops,
settings=settings,
)
# ======================================================================
# 摘要生成 —— 1:1 对照 generateSummaryWithUsage / generateTurnPrefixSummary
# ======================================================================
def _build_summary_prompt(conversation_text: str, previous_summary: Optional[str]) -> str:
"""pi generateSummaryWithUsage 的 prompt 组装(逐字结构)"""
base = UPDATE_SUMMARIZATION_PROMPT if previous_summary else SUMMARIZATION_PROMPT
prompt = f"<conversation>\n{conversation_text}\n</conversation>\n\n"
if previous_summary:
prompt += f"<previous-summary>\n{previous_summary}\n</previous-summary>\n\n"
prompt += base
return prompt
def generate_summary(messages: List[AgentMessage],
summarize_fn: Callable[[str, str, int], str],
reserve_tokens: int,
model_max_tokens: int,
previous_summary: Optional[str] = None
) -> Tuple[Optional[str], Optional[str]]:
"""
返回 (summary_text, error)。
maxTokens = min(0.8 × reserveTokens, model.maxTokens) —— 1:1 对照。
"""
max_tokens = min(
int(0.8 * reserve_tokens),
model_max_tokens if model_max_tokens > 0 else (1 << 30),
)
conversation = serialize_conversation(messages)
prompt = _build_summary_prompt(conversation, previous_summary)
try:
text = summarize_fn(prompt, SUMMARIZATION_SYSTEM_PROMPT, max_tokens)
except Exception as e:
return None, f"Summarization failed: {e}"
if not text or not text.strip():
return None, "Summarization failed: empty response"
return text.strip(), None
def generate_turn_prefix_summary(messages: List[AgentMessage],
summarize_fn: Callable[[str, str, int], str],
reserve_tokens: int,
model_max_tokens: int
) -> Tuple[Optional[str], Optional[str]]:
"""maxTokens = min(0.5 × reserveTokens, model.maxTokens) —— 1:1 对照"""
max_tokens = min(
int(0.5 * reserve_tokens),
model_max_tokens if model_max_tokens > 0 else (1 << 30),
)
conversation = serialize_conversation(messages)
prompt = f"<conversation>\n{conversation}\n</conversation>\n\n{TURN_PREFIX_SUMMARIZATION_PROMPT}"
try:
text = summarize_fn(prompt, SUMMARIZATION_SYSTEM_PROMPT, max_tokens)
except Exception as e:
return None, f"Turn prefix summarization failed: {e}"
if not text or not text.strip():
return None, "Turn prefix summarization failed: empty response"
return text.strip(), None
# ======================================================================
# 主入口 —— 1:1 对照 compact()
# ======================================================================
def compact_context(messages: List[AgentMessage],
model: ModelConfig,
summarize_fn: Callable[[str, str, int], str],
settings: Optional[CompactionSettings] = None
) -> Optional[List[AgentMessage]]:
"""
执行压缩。返回 [压缩摘要消息] + 保留尾巴;不可压缩时返回 None。
摘要消息: role="user", kind="compaction_summary"content 为纯摘要文本
pi 的 compaction 条目;下次压缩时自动走迭代更新提示词)。
断轮(切点落在某轮中间)时 1:1 对照 pi compact()
历史摘要 与 轮前缀摘要 分两次 LLM 调用,拼接为
{history}\n\n---\n\n**Turn Context (split turn):**\n\n{prefix}
最后追加文件操作附录(<read-files>/<modified-files>)。
"""
settings = settings or DEFAULT_COMPACTION_SETTINGS
prep = prepare_compaction(messages, settings)
if prep is None:
return None
history_text: Optional[str] = None
prefix_error: Optional[str] = None
history_error: Optional[str] = None
if prep.is_split_turn and prep.turn_prefix_messages:
if prep.messages_to_summarize:
history_text, history_error = generate_summary(
prep.messages_to_summarize, summarize_fn,
prep.settings.reserve_tokens, model.max_tokens,
prep.previous_summary)
if history_error:
return _degraded_compact(prep, history_error)
else:
history_text = "No prior history."
prefix_text, prefix_error = generate_turn_prefix_summary(
prep.turn_prefix_messages, summarize_fn,
prep.settings.reserve_tokens, model.max_tokens)
if prefix_error:
return _degraded_compact(prep, prefix_error)
summary = (f"{history_text}\n\n---\n\n"
f"**Turn Context (split turn):**\n\n{prefix_text}")
else:
if not prep.messages_to_summarize:
return None
summary, history_error = generate_summary(
prep.messages_to_summarize, summarize_fn,
prep.settings.reserve_tokens, model.max_tokens,
prep.previous_summary)
if history_error:
return _degraded_compact(prep, history_error)
read_files, modified_files = compute_file_lists(prep.file_ops)
summary += format_file_operations(read_files, modified_files)
summary_msg = AgentMessage(role="user", content=summary,
kind="compaction_summary")
return [summary_msg] + prep.retained_tail
def _degraded_compact(prep: CompactionPreparation,
error: str) -> Optional[List[AgentMessage]]:
"""
已声明偏差(对照 pi: 直接返回 CompactionError):
桌面应用优先「不丢上下文」——摘要失败时降级为机械摘录,
保留尾巴原样不动。
"""
old = prep.messages_to_summarize + prep.turn_prefix_messages
if not old:
return None
excerpt = serialize_conversation(old)[-500:]
summary = (f"(自动压缩:摘要生成失败 [{error}],以下为旧对话尾部摘录)\n\n{excerpt}")
summary_msg = AgentMessage(role="user", content=summary,
kind="compaction_summary")
return [summary_msg] + prep.retained_tail
+341
View File
@@ -0,0 +1,341 @@
"""
core/agent/context.py
=====================
🌟 pi 上下文管理的 Python 1:1 移植 —— 令牌估算 + 输出预算钳制
对照 pi-main 源码:
packages/ai/src/api/simple-options.ts
- CONTEXT_SAFETY_TOKENS = 4096 (行 12)
- clampMaxTokensToContext() (行 12-34)
maxOutput = contextWindow - inputTokens - 4096
return min(model.maxTokens, maxOutput) (下限 1)
- clampOutputsToContext() (行 36-60)
多模型共享窗口时按比例分配剩余输出预算
packages/ai/src/api/token-utils.ts
- 每条消息估算: ceil(chars/4) + 4
🌟 已声明偏差(仅 1 处,其余全部 1:1):
pi 按 chars/4 估算(针对英文)。中文 1 字 ≈ 1 token,chars/4 会严重低估。
这里采用 CJK 感知估算:CJK 字符按 1 token/字,其余按 4 字符/token。
这直接影响「压缩触发时机」和「输出预算钳制」,必须更准才不会撞 vLLM 上限。
pi 的 usage 锚定机制 1:1 保留:若历史里存在有效 assistant usage
总估算 = 该 usage 的 totalTokens(服务商精确值)+ 其后消息的逐条估算。
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from .types import AgentMessage, ModelConfig
# ======================================================================
# 对照 simple-options.ts: export const CONTEXT_SAFETY_TOKENS = 4096
# 用途:上下文钳制时预留的安全余量(tokenizer 估算误差 + vLLM 精确计数的偏差)
# ======================================================================
CONTEXT_SAFETY_TOKENS = 4096
# 图片 token 估算(对照 pi 对 image part 的固定估算)
IMAGE_TOKENS = 1600
_CJK_RE = re.compile(
r"[\u2e80-\u2eff\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff"
r"\uf900-\ufaff\uff00-\uffef]"
)
def _count_text_chars(value: Any) -> int:
"""统计内容里的字符数(对照 pi token-utils 的字符统计口径)"""
if isinstance(value, str):
return len(value)
if isinstance(value, list): # OpenAI 多模态 content 数组
n = 0
for part in value:
if isinstance(part, dict):
if part.get("type") == "text":
n += len(part.get("text", "") or "")
elif part.get("type") == "image_url":
n += 0 # 图片单独按 IMAGE_TOKENS 计
else:
n += len(str(part))
return n
return len(str(value or ""))
def _count_image_parts(content: Any) -> int:
if isinstance(content, list):
return sum(1 for p in content
if isinstance(p, dict) and p.get("type") == "image_url")
return 0
def estimate_message_tokens(message: AgentMessage) -> int:
"""
单条消息 token 估算(CJK 感知版)。
pi 口径: ceil(chars/4) + 4。本项目: CJK 字 ×1 + 其余 /4 + 4 + 图片×1600。
"""
raw = message.content
if isinstance(raw, str):
chars = len(raw)
cjk = len(_CJK_RE.findall(raw))
else:
# 数组 content:拼接所有文本部分
joined = []
for part in raw if isinstance(raw, list) else []:
if isinstance(part, dict) and part.get("type") == "text":
joined.append(part.get("text", "") or "")
text = "".join(joined)
chars = len(text)
cjk = len(_CJK_RE.findall(text))
tokens = cjk + math.ceil((chars - cjk) / 4) + 4
tokens += _count_image_parts(raw) * IMAGE_TOKENS
# 思考内容与工具参数也占上下文(assistant 的 reasoning / tool_calls
if message.reasoning:
rcjk = len(_CJK_RE.findall(message.reasoning))
tokens += rcjk + math.ceil((len(message.reasoning) - rcjk) / 4)
for tc in message.tool_calls:
arg_text = _json_str(tc.arguments)
tokens += math.ceil(len(arg_text) / 4)
# toolResult 的 content 走 content 字段(str 或 list
return tokens
def _json_str(obj: Any) -> str:
import json
try:
return json.dumps(obj, ensure_ascii=False)
except Exception:
return str(obj)
def _estimate_text_tokens(text: str) -> int:
"""CJK 感知文本 token 估算(与 estimate_message_tokens 同公式,不含每条 +4 开销)"""
if not text:
return 0
cjk = len(_CJK_RE.findall(text))
return cjk + math.ceil((len(text) - cjk) / 4)
def _estimate_tools_tokens(tools) -> int:
"""
🆕 P2: 工具 schema token 估算 —— name/description/parameters 的 JSON 字符数(CJK 感知)。
对照 pi Context 分支: all tools = Σ estimate_text(json(tool))。
"""
total = 0
for t in tools or []:
schema = {
"name": getattr(t, "name", "") or "",
"description": getattr(t, "description", "") or "",
"parameters": getattr(t, "parameters", None),
}
total += _estimate_text_tokens(_json_str(schema))
return total
def estimate_context_tokens(messages: List[AgentMessage],
system_prompt: str = "",
tools: Optional[List[Any]] = None
) -> "ContextUsageEstimate":
"""
🌟 1:1 对照 pi estimateContextTokensusage 锚定):
- 找到最后一条带有效 usage 的 assistant 消息(🆕 P0: 含 timestamp 时效校验)
- 总 tokens = 该 usage 的精确值 + 其后消息的逐条估算
- 无 usage 时 = 全部消息逐条估算
🆕 P2: 可选 system_prompt / tools 参数(对照 pi Context 分支):
- 仅在无锚点分支计入 system 提示词 + 工具 schema 的估算
(有锚点时 usage 已是含 system+tools 的精确总值,不重复计)
逐条估算用 CJK 感知版 estimate_message_tokens(已声明偏差)。
"""
last_idx, last_usage = _find_last_usage(messages)
sys_tokens = _estimate_text_tokens(system_prompt) if system_prompt else 0
tool_tokens = _estimate_tools_tokens(tools) if tools else 0
if last_idx is None:
estimated = (sum(estimate_message_tokens(m) for m in messages)
+ sys_tokens + tool_tokens)
return ContextUsageEstimate(tokens=estimated, usage_tokens=0,
trailing_tokens=estimated, last_usage_index=None)
usage_tokens = calculate_context_tokens(last_usage)
trailing = sum(estimate_message_tokens(m) for m in messages[last_idx + 1:])
return ContextUsageEstimate(tokens=usage_tokens + trailing,
usage_tokens=usage_tokens,
trailing_tokens=trailing,
last_usage_index=last_idx)
@dataclass
class ContextUsageEstimate:
"""
🌟 1:1 对照 pi estimateContextTokens 的返回值
packages/agent/src/harness/compaction/compaction.ts ContextUsageEstimate:
tokens 总估算
usageTokens 最后一条有效 assistant usage 报告的精确 token 数
trailingTokens 该 usage 之后消息的估算值
lastUsageIndex 提供 usage 的消息下标(无则 None)
"""
tokens: int = 0
usage_tokens: int = 0
trailing_tokens: int = 0
last_usage_index: Optional[int] = None
def calculate_context_tokens(usage: Dict[str, Any]) -> int:
"""pi calculateContextTokens: totalTokens || input+output+cacheRead+cacheWrite"""
if not usage:
return 0
total = int(usage.get("totalTokens", 0) or 0)
if total:
return total
return (int(usage.get("input", 0) or 0) + int(usage.get("output", 0) or 0)
+ int(usage.get("cacheRead", 0) or 0)
+ int(usage.get("cacheWrite", 0) or 0))
def _get_assistant_usage(msg: AgentMessage) -> Dict[str, Any]:
"""
pi getAssistantUsage:只认「有效」的 assistant usage——
stopReason 不是 aborted/error,且 calculateContextTokens > 0。
(🆕 P0: 锚点扫描还需通过 timestamp 时效校验,见 _find_last_usage
"""
if msg.role != "assistant":
return {}
if msg.stop_reason in ("aborted", "error"):
return {}
usage = msg.usage or {}
if usage and calculate_context_tokens(usage) > 0:
return usage
return {}
def _row_block_tool_flags(messages: List["AgentMessage"]) -> List[bool]:
"""
🆕 G1: 逐条标记「其 DB 行(同非空 db_msg_id 的连续块)是否含工具活动」。
含工具输出的行,其入库 usage 是「本轮最后一个子请求」的内存快照
(工具结果未受 4000 字回放截断),不等于下一请求(截断回放)→ 锚点失效。
轮中内存条目(db_msg_id="")一律 False:保持原规则
(轮中条目的内存载荷 == 下一子请求载荷,锚点有效)。
"""
n = len(messages)
flags = [False] * n
i = 0
while i < n:
rid = getattr(messages[i], "db_msg_id", "") or ""
if not rid:
i += 1
continue
j = i
has_tool = False
while j < n and (getattr(messages[j], "db_msg_id", "") or "") == rid:
mj = messages[j]
if mj.role == "toolResult" or (mj.role == "assistant" and mj.tool_calls):
has_tool = True
j += 1
for k in range(i, j):
flags[k] = has_tool
i = j
return flags
def _find_last_usage(messages: List["AgentMessage"]):
"""
🆕 P0: 1:1 对照 pi getLastAssistantUsageInfo(含锚点失效校验):
assistant 的 usage 要成为锚点,必须满足
① timestamp >= 它前面所有消息的最大 timestamp(即它不比任何前消息「更旧」)。
场景:压缩后 state = [新摘要消息(T_new)] + [保留的旧消息(T_old)]
保留窗里 assistant 的 usage 来自压缩前(如 60K 精确值)——不校验的话,
下一次 should_compact 会锚定到过期值 → 误触发二次压缩(摘要套摘要)。
摘要消息 timestamp 晚于全部保留消息 → 前缀游标抬高 → 旧 usage 自动失效;
压缩后第一个新 assistant 回复到达 → 新 usage 锚点自动恢复。
② 🆕 G1: 该 usage 所在 DB 行不含工具活动(toolResult / assistant.tool_calls)。
含工具输出的行,入库 usage = 本轮内存快照(工具结果未截断)≠ 下一请求
(4000 字截断回放)→ 系统性高估 → 误触发压缩(实测 113904 锚点 vs
真实下一请求 ~90k)。纯文本行 / 轮中内存条目的 usage 仍是精确值,保留锚定。
(消息无 timestamp 信息(全 0)时行为等同旧版「取最后一条有效」,完全向后兼容。)
"""
latest_prefix_ts = -1 # 对照 pi: Number.NEGATIVE_INFINITY
last_idx = None
last_usage: Dict[str, Any] = {}
row_tool_flags = _row_block_tool_flags(messages)
for i, msg in enumerate(messages):
if msg.role == "assistant":
applies = (msg.timestamp or 0) >= latest_prefix_ts
if (applies
and not row_tool_flags[i]
and msg.stop_reason not in ("aborted", "error")
and msg.usage
and calculate_context_tokens(msg.usage) > 0):
last_idx, last_usage = i, msg.usage
ts = msg.timestamp or 0
if ts > latest_prefix_ts:
latest_prefix_ts = ts
return last_idx, last_usage
def clamp_max_tokens_to_context(model: ModelConfig,
context: List[AgentMessage],
system_prompt: str = "",
tools: Optional[List[Any]] = None
) -> Optional[Tuple[int, int]]:
"""
🌟 对照 simple-options.ts:12-34 clampMaxTokensToContext1:1 公式):
input_estimate = estimateContextTokens(context) # 🆕 P2: 含 system+toolspi Context 实参)
if (input_estimate > model.contextWindow) return null // 上下文已溢出
maxOutput = contextWindow - inputTokens - CONTEXT_SAFETY_TOKENS
return max(1, min(model.maxTokens, maxOutput))
这就是 pi 约束公式的本体:
输入 + 输出 ≤ contextWindow 4096 ≤ vLLM max_model_len
🆕 P2: system_prompt / tools 仅在无 usage 锚点分支计入(首轮流式精确记账,
与 pi 传完整 Context 的行为一致);有锚点时 usage 已精确覆盖,不重复计。
返回 (max_tokens, input_tokens);输入已溢出窗口时返回 None(由恢复逻辑接管)。
"""
input_tokens = estimate_context_tokens(
context, system_prompt=system_prompt, tools=tools).tokens
if input_tokens > model.context_window:
return None
max_output = model.context_window - input_tokens - CONTEXT_SAFETY_TOKENS
return max(1, min(model.max_tokens, max_output)), input_tokens
def clamp_outputs_to_context(context: List[AgentMessage],
models: List[ModelConfig],
reserve_tokens: int = CONTEXT_SAFETY_TOKENS
) -> List[int]:
"""
对照 simple-options.ts:36-60 clampOutputsToContext
多个模型共享同一窗口时,把剩余输出预算按比例分配给各模型。
(haocode 单模型场景用不到,保留以求框架完整)
"""
input_tokens = estimate_context_tokens(context).tokens
available = max(0, sum(m.context_window for m in models) - input_tokens - reserve_tokens)
if not models:
return []
weights = [m.max_tokens for m in models]
total_w = sum(weights) or 1
alloc = [max(1, int(available * w / total_w)) for w in weights]
# 各自不超过自身 max_tokens
alloc = [min(a, m.max_tokens) for a, m in zip(alloc, models)]
return alloc
def should_compact(messages: List[AgentMessage], model: ModelConfig,
reserve_tokens: int = 16384,
system_prompt: str = "",
tools: Optional[List[Any]] = None) -> Tuple[bool, int]:
"""
🌟 1:1 对照 pi compaction.ts shouldCompactharness 原版公式):
contextTokens > contextWindow - reserveTokens
reserveTokens 默认 16384DEFAULT_COMPACTION_SETTINGS.reserveTokens),
即「为摘要提示词与输出预留 16K」。估算值取 usage 锚定估算。
🆕 G2: system_prompt/tools 透传 —— 无锚点分支(工具行快照锚点被 G1 失效后
更常走到)也要计入 system + 工具 schema,度量「下一请求真实载荷」,
与显示端(update_context_display)同口径。
"""
tokens = estimate_context_tokens(messages, system_prompt=system_prompt,
tools=tools).tokens
return (tokens > model.context_window - reserve_tokens), tokens
+482
View File
@@ -0,0 +1,482 @@
"""
core/agent/loop.py
==================
🌟 pi 核心循环的 Python 1:1 移植 —— 整个框架的心脏
对照 pi-main 源码(packages/agent/src/agent-loop.ts, 796 行):
runAgentLoop() → run_loop(agent, new_message, ...)
runLoop() → _run_loop() (行 163-278)
streamAssistantResponse() → _stream_turn() (行 281-380)
executeToolCalls() → execute_tool_calls() (行 413-427)
executeToolCallsParallel/Sequential → _execute_parallel/_execute_sequential
prepareToolCall() → tools.prepare_tool_calls
failToolCallsFromTruncatedMessage → tools.fail_tool_calls_from_truncated_message
shouldTerminateToolBatch → _should_terminate_batch (行 561-563)
pi runLoop 的结构(本文件逐行对应):
emit agent_start
pending = getSteeringMessages() # 循环开始前先取一次
outer: while True:
inner: while hasMoreToolCalls or pending:
turn_start(首轮不发,首轮 turn_start 在 runAgentLoop 入口发)
注入 pendingmessage_start/end → 入 context + newMessages
assistant = streamAssistantResponse(...)
if stopReason in (error, aborted): turn_end + agent_end + return
toolCalls = assistant 的 toolCall 块
if toolCalls:
length → failToolCallsFromTruncatedMessage(不执行!)
否则 → executeToolCallssequential/parallel 二选一)
hasMoreToolCalls = !batch.terminate
toolResults 入 context + newMessages
turn_end
prepareNextTurn 钩子(可换 context/model
shouldStopAfterTurn 钩子 → agent_end + return
pending = getSteeringMessages() # 每轮结束取一次
followUps = getFollowUpMessages()
if followUps: pending = followUps; continue # 外层续跑
break
agent_end
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple
from .context import clamp_max_tokens_to_context
from .tools import (PreparedToolCall, execute_tool_call, fail_tool_calls_from_truncated_message,
prepare_tool_calls)
from .types import (AgentConfig, AgentError, AgentEvent, AgentMessage,
AgentTool, AgentToolResult, AbortSignal, AssistantMessageEvent,
RunResult, ToolCall, new_id)
# ======================================================================
# 事件发射辅助
# ======================================================================
def _emit(agent, event: AgentEvent):
agent._emit(event)
def _message_events(agent, msg: AgentMessage):
_emit(agent, AgentEvent(type="message_start", message=msg))
_emit(agent, AgentEvent(type="message_end", message=msg))
def _tool_result_message(finalized: Dict[str, Any]) -> AgentMessage:
"""对照 createToolResultMessage"""
tc: ToolCall = finalized["tool_call"]
result: AgentToolResult = finalized["result"]
return AgentMessage(
role="toolResult",
tool_call_id=tc.id,
tool_name=tc.name,
content=result.content,
is_error=finalized.get("is_error", False) or result.is_error,
)
# ======================================================================
# 流式生成一轮助手消息 —— 对照 streamAssistantResponse (行 281-380)
# ======================================================================
def _stream_turn(agent, current_context: List[AgentMessage],
config: AgentConfig, signal: AbortSignal,
stream_fn: Callable) -> Tuple[AgentMessage, Optional[AgentError]]:
"""
返回 (assistant_message, error)。
error 非 None 时 message.stop_reason == "error"
"""
# 🌟 pi 同款:流开始前检查中止 → 立即产出 aborted 消息
if signal.aborted:
msg = AgentMessage(role="assistant", stop_reason="aborted")
agent.state.messages.append(msg)
_message_events(agent, msg)
return msg, None
# 🌟 系统提示词注入(对照 pi:systemPrompt 放在每次 API 请求头部,
# 不进 state.messages、不占压缩/历史)
if config.system_prompt:
api_context: List[AgentMessage] = [
AgentMessage(role="system", content=config.system_prompt)
] + current_context
else:
api_context = current_context
# 🌟 输出预算钳制(对照 simple-options clampMaxTokensToContext
# 🆕 P2: 把 system + 工具 schema 传给估算器(对照 pi 传完整 Context),
# 仅在无 usage 锚点分支生效;否则新会话首轮会多预留 ~system+tools 的预算。
clamped = clamp_max_tokens_to_context(
config.model, current_context,
system_prompt=config.system_prompt or "", tools=config.tools)
if clamped is None:
err = AgentError(
message="上下文溢出:估算输入 token 已超过模型窗口(需要压缩)",
kind="overflow", recoverable=True)
msg = AgentMessage(role="assistant", stop_reason="error",
error_message=err.message)
agent.state.messages.append(msg)
_message_events(agent, msg)
return msg, err
max_tokens, _input_tokens = clamped
msg = AgentMessage(role="assistant")
agent.state.streaming_message = msg
agent.state.streaming_delta = {}
_emit(agent, AgentEvent(type="message_start", message=msg))
raw_tc: Dict[int, Dict[str, str]] = {} # 中止时用于保留部分工具调用(仅展示)
got_final = False
error: Optional[AgentError] = None
try:
for kind, payload in stream_fn(api_context, config.model, signal,
max_tokens, config.tools):
if kind == "event":
ev = payload
if ev.type == "text_delta":
msg.content = (msg.content or "") + ev.text
agent.state.streaming_delta["text"] = ev.text
_emit(agent, AgentEvent(type="message_update", message=msg,
assistant_message_event=ev))
elif ev.type == "thinking_delta":
msg.reasoning += ev.text
agent.state.streaming_delta["thinking"] = ev.text
_emit(agent, AgentEvent(type="message_update", message=msg,
assistant_message_event=ev))
elif ev.type == "toolcall_delta":
slot = raw_tc.setdefault(ev.tool_call_index,
{"id": "", "name": "", "args": ""})
if ev.tool_call_field == "id":
slot["id"] = ev.tool_call_delta
elif ev.tool_call_field == "name":
slot["name"] += ev.tool_call_delta
else:
slot["args"] += ev.tool_call_delta
_emit(agent, AgentEvent(type="message_update", message=msg,
assistant_message_event=ev))
else: # final
final: AgentMessage = payload
got_final = True
# 采用权威 finalusage / stop_reason / 解析好的 tool_calls
msg.usage = final.usage
msg.stop_reason = final.stop_reason
msg.tool_calls = final.tool_calls
if not final.content and msg.content:
pass # 以循环侧累积为准(二者一致)
else:
msg.content = final.content if final.content else msg.content
msg.reasoning = final.reasoning or msg.reasoning
except AgentError as e:
error = e
msg.stop_reason = "error"
msg.error_message = e.message
_emit(agent, AgentEvent(type="message_update", message=msg,
assistant_message_event=
AssistantMessageEvent.error(e.message)))
except Exception as e:
from .stream_fn import classify_error
error = classify_error(e)
msg.stop_reason = "error"
msg.error_message = error.message
if not got_final and error is None:
# 生成器中途结束且无异常 = 中止(流被关闭,pi 同款语义)
msg.stop_reason = "aborted"
# 保留部分工具调用(仅用于 UI 展示;中止后不会执行,也不会进入下次上下文)
for idx in sorted(raw_tc.keys()):
slot = raw_tc[idx]
if slot.get("name"):
msg.tool_calls.append(ToolCall(id=slot.get("id") or new_id("call"),
name=slot["name"],
raw_arguments=slot.get("args", "")))
if error is None and msg.stop_reason not in ("stop", "length", "aborted"):
msg.stop_reason = "stop"
# 🌟 兜底(haocode 扩展,pi 无此层):不支持 tools API 的供应商/模型会把
# 工具调用用文字"演"出来(如 <bash>ls</bash>)。识别单参工具 bash/read
# 转成真 tool_call 继续执行;write/edit 多参歧义大不兜底。
if error is None and not msg.tool_calls and msg.content:
from .tools import parse_text_tool_calls
cleaned, txt_calls = parse_text_tool_calls(msg.content)
if txt_calls:
msg.tool_calls = txt_calls
agent.state.streaming_message = None
agent.state.streaming_delta = {}
agent.state.messages.append(msg)
_emit(agent, AgentEvent(type="message_end", message=msg))
return msg, error
# ======================================================================
# 工具批量执行 —— 对照 executeToolCalls (行 413-427)
# ======================================================================
def _should_terminate_batch(finalized: List[Dict[str, Any]]) -> bool:
"""对照 shouldTerminateToolBatch: 全部结果都请求 terminate 才终止"""
return len(finalized) > 0 and all(
f["result"].terminate for f in finalized)
def _run_prepared_in_pool(prep: PreparedToolCall, assistant: AgentMessage,
config: AgentConfig, signal: AbortSignal,
agent) -> Dict[str, Any]:
"""线程池内执行单个工具(对照 parallel 版的 async 闭包)"""
on_update = None
on_timer = None
if agent is not None:
def on_update(partial: str):
_emit(agent, AgentEvent(type="tool_execution_update",
tool_call=prep.tool_call, arg=partial))
def on_timer(elapsed_i: int, timeout_i: int):
# 🆕 bash 运行中每秒滴一次 → 前端气泡读秒 N/Ts
_emit(agent, AgentEvent(type="tool_execution_timer",
tool_call=prep.tool_call,
arg=(int(elapsed_i), int(timeout_i))))
result = execute_tool_call(prep, assistant, config, signal, on_update,
on_timer)
finalized = {"tool_call": prep.tool_call, "result": result,
"is_error": result.is_error}
_emit(agent, AgentEvent(type="tool_execution_end",
tool_call=prep.tool_call, result=result,
is_error=result.is_error))
return finalized
def _execute_parallel(current_context, assistant, config, signal, agent,
prepared: List[PreparedToolCall]) -> Dict[str, Any]:
"""对照 executeToolCallsParallelstart 事件串行发、准备串行、执行并发、
结果消息按原始顺序产出"""
finalized: List[Dict[str, Any]] = []
pending_futures: List = []
max_workers = max(1, min(8, len(prepared)))
with ThreadPoolExecutor(max_workers=max_workers,
thread_name_prefix="tool") as pool:
for prep in prepared:
_emit(agent, AgentEvent(type="tool_execution_start",
tool_call=prep.tool_call,
arg=str(prep.args) if prep.args else ""))
if prep.error:
# 准备失败(未知工具/参数非法)→ 立即结果(对照 kind:"immediate"
result = AgentToolResult.text(prep.error, is_error=True)
fin = {"tool_call": prep.tool_call, "result": result,
"is_error": True}
_emit(agent, AgentEvent(type="tool_execution_end",
tool_call=prep.tool_call, result=result,
is_error=True))
finalized.append(fin)
if signal.aborted:
break
continue
fut = pool.submit(_run_prepared_in_pool, prep, assistant, config,
signal, agent)
pending_futures.append(fut)
if signal.aborted:
# 已提交的任务仍会完成(它们内部检查 signal),不再追加
pass
for fut in pending_futures:
finalized.append(fut.result())
messages: List[AgentMessage] = []
for fin in finalized:
tr = _tool_result_message(fin)
_message_events(agent, tr)
messages.append(tr)
return {"messages": messages, "terminate": _should_terminate_batch(finalized)}
def _execute_sequential(current_context, assistant, config, signal, agent,
prepared: List[PreparedToolCall]) -> Dict[str, Any]:
"""对照 executeToolCallsSequential:一次一个,完成一个再下一个"""
finalized: List[Dict[str, Any]] = []
messages: List[AgentMessage] = []
for prep in prepared:
_emit(agent, AgentEvent(type="tool_execution_start",
tool_call=prep.tool_call,
arg=str(prep.args) if prep.args else ""))
if not prep.error:
fin = _run_prepared_in_pool(prep, assistant, config, signal, agent)
else:
result = AgentToolResult.text(prep.error, is_error=True)
fin = {"tool_call": prep.tool_call, "result": result, "is_error": True}
_emit(agent, AgentEvent(type="tool_execution_end",
tool_call=prep.tool_call, result=result,
is_error=True))
finalized.append(fin)
tr = _tool_result_message(fin)
_message_events(agent, tr)
messages.append(tr)
if signal.aborted:
break
return {"messages": messages, "terminate": _should_terminate_batch(finalized)}
def execute_tool_calls(current_context, assistant: AgentMessage,
config: AgentConfig, signal: AbortSignal, agent) -> Dict[str, Any]:
"""对照 executeToolCalls: 批次里有 sequential 工具 → 整批串行"""
prepared = prepare_tool_calls(assistant, config.tools)
has_sequential = any(
(p.tool and p.tool.execution_mode == "sequential") for p in prepared
if not p.error)
if config.tool_execution == "sequential" or has_sequential:
return _execute_sequential(current_context, assistant, config, signal,
agent, prepared)
return _execute_parallel(current_context, assistant, config, signal, agent,
prepared)
# ======================================================================
# 主循环 —— 对照 runLoop (行 163-278)
# ======================================================================
def run_loop(agent, new_message: Optional[AgentMessage], signal: AbortSignal,
stream_fn: Callable) -> RunResult:
config = agent.config
new_messages: List[AgentMessage] = []
# 入口校验(对照 runAgentLoopContinue 的前置检查由 Agent 层负责)
_emit(agent, AgentEvent(type="agent_start"))
_emit(agent, AgentEvent(type="turn_start")) # 首轮 turn_start(对照行 142/170
# context 准备 + transformContext 钩子
base = list(agent.state.messages)
if config.transform_context:
try:
base = config.transform_context(base) or base
except Exception:
pass
current_context = list(base)
first_turn = True
pending: List[AgentMessage] = agent._take_steering()
while True: # outer loop
has_more_tool_calls = True
while has_more_tool_calls or pending: # inner loop
if not first_turn:
_emit(agent, AgentEvent(type="turn_start"))
else:
first_turn = False
# ---- 注入 pending 消息(steering / followUp----
if pending:
for m in pending:
_message_events(agent, m)
current_context.append(m)
new_messages.append(m)
agent.state.messages.append(m)
pending = []
# ---- 🆕 轮中主动压缩检查(haocode 增强,偏离 pi 1:1----
# 单条工具输出可能把上下文顶出窗口;发下一次请求前主动检查
#(与轮首 should_compact 同公式)。compact_fn 返回新列表
#(发生了压缩)→ 同步循环局部上下文。
if config.compact_fn is not None and agent.state.messages:
try:
compacted = config.compact_fn(list(agent.state.messages))
if compacted is not None:
agent.state.messages = compacted
current_context = list(compacted)
except Exception:
pass
# ---- 流式生成助手消息 ----
assistant, error = _stream_turn(agent, current_context, config,
signal, stream_fn)
new_messages.append(assistant)
current_context.append(assistant)
if assistant.stop_reason in ("error", "aborted"):
agent.state.error = error
_emit(agent, AgentEvent(type="turn_end", message=assistant))
_emit(agent, AgentEvent(type="agent_end",
stop_reason=assistant.stop_reason,
error=error,
messages=new_messages))
agent._finish_run(new_messages, assistant.stop_reason, error)
return RunResult(stop_reason=assistant.stop_reason, error=error,
message_count=len(new_messages))
# ---- 工具调用 ----
tool_results: List[AgentMessage] = []
has_more_tool_calls = False
if assistant.tool_calls:
if assistant.stop_reason == "length":
# 🌟 截断保护(对照行 212-213):参数可能残缺,一律失败不执行
fail_msgs = fail_tool_calls_from_truncated_message(
assistant, "length")
# 事件流与正常执行对齐
for tc, tr in zip(assistant.tool_calls, fail_msgs):
_emit(agent, AgentEvent(type="tool_execution_start",
tool_call=tc))
res = AgentToolResult.text(
f'工具调用 "{tc.name}" 未执行:输出达到 token 上限,'
f'参数可能被截断。请用完整参数重新发起。', is_error=True)
_emit(agent, AgentEvent(type="tool_execution_end",
tool_call=tc, result=res,
is_error=True))
_message_events(agent, tr)
batch = {"messages": fail_msgs, "terminate": False}
else:
batch = execute_tool_calls(current_context, assistant, config,
signal, agent)
tool_results = batch["messages"]
has_more_tool_calls = not batch["terminate"]
for r in tool_results:
current_context.append(r)
new_messages.append(r)
agent.state.messages.append(r)
_emit(agent, AgentEvent(type="turn_end", message=assistant))
# ---- prepareNextTurn 钩子 ----
if config.prepare_next_turn:
try:
snap = config.prepare_next_turn({
"message": assistant, "tool_results": tool_results,
"context": current_context, "new_messages": new_messages,
})
if snap:
current_context = snap.get("context") or current_context
except Exception:
pass
# ---- shouldStopAfterTurn 钩子 ----
if config.should_stop_after_turn:
try:
if config.should_stop_after_turn({
"message": assistant, "tool_results": tool_results,
"context": current_context,
"new_messages": new_messages}):
stop = _last_assistant_stop(new_messages)
_emit(agent, AgentEvent(type="agent_end",
stop_reason=stop,
messages=new_messages))
agent._finish_run(new_messages, stop, None)
return RunResult(stop_reason=stop,
message_count=len(new_messages))
except Exception:
pass
# ---- 每轮结束取 steering ----
pending = agent._take_steering()
# ---- 外层:followUp ----
follow_ups = agent._take_follow_ups()
if follow_ups:
pending = follow_ups
continue
break
stop = _last_assistant_stop(new_messages)
_emit(agent, AgentEvent(type="agent_end", stop_reason=stop,
messages=new_messages))
agent._finish_run(new_messages, stop, None)
return RunResult(stop_reason=stop, message_count=len(new_messages))
def _last_assistant_stop(new_messages: List[AgentMessage]) -> str:
"""对照 pi: agent_end 不携带 stopReason,会话层从最后一条 assistant 消息读取"""
for m in reversed(new_messages):
if m.role == "assistant":
return m.stop_reason or "stop"
return "stop"
+519
View File
@@ -0,0 +1,519 @@
"""
core/agent/recovery.py
======================
🌟 pi 会话层恢复逻辑的 Python 1:1 移植
对照 pi-main 源码:
packages/coding-agent/src/core/agent-session.ts
_runAgentPrompt (行 1074): prompt → while _handlePostAgentRun(): continue()
_handlePostAgentRun (行 1088): 基于「最后一条 assistant 消息」的三路决策
_prepareRetry (行 2811): 计数 → 移除坏消息 → 退避睡眠
_isRetryableError (行 2770): 溢出交给压缩;其余走重试判定
_checkCompaction (行 ~2034): 溢出压缩恢复(只试一次)
packages/ai/src/utils/retry.ts
isRetryableAssistantError (行 223) + 可重试/不可重试正则模式表
退避: baseDelayMs × 2^(attempt-1)(默认 2s, 4s, 8smaxRetries 默认 3
packages/ai/src/utils/overflow.ts
isContextOverflow (行 134):三种溢出识别
isRecoverableLengthlength 且 output < 原始输出上限
🌟 决策表(与 pi 完全一致,基于最后一条 assistant 消息):
(1) stop_reason=="error"
a. 是上下文溢出 → 删除坏消息 + 压缩 + continue(只试一次)
b. 命中可重试模式(429/5xx/超时/断连…且非配额耗尽)
且未超 maxRetries → 删除坏消息 + 退避(2s×2^(n-1)) + continue
c. 其他 → 结束
(2) isRecoverableLengthlength 且实际输出 < 原始 maxTokens
说明是被上下文窗口挤断而非输出上限截断)且未尝试过
→ 删除该助手消息 + 压缩 + continue(只试一次)
(3) 队列里还有 followUp/steering → continue(续跑)
(4) 都不满足 → settle
"""
from __future__ import annotations
import os
import re
import time
from typing import Callable, Optional
from .compaction import CompactionSettings, _content_text, compact_context
from .context import estimate_context_tokens, should_compact
from .types import (AgentMessage, AgentConfig, RetryConfig, RunResult)
# ========== 压缩诊断日志(与 main_window.diag_log 同一规范:
# [HH:MM:SS.mmm] 时间戳 + 项目根目录追加 + UTF-8 + 失败静默) ==========
_COMPACT_LOG_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"compaction_diag.log")
def compact_diag_log(line: str):
"""压缩日志只记录:同一行内容 print(控制台)+ 追加写 compaction_diag.log"""
t = time.time()
stamp = (f"[{time.strftime('%H:%M:%S', time.localtime(t))}"
f".{int(t * 1000) % 1000:03d}]")
print(f"{stamp} {line}", flush=True)
try:
with open(_COMPACT_LOG_PATH, "a", encoding="utf-8") as f:
f.write(f"{stamp} {line}\n")
except Exception:
pass
def _settings_of(cfg: AgentConfig) -> CompactionSettings:
"""AgentConfig 压缩参数 → CompactionSettings1:1 映射 pi settings 字段)"""
return CompactionSettings(reserve_tokens=cfg.compaction_reserve,
keep_recent_tokens=cfg.compaction_keep_recent)
def _cut_ids_of(msgs: list, new_msgs: list) -> tuple:
"""🆕 压缩持久化切点:new_msgs = [摘要] + 保留尾巴(旧 msgs 的连续后缀)。
按 identity 定位尾巴起点 → (cut_before_id, first_retained_id)DB 行 id)。
⚠️ 行粒度:同一 DB 行(assistant timeline)会被 build_api_context 回放成
多条 API 消息(asst/tool 条目共享同一 _db_msg_id)。若尾巴起点落在某行
回放序列中间,必须回退到该行第一条,否则 cut_before 与 first_retained
会是同一行 → DB 自环(链死循环卡死)。
回退后下一轮会从该行完整重放(保守方向,宁多勿漏)。
任一为空 = 无法在 DB 链上切(如尾巴全是轮中内存消息)→ UI 不插标记。"""
if not new_msgs or getattr(new_msgs[0], "kind", None) != "compaction_summary":
return "", ""
tail = new_msgs[1:]
if not tail:
return "", ""
first = tail[0]
for i, m in enumerate(msgs):
if m is first:
# 回退到同一 DB 行回放序列的头部
fid = getattr(first, "db_msg_id", "") or ""
j = i
while j > 0 and fid and getattr(msgs[j - 1], "db_msg_id", "") == fid:
j -= 1
if j == 0:
return "", "" # 没有“之前”的行 → 无切点
return (getattr(msgs[j - 1], "db_msg_id", "") or "",
getattr(first, "db_msg_id", "") or "")
return "", "" # 尾巴首条不在旧列表(不应发生)→ 不插标记
# ======================================================================
# 对照 retry.ts 的模式表(原样移植)
# ======================================================================
_NON_RETRYABLE_LIMIT_RE = re.compile(
r"GoUsageLimitError|FreeUsageLimitError|"
r"Monthly usage limit reached|available balance|"
r"insufficient_quota|out of budget|quota exceeded|billing",
re.IGNORECASE,
)
_RETRYABLE_RE = re.compile(
r"overloaded|rate.?limit|too many requests|429|500|502|503|504|524|"
r"service.?unavailable|server.?error|internal.?error|"
r"provider.?returned.?error|exceeded request buffer limit while retrying upstream|"
r"network.?error|connection.?error|connection.?refused|connection.?lost|"
r"other side closed|fetch failed|getaddrinfo|ENOTFOUND|EAI_AGAIN|"
r"upstream.?connect|reset before headers|socket hang up|timeout|timed?\s?out",
re.IGNORECASE,
)
# 对照 overflow.ts OVERFLOW_PATTERNS(各服务商话术)
_OVERFLOW_RE = re.compile(
r"context\s*window|prompt is too long|maximum context|too many tokens|"
r"context length|exceeds the (model|maximum) context|"
r"maximum context length is \d+ tokens|exceeds the maximum allowed input length|"
r"longer than the model's context length|exceeds the available context size|"
r"greater than the context length|exceeded model token limit|"
r"Range of input length should be|configured context size|"
r"长于|超出.*上下文|上下文.*(超出|超过|不足)",
re.IGNORECASE,
)
# 限速类不是溢出(对照 overflow.ts NON_OVERFLOW_PATTERNS 的判定意图)
_NON_OVERFLOW_RE = re.compile(
r"rate.?limit|too many requests|429|overloaded", re.IGNORECASE)
# ======================================================================
# 判定函数(对照 pi overflow.ts / retry.ts,基于 assistant 消息)
# ======================================================================
def is_context_overflow(message: AgentMessage,
context_window: int = 0) -> bool:
"""
对照 isContextOverflow 的三种情形:
1. error + 消息文本命中溢出模式(且不是限速类)
2. 静默溢出:stop 但 usage.input > contextWindowz.ai 风格)
3. 服务端截断式溢出:length 且 output==0 且 input ≥ 0.99×窗口
"""
if message.role != "assistant":
return False
if message.stop_reason == "error" and message.error_message:
if not _NON_OVERFLOW_RE.search(message.error_message) \
and _OVERFLOW_RE.search(message.error_message):
return True
usage = message.usage or {}
input_tokens = int(usage.get("input", 0) or 0) + int(usage.get("cacheRead", 0) or 0)
if context_window and message.stop_reason == "stop":
if input_tokens > context_window:
return True
if context_window and message.stop_reason == "length" \
and int(usage.get("output", 0) or 0) == 0:
if input_tokens >= context_window * 0.99:
return True
return False
def is_retryable_assistant_error(message: AgentMessage) -> bool:
"""对照 isRetryableAssistantError:基于 errorMessage 文本模式"""
if message.role != "assistant" or message.stop_reason != "error":
return False
if not message.error_message:
return False
if _NON_RETRYABLE_LIMIT_RE.search(message.error_message):
return False
return bool(_RETRYABLE_RE.search(message.error_message))
def is_recoverable_length(message: AgentMessage,
desired_max_output: int) -> bool:
"""
对照 pi isRecoverableLength
length 停止 且 实际输出 < 「原始输出上限」(钳制前的 maxTokens)。
"""
if message.role != "assistant" or message.stop_reason != "length":
return False
if desired_max_output <= 0:
return False
out = int((message.usage or {}).get("output", 0) or 0)
return out < desired_max_output
def compute_retry_delay_ms(attempt: int, cfg: RetryConfig) -> float:
"""对照 pi _prepareRetry: baseDelayMs × 2^(attempt-1)(无抖动、默认无封顶)"""
delay = cfg.base_delay_ms * (cfg.factor ** (attempt - 1))
if cfg.max_delay_ms and delay > cfg.max_delay_ms:
delay = cfg.max_delay_ms
return float(delay)
def find_last_assistant(messages) -> Optional[AgentMessage]:
"""对照 agent-session _findLastAssistantMessage"""
for m in reversed(messages):
if m.role == "assistant":
return m
return None
# ======================================================================
# 编排器 —— 对照 agent-session 的 run 循环
# ======================================================================
class AgentRunner:
"""
对照 agent-session.ts 的 run 编排(agent 之外的「会话层」)。
haocode 里由 AgentWorker(QThread) 持有一个 AgentRunner。
summarize_fn(transcript) -> str:压缩用的非流式 LLM 调用(缺省 → 禁压缩)。
"""
def __init__(self, agent: Agent,
summarize_fn: Optional[Callable[[str], str]] = None,
on_retry_scheduled: Optional[Callable] = None,
on_retry_finished: Optional[Callable] = None,
on_compaction_started: Optional[Callable] = None,
on_compaction_finished: Optional[Callable] = None):
self.agent = agent
self.summarize_fn = summarize_fn
# 🆕 M3: 重试回调(对照 pi retryAssistantCall 的
# onRetryScheduled / onRetryFinished)——UI 可提示「正在重试」
self.on_retry_scheduled = on_retry_scheduled
self.on_retry_finished = on_retry_finished
# 🆕 压缩开始回调(摘要 LLM 调用阻塞前触发)——UI 显示「执行中」动态气泡
self.on_compaction_started = on_compaction_started
# 🆕 压缩完成回调(压缩结束瞬间触发,不等整轮 run 结束)
# ——前端气泡即时定格「已完成上下文压缩」(与思考块定格同款时机)
self.on_compaction_finished = on_compaction_finished
self._overflow_recovered = False # 对照 _overflowRecoveryAttempted
self._retry_attempt = 0 # 对照 _retryAttempt
self.last_action = "none" # 诊断: none/retry/overflow_compact/length_compact/queued
self.compactions_performed = 0 # 成功压缩次数(UI 提示用)
# 🆕 压缩事件队列(UI 气泡用:摘要全文/前后 token/耗时/路径)
self.compaction_events: list = []
# 🆕 轮中主动压缩护栏(同一 run 内;AgentRunner 每轮新建 → 自动复位)
self._mid_turn_fail_streak = 0
self._mid_turn_exhausted = False
# ------------------------------------------------------------------
# 发送前压缩(🌟 1:1 对照 pi shouldCompacttokens > window - reserveTokens
# ------------------------------------------------------------------
def pre_prompt_compaction(self) -> bool:
if self.summarize_fn is None:
return False
cfg = self.agent.config
msgs = self.agent.state.messages
m = cfg.model
# 🆕 G2: 透传 system_prompt/tools —— 无锚点时度量「下一请求真实载荷」
should, tokens = should_compact(msgs, m, cfg.compaction_reserve,
system_prompt=cfg.system_prompt,
tools=cfg.tools)
# 🆕 每次请求前的校验:print + 写 compaction_diag.log(触发与否都记)
compact_diag_log(
f"[PRE_CHECK] model={m.name} window={m.context_window} "
f"reserve={cfg.compaction_reserve} "
f"threshold={m.context_window - cfg.compaction_reserve} "
f"est={tokens} triggered={str(should).lower()}")
if not should:
return False
self._notify_compaction_started("pre_prompt", tokens)
t0 = time.time()
new_msgs = compact_context(msgs, cfg.model, self.summarize_fn,
settings=_settings_of(cfg))
if new_msgs is not None:
cut_before, first_retained = _cut_ids_of(msgs, new_msgs)
self.agent.state.messages = new_msgs
self.compactions_performed += 1
self._record_compaction("pre_prompt", tokens, new_msgs, t0,
cut_before_id=cut_before,
first_retained_id=first_retained)
return True
self._record_compaction_failed("pre_prompt", tokens, t0)
return False
# ------------------------------------------------------------------
# 🆕 轮中主动压缩(haocode 增强,偏离 pi 1:1
# ------------------------------------------------------------------
def compact_if_needed(self, msgs: list) -> Optional[list]:
"""接线到 AgentConfig.compact_fn:内层循环每次 LLM 请求前调用。
与轮首 pre_prompt_compaction 同公式(tokens > window reserve)。
轮中单条工具输出可把上下文顶出窗口(轮中内存工具结果没有
4000 字回放上限),发请求前主动压缩,避免「一次失败往返 +
响应式兜底」。返回新消息列表(发生了压缩)或 None(不需要/
不可压缩 → 原样发请求,响应式 overflow 安全网仍在)。
防刷屏护栏:同一 run 内连续失败 2 次后不再尝试(形状无有效
切点,如单条巨型条目,硬试无意义);成功则复位计数。
"""
if self.summarize_fn is None or self._mid_turn_exhausted:
return None
cfg = self.agent.config
m = cfg.model
# 🆕 G2: 透传 system_prompt/tools(与轮首同口径)
should, tokens = should_compact(msgs, m, cfg.compaction_reserve,
system_prompt=cfg.system_prompt,
tools=cfg.tools)
compact_diag_log(
f"[PRE_CHECK] path=mid_turn est={tokens} "
f"threshold={m.context_window - cfg.compaction_reserve} "
f"triggered={str(should).lower()}")
if not should:
return None
if self._do_compaction("mid_turn", msgs=msgs):
self._mid_turn_fail_streak = 0
return list(self.agent.state.messages)
self._mid_turn_fail_streak += 1
if self._mid_turn_fail_streak >= 2:
self._mid_turn_exhausted = True
compact_diag_log(
"[PRE_CHECK] path=mid_turn exhausted "
"(连续 2 次失败,本轮不再尝试)")
return None
# ------------------------------------------------------------------
# 主入口 —— 对照 _runAgentPrompt
# ------------------------------------------------------------------
def run(self, message) -> RunResult:
self.pre_prompt_compaction()
self._overflow_recovered = False
self._retry_attempt = 0
result = self.agent.prompt(message)
return self._post_loop(result)
def _post_loop(self, result: RunResult) -> RunResult:
# 对照: while (await this._handlePostAgentRun()) await this.agent.continue()
while True:
action = self._handle_post_agent_run(result)
if action == "stop":
return result
if action == "retry":
result = self.agent.continue_()
# 🆕 M3: 重试结果回调(成功 = 下一轮未以 error/aborted 收尾)
if self.on_retry_finished is not None:
la = find_last_assistant(self.agent.state.messages)
ok = (la is not None
and la.stop_reason not in ("error", "aborted"))
try:
self.on_retry_finished(bool(ok))
except Exception:
pass
continue
if action == "compact_retry":
if not (self._remove_last_bad_assistant()
and self._do_compaction(self.last_action)):
return result # 压缩不可用 → 放弃,交 UI 报错
result = self.agent.continue_()
continue
if action == "queued":
result = self.agent.continue_()
continue
return result
# ------------------------------------------------------------------
# 三路决策 —— 对照 _handlePostAgentRun
# ------------------------------------------------------------------
def _handle_post_agent_run(self, result: RunResult) -> str:
msg = find_last_assistant(self.agent.state.messages)
if msg is None:
return "stop"
cfg = self.agent.config
# (1) 错误路径
if msg.stop_reason == "error":
# a. 溢出 → 压缩恢复(只试一次)
if is_context_overflow(msg, cfg.model.context_window) \
and not self._overflow_recovered \
and self.summarize_fn is not None:
self._overflow_recovered = True
self.last_action = "overflow_compact"
return "compact_retry"
# b. 可重试错误 → 移除坏消息 + 退避 + continue
if is_retryable_assistant_error(msg) \
and self._prepare_retry(cfg.retry,
reason=msg.error_message or ""):
self.last_action = "retry"
return "retry"
return "stop"
# (2) 可恢复的 length 截断
if is_recoverable_length(msg, cfg.model.max_tokens) \
and not self._overflow_recovered \
and self.summarize_fn is not None:
self._overflow_recovered = True
self.last_action = "length_compact"
return "compact_retry"
# (3) 队列里还有消息 → 续跑
if self.agent.has_queued():
self.last_action = "queued"
return "queued"
# (4) settle
return "stop"
# ------------------------------------------------------------------
# 对照 _prepareRetry:计数 → 移除坏消息 → 退避睡眠
# ------------------------------------------------------------------
def _prepare_retry(self, cfg: RetryConfig, reason: str = "") -> bool:
if self._retry_attempt >= cfg.max_attempts:
return False
self._retry_attempt += 1
delay_ms = compute_retry_delay_ms(self._retry_attempt, cfg)
# 移除错误助手消息(对照: messages.slice(0,-1),会话历史保留由 UI 层负责)
messages = self.agent.state.messages
if messages and messages[-1].role == "assistant":
self.agent.state.messages = messages[:-1]
# 🆕 M3: 通知重试已调度(参数: attempt, max_attempts, delay_ms, reason
if self.on_retry_scheduled is not None:
try:
self.on_retry_scheduled(self._retry_attempt, cfg.max_attempts,
delay_ms, reason)
except Exception:
pass
time.sleep(delay_ms / 1000.0)
return True
# ------------------------------------------------------------------
# 压缩恢复辅助
# ------------------------------------------------------------------
def _remove_last_bad_assistant(self) -> bool:
msgs = self.agent.state.messages
if msgs and msgs[-1].role == "assistant" \
and msgs[-1].stop_reason in ("length", "error"):
self.agent.state.messages = msgs[:-1]
return True
return False
def _do_compaction(self, path: str = "reactive",
msgs: Optional[list] = None) -> bool:
if self.summarize_fn is None:
return False
cfg = self.agent.config
if msgs is None:
msgs = self.agent.state.messages
tokens_before = estimate_context_tokens(msgs).tokens
self._notify_compaction_started(path, tokens_before)
t0 = time.time()
new_msgs = compact_context(msgs, cfg.model, self.summarize_fn,
settings=_settings_of(cfg))
if new_msgs is not None:
cut_before, first_retained = _cut_ids_of(msgs, new_msgs)
self.agent.state.messages = new_msgs
self.compactions_performed += 1
self._record_compaction(path, tokens_before, new_msgs, t0,
cut_before_id=cut_before,
first_retained_id=first_retained)
return True
self._record_compaction_failed(path, tokens_before, t0)
return False
# ------------------------------------------------------------------
# 🆕 压缩可视化辅助:开始/完成 两阶段(print + 日志 + 事件供 UI 气泡)
# ------------------------------------------------------------------
def _notify_compaction_started(self, path: str, tokens_before: int) -> None:
compact_diag_log(f"[COMPACT_START] path={path} before={tokens_before}")
if self.on_compaction_started is not None:
try:
self.on_compaction_started(path)
except Exception:
pass
def _record_compaction_failed(self, path: str, tokens_before: int,
t0: float) -> None:
"""压缩不可行(如历史无法切分)→ 气泡显示失败原因,不能永远卡在「执行中」"""
duration_ms = int((time.time() - t0) * 1000)
compact_diag_log(
f"[COMPACT_FAIL] path={path} before={tokens_before} "
f"duration_ms={duration_ms}")
ev = {
"summary": "本次压缩未能执行(历史无法切分),继续以原有历史对话。",
"before": tokens_before,
"after": tokens_before,
"duration_ms": duration_ms,
"path": path,
"failed": True,
}
self.compaction_events.append(ev)
if self.on_compaction_finished is not None:
try:
self.on_compaction_finished(dict(ev))
except Exception:
pass
def _record_compaction(self, path: str, tokens_before: int,
new_msgs: list, t0: float,
cut_before_id: str = "",
first_retained_id: str = "") -> None:
summary = ""
if new_msgs and getattr(new_msgs[0], "kind", None) == "compaction_summary":
summary = _content_text(new_msgs[0].content) or ""
tokens_after = estimate_context_tokens(new_msgs).tokens
duration_ms = int((time.time() - t0) * 1000)
compact_diag_log(
f"[COMPACT_DONE] path={path} before={tokens_before} "
f"after={tokens_after} summary_chars={len(summary)} "
f"duration_ms={duration_ms} "
f"cut_before={cut_before_id or '-'} first_retained={first_retained_id or '-'}")
ev = {
"summary": summary,
"before": tokens_before,
"after": tokens_after,
"duration_ms": duration_ms,
"path": path,
# 🆕 压缩持久化切点:UI 收到后在 DB 链上插标记行(两者齐备才插)
"cut_before_id": cut_before_id,
"first_retained_id": first_retained_id,
}
self.compaction_events.append(ev)
if self.on_compaction_finished is not None:
try:
self.on_compaction_finished(dict(ev))
except Exception:
pass
+438
View File
@@ -0,0 +1,438 @@
"""
core/agent/stream_fn.py
=======================
🌟 pi-ai 流式接口的 Python 1:1 移植 —— OpenAI 兼容(含 vLLM
对照 pi-main 源码:
packages/ai/src/api/openai-completions.ts
- streamSimple() (行 469 起): 消费 SSE chunk → 产出 AssistantMessage
- buildParams() (行 536 起): 组装请求体
* vLLM/OpenAI 默认字段名 = max_completion_tokens
* 少数服务商(chutes/deepseek/moonshot 等)用 max_tokens
- mapStopReason(): finish_reason "length" → stopReason "length"
packages/ai/src/api/simple-options.ts
- buildBaseOptions(): maxTokens = options?.maxTokens ?? model.maxTokens
(即模型定义里的 maxTokens 一定会进入请求体,这就是它被发给 vLLM 的原因)
本文件职责:
1. to_openai_messages() —— pi 消息格式 → OpenAI API 格式(每轮调用前转换)
2. openai_stream() —— 流式请求,逐 chunk 产出 AssistantMessageEvent
结束产出最终 AgentMessage(含 usage / stop_reason / tool_calls
3. classify_error() —— 异常分类(对照 pi isRetryable 的输入)
🌟 修复 haocode 原有 P1 bugopenai 客户端带 timeout(原来无超时,
挂死的流会让线程永久阻塞)。max_retries=0(重试统一交给 recovery 层,
与 pi 一致:网络层不重试,会话层按 1600ms×1.6^n 退避重试)。
"""
from __future__ import annotations
import json
from typing import Any, Dict, Iterator, List, Optional, Tuple
from openai import OpenAI
from openai import APIConnectionError, APIStatusError, APITimeoutError, RateLimitError
from .types import (AgentError, AgentMessage, AssistantMessageEvent,
AbortSignal, ModelConfig, ToolCall, new_id)
# ======================================================================
# pi 消息 → OpenAI 消息(每轮发送前调用)
# ======================================================================
def to_openai_messages(context: List[AgentMessage],
pass_reasoning: bool = True) -> List[Dict[str, Any]]:
"""pi 消息 → OpenAI chat 格式。
🆕 thinking 模式(DeepSeek 等):assistant 消息必须把 reasoning_content
原样回传,否则服务端 400
'The reasoning_content in the thinking mode must be passed back to the API')。
默认开启:轮中续问/工具回灌场景,内存里本轮刚流式产出的 assistant 带
reasoning → 回传;DB 回放的旧行 reasoning=""(不入库)→ 无该字段,
跨轮全新请求不受影响。pass_reasoning=False 显式关闭(特殊网关逃生阀)。
"""
out: List[Dict[str, Any]] = []
for m in context:
if m.role == "system":
# 只放行请求头部的系统提示词(loop 注入);
# 历史中间出现的 system(DB 防御)仍然跳过
if not out:
out.append({"role": "system", "content": m.content})
continue
if m.role == "toolResult":
out.append({
"role": "tool",
"tool_call_id": m.tool_call_id,
"content": m.content if isinstance(m.content, str)
else "".join(c.get("text", "") for c in m.content
if isinstance(c, dict)),
})
continue
if m.role == "assistant":
entry: Dict[str, Any] = {"role": "assistant"}
# 纯工具调用轮:content 可能为空 → 用 ""vLLM 接受,避免 null 报错)
entry["content"] = m.content if m.content else ""
if m.tool_calls:
entry["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments, ensure_ascii=False)
if tc.arguments else tc.raw_arguments or "{}",
},
}
for tc in m.tool_calls
]
if pass_reasoning and m.reasoning:
entry["reasoning_content"] = m.reasoning
out.append(entry)
continue
# usercontent 可以是 str 或 OpenAI 多模态 list,原样透传)
out.append({"role": "user", "content": m.content})
return out
# ======================================================================
# 异常分类 —— 对照 pi isRetryable / retryable error 判定
# ======================================================================
def classify_error(e: Exception) -> AgentError:
"""把 openai SDK 异常分类,恢复层据此决定重试/放弃。"""
if isinstance(e, RateLimitError):
return AgentError(message=str(e), kind="rate_limit",
status_code=429, recoverable=True)
if isinstance(e, APITimeoutError):
return AgentError(message=f"请求超时: {e}", kind="timeout", recoverable=True)
if isinstance(e, APIConnectionError):
return AgentError(message=f"连接失败: {e}", kind="connection", recoverable=True)
if isinstance(e, APIStatusError):
code = e.status_code
msg = str(e)
if code in (502, 503, 504):
return AgentError(message=msg, kind="server_error",
status_code=code, recoverable=True)
if code == 529:
return AgentError(message=msg, kind="overload",
status_code=code, recoverable=True)
if code == 408:
return AgentError(message=msg, kind="timeout",
status_code=code, recoverable=True)
if code in (401, 403):
return AgentError(message=msg, kind="auth",
status_code=code, recoverable=False)
# 4xx(参数错误/模型不存在/上下文超长等)不可重试,交恢复层细分
return AgentError(message=msg, kind="unknown",
status_code=code, recoverable=False)
if isinstance(e, (ConnectionError, TimeoutError)):
return AgentError(message=str(e), kind="connection", recoverable=True)
return AgentError(message=str(e), kind="unknown", recoverable=False)
# ======================================================================
# 🆕 M1/M2: 流式字段提取(对照 pi openai-completions.ts 的字段优先级)
# ======================================================================
# 🆕 M1: 1:1 对照 pi chunk 级 reasoning 字段优先级(llama.cpp/vLLM/OpenRouter 各不同)
_REASONING_FIELDS = ("reasoning_content", "reasoning", "reasoning_text")
def _pick_reasoning(delta) -> str:
"""从 delta 上取第一个非空思考字段(只取一个,防同内容多字段重复计)"""
if delta is None:
return ""
dump = None
for name in _REASONING_FIELDS:
v = getattr(delta, name, None)
if v is None and dump is None and hasattr(delta, "model_dump"):
try:
dump = delta.model_dump()
except Exception:
dump = {}
if v is None and isinstance(dump, dict):
v = dump.get(name)
if v:
return str(v)
return ""
def _pick_usage(chunk):
"""🆕 M2: 对照 pi:先 chunk.usage,再 choices[0].usageMoonshot 系只放 choice 里)"""
u = getattr(chunk, "usage", None)
if u is not None:
return u
try:
choices = getattr(chunk, "choices", None)
if choices:
return getattr(choices[0], "usage", None)
except Exception:
pass
return None
# ======================================================================
# 流式请求核心 —— 对照 openai-completions.ts streamSimple
# ======================================================================
def openai_stream(context: List[AgentMessage],
model: ModelConfig,
signal: AbortSignal,
max_tokens: int,
tools: Optional[List[Any]] = None,
) -> Iterator[Tuple[str, Any]]:
"""
生成器协议(对照 pi streamSimple 的 for-await 消费方式):
yield ("event", AssistantMessageEvent) # 增量事件(text/thinking/toolcall/done
yield ("final", AgentMessage) # 最终助手消息(含 stop_reason/usage
异常统一转 AgentError 抛出(由循环层捕获 → stop_reason="error")。
🌟 中止语义与 pi 一致:每个 chunk 边界检查 signal.aborted
命中即关闭流、以 stop_reason="aborted" 收尾(不是 error)。
"""
client = OpenAI(
api_key=model.api_key or "EMPTY",
base_url=model.base_url,
# 🌟 P1 修复:显式超时(pi 侧由 fetch timeout 保证)
timeout=model.timeout_seconds,
max_retries=0, # 重试统一由 recovery 层负责
)
params: Dict[str, Any] = {
"model": model.name,
"messages": to_openai_messages(context, model.pass_reasoning),
"stream": True,
"stream_options": {"include_usage": True}, # 拿真实 usagepi 同款)
"temperature": model.temperature,
}
# 对照 pi buildParams:默认 max_completion_tokensvLLM 兼容)
if getattr(model, "use_max_tokens_field", False):
params["max_tokens"] = max_tokens
else:
params["max_completion_tokens"] = max_tokens
# 🌟 工具定义(对照 pi buildParams 的 tools 段)——不发给模型,
# 模型就不可能发出真正的 tool_call,只会用文字"演"工具调用!
if tools:
params["tools"] = [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters,
},
}
for t in tools
]
# 累积状态
content_parts: List[str] = []
reasoning_parts: List[str] = []
tc_acc: Dict[int, Dict[str, Any]] = {} # index -> {id, name, args}
finish_reason: Optional[str] = None
usage: Dict[str, Any] = {}
stream = None
try:
stream = client.chat.completions.create(**params)
for chunk in stream:
# 🌟 pi 同款:chunk 边界检查中止
if signal.aborted:
finish_reason = "aborted"
try:
stream.close()
except Exception:
pass
break
# 🆕 M2: usage 读取顺序对照 pi —— chunk.usage 优先,choice.usage 兜底
usage_obj = _pick_usage(chunk)
if usage_obj is not None:
usage = {
"input": getattr(usage_obj, "prompt_tokens", 0) or 0,
"output": getattr(usage_obj, "completion_tokens", 0) or 0,
"cacheRead": 0, "cacheWrite": 0,
}
if not getattr(chunk, "choices", None):
continue
choice = chunk.choices[0]
delta = choice.delta
# 🆕 M1: 思考增量(对照 pi reasoningFields 优先级,含多字段去重)
reasoning = _pick_reasoning(delta)
if reasoning:
yield ("event", AssistantMessageEvent.thinking_delta(reasoning))
reasoning_parts.append(reasoning)
if getattr(delta, "content", None):
yield ("event", AssistantMessageEvent.text_delta(delta.content))
content_parts.append(delta.content)
# 工具调用增量(按 index 累积 id/name/arguments 三段)
for tc_delta in (getattr(delta, "tool_calls", None) or []):
idx = tc_delta.index or 0
slot = tc_acc.setdefault(idx, {"id": "", "name": "", "args": ""})
if tc_delta.id:
slot["id"] = tc_delta.id
yield ("event", AssistantMessageEvent.toolcall_delta(
idx, tc_delta.id, field="id"))
fn = tc_delta.function
if fn is not None:
if fn.name:
slot["name"] += fn.name
yield ("event", AssistantMessageEvent.toolcall_delta(
idx, fn.name, field="name"))
if fn.arguments:
slot["args"] += fn.arguments
yield ("event", AssistantMessageEvent.toolcall_delta(
idx, fn.arguments, field="arguments"))
if choice.finish_reason:
finish_reason = choice.finish_reason
except Exception as e:
# 中止过程中断网/断流不当错误处理(pi 同款语义)
if signal.aborted:
finish_reason = "aborted"
else:
raise classify_error(e)
finally:
try:
if stream is not None:
stream.close()
except Exception:
pass
# ---- 组装最终助手消息(对照 mapStopReason + AssistantMessage 构造)----
# pi mapStopReason: "length"→"length", "stop"/"tool_calls"→"stop", 其余→"stop"
if finish_reason == "aborted":
stop_reason = "aborted"
elif finish_reason == "length":
stop_reason = "length"
else:
stop_reason = "stop"
tool_calls: List[ToolCall] = []
for idx in sorted(tc_acc.keys()):
slot = tc_acc[idx]
try:
args = json.loads(slot["args"]) if slot["args"] else {}
except json.JSONDecodeError:
# 参数 JSON 被截断/损坏:保留 rawarguments 置空,
# 由循环层按「截断保护」路径处理(不执行残缺调用)
args = {}
tool_calls.append(ToolCall(
id=slot["id"] or new_id("call"),
name=slot["name"],
arguments=args,
raw_arguments=slot["args"],
))
final = AgentMessage(
role="assistant",
content="".join(content_parts),
reasoning="".join(reasoning_parts),
tool_calls=tool_calls,
stop_reason=stop_reason,
usage=usage,
)
yield ("event", AssistantMessageEvent.done())
yield ("final", final)
# ======================================================================
# OpenAI 格式 → pi 格式(from_openai_messages 的逆转换,用于从 DB 装载历史)
# ======================================================================
def _parse_tool_call(tc: Dict[str, Any]) -> Optional[ToolCall]:
"""
解析一条 OpenAI tool_call → ToolCall。
🌟 参数 JSON 可能残缺(历史里存了被截断的调用):
解析失败 → arguments={} 且 raw_arguments 兜底成合法 "{}"
保证 to_openai_messages 回发 API 时参数始终是合法 JSON
API 只校验 JSON 合法性 + tool_call_id 配对,不校验参数内容)。
"""
if not isinstance(tc, dict):
return None
fn = tc.get("function") or {}
raw_args = fn.get("arguments")
if not isinstance(raw_args, str):
raw_args = "" if raw_args is None else str(raw_args)
try:
args = json.loads(raw_args) if raw_args else {}
if not isinstance(args, dict):
args = {"_": args}
except (json.JSONDecodeError, TypeError, ValueError):
args = {}
raw_args = "{}" # 残缺 JSON 兜底,防回发 API 时 400
return ToolCall(
id=tc.get("id") or new_id("call"),
name=fn.get("name") or "",
arguments=args,
raw_arguments=raw_args or "{}",
)
def from_openai_messages(messages: List[Dict[str, Any]]) -> List[AgentMessage]:
"""
把 OpenAI chat 消息数组转成 pi 的 AgentMessage 列表(全保真)。
🌟 修复:worker 模式此前在此丢失全部工具历史(tool 消息被跳过、
assistant.tool_calls 不解析)→ 任务完成/中断后再次提问,模型看不到
之前读过什么文件、执行过什么命令。现改为无损转换:
- user: content 保持 str 或 OpenAI 多模态 list(原样透传)
- assistant: content + reasoning + tool_calls(纯工具轮保留 tool_calls
content 置 "",不再变成空 assistant 污染上下文)
- tool: → AgentMessage(role="toolResult", tool_call_id, content)
- system: 防御性跳过(DB 链表里不会出现)
下游 to_openai_messages 原生支持 toolResult / assistant.tool_calls
与 pi 语义一致,往返(to(from(x)))保真。
"""
out: List[AgentMessage] = []
for m in messages:
if not isinstance(m, dict):
continue
role = m.get("role")
content = m.get("content")
if role == "user":
if content is None:
content = ""
out.append(AgentMessage(role="user", content=content,
# 🆕 压缩持久化:跨轮携带 DB 行 id + 摘要 kind
# kind=compaction_summary → 二次压缩走迭代更新)
kind=m.get("_kind") or "",
db_msg_id=m.get("_db_msg_id") or "",
timestamp=int(m.get("timestamp") or 0)))
elif role == "assistant":
# tool_calls 解析(容忍缺失/损坏)
tool_calls: List[ToolCall] = []
for tc in (m.get("tool_calls") or []):
parsed = _parse_tool_call(tc)
if parsed is not None:
tool_calls.append(parsed)
out.append(AgentMessage(
role="assistant",
content=content if isinstance(content, str) else "",
reasoning=m.get("reasoning") or "",
tool_calls=tool_calls,
stop_reason="stop",
# 🆕 P1: 回放入库的 usage(锚点)+ timestampP0 时效校验)
usage=m.get("usage") or {},
timestamp=int(m.get("timestamp") or 0),
db_msg_id=m.get("_db_msg_id") or "",
))
elif role == "tool":
# tool 结果 → toolResult(保留 tool_call_id 供 API 配对)
if content is None:
content = ""
elif not isinstance(content, str):
content = str(content)
out.append(AgentMessage(
role="toolResult",
content=content,
tool_call_id=m.get("tool_call_id") or "",
timestamp=int(m.get("timestamp") or 0),
db_msg_id=m.get("_db_msg_id") or "",
))
# 其他 rolesystem 等)防御性跳过
return out
+865
View File
@@ -0,0 +1,865 @@
"""
core/agent/tools.py
===================
🌟 pi 工具执行管线的 Python 1:1 移植 + 内置工具
对照 pi-main 源码:
packages/agent/src/agent-loop.ts
- prepareToolCalls() (行 ~470): 校验参数 → 标记错误(不执行)
- executeTool() (行 ~520): before 钩子 → 执行 → after 钩子 → 结果定型
- 并行语义: prepare 串行 → 执行并发(Promise.all) → 结果按原始顺序回写
packages/agent/src/tools/*.ts (coding-agent 内置工具 read/bash/edit/write)
JSON Schema 校验:pi 用 validate-json-schema + ai/src/utils/validation.ts
structuredClone → normalizeOptionalNulls → Value.Convert → coerceWithJsonSchema →
全量错误上报 + 回显收到的参数);这里实现核心子集
type/required/properties/enum/items + 可选字段 null 归一化 + 数字/布尔轻量转换),
零外部依赖。工具自定义预处理对照 pi 的 tool.prepareArguments(见 edit 的 legacy 兼容)。
"""
from __future__ import annotations
import json
import os
import queue
import re
import subprocess
import sys
import tempfile
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from .types import (AgentMessage, AgentTool, AgentToolResult, AbortSignal,
ToolCall, new_id)
from ..platform_shell import kill_process_tree, popen_flags, shell_command # P1-02
try:
from core.debug_log import debug_log as _dbg_log # 🆕 计时观察日志(线程安全/静默)
except Exception: # 导入失败也不影响工具执行
def _dbg_log(msg, tag="APP"):
pass
# ======================================================================
# JSON Schema 校验(子集)
# ======================================================================
# ======================================================================
# 参数归一化 + JSON Schema 校验(子集)
# 对照 pi ai/src/utils/validation.ts:317-347 validateToolArguments
# structuredClone → normalizeOptionalNulls → Value.Convert
# → coerceWithJsonSchema → Check → 【报全部错误 + 回显收到的参数】
# 这里实现核心子集(type/required/properties/enum/items),零外部依赖。
# ======================================================================
_TYPE_MAP = {
"string": str,
"integer": int,
"number": (int, float),
"boolean": bool,
"array": list,
"object": dict,
}
def _norm_types(schema: Dict[str, Any]) -> List[str]:
"""schema 声明的类型(兼容 TypeBox 的 type 数组形式)"""
t = schema.get("type")
if isinstance(t, list):
return [x for x in t if isinstance(x, str)]
return [t] if isinstance(t, str) else []
def _type_ok(value: Any, t: str) -> bool:
"""类型匹配。🌟 bool 不算 integer/numberpython 里 bool 是 int 子类)"""
if t == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if t == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if t == "boolean":
return isinstance(value, bool)
py = _TYPE_MAP.get(t)
return isinstance(value, py) if py else True
def _join_path(path: str, key: str) -> str:
return f"{path}.{key}" if path else key
def normalize_and_coerce(args: Any, schema: Dict[str, Any]) -> Any:
"""归一化 + 轻量强制转换(对照 pi normalizeOptionalNulls + coerceWithJsonSchema
- 可选字段的显式 null → 删除该键(模型常对「未用到的参数」发 null)
- 数字字段收到纯数字字符串 → integer/number"30" → 30
- boolean 字段收到 "true"/"false" → 布尔
返回新对象,不修改入参。
"""
if not isinstance(schema, dict) or not isinstance(args, dict):
return args
props = schema.get("properties") or {}
required = set(schema.get("required") or [])
out: Dict[str, Any] = dict(args)
for key, val in list(out.items()):
sub = props.get(key)
if not isinstance(sub, dict):
continue
if val is None:
if key not in required:
del out[key]
continue
out[key] = _coerce_value(val, sub)
return out
def _coerce_value(val: Any, sub: Dict[str, Any]) -> Any:
types = _norm_types(sub)
if isinstance(val, bool) or val is None:
return val
if isinstance(val, str):
s = val.strip()
if s and ("integer" in types or "number" in types):
try:
f = float(s)
if f.is_integer():
return int(f)
if "number" in types:
return f
return val # integer 字段收到 1.5 → 保持原值(交由校验报错)
except ValueError:
return val
if "boolean" in types and s.lower() in ("true", "false"):
return s.lower() == "true"
return val
if isinstance(val, dict):
return normalize_and_coerce(val, sub)
if isinstance(val, list):
items = sub.get("items")
if isinstance(items, dict):
return [_coerce_value(v, items) if not isinstance(v, dict)
else normalize_and_coerce(v, items) for v in val]
return val
def _type_error(path: str, types: List[str], value: Any) -> str:
t = types[0] if types else "object"
if t == "string":
return f"参数 {path} 应为 string"
if t in ("integer", "number") and isinstance(value, bool):
return f"参数 {path} 应为 {t}"
return f"参数 {path} 类型错误: 期望 {t}"
def _check_value(value: Any, schema: Dict[str, Any], path: str,
errs: List[str], root: bool = False) -> None:
"""递归收集【全部】校验错误(对照 pi Errors() 全量上报)"""
if not isinstance(schema, dict):
return
if root and not isinstance(value, dict):
errs.append(f"参数必须是对象,实际是 {type(value).__name__}")
return
types = _norm_types(schema) or (["object"] if root else [])
if types and not any(_type_ok(value, t) for t in types):
errs.append(_type_error(path, types, value))
return # 类型不符 → 后续检查无意义
if "enum" in schema and value not in schema["enum"]:
errs.append(f"参数 {path} 取值必须是 {schema['enum']} 之一")
if isinstance(value, dict):
props = schema.get("properties") or {}
for req in schema.get("required") or []:
if req not in value:
errs.append(f"缺少必填参数: {req}" if root
else f"参数 {path} 缺少必填字段 {req}")
for k, v in value.items():
if k in props:
_check_value(v, props[k], _join_path(path, k), errs)
elif isinstance(value, list):
items = schema.get("items")
if isinstance(items, dict):
for i, item in enumerate(value):
_check_value(item, items, f"{path}[{i}]", errs)
def validate_json_schema(args: Any, schema: Dict[str, Any]) -> Optional[str]:
"""返回错误描述(多条以「; 」连接);合法返回 None。"""
errs: List[str] = []
_check_value(args, schema, "", errs, root=True)
return "; ".join(errs) if errs else None
def _validate_value(value: Any, schema: Dict[str, Any], path: str) -> Optional[str]:
"""(保留旧签名:返回该节点的首个错误)"""
errs: List[str] = []
_check_value(value, schema, path, errs)
return errs[0] if errs else None
def _brief_json(obj: Any, limit: int = 600) -> str:
"""参数回显(长内容截断,避免 write 的大 content 撑爆错误消息)"""
try:
s = json.dumps(obj, ensure_ascii=False)
except Exception:
s = repr(obj)
return s if len(s) <= limit else s[:limit] + f"…(共 {len(s)} 字符)"
# ======================================================================
# 准备阶段 —— 对照 agent-loop.ts prepareToolCalls
# ======================================================================
@dataclass
class PreparedToolCall:
"""一次工具调用的完整准备结果(执行前定型)"""
tool_call: ToolCall
tool: Optional[AgentTool]
args: Dict[str, Any]
error: str = "" # 准备阶段失败原因(未知工具/参数非法)→ 直接返回错误 toolResult
def prepare_tool_calls(assistant: AgentMessage,
tools: List[AgentTool]) -> List[PreparedToolCall]:
tool_map = {t.name: t for t in tools}
prepared: List[PreparedToolCall] = []
for tc in assistant.tool_calls:
tool = tool_map.get(tc.name)
if tool is None:
prepared.append(PreparedToolCall(
tool_call=tc, tool=None, args={},
error=f"未知工具: {tc.name}",
))
continue
if not isinstance(tc.arguments, dict):
prepared.append(PreparedToolCall(
tool_call=tc, tool=tool, args={},
error="工具参数解析失败(JSON 不完整)",
))
continue
# 🌟 工具自定义参数预处理(对照 pi tool.prepareArguments
raw_args = tc.arguments
if tool.prepare_arguments is not None:
try:
raw_args = tool.prepare_arguments(raw_args)
except Exception as e:
prepared.append(PreparedToolCall(
tool_call=tc, tool=tool, args=tc.arguments,
error=f"参数预处理失败: {e}",
))
continue
if not isinstance(raw_args, dict):
prepared.append(PreparedToolCall(
tool_call=tc, tool=tool, args=tc.arguments,
error="参数预处理返回的不是对象",
))
continue
# 🌟 归一化 + 轻量强制转换(null 可选字段删除 / "30" → 30
args = normalize_and_coerce(raw_args, tool.parameters)
err = validate_json_schema(args, tool.parameters)
if err:
prepared.append(PreparedToolCall(
tool_call=tc, tool=tool, args=args,
error=f"参数校验失败: {err};收到的参数: {_brief_json(raw_args)}",
))
continue
prepared.append(PreparedToolCall(tool_call=tc, tool=tool,
args=args))
return prepared
# ======================================================================
# 执行阶段 —— 对照 agent-loop.ts executeTool
# before 钩子 → 执行 → after 钩子 → 异常兜底
# ======================================================================
def execute_tool_call(prepared: PreparedToolCall,
assistant: AgentMessage,
config,
signal: AbortSignal,
on_update: Optional[Callable[[str], None]],
on_timer: Optional[Callable[[int, int], None]] = None,
) -> AgentToolResult:
"""对照 agent-loop.ts executePreparedToolCall + prepareToolCall 的钩子语义
🆕 on_timer(elapsed_s, timeout_s)bash 运行中每秒滴一次(气泡读秒)"""
tc = prepared.tool_call
# 准备阶段已失败 → 直接错误结果(pi 同款:不进入执行)
if prepared.error:
return AgentToolResult.text(prepared.error, is_error=True)
if signal.aborted:
return AgentToolResult.text("操作已中止 (Operation aborted)", is_error=True)
# before 钩子(对照 pi: 可修改 args / 拒绝 block / 请求 terminate
if config.before_tool_call:
try:
decision = config.before_tool_call(
{"assistant_message": assistant, "tool_call": tc,
"args": prepared.args, "context": config.tool_context},
signal)
except Exception as e:
return AgentToolResult.text(f"before 钩子异常: {e}", is_error=True)
if signal.aborted:
return AgentToolResult.text("操作已中止 (Operation aborted)",
is_error=True)
if decision:
if decision.get("block"):
# 🐛 修复:旧代码向 AgentToolResult.text() 传了不存在的 terminate 参数
# (钩子一旦 block 就抛 TypeError)—— 改为直接构造,保留 terminate 语义
return AgentToolResult(
content=[{"type": "text",
"text": decision.get("reason") or "工具执行被拦截"}],
is_error=True,
terminate=bool(decision.get("terminate")))
if decision.get("args") is not None:
# 🌟 对照 pi applyBeforeToolDecision:钩子改参后【重新校验】
new_args = decision["args"]
if not isinstance(new_args, dict):
return AgentToolResult.text(
"before 钩子返回的参数不是对象", is_error=True)
new_args = normalize_and_coerce(new_args, prepared.tool.parameters)
_verr = validate_json_schema(new_args, prepared.tool.parameters)
if _verr:
return AgentToolResult.text(
f"before 钩子修改后的参数校验失败: {_verr}"
f"收到的参数: {_brief_json(decision['args'])}",
is_error=True)
prepared.args = new_args
t0 = time.time()
try:
# 🆕 秒级滴答回调注入 ctx(不污染调用方的 tool_context 字典)
_ctx = dict(config.tool_context or {})
if on_timer is not None:
_ctx["on_timer"] = on_timer
result = prepared.tool.execute(
tc.id, prepared.args, signal, on_update, _ctx,
)
if not isinstance(result, AgentToolResult):
# 宽容处理:工具返回 str 也接受
result = AgentToolResult.text(str(result))
except Exception as e:
result = AgentToolResult.text(f"工具执行异常: {e}", is_error=True)
# after 钩子
if config.after_tool_call:
try:
config.after_tool_call(tc, result, result.is_error)
except Exception:
pass
# 结果定型(对照 pi finalizeToolResult:确保 content 结构合法)
if not result.content:
result = AgentToolResult.text("(无输出)")
result.details = {"duration_ms": int((time.time() - t0) * 1000),
**(result.details or {} if isinstance(result.details, dict) else {})}
return result
# ======================================================================
# 截断保护 —— 对照 agent-loop.ts failToolCallsFromTruncatedMessage
# 助手消息被 length/aborted 截断时,其工具调用参数可能残缺:
# 一律替换为错误 toolResult,绝不执行残缺调用。
# ======================================================================
def fail_tool_calls_from_truncated_message(assistant: AgentMessage,
reason: str = "aborted") -> List[AgentMessage]:
"""为每个工具调用生成错误 toolResult 消息(对照 pi 返回值的组装)"""
msgs: List[AgentMessage] = []
for tc in assistant.tool_calls:
# 对照 pi: "Tool call {name} was not executed: the response hit the
# output token limit before the arguments were complete..."
text = (f"工具调用 {tc.name} 未执行({reason}):"
f"响应在参数完整前达到输出长度上限,部分参数已被丢弃。"
f"请用完整参数重试该操作。")
msgs.append(AgentMessage(
role="toolResult",
tool_call_id=tc.id,
tool_name=tc.name,
content=text,
is_error=True,
))
return msgs
# ======================================================================
# 内置工具 —— 对照 pi coding-agent 的 read / bash / write / edit
# ======================================================================
_MAX_READ_LINES = 2000
_MAX_OUTPUT_BYTES = 50 * 1024
def _resolve_path(path: str, ctx: Dict[str, Any]) -> str:
"""相对路径基于 tool_context 的 cwd(默认项目根)"""
if os.path.isabs(path):
return os.path.abspath(path)
cwd = ctx.get("cwd", os.getcwd())
return os.path.abspath(os.path.join(cwd, path))
def tool_read(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal,
on_update, ctx: Dict[str, Any]) -> AgentToolResult:
"""read: 读取文本文件(支持 offset/limit 行窗口),带行号输出"""
if not args.get("path"):
return AgentToolResult.text("path 不能为空", is_error=True)
path = _resolve_path(args["path"], ctx)
if not os.path.isfile(path):
return AgentToolResult.text(f"文件不存在: {path}", is_error=True)
try:
offset = max(1, int(args.get("offset", 1)))
except (TypeError, ValueError):
return AgentToolResult.text("offset 必须是整数", is_error=True)
try:
# 🌟 limit 必须有下限:负数会被 python 负索引语义吃掉
# (旧版 limit=-5 → lines[0:-5] → 除末尾 5 行外全部返回,与直觉完全相反)
limit = max(1, min(int(args.get("limit", 2000)), _MAX_READ_LINES))
except (TypeError, ValueError):
return AgentToolResult.text("limit 必须是整数", is_error=True)
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except Exception as e:
return AgentToolResult.text(f"读取失败: {e}", is_error=True)
total = len(lines)
if total == 0:
return AgentToolResult.text("[文件为空(0 行)]")
if offset > total:
return AgentToolResult.text(
f"[起始行 offset={offset} 超出文件范围,该文件共 {total} 行]")
chunk = lines[offset - 1: offset - 1 + limit]
numbered = "".join(
f"{i + offset:6d}\t{line}" for i, line in enumerate(chunk)
)
shown_hi = offset - 1 + len(chunk)
footer = f"\n[已显示 {offset}{shown_hi} 行,共 {total} 行]"
if shown_hi < total:
footer += f"(还有 {total - shown_hi} 行未显示,用 offset={shown_hi + 1} 继续)"
out = numbered + footer
if len(out.encode("utf-8")) > _MAX_OUTPUT_BYTES:
out = out.encode("utf-8")[:_MAX_OUTPUT_BYTES].decode("utf-8", "ignore")
out += "\n[输出超过 50KB 已截断]"
return AgentToolResult.text(out)
def tool_bash(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal,
on_update, ctx: Dict[str, Any]) -> AgentToolResult:
"""bash: 执行 shell 命令(默认 120s 超时)。对照 pi: bash 是 sequential 工具
🆕 秒级滴答:Popen + communicate(timeout=1) 循环,每秒:
① 日志 [timer] bash 读秒 N/Ts
② on_timer(N, T) 推前端气泡读秒
③ 到期杀进程树(Windows taskkill /T,连孤儿子进程一起杀)→ 真超时
"""
if signal.aborted:
return AgentToolResult.text("操作已中止 (Operation aborted)", is_error=True)
command = args.get("command", "")
if not command.strip():
return AgentToolResult.text("command 不能为空", is_error=True)
timeout = min(float(args.get("timeout", 120)), 600)
cwd = ctx.get("cwd") or os.getcwd()
# P1-02:显式 shell 契约(Windows cmd.exe /d /cLinux /bin/bash -lc),
# 不再依赖 shell=True 的平台默认值;ctx 的 "shell" 键已废弃(bash 工具即 shell 工具)
on_timer = ctx.get("on_timer")
t0 = time.time()
# 🆕 计时观察①:计时器启动时刻 + 模型实际传的 timeout 值
_dbg_log(f"[timer] bash 开始 timeout={timeout:.0f}s "
f"(显式={args.get('timeout')}) cmd={command[:80]!r}")
def _tick(elapsed_i: int):
"""每秒一次:日志 + 推前端(静默吞异常,绝不影响执行)"""
_dbg_log(f"[timer] bash 读秒 {elapsed_i}/{int(timeout)}s")
try:
if on_timer:
on_timer(elapsed_i, int(timeout))
except Exception:
pass
def _kill_tree(proc):
"""杀整个进程树:Windows taskkill /F /TLinux 独立进程组
SIGTERM → 宽限 → SIGKILL(见 core.platform_shell.kill_process_tree)。"""
kill_process_tree(proc)
# 🐛 修复:text=True 不带 encoding 时按系统码页(中文 Windows=GBK)解码,
# 子进程输出 UTF-8python/git/中文 echo)→ _readerthread UnicodeDecodeError。
# 强制 UTF-8 + 容错替换;PYTHONIOENCODING 让 python 子进程也按 UTF-8 输出。
_env = dict(os.environ, PYTHONIOENCODING="utf-8")
try:
proc = subprocess.Popen(
shell_command(command), # P1-02:显式 shell 命令
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace",
cwd=cwd, env=_env,
**popen_flags(), # P1-02Linux 独立进程组(start_new_session
)
except Exception as e:
return AgentToolResult.text(f"执行失败: {e}", is_error=True)
timed_out = False
out_parts: List[str] = []
err_parts: List[str] = []
err_header_fed = False
# 🆕 实时输出:stdout/stderr 各起一个 reader 线程 → 队列,
# 主循环(仍在 worker 线程内)抽干队列并回调 on_update。
# 绝不从 reader 线程直接回调 —— UI 侧 _on_tool_updated 会改 timeline,跨线程不安全。
_q: "queue.Queue" = queue.Queue()
def _reader(stream, tag):
try:
for line in iter(stream.readline, ""):
_q.put((tag, line))
except Exception:
pass
finally:
try:
stream.close()
except Exception:
pass
_t_out = threading.Thread(target=_reader, args=(proc.stdout, "out"), daemon=True)
_t_err = threading.Thread(target=_reader, args=(proc.stderr, "err"), daemon=True)
_t_out.start()
_t_err.start()
def _feed(tag, chunk):
"""抽到一块输出:累积 + 推实时流(只在 worker 线程内调用)"""
nonlocal err_header_fed
piece = chunk
if tag == "err":
err_parts.append(chunk)
if not err_header_fed:
err_header_fed = True
piece = "[stderr]\n" + chunk
else:
out_parts.append(chunk)
try:
if on_update and piece:
on_update(piece)
except Exception:
pass
def _drain():
"""抽干队列里已到达的输出(单轮上限防极端刷屏卡死)"""
for _ in range(500):
try:
tag, chunk = _q.get_nowait()
except queue.Empty:
return
_feed(tag, chunk)
last_tick = 0 # 首次滴答仍在 elapsed=1(与旧 communicate 实现一致,不在 0 秒多滴一次)
try:
while True:
# ① 最多 0.2s 醒一次 → 把新输出实时推给前端
try:
tag, chunk = _q.get(timeout=0.2)
_feed(tag, chunk)
except queue.Empty:
pass
_drain()
# ② 进程已退出:等 reader 读完管道残余 → 收尾
if proc.poll() is not None:
_t_out.join(timeout=1.0)
_t_err.join(timeout=1.0)
_drain()
break
# ③ 每秒读秒 + 中止/超时判定(原语义不变)
elapsed_i = int(time.time() - t0)
if elapsed_i > last_tick:
last_tick = elapsed_i
if signal.aborted:
_kill_tree(proc)
_t_out.join(timeout=1.0)
_t_err.join(timeout=1.0)
_drain()
return AgentToolResult.text(
"操作已中止 (Operation aborted)", is_error=True)
_tick(elapsed_i)
if time.time() - t0 >= timeout:
# 到期 → 真杀(进程树)
timed_out = True
_kill_tree(proc)
try:
proc.wait(timeout=15)
except Exception:
pass
_t_out.join(timeout=5.0)
_t_err.join(timeout=5.0)
_drain()
break
except Exception as e:
try:
_kill_tree(proc)
except Exception:
pass
return AgentToolResult.text(f"执行失败: {e}", is_error=True)
out = "".join(out_parts)
err = "".join(err_parts)
code = proc.returncode if proc.returncode is not None else -1
dur = time.time() - t0
if timed_out:
# 🆕 计时观察②:计时器到期
_dbg_log(f"[timer] bash 超时触发 设定={timeout:.0f}s "
f"实际={dur:.1f}s cmd={command[:80]!r}")
return AgentToolResult.text(f"命令超时(>{timeout:.0f}s)已终止", is_error=True)
# 🆕 计时观察③:正常结束 + 实际耗时
_dbg_log(f"[timer] bash 正常结束 dur={dur:.1f}s "
f"exit={code} 设定timeout={timeout:.0f}s")
result = f"$ {command}\n"
if out:
result += out if out.endswith("\n") else out + "\n"
if err:
result += f"[stderr]\n{err}"
result += f"\n[exit {code}] ({dur:.1f}s)"
if len(result.encode("utf-8")) > _MAX_OUTPUT_BYTES:
result = result.encode("utf-8")[:_MAX_OUTPUT_BYTES].decode("utf-8", "ignore")
result += "\n[输出超过 50KB 已截断]"
return AgentToolResult.text(result, is_error=code != 0,
details={"exit_code": code})
def _atomic_write(path: str, content: str) -> None:
"""原子写:同目录临时文件 + os.replace(避免半截文件)
保留原有 newline 语义(默认 None → 平台换行翻译),仅增加原子性。
"""
d = os.path.dirname(os.path.abspath(path)) or "."
fd, tmp = tempfile.mkstemp(dir=d, prefix=".hocode_w_", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
os.replace(tmp, path) # Windows/POSIX 均为原子替换
except BaseException:
try:
os.remove(tmp)
except Exception:
pass
raise
def tool_write(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal,
on_update, ctx: Dict[str, Any]) -> AgentToolResult:
"""write: 创建/覆盖文件(自动建父目录)"""
if not args.get("path"):
return AgentToolResult.text("path 不能为空", is_error=True)
path = _resolve_path(args["path"], ctx)
content = args.get("content", "")
if content is None:
content = ""
if not isinstance(content, str):
content = str(content)
try:
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
_atomic_write(path, content)
except Exception as e:
return AgentToolResult.text(f"写入失败: {e}", is_error=True)
return AgentToolResult.text(f"已写入 {len(content)} 字符 → {path}")
def _prepare_edit_args(args: Any) -> Any:
"""归一化 edit 参数(对照 pi edit.ts:56-70 prepareEditArguments
- edits 为 JSON 字符串 → 解析为数组
- edits 内条目为 JSON 字符串 → 解析为对象
- 兼容 legacy 单条形式 {path, oldText, newText} → 包装为 edits:[{...}]
"""
if not isinstance(args, dict):
return args
out = dict(args)
edits = out.get("edits")
if isinstance(edits, str):
try:
parsed = json.loads(edits)
if isinstance(parsed, list):
out["edits"] = parsed
elif isinstance(parsed, dict):
out["edits"] = [parsed]
except Exception:
pass # 解析不了就交给校验层报错
if out.get("edits") is None and ("oldText" in out or "newText" in out):
ed: Dict[str, Any] = {}
if "oldText" in out:
ed["oldText"] = out["oldText"]
if "newText" in out:
ed["newText"] = out["newText"]
out = {"path": out.get("path"), "edits": [ed]}
if isinstance(out.get("edits"), list):
norm = []
for it in out["edits"]:
if isinstance(it, str):
try:
it = json.loads(it)
except Exception:
pass
norm.append(it)
out["edits"] = norm
return out
def _plan_edits(content: str, edits: List[Any]):
"""对【原始内容】定位每条 oldText(要求恰好 1 次)+ 区间重叠检测
对照 pi edit-diff.ts:348「edits[i] and edits[j] overlap … Merge them into one edit」。
返回 (错误信息, [(start, end, index, new_text), ...] 已按 start 排序)
"""
spans: List[tuple] = []
for i, ed in enumerate(edits):
if not isinstance(ed, dict):
return f"{i + 1} 条 edit 不是对象", None
old = ed.get("oldText", "")
if not isinstance(old, str):
return f"{i + 1} 条 edit 的 oldText 必须是字符串", None
if old == "":
return f"{i + 1} 条 edit 的 oldText 不能为空", None
c = content.count(old)
if c == 0:
return f"{i + 1} 条 edit 未找到匹配文本(oldText 不存在或已变化)", None
if c > 1:
return f"{i + 1} 条 edit 匹配到 {c} 处(要求唯一),请提供更长的上下文", None
start = content.index(old)
spans.append((start, start + len(old), i, ed.get("newText", "")))
ordered = sorted(spans)
for a, b in zip(ordered, ordered[1:]):
if b[0] < a[1]:
return (f"edits[{a[2]}] 与 edits[{b[2]}] 区域重叠,"
f"请合并为一条 edit 或改为互不相交的修改"), None
return None, ordered
def tool_edit(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal,
on_update, ctx: Dict[str, Any]) -> AgentToolResult:
"""edit: 精确文本替换(edits: [{oldText, newText}]
🌟 语义对齐 pi:全部 edit 都对【原始文件内容】定位,要求各自唯一且区间互不重叠,
然后按偏移一次性重建(不是逐条 replace 的增量语义)。
"""
if not args.get("path"):
return AgentToolResult.text("path 不能为空", is_error=True)
path = _resolve_path(args["path"], ctx)
if not os.path.isfile(path):
return AgentToolResult.text(f"文件不存在: {path}", is_error=True)
edits = args.get("edits", [])
if not isinstance(edits, list) or not edits:
return AgentToolResult.text("edits 不能为空", is_error=True)
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
return AgentToolResult.text(f"读取失败: {e}", is_error=True)
err, ordered = _plan_edits(content, edits)
if err:
return AgentToolResult.text(err, is_error=True)
# 按偏移从后往前替换(前面的偏移不受影响)
out = content
for start, end, _i, new_text in reversed(ordered):
out = out[:start] + (new_text if isinstance(new_text, str) else str(new_text)) + out[end:]
try:
_atomic_write(path, out)
except Exception as e:
return AgentToolResult.text(f"编辑失败: {e}", is_error=True)
return AgentToolResult.text(f"已应用 {len(ordered)} 处编辑 → {path}")
# ======================================================================
# 默认工具集 —— 对照 pi 默认启用 read/bash/edit/write
# ======================================================================
_TEXT_TOOL_RE = re.compile(r"<(bash|read)>(.*?)</\1>", re.DOTALL)
def parse_text_tool_calls(content: str):
"""
兜底解析(haocode 扩展,pi 无此层):
对不支持 tools API 的供应商/模型——它们会把工具调用用纯文字""出来
(例如 <bash>ls</bash>、<read>路径</read>),本函数识别单参数工具
bash / read 并转成真 ToolCall 供循环执行。
write / edit 参数多、文本歧义大,不做兜底(保持安全)。
返回 (原文, [ToolCall]);未命中时 calls 为空列表。
"""
if not content or "<" not in content:
return content, []
calls: List[ToolCall] = []
def _sub(m):
name, payload = m.group(1), m.group(2).strip()
if not payload:
return m.group(0)
if name == "bash":
calls.append(ToolCall(id=new_id("txtcall"), name="bash",
arguments={"command": payload}))
elif name == "read":
calls.append(ToolCall(id=new_id("txtcall"), name="read",
arguments={"path": payload}))
return m.group(0) # 保留原文(UI 已渲染,不回改;执行由 tool_calls 驱动)
cleaned = _TEXT_TOOL_RE.sub(_sub, content)
return cleaned, calls
def default_tools() -> List[AgentTool]:
return [
AgentTool(
name="read", label="读取文件",
description="读取文本文件内容(带行号)。支持 offset/limit 按行窗口读取大文件。",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径"},
"offset": {"type": "integer", "description": "起始行号(从 1 开始)"},
"limit": {"type": "integer", "description": "最多读取行数(默认 2000"},
},
"required": ["path"],
},
execute=tool_read,
),
AgentTool(
name="bash", label="执行命令",
description="执行 shell 命令并返回 stdout/stderr/退出码。默认 120 秒超时。",
parameters={
"type": "object",
"properties": {
"command": {"type": "string", "description": "要执行的命令"},
"timeout": {"type": "number", "description": "超时秒数(默认 120,最大 600"},
},
"required": ["command"],
},
execute=tool_bash,
execution_mode="sequential", # 对照 pi: bash 整批串行
),
AgentTool(
name="write", label="写入文件",
description="创建或覆盖写入文件(自动创建父目录)。",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径"},
"content": {"type": "string", "description": "文件内容"},
},
"required": ["path", "content"],
},
execute=tool_write,
),
AgentTool(
name="edit", label="编辑文件",
description=("对文件做精确文本替换。edits 中每条 oldText 必须在原文件中唯一,"
"且各条区间不得重叠(重叠请合并为一条)。"),
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径"},
"edits": {
"type": "array",
"items": {
"type": "object",
"properties": {
"oldText": {"type": "string"},
"newText": {"type": "string"},
},
"required": ["oldText"],
},
"description": "替换操作列表",
},
},
"required": ["path", "edits"],
},
execute=tool_edit,
prepare_arguments=_prepare_edit_args,
),
]
+331
View File
@@ -0,0 +1,331 @@
"""
core/agent/types.py
====================
🌟 pi agent-core 的 Python 1:1 移植 —— 数据模型层
对照 pi-main 源码:
packages/agent/src/types.ts (443 行)
- AgentMessage 联合类型 (user / assistant / toolResult)
- AgentEvent 11 种事件
- AgentTool / AgentToolCall / AgentToolResult
- AgentState / AgentConfig / AgentError
设计原则(与 pi 完全一致):
1. 消息是不可变事实(append-only),循环只追加、不修改
2. 事件是唯一对外输出(TUI/Qt 都只是事件订阅者)
3. 工具 = (name, description, JSON-Schema 参数, execute 函数)
4. 中止用 AbortSignal 标志位,不用异常控制流
"""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
def _now_ms() -> int:
return int(time.time() * 1000)
def new_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:16]}"
# ======================================================================
# 中止信号 —— 对照 pi: AbortController/AbortSignalWeb 标准)
# pi 里 streamSimple 每消费一个 chunk 检查 signal.aborted
# ======================================================================
class AbortSignal:
def __init__(self):
self.aborted: bool = False
self.reason: str = ""
def abort(self, reason: str = "aborted"):
self.aborted = True
self.reason = reason
# ======================================================================
# 消息模型 —— 对照 types.ts 的 AgentMessage 联合类型
# pi: type AgentMessage = AgentUserMessage | AgentAssistantMessage | AgentToolResultMessage
# ======================================================================
@dataclass
class ToolCall:
"""pi: AssistantMessage 内容块里的 {type:"toolCall", id, name, arguments}"""
id: str
name: str
arguments: Dict[str, Any] = field(default_factory=dict)
# 流式阶段累积的原始 JSON 字符串(参数被截断时可能是残缺 JSON)
raw_arguments: str = ""
@dataclass
class AgentMessage:
"""
统一消息容器。role 决定哪些字段有效:
user -> content: str 或 OpenAI 多模态 list
assistant -> content(str 正文) + reasoning(思考) + tool_calls + stop_reason + usage
toolResult -> tool_call_id + tool_name + content + is_error
与 pi 的三种 Message 类型字段一一对应。
"""
role: str # "user" | "assistant" | "toolResult"
content: Any = "" # str | List[dict]
reasoning: str = "" # pi: thinking blockvLLM: reasoning_content
tool_calls: List[ToolCall] = field(default_factory=list)
tool_call_id: str = "" # toolResult 专用
tool_name: str = "" # toolResult 专用
is_error: bool = False # toolResult 专用
stop_reason: str = "" # assistant 专用: stop|length|aborted|error
error_message: str = "" # assistant 出错时的说明
usage: Dict[str, Any] = field(default_factory=dict) # {input, output, cacheRead...}
kind: str = "" # "" | "compaction_summary"(对照 pi compaction 条目)
db_msg_id: str = "" # 🆕 压缩持久化:对应 DB 行的 idbuild_api_context 注入,切点计算用)
timestamp: int = field(default_factory=_now_ms)
id: str = field(default_factory=lambda: new_id("msg"))
def to_dict(self) -> Dict[str, Any]:
return {
"role": self.role, "content": self.content, "reasoning": self.reasoning,
"tool_calls": [
{"id": tc.id, "name": tc.name, "arguments": tc.arguments,
"raw_arguments": tc.raw_arguments}
for tc in self.tool_calls
],
"tool_call_id": self.tool_call_id, "tool_name": self.tool_name,
"is_error": self.is_error, "stop_reason": self.stop_reason,
"error_message": self.error_message, "usage": self.usage,
"kind": self.kind,
"db_msg_id": self.db_msg_id,
"timestamp": self.timestamp, "id": self.id,
}
@staticmethod
def from_dict(d: Dict[str, Any]) -> "AgentMessage":
return AgentMessage(
role=d.get("role", "user"), content=d.get("content", ""),
reasoning=d.get("reasoning", ""),
tool_calls=[ToolCall(id=t.get("id", ""), name=t.get("name", ""),
arguments=t.get("arguments", {}),
raw_arguments=t.get("raw_arguments", ""))
for t in d.get("tool_calls", [])],
tool_call_id=d.get("tool_call_id", ""), tool_name=d.get("tool_name", ""),
is_error=d.get("is_error", False), stop_reason=d.get("stop_reason", ""),
error_message=d.get("error_message", ""), usage=d.get("usage", {}),
kind=d.get("kind", ""),
db_msg_id=d.get("db_msg_id", ""),
timestamp=d.get("timestamp", 0), id=d.get("id") or new_id("msg"),
)
# ======================================================================
# 助手消息的流式增量事件 —— 对照 pi: AssistantMessageEvent
# {type:"text_delta", textDelta} | {type:"thinking_delta", ...}
# | {type:"toolcall_delta", ...} | {type:"done"} | {type:"error"}
# ======================================================================
@dataclass
class AssistantMessageEvent:
type: str # "text_delta" | "thinking_delta" | "toolcall_delta" | "done" | "error"
text: str = "" # text_delta / thinking_delta 的增量
tool_call_index: int = -1 # toolcall_delta 属于第几个 toolCall
tool_call_delta: str = "" # toolcall_delta 的原始增量片段
tool_call_field: str = "arguments" # 片段属于 id | name | arguments 哪一段
error_message: str = "" # error 时的说明
@staticmethod
def text_delta(text: str) -> "AssistantMessageEvent":
return AssistantMessageEvent(type="text_delta", text=text)
@staticmethod
def thinking_delta(text: str) -> "AssistantMessageEvent":
return AssistantMessageEvent(type="thinking_delta", text=text)
@staticmethod
def toolcall_delta(index: int, delta: str,
field: str = "arguments") -> "AssistantMessageEvent":
return AssistantMessageEvent(type="toolcall_delta", tool_call_index=index,
tool_call_delta=delta, tool_call_field=field)
@staticmethod
def done() -> "AssistantMessageEvent":
return AssistantMessageEvent(type="done")
@staticmethod
def error(message: str) -> "AssistantMessageEvent":
return AssistantMessageEvent(type="error", error_message=message)
# ======================================================================
# Agent 事件(11 种)—— 对照 types.ts 的 AgentEvent 联合类型
# ======================================================================
EVENT_TYPES = (
"agent_start", "agent_end",
"turn_start", "turn_end",
"message_start", "message_update", "message_end",
"tool_execution_start", "tool_execution_update", "tool_execution_end",
)
@dataclass
class AgentEvent:
"""
事件容器。字段随 type 变化:
agent_end -> stop_reason, error
message_start/end -> message (AgentMessage)
message_update -> message(当前累积快照), assistant_message_event
tool_execution_start -> tool_call, tool_name
tool_execution_update -> tool_call, arg(增量)
tool_execution_timer -> tool_call, arg=(已耗秒, 超时秒) 🆕 bash 读秒
tool_execution_end -> tool_call, result, is_error
"""
type: str
message: Optional[AgentMessage] = None
assistant_message_event: Optional[AssistantMessageEvent] = None
tool_call: Optional[ToolCall] = None
arg: str = ""
result: Optional["AgentToolResult"] = None
is_error: bool = False
stop_reason: str = ""
error: Optional["AgentError"] = None
messages: List[AgentMessage] = field(default_factory=list) # agent_end 携带的本轮新消息
# ======================================================================
# 工具 —— 对照 types.ts: AgentTool / AgentToolCall / AgentToolResult
# ======================================================================
@dataclass
class AgentToolResult:
"""pi: {content: OutputContent[], details?, isError, terminate?}"""
content: List[Dict[str, Any]] = field(default_factory=list) # [{type:"text", text}]
details: Any = None
is_error: bool = False
terminate: bool = False # 对照 pi: 工具可请求终止整个批次/循环
@staticmethod
def text(text: str, is_error: bool = False, details: Any = None) -> "AgentToolResult":
return AgentToolResult(content=[{"type": "text", "text": text}],
details=details, is_error=is_error)
def as_text(self) -> str:
return "".join(c.get("text", "") for c in self.content if c.get("type") == "text")
@dataclass
class AgentTool:
"""
pi: interface AgentTool<T> { name; description; parameters(JSON Schema);
execute(toolCallId, args, signal, onUpdate, context) => Promise<ToolResult> }
Python 版 execute 签名完全一致(同步执行,循环里用线程池并发)。
"""
name: str
description: str
parameters: Dict[str, Any] # JSON Schema
execute: Callable # (tool_call_id, args, signal, on_update, context) -> AgentToolResult
label: str = "" # UI 展示用短标签
# 对照 pi types.ts: executionMode —— "sequential" 工具会把整个批次拉回串行执行
execution_mode: str = "parallel"
# 🌟 对照 pi AgentHarnessTool.prepareArguments:校验前的确定性参数预处理
# (如 edit 兼容 legacy 单条形式 / edits 为 JSON 字符串),签名 (args) -> args
prepare_arguments: Optional[Callable] = None
# ======================================================================
# 运行时状态 / 配置 —— 对照 types.ts: AgentState / AgentConfig
# ======================================================================
@dataclass
class AgentState:
messages: List[AgentMessage] = field(default_factory=list)
is_streaming: bool = False
streaming_message: Optional[AgentMessage] = None # 正在流式生成的助手消息
streaming_delta: Dict[str, str] = field(default_factory=dict) # 各通道当前增量缓冲
error: Optional["AgentError"] = None
@dataclass
class RetryConfig:
"""对照 pi settings-manager.ts retry 默认值(行 32-33):
maxRetries=3, baseDelayMs=2000,指数退避 2s → 4s → 8sbase × 2^(n-1)
max_delay_ms=0 表示不设上限(pi _prepareRetry 无封顶)"""
max_attempts: int = 3
base_delay_ms: int = 2000
factor: float = 2.0
max_delay_ms: int = 0 # 0 = 不封顶
@dataclass
class ModelConfig:
"""
对照 pi models.json 里的模型定义。
context_window = pi 的 contextWindowmax_tokens = pi 的 maxTokens。
"""
provider: str = ""
name: str = ""
context_window: int = 128000
max_tokens: int = 8192
# 请求参数
temperature: float = 0.7
timeout_seconds: float = 180.0
api_key: str = ""
base_url: str = "https://api.openai.com/v1"
# 🆕 thinking 模式(DeepSeek 等):assistant 消息必须把 reasoning_content
# 原样回传,否则 400。默认开启(只影响有 reasoning 的轮中消息;
# DB 回放行 reasoning="" 不带字段 → 全新请求体不变)。
pass_reasoning: bool = True
@dataclass
class AgentConfig:
"""
对照 types.ts: AgentConfig
model / tools / systemPrompt / maxTurns / steeringMode / followUpMode
+ 6 个循环钩子(transformContext / beforeToolCall / afterToolCall /
shouldStopAfterTurn / prepareNextTurn
"""
model: ModelConfig
system_prompt: str = ""
tools: List[AgentTool] = field(default_factory=list)
max_turns: int = 50 # pi 默认 50
steering_mode: str = "one-at-a-time" # pi 默认 "one-at-a-time""all" 一次性全注入)
follow_up_mode: str = "one-at-a-time"
# 对照 pi types.ts ToolExecutionMode: 批次级执行模式(有 sequential 工具自动转串行)
tool_execution: str = "parallel" # "sequential" | "parallel"
retry: RetryConfig = field(default_factory=RetryConfig)
# 压缩(🌟 1:1 对照 pi DEFAULT_COMPACTION_SETTINGS
compaction_reserve: int = 16384 # reserveTokens: 摘要提示词与输出预留
compaction_keep_recent: int = 20000 # keepRecentTokens: 压缩后保留的近期上下文预算
# 钩子(None = 无钩子,对照 pi 的可选字段)
transform_context: Optional[Callable] = None # (messages) -> messages
before_tool_call: Optional[Callable] = None # (tool_call, args) -> 可修改/拒绝
after_tool_call: Optional[Callable] = None # (tool_call, result, is_error) -> None
should_stop_after_turn: Optional[Callable] = None # (messages) -> bool
prepare_next_turn: Optional[Callable] = None # (context) -> context
# 🆕 haocode 增强(偏离 pi 1:1):轮中请求前的主动压缩检查
# (messages) -> messages|None:返回新列表 = 发生了压缩(循环需同步上下文);
# None = 不需要/不可压缩(原样发请求,响应式安全网仍在)
compact_fn: Optional[Callable] = None # (messages) -> messages|None
# 运行上下文(透传给 tool.execute,如工作目录)
tool_context: Dict[str, Any] = field(default_factory=dict)
@dataclass
class AgentError(Exception):
"""对照 pi 的 AgentError —— 携带分类信息供恢复逻辑判定"""
message: str = ""
# 分类: "rate_limit" | "overload" | "server_error" | "timeout" |
# "connection" | "auth" | "unknown"
kind: str = "unknown"
status_code: Optional[int] = None
recoverable: bool = False # 恢复逻辑判定后的标记
def __str__(self):
return self.message or "AgentError"
# ======================================================================
# 运行结果 —— 对照 agent-session.ts 里对 agent_end 事件的读取
# ======================================================================
@dataclass
class RunResult:
stop_reason: str = "stop" # stop | length | aborted | error
error: Optional[AgentError] = None
message_count: int = 0
+77
View File
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""core/config_paths.py —— 统一的配置文件路径解析与容错加载(P0-01 新增)
所有配置读取入口必须经过本模块,不得在别处直接定位 data/config.json
1. 环境变量 HAOCODE_CONFIG_FILE 优先级最高(自动化测试用它指向临时文件);
2. 未设置环境变量时才回落到项目内 data/config.json(锚定 core/ 目录,不受 CWD 影响)。
行为约定(REPAIR_BACKLOG P0-01 硬约束):
· 路径解析与加载过程只在出错时输出可见警告(只含路径与错误类型,不含配置内容);
· 缺失 / 损坏 / 非 JSON 对象 → 返回安全空 dict 继续,不抛异常、不阻断
不需要该配置的源码路径;
· import 本模块时不打印、不复制、不迁移任何配置内容。
"""
import json
import os
ENV_KEY = "HAOCODE_CONFIG_FILE"
_DEFAULT_CONFIG_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "data", "config.json")
def config_path() -> str:
"""返回当前生效的配置文件路径(环境变量优先,调用时解析)。"""
p = os.environ.get(ENV_KEY, "").strip()
return p if p else _DEFAULT_CONFIG_PATH
def load_config() -> dict:
"""容错加载配置。缺失/损坏/非对象 → 可见警告 + 安全空 dict(绝不抛异常)。"""
p = config_path()
if not os.path.exists(p):
print(f"[config] 配置文件缺失: {p} → 使用安全默认值(providers 为空)继续启动")
return {}
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
print(f"[config] 配置文件读取/解析失败: {p}{type(e).__name__}"
f" → 使用安全默认值继续启动")
return {}
if not isinstance(data, dict):
print(f"[config] 配置文件内容不是 JSON 对象: {p}"
f" → 使用安全默认值继续启动")
return {}
return data
# ----------------------------------------------------------------------
# P1-01:渲染窗口配置解析(render_window_mode / render_window_size
# 规则(与 ui/web/render_window.js 的 JS 侧守卫保持一致):
# size:只接受非布尔整数 10..200,缺失/布尔/字符串/小数/零/负数/越界 → 静默回落 40;
# mode:只接受 "auto"/"manual",否则回落 "auto"。
# ----------------------------------------------------------------------
DEFAULT_RENDER_WINDOW_SIZE = 40
ALLOWED_RENDER_WINDOW_SIZES = (10, 40, 200)
def render_window_settings(cfg: dict | None = None) -> dict:
"""解析渲染窗口配置。cfg 缺省时读取当前生效配置。永不抛异常。"""
if cfg is None:
cfg = load_config()
if not isinstance(cfg, dict):
cfg = {}
raw_size = cfg.get("render_window_size", None)
if isinstance(raw_size, bool) or not isinstance(raw_size, int) \
or raw_size < 10 or raw_size > 200:
size = DEFAULT_RENDER_WINDOW_SIZE
else:
size = raw_size
raw_mode = cfg.get("render_window_mode", None)
mode = raw_mode if raw_mode in ("auto", "manual") else "auto"
return {"mode": mode, "size": size}
+715
View File
@@ -0,0 +1,715 @@
import sqlite3
import os
import sys
import re
import json
import uuid
import time
import shutil
from typing import List, Dict, Optional
# 🝙 P0 fix: DB path based on the file's location (no longer depends on the process's working directory)
# 🌟 打包(PyInstaller onedir)时:优先与源码树共用 data/chat_history.db(历史不丢失);
# 若 exe 被复制到源码树之外 → 回退为 exe 旁的 data/chat_history.db
if getattr(sys, "frozen", False):
_exe_dir = os.path.dirname(os.path.abspath(sys.executable))
_shared = os.path.abspath(os.path.join(_exe_dir, "..", "..", "data", "chat_history.db"))
if os.path.isdir(os.path.dirname(_shared)):
_DEFAULT_DB = _shared
else:
_DEFAULT_DB = os.path.join(_exe_dir, "data", "chat_history.db")
else:
_DEFAULT_DB = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "data", "chat_history.db")
class _AutoCloseConn:
"""🝙 P0 fix: wraps sqlite3.Connection.
sqlite3.Connection's with only handles commit/rollback and does NOT close.
All 19 call sites use `with self.get_connection() as conn:`, so we auto-close on with exit.
"""
def __init__(self, conn):
self._conn = conn
def __getattr__(self, name):
return getattr(self._conn, name)
def __enter__(self):
self._conn.__enter__()
return self
def __exit__(self, exc_type, exc, tb):
try:
return self._conn.__exit__(exc_type, exc, tb)
finally:
try:
self._conn.close()
except Exception:
pass
class DBManager:
def __init__(self, db_path: str = None):
# 🝙 Default path is anchored to the core/ directory, not affected by launch CWD
self.db_path = db_path or os.path.abspath(_DEFAULT_DB)
# 🆕 附件/媒体文件根目录(copy_session 深拷贝磁盘文件用)。默认=项目根;测试可覆盖。
self.files_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.is_first_run = not os.path.exists(self.db_path)
d = os.path.dirname(self.db_path)
if d:
os.makedirs(d, exist_ok=True)
self._init_db()
def get_connection(self):
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return _AutoCloseConn(conn)
def _init_db(self):
"""初始化表结构并注入默认数据"""
with self.get_connection() as conn:
cursor = conn.cursor()
# 1. 创建 sessions 表 (新增 current_leaf_msg_id)
cursor.execute("""
CREATE TABLE IF NOT EXISTS 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
)
""")
# 2. 创建 messages 表 (新增 parent_id)
cursor.execute("""
CREATE TABLE IF NOT EXISTS 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,
stop_reason TEXT,
error_message TEXT,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
""")
# ==========================================
# 🌟 核心性能优化:为高频查询的字段建立索引
# ==========================================
cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_id ON messages(session_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_parent_id ON messages(parent_id)")
# 3. 🌟 自动化热升级:检测并兼容旧数据库
self._upgrade_schema(cursor)
# 4. 检查是否需要插入初始默认对话
cursor.execute("SELECT COUNT(*) FROM sessions")
if cursor.fetchone()[0] == 0:
self._seed_default_chat(cursor)
conn.commit()
def _upgrade_schema(self, cursor):
"""检测缺少的新字段并自动补齐,如果是刚升级,则自动将旧线性数据串联成链表"""
upgraded = False
# 兼容 sessions 字段
for col in ["has_messages", "sort_order", "is_starred"]:
try:
cursor.execute(f"SELECT {col} FROM sessions LIMIT 1")
except sqlite3.OperationalError:
if col == "has_messages": cursor.execute("ALTER TABLE sessions ADD COLUMN has_messages BOOLEAN DEFAULT 0")
if col == "sort_order": cursor.execute("ALTER TABLE sessions ADD COLUMN sort_order INTEGER DEFAULT 0")
if col == "is_starred": cursor.execute("ALTER TABLE sessions ADD COLUMN is_starred BOOLEAN DEFAULT 0")
# 🌟 核心:兼容链表树架构
try:
# 兼容 sessions.mode 列(chat/worker 模式锁定,NULL=未发送过)
try:
cursor.execute("SELECT mode FROM sessions LIMIT 1")
except Exception:
cursor.execute("ALTER TABLE sessions ADD COLUMN mode TEXT")
cursor.execute("SELECT current_leaf_msg_id FROM sessions LIMIT 1")
except sqlite3.OperationalError:
print("[DB]: 正在升级 sessions 表 (添加 current_leaf_msg_id)...")
cursor.execute("ALTER TABLE sessions ADD COLUMN current_leaf_msg_id TEXT")
upgraded = True
try:
cursor.execute("SELECT attachment_metadata, parent_id FROM messages LIMIT 1")
except sqlite3.OperationalError:
print("[DB]: 正在升级 messages 表 (添加 attachment_metadata, parent_id)...")
try: cursor.execute("ALTER TABLE messages ADD COLUMN attachment_metadata TEXT")
except: pass
try: cursor.execute("ALTER TABLE messages ADD COLUMN parent_id TEXT")
except: pass
upgraded = True
# 🌟 messages.timeline 列(agent 时间线 JSON: 思考/文本/工具 按事件顺序)
try:
cursor.execute("SELECT timeline FROM messages LIMIT 1")
except sqlite3.OperationalError:
print("[DB]: 正在升级 messages 表 (添加 timeline)...")
try: cursor.execute("ALTER TABLE messages ADD COLUMN timeline TEXT")
except: pass
upgraded = True
# 🆕 P1: messages.usage 列(assistant 回复的精确 usage JSON
# 供显示/压缩估算做 usage 锚定,对照 pi 内存态 usage 回放)
try:
cursor.execute("SELECT usage FROM messages LIMIT 1")
except sqlite3.OperationalError:
print("[DB]: 正在升级 messages 表 (添加 usage)...")
try: cursor.execute("ALTER TABLE messages ADD COLUMN usage TEXT")
except: pass
upgraded = True
# 🆕 失败轮次持久化(对照 pi: message_end 无条件入库):
# messages.stop_reason / error_message —— 区分「正常行 / 出错行」,
# 供 UI 展示与 build_api_context 回放决策(NULL = 正常)
#
# ⚠️⚠️ 绝不能置 upgraded=True:该标志会触发下方的「旧数据链表化重构」,
# 把用户的**树状分支拍平成线性链**(数据破坏)!
# 纯追加列对本迁移自身而言是安全的,与旧库结构修复无关。
try:
cursor.execute("SELECT stop_reason, error_message FROM messages LIMIT 1")
except sqlite3.OperationalError:
print("[DB]: 正在升级 messages 表 (添加 stop_reason, error_message)...")
try: cursor.execute("ALTER TABLE messages ADD COLUMN stop_reason TEXT")
except: pass
try: cursor.execute("ALTER TABLE messages ADD COLUMN error_message TEXT")
except: pass
# 如果刚才执行了树状结构升级,立即对旧数据进行“时间线串联”修复
if upgraded:
print("[DB]: 🚀 正在执行旧数据链表化重构...")
cursor.execute("SELECT id FROM sessions")
sessions = cursor.fetchall()
for s in sessions:
sid = s['id']
cursor.execute("SELECT id FROM messages WHERE session_id = ? ORDER BY created_at ASC", (sid,))
msgs = cursor.fetchall()
if not msgs: continue
# 遍历消息,将后一条的 parent_id 指向上一条
prev_id = None
for m in msgs:
mid = m['id']
if prev_id:
cursor.execute("UPDATE messages SET parent_id = ? WHERE id = ?", (prev_id, mid))
prev_id = mid
# 最后一个 msg_id 就是这棵树的末端叶子节点
cursor.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (prev_id, sid))
print("[DB]: ✅ 数据结构完美升级为链表树!")
def _seed_default_chat(self, cursor):
session_id = f"sess_{uuid.uuid4().hex[:12]}"
now = int(time.time())
cursor.execute(
"INSERT INTO sessions (id, title, created_at, updated_at, has_messages) VALUES (?, ?, ?, ?, ?)",
(session_id, "让我们从这里开始吧", now, now, 1)
)
sys_id = f"msg_sys_init"
cursor.execute("""
INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (sys_id, session_id, "system", "你是一个优秀的助手!", "", 0, now, None))
default_messages = [
{"role": "user", "content": "你好呀,你是谁?"},
{"role": "assistant", "content": "嗨!我是你的 AI 助手 ✨ ..."},
{"role": "user", "content": "那你到底能帮我做什么?"},
{"role": "assistant", "content": "简单来说,能打字问的我都聊..."}
]
prev_id = sys_id
for msg in default_messages:
msg_id = f"msg_{uuid.uuid4().hex[:16]}"
now += 1
cursor.execute("""
INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (msg_id, session_id, msg["role"], msg["content"], "", 0, now, prev_id))
prev_id = msg_id
# 设置默认会话的叶子节点
cursor.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (prev_id, session_id))
# ==================== 会话 (Session) 操作 ====================
def get_session_mode(self, session_id: str) -> Optional[str]:
"""读取会话锁定的模式(chat/worker),未发送过返回 None"""
with self.get_connection() as conn:
row = conn.execute("SELECT mode FROM sessions WHERE id = ?",
(session_id,)).fetchone()
return row["mode"] if row else None
def set_session_mode(self, session_id: str, mode: str):
"""锁定会话模式(首条消息发送时调用,之后不可变)"""
with self.get_connection() as conn:
conn.execute("UPDATE sessions SET mode = ? WHERE id = ?",
(mode, session_id))
def get_all_sessions(self) -> List[Dict]:
with self.get_connection() as conn:
return [dict(row) for row in conn.execute("SELECT * FROM sessions ORDER BY is_starred DESC, sort_order ASC, updated_at DESC").fetchall()]
def create_session(self, title: str = "新对话") -> Dict:
session_id = f"sess_{uuid.uuid4().hex[:12]}"
now = int(time.time())
sys_msg_id = f"msg_{uuid.uuid4().hex[:16]}"
with self.get_connection() as conn:
cursor = conn.cursor()
min_order = self.get_min_sort_order()
# 创建 session,直接将 system 消息设为初始叶子节点
cursor.execute(
"INSERT INTO sessions (id, title, created_at, updated_at, has_messages, sort_order, current_leaf_msg_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
(session_id, title, now, now, 0, min_order, sys_msg_id)
)
cursor.execute("""
INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (sys_msg_id, session_id, "system", "你是一个优秀的助手!", "", 0, now, None))
conn.commit()
return dict(cursor.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone())
# ------------------------------------------------------------------
# 🆕 会话复制:深度克隆(全部分支 + 压缩标记 + 附件文件)
# ------------------------------------------------------------------
def _make_copy_title(self, base: str) -> str:
"""生成不重名的副本标题:X → X (副本) → X (副本 2) → …"""
root = re.sub(r"\s*\(副本(?:\s*\d+)?\)\s*$", "", base or "").strip() or "新对话"
with self.get_connection() as conn:
existing = {r[0] for r in conn.execute("SELECT title FROM sessions").fetchall()}
cand = f"{root} (副本)"
n = 2
while cand in existing and n < 1000:
cand = f"{root} (副本 {n})"
n += 1
return cand
def _dup_media_file(self, old_path: str, prefix: str):
"""把一个项目内媒体文件复制成新名字。
返回 (新绝对路径, 新项目相对路径);文件不存在或异常返回 None。"""
if not old_path:
return None
old_abs = old_path if os.path.isabs(old_path) else os.path.join(self.files_root, old_path)
if not os.path.isfile(old_abs):
return None
ext = os.path.splitext(old_abs)[1] or ".bin"
new_dir = os.path.join(self.files_root, "data", "attachments")
os.makedirs(new_dir, exist_ok=True)
new_abs = ""
for _ in range(5):
new_abs = os.path.join(new_dir, f"{prefix}_{uuid.uuid4().hex[:8]}{ext}")
if not os.path.exists(new_abs):
break
shutil.copy2(old_abs, new_abs)
new_rel = os.path.relpath(new_abs, self.files_root).replace("\\", "/")
return new_abs, new_rel
def _copy_attachment_files(self, meta_json: Optional[str]) -> Optional[str]:
"""附件深拷贝:image/pdf 的磁盘文件复制改名并改写 local_path;
text 类型正文内联在 JSON 里,无需处理。
任何异常/文件缺失都原样返回,绝不让复制整体失败。"""
if not meta_json:
return meta_json
try:
meta = json.loads(meta_json)
except Exception:
return meta_json
if not isinstance(meta, dict) or not meta.get("attachments"):
return meta_json
changed = False
for att in meta.get("attachments") or []:
if not isinstance(att, dict):
continue
try:
if att.get("type") in ("image", "pdf") and att.get("local_path"):
got = self._dup_media_file(
att["local_path"], "img" if att["type"] == "image" else "pdf")
if got:
att["local_path"] = got[1]
if "abs_path" in att:
att["abs_path"] = got[0]
changed = True
if att.get("type") == "pdf":
for im in att.get("images") or []:
if not isinstance(im, dict):
continue
got = self._dup_media_file(
im.get("abs_path") or im.get("local_path"), "pdfimg")
if got:
im["abs_path"] = got[0]
im["local_path"] = got[1]
changed = True
except Exception as e:
print(f"[DB] ⚠️ 附件深拷贝失败(保留原路径): {e}", flush=True)
return json.dumps(meta, ensure_ascii=False) if changed else meta_json
def copy_session(self, session_id: str, new_title: Optional[str] = None,
copy_attachments: bool = True) -> Optional[Dict]:
"""📋 深度复制一个会话。
- messages 全部重新生成 IDparent_id / current_leaf_msg_id 全量重映射
→ 分支、压缩标记(role='compaction')都原样保留
- image/pdf 附件文件物理复制成新文件 → 副本自包含,删任意一方不影响另一方
- 单事务写入;源会话零改动
返回新会话 dict;源不存在返回 None。
"""
now = int(time.time())
n_att = 0
with self.get_connection() as conn:
cursor = conn.cursor()
src_row = cursor.execute(
"SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
if not src_row:
return None
src = dict(src_row)
rows = [dict(r) for r in cursor.execute(
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC, rowid ASC",
(session_id,)).fetchall()]
# ① 新 ID 映射(保持 comp_ / msg_ 前缀约定)
idmap = {}
for m in rows:
pre = "comp_" if m.get("role") == "compaction" else "msg_"
idmap[m["id"]] = f"{pre}{uuid.uuid4().hex[:16]}"
# ② 标题(重名自动递增)
title = new_title or self._make_copy_title(src.get("title") or "新对话")
# ③ 消息 created_at 保相对间隔(同层兄弟排序不变)
t0 = min((m.get("created_at") or 0) for m in rows) if rows else now
new_sid = f"sess_{uuid.uuid4().hex[:12]}"
new_leaf = idmap.get(src.get("current_leaf_msg_id"))
if rows and not new_leaf:
# 兜底:源叶子不在链上(数据异常)→ 取副本里时间最新的一条
last = max(rows, key=lambda m: (m.get("created_at") or 0))
new_leaf = idmap.get(last["id"])
print(f"[DB] ⚠️ copy_session 源叶子异常,回退 leaf={new_leaf}", flush=True)
# ④ 新会话(置列表顶部、不带星标、模式跟随源)
cursor.execute(
"INSERT INTO sessions (id, title, created_at, updated_at, has_messages, "
"sort_order, is_starred, current_leaf_msg_id, mode) VALUES (?,?,?,?,?,?,?,?,?)",
(new_sid, title, now, now, src.get("has_messages") or 0,
self.get_min_sort_order(), 0, new_leaf, src.get("mode")))
# ⑤ 逐条复制消息(parent 重映射 + 附件深拷贝)
for m in rows:
meta = m.get("attachment_metadata")
if copy_attachments and meta:
new_meta = self._copy_attachment_files(meta)
if new_meta != meta:
n_att += 1
meta = new_meta
cursor.execute(
"INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, "
"created_at, attachment_metadata, parent_id, timeline, usage, stop_reason, error_message) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(idmap[m["id"]], new_sid, m.get("role"), m.get("content"),
m.get("reasoning"), m.get("is_ignored"),
now + ((m.get("created_at") or 0) - t0),
meta, idmap.get(m.get("parent_id")),
m.get("timeline"), m.get("usage"),
m.get("stop_reason"), m.get("error_message")))
conn.commit()
out = dict(cursor.execute(
"SELECT * FROM sessions WHERE id = ?", (new_sid,)).fetchone())
try:
print(f"[DB] copy_session {session_id[:8]}{new_sid[:8]} "
f"消息={len(rows)} 附件深拷贝={n_att} 标题={title}", flush=True)
except Exception:
pass
return out
def update_session_title(self, session_id: str, new_title: str):
with self.get_connection() as conn:
conn.execute("UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?", (new_title, int(time.time()), session_id))
conn.commit()
def delete_session(self, session_id: str):
with self.get_connection() as conn:
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
conn.commit()
# 下方其它基本Session功能保持不变...
def mark_session_has_messages(self, session_id: str):
with self.get_connection() as conn:
conn.execute("UPDATE sessions SET has_messages = 1 WHERE id = ?", (session_id,))
conn.commit()
def check_session_needs_title(self, session_id: str) -> bool:
with self.get_connection() as conn:
row = conn.execute("SELECT title, has_messages FROM sessions WHERE id = ?", (session_id,)).fetchone()
if row: return row[0] == "新对话" and row[1] == 1
return False
def get_min_sort_order(self) -> int:
with self.get_connection() as conn:
row = conn.execute("SELECT MIN(sort_order) FROM sessions").fetchone()
return (row[0] or 0) - 1
def update_session_order(self, ordered_ids: list):
with self.get_connection() as conn:
for idx, sid in enumerate(ordered_ids):
conn.execute("UPDATE sessions SET sort_order = ? WHERE id = ?", (idx, sid))
conn.commit()
def update_session_star(self, session_id: str, is_starred: bool):
with self.get_connection() as conn:
conn.execute("UPDATE sessions SET is_starred = ?, updated_at = ? WHERE id = ?", (1 if is_starred else 0, int(time.time()), session_id))
conn.commit()
def is_session_starred(self, session_id: str) -> bool:
with self.get_connection() as conn:
row = conn.execute("SELECT is_starred FROM sessions WHERE id = ?", (session_id,)).fetchone()
return bool(row and row[0])
# ==================== 🌟 核心:消息链表树操作 ====================
def get_session_leaf(self, session_id: str) -> Optional[str]:
"""获取会话当前的叶子节点ID"""
with self.get_connection() as conn:
row = conn.execute("SELECT current_leaf_msg_id FROM sessions WHERE id = ?", (session_id,)).fetchone()
return row[0] if row else None
def update_session_leaf(self, session_id: str, leaf_msg_id: str):
"""切换时间线:手动更新当前会话的叶子节点"""
with self.get_connection() as conn:
conn.execute("UPDATE sessions SET current_leaf_msg_id = ?, updated_at = ? WHERE id = ?",
(leaf_msg_id, int(time.time()), session_id))
conn.commit()
def get_message_chain(self, session_id: str) -> List[Dict]:
"""🚀 极客级递归拉取:顺藤摸瓜,只返回当前激活时间线上的消息!彻底断绝下游污染!"""
leaf_id = self.get_session_leaf(session_id)
if not leaf_id:
return []
chain = []
with self.get_connection() as conn:
cursor = conn.cursor()
current_id = leaf_id
seen = set() # 🐛 环检测:数据异常(如自环标记)时快速退出,防主线程死循环卡死
# 使用 Python 循环向上回溯(比 SQL CTE 更好调试,性能对于本地几千条聊天来说在 1ms 内)
while current_id and current_id not in seen:
seen.add(current_id)
cursor.execute("SELECT * FROM messages WHERE id = ?", (current_id,))
msg = cursor.fetchone()
if not msg:
break
chain.append(dict(msg))
current_id = msg['parent_id']
if current_id in seen:
try:
print(f"[DB] ⚠️ get_message_chain 检测到环(session={session_id}),已截断", flush=True)
except Exception:
pass
# 因为是向上回溯,拉出来的链条是反的,最后翻转一下恢复正序
chain.reverse()
return chain
def get_branch_info(self, parent_id: str) -> List[Dict]:
"""获取某一父节点下的所有子分支消息 (第二阶段用于UI渲染 '1/3')"""
if not parent_id: return []
with self.get_connection() as conn:
return [dict(row) for row in conn.execute(
"SELECT * FROM messages WHERE parent_id = ? ORDER BY created_at ASC", (parent_id,)
).fetchall()]
def add_message(self, session_id: str, role: str, content: str, parent_id: str,
reasoning: str = "", is_ignored: bool = False,
msg_id: Optional[str] = None, attachment_metadata: Optional[str] = None,
timeline: Optional[str] = None,
usage: Optional[str] = None,
stop_reason: Optional[str] = None,
error_message: Optional[str] = None) -> Dict:
"""添加新消息,并自动将该消息设为当前会话的最新叶子节点
🆕 P1: usage —— assistant 回复的精确 usage JSON(如 '{"input":..,"output":..}'
🆕 失败轮次: stop_reason/error_message —— 'error' 行入库但不回退叶子
(对照 pi:错误也持久化,回放时由 build_api_context 决定取舍)"""
if not msg_id: msg_id = f"msg_{uuid.uuid4().hex[:16]}"
now = int(time.time())
with self.get_connection() as conn:
cursor = conn.cursor()
# 1. 插入消息
cursor.execute("""
INSERT INTO messages (id, session_id, role, content, reasoning, is_ignored, created_at, attachment_metadata, parent_id, timeline, usage, stop_reason, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (msg_id, session_id, role, content, reasoning, 1 if is_ignored else 0, now, attachment_metadata, parent_id, timeline, usage, stop_reason, error_message))
# 2. 自动更新 session 的叶子节点(时间线前推)
cursor.execute("UPDATE sessions SET current_leaf_msg_id = ?, updated_at = ? WHERE id = ?",
(msg_id, now, session_id))
conn.commit()
try:
print(f"[DB] add_message role={role} 内容={len(content or '')}c "
f"思考={len(reasoning or '')}c 时间线={'' if timeline else ''} "
f"id={msg_id} session={session_id[:8]}", flush=True)
except Exception:
pass
return dict(cursor.execute("SELECT * FROM messages WHERE id = ?", (msg_id,)).fetchone())
# ------------------------------------------------------------------
# 🆕 压缩持久化:链上标记点(子叶子回溯到标记即停)
# 插入后链形:…→ cut_before → [MARK role="compaction"] → first_retained → …→叶子
# 历史行全部保留(前端切会话渲染照常);API 上下文构建在标记处截断。
# 关键:不动 sessions.current_leaf_msg_id(绝不能用 add_message);
# INSERT + UPDATE 同一事务,不留断链窗口。
# ------------------------------------------------------------------
def insert_compaction_mark(self, session_id: str, summary: str,
cut_before_id: str, first_retained_id: str,
meta_json: Optional[str] = None) -> Optional[str]:
"""在链上插入压缩标记行并把保留首条的 parent_id 改指到标记。返回 mark_id。"""
if not cut_before_id or not first_retained_id:
return None
if cut_before_id == first_retained_id:
# 🐛 防自环:同一行不能既做切点前又做保留首条(timeline 回放同 id 场景)
try:
print(f"[DB] ⚠️ insert_compaction_mark 拒绝自环 cut==retained={cut_before_id}", flush=True)
except Exception:
pass
return None
mark_id = f"comp_{uuid.uuid4().hex[:16]}"
now = int(time.time())
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""INSERT INTO messages
(id, session_id, role, content, reasoning, is_ignored,
created_at, attachment_metadata, parent_id, timeline, usage)
VALUES (?, ?, 'compaction', ?, '', 1, ?, ?, ?, NULL, NULL)""",
(mark_id, session_id, summary, now, meta_json, cut_before_id))
cursor.execute("UPDATE messages SET parent_id = ? WHERE id = ?",
(mark_id, first_retained_id))
conn.commit()
try:
print(f"[DB] insert_compaction_mark session={session_id[:8]} "
f"cut_before={cut_before_id} first_retained={first_retained_id} "
f"mark={mark_id} summary={len(summary or '')}c", flush=True)
except Exception:
pass
return mark_id
# === 在 db_manager.py 中添加这个方法 ===
def get_branch_leaf(self, msg_id: str) -> str:
"""寻找一条时间线的最末端叶子节点"""
current_id = msg_id
with self.get_connection() as conn:
cursor = conn.cursor()
while True:
# 寻找把当前节点作为父节点的子节点,按时间倒序取最新的一条(意味着它顺着最近被聊过的那条线往下走)
cursor.execute("SELECT id FROM messages WHERE parent_id = ? ORDER BY created_at DESC LIMIT 1", (current_id,))
child = cursor.fetchone()
if child:
current_id = child[0]
else:
break # 没有子节点了,它自己就是叶子!
return current_id
def delete_message_branch(self, session_id: str, msg_id: str):
"""🚀 精准剪枝:删AI只删当前分支,删User连根拔起,并自动平滑回退时间线"""
with self.get_connection() as conn:
cursor = conn.cursor()
# 1. 查出要删除的节点的父亲
cursor.execute("SELECT parent_id FROM messages WHERE id = ?", (msg_id,))
row = cursor.fetchone()
if not row: return
safe_parent_id = row['parent_id']
# 2. 目标就是传进来的 msg_id 本身 (不再强制上移到 parent)
target_id = msg_id
# 3. 拉取全会话,构建亲属关系树
cursor.execute("SELECT id, parent_id FROM messages WHERE session_id = ?", (session_id,))
all_msgs = cursor.fetchall()
children_map = {}
for m in all_msgs:
pid = m['parent_id']
if pid not in children_map:
children_map[pid] = []
children_map[pid].append(m['id'])
# 4. 广度优先搜索 (BFS):找出目标消息及其所有子孙
to_delete = set([target_id])
queue = [target_id]
while queue:
curr = queue.pop(0)
if curr in children_map:
for child in children_map[curr]:
if child not in to_delete:
to_delete.add(child)
queue.append(child)
# 5. 判断当前时间线的“叶子节点”是否在被波及的名单里
cursor.execute("SELECT current_leaf_msg_id FROM sessions WHERE id = ?", (session_id,))
leaf_row = cursor.fetchone()
leaf_needs_update = leaf_row and leaf_row['current_leaf_msg_id'] in to_delete
# 6. 🌟 执行物理删除前,收集将被删除的附件元数据
deleted_metadata = []
for d_id in to_delete:
# 先查出它的 metadata
cursor.execute("SELECT attachment_metadata FROM messages WHERE id = ?", (d_id,))
row = cursor.fetchone()
if row and row['attachment_metadata']:
deleted_metadata.append(row['attachment_metadata'])
# 然后再执行物理删除
cursor.execute("DELETE FROM messages WHERE id = ?", (d_id,))
# 7. 🌟 核心:如果时间线断了,自动寻找平滑降落点
sibling_row = None
if leaf_needs_update:
# 尝试寻找被删节点的最新“兄弟姐妹” (例如删了分支2,寻找分支1)
cursor.execute("SELECT id FROM messages WHERE parent_id = ? ORDER BY created_at DESC LIMIT 1", (safe_parent_id,))
sibling_row = cursor.fetchone()
# 如果有兄弟,降落到兄弟;如果没兄弟(只有1次回答),退回原点(提问)
new_leaf = sibling_row['id'] if sibling_row else safe_parent_id
cursor.execute("UPDATE sessions SET current_leaf_msg_id = ? WHERE id = ?", (new_leaf, session_id))
conn.commit()
# 8. 如果降落到了兄弟分支,兄弟可能还有下文,需再次对齐真实叶子节点
if leaf_needs_update and sibling_row:
real_leaf = self.get_branch_leaf(new_leaf)
self.update_session_leaf(session_id, real_leaf)
return deleted_metadata # 🌟 返回被删除的元数据,交给 MainWindow 去粉碎文件
+78
View File
@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""统一调试日志 + 调试器窗口控制协议(纯文件通信,与主窗口 UI 零耦合)
日志文件: data/debug_session.log (环境变量 HAOCODE_DEBUG_LOG 可覆盖)
控制文件: data/debug_window.cmd (环境变量 HAOCODE_DEBUG_CMD 可覆盖)
三方写入协议:
[USER] 用户在调试窗口输入框手输的观察情况
[AGENT] 代理(外部脚本/命令行)注入的指令与备注
[APP] 应用自身事件(上下文标签变化/usage/发送/完成/报错/压缩)
[SYS] 调试窗口自身的开关事件
行格式: [YYYY-MM-DD HH:MM:SS.mmm] [TAG] 内容
控制协议: 代理往 debug_window.cmd 写入 "show""hide"(一行),
app 侧 2s QTimer 轮询并消费(读完即删)。
"""
import os
import threading
import time
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_DATA_DIR = os.path.join(_ROOT, "data")
DEBUG_LOG_PATH = (os.environ.get("HAOCODE_DEBUG_LOG")
or os.path.join(_DATA_DIR, "debug_session.log"))
DEBUG_CMD_PATH = (os.environ.get("HAOCODE_DEBUG_CMD")
or os.path.join(_DATA_DIR, "debug_window.cmd"))
_lock = threading.Lock()
def _stamp() -> str:
t = time.time()
return (f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(t))}"
f".{int(t * 1000) % 1000:03d}")
def debug_log(msg: str, tag: str = "APP") -> None:
"""线程安全追加一条日志(worker 线程亦可调用)。
静默吞掉一切异常——本模块绝不影响主流程。"""
try:
with _lock:
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
f.write(f"[{_stamp()}] [{tag}] {msg}\n")
except Exception:
pass
def poll_debug_cmd():
"""消费控制文件,返回 'show' / 'hide' / None。由主线程 QTimer 周期调用。"""
try:
if os.path.exists(DEBUG_CMD_PATH):
with open(DEBUG_CMD_PATH, "r", encoding="utf-8") as f:
action = (f.read() or "").strip().lower()
try:
os.remove(DEBUG_CMD_PATH)
except Exception:
pass
if action in ("show", "hide"):
return action
except Exception:
pass
return None
def autostart_debug_window(cfg: dict) -> bool:
"""调试窗口随程序启动:cfg["debug_window_autostart"] 为 true(缺省也是 true
时向控制文件写 "show",主窗口事件循环启动后 2s 轮询即开窗。
纯文件操作(不依赖 Qt),返回是否写入。"""
try:
if not bool(cfg.get("debug_window_autostart", True)):
return False
with open(DEBUG_CMD_PATH, "w", encoding="utf-8") as f:
f.write("show\n")
return True
except Exception:
return False
+448
View File
@@ -0,0 +1,448 @@
"""
core/llm_engine.py
==================
Qt 胶水层 —— 把 core.agentpi 1:1 核心)接到 PyQt6 信号系统。
🌟 对照 pi 架构:
AgentWorker(QThread)
内部持有 Agent + AgentRunner(对照 pi 的 Agent + AgentSession 会话层)
run() = pi 的 _runAgentPromptprompt → 循环 post-agent-run 恢复 → settle
事件桥:AgentEvent → pyqtSignal(跨线程 queued 投递到 UI 线程)
ChatWorker(QThread)
chat 模式:普通聊天单次流式(无工具循环/无重试/无压缩)。
TitleWorker(QThread)
轻量单次流式补全(标题生成),保留原 LLMWorker 的信号面。
信号面(与旧 LLMWorker 兼容 + 新增工具事件):
chunk_received(str) # text_delta
reasoning_received(str) # thinking_delta
error_occurred(str) # 终局错误(已重试/压缩恢复仍失败)
tool_execution_started(str, str, str) # (call_id, tool_name, args_json)
tool_execution_updated(str, str) # (call_id, 执行中增量输出)
tool_execution_finished(str, str, bool, str) # (call_id, tool_name, ok, 结果摘要)
"""
from __future__ import annotations
import json
import os
from typing import Any, Dict, List, Optional
from openai import OpenAI
from PyQt6.QtCore import QThread, pyqtSignal
from core.agent import (Agent, AgentConfig, AgentEvent, AgentMessage, AgentRunner,
ModelConfig, RetryConfig, calculate_context_tokens,
default_tools, from_openai_messages,
openai_stream)
from core.agent.stream_fn import _pick_reasoning, _pick_usage
# P0-01:配置读取统一入口(环境变量 HAOCODE_CONFIG_FILE 优先,缺省回落项目内 data/config.json
from core.config_paths import load_config
from core import platform_shell # P1-02:提示词平台段(通用正文 + 短 shell/path 段)
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
# 🌟 Agent 系统提示词文件(项目根目录,core 前面那个 .md)
SYSTEM_PROMPT_FILE = os.path.join(PROJECT_ROOT, "SYSTEM_PROMPT.md")
_FALLBACK_SYSTEM_PROMPT = (
"你是 haocode 的本地智能体,可以读取/写入文件并执行 bash 命令"
"(工作目录为项目根目录)。请用简体中文简洁地回答。"
)
def load_system_prompt() -> str:
"""读取 SYSTEM_PROMPT.md;缺失时用兜底短提示词。
P1-02:通用正文 + 运行时插入的短平台 shell/path 段(占位符替换);
每次请求重读本函数即重读文件,Windows 段不会出现在 Linux 请求中,反之亦然。
"""
text = None
try:
with open(SYSTEM_PROMPT_FILE, "r", encoding="utf-8") as f:
text = f.read().strip()
except FileNotFoundError:
pass
if not text:
text = _FALLBACK_SYSTEM_PROMPT
return platform_shell.apply_platform_section(text)
def _load_config() -> dict:
"""P0-01:统一走 core.config_paths.load_config(环境变量优先、容错、可见警告)。"""
return load_config()
def _provider_info(config: dict, provider_name: str) -> dict:
return config.get("providers", {}).get(provider_name, {}) or {}
class AgentWorker(QThread):
"""
一次「发送」= 一个 AgentWorker(对照 pi:一个 AgentSession 实例)。
多轮工具循环在一次 run() 内完成;UI 只管收信号 + 最终入库。
"""
# ---- 与旧 LLMWorker 兼容的信号 ----
chunk_received = pyqtSignal(str)
reasoning_received = pyqtSignal(str)
error_occurred = pyqtSignal(str)
# ---- 新增:工具执行事件(pi tool_execution_* 事件)----
tool_execution_started = pyqtSignal(str, str, str) # (call_id, name, args)
tool_execution_updated = pyqtSignal(str, str) # (call_id, 增量输出)
tool_execution_timed = pyqtSignal(str, int, int) # (call_id, 已耗秒, 超时秒) 🆕 bash 读秒
tool_execution_finished = pyqtSignal(str, str, bool, str) # (call_id, name, ok, 结果)
# 上下文压缩发生(UI 可提示「已自动压缩上下文」)
context_compacted = pyqtSignal(dict)
# 🆕 压缩开始(摘要 LLM 阻塞调用前)→ 前端显示「执行中」动态气泡
# payload: {summary, before, after, duration_ms, path}
compaction_started = pyqtSignal(str) # path: pre_prompt/overflow_compact/length_compact
# 🆕 P1: 收到本轮精确 usage(UI 上下文标签做 usage 锚定,含 system+tools
usage_updated = pyqtSignal(dict)
# 🆕 M3: 重试调度 / 重试结果(对照 pi onRetryScheduled/onRetryFinished
retry_scheduled = pyqtSignal(int, int, float, str) # (attempt, max_attempts, delay_ms, reason)
retry_finished = pyqtSignal(bool)
def __init__(self, provider_name: str, model_name: str,
openai_messages: List[Dict[str, Any]],
tool_context: Optional[Dict[str, Any]] = None,
enable_tools: bool = True,
temperature: Optional[float] = None):
"""
openai_messages: build_api_context 的产物(OpenAI 格式)。
最后一条 user 消息 = 本次提问;其余 = 历史上下文。
"""
super().__init__()
self.provider_name = provider_name
self.model_name = model_name
self.openai_messages = openai_messages
self.tool_context = tool_context or {"cwd": PROJECT_ROOT}
self.enable_tools = enable_tools
self._temperature_override = temperature
self.config = _load_config()
self._aborted = False
self._agent: Optional[Agent] = None
self._runner: Optional[AgentRunner] = None
self._compactions_before = 0
# ------------------------------------------------------------------
# 中断(对照 pi abort:当前流立刻停止,已产生的内容保留)
# ------------------------------------------------------------------
def abort(self):
self._aborted = True
if self._agent is not None:
self._agent.abort()
# 兼容旧调用名
def cancel(self):
self.abort()
# ------------------------------------------------------------------
# 模型配置(对照 pi models.json 条目 → ModelConfig
# ------------------------------------------------------------------
def _build_model_config(self) -> ModelConfig:
p = _provider_info(self.config, self.provider_name)
context_window = int(p.get("model_contexts", {}).get(self.model_name, 128000))
max_tokens = int(p.get("model_max_tokens", {}).get(self.model_name, 8192))
temperature = (self._temperature_override
if self._temperature_override is not None
else float(self.config.get("temperature", 0.7)))
return ModelConfig(
provider=self.provider_name,
name=self.model_name,
context_window=context_window,
max_tokens=max_tokens,
temperature=temperature,
api_key=p.get("api_key", ""),
base_url=p.get("base_url", "https://api.openai.com/v1"),
# 🆕 thinking reasoning_content 回传默认开启(ModelConfig.pass_reasoning=True
)
# ------------------------------------------------------------------
# 压缩用的非流式 LLM 调用(对照 pi completeSimpleWithRetries
# 新签名 1:1 对照 pi(prompt_text, system_prompt, max_tokens) -> str
# ------------------------------------------------------------------
def _make_summarize_fn(self, model: ModelConfig):
def summarize(prompt_text: str, system_prompt: str, max_tokens: int) -> str:
client = OpenAI(api_key=model.api_key, base_url=model.base_url,
timeout=120.0, max_retries=0)
resp = client.chat.completions.create(
model=model.name,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt_text},
],
stream=False,
max_tokens=max_tokens,
temperature=0.3,
)
return (resp.choices[0].message.content or "").strip()
return summarize
# ------------------------------------------------------------------
# 事件桥:AgentEvent → Qt 信号(在工作线程内 emit,queued 投递)
# ------------------------------------------------------------------
def _bridge(self, e: AgentEvent):
if e.type == "message_end" and e.message is not None:
# 🆕 P1: 本轮刚结束 → 它的 usage 是「上一张照片」(精确覆盖
# system+tools+历史+本轮输出),供 UI 显示锚定
usage = e.message.usage or {}
if calculate_context_tokens(usage) > 0:
self.usage_updated.emit(dict(usage))
if e.type == "message_update" and e.assistant_message_event is not None:
ev = e.assistant_message_event
# 🌟 字段是 ev.text(不是 ev.data);_emit 会吞掉订阅者异常,
# 写错字段 = 静默无流式输出,必须靠 test_agent_worker_stream_fn_wiring 守住
if ev.type == "text_delta" and ev.text:
self.chunk_received.emit(ev.text)
elif ev.type == "thinking_delta" and ev.text:
self.reasoning_received.emit(ev.text)
return
if e.type == "tool_execution_start" and e.tool_call is not None:
try:
args = json.dumps(e.tool_call.arguments, ensure_ascii=False) \
if e.tool_call.arguments else (e.tool_call.raw_arguments or "")
except Exception:
args = str(e.tool_call.arguments)
self.tool_execution_started.emit(e.tool_call.id, e.tool_call.name,
str(args)[:2000])
elif e.type == "tool_execution_update" and e.arg:
self.tool_execution_updated.emit(e.tool_call.id, str(e.arg))
elif e.type == "tool_execution_timer" and isinstance(
getattr(e, "arg", None), tuple) and len(e.arg) == 2:
# 🆕 bash 运行中每秒滴一次 → 前端气泡读秒
self.tool_execution_timed.emit(e.tool_call.id, int(e.arg[0]),
int(e.arg[1]))
elif e.type == "tool_execution_end" and e.tool_call is not None:
text = ""
if e.result is not None:
c = e.result.content
text = c if isinstance(c, str) else \
"".join(x.get("text", "") for x in c if isinstance(x, dict))
# 🌟 修复: 原来 ok = is_error(反了)——成功的工具被显示成失败
ok = not (e.result.is_error if e.result is not None else True)
self.tool_execution_finished.emit(
e.tool_call.id, e.tool_call.name, bool(ok), text[:20000]) # 长结果供 UI 展开
# ------------------------------------------------------------------
# 主入口 —— 对照 pi _runAgentPrompt
# ------------------------------------------------------------------
def run(self):
provider = _provider_info(self.config, self.provider_name)
if not provider:
self.error_occurred.emit(f"未找到 provider 配置: {self.provider_name}")
return
history = from_openai_messages(self.openai_messages)
if not history or history[-1].role != "user":
self.error_occurred.emit("上下文必须以 user 消息结尾")
return
last_user = history.pop() # 本次提问(其余为历史)
model = self._build_model_config()
cfg = AgentConfig(
model=model,
# 🌟 系统提示词:SYSTEM_PROMPT.md(每次请求头部注入,不入历史)
system_prompt=load_system_prompt() if self.enable_tools else "",
tools=default_tools() if self.enable_tools else [],
tool_context=self.tool_context,
retry=RetryConfig(max_attempts=3, base_delay_ms=2000, factor=2.0),
)
agent = Agent(cfg)
# 🌟 关键:注入流函数(对照 pi 的 agentLoopConfig.streamFn 注入点)。
# 漏掉这一步 Agent 会抛 AgentError("未配置 stream_fn")。
agent.set_stream_fn(openai_stream)
agent.state.messages = list(history)
agent.subscribe(self._bridge)
summarize = self._make_summarize_fn(model)
runner = AgentRunner(agent, summarize_fn=summarize,
# 🆕 M3: 重试回调 → Qt 信号(跨线程 queued 投递)
on_retry_scheduled=lambda a, m, d, r:
self.retry_scheduled.emit(int(a), int(m), float(d), str(r)),
on_retry_finished=lambda ok:
self.retry_finished.emit(bool(ok)),
# 🆕 压缩开始回调 → 前端动态气泡
on_compaction_started=lambda p:
self.compaction_started.emit(str(p)),
# 🆕 压缩完成回调 → 气泡即时定格(不等整轮结束)
on_compaction_finished=lambda p:
self.context_compacted.emit(dict(p)))
# 🆕 接线轮中主动压缩检查(haocode 增强,偏离 pi 1:1):
# 内层循环每次 LLM 请求前主动检查,单条巨型工具输出不再依赖
# 「一次失败往返 + 响应式兜底」
agent.config.compact_fn = runner.compact_if_needed
self._agent = agent
self._runner = runner
self._compactions_before = 0
try:
result = runner.run(last_user)
except Exception as e:
if not self._aborted:
self.error_occurred.emit(f"\n[Agent 异常]: {e}")
return
if self._aborted:
return # 取消路径:UI 已自行清理,不回退时间线
# 注:压缩事件(context_compacted)已由 on_compaction_finished 在压缩结束
# 瞬间实时发出(气泡即时定格),这里不再重复 flush runner.compaction_events
if result.error is not None and result.stop_reason == "error":
# 恢复逻辑(重试 + 压缩)全部用尽后的终局错误
self.error_occurred.emit(
f"\n[API 请求异常]: {result.error.message or '未知错误'}")
# ------------------------------------------------------------------
# 供 UI 读取最终状态(入库用)
# ------------------------------------------------------------------
def final_messages(self) -> List[AgentMessage]:
return list(self._agent.state.messages) if self._agent else []
class ChatWorker(QThread):
"""
chat 模式 —— 普通聊天:单次流式补全(对照旧 LLMWorker 的行为)。
不进入 agent 循环:无工具调用、无重试、无压缩。
worker 模式请用 AgentWorker。
"""
chunk_received = pyqtSignal(str)
reasoning_received = pyqtSignal(str)
error_occurred = pyqtSignal(str)
# 🆕 P1: 收到精确 usageUI 上下文标签 usage 锚定)
usage_updated = pyqtSignal(dict)
def __init__(self, provider_name: str, model_name: str,
openai_messages: List[Dict[str, Any]],
temperature: Optional[float] = None):
super().__init__()
self.provider_name = provider_name
self.model_name = model_name
self.openai_messages = openai_messages
self._temperature_override = temperature
self.config = _load_config()
self._cancelled = False
def cancel(self):
self._cancelled = True
def abort(self):
self._cancelled = True
def run(self):
p = _provider_info(self.config, self.provider_name)
if not p:
self.error_occurred.emit(f"未找到 provider 配置: {self.provider_name}")
return
model = self._build_model_config(p)
max_tokens = int(p.get("model_max_tokens", {}).get(self.model_name, 8192))
try:
client = OpenAI(api_key=p.get("api_key", ""),
base_url=p.get("base_url", "https://api.openai.com/v1"),
timeout=model.timeout_seconds, max_retries=0)
response = client.chat.completions.create(
model=self.model_name,
messages=self.openai_messages,
stream=True,
temperature=model.temperature,
max_tokens=max_tokens,
# 🆕 P1: 拿精确 usage 供上下文标签锚定(对照 pi stream_options
stream_options={"include_usage": True},
)
for chunk in response:
if self._cancelled:
try:
response.close()
except Exception:
pass
return
# 🆕 P1/M2: usage 读取(含 choice 兜底)
_u = _pick_usage(chunk)
if _u is not None and (
getattr(_u, "prompt_tokens", 0) or getattr(_u, "completion_tokens", 0)):
self.usage_updated.emit({
"input": getattr(_u, "prompt_tokens", 0) or 0,
"output": getattr(_u, "completion_tokens", 0) or 0,
"cacheRead": 0, "cacheWrite": 0,
})
if not chunk.choices:
continue
delta = chunk.choices[0].delta
# 🆕 M1: 思考字段优先级对照 pireasoning_content/reasoning/reasoning_text
_r = _pick_reasoning(delta)
if _r:
self.reasoning_received.emit(_r)
if delta.content:
self.chunk_received.emit(delta.content)
except Exception as e:
if not self._cancelled:
self.error_occurred.emit(str(e))
def _build_model_config(self, p: dict) -> ModelConfig:
context_window = int(p.get("model_contexts", {}).get(self.model_name, 128000))
temperature = (self._temperature_override
if self._temperature_override is not None
else float(self.config.get("temperature", 0.7)))
return ModelConfig(
provider=self.provider_name,
name=self.model_name,
context_window=context_window,
max_tokens=int(p.get("model_max_tokens", {}).get(self.model_name, 8192)),
temperature=temperature,
api_key=p.get("api_key", ""),
base_url=p.get("base_url", "https://api.openai.com/v1"),
# 🆕 thinking reasoning_content 回传默认开启
)
class TitleWorker(QThread):
"""标题生成:单次流式补全(保留原信号面)"""
chunk_received = pyqtSignal(str)
error_occurred = pyqtSignal(str)
def __init__(self, provider_name: str, model_name: str,
openai_messages: List[Dict[str, Any]]):
super().__init__()
self.provider_name = provider_name
self.model_name = model_name
self.openai_messages = openai_messages
self.config = _load_config()
self._cancelled = False
def cancel(self):
self._cancelled = True
def run(self):
p = _provider_info(self.config, self.provider_name)
if not p:
return
try:
client = OpenAI(api_key=p.get("api_key", ""),
base_url=p.get("base_url", "https://api.openai.com/v1"),
timeout=60.0, max_retries=0)
temperature = float(self.config.get("temperature", 0.7))
response = client.chat.completions.create(
model=self.model_name,
messages=self.openai_messages,
stream=True,
temperature=temperature,
max_tokens=60,
)
for chunk in response:
if self._cancelled:
try:
response.close()
except Exception:
pass
return
if chunk.choices:
delta = chunk.choices[0].delta
if delta.content:
self.chunk_received.emit(delta.content)
except Exception as e:
if not self._cancelled:
self.error_occurred.emit(str(e))
+141
View File
@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
"""P1-02:窄平台进程适配 —— 显式 shell 契约 + 进程树终止 + 提示词平台段。
契约(不依赖 shell=True 的平台默认值):
- Windows:字符串命令行 ``cmd.exe /d /s /c "<command>"``/s 保留内部引号;
不能传 argv 列表——list2cmdline 的引号转义 cmd 不认)
- Linux ``/bin/bash -lc <command>``(登录 shell,可读 profile 别名/函数)
进程树终止:
- Windowstaskkill /F /T(既有等价机制,整树强杀)
- Linux start_new_session 独立 POSIX 进程组;SIGTERM 整组 → 宽限期 → SIGKILL 整组
(安全不变量:仅当 getpgid == 子进程 pid,即确认独立组后才 killpg,
否则退化为单进程 kill,绝不误杀调用方所在组)
系统提示词:通用正文 + 运行时插入的短平台 shell/path 段(占位符
``{{SHELL_PLATFORM_SECTION}}``)。不维护两份完整提示词。
"""
import os
import signal
import subprocess
import time
#: 系统提示词中的平台段占位符
SHELL_SECTION_PLACEHOLDER = "{{SHELL_PLATFORM_SECTION}}"
_KILL_GRACE_S = 3.0 # LinuxSIGTERM → SIGKILL 宽限
_TASKKILL_TIMEOUT_S = 10 # Windowstaskkill 自身超时
def is_windows() -> bool:
return os.name == "nt"
def shell_command(command: str):
"""显式 shell 命令(bash 工具的 shell 契约,替代 shell=True 平台默认值)。
- Windows:返回**字符串命令行** ``cmd.exe /d /s /c "<command>"``。
必须走字符串而非 argv 列表:列表形态会被 CPython ``list2cmdline``
给内部引号加反斜杠转义,cmd.exe 不认 ``\"``;字符串形态下命令行
原样交给 CreateProcessWcmd 自行按 /s 规则解析 /c 参数
(外层引号剥离、内部引号保留,实测引号路径/&&/管道/%VAR% 均正确)。
- Linux:返回 argv 列表 ``[\ "/bin/bash", "-lc", command]``POSIX 下
列表参数无引号转义问题,-l 登录 shell 可读 profile 别名/函数)。
"""
if is_windows():
return 'cmd.exe /d /s /c "' + command + '"'
return ["/bin/bash", "-lc", command]
def popen_flags() -> dict:
"""进程树管理所需的 Popen 附加参数:Linux 独立进程组。"""
if is_windows():
return {}
return {"start_new_session": True}
def kill_process_tree(proc, grace_s: float = _KILL_GRACE_S) -> None:
"""终止 proc 的整棵子进程树;最坏情况也保证返回(不抛异常)。
- Windowstaskkill /F /T 整树强杀(既有机制)。
- Linux:独立进程组 SIGTERM → 宽限 grace_s 秒 → SIGKILL 整组。
若无法确认独立组(进程已退 / 标志未生效)→ 退化单进程 kill。
"""
if proc is None:
return
if is_windows():
try:
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
capture_output=True, timeout=_TASKKILL_TIMEOUT_S)
except Exception:
try:
proc.kill()
except Exception:
pass
return
# ---- POSIX ----
pgid = None
try:
pgid = os.getpgid(proc.pid)
except Exception:
pass
if pgid is None or pgid != proc.pid:
# 进程已退出,或没有独立组 → 不能 killpg(可能误伤调用方组)
try:
proc.kill()
except Exception:
pass
return
def _pg(sig: int) -> None:
try:
os.killpg(pgid, sig)
except Exception:
pass
_pg(signal.SIGTERM)
deadline = time.time() + grace_s
while time.time() < deadline:
try:
if proc.poll() is not None:
return
except Exception:
return
time.sleep(0.05)
_pg(signal.SIGKILL)
# ----------------------------------------------------------------------
# 系统提示词平台段(短段,运行时插入;通用正文保持平台无关)
# ----------------------------------------------------------------------
_WIN_SECTION = """### 1.1 平台 shell 与路径(Windows
- 操作系统:Windows。路径形如 `C:\\Users\\14890\\Desktop\\haocode`;不确定真实路径时先 `dir` / `ls`。
- bash 命令经 **cmd.exe** 执行(不是 git-bash);`C:\\Program Files\\Git\\usr\\bin` 在 PATH 上,
`ls` `grep` `cat` `head` `tail` `wc` `rm` `sed` `awk` 可直接用,`|` `>` `2>&1` `&&` 可用。
- ⚠️ cmd 陷阱:`;` 不是分隔符(用 `&&` 或换行);环境变量用 `%VAR%``$VAR` 不展开);
内建命令不剥单引号(`echo 'x'` 原样带引号);每次调用都是新进程,`cd` 不跨调用保留
(写 `cd core && <命令>`);需要 `for` / `$(...)` / `[ ]` 测试等真实 bash 语义时
一律 `bash -c "..."`。"""
_LINUX_SECTION = """### 1.1 平台 shell 与路径(Linux
- 操作系统:Linux。路径为 POSIX 风格(`/home/user/...`、`/tmp/...`)。
- bash 命令经 **/bin/bash -lc** 执行(登录 shell):`;` `&&` `|` `$(...)`、单引号、
环境变量 `$VAR`、profile 里的别名与函数都可用。
- 每次调用都是新进程:`cd` 不跨调用保留(写 `cd core && <命令>` 或直接用绝对路径)。"""
def shell_prompt_section() -> str:
"""当前平台的短 shell/path 段。"""
return _WIN_SECTION if is_windows() else _LINUX_SECTION
def apply_platform_section(text: str) -> str:
"""把占位符替换为当前平台段;无占位符(如兜底提示词)时原样返回。"""
if SHELL_SECTION_PLACEHOLDER in text:
return text.replace(SHELL_SECTION_PLACEHOLDER, shell_prompt_section())
return text
+164
View File
@@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
"""P1-03:渲染器后端选择 + QtWebEngine profile/sandbox 适配(小模块,仅 stdlib,可在导入 PyQt6 前使用)。
契约见 docs/agent-handoff/PLATFORM_PLAN.md「浏览器后端」「Chromium sandbox」:
- 平台矩阵:Windows 首选 WebView2、失败回落 QtWebEngine;非 Windows 只走 QtWebEngine
(非 Windows 不探测/导入/加载 WebView2 —— 由 main_window 的导入门控保证)。
- ``webview_backend`` 配置值 ∈ {auto, webview2, qtwebengine};非法值 → 可见警告 +
回落平台默认,绝不阻断源码启动。
- 每个 QtWebEngine 实例使用独立 profile/storage 目录:源码运行阶段位于项目
``data/webengine/`` 范围;自动化测试用 ``HAOCODE_WEBENGINE_PROFILE_DIR`` 重定向到
临时目录。实例间不互相清理对方 profile。
- Chromium sandbox 默认保留。仅当运行者显式在 ``QTWEBENGINE_CHROMIUM_FLAGS`` 中加入
``--no-sandbox`` 且当前确认为 root/容器场景时才接受(打印高可见警告);
普通桌面运行中的 ``--no-sandbox`` 会被剥离并告警。
"""
import os
import sys
import time
#: webview_backend 合法配置值
VALID_BACKENDS = ("auto", "webview2", "qtwebengine")
_NO_SANDBOX = "--no-sandbox"
def is_windows() -> bool:
return sys.platform == "win32"
def platform_default_backend() -> str:
"""平台默认渲染器:Windows → webview2;其余 → qtwebengine。"""
return "webview2" if is_windows() else "qtwebengine"
def resolve_backend(pref) -> "tuple[str, str | None]":
"""归一化 ``webview_backend`` 配置(不抛异常)。
返回 ``(backend, warning)``backend ∈ {"webview2", "qtwebengine"}
warning 为 None 或需要可见打印的警告(非法值 / 平台不支持)。
"""
default = platform_default_backend()
if not isinstance(pref, str):
return default, (f"[Renderer] ⚠️ webview_backend 配置非法: {pref!r}"
f"(应为字符串 auto/webview2/qtwebengine)→ 回落平台默认 {default}")
p = pref.strip().lower()
if p not in VALID_BACKENDS:
return default, (f"[Renderer] ⚠️ webview_backend 配置非法: {pref!r}"
f"(允许 {('/'.join(VALID_BACKENDS))})→ 回落平台默认 {default}")
if p == "auto":
return default, None
if p == "webview2" and not is_windows():
return "qtwebengine", (f"[Renderer] ⚠️ webview_backend=webview2 但当前平台为 {sys.platform}"
f"WebView2 仅 Windows 支持)→ 回落 qtwebengine")
return p, None
def webview2_module():
"""按需加载 core.webview2:非 Windows 永不导入(不触达 pythonnet/Win32/DLL/taskkill)。
main_window 在模块级用 `sys.platform == "win32"` 门控导入;本函数供其他入口复用同一门控。
"""
if not is_windows():
return None
try:
from core import webview2
except Exception as e:
print(f"[WV2] 后端不可用(回落 QtWebEngine: {e}")
return None
return webview2
def webengine_profile_name() -> str:
"""当前实例的 QtWebEngine profile 名(进程 pid + 启动毫秒时间戳,天然唯一)。
两个并行源码实例 → 两个不同 profile 名 → 两个独立 Chromium profile 目录,互不争用。
"""
return f"profile_{os.getpid()}_{int(time.time() * 1000)}"
def webengine_profile_dir(profile_name: str | None = None) -> str:
"""当前实例的独立 profile/storage 目录(源码运行在 data/webengine/;测试经环境变量重定向)。
返回目录已确保存在。本函数【不删除】任何已有目录(实例间不互相清理)。
"""
name = profile_name or webengine_profile_name()
base = os.environ.get("HAOCODE_WEBENGINE_PROFILE_DIR", "").strip()
if not base:
base = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "webengine")
d = os.path.join(base, name)
try:
os.makedirs(d, exist_ok=True)
except Exception:
pass
return d
# ----------------------------------------------------------------------
# Chromium sandbox 契约
# ----------------------------------------------------------------------
_CONTAINER_MARKERS = ("/.dockerenv", "/run/.containerenv", "/.lxc")
_CGROUP_HINTS = ("docker", "containerd", "kubepods", "lxc")
def is_root_or_container() -> bool:
"""root/容器场景探测(仅 POSIX 有意义;Windows 恒 False)。"""
if os.name != "posix":
return False
try:
if os.geteuid() == 0:
return True
except Exception:
pass
for marker in _CONTAINER_MARKERS:
try:
if os.path.exists(marker):
return True
except Exception:
pass
try:
with open("/proc/1/cgroup", "r", encoding="utf-8", errors="replace") as f:
cgroup = f.read()
if any(k in cgroup for k in _CGROUP_HINTS):
return True
except Exception:
pass
return False
def sanitize_chromium_flags(flags: str, verbose: bool = True) -> str:
"""按契约处理 ``QTWEBENGINE_CHROMIUM_FLAGS`` 中的 ``--no-sandbox``main.py 在导入 PyQt6 前调用)。
- 显式带 ``--no-sandbox``
- root/容器场景 → 保留 + 高可见风险提示;
- 普通桌面 → 剥离并告警(普通运行不得关闭 sandbox)。
- 未带 ``--no-sandbox``
- root/容器场景 → 提示可能需显式设置;
- 其余原样返回。
"""
def _say(msg: str) -> None:
if verbose:
print(msg, flush=True)
tokens = (flags or "").split()
has_ns = _NO_SANDBOX in tokens
rootc = is_root_or_container()
if has_ns:
if rootc:
_say("[Renderer] ⚠️⚠️ 高可见警告:已启用 --no-sandbox(显式配置 + 确认为 root/容器场景)。"
"Chromium 沙箱已关闭,攻击面显著增大;仅限 root/容器调试环境,"
"普通桌面运行请勿使用。")
return flags
stripped = " ".join(t for t in tokens if t != _NO_SANDBOX)
_say("[Renderer] ⚠️ QTWEBENGINE_CHROMIUM_FLAGS 含 --no-sandbox,但当前是普通桌面运行"
"(非 root/非容器)→ 已剥离该标志并保留 Chromium 沙箱。"
"如确属 root/容器环境,请检查运行环境识别是否正确。")
return stripped
if rootc:
_say("[Renderer] ⚠️ 检测到 root/容器环境:Chromium 沙箱可能无法启动。"
"若页面空白,请显式在 QTWEBENGINE_CHROMIUM_FLAGS 中加入 --no-sandbox"
"(显式选择,启动时会打印风险警告)。")
return flags or ""
+417
View File
@@ -0,0 +1,417 @@
# -*- coding: utf-8 -*-
"""
WebView2 后端(Windows 首选浏览器内核,失败自动回落 QtWebEngine)。
设计要点(来自 P1 实验 tests/_tmp_wv2_demo.py 的实测结论):
1. pythonnet + WebView2 .NET SDKvendor/webview2/ 内 net462 Core.dll + webview2loader_x64.dll
2. 必须 OleInitializeSTA)后才能 CreateAsync
3. 本机的 SDK 怪癖:传任何非空 user_data_folder 都报 RuntimeNotFound → 一律用默认 profileud=None
4. 残留 msedgewebview2.exe 会锁默认 profile0x800700AA)→ 初始化前 taskkill
5. .NET 版 CoreWebView2 不暴露子窗口 HWND → EnumChildWindows 找 Chrome_WidgetWin_* 类
6. 子窗口天然是 Qt 顶层窗口的子 HWND,不需要 QWindow.fromWinId 包装(对子窗口会失败),
由 Qt 布局算 slot 矩形后用 SetBoundsAndZoomFactor 驱动(父窗客户区物理像素)
7. JS→Python 用 WebMessageReceivedJSON),Python→JS 用 ExecuteScriptAsync(与现有
ChatBridge.run_js 生成的 JS 调用文本完全同构,前端零改动;仅 index.html 的
window.bridge bootstrap 走双协议)
"""
import os
import sys
import time
import shutil
import subprocess
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WV2_DIR = os.path.join(ROOT, "vendor", "webview2")
CORE_DLL = os.path.join(WV2_DIR, "net462_Microsoft.Web.WebView2.Core.dll")
LOADER = os.path.join(WV2_DIR, "webview2loader_x64.dll")
_env = None
_System = None # pythonnet 加载后缓存
_INSTANCE_LOCK = {"fh": None}
def _instance_lock_path():
# 可覆盖(测试隔离用:HAOCODE_INSTANCE_LOCK_FILE 指向临时文件,
# 否则测试会与正在运行的 app 争同一把锁 → 断言依赖环境)
p = os.environ.get("HAOCODE_INSTANCE_LOCK_FILE")
if p:
return p
d = os.path.join(ROOT, "data")
try:
os.makedirs(d, exist_ok=True)
except Exception:
pass
return os.path.join(d, "app_instance.lock")
def acquire_instance_lock():
"""独占 data/app_instance.lock(非阻塞)。
返回 True = 本机唯一实例 → 可安全 taskkill 残留 msedgewebview2.exe
False = 已有实例在跑 → 必须回落 QtWebEngine
None = 平台不支持/异常(保守当“不确定”处理)
🐛 T0 根因:本函数存在之前,每个 MainWindow()(包括 offscreen 测试)都会走到
_get_environment() 里的 `taskkill /F /IM msedgewebview2.exe`
把【当时正在运行的生产 app】的 WebView2 浏览器进程一并杀掉
→ 它的 controller 变 disposedset_bounds 报 0x8007139F
→ DOM 照渲染但视觉层永久空白(= “选中会话不渲染核心内容”)。
"""
if sys.platform != "win32":
return None
if _INSTANCE_LOCK["fh"] is not None:
return True
fh = None
try:
import msvcrt
fh = open(_instance_lock_path(), "a+b")
fh.seek(0) # 固定锁位置(锁定前 1 字节),不依赖 append 模式的当前位置
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
_INSTANCE_LOCK["fh"] = fh
return True
except Exception:
try:
if fh is not None:
fh.close()
except Exception:
pass
return False
def _wv2_allowed_here() -> bool:
"""无头/测试环境一律不得碰 WebView2(共享默认 profile,一碰就可能误杀在生产实例)"""
if os.environ.get("HAOCODE_FORCE_QTWEBENGINE", "") in ("1", "true", "True"):
return False
plat = (os.environ.get("QT_QPA_PLATFORM") or "").strip().lower()
if plat and plat != "windows":
return False
return True
def _pump_wait(op, app, timeout=15.0):
"""在 Qt 事件循环里等 .NET Task 完成(WebView2 初始化依赖消息泵)"""
t0 = time.time()
while not op.IsCompleted and time.time() - t0 < timeout:
app.processEvents()
time.sleep(0.005)
if not op.IsCompleted:
raise TimeoutError("WebView2 operation timed out")
if op.IsFaulted:
raise RuntimeError(str(op.Exception))
return op.Result
def _ts():
"""启动链时间戳(WebView2 冷启动耗时诊断,保留)"""
t = time.time()
return time.strftime("%H:%M:%S", time.localtime(t)) + f".{int(t*1000) % 1000:03d}"
def get_environment(app):
"""初始化并返回 CoreWebView2Environment 单例;任何失败返回 None(调用方回落 QtWebEngine"""
global _env
if _env is not None:
return _env
if sys.platform != "win32":
return None
if not (os.path.exists(CORE_DLL) and os.path.exists(LOADER)):
return None
# 🛡 守卫 1:无头/测试环境(QT_QPA_PLATFORM=offscreen 等)绝不启用 WebView2
if not _wv2_allowed_here():
print(f"[WV2] QT_QPA_PLATFORM={os.environ.get('QT_QPA_PLATFORM')!r} → 跳过 WebView2"
f"回落 QtWebEngine(无头环境不得触碰共享 profile)")
return None
# 🛡 守卫 2:本机已有实例在跑 → 不启用 WebView2、更不 taskkill(否则会把它的
# 浏览器进程杀掉 → 对方 controller disposed → 聊天区永久空白)
try:
_lock_ok = acquire_instance_lock()
except Exception:
_lock_ok = None
if _lock_ok is not True:
print("[WV2] 检测到已有 haocode 实例在运行(instance lock 被占)→ "
"本实例回落 QtWebEngine;已跳过 taskkill,不会影响对方渲染")
return None
try:
os.environ["PATH"] = WV2_DIR + ";" + os.environ.get("PATH", "")
try:
os.add_dll_directory(WV2_DIR)
except Exception:
pass
# .NET Core 的 P/Invoke 默认不查 CWD → 把 loader 复制到 CWD 一份
try:
cwd_loader = os.path.join(os.getcwd(), "WebView2Loader.dll")
if not os.path.exists(cwd_loader):
shutil.copyfile(LOADER, cwd_loader)
except Exception:
pass
import ctypes
ctypes.windll.ole32.OleInitialize(None) # STACOM 初始化要求)
# 清残留浏览器进程(锁默认 profile 会导致 0x800700AA
# 🛡 只有【本机唯一实例】才会走到这里(已在上面用 instance lock 保证),
# 否则会把兄弟实例的浏览器进程杀掉 → 对方控制器 disposed → 聊天区空白
print(f"[WV2] {_ts()} warmup: taskkill 残留进程(唯一实例,安全)...")
try:
subprocess.run(["taskkill", "/F", "/IM", "msedgewebview2.exe"],
capture_output=True, timeout=10)
time.sleep(1.0)
except Exception:
pass
import clr
global _System
# 🐛 部分机器(本机 D: 卷).NET Framework 把工程所在本地卷误判为
# “网络位置”,clr.AddReference= Assembly.LoadFrom)报 0x80131515。
# 快路径保持 LoadFrom(正常机器行为不变);失败回落 Assembly.Load(byte[])
# 字节加载不做路径信任分类(本机实测:D: 字节加载 OK,类型解析 OK)。
try:
clr.AddReference(CORE_DLL)
except BaseException as _ex: # pythonnet 的 .NET 异常未必是 Python Exception 子类
_msg = str(_ex).splitlines()[0][:120] if str(_ex) else type(_ex).__name__
print(f"[WV2] AddReference 路径加载失败({_msg})→ 回落 Assembly.Load(byte[])")
from System.Reflection import Assembly as _Assembly
with open(CORE_DLL, "rb") as _f:
_Assembly.Load(_f.read())
import System
_System = System
from Microsoft.Web.WebView2.Core import CoreWebView2Environment
# ⚠️ ud=None:本机 SDK 对非空 user_data 路径报 RuntimeNotFound(实测怪癖)
_env = _pump_wait(CoreWebView2Environment.CreateAsync(None, None, None), app)
print(f"[WV2] {_ts()} Runtime ready: {_env.BrowserVersionString}")
return _env
except BaseException as ex: # pythonnet/.NET 异常未必是 Exception 子类,必须 BaseException
_env = None
print(f"[WV2] init failed → fallback to QtWebEngine: {ex}")
return None
class Wv2Session:
"""一个 WebView2 实例:controller + 子窗口 + 消息泵 + bounds 驱动"""
def __init__(self, env, parent_hwnd, app):
self.app = app
print(f"[WV2] {_ts()} controller create begin (hwnd={parent_hwnd:#x})")
self.controller = _pump_wait(
env.CreateCoreWebView2ControllerAsync(_System.IntPtr(int(parent_hwnd))), app)
print(f"[WV2] {_ts()} controller ready")
self.core = self.controller.CoreWebView2
try:
self.core.Settings.AreDefaultContextMenusEnabled = False
self.core.Settings.IsZoomControlEnabled = False
self.core.Settings.IsStatusBarEnabled = False
except Exception:
pass
self.child_hwnd = 0
self.on_message = None # callable(dict) —— main_window 绑定到 ChatBridge
self.on_load_finished = None # callable() —— WebView2View 绑定 loadFinished 信号
self._msg_handler = self._make_msg_handler()
self.core.add_WebMessageReceived(self._msg_handler)
self._nw_handler = self._make_navigated_handler()
self.core.add_NavigationCompleted(self._nw_handler)
# 🆕 真异步 JS 执行队列:单一定时器轮询完成,回调从主线程定时器发出。
# (旧版 execute_js_async 是阻塞忙等 + processEvents → 从定时器/事件回调里调用
# 时产生重入嵌套事件循环 → COM 事件分发崩溃 → 启动卡死/拖动时 webview 不刷新)
self._js_pending = [] # [task, cb, t0]
from PyQt6.QtCore import QTimer
self._js_pump = QTimer()
self._js_pump.setInterval(25)
self._js_pump.timeout.connect(self._js_pump_tick)
self._js_pump.start()
# 🆕 预热:立即导航 about:blank,让 msedgewebview2 进程/GPU 在 UI 构建期间冷启动
# (实测本机首次真实页面导航需 12-15s,预热后降到 ~1s)
try:
print(f"[WV2] {_ts()} warmup: Navigate about:blank")
self.core.Navigate("about:blank")
except Exception as ex:
print("[WV2] warmup navigate error:", ex)
# ---------- 事件 ----------
def _make_msg_handler(self):
import json
from System import EventHandler
from Microsoft.Web.WebView2.Core import CoreWebView2WebMessageReceivedEventArgs
def handler(sender, args):
try:
data = json.loads(args.WebMessageAsJson)
if self.on_message:
self.on_message(data)
except Exception as ex:
print("[WV2] message error:", ex)
return EventHandler[CoreWebView2WebMessageReceivedEventArgs](handler)
def _make_navigated_handler(self):
from System import EventHandler
from Microsoft.Web.WebView2.Core import CoreWebView2NavigationCompletedEventArgs
def handler(sender, args):
try:
src = self.core.Source or ""
except Exception:
src = "?"
print(f"[WV2] {_ts()} NavigationCompleted src={src}")
# 预热页(about:blank)的加载完成不触发 loadFinished(避免 JS 探针空转)
if src.startswith("about:blank"):
return
if self.on_load_finished:
self.on_load_finished()
return EventHandler[CoreWebView2NavigationCompletedEventArgs](handler)
# ---------- 子窗口发现(.NET 不暴露 HWND,轮询枚举) ----------
def find_child_once(self):
"""单次探测;找到返回 hwnd,否则 0(非阻塞,供 UI 线程定时器调)"""
import ctypes
import ctypes.wintypes as wt
u32 = ctypes.windll.user32
parent = wt.HWND(self.controller.ParentWindow.ToInt64())
found = []
@ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPVOID)
def cb(h, _):
buf = ctypes.create_unicode_buffer(256)
u32.GetClassNameW(h, buf, 256)
if buf.value.startswith("Chrome_WidgetWin"):
found.append(h)
return True
u32.EnumChildWindows(parent, cb, None)
if found:
self.child_hwnd = int(found[0])
return self.child_hwnd
return 0
def find_child(self):
"""同步等待并返回 Chrome_WidgetWin_* 子 HWND(最多 10s"""
import ctypes
import ctypes.wintypes as wt
u32 = ctypes.windll.user32
parent = wt.HWND(self.controller.ParentWindow.ToInt64())
for _ in range(200):
found = []
@ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPVOID)
def cb(h, _):
buf = ctypes.create_unicode_buffer(256)
u32.GetClassNameW(h, buf, 256)
if buf.value.startswith("Chrome_WidgetWin"):
found.append(h)
return True
u32.EnumChildWindows(parent, cb, None)
if found:
self.child_hwnd = int(found[0])
return self.child_hwnd
self.app.processEvents()
time.sleep(0.05)
return 0
# ---------- 几何 ----------
def set_visible(self, visible: bool):
try:
self.controller.IsVisible = bool(visible)
except Exception:
pass
def set_bounds(self, left, top, width, height):
"""父窗客户区物理像素"""
try:
from System.Drawing import Rectangle as _Rect
self.controller.SetBoundsAndZoomFactor(_Rect(int(left), int(top),
int(width), int(height)), 1.0)
except Exception as ex:
print("[WV2] set_bounds error:", ex)
def child_size(self):
"""子窗口当前屏幕像素尺寸(观察用)"""
if not self.child_hwnd:
return (0, 0)
import ctypes
import ctypes.wintypes as wt
r = wt.RECT()
if ctypes.windll.user32.GetWindowRect(wt.HWND(self.child_hwnd), ctypes.byref(r)):
return (r.right - r.left, r.bottom - r.top)
return (0, 0)
# ---------- JS ----------
def navigate(self, url: str):
print(f"[WV2] {_ts()} navigate {url[:80]}")
try:
self.core.Navigate(url)
except Exception as ex:
print("[WV2] navigate error:", ex)
def execute_js(self, script: str):
"""fire-and-forgetChatBridge.run_js 的替换,JS 文本完全同构)"""
self._js_run(script, None)
def execute_js_async(self, script: str, cb):
"""带回调执行:真异步,回调在主线程定时器 tick 中发出(绝不阻塞)"""
self._js_run(script, cb)
def _js_run(self, script: str, cb):
try:
task = self.core.ExecuteScriptWithResultAsync(script)
except Exception as ex:
print("[WV2] execute_js error:", ex)
if cb:
try:
cb(None)
except Exception:
pass
return
self._js_pending.append([task, cb, time.time()])
def _js_pump_tick(self):
if not self._js_pending:
return
import json
remaining = []
for task, cb, t0 in self._js_pending:
done = False
try:
done = task.IsCompleted
except Exception:
done = True
if not done and time.time() - t0 > 10:
done = True # 10s 安全超时(渲染器死亡时不永久卡队列)
if not done:
remaining.append([task, cb, t0])
continue
result = None
try:
if task.IsCompleted and not task.IsFaulted:
r = task.Result # CoreWebView2ExecuteScriptResult 包装结构
if getattr(r, "Succeeded", True):
s = r.ResultAsJson # JSON 编码字符串(或 null
if s:
result = json.loads(s)
if isinstance(result, str):
try:
result = json.loads(result)
except Exception:
pass
except Exception as ex:
print("[WV2] js result decode error:", ex)
if cb:
try:
cb(result)
except Exception as ex:
print("[WV2] js callback error:", ex)
self._js_pending = remaining
def close(self):
try:
self._js_pump.stop()
except Exception:
pass
self._js_pending = []
try:
self.core.remove_WebMessageReceived(self._msg_handler)
except Exception:
pass
try:
self.core.remove_NavigationCompleted(self._nw_handler)
except Exception:
pass
try:
self.controller.Close()
except Exception:
pass
+110
View File
@@ -0,0 +1,110 @@
# 当前代码状态与结构审计
> 用途:处理结构、命名、模块边界或技术债任务前读取。本文只记录已经由源码确认的现状;精确实现仍以当前源码为准。修复顺序和验收要求见 [REPAIR_BACKLOG.md](REPAIR_BACKLOG.md) 与 [VERIFICATION.md](VERIFICATION.md)。
## 结论
项目的目录分层和文件命名总体符合 Python、JavaScript 与 Qt 项目的常见习惯,不需要为了“看起来规范”而批量改名或搬目录。当前主要风险不在命名,而在以下位置:
- `ui/views/main_window.py` 承担窗口组装、浏览器选择、会话、附件、截图、弹层和大量事件处理,已经形成高耦合中心。
- `ui/web/app.js` 同时负责渲染、流式更新、历史、分支和交互状态,Python 与 JavaScript 之间没有显式协议定义。
- Windows 专用的 WebView2、全局热键、进程终止和路径假设尚未被完整隔离,Linux 不能仅凭“Python 跨平台”获得支持。
- 测试入口聚合不完整(P2-04 待实施);配置重定向与运行时数据隔离已由 P0-01 统一(见 [REPAIR_BACKLOG.md](REPAIR_BACKLOG.md) P0-01 与 evidence/P0-01.md)。
- 根目录旧文档混有过时描述;它们不能继续作为实现依据。
当前阶段允许修复错误、添加测试和添加小型平台适配器,但不移动或拆分现有模块。超大文件是已记录技术债,不是本轮重构授权。
## 目录职责
| 路径 | 当前职责 | 审计判断 |
| --- | --- | --- |
| `main.py` | 进程入口、标准输出保护、QtWebEngine 启动参数、`QApplication` 和主窗口创建 | 入口职责基本合理;渲染参数需要区分平台 |
| `core/` | 数据库、LLM 工作线程、日志和 WebView2 后端 | 名称规范;仍含 Windows 专用实现和配置路径旁路 |
| `core/agent/` | 与 pi 对齐的 agent 循环、上下文、压缩、恢复和内置工具 | 应继续保持 GUI-free、可离线测试 |
| `ui/views/` | PyQt6 窗口、桥接层和浏览器包装 | 边界最薄弱;`main_window.py` 是主要风险点 |
| `ui/views/system_tools/` | 文件读取、全局热键、屏幕截图 | 适合作为平台适配落点;当前实现以 Windows 为中心 |
| `ui/web/` | 离线 HTML/CSS/JavaScript 聊天界面和 vendored 前端库 | 无需构建步骤;历史窗口状态机尚未独立 |
| `tools/builtin_tools/` | 面向 agent 的独立实用工具 | 当前仅有 PDF 读取器;与其他两类“工具”需用全路径区分 |
| `tests/` | 纯逻辑测试、offscreen smoke、诊断与人工验证脚本 | 命名前缀有约定,但聚合入口没有覆盖全部自动化测试 |
| `data/` | 源码运行时的配置、数据库和附件 | 本阶段保留项目内路径;凭据处理遵循 [README.md](README.md) 的硬性边界 |
| `vendor/webview2/``WebView2Loader.dll` | Windows WebView2 运行依赖 | 只允许 Windows 路径加载,不能成为 Linux 启动前置条件 |
| `svg/` | UI 图标 | 目录职责清晰 |
## 命名规范审计
| 范围 | 现状 | 结论 |
| --- | --- | --- |
| Python 文件、函数、变量 | 基本使用 `snake_case` | 符合 PEP 8 常规写法 |
| Python 类 | 基本使用 `PascalCase` | 符合惯例 |
| Qt 覆盖方法 | 使用 `eventFilter``closeEvent` 等 Qt 固定名称 | 正确例外,不应改成 `snake_case` |
| QWebChannel/JS 可调用接口 | 存在 camelCase 名称 | 跨语言协议名称可保留,但必须集中记录 |
| JavaScript | 主要使用 `camelCase` | 符合惯例 |
| CSS | 选择器和属性使用 Web 常规形式 | 符合惯例 |
| 测试文件 | `test_*``smoke_*``diag_*``verify_*``tune_*` | 前缀表达运行性质,约定合理 |
以下名称有可读性或发布规范问题,但不应在当前修复阶段批量改名:
- `Frame.md` 语义过宽,且与 `readme.md``ARCHITECTURE.md` 的职责重叠。旧文档只作历史资料。
- `readme.md` 的大小写不影响源码运行;是否改成 `README.md` 留到仓库整理阶段。
- `db_manager.py``llm_engine.py` 等名称合规,但 “manager/engine” 隐藏了较宽职责;先通过边界文档约束新增代码。
- `core/agent` 的版本字符串不是标准 PEP 440 形式;打包阶段再统一。
- “工具”同时指 agent 的 `read/bash/write/edit``tools/builtin_tools/` 实用工具和 `ui/views/system_tools/` 桌面集成。任务与文档必须使用完整路径或明确类别,不能只写“tool”。
## 已确认的结构与行为缺陷
### 配置和测试隔离
- 已解决(P0-01):所有运行时配置读取统一经 `core/config_paths``HAOCODE_CONFIG_FILE` 优先);`tests/test_config_isolation.py` 用打开路径拦截器证明真实配置从未被打开。
- 新测试必须在导入 `MainWindow` 及相关模块前同时重定向数据库和配置(统一用 `tests/_test_env.py``isolate()`),并检查被测模块是否缓存了路径常量。
- 少数 GUI 测试(`smoke_offscreen.py``smoke_mode.py``smoke_copy_session.py`)自身只重定向数据库、未设置 `HAOCODE_CONFIG_FILE`;独立运行时由环境注入临时配置,P2-04 聚合入口将按子进程强制注入。
- `tests/run_tests.py` 当前不是全套测试聚合器,不能把一次成功运行等同于整个 `tests/` 目录通过。
### UI 和事件处理
- 已解决(P0-02):`MainWindow` 重复定义的 `eventFilter` 已合并为一份;发送规则收进 `send_message(from_enter=...)` 单一实现(见 evidence/P0-02.md)。
- UI 层存在直接 SQL 和跨模块私有成员访问,导致数据库、窗口和渲染状态相互渗透。当前只修复会造成实际错误的调用,不展开分层重构。
- 原生窗口型 WebView2 与 Qt 弹层的遮挡关系需要逐个验证;重命名弹层是已确认案例,不能假定所有 QWidget 弹层都会自然显示在 WebView2 之上。
### Python 与 JavaScript 边界
- 当前通过 `ui/views/chat_bridge.py`、直接 JavaScript 执行以及硬编码函数名传递状态,没有协议版本、载荷 schema 或统一错误回传。
- 当前没有“按边界消息 ID + 方向”请求历史页的 JS→Python 协议。现有消息缓冲也不足以重建附件、时间线、工具输出和分支关系完整的历史项。
- 建立历史滑动窗口时,必须传递完整消息描述符,并用会话/分支 generation 丢弃过期响应;不能让前端自行拼接不完整历史。
- 跨语言公开名称一旦落地即视为协议。实现 Agent 应把请求、响应、错误和重置事件集中列在同一处,并用契约测试锁定。
### 平台边界
- `core/webview2.py``ui/views/wv2_view.py` 和相关进程处理是 Windows 专用路径;Linux 只能选择 QtWebEngine。
- `ui/views/system_tools/global_hotkey.py` 使用 Windows 原生 API,Linux 导入和运行路径尚未隔离。
- agent 的 bash 工具使用隐式 shell 选择:Windows 通常落到 `cmd.exe`Linux 通常落到 `/bin/sh`,不满足既定的 `/bin/bash -lc` 契约。
- Windows 使用进程树终止手段;POSIX 侧尚无对完整进程组的等价取消/超时保证。
- 项目中的 Windows 路径、动态库和 WebView2 探测不能在 Linux 启动阶段被无条件访问。
### 文档和仓库状态
- `ARCHITECTURE.md` 仍含旧产品名和失效目录;误拼接的外部修复文档已经移除,文件顶部已明确标记为历史资料。
- 第三方修复要求提到的 `haocode.spec``pyi_rth_trace.py``tests/diag_render_scale.py``tests/diag_panel_scrollbar.py``tests/diag_rename_overlay.py` 当前不存在。上述三个 `tests/diag_*` 脚本可按任务新建;两个打包文件属于后续阶段。
- 工作区没有可用 Git 历史,不能引用不存在的基线提交,也不应擅自初始化仓库。任务清单仍按可独立提交的粒度书写,供未来接入版本控制。
- 源码树存在 `__pycache__`、数据库、日志和锁等运行产物。当前不做清理工程;测试必须使用临时位置,避免继续污染生产数据。
## 应保持的边界
1. `core/agent/` 保持 GUI-free;纯 agent 行为可不创建 `QApplication` 直接测试。
2. 浏览器差异留在 WebView2/QtWebEngine 适配层,不把后端判断散落到业务逻辑。
3. 系统热键、截图和 shell 通过最小平台适配接口选择实现;Windows 模块与 Linux 模块只在对应平台延迟导入。
4. 数据库存取继续由 `core/db_manager.py` 承担;新的 UI 功能不要增加直接 SQL。
5. Python↔JavaScript 载荷使用完整、可测试的描述符;DOM 只保存当前窗口,不承担持久化或完整会话真相。
6. 运行时秘密边界、任务范围和验证要求分别以本目录的 `README.md``REPAIR_BACKLOG.md``VERIFICATION.md` 为准。
## 本阶段明确延后
- 拆分 `main_window.py``app.js` 或迁移现有模块。
- 批量重命名文件、类或公开跨语言接口。
- PyInstaller/其他打包配置、安装器、AppData/XDG 数据目录迁移。
- WebKitGTK 或其 Qt 封装。
- 通用键盘钩子框架;Linux 只实现现有截图快捷键需要的能力。
- agent shell 沙盒、命令审批、路径权限边界。
- 新工具注册系统、参考其他 harness 增加功能或改变 pi 对齐目标。
- Ubuntu 以外 Linux 发行版的支持承诺。
结构或命名任务只有在“发现的问题已逐项归类为确定缺陷、已接受技术债或明确延后,并且没有借机移动/拆分模块”时才算审计完成。
+65
View File
@@ -0,0 +1,65 @@
# 全量修复执行状态
状态:ACTIVE
总目标:完成 REPAIR_BACKLOG.md 中所有待实施任务
## 调试铁律(跨压缩持久,每次恢复后必须遵守)
- **所有调试命令、测试与诊断脚本执行都必须设置显式的最长耗时预算(timeout)**。
- 若中途因超时跳出,先定位卡点,再允许延长预算重跑;**严禁不设超时让它无限卡死**。
当前任务:全部完成(P0-01 → P2-04
当前阶段:COMPLETE(自动化部分;Windows 真机双渲染路径已验证;Linux 真机平台验证项见各任务 evidence)
最后完成动作:Windows 真机验证 —— ① `core/webview2.py` 加 byte[] 加载回落(本机 D: 卷 .NET “网络位置”怪癖),真桌面 WebView2 启动成功(Runtime 153.0.4234.32、controller ready、NavigationCompleted、截图像素证实聊天区真实 DOM 渲染);② `HAOCODE_FORCE_QTWEBENGINE=1` 真桌面回落验证(独立 per-instance profile、无 taskkill、渲染正常);③ P2-02 真机截图(diag_panel_scrollbar 真桌面 ALL PASS,三图已更新);④ T0 守卫真机交叉验证(diag 实例被锁自动回落,未误杀主程序 WebView2);证据更新 evidence/P1-03.md、evidence/P2-02.md、evidence/P2-03.md
下一步唯一动作:P2-03 人工清单 1–5 可在本机真实 WebView2 模式下走查(app 现可正常以 WV2 启动,当前前台实例为强制 QtWebEngine 模式,重开即默认 WV2);其余为 Linux 真机验证(P1-03/P1-04),需 Ubuntu 桌面主机
当前修改文件:core/webview2.pybyte[] 加载回落 + BaseException 防护)、docs/agent-handoff/evidence/{P1-03,P2-02,P2-03}.md、evidence/win_real_*_mainwindow.png(新)
最近测试结果:test_wv2_guard 10/10(修复后无回归);diag_panel_scrollbar 真桌面 ALL PASSrun_all 25/25(前次终态)
尚未验证的平台:Linux X11、Linux WaylandP1-03 启动链、P1-04 X11 XGrabKey 真实注册/命中、Wayland portal 三态均需真机);Windows 真机双渲染路径(WebView2 + QtWebEngine 回落)已于 2026-09-17 验证,仅剩 P2-03 人工遮罩走查(1–5,可本机执行)
阻塞项:无
观察项:
- 【P1-01 教训·offscreen QtWebEngine 诊断】未 `window.resize()`+`show()``innerHeight=0`,锚点/滚动几何全废;诊断必须先 resize+show、等待 `innerHeight>0`、再显式 `load_messages_to_web` 重载
- 【P1-01 教训】`runJavaScript` 回调不能返回 DOM 元素(转换失败);`wait_until` 条件一律返回原语(`cond ? 1 : 0`
- 【P1-01 教训】批次渲染守卫(`__rwPageRendering`)必须在**调度时刻**捕获,延迟回调(rAF+setTimeout)触发时批次已结束、标志已复位,届时再读会漏放 `scrollIntoView` 触发 'newer' 振荡
- 【P1-01 设计】页 = 半窗(`size//2`),非整窗:整窗页会使向上换页锚点必被裁出窗口,≤2px 锚点恢复不可达
- 【P1-02 教训】Windows 下 argv 列表形态 Popen 会被 `list2cmdline` 转义内部引号(`\"`),cmd.exe 不认 → 显式 cmd 契约必须用**字符串命令行** `cmd.exe /d /s /c "<cmd>"`
- 【P1-02 发现】旧 `shell=True` 对带引号程序名实为直接 CreateProcessW(不经 cmd);显式 cmd 后行为统一且可预测
- 【P1-03 教训】`QTimer.singleShot` 单位是**毫秒**(25 = 25ms),兜底预算要写 30000;事件循环启动前不触发
- 【P1-03 教训】QtWebEngine 顺序硬约束:QtWebEngineWidgets 必须先于 QApplication 导入;QWebEngineProfile 必须先于使用它的 page/view 创建,且 setPersistentStoragePath/setCachePath 要在 profile 使用前调
- 【P1-03 发现】PyQt6-WebEngine 6.10 未暴露 `QWebEnginePage.errorOccurred`(Qt 6.5+ API),加载失败诊断用 loadFinished(ok) + processEvents 循环
- 【P1-03 教训】改构造函数签名必须保旧式位置调用:`CustomWebPage(browser)` 的 view 会被新首形参误当 profile,按 `isinstance(QWebEngineProfile)` 分派
- 【P1-04 教训】PyQt6`QThread.run()` 内任何未处理异常 = **abort 整个进程**(退出码 127、无 traceback、stdout 缓冲丢失,极难诊断)→ QThread.run() 必须顶层 try/except 全捕获 + 日志
- 【P1-04 教训】`ctypes.byref(x)` 返回 CArgObject:真实 CDLL 调用正常,传给测试替身(普通可调用对象)会 TypeError —— 替身端 `getattr(arg, "_obj", arg)` 还原原对象
- 【P1-04 教训】Windows 上 `os.pipe()` 的 fd 不能可靠用于 select()10093/10038)——跨平台可 select 假 fd 用 `socket.socketpair()`;且 `a.send()` 的数据在 b 的接收缓冲(方向别写反)
- 【P1-04 发现】`file://` URI 解析用剥前缀+unquote 而非 urlparse().path`file://D%3A%5Cx`(无第三斜杠)会被当 netlocgdbus portal 的 FilePicked/Finished 信号都发在 request 对象上
- 【P2-01 教训】Qt:对刚展开、布局尚未 flush 的 QPlainTextEdit 立即设水平滚动值,随后的挂起 resize flush 会把滚动清零——真实用户无法在未渲染框上滚动,属测试时序伪影;测试设滚动前必须先 settle(N1×N2 探针矩阵证实 N2≥1 即稳定)
- 【P2-01 教训】offscreen harness 收尾用 `os._exit``sys.exit` 后 QtWebEngine 渲染/GPU 子进程可能不回收 → 解释器挂起至 timeout;管道执行时只看输出会误判通过(必须验 EXIT 码)
- 【P2-02 教训】offscreen 下 QPlainTextEdit 的 `grab()` 文档区不填充(黑图)但 `panel.grab()` 正常;像素断言一律走 `widget.render(painter)`(渲染到透明 QPixmap),且先做健全性检查(文本色/handle 色像素计数>0)防黑图假通过
- 【P2-02 教训】PyQt6`Qt.Vertical``Qt.Orientation.Vertical``QStyleOptionSlider()` 无参构造(不接受 widget);`subControlRect(cc, opt, sc, widget)` 参数序 + 滚动条箭头子控件枚举名是 `SC_ScrollBarSubLine/AddLine``CC_ScrollBar` 属于 `QStyle.ComplexControl`
- 【P2-02 发现】裸控件(无样式表祖先)`style()` 是基础风格,空 option 下 subControlRect 返回 0 矩形——「原生参照」只能靠 sizeHint/实际几何对照(原生横条 14px vs 面板 8px
- 【P2-03 发现】offscreen/无真实事件循环时 `processEvents()` **不处理 DeferredDelete**`deleteLater()` 后必须显式 `QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)` 才会真正删除(offscreen 测试断言"已删除"前必须冲刷;生产事件循环常驻不受影响)
- 【P2-03 教训】PyQt6 `setGeometry``(QPoint, QSize)` 重载→构造 `QRect(tl,size)``QTest.mouseClick(widget, button, modifier, pos)` 第 3 参是 **modifier**`QTest` 在独立模块 `PyQt6.QtTest`
- 【P2-03 发现】`core/webview2.py` `get_environment()``taskkill /F /IM msedgewebview2.exe` + 共享默认 profile 不可重定向 → 无人值守自动化不得走真实 WebView2 启动路径;真机验收走人工清单(拥有者顶层 Tool 窗 z 序上必盖 WebView2 原生子 HWND,机制与生产已验证的 AttachmentPreviewOverlay 同构)
- 【P2-03 结构判据】顶层窗可带 owner parent`setWindowFlags(Tool|Frameless)``parentWidget()` 仍可非 None;判据是 `isWindow()` / `window() is self`,不是 `parentWidget() is None`
- 本机无系统级 Python 3.10;已用 uv 安装 CPython 3.10.21(用户目录托管)并重建项目内 `.venv`Python 3.10.21),与文档基线对齐
- WSL 存在 Ubuntu-22.04(当前 Stoppedpython3.12.3,未装 PyQt6);未检测到 X11/Wayland 桌面会话
- `tests/diag_live_agent.py:19``tests/tune_model_popup.py:155` 直接引用真实配置路径;属 live/tune 脚本,默认聚合排除,不属 P0-01 允许范围
- `readme.md` 提及的 `haocode.spec`/`pyi_rth_trace.py` 不在本快照内(打包属后续阶段,不影响源码运行)
环境探测(只读):
- OSWindows 11 10.0.26200 x64
- Python:默认 3.13 / 3.12.10,无 3.10`.venv` = **3.10.21**uv 重建,P0-01+ PyQt6 6.10.2 + PyQt6-WebEngine 6.10.0 + openai 2.26.0 + pythonnet 3.1.0 + PyMuPDF 1.28.0
- WebView2 Runtime153.0.4234.32 存在
- WSLUbuntu-22.04Stopped)、docker-desktopRunning
任务状态表:
- P0-01: COMPLETE
- P0-02: COMPLETE
- P0-03: COMPLETE2026-09-16 复核通过,文档一致性修订已固化)
- P1-01: COMPLETE
- P1-02: COMPLETEWindows 侧自动化全绿;Linux 进程组用例 C3 待 Linux 环境运行生效)
- P1-03: COMPLETEWindows 侧自动化 + offscreen 真实启动链全绿;Linux 真机与 root/容器 `--no-sandbox` 接受路径待对应环境验证)
- P1-04: COMPLETEWindows 侧自动化全绿:路由矩阵/X11 替身/portal 替身/offscreen 真实启动链;Linux X11 XGrabKey 真实注册与 Wayland portal 授权/取消/无 portal 三态待真实 Linux 宿主手动验证,见 evidence/P1-04.md
- P2-01: COMPLETE(两栏启动倒序 + 状态保持,offscreen 全量覆盖,无平台相关项;见 evidence/P2-01.md
- P2-02: COMPLETEoffscreen 全量覆盖 + 无泄漏断言;Windows/Linux 真机截图待对应环境运行 diag_panel_scrollbar.py;见 evidence/P2-02.md
- P2-03: COMPLETEoffscreen 结构+行为全量覆盖 34/34;审计确认唯一受影响遮罩;Windows 真机 WebView2 人工验收清单见 evidence/P2-03.md
- P2-04: COMPLETE(聚合入口 tests/run_all.pyWindows 25/25 PASSWSL/3.12 FAIL 0 + 21 有理由 SKIP;夹具演示非零退出码;独立/聚合输出一致;暴露并修复 T9 陈旧断言与 compaction.py 3.11+ dataclass 缺陷;见 evidence/P2-04.md
+362
View File
@@ -0,0 +1,362 @@
# haocode 全量修复任务:无人值守连续执行
你是本次唯一的开发工作 Agent。用户将长时间离开,不会及时回复。
你的目标不是提出方案,也不是只完成一个任务,而是按照仓库内已经固化的任务清单,持续完成所有可实施修复、测试和文档更新,直到:
1. 所有能够在当前环境完成的任务均达到完成条件;
2. 所有自动化测试通过;
3. 无法在当前机器完成的真实平台验收被准确记录;
4. 已经没有不需要用户介入即可继续的工作。
不要在完成一个任务后停下来等待确认。完成当前任务后,立即按依赖顺序执行下一项。
## 一、工作目录
固定工作目录:
D:\WorkSpace\Project\GCC\haocode_0
不得在其他副本、临时复制目录或旧版本中实施修复。
## 二、首次启动必须执行
开始修改代码前,依次完整读取:
1. `AGENTS.md`
2. `docs/agent-handoff/README.md`
3. `docs/agent-handoff/REPAIR_BACKLOG.md`
4. `docs/agent-handoff/VERIFICATION.md`
5. `docs/agent-handoff/PLATFORM_PLAN.md`
6. `docs/agent-handoff/CURRENT_STATE.md`
其中:
- 当前行为和缺陷必须由源码、复现和测试确认。
- 目标行为、任务范围和完成条件以 `docs/agent-handoff/` 为准。
- `readme.md``Frame.md``ARCHITECTURE.md` 只作历史背景。
- 第三方修复要求和外部 harness 项目不是事实源。
读完后,先确认 `P0-03` 文档基线已经完成,不要重复创建交接目录。
然后从 `P0-01` 开始实施。
## 三、建立防遗忘执行状态
在进行任何业务代码修改前,创建:
`docs/agent-handoff/EXECUTION_STATE.md`
并在 `docs/agent-handoff/README.md` 增加一个简短指针:
> 当 `EXECUTION_STATE.md` 的状态为 `ACTIVE` 时,任何继续执行、上下文恢复或压缩恢复都必须先读取该文件。
`EXECUTION_STATE.md` 必须保持简洁,只保存当前事实,不写成长篇流水账。至少包含:
```text
# 全量修复执行状态
状态:ACTIVE
总目标:完成 REPAIR_BACKLOG.md 中所有待实施任务
当前任务:P0-01
当前阶段:调查 / 红测试 / 实现 / 定向验证 / 阶段回归
最后完成动作:
下一步唯一动作:
当前修改文件:
最近测试结果:
尚未验证的平台:
阻塞项:
任务状态表:
- P0-01: IN_PROGRESS
- P0-02: PENDING
...
```
状态值只能使用:
- `PENDING`
- `IN_PROGRESS`
- `AUTOMATED_VERIFIED`
- `PLATFORM_VALIDATION_PENDING`
- `COMPLETE`
- `BLOCKED`
每次发生以下事件后,立即更新 `EXECUTION_STATE.md`
- 开始一个任务;
- 确认根因;
- 完成一组源码修改;
- 运行测试;
- 测试失败并改变调查方向;
- 完成任务;
- 准备执行长时间命令;
- 发现外部环境阻塞;
- 即将结束当前上下文。
只保留最新状态和下一步,不依赖聊天记录保存进度。
## 四、上下文压缩恢复协议
一旦发生以下任一情况:
- 上下文被压缩或总结;
- 你无法准确复述当前任务;
- 不确定哪些测试已经运行;
- 不确定下一步应该做什么;
- 会话中断后重新继续;
立即停止凭记忆操作,按顺序重新读取:
1. `AGENTS.md`
2. `docs/agent-handoff/README.md`
3. `docs/agent-handoff/EXECUTION_STATE.md`
4. `REPAIR_BACKLOG.md` 中“当前任务”的完整章节
5. 当前任务引用的 `PLATFORM_PLAN.md``VERIFICATION.md` 章节
6. `EXECUTION_STATE.md` 列出的当前修改文件
然后核对工作区实际状态和最近测试结果,再从“下一步唯一动作”继续。
聊天摘要不能代替 `EXECUTION_STATE.md`。不得因为上下文压缩重新设计范围、跳过测试或把未完成任务误判为完成。
## 五、永久硬约束
### 凭据铁律
`data/config.json` 是不透明的本机密钥文件。
绝对不得:
- 打开;
- 读取;
- 搜索其内容;
- 打印;
- 复制;
- 修改;
- 计算散列;
- 制作快照;
- 让递归内容搜索包含它;
- 让其内容进入工作上下文或测试输出。
所有自动化测试必须使用临时配置和临时数据库。
验证真实配置未被访问时,使用打开路径拦截器、替身或访问记录;不得通过读取真实文件验证。
`P0-01` 完成前,不得运行可能绕过临时配置而读取真实配置的 GUI/LLM 测试。先审查测试入口并完成隔离。
### 范围约束
- 不初始化 Git,不创建提交,不伪造历史。
- 不删除或清理用户现有数据库、附件、日志、锁文件或运行产物。
- 不移动或拆分现有模块。
- 不借修复重写 `main_window.py``app.js` 或弹窗系统。
- 不增加新产品功能。
- 不增加工具注册系统。
- 不参考或移植 Claude Code、Codex、Grok Build、DeepSeek Harness。
- 不改变 `core/agent/` 作为 pi Python 移植的定位。
- 不增加 Agent shell sandbox、命令审批或路径权限边界。
- Chromium sandbox 保持默认开启。
- 不引入 WebKitGTK 或相关 Qt 封装。
- 不做 PyInstaller、安装器、AppData/XDG 迁移或发行包。
- 本阶段只保证源码运行。
- `vendor/webview2/` 和根目录 `WebView2Loader.dll` 不得删除。
- 产品内部 Agent 继续按原方式调用 LLM,不修改其产品行为。
- 不把开发工作委派给其他 Agent;由你连续完成。
只修改当前任务“允许修改”中列出的文件。发现无关问题时记录到 `EXECUTION_STATE.md` 的“观察项”,不要顺手扩大范围。
## 六、任务执行顺序
严格按以下顺序连续执行:
1. `P0-01` 配置路径与测试隔离
2. `P0-02` 合并重复的 `MainWindow.eventFilter`
3. 复核已经完成的 `P0-03`
4. `P1-01` 双向消息渲染窗口
5. `P1-02` Windows/Linux shell 与进程树终止
6. `P1-03` Windows/Linux 渲染器启动链
7. `P1-04` Linux 截图热键与截图实现
8. `P2-01` Bash 任务按启动时间倒序
9. `P2-02` 右侧 Bash 面板滚动条
10. `P2-03` WebView2 原生窗口遮挡层
11. `P2-04` 跨平台聚合测试入口
不得跳过依赖。某项存在真实平台验收阻塞时,先完成其实现和自动化验证,将状态设为 `PLATFORM_VALIDATION_PENDING`,然后继续所有不受该阻塞影响的后续任务。
## 七、每个任务的固定执行循环
对每个任务严格执行以下循环:
### 1. 领取
-`EXECUTION_STATE.md` 中把任务设为 `IN_PROGRESS`
- 完整读取该任务的“先读文件、允许修改、硬约束、目标测试、完成证据”。
- 只加载当前任务需要的源码。
完成标准:能够准确列出当前任务允许修改的文件、禁止范围和验收条件。
### 2. 复现和根因
- 从实际源码出发确认附件描述是否正确。
- 建立最小复现或失败测试。
- 对确定性缺陷记录实际调用链、状态变化或平台差异。
- 描述不准确时以代码证据纠正,不机械照抄文档中的猜测。
完成标准:测试或可重复证据在修复前能够暴露缺陷。
### 3. 实现
- 采用与现有代码风格一致的最小修改。
- 优先复用现有接口和模块边界。
- 只在确实能隔离平台差异或测试状态时增加窄辅助模块。
- 不进行无关格式化、批量重命名或结构重构。
完成标准:失败复现转绿,且没有扩大任务行为面。
### 4. 定向验证
运行任务章节列出的全部目标测试。
每条测试记录:
- 精确命令;
- 平台和环境;
- 退出码;
- PASS/FAIL/SKIP
- 关键断言;
- 失败原因;
- 是否使用临时配置和数据库。
测试失败时进入诊断循环并继续修复,不能通过删除断言、放宽正确性要求或把失败改成 SKIP 获得通过。
完成标准:全部适用定向测试通过;不适用项有真实平台理由。
### 5. 自检
逐条核对当前任务的所有“硬约束”和“完成证据”。
检查:
- 是否改了允许范围之外的文件;
- 是否引入了新功能;
- 是否碰到真实配置或数据库;
- 是否只验证了 happy path
- 是否保留 Windows 现有行为;
- 是否误把 offscreen 当作真实桌面证据;
- 是否有未记录的测试失败。
完成标准:每条完成证据都有源码、测试输出或真机证据对应。
### 6. 固化进度
- 更新 `REPAIR_BACKLOG.md` 的任务状态。
- 更新 `EXECUTION_STATE.md`
- 将简洁证据写入 `docs/agent-handoff/evidence/<TASK_ID>.md`
- 不粘贴大量完整日志,只记录命令、结果和证据文件路径。
- 立即开始下一任务,不等待用户确认。
只有全部完成条件满足时才能标记 `COMPLETE`
## 八、阶段回归
完成 P0、P1、P2 每个阶段后,运行 `VERIFICATION.md` 对应的阶段完整回归。
要求:
- 定向测试不能代替阶段回归。
- `tests/run_tests.py` 在修复聚合入口前不能被称为全套测试。
- 平台不适用项必须明确显示 `SKIP` 和理由。
- 共同逻辑测试失败不能以平台差异豁免。
- 测试不得访问网络、真实 API 或真实凭据,除非任务明确要求且用户已经提供授权;当前没有该授权。
- 不运行 `diag_live_*``smoke_live_*` 的真实 API 路径。
P2-04 完成后,使用新的聚合入口运行最终自动化集合,并保留每个独立测试的运行能力。
## 九、平台验收处理
先只读检测当前机器实际具备的环境:
- Windows 版本;
- WebView2 是否可用;
- QtWebEngine 是否可用;
- 是否存在可用的 WSL/Ubuntu、X11 或 Wayland 环境。
不得为了补齐平台矩阵擅自安装操作系统、创建虚拟机或修改宿主机关键配置。
当前环境具备的平台必须完成真实验证。
当前环境不具备的平台:
1. 完成平台适配代码;
2. 完成平台路由和替身自动化测试;
3. 记录缺少的真实环境;
4. 将任务标为 `PLATFORM_VALIDATION_PENDING`,不能标记 `COMPLETE`
5. 继续执行其他任务。
不得伪造 Windows WebView2、Linux X11、Linux Wayland、portal、DPI、全局热键或截图的真机证据。
## 十、阻塞处理
用户正在休息。不要因为普通实现选择、测试失败或代码复杂而询问用户。
优先采用:
1. 现有源码行为;
2. `docs/agent-handoff/` 中已经冻结的决策;
3. 最小、兼容、可测试的实现;
4. 将判断依据写入执行状态和证据文档。
只有遇到以下情况才允许停止:
- 必须获取用户凭据;
- 必须执行不可逆或破坏性操作;
- 必须使用当前不存在的外部机器;
- 两个权威要求存在无法同时满足的真实矛盾;
- 连续诊断后确认没有任何不需要用户介入的工作可继续。
即使一个任务阻塞,也要继续所有不依赖该阻塞的任务。
## 十一、最终收尾
所有可执行工作完成后:
1. 运行最终自动化聚合测试。
2. 复核所有单文件测试入口仍可运行。
3. 复核 `data/config.json` 未被测试访问,但不要读取它。
4. 检查交接文档与最终代码是否一致。
5. 更新 `CURRENT_STATE.md`,移除已经修复的“当前缺陷”表述。
6. 更新 `REPAIR_BACKLOG.md` 中每项真实状态。
7.`EXECUTION_STATE.md` 状态改为:
- `COMPLETE`:所有自动化和真实平台条件均满足;
- `PLATFORM_VALIDATION_PENDING`:仅剩当前机器无法提供的真机验证;
- `BLOCKED`:仍有必须由用户决定或提供资源的问题。
8. 创建 `docs/agent-handoff/FINAL_REPORT.md`
`FINAL_REPORT.md` 必须包含:
- 每个任务的最终状态;
- 根因与实际修复摘要;
- 修改文件清单;
- 自动化测试命令和结果;
- Windows/Linux 真机验证结果;
- 明确的未验证项;
- 对真实配置和数据库的隔离证明方式;
- 仍需用户处理的最少事项;
- 下一位 Agent 的恢复入口。
## 十二、最终回复格式
只有在没有可继续执行的工作时才回复用户。
最终回复必须先说明整体状态,然后依次给出:
1. 已完成任务;
2. 修改范围;
3. 测试结果;
4. 真机平台证据;
5. 未完成或受阻事项;
6. `FINAL_REPORT.md``EXECUTION_STATE.md` 路径;
7. 用户醒来后需要执行的最少动作。
不要只回复“完成了”。不要隐藏失败、跳过项或未验证平台。
现在开始执行首次启动步骤,创建持久化执行状态,然后从 P0-01 连续工作,直到达到上述终止条件。
+153
View File
@@ -0,0 +1,153 @@
# Windows / Linux 源码运行契约
> 触发条件:修改启动、浏览器、shell、进程取消、系统热键、截图、路径、依赖或平台测试前读取。本文定义目标行为,不代表这些能力已经实现。只有完成本文验收矩阵后,才能把对应环境标为“已验证”。
## 支持矩阵
| 环境 | Python | 浏览器后端 | 系统集成 | 状态定义 |
| --- | --- | --- | --- | --- |
| Windows x64 | 现有 Python 3.10 基线 | WebView2 首选;失败或 profile 被占用时回落 QtWebEngine | 现有 Windows 原生全局热键和截图 | 必须保持现有产品行为 |
| Ubuntu 22.04/24.04 x64 + X11 | Python 3.103.12 | 仅 QtWebEngine | X11 原生全局热键;现有截图交互的 X11 实现 | 本阶段 Linux 主支持目标 |
| Ubuntu 22.04/24.04 x64 + Wayland | Python 3.103.12 | 仅 QtWebEngine | `xdg-desktop-portal` 全局快捷键/截图能力 | portal 缺失或桌面不支持时明确报错 |
| `QT_QPA_PLATFORM=offscreen` | 同对应系统 | QtWebEngine 测试路径;Windows 跳过 WebView2 | 不验证真实全局热键和桌面截图 | 只用于自动化测试,不算桌面支持证据 |
其他 Linux 发行版可以尝试源码运行,但必须标记“未验证”。Linux 不引入 WebKitGTK、WebKitGTK 的 Qt 包装层或第二套网页 UI。
## 平台选择原则
平台能力通过小型适配器隔离,不建立通用插件框架,也不移动现有模块。适配器至少覆盖以下三类能力:
- 浏览器选择:Windows 尝试 WebView2 后回落 QtWebEngine;非 Windows 直接进入 QtWebEngine。
- shell 生命周期:生成固定 shell argv、设置进程组选项、取消和超时时终止完整命令树。
- 桌面集成:注册/注销 `Alt+S`,启动截图并以现有 Qt 信号返回结果或错误。
Windows 专用模块和 Linux 专用模块只在平台选择完成后延迟导入。Linux 启动链不得先访问 `ctypes.WinDLL`、Windows HWND、`taskkill` 或 WebView2 DLLWindows 启动链不得依赖 DBus、portal 或 X11 包。
## 浏览器后端
### Windows
1. 保持 WebView2 为首选,初始化失败时显示可诊断日志并回落 QtWebEngine,主窗口仍可使用。
2. 保持现有实例锁语义。同一 WebView2 profile 已由另一个实例使用时,新实例回落 QtWebEngine,不与其争用 profile,也不结束对方进程。
3. WebView2 原生子窗口继续使用现有包装层;修复弹层时只调整已确认的遮挡案例。
4. 强制 QtWebEngine 回落必须有测试入口,以便在 Windows 上验证两条渲染路径。
### Linux 与 QtWebEngine 回落
1. 非 Windows 平台不探测、不导入、不加载 WebView2。
2. 每个 QtWebEngine 应用实例使用独立 profile/storage 目录,避免多个 Chromium 实例争用同一 profile。源码阶段目录仍位于项目 `data/` 范围;自动化测试使用临时目录。
3. 保持离线加载 `ui/web/` 资源;路径拼接必须兼容大小写敏感文件系统。
4. QtWebEngine 必须在 `QApplication` 创建前完成项目所需的导入和 Chromium 环境设置。
5. 保留现有软件渲染/GPU 配置入口。无效可选配置应输出明确警告并回落可启动默认值,不能让源码运行因可选渲染配置直接失败。
**Linux 源码运行(最小步骤,Ubuntu 22.04/24.04 x64):**
```bash
# 1) 依赖(pythonnet/clr_loader 带 sys_platform == "win32" markerLinux 自动跳过)
python3.10 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
sudo apt install -y libnss3 libxkbcommon0 libfontconfig1 libdbus-1-3 libgl1 libegl1 libasound2t64
# 22.04 无 t64 后缀,用 libasound2
# 2) 运行(普通用户;只走 QtWebEngine;每实例独立 profiledata/webengine/profile_<pid>_*
python3.10 main.py
# 3) 仅当 root/容器启动且页面空白时,才显式禁用沙箱(启动会打印高可见风险警告):
QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox" python3.10 main.py
# 4) 离屏自动化测试:
QT_QPA_PLATFORM=offscreen HAOCODE_RENDER=software QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu \
python3.10 tests/smoke_offscreen.py
```
实现锚点:`core/renderer_backend.py`(后端解析 / profile 目录 / sandbox 标志处理);`main.py` 在导入 PyQt6 前调用 `sanitize_chromium_flags``ui/views/main_window.py``sys.platform == "win32"` 门控 `core.webview2` 导入(非 Windows 永不导入,不触达 pythonnet/Win32/WebView2 DLL/taskkill);`CustomWebPage(profile, parent)` 接收本实例独立 `QWebEngineProfile`
### Chromium sandbox
Chromium sandbox 与 agent 命令权限是两件事。默认保留 Chromium sandbox,项目代码不得普遍追加 `--no-sandbox`。仅当运行者明确为 root/容器场景配置现有 `QTWEBENGINE_CHROMIUM_FLAGS`,且启动代码确认处于该场景时才接受该标志,同时输出高可见警告。普通 Windows/Linux 桌面运行不得关闭 sandbox。
## Shell 和进程生命周期
“bash 工具”保留产品名称,但执行契约按平台固定:
| 平台 | argv | `Popen` 要求 | 取消/超时 |
| --- | --- | --- | --- |
| Windows | `cmd.exe /d /s /c <command>` | `shell=False`;保持现有流式 stdout/stderr | 终止该命令的完整 Windows 进程树 |
| Linux | `/bin/bash -lc <command>` | `shell=False``start_new_session=True`;保持流式 stdout/stderr | 向进程组发 `SIGTERM`,短暂宽限后仍存活则发 `SIGKILL` |
实现必须满足:
- 取消、超时、窗口退出三条路径复用同一个幂等终止函数。
- Linux 以 `os.getpgid(proc.pid)` 定位本次命令进程组,不按进程名杀进程,也不遗留孙进程。
- 正常退出不进入强杀路径;进程已经结束时重复取消不抛出用户可见异常。
- `cwd`、环境变量、编码和分块输出保持现有工具语义。命令字符串不在 Python 中按 `;``&&` 或管道自行拆分。
- 平台 shell 名称进入诊断日志,避免把 Linux `/bin/sh` 误报成 bash。
## 系统提示词
只维护根目录一份 `SYSTEM_PROMPT.md`。每次请求继续重新读取公共正文,再由运行时生成一段短的平台信息并插入请求,不创建 Windows/Linux 两份完整提示词。
运行时段至少声明:
- 当前操作系统和 shellWindows `cmd.exe` 或 Linux `/bin/bash -lc`
- 路径格式和路径分隔符;Linux 文件名大小写敏感。
- 对应 shell 的环境变量、命令连接和引号规则。
- agent 没有额外 shell 沙盒或命令审批层,不要虚构这些能力。
平台段是请求构造的一部分,不写入会话历史,不参与压缩持久化。契约测试应验证公共提示词只有一份,且不同平台只改变运行时段。
## 配置和路径
1. 所有配置读取入口统一尊重 `HAOCODE_CONFIG_FILE`;测试在导入 UI/LLM 模块前把它指向临时文件。真实凭据文件按 [README.md](README.md) 的边界处理。
2. 所有自动化测试同时把默认数据库改到临时目录,不能依赖开发机已有数据库或附件。
3. 源码运行阶段继续使用项目内 `data/`。AppData、XDG Base Directory、安装器写入权限和配置迁移全部留到打包阶段。
4. 使用 `pathlib``os.path` 组合路径,不拼接平台分隔符;资源存在性检查覆盖 Linux 大小写差异。
5. WebView2 DLL、Windows 锁文件和 Windows 进程命令不成为 Linux 配置校验项。Linux portal/X11 依赖缺失也不能阻止不相关的聊天和 shell 功能启动。
## Linux 桌面集成
### X11
- 为现有 `Alt+S` 截图快捷键实现 X11 原生注册/注销,保持 `GlobalHotkeyThread` 对上层的信号语义。
- 注册失败时返回具体原因并保留窗口内截图按钮或应用内快捷键;不得后台忙等键盘状态。
- 屏幕像素获取走 X11 可用的原生抓屏路径;区域选择 UI 可以继续使用 Qt。
- 截图继续提供现有 `QImage` 返回语义,覆盖多显示器和负坐标桌面布局。
### Wayland
- 使用 `xdg-desktop-portal` 提供的桌面协议请求全局快捷键和截图,不使用 X11 API 假装支持 Wayland。
- portal 请求必须异步处理授权、拒绝、取消和桌面不支持四种结果;UI 只禁用受影响能力,主程序继续运行。
- 无法提供全局快捷键时给出明确、可操作的错误,不能静默降级成“看似注册成功”。窗口内按钮/快捷键仍按桌面允许范围工作。
- portal 返回的截图 URI/数据先完成有效性检查,再转换为现有 `QImage`/附件流程;临时文件生命周期由适配器负责。
会话类型优先综合 Qt 平台名和 `XDG_SESSION_TYPE` 判断,并记录最终选择。环境变量与实际 Qt 后端矛盾时,选择可证明可用的后端并输出警告。
> **实现状态(P1-042026-07-17,证据 `evidence/P1-04.md`**`ui/views/system_tools/desktop_session.py` 按上文判定顺序实现 `session_kind()`offscreen→unknownQT_QPA_PLATFORM/WAYLAND_DISPLAY/XDG_SESSION_TYPE 综合);X11 热键 = `x11_hotkey.py`ctypes→libX11 XGrabKey,同 `triggered` 信号语义,失败明确日志);Wayland 截图 = `portal_capture.py`xdg-desktop-portal Screenshot,系统 gdbus CLI,异步 worker,授权/拒绝/不支持/超时四态明确)。**Wayland 全局快捷键**依赖 compositor 桌面协议(ext-global-shortcut 等),本版本无免依赖实现 → 按上文“无法提供全局快捷键时给出明确、可操作的错误”处理(日志明确说明 + 保留应用内 Alt+S/按钮),不静默降级。真机验证待 Ubuntu 22.04/24.04 X11/Wayland 会话。
## 依赖范围
- `requirements.txt` 只加入源码确实导入的 Python 包,并使用平台 marker 隔离 Windows/Linux 专用依赖。
- Ubuntu 所需系统包另写安装说明,不把 apt 包名伪装成 pip 依赖。
- 不增加 WebKitGTK、打包器、安装器或与现有功能无关的桌面框架。
- 可选平台能力缺失时要局部报错;聊天、会话和 shell 的基本源码运行仍应可达。
## 验收矩阵
每行都要留下命令输出、日志或截图证据;人工桌面项不能用 offscreen 结果替代。具体测试分层见 [VERIFICATION.md](VERIFICATION.md)。
| 环境 | 必验操作 | 通过标准 | 证据 |
| --- | --- | --- | --- |
| Windows / WebView2 | 启动、流式聊天、弹层、截图、退出 | 选择 WebView2;弹层无已确认遮挡;退出不影响其他实例 | 后端日志 + 桌面截图 |
| Windows / 强制 QtWebEngine | 同一基本流程 | 不加载 WebView2;UI 和消息协议行为一致 | 后端日志 + 定向测试 |
| Windows / 第二实例 | 首实例占用 WebView2 profile 后启动第二实例 | 第二实例回落 QtWebEngine;首实例继续工作 | 双实例日志 |
| Ubuntu 22.04 X11 | 启动、聊天、`Alt+S`、区域截图、shell | 只使用 QtWebEngine;热键与截图成功;shell 为 bash | 日志 + 截图 + shell 测试 |
| Ubuntu 24.04 X11 | 同上 | 与 22.04 相同 | 日志 + 截图 + shell 测试 |
| Ubuntu 22.04/24.04 Wayland | portal 授权、拒绝、取消;截图和快捷键 | 授权路径可用;其余路径明确报错且主程序不退出 | portal 日志 + 桌面截图 |
| Linux 进程生命周期 | 命令创建子进程后取消和超时 | 父、子、孙进程全部退出;无同名进程误杀 | PID/进程组测试记录 |
| Windows 进程生命周期 | 命令创建子进程后取消和超时 | 本次命令树全部退出;其他进程不受影响 | PID 测试记录 |
| Windows/Linux 提示词 | 捕获请求构造结果 | 公共正文一致;shell/路径段与平台匹配;平台段不入历史 | 契约测试 |
| Windows/Linux 临时配置 | 使用临时配置和数据库运行 GUI smoke | 不访问项目真实配置/数据库;运行结束无生产数据变更 | 隔离断言 |
| Windows/Linux offscreen | 运行自动化 smoke | 可重复通过;明确不声称验证系统热键/截图 | 测试汇总 |
| root/容器 | 分别在未显式配置和显式配置下启动 | 默认保持 sandbox;显式 `--no-sandbox` 时打印警告 | 启动日志 |
平台任务完成条件:矩阵中该阶段承诺的每一行都有证据;自动化定向测试和阶段全量测试均通过;Linux 启动链没有 Windows 专用导入;实现没有引入 WebKitGTK、打包工作、通用钩子框架或新的 agent 权限模型。
+55
View File
@@ -0,0 +1,55 @@
# haocode 维修交接入口
> **执行状态**:当 `EXECUTION_STATE.md` 的状态为 `ACTIVE` 时,任何继续执行、上下文恢复或压缩恢复都必须先读取该文件。
本目录是 2026-09-16 之后的维修与跨平台工作的唯一交接入口。当前交付固化事实、决策、任务和验收方式;业务代码修复已按 `REPAIR_BACKLOG.md` 状态表推进(任务完成状态以该表和 `EXECUTION_STATE.md` 为准)。
精确实现仍以源码和可重复测试为准。若本文档与源码冲突,先记录证据,再修正文档;不要用旧文档覆盖已经核实的代码事实。
## 强制读取顺序
后续 Agent 必须从仓库根目录开始,按以下顺序读取:
1. `AGENTS.md`
2. `docs/agent-handoff/README.md`(本文件)
3. 根据任务类型,只读取下表对应的文档
| 触发条件 | 接着读取 | 用途 |
|---|---|---|
| 准备领取或实施修复任务 | `REPAIR_BACKLOG.md` | 任务顺序、允许范围、禁止事项、完成条件 |
| 涉及 Windows/Linux、渲染器、shell、热键或截图 | `PLATFORM_PLAN.md` | 已冻结的跨平台契约 |
| 涉及测试、诊断脚本或验收 | `VERIFICATION.md` | 分层测试矩阵与证据要求 |
| 需要了解目录、命名、模块边界或技术债 | `CURRENT_STATE.md` | 已核实的当前状态 |
不要从 `readme.md``Frame.md``ARCHITECTURE.md` 开始。这三份文件只能作为历史背景;其中的路径、打包说明和完成状态可能已经失效。第三方提供的《haocode 修复需求》也不是事实源,其中只有经源码核验并写入 `REPAIR_BACKLOG.md` 的内容有效。
## 永久操作约束
-`data/config.json` 视为不透明的本机密钥文件。不得打开、读取、搜索、打印、复制、修改或让它进入 Agent 上下文;任何递归内容搜索都必须排除它。
- 测试必须使用临时配置和临时数据库,并在导入 `MainWindow` 前完成重定向(统一用 `tests/_test_env.py``isolate()`)。自 P0-01 起所有运行时配置读取统一走 `core/config_paths``HAOCODE_CONFIG_FILE` 优先);少数 GUI 测试脚本(如 `smoke_offscreen.py`)自身不设置该环境变量,独立运行时由环境注入临时配置,P2-04 聚合入口将按子进程强制注入。
- 不初始化 Git,不伪造提交历史。任务按可独立提交的粒度编写,等仓库以后具备 Git 历史再逐项提交。
- 不引入 WebKitGTK。Windows 使用 WebView2(首选)或 QtWebEngine(回退);Linux 只使用 QtWebEngine。
- 不新增 Agent shell 沙箱、审批或路径边界;Chromium 渲染进程的 sandbox 保持默认开启。
- 不拆分或移动现有模块,不借修 bug 增加新产品功能。允许修改确认错误的源码,并新增聚焦测试、平台适配器、文档和配置样例。
- 本阶段以 Windows/Linux 源码运行正确为目标;PyInstaller、安装器、AppData/XDG 目录迁移和发行包是下一阶段。
- 不比较或移植 Claude Code、Codex、Grok Build、DeepSeek Harness。当前 `core/agent/` 继续保持 pi 的 Python 移植定位。
## 文档职责
- `CURRENT_STATE.md` 只记录已经从仓库核实的事实与结构/命名结论。
- `PLATFORM_PLAN.md` 是平台行为的唯一决策源。
- `REPAIR_BACKLOG.md` 是工作拆分和改动边界的唯一决策源。
- `VERIFICATION.md` 是测试命令、平台矩阵和验收证据的唯一决策源。
- `EXECUTION_STATE.md` 是无人值守执行与上下文压缩后的唯一恢复入口;任务权威状态仍以 `REPAIR_BACKLOG.md` 为准,执行证据放 `evidence/`
同一规则不要在多份文件中复制扩写。需要变更决策时,先修改其唯一归属文档,再更新这里的路由;不要在实现过程中悄悄改变范围。
## 领取任务
1.`REPAIR_BACKLOG.md` 选择一个未完成任务 ID。
2. 只读取该任务列出的源码和它引用的规范文档。
3. 先建立最小复现或失败测试,再修改允许范围内的文件。
4. 运行任务的聚焦测试;阶段结束时再运行 `VERIFICATION.md` 指定的完整自动化集合。
5. 记录实际命令、结果和平台证据。没有运行的测试必须明确写“未运行”,不能按通过处理。
任务完成的含义是:行为、回归测试、平台适用性和文档中的完成条件全部满足。只提交代码或只写说明都不算完成。
+548
View File
@@ -0,0 +1,548 @@
# 修复任务清单
本文是修复工作的任务源。每次只领取一个任务单元;任务完成后再进入下一个单元。精确实现以当前源码为准,本文约束修复范围和验收结果,不授权功能扩展。
验证方法与平台取证要求统一见 [VERIFICATION.md](VERIFICATION.md)。跨平台运行契约见 [PLATFORM_PLAN.md](PLATFORM_PLAN.md)。
## 执行边界
- 当前阶段只保证源码运行。安装包、冻结构建、AppData/XDG 目录迁移均不在本清单内。
- 保持现有模块位置,不拆分或移动 `ui/views/main_window.py``ui/web/app.js` 等大文件。可以新增测试、窄的平台适配器和配置路径辅助模块。
- 保持现有 Agent 能力和 `core/agent/` 与 pi 的行为,不增加工具注册、权限确认、命令沙盒或路径边界。
- 保留 Chromium sandbox。只有明确检测到 root/container 且用户显式选择时,才允许添加 `--no-sandbox`
- `data/config.json` 是不透明的运行时秘密。工作 Agent 不得打开、读取、搜索、打印、复制或修改该文件;所有自动化测试使用临时配置。
- 第三方修复文档只是线索。只有源码可复现的缺陷和本清单明确写出的行为才是修复依据。
- 当前快照没有 Git 历史。每个任务仍须保持可独立审查;以后接入 Git 时,一个任务对应一个提交。
## 顺序与依赖
| 顺序 | 任务 | 状态 | 依赖 |
|---|---|---|---|
| P0-01 | 配置路径与测试隔离 | 已完成(P0 阶段回归通过,2026-09-16;见 evidence/P0-01.md | 无,其他 GUI/配置测试的前置任务 |
| P0-02 | 合并重复的 `MainWindow.eventFilter` | 已完成(P0 阶段回归通过,2026-09-16;见 evidence/P0-02.md | P0-01 |
| P0-03 | 修复交接文档基线 | 已完成(2026-09-16) | 无;后续只做一致性复核 |
| P1-01 | 双向消息渲染窗口 | 已完成 | P0-01 |
| P1-02 | Windows/Linux shell 与进程树终止 | 已完成(Windows 侧自动化全绿,2026-07-09;见 evidence/P1-02.md | P0-01 |
| P1-03 | Windows/Linux 渲染器启动链 | 已完成(Windows 侧自动化 + offscreen 真实启动链全绿,2026-07-17;见 evidence/P1-03.md | P0-01、P1-02 |
| P1-04 | Linux 截图热键与截图实现 | 已完成(Windows 侧自动化全绿,2026-07-17X11/Wayland 真机待手动;见 evidence/P1-04.md | P1-03 |
| P2-01 | Bash 任务按启动时间倒序 | 已完成(offscreen 全量覆盖,2026-07-21;见 evidence/P2-01.md | P0-01 |
| P2-02 | 右侧 Bash 面板滚动条 | 已完成(offscreen 全量覆盖,2026-07-21;真机截图待对应环境跑 diag;见 evidence/P2-02.md | P2-01 |
| P2-03 | WebView2 原生窗口遮挡层 | 已完成(offscreen 34 项全绿,2026-07-21;真机 WebView2 人工验收清单见 evidence/P2-03.md | P0-02、P1-03 |
| P2-04 | 跨平台聚合测试入口 | 已完成(2026-07-17 | 新增 `tests/run_all.py`Windows 25/25 PASSWSL/3.12 FAIL 0 + 21 有理由 SKIP;暴露并修复 T9 陈旧断言与 `core/agent/compaction.py` 的 3.11+ dataclass 缺陷;证据 `evidence/P2-04.md` |
## P0-01 配置路径与测试隔离
**状态:已实施(定向测试通过)。** 新增 `core/config_paths.py`(统一入口,环境变量优先、容错加载、可见警告);`core/llm_engine.py``ui/views/main_window.py`webview_backend 与 init_model_popup 两处)、`ui/views/bash_panel.py` 全部改走统一入口;新增 `tests/_test_env.py` 统一临时环境与 `tests/test_config_isolation.py` 隔离回归。证据见 [evidence/P0-01.md](evidence/P0-01.md)。
**目标**
让所有配置读取方统一尊重 `HAOCODE_CONFIG_FILE`,并保证自动化测试在导入 GUI 前完成数据库和配置重定向。默认源码运行仍使用项目内 `data/`
**先读文件**
- `core/llm_engine.py`
- `ui/views/bash_panel.py`
- `ui/views/main_window.py` 中所有配置路径和配置读取函数
- `core/db_manager.py``_DEFAULT_DB` 的定义和初始化时机
- `tests/smoke_bash_panel.py`
- `tests/test_error_persist.py`
- `tests/test_agent_core.py` 中依赖 provider 配置的用例
只读源码中的路径引用,不读取 `data/config.json` 的内容。
**允许修改**
- 上述源码和测试。
- 可以新增一个只负责路径解析和容错加载的 `core/` 辅助模块,以及一个 `tests/` 临时环境辅助模块。
- 可以新增 `tests/test_config_isolation.py`
**硬约束**
- 环境变量优先级统一;没有环境变量时才回到项目内现有路径。
- 路径解析本身不得在 import 时输出、复制或迁移配置内容。
- 缺失或格式错误的配置必须产生可见警告,并使用现有安全默认值继续启动;不得吞掉错误,也不得因此阻断不需要该配置的源码路径。
- 测试必须先创建临时配置、设置 `HAOCODE_CONFIG_FILE`、重定向 `core.db_manager._DEFAULT_DB`,然后才能 import `MainWindow`
- 不修改真实配置,不引入配置迁移或新配置格式。
**目标测试**
```text
python tests/test_config_isolation.py
python tests/test_error_persist.py
python tests/smoke_bash_panel.py
python tests/test_agent_core.py
```
**完成证据**
- 测试用拦截器记录到的配置打开路径全部位于临时目录,且禁止路径从未被打开;此断言不得通过读取或散列真实配置完成。
- 临时配置的读写用例通过,临时数据库之外没有数据库写入。
- 缺失配置和损坏配置各有一个回归用例,日志包含明确警告,进程正常退出。
- 源码中不存在绕过统一路径解析的运行时配置读取。
## P0-02 合并重复的 `MainWindow.eventFilter`
**状态:已实施(定向测试通过)。** 两处定义合并为一份(位于 `init_chat_events` 前的「事件拦截」节);发送规则(按钮禁用 / 流式生成时 Enter 不发送)收进 `send_message(from_enter=...)` 单一实现,按钮点击的中断语义不变。证据见 [evidence/P0-02.md](evidence/P0-02.md)。
**目标**
修复同一类中后定义方法覆盖前定义方法的确定性缺陷,使 Enter、Shift+Enter、发送按钮状态和流式生成状态使用一套事件策略。
**先读文件**
- `ui/views/main_window.py``MainWindow.init_chat_events`、两处 `MainWindow.eventFilter``send_message``_update_send_button_state`
- 与输入框发送行为相关的现有 smoke 测试
**允许修改**
- `ui/views/main_window.py` 的事件过滤逻辑。
- 可以新增 `tests/test_main_window_event_filter.py` 或在现有离屏 smoke 中增加断言。
**硬约束**
- `MainWindow` 最终只能有一个 `eventFilter` 定义。
- Enter 只发送一次;Shift+Enter 放行换行;发送按钮禁用或当前会话正在流式生成时不得发送。
- 其他对象和事件必须继续交给父类,不顺带重构主窗口事件系统。
- 测试遵守 P0-01 的临时配置和临时数据库规则。
**目标测试**
```text
python tests/test_main_window_event_filter.py
python tests/smoke_offscreen.py
python tests/smoke_mode.py
```
**完成证据**
- 四种键盘状态均有断言:Enter 可发送、Enter 被禁用、流式时 Enter 被拦截、Shift+Enter 换行。
- 静态断言或 AST 检查证明 `MainWindow` 只有一个 `eventFilter`
- 测试中一次按键对应最多一次 `send_message` 调用。
## P0-03 修复交接文档基线
**状态:已完成。** 2026-09-16 无人值守轮次按本节标准复核:三项 `rg` 回归通过,交接目录与 `AGENTS.md` 相对链接全部有效;并随 P0-01/P0-02 结果更新了 README/CURRENT_STATE/VERIFICATION 中相应过时表述。保留本节作为后续文档变更的回归标准,不要重复搬运或重建交接目录。
**目标**
让后续 Agent 只从一条清晰入口读取当前事实,并把损坏或过时材料降级为历史资料。
**先读文件**
- `AGENTS.md`
- `ARCHITECTURE.md`
- `docs/agent-handoff/README.md`
- `docs/agent-handoff/CURRENT_STATE.md`
- `docs/agent-handoff/PLATFORM_PLAN.md`
- 本文和 `VERIFICATION.md`
**允许修改**
- 仅上述文档。
**硬约束**
- `AGENTS.md` 的入口指针必须覆盖修复、跨平台、测试、结构审查和工作交接五类触发场景。
- `ARCHITECTURE.md` 必须移除被拼接进来的第三方任务书正文,并在旧架构内容前明确标记“历史资料”;不得把旧目录树继续描述为当前事实。
- 当前支持矩阵、任务要求和验证规则分别只有一个权威位置,通过链接引用,不复制成多份。
- 不创建虚构的 Git 历史,不声称缺失的脚本或打包配置已经存在。
**目标测试**
```text
rg -n "agent-handoff/README.md" AGENTS.md
rg -n "历史资料|当前事实" ARCHITECTURE.md
rg -n "haocode 修复需求(团队任务书)" ARCHITECTURE.md
```
最后一条应无匹配;再逐一检查交接文档中的相对链接和文件路径存在性。
**完成证据**
- 新 Agent 按 `AGENTS.md` 指针能在一次跳转内到达 `docs/agent-handoff/README.md`
- `ARCHITECTURE.md` 没有拼接残片,且任何保留旧内容均带历史标记。
- 交接目录中没有互相冲突的支持矩阵、默认值或验收口径。
## P1-01 双向消息渲染窗口
**目标**
把前端 DOM 限制为严格的双向滑动窗口,同时保留完整当前会话链、附件、时间线、工具输出、分支切换和流式体验。
**先读文件**
- `ui/views/main_window.py``load_messages_to_web`、分支切换、删除/重答和 `_active_streams`
- `ui/views/chat_bridge.py`
- `ui/web/app.js` 中消息创建、`messageBuffer`、滚动、`clearChat`、流式完成和时间线回放
- `ui/web/index.html``ui/web/style.css`
- `core/db_manager.py``get_message_chain` 和分支查询
- `tests/test_math_extract.js``tests/smoke_timeline.py``tests/smoke_midswitch.py`
**允许修改**
- 上述 Python/JS/CSS 文件和相关测试。
- 可以新增一个 DOM 无关的 JS 窗口状态模块、`tests/test_render_window.js``tests/diag_render_scale.py`
- 可以在统一配置加载器中增加 `render_window_mode``render_window_size` 的解析。
**硬约束**
- Python 保留完整的当前会话可见消息链作为窗口数据源;本任务不改 SQLite 查询模型或数据库 schema。
- 初始窗口为最新 `size` 条。`size` 只接受非布尔整数 `10..200`,缺失、布尔、字符串、零、负数和越界值都静默回落 `40`。模式只接受 `auto`/`manual`,否则回落 `auto`
- `.message-wrapper` 数量始终不超过 `size`。流式消息计入上限;默认值下有一条流式消息时,最多保留另外 39 条。
- JS 以“方向 + 边界消息 ID”向 Python 请求页;Python 以单个批次返回完整消息描述。描述必须足以独立还原正文、reasoning、附件、时间线和工具结果、分支信息及必要的发送者信息。
- 请求与响应携带会话/代次标识;切会话、清屏或切分支后到达的旧响应必须丢弃。
- `messageBuffer` 只负责活动流,不得作为历史分页数据源。
- `manual` 模式向上只能点击“加载更早消息”;滚到顶部不得自动请求。`auto` 模式由顶部观察器自动请求,同时保留同一按钮。两个模式向下都自动恢复较新消息。
- 每个新流式 token 延续当前行为:立即回到底部并跟随活动消息。
- 向上换页使用“首个可见消息 ID + 像素偏移”恢复锚点,误差不超过 2 px。不得只按总高度差估算。
- 切换分支后重建链,并尽量让目标消息保持在同一视口位置;目标已不存在时回到底部。
- `clearChat()` 清除当前窗口的游标、缓存、DOM、未决请求和代次;已注入的配置模式和大小保持不变。
- 一次换页始终执行“加入一端、裁掉另一端”,并维护 `hiddenOlder``hiddenNewer`。上方没有更多记录时隐藏加载入口。
- 不引入 JSDOM、npm 工程或新的前端依赖。
**目标测试**
```text
node tests/test_render_window.js
python tests/diag_render_scale.py 400
python tests/smoke_offscreen.py
python tests/smoke_timeline.py
python tests/smoke_midswitch.py
python tests/test_file_attach.py
node tests/test_math_extract.js
```
`test_render_window.js` 必须直接测试 DOM 无关状态机;DOM/QWebChannel 集成由 Qt smoke 覆盖。
**完成证据**
- 参数化测试覆盖 `auto`/`manual`、10/40/200、全部非法配置类型、双向连续换页、首尾边界和过期响应。
- 固定 400 条夹具中 `.message-wrapper <= size`,并记录总节点数与页面高度;节点和高度阈值只对该固定夹具验收,见 `VERIFICATION.md`
- 附件消息、带工具时间线消息和多分支消息在被裁剪后再次加载,内容与控件完整。
- 有/无活动流两种情况下均满足严格上限;流式期间没有删除活动消息。
- 自动与手动模式分别有向上行为断言;两个模式都有无需点击的向下恢复断言。
- 锚点误差记录不超过 2 px;分支目标缺失的回底行为有断言。
- 帧耗时只形成真机人工基准报告,不作为自动化硬阈值。
## P1-02 Windows/Linux shell 与进程树终止
**状态:已完成。** 2026-07-09 无人值守轮次:新增 `core/platform_shell.py` 窄适配(Windows 字符串命令行 `cmd.exe /d /s /c "<cmd>"`、Linux `/bin/bash -lc` argv + 独立进程组;超时/中止整树终止);`SYSTEM_PROMPT.md` 单一通用正文 + `{{SHELL_PLATFORM_SECTION}}` 运行时短平台段。目标测试 20/20、30/30、35/35、41/41 全绿,smoke_mode 完整 agent 回合 ALL PASS。Linux 进程组用例(C3)在 Linux 环境运行时生效。证据见 `evidence/P1-02.md`
**目标**
明确工具 shell 契约,并保证超时、取消和异常清理能结束整棵子进程树。
**先读文件**
- `core/agent/tools.py` 中 bash 工具、`Popen`、超时和终止逻辑
- `core/llm_engine.py``load_system_prompt`
- `SYSTEM_PROMPT.md` 的 shell/path 说明
- `tests/test_bash_stream.py``tests/test_tool_params.py`
**允许修改**
- 上述文件和测试。
- 可以新增一个窄的平台进程适配模块及 `tests/test_cross_platform_shell.py`
**硬约束**
- Windows 明确通过 `cmd.exe` 执行;Linux 明确通过 `/bin/bash -lc` 执行,不依赖 `shell=True` 的平台默认值。
- Windows 使用现有等价机制终止进程树;Linux 创建独立 POSIX 进程组,超时和主动中止均向整组发送终止信号,并在宽限期后强制结束。
- 输出流、超时提示、截断上限和工具结果结构保持现有接口。
- 保留一份通用 `SYSTEM_PROMPT.md`;运行时只插入短的平台 shell/path 段。不得维护两份完整提示词。
- 提示词仍在每次请求时重读。Windows 段不得出现在 Linux 请求中,Linux 段不得出现在 Windows 请求中。
- 不增加命令审批、Agent shell sandbox 或路径限制。
**目标测试**
```text
python tests/test_cross_platform_shell.py
python tests/test_bash_stream.py
python tests/test_tool_params.py
python tests/test_agent_core.py
```
**完成证据**
- 平台参数测试捕获到 Windows 的 `cmd.exe` argv 和 Linux 的 `/bin/bash -lc` argv。
- Linux 用例启动父进程和孙进程,分别在超时与主动中止后证明两者都不存在;Windows 有等价进程树用例。
- 提示词测试模拟两个平台,证明只有对应平台段被插入且通用正文完全相同。
- 现有 bash 流式输出、返回码、超时和截断回归全部通过。
## P1-03 Windows/Linux 渲染器启动链
**状态:已完成。** 2026-07-17 无人值守轮次:新增 `core/renderer_backend.py` 窄适配(后端解析:非法/跨平台 `webview2` 可见警告 + 回落平台默认;每实例独立 QtWebEngine profile 目录:源码 `data/webengine/profile_<pid>_<ms>`、测试经 `HAOCODE_WEBENGINE_PROFILE_DIR` 重定向;`--no-sandbox` 仅显式 + root/容器时接受并打印高可见警告,普通桌面剥离);`main_window.py``sys.platform == "win32"` 门控 `core.webview2` 导入(Linux 永不触达 pythonnet/Win32/DLL/taskkill);`CustomWebPage(profile, parent)` 兼容旧式调用;`main.py` 导入 PyQt6 前 sanitize flagsrequirements 平台 marker + Linux 运行说明。目标测试 19/19、10/10、22/0、8/8、39 passed 全绿 + `main.py` offscreen 真实启动链验证;Linux 真机与 root/容器接受路径待对应环境。证据见 `evidence/P1-03.md`
**目标**
建立明确的渲染器矩阵:Windows 首选 WebView2、失败回落 QtWebEngineLinux 只使用 QtWebEngine。
**先读文件**
- `main.py`
- `ui/views/main_window.py` 中浏览器创建、JS 就绪和配置读取
- `core/webview2.py`
- `ui/views/wv2_view.py`
- `ui/views/custom_web_page.py`
- `requirements.txt`
- `tests/test_wv2_guard.py``tests/smoke_offscreen.py`
**允许修改**
- 上述启动链、依赖说明和测试。
- 可以新增小型平台检测/QtWebEngine profile 适配器和 Linux 源码运行说明。
**硬约束**
- Linux 路径不得导入或调用 pythonnet、Win32 API、WebView2 DLL 和 `taskkill`
- 不引入 WebKitGTK 或它的 Qt 封装。
- QtWebEngine 使用隔离 profile,两个并行源码实例不得争用同一个 Chromium profile。保持现有页面功能,不伪造 Linux 单实例锁。
- 非法 `webview_backend` 配置产生可见警告并回落平台默认值,不能阻断源码启动。
- 正常桌面运行保留 Chromium sandboxroot/container 的无 sandbox 路径必须显式选择并打印风险提示。
- `vendor/webview2/` 和根目录 `WebView2Loader.dll` 保持 Windows 运行时用途,不删除。
- 不添加 PyInstaller/spec 文件或打包承诺。
**目标测试**
```text
python tests/test_wv2_guard.py
python tests/smoke_offscreen.py
python tests/test_debug_window.py
node tests/test_math_extract.js
```
平台真实启动按 `VERIFICATION.md` 执行。
**完成证据**
- 平台模拟测试证明 Windows 的首选/回落路径和 Linux 的 QtWebEngine-only 路径。
- Linux import 测试不触达任何 Windows-only 符号。
- 两个 QtWebEngine 实例同时运行、载入本地页面和关闭,profile 无锁冲突或互相清理。
- Windows 真实 WebView2 和强制 QtWebEngine 回落各有一次启动记录;Linux X11/Wayland 各有一次 QtWebEngine 启动记录。
- 普通用户运行参数中不存在 `--no-sandbox`;显式 root/container 路径有单独证据。
## P1-04 Linux 截图热键与截图实现
**状态:已完成。** 2026-07-17 无人值守轮次:新增三个窄适配器——`desktop_session.py`(会话探测 win32/x11/wayland/unknown + 能力路由/明确不可用日志)、`x11_hotkey.py`ctypes→libX11 XGrabKey 原生全局热键,零 pip 依赖,注册失败/无显示明确日志,stop 释放)、`portal_capture.py`Wayland 经 xdg-desktop-portal Screenshot,系统 gdbus CLI,用户授权不绕过 compositorFilePicked→现有附件流程,拒绝/不支持/超时有明确结果);`main_window.py` 热键与截图按平台路由(Windows 行为保持);`screen_capture.py` 空画面守卫。目标测试 23/23、17/17、9 OK、8/8 全绿 + 回归 41/41 等全绿 + `main.py` offscreen 真实启动链;X11 真实注册/命中与 Wayland portal 三态待真实 Linux 宿主手动。证据见 `evidence/P1-04.md`
**目标**
把现有“截图全局热键”能力适配到 Linux,而不是扩展成通用键盘钩子系统。
**先读文件**
- `ui/views/system_tools/global_hotkey.py`
- `ui/views/system_tools/screen_capture.py`
- `ui/views/main_window.py` 中热键注册、截图启动和结果处理
- `main.py` 的平台启动逻辑
- 相关附件/图片测试
**允许修改**
- 上述文件和测试。
- 可以新增 Windows、Linux X11、Linux Wayland 的窄适配器;原入口保持稳定。
**硬约束**
- Windows 保持现有行为。
- Linux X11 使用原生全局快捷键和可行的原生屏幕捕获路径。
- Linux Wayland 使用 `xdg-desktop-portal` 或桌面协议完成快捷键和截图;遵守用户授权流程,不绕过 compositor。
- portal、桌面环境或协议版本不支持时,界面/日志必须明确说明当前能力不可用,主程序仍可聊天和使用其他功能。
- 只处理现有截图快捷键,不增加任意键监听、记录或重映射。
- 不做打包和发行版泛化;Ubuntu 22.04/24.04 x64 以外标记“未验证”。
**目标测试**
```text
python tests/test_global_hotkey_platforms.py
python tests/test_screen_capture_platforms.py
python tests/test_file_attach.py
python tests/smoke_offscreen.py
```
适配器单元测试使用替身;X11/Wayland 真实行为按 `VERIFICATION.md` 手工取证。
**完成证据**
- 平台路由、授权拒绝、portal 缺失和注册失败均有确定的回归测试。
- X11 真实桌面中,应用失焦时热键仍触发截图,图片回到现有附件流程。
- Wayland 真实桌面中,通过 portal/桌面协议完成授权、触发和截图;若目标桌面确实不支持,留下明确错误和环境信息,而不是伪造成功。
- Windows 现有热键和截图回归通过。
## P2-01 Bash 任务按启动时间倒序
**目标**
运行中和已完成两栏都让最新启动的任务位于第一项,任务从运行中移动到已完成时仍使用原启动顺序。
**先读文件**
- `ui/views/bash_panel.py``set_session``_refresh``on_started``on_finished``BashLayer` 和两个 section 类
- `ui/views/main_window.py``_on_tool_started`、输出/计时/完成转发
- `tests/smoke_bash_panel.py`
**允许修改**
- `ui/views/bash_panel.py`、必要的事件元数据传递和测试。
**硬约束**
- 排序键是启动时间/启动序号,降序显示;不得用完成时间重排。
- 已落库时间线没有显式时间时,使用消息链顺序和时间线内顺序构造稳定启动序号,不改数据库 schema。
- 任务从运行中进入已完成后,相对顺序由原启动键决定,不因结束先后跳位。
- 重排复用现有 `BashLayer` 实例;展开/折叠状态、实时输出、代码框水平/垂直滚动值、运行中栏和已完成栏的 section 滚动位置都保持。
- 保留现有限量显示和上下文标记语义。
**目标测试**
```text
python tests/smoke_bash_panel.py
python tests/test_bash_stream.py
```
**完成证据**
- 至少三项任务以不同启动/完成顺序运行,两个 section 均断言启动时间降序。
- 完成中间任务前后,对象 identity、展开状态、输出文本和四类滚动值保持。
- 切换会话后从 DB/活动流重建的顺序与实时期间一致。
**状态:已完成。** 2026-07-21 无人值守轮次:`bash_panel.py``_refresh()` 两栏显示改为启动序号降序(排序键 = `_order` 稳定启动序号,构造方式 = 消息链顺序 + 时间线内顺序,无时间戳、无 schema 变更;完成时间从不参与排序,`on_finished` 不移动 `_order` 位置);重排仍走 `set_layers` 复用同一批 `BashLayer` 实例,展开/折叠、实时输出、代码框滚动、栏滚动位置全部保持;限量窗口成员与提示语不变。`smoke_bash_panel.py` 新增第 11 节 23 项断言(交错完成顺序、对象 identity、四类滚动值保持、DB 重建一致性)140 项 ALL PASS`test_bash_stream.py` 30/30,回归 smoke_offscreen 8/8、run_tests 41/41 全绿。证据见 `evidence/P2-01.md`
## P2-02 右侧 Bash 面板滚动条
**目标**
只修复右侧 Bash 面板的原生滚动条和横纵滚动条交汇角,不污染其他 Qt 控件。
**先读文件**
- `ui/views/main_window.py` 中全局 QSS 的 `#right_sidebar``#bl_code`
- `ui/views/bash_panel.py``BashLayer._code_box` 和 section 滚动区
- Qt 当前版本的 `QAbstractScrollArea::corner`/`QPlainTextEdit::corner` 样式行为
- `tests/smoke_bash_panel.py`
**允许修改**
- 右侧面板的局部 QSS、相关测试和新建 `tests/diag_panel_scrollbar.py`
**硬约束**
- 保留现有 `#bl_code` 背景、边框、圆角和文本基础样式,只补滚动条与正确的 corner 规则。
- 所有选择器必须限定在右侧 Bash 面板或 `#bl_code`;不得添加无作用域的 `QScrollBar`/`QAbstractScrollArea` 全局规则。
- 横纵滚动条目标厚度为 8 px,隐藏箭头,handle 可见且 hover 正常;corner 与代码框背景一致。
- 使用 Qt 支持的 `QAbstractScrollArea`/`QPlainTextEdit` corner 子控件语法,不写 `QScrollBar::corner`
- 不借机调整其他弹窗、会话列表或全局字体。
**目标测试**
```text
python tests/smoke_bash_panel.py
python tests/diag_panel_scrollbar.py
```
**完成证据**
- 离屏测试断言横纵滚动条 sizeHint/实际厚度和箭头 extent,诊断脚本生成局部截图并打印测量值。
- Windows 与 Linux QtWebEngine 真机截图均显示无箭头、无亮色 corner 方块、handle 可辨识。
- 模型弹窗、会话列表、附件预览和调试窗口的滚动条与修复前一致。
**状态:已完成。** 2026-07-21 无人值守轮次:`main_window.py` 全局 QSS 中 `#bl_code` 规则后插入一段完全限定作用域的滚动条 + corner 规则(`QPlainTextEdit#bl_code QScrollBar:*``QPlainTextEdit#bl_code::corner { background-color: #fbfcfe; }``QScrollArea#bl_scroll QScrollBar:*`)——零无作用域规则;`#bl_code` 原有背景/边框/圆角/文本样式未动。新建 `tests/diag_panel_scrollbar.py`18 项断言:厚度/sizeHint/箭头 subControlRect=0/corner 像素 #fbfcfe/无亮白/未命名框仍原生 14px/附件区仍 6px18 项 ALL PASS`smoke_bash_panel.py` 140 项、回归 smoke_offscreen 8/8、run_tests 41/41、timeline 11/11、midswitch 7/7 全绿。截图落盘 `evidence/p2-02-*.png`。Windows/Linux 真机截图项:在对应环境直接运行 `diag_panel_scrollbar.py` 即可出图验证。证据见 `evidence/P2-02.md`
## P2-03 WebView2 原生窗口遮挡层
**目标**
修复改名遮罩被 WebView2 原生子窗口压住的确定性缺陷,并审计同类遮罩,只修复能确认的原生窗口遮挡。
**先读文件**
- `ui/views/main_window.py``AttachmentPreviewOverlay``RenameOverlay``SessionContextPopup``SettingsWindow`、所有以 `bg_widget` 或主窗口为 parent 的全窗口候选,以及 `_rename_session`
- `ui/views/wv2_view.py`
- `core/webview2.py` 中原生子窗口层级说明
- `tests/smoke_offscreen.py`
**允许修改**
- 上述遮罩实现和测试。
- 可以新增 `tests/diag_rename_overlay.py`;只有出现真实重复时才可加一个窄的几何同步辅助函数。
**硬约束**
- `RenameOverlay` 使用可覆盖原生 WebView2 的独立顶层透明窗口,覆盖主窗口客户区并保持卡片居中;系统标题栏和窗口控制仍可操作。
- 主窗口移动、缩放、最大化、还原和多显示器/DPI 变化时几何同步正确。
- 保留点击空白关闭、Esc、关闭按钮、输入框全选、Enter 提交和关闭后焦点恢复。
- 顶层窗口关闭后释放,不残留遮罩、事件过滤器或焦点捕获。
- 审计每个遮罩后只修复可复现的同类问题;普通 popup/dialog 不重写成统一框架。
- QtWebEngine 路径行为不得退化;真正的 WebView2 覆盖只能在 Windows 真机验收。
**目标测试**
```text
python tests/diag_rename_overlay.py
python tests/smoke_offscreen.py
python tests/smoke_copy_session.py
```
**完成证据**
- 结构测试证明遮罩是顶层 Tool 窗口、启用透明背景、覆盖主窗口客户区并可释放。
- Windows WebView2 真机截图显示聊天区与 Qt 区域均匀变暗,卡片在最上层且可交互。
- 拖动、缩放、最大化、还原、多 DPI 显示器至少各验证一次;记录覆盖误差。
- 遮罩审计表列出每个候选、是否受原生窗口影响、复现结果和处理决定。
**状态:已完成。** 2026-07-21 无人值守轮次:`RenameOverlay` 整类重写为独立顶层 `FramelessWindowHint|Tool` 透明窗(`WA_TranslucentBackground` + `WA_DeleteOnClose`)——只覆盖主窗口**客户区**`mapToGlobal(main.rect().topLeft())`+客户区尺寸,系统标题栏/窗口控制保持可操作),eventFilter 跟随 `Move`/`Resize`/`WindowStateChange`(最大化/还原/DPI 变化同路),保留点空白/✕/取消/Enter/输入全选,新增 Esc 关闭;关闭时 `removeEventFilter`+焦点回主窗+`deleteLater``_closed` 幂等。调用点 `_rename_session` 未改。审计:`AttachmentPreviewOverlay`/`SettingsWindow`/`SessionContextPopup`/`ModelSelectPopup`/`SessionModePopup`/`PdfModePopup` 均为独立顶层窗(不受影响),`RenameOverlay` 是唯一 bg_widget 子控件模式者(已修)。未抽公共辅助函数(不构成真实重复);未重写普通 popup/dialog。新建 `tests/diag_rename_overlay.py`(34 项:结构/客户区覆盖/跟随/全部关闭与提交行为/释放无残留)ALL PASS;目标测试 smoke_offscreen 8/8、smoke_copy_session ALL PASS;回归 smoke_bash_panel 140 项、run_tests 41/41 全绿(均 EXIT=0)。真机 WebView2 验收:`core/webview2.py` `get_environment()``taskkill /F /IM msedgewebview2.exe` 且共享 profile 不可重定向,无人值守自动化有意不走该路径;机制与生产已验证的附件预览层同构(拥有者顶层 Tool 窗 z 序必盖 WebView2 原生子 HWND),人工验收清单见 `evidence/P2-03.md`。证据见 `evidence/P2-03.md`
## P2-04 跨平台聚合测试入口
**目标**
提供一个可在 Windows/Linux 调用的自动化聚合入口,同时保留每个现有测试的独立运行方式。
**先读文件**
- `tests/run_tests.py`
- `tests/test_*.py``tests/smoke_*.py` 的入口和环境假设
- 本文各任务新增的测试
- `VERIFICATION.md`
**允许修改**
- `tests/run_tests.py`,或新增 `tests/run_all.py`
- 可以新增测试清单/分组元数据和必要的测试环境辅助模块。
- 可以更新交接测试文档。
**硬约束**
- 保留所有独立命令;聚合入口不得要求 pytest、npm 或网络。
- 每个测试使用独立临时目录;GUI 测试在 import `MainWindow` 前完成临时数据库与临时配置设置。
- 默认聚合不运行 `diag_*``verify_*``tune_*`、真实 API、真实桌面或需要凭据的脚本。
- 平台不适用项必须以明确 `SKIP` 和理由呈现,不能伪装通过;平台共同项失败时返回非零。
- 一个子测试崩溃或超时不能阻止结果汇总,最终退出码仍反映失败。
- 不读取真实配置,不访问网络,不删除用户运行数据。
**目标测试**
```text
python tests/run_all.py --group logic
python tests/run_all.py --group offscreen
python tests/run_all.py --group all
```
若选择扩展现有 `run_tests.py`,保持等价分组参数,并同步本文命令。
**完成证据**
- Windows 和 Linux 各有一份汇总,列出 PASS/FAIL/SKIP、耗时和失败命令。✅ Windows 25/25 PASSlogic 12 + offscreen 13,均 EXIT=0);WSL/CPython 3.12.3 全量 PASS 4 / FAIL 0 / SKIP 21(依赖缺失/平台不适用,理由逐条打印,EXIT=0)。
- 用一个故意失败的临时夹具证明聚合入口返回非零且仍汇总后续测试;夹具不提交。✅ 崩溃夹具→FAIL、超时夹具→TIMEOUT,后续健康测试照跑,退出码 1,完整汇总 + 失败命令清单;夹具用后即删。
- 默认运行记录证明未启动真实 API 测试、人工诊断脚本或真实桌面脚本。✅ 默认清单仅 25 条自动化条目;diag_*/verify_*/tune_*/real-DB/凭据类均在排除清单带理由(见 run_all.py 注释)。
- 各单文件命令仍可直接运行,输出与聚合子进程一致。✅ `test_copy_session.py` 独立 vs 聚合子日志尾逐字一致(54/54 PASS)。
## 整体验收终点
只有同时满足以下条件,本清单才完成:
1. 每个任务的目标测试通过,并留下该任务要求的证据。
2. `VERIFICATION.md` 的完整自动化回归在 Windows 和 Linux 通过;合理的平台专属项明确跳过。
3. Windows WebView2、Windows QtWebEngine 回落、Linux X11 QtWebEngine、Linux Wayland QtWebEngine 均完成真实桌面检查。(Windows 两项:✅ 2026-09-17 已验证,见 evidence/P1-03.mdLinux 两项:待 Ubuntu 桌面主机)
4. X11 与 Wayland 的截图热键按各自协议验证;不支持的 Wayland 环境留下明确错误证据。
5. 没有业务功能扩展、模块搬迁、打包变更、Agent sandbox 或真实配置访问混入修复。
+262
View File
@@ -0,0 +1,262 @@
# 验证与取证规范
本文定义修复任务的统一验证口径。任务范围和完成条件见 [REPAIR_BACKLOG.md](REPAIR_BACKLOG.md)。平台行为契约见 [PLATFORM_PLAN.md](PLATFORM_PLAN.md)。
## 测试前置条件
### 凭据与运行数据隔离
`data/config.json` 是不透明秘密。执行测试的 Agent 不得打开、读取、搜索、打印、复制或修改它,也不得用散列或快照方式“验证未变化”。采用以下正向隔离:
1. 为每个测试进程创建独立临时目录和最小临时配置。
2. 在 import 任意可能间接加载 `MainWindow` 的模块前设置 `HAOCODE_CONFIG_FILE`
3. 在 import `MainWindow` 前把 `core.db_manager._DEFAULT_DB` 指向临时数据库。
4. 写入、迁移、附件和截图产物只落到临时目录。
5. 用文件打开拦截器或替身断言禁止路径从未被访问,不读取禁止路径本身。
需要真实 API key 的 `diag_live_*``smoke_live_*` 由用户在受控环境手工决定是否运行;默认测试和工作 Agent 均不运行它们。
### GUI 环境
离屏测试只验证结构和一般 Qt 行为,不算真机视觉证据。
Windows PowerShell
```powershell
$env:PYTHONIOENCODING = 'utf-8'
$env:QT_QPA_PLATFORM = 'offscreen'
python tests/smoke_offscreen.py
```
Linux
```bash
PYTHONIOENCODING=utf-8 QT_QPA_PLATFORM=offscreen python tests/smoke_offscreen.py
```
真实桌面检查前移除 `QT_QPA_PLATFORM=offscreen`。不得用离屏截图替代 WebView2、X11、Wayland、portal、DPI 或滚动条真机检查。
### 结果记录
每个任务至少记录:
- 操作系统、桌面会话类型、Python、PyQt6/Qt 版本;
- 精确命令、退出码和测试摘要;
- 失败或跳过项及理由;
- 真机项的截图/录屏路径和复现步骤;
- 是否使用临时配置、临时数据库和离屏模式。
不要写固定“应通过 N 项”的文案;用退出码和当前测试自己报告的数量为准,避免测试增删后文档失真。
## 定向测试矩阵
每个任务先跑自己的定向测试。定向通过后才跑所在阶段的完整回归。
| 任务 | 必跑命令 | 额外人工证据 |
|---|---|---|
| P0-01 配置隔离 | `python tests/test_config_isolation.py``python tests/test_error_persist.py``python tests/smoke_bash_panel.py`test_agent_core(经 `python tests/run_tests.py` 运行) | 打开路径拦截记录只含临时目录 |
| P0-02 `eventFilter` | `python tests/test_main_window_event_filter.py``python tests/smoke_offscreen.py``python tests/smoke_mode.py` | Enter/Shift+Enter 行为记录 |
| P0-03 文档 | 文档链接检查与 `rg` 检查 | 从 `AGENTS.md` 演练一次读取路径 |
| P1-01 渲染窗口 | `node tests/test_render_window.js``python tests/diag_render_scale.py 400`;相关 Qt smoke | 400 条固定夹具、锚点误差、帧时间报告 |
| P1-02 shell | `python tests/test_cross_platform_shell.py``python tests/test_bash_stream.py``python tests/test_tool_params.py` | 两平台进程树消失证明 |
| P1-03 渲染器 | `python tests/test_wv2_guard.py``python tests/smoke_offscreen.py``node tests/test_math_extract.js` | Windows 两后端、Linux X11/Wayland 启动 |
| P1-04 热键/截图 | 平台适配单元测试、`python tests/test_file_attach.py` | Windows、X11、Wayland 各自真机行为 |
| P2-01 Bash 排序 | `python tests/smoke_bash_panel.py``python tests/test_bash_stream.py` | 重排前后状态与滚动位置 |
| P2-02 滚动条 | `python tests/diag_panel_scrollbar.py``python tests/smoke_bash_panel.py` | Windows/Linux 局部截图和尺寸 |
| P2-03 遮罩 | `python tests/diag_rename_overlay.py``python tests/smoke_offscreen.py` | Windows WebView2 遮罩截图/移动缩放录屏 |
| P2-04 聚合入口 | `python tests/run_all.py --group logic``--group offscreen``--group all` | Windows/Linux 汇总各一份 |
表中尚不存在的脚本属于对应任务的交付物,不得在创建前声称已经通过。
## P1-01 渲染窗口专项
### DOM 无关状态机
`tests/test_render_window.js` 直接加载纯状态机,不依赖浏览器、JSDOM 或 npm。至少覆盖:
- `auto``manual` 两种模式;
- `size` 为 10、40、200
- 缺失、`true`/`false`、字符串、0、负数、9、201 等值都回落 40;
- 初始最新页、连续向上、连续向下、首尾边界;
- 加一端时裁另一端,窗口始终不超过上限;
- 活动流计入上限,且不会被裁;
- 新 token 到来回到底部;
- `clearChat` 清游标/缓存/未决请求但保留配置;
- 会话/代次变化后丢弃旧响应;
- 分支目标保留和目标缺失回底。
### Qt/DOM 集成
Qt smoke 使用临时会话生成完整描述,至少包含普通消息、附件、reasoning、工具时间线、工具结果和多分支消息。用 QWebChannel 完成多轮双向换页后,逐项比较重建结果。
通用硬指标:任意时刻 `.message-wrapper <= render_window_size`。这条对所有夹具、所有窗口大小和流式状态都成立。
固定 400 条夹具的附加指标:
| 指标 | 门槛 |
|---|---|
| `.message-wrapper` | `<= render_window_size`,默认 `<= 40` |
| DOM 总节点 | `<= 4000` |
| 页面总高度 | `<= 30000 px` |
| 向上换页锚点误差 | `<= 2 px` |
总节点数和页面高度只对版本化的固定 400 条夹具验收。修改夹具必须在结果中说明,不得把这些数字套到任意内容长度。
### 帧时间
帧时间属于人工基准报告,不是自动化通过门槛。相同机器、相同窗口尺寸、相同 400 条夹具分别记录修复前/后:
- 流式追加一段固定文本期间的采样次数;
- frame duration 的 median、p95 和最大值;
- 是否发生肉眼可见停顿;
- 浏览器后端和 Qt 版本。
报告原始数值和测量方法,不把环境波动包装成确定性断言。
## 当前独立回归命令
在 P2-04 聚合入口完成前,按影响范围选择下列现有命令。每项任务不要求机械运行所有命令;一个阶段结束时运行完整集合。
```text
python tests/run_tests.py
python tests/test_config_isolation.py
python tests/test_main_window_event_filter.py
python tests/test_tool_params.py
python tests/test_compaction_persist.py
python tests/test_copy_session.py
python tests/test_bash_stream.py
python tests/test_error_persist.py
python tests/test_wv2_guard.py
python tests/test_debug_window.py
python tests/test_think_code_neutral.py
python tests/test_file_attach.py
python tests/test_pdf_reader.py
python tests/smoke_offscreen.py
python tests/smoke_mode.py
python tests/smoke_copy_session.py
python tests/smoke_bash_panel.py
node tests/test_math_extract.js
```
平台专属测试在不适用的平台明确 `SKIP`。任何共同逻辑测试失败都不能以平台差异豁免。
`tests/run_tests.py` 当前只覆盖其显式加载内容;在 P2-04 完成前,不能把它单独称为“全套测试”。
## 分阶段完整回归
### P0 完成后
运行全部纯逻辑测试和所有受影响的离屏 GUI 测试。重点证明测试不会访问真实配置/数据库,并且 `MainWindow` 输入行为没有回归。
### P1 完成后
运行当前独立回归命令全集,加上:
```text
node tests/test_render_window.js
python tests/test_cross_platform_shell.py
python tests/test_global_hotkey_platforms.py
python tests/test_screen_capture_platforms.py
```
随后完成 Windows/Linux 真实桌面矩阵。平台适配任务没有真实桌面证据时不能标记完成。
### P2 完成后
优先运行新聚合入口:
```text
python tests/run_all.py --group all
```
再单独运行三个诊断脚本;它们属于人工/半自动取证,不应混入默认聚合:
```text
python tests/diag_render_scale.py 400
python tests/diag_panel_scrollbar.py
python tests/diag_rename_overlay.py
```
## 真实桌面矩阵
### Windows 10/11 x64
至少覆盖以下路径:
1. WebView2 首选路径:启动、发一轮对话、流式输出、附件、分支、切会话。
2. QtWebEngine 强制回落:使用临时配置启动同一套基本流程。
3. 同时启动两个 QtWebEngine 实例,证明 profile 不争用。
4. WebView2 下打开改名遮罩,验证整个客户区覆盖;拖动、缩放、最大化和还原。
5. Bash 面板三项以上任务,验证最新任务在顶部、状态保持和滚动条视觉。
6. 截图全局热键在应用失焦时仍能捕获并进入附件流程。
证据必须标注实际后端。QtWebEngine 截图不能替代 WebView2 原生遮挡验收。
### Ubuntu 22.04/24.04 x64 + X11
至少使用 Python 3.10--3.12 范围内一个受支持版本完成:
1. `python main.py` 启动 QtWebEngine,渲染 Markdown、KaTeX、代码块和工具时间线。
2. 发一轮对话并执行 shell 工具,实际命令由 `/bin/bash -lc` 执行。
3. 超时和主动中止后检查父/孙进程均不存在。
4. 双开应用,两个 QtWebEngine profile 不冲突。
5. X11 原生全局截图热键在应用失焦时触发,截图进入附件流程。
6. 消息窗口、Bash 排序与滚动条完成一次真机检查。
### Ubuntu 22.04/24.04 x64 + Wayland
至少完成:
1. QtWebEngine 正常启动并完成基本对话与渲染。
2. portal/桌面协议请求有清晰的用户授权流程。
3. 全局快捷键和截图通过 portal/桌面协议完成;若当前桌面协议不支持,界面明确报错,应用其余功能继续可用。
4. 拒绝授权、portal 服务缺失和协议版本不足各记录一次结果。
5. 普通用户启动保持 Chromium sandbox。
Wayland 下不得用 X11 私有 API 或静默降级成“仅窗口内快捷键”并声称全局热键通过。
### 其他发行版
可以记录探索结果,但统一标记“未验证”,不能扩大官方支持矩阵。
## 人工 UI 检查
### Bash 排序与状态
按 A、B、C 顺序启动,按 B、A、C 或其他不同顺序结束。运行中栏和已完成栏始终按 C、B、A 的启动顺序显示。操作前先:
- 展开其中一个 layer
- 在输出框分别设置水平和垂直滚动位置;
- 滚动运行中栏和已完成栏。
任务状态迁移后,上述展开状态、输出、代码框滚动和两个 section 滚动均保持。
### Bash 滚动条
同时制造横向与纵向溢出,记录:
- 横纵滚动条实际厚度;
- 箭头区域是否消失;
- handle 是否可拖动并有 hover
- 横纵交汇处是否与代码框背景一致;
- 模型弹窗、会话列表、附件预览和调试窗口是否未受影响。
### 改名遮罩
Windows WebView2 下记录打开、移动、缩放、最大化、还原、提交和取消。截图必须包含整个主窗口,能比较聊天区与 Qt 控件区的遮罩亮度。结构性离屏断言只是补充。
## 聚合入口契约
P2-04 完成后的聚合入口必须:
- 使用 Python 子进程执行现有独立测试,不把所有测试 import 到同一进程;
- 为每个子进程建立独立临时环境;
- 支持至少 `logic``offscreen``all` 三组;
- 汇总命令、退出码、耗时和 PASS/FAIL/SKIP
- 默认排除 live、diag、verify、tune、网络和凭据测试;
- 一个子进程失败后继续收集其余结果,最终返回非零;
- 在 Windows 和 Linux 使用同一 Python 接口,不嵌入 `.bat` 或 Bash 专属命令串。
## 最终判定
一项修复只有在以下内容齐全时才算完成:定向测试通过、阶段完整回归通过、该平台需要的真实桌面证据齐全、所有跳过项有理由、真实配置和数据库从未被测试访问。任何一项缺失都应标记为“未完成”而不是“基本完成”。
+45
View File
@@ -0,0 +1,45 @@
# P0-01 配置路径与测试隔离 — 执行证据
日期:2026-09-16(无人值守轮次)
平台:Windows 11 10.0.26200 x64 · Python 3.10.21`.venv`)· PyQt6/Qt 6.10.0/6.10.2 · 离屏 `QT_QPA_PLATFORM=offscreen`
## 根因(源码确认)
1. `core/llm_engine.py` 模块常量 `CONFIG_PATH` + `_load_config()` 不读 `HAOCODE_CONFIG_FILE` → 三个 Qt workerAgent/Chat/Title)直接读项目内真实配置。
2. `ui/views/main_window.py:3133`webview_backend 分支)与 `:5244``init_model_popup`)直接 `open(data/config.json)`,绕过环境变量。
3. `ui/views/bash_panel.py` 有私有的环境变量解析(双入口,非统一)。
4. `core/db_manager._DEFAULT_DB` 为模块级全局,测试在 import 前重定向的既有模式成立,沿用。
## 修复摘要
- 新增 `core/config_paths.py``config_path()``HAOCODE_CONFIG_FILE` 优先、调用时解析)+ `load_config()`(缺失/损坏/非对象 → 可见警告 + 安全空 dict,不抛异常)。
- `core/llm_engine.py`:删除 `CONFIG_PATH` 常量;`_load_config()` 委托 `load_config()`(保留函数名兼容既有调用方)。
- `ui/views/main_window.py`:两处直接 open 改走 `core.config_paths.load_config`
- `ui/views/bash_panel.py``_cfg_path()` 委托统一 `config_path()`,删除私有 `_CFG_PATH` 常量。
- 新增 `tests/_test_env.py``isolate(tag, config)` 统一创建临时配置 + 临时数据库并在 import MainWindow 前完成重定向。
- 改造 3 个在范围测试使用统一临时环境:`tests/smoke_bash_panel.py``tests/test_error_persist.py``tests/test_agent_core.py`(provider 用例改读临时配置中的 `testprov`,不再依赖真实配置)。
- 新增 `tests/test_config_isolation.py`open/sqlite 拦截器 + 缺失/损坏/非对象回归 + AST 静态扫描。
## 定向测试(命令 / 退出码 / 结果)
| 命令 | 退出码 | 结果 |
|---|---|---|
| `python tests/test_config_isolation.py` | 0 | ALL PASS14 项断言) |
| `python tests/test_error_persist.py` | 0 | 39 PASS(与交接基线 39 一致) |
| `python tests/smoke_bash_panel.py` | 0 | 116 PASS(与交接基线 116 一致) |
| `python tests/run_tests.py`test_agent_core.py 的仓库标准运行方式,pytest 由 harness stub | 0 | 41 passed, 0 failed(与交接基线 41 一致) |
注:`python tests/test_agent_core.py` 直接运行在本仓库不可用(文件无独立 runner 且 .venv 不装 pytest,见 requirements.txt 说明),按其设计经 `tests/run_tests.py` 运行;P2-04 聚合入口将统一固化该运行方式。
## 完成证据对应
- **拦截器证明**MainWindow 构造 + `save_panel_width` 写回全程,所有 `config.json`(含原子写 `.tmp`)打开路径均位于 `tempfile.gettempdir()/haocode_test_cfgiso_<pid>/`;真实配置路径(仅以字符串比较)从未出现在打开记录中。未读取、未散列真实配置。
- **临时配置读写**`llm_engine._load_config()` 读到 `testprov``save_panel_width(340)``load_panel_width() == 340`,写路径落临时目录。
- **数据库隔离**sqlite3.connect 拦截记录中临时库之外零连接/写入。
- **缺失/损坏/非对象**:三个回归用例均返回 `{}` 且 stdout 含明确警告(`[config] 配置文件缺失/读取/解析失败/不是 JSON 对象`),进程正常退出。
- **静态扫描**`core/``ui/``tools/``main.py` 中除 `core/config_paths.py` 外不存在 `config.json` 字符串字面量(AST 级,docstring/注释排除)。
## 观察项(未扩范围,留待后续)
- `tests/smoke_offscreen.py``smoke_mode.py``smoke_copy_session.py` 只重定向了数据库、未设置 `HAOCODE_CONFIG_FILE`(不在 P0-01 允许修改清单内)。本轮运行这些套件时在启动环境显式导出临时配置;P2-04 聚合入口将按子进程强制注入临时环境,彻底闭环。
- `tests/diag_live_agent.py:19``tests/tune_model_popup.py:155` 直接引用真实配置路径;二者属 live/tune 人工脚本,默认聚合排除。
+56
View File
@@ -0,0 +1,56 @@
# P0-02 合并重复的 `MainWindow.eventFilter` — 执行证据
日期:2026-09-16(无人值守轮次)
平台:Windows 11 10.0.26200 x64 · Python 3.10.21`.venv`)· PyQt6/Qt 6.10.0/6.10.2 · 离屏 `QT_QPA_PLATFORM=offscreen`
## 根因(源码确认)
- `MainWindow` 类体内定义了两个 `eventFilter`(行 3697 与 3763):后定义者覆盖前者,前者的
`_active_streams` 守卫是死代码。
- 生效版本(3763)用 `btn_send.isEnabled()` 做守卫,而 `set_send_button_state` 只切换
图标、从不禁用按钮 → 守卫恒真 → 流式生成中按 Enter 会落入 `send_message` 的中断路径
(触发停止),与注释声称的「生成时按回车无效,防止误触」相反。
## 修复摘要(仅 `ui/views/main_window.py` 事件过滤逻辑)
- 删除行 3763 的重复 `eventFilter`(及其后不可达的两行过期分节注释)。
- 保留行 3697 处为 `MainWindow` 唯一 `eventFilter`Enter(无 Shift)→ `send_message(from_enter=True)`
并消费事件(一次按键至多一次调用);Shift+Enter → 返回 False 放行换行;其他对象/事件交父类。
- `send_message(self, from_enter: bool = False)`:函数顶部为发送规则单一实现:
1) `btn_send` 禁用 → 一律不发送;
2) `from_enter=True` 且当前会话在 `_active_streams` → 直接返回(Enter 不参与停止/中断语义);
3) 按钮点击路径行为完全不变(流式中点击 = 原有红色停止按钮中断语义,含 Fix B/C)。
- `_update_send_button_state` 未改(其语义与规则一致)。
## 定向测试(命令 / 退出码 / 结果)
| 命令 | 退出码 | 结果 |
|---|---|---|
| `python tests/test_main_window_event_filter.py` | 0 | ALL PASS18 项断言) |
| `python tests/smoke_offscreen.py` | 0 | ALL PASS: 8/8 |
| `python tests/smoke_mode.py` | 0 | ALL PASS |
| 回归 `python tests/test_config_isolation.py` | 0 | ALL PASS14 项) |
| 回归 `python tests/test_error_persist.py` | 0 | 39 PASS |
| 回归 `python tests/smoke_bash_panel.py` | 0 | 116 PASS |
注:`smoke_offscreen.py` / `smoke_mode.py` 自身只重定向数据库、未设置 `HAOCODE_CONFIG_FILE`
(不在 P0-02 允许修改清单内)。本轮运行时在进程环境显式导出指向临时配置的
`HAOCODE_CONFIG_FILE`;P2-04 聚合入口将按子进程强制注入临时环境,彻底闭环。
## 完成证据对应(test_main_window_event_filter.py
- **AST 静态断言**:解析 `ui/views/main_window.py``MainWindow` 类体内 `eventFilter` 定义恰好 1 个(行 3697);
- **Enter 可发送**A1–A5):空闲 + 按钮可用 + 有文本 → 一次 Enter 恰好一次 `send_message(from_enter=True)`
调用(计数 wrapper 包住真实实现),流同步注册、输入框清空、错误路径自清理;
- **Enter 被禁用**B1B3):`btn_send.setEnabled(False)` → 至多一次调用且无流、输入内容保留(规则在 `send_message` 内生效);
- **流式时 Enter 被拦截**C1C3):注入假流 → Enter 后假流对象未被替换、字段未被改动(未触发中断)、输入保留;
- **Shift+Enter 换行**D1–D3):零调用,事件放行到输入框(光标处插入 `\n`)、无流;
- **其他键/事件交父类**(E1–E2):按 `a` 正常插入字符、零发送调用;
- **一次按键至多一次调用**:A/B/C/D 各用例均以调用计数断言(全部 ≤1 且语义正确)。
## 行为变化说明
- 流式生成中按 Enter:旧(生效)代码会触发停止/中断;新代码 no-op(Enter 只管发送,
停止只走按钮)。这与被覆盖版本注释中声明的原始意图(「生成时按回车无效,防止误触」)
和 P0-02 硬约束(「流式生成时不得发送」)一致,属本任务预期的确定性修复。
- 发送按钮点击路径(含流式中点击 = 中断):逐行未动。
@@ -0,0 +1,42 @@
# P0 阶段完整回归 — 执行证据
日期:2026-09-16(无人值守轮次)
平台:Windows 11 10.0.26200 x64 · Python 3.10.21`.venv`)· PyQt6/Qt 6.10.0/6.10.2 · Nodetest_math_extract
运行方式:全部子进程统一注入 `HAOCODE_CONFIG_FILE` 指向临时配置(`smoke_offscreen/smoke_mode/smoke_copy_session` 自身不设该变量,属 P2-04 前已知观察项);GUI 套件 `QT_QPA_PLATFORM=offscreen`
## 结果(命令 / 退出码 / 摘要)
纯逻辑(13 个套件,全部 EXIT=0):
| 套件 | 摘要 |
|---|---|
| `python tests/run_tests.py` | 41 passed, 0 failed |
| `python tests/test_tool_params.py` | ALL PASS |
| `python tests/test_compaction_persist.py` | ALL PASS |
| `python tests/test_copy_session.py` | ALL PASS |
| `python tests/test_bash_stream.py` | ALL PASS |
| `python tests/test_error_persist.py` | ALL PASS39 项) |
| `python tests/test_wv2_guard.py` | ALL PASS |
| `python tests/test_debug_window.py` | 22 PASS / 0 FAIL |
| `python tests/test_think_code_neutral.py` | ALL PASS |
| `python tests/test_file_attach.py` | OK |
| `python tests/test_pdf_reader.py` | OK |
| `python tests/test_config_isolation.py` | ALL PASS14 项,P0-01 新增) |
| `python tests/test_main_window_event_filter.py` | ALL PASS18 项,P0-02 新增) |
离屏 GUI(4 个套件,全部 EXIT=0):
| 套件 | 摘要 |
|---|---|
| `python tests/smoke_offscreen.py` | ALL PASS: 8/8 |
| `python tests/smoke_mode.py` | ALL PASS |
| `python tests/smoke_copy_session.py` | ALL PASS |
| `python tests/smoke_bash_panel.py` | ALL PASS116 项) |
JS1 个套件,EXIT=0):`node tests/test_math_extract.js` — 39 passed, 0 failed。
## 判定
P0 阶段回归通过:测试不访问真实配置/数据库(拦截器证明 + 临时环境),`MainWindow`
输入行为无回归(P0-02 四态断言 + 既有 116 项 bash 面板回归)。真实桌面矩阵属
P1/P2 阶段(PLATFORM_PLAN 验收矩阵),本阶段不声称桌面已验证。
+90
View File
@@ -0,0 +1,90 @@
# P1-01 双向消息渲染窗口 — 完成证据
**状态**: COMPLETE
**完成时间**: 2026-07-21(会话时间)
**环境**: Windows 11 x64, CPython 3.10.21 (.venv, uv), PyQt6 / Qt 6.10.0, Node
## 设计
**数据流(引擎无关)**
- JS → Python`bridge.onRequestWindowPage(sessionId, direction, boundaryId, generation)`
QtWebChannel slot 与 WebView2 postMessage 白名单同名,单一实现)。
- Python → JS`run_js("rwPageResponse(<json>)")` / `rwInitWindow(<json>)` / `rwNoteLive(...)` / `rwBegin(...)` / `rwConfig(...)`
- JS 端只做"窗口游标 + DOM 搬移"Python 用既有逐消息 bridge 调用渲染 DOM
JS 状态机 `ui/web/render_window.js` 跟踪 `order`(消息 id 序列)+ `indexById`(链内下标),
不解析消息内容。
**关键参数**
- 窗口 = `render_window_size`10/40/200,非法静默回落 40mode `auto`/`manual` 回落 auto)。
- **页 = 半窗**`max(1, size//2)`)。若页 = 整窗,"首个可见消息"锚点必然被裁出窗口,
需求中的锚点恢复(≤2px)永远不可达——诊断实测验证了这一点后才改为半窗。
- 活动流式消息受保护(`trimHead`/`trimTail` 跳过 `activeStreamId`),计入上限;
`streamFinished` 解除保护。`rwNoteLive` 经 DOM `.streaming` 类自动接管流式保护,
并携带实时 `chainLen` 维持 hidden 计数新鲜。
**滚动语义**
- 批次渲染期间 `window.__rwPageRendering = true`,抑制 `softScroll()`
`finishMessage``scrollIntoView`
- **守卫在调度时刻捕获**`var rwBatchSuppressed = !!window.__rwPageRendering` 后立即
rAF+50ms 延迟回调):延迟回调触发时批次已结束、标志已被 Python 复位,届时再读会漏放
`scrollIntoView(smooth)` → 平滑滚底 → `rwAutoCheck` 误判贴底 → 触发 'newer' 反向换页振荡。
这是诊断中 `scrollY 0→4367` 振荡的根因,已修复。
- 向上换页锚点恢复:`scrollTop = oldScroll + (anchor.newTop - anchor.docTop)`
绝对顶部(`oldScroll <= 1`)例外:停在 0 露出新页;auto 模式沿顶部 60ms 链式补页。
- 向下换页两模式均自动恢复(`isNearBottom() && canRequest('newer')`)。
- 过期响应(会话/代次不匹配、pending 已被新请求替换、重复投递)整批丢弃,已渲染 DOM 回滚。
**生成代次(generation**
- Python `MainWindow._rw_generation` 为唯一权威源:每次 `load_messages_to_web` / 新建会话 +1。
- JS `clear()` 本地防御性 +1,Python 下次推送重新同步。
## 文件变更
| 文件 | 变更 |
|---|---|
| `core/config_paths.py` | 新增 `render_window_settings()`16 类配置归一化)、`DEFAULT_RENDER_WINDOW_SIZE``ALLOWED_RENDER_WINDOW_SIZES` |
| `ui/web/render_window.js` | 新增:DOM 无关状态机(id+chainIndex 模型,双遍 recompute |
| `ui/web/app.js` | `rwState`/`rwInitWindow`/`rwPageResponse`/`rwApplyOlder`/`rwApplyNewer`/`rwNoteLive`/`rwRequestPage`/`rwAutoCheck`/`rwCaptureAnchor`/`rwRemoveMessageDom`/`rwEnsureLoadButtons`/`rwUpdateLoadButtons``clearChat` 联动状态机清空;`finishMessage` 步骤 C 调 `streamFinished`、步骤 F 守卫捕获式;`createMessage`/`createLongMessage`/`createUserMessageWithAttachments``softScroll` 守卫;scroll 监听挂 `rwAutoCheck` |
| `ui/web/index.html` | 引入 `render_window.js`(先于 app.js);WebView2 shim 增加 `onRequestWindowPage` |
| `ui/web/style.css` | `.load-window-btn` 样式(含 `[hidden]` 规则) |
| `ui/views/chat_bridge.py` | 信号 `window_page_requested`、slot `onRequestWindowPage`、推送方法 `rw_config`/`rw_begin`/`rw_init_window`/`rw_note_live`/`rw_page_response` |
| `ui/views/wv2_view.py` | `_BRIDGE_METHODS` 白名单加 `onRequestWindowPage` |
| `ui/views/main_window.py` | `init_browser` 窗口状态初始化 + 信号连接;`_on_js_ready_checked` 一次性 `rwConfig` 推送;`load_messages_to_web` 窗口化重写(代次+1、`rwBegin`、最新 size 条窗口渲染、`rwInitWindow`、流式恢复保留);新增 `_rw_visible_chain`/`_rw_note_live`/`_render_history_one`/`_on_window_page_request`(边界缺失安全降级空页);`on_new_chat_clicked` 代次+1;发送/重答/完成 5 处 `_rw_note_live` 挂点 |
| `tests/test_render_window.js` | 新增:状态机 Node 测试 |
| `tests/diag_render_scale.py` | 新增:400 条链 offscreen 规模诊断(真实 viewportresize+show+等待 innerHeight>0+显式重载) |
| `tests/smoke_timeline.py``tests/smoke_midswitch.py` | 转换到 `tests/_test_env.isolate()`(临时 DB+临时配置,P0-01 铁律) |
| `tests/_probe_rw.py` | 调试探针(保留,供后续排障) |
## 验证(全部 EXIT=0,全部带显式超时执行)
| 套件 | 结果 |
|---|---|
| `node tests/test_render_window.js` | **424/424 PASS**(配置归一化 16 例、auto/manual×10/40/200 初始窗口、双向连续换页 39 页/向、短链、过期响应 4 类、活动流保护、clear 语义、锚点几何、原子性、noteLive) |
| `python tests/diag_render_scale.py 400`offscreen | **6/6 PASS**:初始窗口=最新 40 条(链长 400);中部锚点保持(误差 ≤2px、无 newer 振荡);自顶部向上分页至头部(绝对顶部例外);头部状态+全链 400 条可达无重复;自顶部向下回翻 2 页;auto 模式顶部自动补页。**锚点误差 0.00px**18 页 @ 169/183/204 msmin/avg/max);DOM 节点 10571080;页面高度 71277213 px |
| `python tests/smoke_offscreen.py` | 8/8 ALL PASS |
| `python tests/smoke_timeline.py` | 11/11 ALL PASS(流式/历史路径,含 streaming 类收尾) |
| `python tests/smoke_midswitch.py` | 7/7 ALL PASS(切走切回时间线完整) |
| `python tests/test_file_attach.py` | 9 tests OK |
| `node tests/test_math_extract.js` | 39/39 PASS |
| 回归:`smoke_bash_panel` / `test_config_isolation` / `test_main_window_event_filter` / `smoke_mode` / `test_error_persist` | 全部 ALL PASS |
| `python tests/run_tests.py`agent core 规范入口) | **41/41 PASS** |
## 诊断过程记录(问题 → 根因 → 修复)
1. **`rw_init_window` 链下标偏移**:初始窗口传入局部下标 0..39 而非链下标 → `hiddenOlder` 恒 0。
修复:`offset = total - len(window_items)`,传 `offset + i`
2. **offscreen 零视口**:未 `resize`+`show``innerHeight=0`,锚点几何全废。
修复(诊断侧):`window.resize(1400,950)` + `show()` + 等待 `innerHeight>0` + 显式 `load_messages_to_web` 重载。
3. **`runJavaScript` 不能返回 DOM 元素**:回调转换失败 → 用 `cond ? 1 : 0` / 原语返回值。
4. **分支兄弟偷叶**:链尾补兄弟消息使 `add_message` 自动改叶 → 链被截断。
修复(夹具):兄弟消息在循环内 `i==298` 处插入。
5. **页 = 整窗导致锚点必被裁**(设计缺陷):半窗页修复(见"关键参数")。
6. **`finishMessage` 守卫延迟求值 → 滚底 → 'newer' 振荡**(见"滚动语义"第 2 条)。
7. **中部换页落点贴底**:15% 视口位置向上换页后锚点落 65%(不贴底);底部半窗换页本身会
落向底部属半窗几何固有——真实入口(顶部"加载更早消息"按钮 / auto 顶部观察器)不触发该位置,
且落底后自动 'newer' 恢复符合"向下自动恢复"需求。
## 已知观察项(不在本任务范围)
- `diag_render_scale` 的每页耗时(~180ms)只作真机基准参考,非硬阈值(符合任务要求)。
- 帧耗时真机人工基准报告留待人工验收环节。
+74
View File
@@ -0,0 +1,74 @@
# P1-02 证据:Windows/Linux shell 与进程树终止
日期:2026-07-09(无人值守轮次)
状态:**完成(Windows 侧自动化全绿;Linux 侧逻辑已实现并单测覆盖参数/提示词,进程组用例在 Linux 上运行时生效)**
## 目标(摘自 REPAIR_BACKLOG.md
- Windows 明确通过 `cmd.exe` 执行;Linux 明确通过 `/bin/bash -lc` 执行,不依赖 `shell=True` 的平台默认值。
- 超时与主动中止都终止完整子进程树(Windows `taskkill /F /T`Linux 独立 POSIX 进程组,SIGTERM→宽限→SIGKILL 整组)。
- 只保留一份通用 `SYSTEM_PROMPT.md`,运行时插入**短**平台 shell/path 段;两平台互不串段。
- 保留输出流、超时、截断、工具结果结构;不加命令审批/沙箱/路径限制。
## 改动文件
| 文件 | 改动 |
|---|---|
| `core/platform_shell.py` | **新增**窄平台适配:`shell_command()``popen_flags()``kill_process_tree()``shell_prompt_section()``apply_platform_section()`、占位符 `{{SHELL_PLATFORM_SECTION}}` |
| `core/agent/tools.py` | `tool_bash` 的 Popen 改 `shell_command(command) + popen_flags()``_kill_tree` 委托 `kill_process_tree`;移除 `ctx["shell"]` 隐式开关 |
| `core/llm_engine.py` | `load_system_prompt()` 读文件后过 `apply_platform_section()`(每次请求仍重读,既有行为不变) |
| `SYSTEM_PROMPT.md` | 通用正文化:第 1 节去 Windows 路径/conda 环境名;原 1.1「shell 真相」cmd 表整体移入运行时 Windows 段;工具表与 3.2 去掉 `cmd.exe`/`dir`/`findstr` 字样;占位符落在原 1.1 位置 |
| `tests/test_cross_platform_shell.py` | **新增** 20 项断言(A 平台参数 / B 提示词 / C 进程树 / D 安全边界) |
## 关键设计决策
1. **Windows 用字符串命令行,不用 argv 列表。**
`["cmd.exe","/d","/c",cmd]` 列表形态会被 CPython `list2cmdline` 把内部引号转义成 `\"`cmd 不认,
带引号路径直接 `'...\python.exe"' is not recognized`(实测复现)。最终契约:
`cmd.exe /d /s /c "<command>"` 作为**字符串**交给 CreateProcessWcmd 按 /s 规则解析 /c 参数
(外层引号剥离、内部引号保留)。实测矩阵:引号 Python 路径 `rc=0``echo a && echo b` 正确;
`%USERPROFILE%` 展开;`;`/单引号行为与旧文档一致。
注:旧 `shell=True` 之所以能跑,是因为 CPython 对带引号程序名直接 CreateProcess(不经 cmd);
新契约统一显式过 cmd,行为更可预测且与提示词一致。
2. **POSIX 安全不变量**`kill_process_tree` 仅当 `os.getpgid(pid) == pid`(确认 `start_new_session`
生效、子进程是组首)才 `killpg`,否则退化单进程 `kill`,绝不误杀调用方所在组。
流程:SIGTERM 整组 → 轮询至 `grace_s=3.0s` → SIGKILL 整组。
3. **提示词单一来源**`SYSTEM_PROMPT.md` 唯一;`apply_platform_section` 只做占位符替换,
无占位符(兜底提示词)原样返回。`load_system_prompt()` 每请求重读 → 平台段永远对应当前平台。
## 验证结果(本机 Windows 11 x64CPython 3.10.21,全部显式超时)
| 命令 | 结果 |
|---|---|
| `python tests/test_cross_platform_shell.py` | **20/20 PASS**EXIT=0 |
| `python tests/test_bash_stream.py` | **30/30 PASS**EXIT=0 |
| `python tests/test_tool_params.py` | **35/35 PASS**EXIT=0 |
| `python tests/run_tests.py`agent core,含 test_agent_core | **41/41**EXIT=0 |
| `python tests/smoke_mode.py`(完整 agent 回合,走 tool_bash | ALL PASSEXIT=0 |
| P0-03 一致性检查(AGENTS.md 指针 / ARCHITECTURE 历史资料标记 / 无第三方任务书正文) | 3/3 PASS |
### 测试点明细(test_cross_platform_shell.py
- **A 平台参数**Windows 命令 == `cmd.exe /d /s /c "echo hi"`Linux argv == `["/bin/bash","-lc","echo hi"]`
Linux `popen_flags() == {"start_new_session": True}`Windows 为空。
- **B 提示词**:通用正文含占位符且无平台泄漏(无 `cmd.exe`/`/bin/bash`);Windows 段含 `cmd.exe`
`/bin/bash`,Linux 段反之;替换后通用正文逐字节相同(B6);`load_system_prompt()` == 文件+当前平台段。
- **C 进程树(真实进程,父挂 30s + 孙每 0.2s 写心跳文件)**:
- C1 超时 3s → 错误结果含「超时」,耗时 <15s,**父与孙都不存在**(心跳静默 >0.6s 且无完成标记);
- C2 主动中止 1.5s → 错误结果含「中止」,**父与孙都不存在**;
- C3 Linux 独立进程组(Windows 上 SKIP`pgid==pid` 断言 + killpg 后子进程消失,Linux 运行即生效)。
- **D 安全边界**:已退出进程、`None` 输入均不抛异常。
### 调试过程记录(铁律:所有调试命令显式超时)
1. 首跑 C1 失败:`'...\python.exe"' is not recognized` → 定位为 `list2cmdline` 引号转义;
读 CPython 3.10 `subprocess.py` 确认 `shell=True` 实为直接 CreateProcessW(带引号程序名不经 cmd)。
2. 尝试 `["cmd.exe","/d","/s","/c", '"'+cmd+'"']` 列表 → 仍失败(同样被转义)。
3. 改**字符串**命令行 + 实测矩阵(引号路径/&&/管道/%VAR%)→ 全过,定稿。
4. 首跑进程树用例 `hb_last=None`:父脚本模板漏传 `child.py` 脚本路径(把心跳路径当脚本)→
手动 `subprocess.run` 复现(超时 6s/3s 探针)→ 修模板,20/20 通过。
## Linux 侧待办(不阻塞本任务)
- 在 Linux 环境跑一次 `python tests/test_cross_platform_shell.py`C3 生效)+ `test_bash_stream.py`
即可闭环;代码路径与 Windows 共用同一套 `kill_process_tree`/`shell_command` 分派。
+60
View File
@@ -0,0 +1,60 @@
# P1-03 证据:Windows/Linux 渲染器启动链
日期:2026-07-17 · 执行环境:Windows 11 10.0.26200 x64 / CPython 3.10.21 (.venv, uv) /
PyQt6 6.10 + PyQt6-WebEngine 6.10 · 每条命令均带显式超时
## 改动文件
| 文件 | 性质 | 说明 |
|---|---|---|
| `core/renderer_backend.py` | 新增(仅 stdlib,可在导入 PyQt6 前使用) | `resolve_backend(pref)`auto/webview2/qtwebengine 归一化;非法值/平台不支持 → 可见警告 + 平台默认,绝不阻断启动);`platform_default_backend()``webview2_module()`win32 门控的按需导入);`webengine_profile_name()/webengine_profile_dir()`(每实例独立 profile 目录:源码运行 `data/webengine/profile_<pid>_<ms>`,测试经 `HAOCODE_WEBENGINE_PROFILE_DIR` 重定向临时目录;只创建、从不清理);`is_root_or_container()`geteuid==0 / .dockerenv / .containerenv / .lxc / /proc/1/cgroup);`sanitize_chromium_flags()``--no-sandbox` 契约) |
| `main.py` | 修改 | sys.path 就绪后、导入 `MainWindow` 前调用 `sanitize_chromium_flags` 并打印最终 `QTWEBENGINE_CHROMIUM_FLAGS` |
| `ui/views/main_window.py` | 修改(手术式) | ① `core.webview2` 导入改为 `if sys.platform == "win32"` 门控(Linux 永不导入,不触达 pythonnet/Win32/WebView2 DLL/taskkill);② 浏览器创建处先 `resolve_backend(config["webview_backend"])` 并打印警告(原「读取配置」从 win32 分支内提前到分支外,Linux 非法值也有可见警告);③ QtWebEngine 路径创建本实例 `QWebEngineProfile``setPersistentStoragePath`/`setCachePath` 指向独立目录),`CustomWebPage(profile, browser)` |
| `ui/views/custom_web_page.py` | 修改 | 构造函数兼容 `CustomWebPage(profile, parent)` 与旧式 `CustomWebPage(parent)`(按 `isinstance(QWebEngineProfile)` 分派,防旧调用把 view 误当 profile |
| `requirements.txt` | 修改 | `pythonnet==3.1.0; sys_platform == "win32"``clr_loader==0.3.1; sys_platform == "win32"`;追加 Linux 源码运行最小步骤(apt 系统库清单、root/容器 `--no-sandbox` 说明、离屏测试命令) |
| `tests/_test_env.py` | 修改 | `isolate()` 增加 `HAOCODE_WEBENGINE_PROFILE_DIR` → 临时目录(setdefault,可覆盖) |
| `tests/smoke_offscreen.py` | 修改 | 同上(该文件不走 isolate) |
| `tests/test_renderer_matrix.py` | 新增 | R1/R3/R4/R5(见下)19 项 |
| `docs/agent-handoff/PLATFORM_PLAN.md` | 修改 | 「Linux 与 QtWebEngine 回落」追加 Linux 源码运行最小步骤 + 实现锚点 |
`vendor/webview2/` 与根 `WebView2Loader.dll` 未动;未新增产品功能/安装器/PyInstaller 改动。
## 关键设计决策与踩坑记录
1. **`QTimer.singleShot` 单位是毫秒**R4 首版写 `QTimer.singleShot(25, finish)` 期望 25s 兜底,实际 25ms 就触发 → 两个 worker 都报 `loaded=False`rc=1)。诊断时手动复现看到 `BACKSTOP t=0.4`(30ms 定时器在事件循环启动 ~0.35s 后即触发),定位后改为 `30000`
2. **QtWebEngineWidgets 必须先于 QApplication 导入**(否则 ImportError`QtWebEngineWidgets must be imported ... before a QCoreApplication instance is created`);**QWebEngineProfile 必须先于使用它的 page/view 创建**`setPersistentStoragePath/setCachePath` 必须在 profile 使用前调用。探针脚本先后踩中这两个顺序问题(前者 ImportError;后者在 QApplication 前建 profile 直接进程被杀 exit 127、stdout 缓冲丢失)。
3. **PyQt6-WebEngine 6.10 无 `QWebEnginePage.errorOccurred`**Qt 6.5+ API 未在此绑定暴露)→ 诊断改用 `loadFinished(ok)` + 手动 processEvents 循环对比三种 profile 配置(默认 profile / 命名 profile 无自定义路径 / 命名 profile + 自定义路径),三者 file:// 加载全部成功,证明「命名 profile + setPersistentStoragePath」组合可用。
4. **旧式位置调用兼容**`CustomWebPage(browser)` 的 view 会被新签名当作 `profile` 传入 → `TypeError: argument 1 has unexpected type 'QWebEngineView'`(smoke 前用探针暴露)。按类型分派(`isinstance(QWebEngineProfile)`)同时支持新旧调用。
5. **R3 Linux 模拟导入**:子进程 `sys.platform='linux'``import ui.views.main_window` 成功——因 `global_hotkey.py``from ctypes import wintypes` 只是类型定义(3.10 下跨平台可导入),`ctypes.windll` 访问全在 `if _is_windows` 内;`core.webview2` 模块级仅 stdlib 导入,但被 main_window 的 win32 门控挡住,`clr`/`clr_loader` 未进 `sys.modules`
## 验证结果(全部显式超时)
| 检查 | 结果 | 命令(超时) |
|---|---|---|
| `tests/test_renderer_matrix.py` | **19/19 PASS** | `PYTHONIOENCODING=utf-8 python tests/test_renderer_matrix.py`timeout 300 |
| `tests/test_wv2_guard.py`P0 遗留守卫) | **10/10 PASS** | `timeout 120` |
| `tests/test_debug_window.py` | **22 PASS / 0 FAIL** | `timeout 180` |
| `tests/smoke_offscreen.py` | **8/8 ALL PASS**,日志含 `[Renderer] QtWebEngine 独立 profile: <temp>/profile_<pid>_*` | `QT_QPA_PLATFORM=offscreen HAOCODE_RENDER=software QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu timeout 240` |
| `node tests/test_math_extract.js` | **39 passed, 0 failed** | `timeout 60` |
| `main.py` 真实启动链(offscreen + 临时配置,无 providers | 启动成功、无 traceback`[渲染] 最终 QTWEBENGINE_CHROMIUM_FLAGS = '--disable-gpu'``[Renderer] QtWebEngine 独立 profile: D:\...\data\webengine\profile_56256_*``[System] 浏览器内核: QtWebEngine``data/config.json` mtime 前后一致(未触碰) | `HAOCODE_CONFIG_FILE=<temp> QT_QPA_PLATFORM=offscreen ... timeout 45 python main.py`124=到点 kill,预期) |
| `main.py` 真实启动链:普通桌面 + `--no-sandbox` | 标志被剥离 + 告警:`[Renderer] ⚠️ ... 已剥离该标志并保留 Chromium 沙箱`,最终 flags 无 `--no-sandbox` | 同上(timeout 30 |
| 回归:`tests/run_tests.py` | **41 passed** | `timeout 300` |
| 回归:`test_bash_stream` / `test_tool_params` / `test_main_window_event_filter` / `test_config_isolation` | 全部 **ALL PASS / EXIT=0** | 各 `timeout 300` |
| 回归:`node tests/test_render_window.js` | **424 passed, 0 failed** | `timeout 120` |
| 回归:`tests/smoke_mode.py` | **ALL PASS** | `timeout 240` |
| P0-03 一致性抽查(文件存在、requirements marker、wv2 门控、单提示词占位符) | **10/10** | 纯 Python 读文件 |
## 测试点明细(test_renderer_matrix.py
- **R1.1R1.11** resolve_backend 矩阵(mock 平台):Win × auto/webview2/qtwebengine/非法字符串/非字符串/大小写容差;Linux × auto/webview2(警告+回落)/qtwebengine/非法;`webview2_module()` 非 Windows 返回 None。
- **R3.1** 子进程 `sys.platform='linux'` 导入 `ui.views.main_window``_wv2mod is None``core.webview2`/`clr`/`clr_loader` 均不在 `sys.modules`
- **R4.1R4.3** 两个 offscreen 子进程**并行**各建 `QWebEngineProfile``HAOCODE_WEBENGINE_PROFILE_DIR` 同基目录、各自 `parallel_<pid>` 子目录)+ `CustomWebPage(profile, view)` 载入本地 HTML:均 `WORKER_OK loaded=True`;两 profile 目录不同;进程退出后目录仍在(无互相清理)。
- **R5.1R5.4** `sanitize_chromium_flags` 契约(mock root/容器探测 + 捕获 stdout):普通桌面无标志原样;普通桌面剥离 `--no-sandbox`+告警;root/容器+显式保留+「高可见警告」;root/容器未设→原样+提示。
## 遗留 / 平台验证待办
- **Linux 真机验证(待 Ubuntu 主机)**:`pip install -r requirements.txt`(确认 pythonnet/clr_loader 被 marker 跳过)→ `python3.10 main.py`X11/Wayland+ 离屏套件 + `tests/test_renderer_matrix.py` 的 R3/R4(真实 Linux 平台而非模拟)。
- **root/容器真机**`--no-sandbox` 接受路径的运行时验证(本环境为普通 Windows 桌面,只能验证剥离路径;接受路径为纯逻辑 + mock 验证)。
- **Windows 真机 WebView2 首选路径:已验证(2026-09-17 真桌面)**。本机 D: 卷 .NET 把工程卷误判为“网络位置”导致 `clr.AddReference`LoadFrom)报 0x80131515;在 `core/webview2.py`**byte[] 回落**(快路径仍 `AddReference`,失败才 `Assembly.Load(byte[])`,正常机器行为不变;外层异常改 `BaseException` 以兼容 pythonnet 非 Exception 异常)。修复后真桌面启动日志:`[WV2] AddReference 路径加载失败 → 回落 Assembly.Load(byte[])``Runtime ready: 153.0.4234.32``controller ready``NavigationCompleted src=file:///.../ui/web/index.html`stderr 无 error/disposed/0x8007。`test_wv2_guard` 10/10 无回归。
- **Windows 真机 QtWebEngine 回落:已验证(2026-09-17 真桌面,`HAOCODE_FORCE_QTWEBENGINE=1`**。日志:`[WV2] ... 跳过 WebView2,回落 QtWebEngine`(强制标志生效,**未执行 taskkill**)→ `[Renderer] QtWebEngine 独立 profile: data/webengine/profile_<pid>_<ms>`per-instance 隔离 profile 真机生效)→ `[System] 浏览器内核: QtWebEngine`。聊天区截图像素统计:83% 亮背景 + 159 种颜色桶(含文本深色像素)= 真实 DOM 渲染,非空白。两模式主窗口截图已存 evidence:`win_real_wv2_mainwindow.png``win_real_qtwebengine_mainwindow.png`(半尺寸 PNG)。
- 交叉验证(T0 守卫真机):WV2 主程序运行期间另跑 diag_panel_scrollbar.pydiag 实例因 instance-lock 被占自动回落 QtWebEngine,未误杀主程序 WebView2 进程。
+59
View File
@@ -0,0 +1,59 @@
# P1-04 证据:Linux 截图热键与截图实现
状态:Windows 侧自动化验证全绿;Linux X11/Wayland 真实宿主验证按 VERIFICATION.md 手动待办(本环境为 Windows 桌面)。
## 交付物(文件级)
| 文件 | 变更 |
|---|---|
| `ui/views/system_tools/desktop_session.py` | **新增**(纯 stdlib):`session_kind()``win32/x11/wayland/unknown`WAYLAND_DISPLAY / QT_QPA_PLATFORM=wayland / DISPLAY 判定,offscreen→unknown);`hotkey_plan(kind)` / `capture_plan(kind)` 能力路由 + 明确能力说明文案 |
| `ui/views/system_tools/x11_hotkey.py` | **新增**(窄适配器,零新依赖,ctypes→libX11):`X11HotkeyThread`(与 Windows `GlobalHotkeyThread` 同一公开面 `triggered/start/stop`);XOpenDisplay→XKeysymToKeycode('s')→XSelectInput(KeyPressMask)→XGrabKey(root, keycode, Mod1Mask, owner_events)→select(X 连接 fd, 0.2s)+XPending/XNextEvent 循环;命中 Alt+S 发射 `triggered`XEvent 结构体按 xproto.h XKeyEvent 布局(64 位 keycode@76);`_open_x11()` 可注入(测试替身);`wait_ready()` |
| `ui/views/system_tools/portal_capture.py` | **新增**(窄适配器,系统 gdbus CLI,零 pip 依赖):`detect_portal()`Linux + XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESS + gdbus 探测);`portal_screenshot_sync()`gdbus 调 `org.freedesktop.portal.Screenshot.Screenshot(handle, "/", {})` 取 request 对象路径 → `gdbus monitor --session --object-path <request>` 监听 `FilePicked`(成功,file:// URI 剥前缀+unquote 解码)/`Request.Finished`(无 FilePicked → 用户取消/拒绝);显式预算:request 10s + 等待 120s,超预算 → timeout`PortalScreenshotWorker(QThread)` 信号 `done(ok, path)` 回主线程 |
| `ui/views/main_window.py` | 热键注册块:`desktop_session.hotkey_plan(session_kind())` 平台路由(win32→GlobalHotkeyThread 行为字节级保持;x11→X11HotkeyThreadwayland/offscreen→None + 明确"全局热键不可用"日志),非 Windows 保留应用内 `QShortcut(Alt+S)` 兜底;`_start_screenshot()` 路由:win32/x11→现有覆盖层、wayland→`_start_portal_screenshot()`worker 完成→`_on_image_pasted([path])` 进现有图片附件流程)、unknown→明确"截图不可用,聊天与其他功能不受影响"日志 |
| `ui/views/system_tools/screen_capture.py` | `start()` 增加空画面守卫:无主屏幕 / grabWindow 返回空图(X11 个别 compositor 限制)→ 明确日志 + 不显示覆盖层(Wayland 已在路由层改走 portal |
| `tests/test_global_hotkey_platforms.py` | **新增** 23 检查:H1 session_kind 矩阵(win32/x11/wayland/offscreen/无显示 + XDG_SESSION_TYPE 三分支 + 矛盾时 WAYLAND_DISPLAY 优先);H2 hotkey_plan 四路由;H3 X11 成功路径(fake libX11XGrabKey 参数 keycode=39/Mod1Mask=1/root=123/owner_events=1、命中发射 triggered、stop 后 XUngrabKey+XCloseDisplay);H4 失败三分支(键被占用 XGrabKey=0 / 无显示 XOpenDisplay=None / 不支持组合不打开显示)均安静退出+明确日志;H5 Windows 路径保持(本机实测 RegisterHotKey 线程运行 + stop 干净释放) |
| `tests/test_screen_capture_platforms.py` | **新增** 17 检查:C1 capture_plan 四路由;C2 detect_portal 四分支(非 Linux/无 D-Bus/无 gdbus/齐备);C3 成功路径(fake subprocess 校验 gdbus 命令行 `--dest/--object-path/--method=...Screenshot/parent=/`、monitor 监听 request 对象、file:// 含空格文件名 URI 解码 → 真实文件);C4 授权被拒不伪造成功;C5 portal NotSupported 原因透出;C6 超预算 timeout(迟到信号不算成功);C7 worker 信号回主线程;C8 覆盖层 offscreen 构造+空画面守卫不崩 |
## 关键设计决定
1. **Windows 字节级保持**`GlobalHotkeyThread`Win32 RegisterHotKey 线程)与覆盖层路径零改动,仅调用处改为经 `hotkey_plan("win32")` 取回同一工厂;`capture_plan("win32")` 仍返回 overlay。
2. **X11 全局热键 = 原生 XGrabKey,无新 pip 依赖**libX11 是 X11 桌面必然存在的系统库,ctypes 直调;只映射现有 Alt+S`_VK_TO_KEYSYM` 窄表,扩展需显式加表项);`owner_events=1`stop 走 XUngrabKey+XCloseDisplay(关连接本身即释放 grab,双保险)。
3. **Wayland = xdg-desktop-portal,不绕过 compositor**compositor 安全模型禁止应用直接抓屏,故 Wayland 截图走 `org.freedesktop.portal.Screenshot`(交互式授权窗口,用户批准/取消);`gdbus`GLib 系统组件)CLI 完成 D-Bus 调用,不引入 dbus-python;成功返回文件路径 → 直接进现有 `_on_image_pasted([path])` 附件流程(不经过 Qt 覆盖层,因为 Wayland 下无法把画面抓进 Qt widget)。
4. **Wayland 全局热键:明确"不可用"而非硬做**:通用全局快捷键在 Wayland 依赖 compositor 桌面协议(ext-global-shortcut-unstable-v1 等,无统一 portal API),免依赖实现需完整 Wayland 客户端协议栈,超出窄适配器范围 → `hotkey_plan("wayland")` 返回 None + 日志明确说明(保留应用内 Alt+S + 截图按钮),符合硬约束"不支持时界面/日志必须明确说明能力不可用,主程序仍可聊天"。
5. **能力不可用的一等公民**`session_kind()=unknown`(offscreen/无显示)时,热键与截图都有显式日志("全局热键不可用"/"截图功能不可用;聊天与其他功能不受影响"),不再静默。
6. **异常绝不逃逸 QThread.run()**X11 事件循环整体 try/except——PyQt6 中 QThread.run() 未处理异常会 **abort 整个进程**(实测:CArgObject TypeError 逃逸 → 进程静默 127 退出、无 traceback、stdout 缓冲丢失)。这是本任务最贵的一个坑,已把"QThread.run() 必须全捕获"写进教训。
7. **超时语义**portal 等待中,FilePicked 之前/之后的信号都看预算——`Finished` 在预算内 → denied(用户取消);任何结果晚于预算 → timeout(不伪造、不无限挂起)。
## 验证(全部显式超时)
| 套件 | 结果 |
|---|---|
| `python tests/test_global_hotkey_platforms.py` | **23/23 PASS** EXIT=0timeout 120 |
| `python tests/test_screen_capture_platforms.py` | **17/17 PASS** EXIT=0timeout 180 |
| `python tests/test_file_attach.py`(回归) | 9 tests OKtimeout 90 |
| `python tests/smoke_offscreen.py`(回归,offscreen+software+--disable-gpu | **ALL PASS 8/8**timeout 240),日志含 `[GlobalHotkey] Windows:系统级全局热键 Alt+SRegisterHotKey` |
| `python tests/run_tests.py` | 41/41 |
| `tests/test_main_window_event_filter.py` / `test_config_isolation.py` / `test_wv2_guard.py` / `test_renderer_matrix.py` / `test_cross_platform_shell.py` | ALL PASS / ALL PASS / ALL PASS / 19/19 / 20/20 |
| `main.py` 真实启动链(offscreen + 临时 configtimeout 45→124 kill 预期) | 无 traceback`[GlobalHotkey] Windows:系统级全局热键 Alt+S` 打印;到达"JS 引擎已就绪"`data/config.json` mtime 前后一致(未触碰);error 行仅为 offscreen 已知 GPU 回落噪音 |
## 测试点细节(对应目标测试要求)
- **平台路由矩阵**H1session_kind 9 分支,含 XDG_SESSION_TYPE 与矛盾优先级)+ H2hotkey_plan 4 路由)+ C1capture_plan 4 路由)。
- **X11 替身**FakeX11 鸭子类型 libX11socketpair 提供可 select 的 fdXEvent 用 `ctypes.memmove` 填充;`CArgObject._obj``ctypes.byref(ev)` 还原原 struct——生产走真实 CDLL 不受影响)。
- **注册失败→明确消息**H4.1 键占用("Alt+S 可能已被其他程序占用")、H4.2 无显示("XOpenDisplay 失败")、H4.3 不支持组合("暂不支持的快捷键组合")——均无信号、`_registered=False`、线程安静退出。
- **portal 替身 subprocess**FakeRun/FakePopen 记录 argv 并回放 gdbus 输出(成功/拒绝/NotSupported/超时四态),验证真实 CLI 命令行与 JSON 事件解析,不依赖真实 portal。
- **X11/Wayland 真实行为**:本环境为 Windows 桌面,X11/Wayland 真机按 VERIFICATION.md 手动(见下)。
## 待办(需真实 Linux 宿主,按 VERIFICATION.md 手动)
1. Ubuntu 22.04/24.04 x64 X11 会话:`python3.10 main.py` → 日志 `[GlobalHotkey] X11:原生全局热键 Alt+SXGrabKey`;窗口失焦按 Alt+S 弹出覆盖层、框选截图成功;XGrabKey 被占用时日志明确。
2. Wayland 会话(GNOME/KDE):启动日志 `[GlobalHotkey] Wayland:…未启用 → 仅提供应用内 Alt+S…`;点截图按钮(或应用内 Alt+S)→ xdg-desktop-portal 授权窗口出现;批准 → 文件进入附件;取消 → 日志"portal 截图未完成…已取消";无 portal 时日志"portal 不可用"。
3. 其他发行版/DE 标记"未验证"(硬约束:不做发行版泛化)。
## 教训(持久)
- **PyQt6QThread.run() 内任何未处理异常 = abort 整个进程**(退出码 127、无 traceback、stdout 缓冲丢失,极难诊断)。QThread.run() 必须顶层 try/except 全捕获 + 日志。
- `ctypes.byref(x)` 返回 `CArgObject`:真实 CDLL 调用正常,但传给**普通 Python 可调用对象**(测试替身)时对方 `byref()` 会 TypeError——替身端用 `getattr(arg, "_obj", arg)` 还原。
- Windows 上 `os.pipe()` 的 fd 不能可靠用于 `select()`(无 WSAStartup 时 WinError 10093;初始化后是普通 pipe 又 10038)——跨平台可 select 的假 fd 用 `socket.socketpair()`;且 `a.send()` 的数据在 **b** 的接收缓冲(方向别写反)。
- `file://` URI 解析用"剥前缀 + unquote"而非 `urlparse().path``file://D%3A%5Cx`(无第三斜杠)会被 urlparse 当成 netloc → path 为空。
- gdbus 的 Screenshot 结果信号(FilePicked/Finished)都发在 **request 对象**上(方法返回的句柄路径),不是单独 handle 对象。
+50
View File
@@ -0,0 +1,50 @@
# P2-01 证据:Bash 任务按启动时间倒序
完成日期:2026-07-21(无人值守轮次)
结论:**完成**。两栏均按启动顺序降序显示(最新启动在第一项);运行中→已完成保持原启动位置;重排复用同一批 `BashLayer` 实例,全部 UI 状态保持。目标测试与回归全绿。
## 交付物
| 文件 | 改动 |
|---|---|
| `ui/views/bash_panel.py` | 模块 docstring 口径更新;`_refresh()` 两栏显示顺序改为启动序号降序;`layer_ids()` 的 running/done 返回真实显示顺序("all" 仍为原始启动正序,调试口径不变) |
| `tests/smoke_bash_panel.py` | 新增第 11 节(P11.1P11.20 共 23 项断言);收尾改 `os._exit`(offscreen 铁律,修复解释器退出挂起) |
未改动:`main_window.py`(事件转发链已是实时、按启动到达顺序带 `call_id` 转发,面板从到达顺序推导启动序号,无需新增元数据)、数据库 schema、`set_layers`(本就复用实例)。
## 关键设计决策
1. **排序键 = `self._order` 中的位置(稳定启动序号),不引入时间戳、不改 schema。**
面板的 `_order` 在三个入口按启动先后追加:
- `set_session()` DB 重建:消息链顺序 + 时间线内顺序(= backlog 要求的「稳定启动序号」构造方式);
- `set_session()` 活动流:时间线内顺序(当前轮次天然晚于历史);
- 实时 `on_started()`:事件到达顺序。
`_order` 即启动序号本身,`_refresh()` 只需对其取逆即可,无需任何新字段。
2. **只在显示层取逆,不改内部数据。** `run_ids`/`done_ids` 仍按 `_order` 正序过滤;`reversed()` 只作用于传给 `set_layers` 的 widget 列表。已完成栏限量窗口 `done_ids[-LAYER_LIMIT:]` 的成员不变(仍是「最近启动的 30 个」),仅窗口内显示顺序反转,提示语文义保持。
3. **完成时间从不参与排序。** `on_finished` 对已知层只改状态集合(`_running``_done`),绝不移动 `_order` 位置;仅当层完全未知(先收到 finished 事件)才以首次感知时间追加——这是唯一的信息可用时刻。因此「先启动后完成」的任务永远压在「后启动先完成」的任务之下,与结束先后无关(P11.10/P11.13 断言)。
4. **状态保持靠「同一对象」。** `set_layers` 逻辑未动:`takeAt → setParent(None) → addWidget → show`,操作的是同一批 `BashLayer` 实例。展开/折叠(`expanded` + `body` 显隐)、实时缓冲(`_live`)、代码框滚动值(`out_box`/`arg_box` 子控件属性)、两栏 section 滚动位置(`QScrollArea` 自身属性,子层重排不触碰)全部天然保持,测试逐项断言(P11.4–P11.8、P11.13bd)。
## 验证(全部显式 timeout
| 套件 | 结果 | 预算 |
|---|---|---|
| `tests/smoke_bash_panel.py`(含新增 P11 节) | **ALL PASS140 项断言)EXIT=0** | 240s |
| `tests/test_bash_stream.py` | **30/30 ALL PASS EXIT=0** | 180s |
| 回归 `tests/smoke_offscreen.py` | ALL PASS 8/8 EXIT=0 | 240s |
| 回归 `tests/run_tests.py` | 41 passed / 0 failed EXIT=0 | 300s |
| 回归 `test_main_window_event_filter` / `test_config_isolation` / `test_wv2_guard` | 均 ALL PASS EXIT=0 | 各 120s |
新增断言要点(对应 backlog「完成证据」三条):
- **≥3 项任务以不同启动/完成顺序**s1/s2/s3/s4/s6 + t0 + t1..t31 共 36 项已完成、交错完成(s2 先完成仍居顶、s3 最后完成插入启动位而非顶格),两栏均断言启动降序(P11.1/P11.2/P11.9P11.13);
- **完成中间任务前后状态保持**:对象 identity(`is`)、展开态、实时输出文本、代码框水平滚动值(先强制非 0)、section 垂直滚动值(用 20 行内容撑出真实滚动范围后设 30)在重排后逐项相等(P11.3P11.8、P11.13bd);
- **DB 重建与实时一致**:切换会话后 3 条时间线条目按 `db2,db1,db0` 显示(消息链+时间线序的逆),`layer_ids()` 原始正序不变(P11.19/P11.20)。
## 测试中发现并处理的问题
1. **Qt 布局 flush 会重置代码框水平滚动(测试时序伪影,非产品 bug)**:展开层与 `setValue` 同 tick 执行时,`out_box` 的终宽布局尚未 flush,随后任何布局事件(如新任务触发的 `set_layers`)应用挂起 resize 会把水平滚动清零。探针矩阵(N1×N2 settle 圈数)证实:`setValue` 前至少一次事件循环(N2≥1)则滚动稳定保持。真实用户不可能在未渲染的框上滚动,故测试在设滚动值前补 `settle(120)` 并在注释中记录该伪影。
2. **`smoke_bash_panel.py` 解释器退出挂起**:末行 `sys.exit(0)` 后 QtWebEngine 渲染/GPU 子进程(offscreen)不回收,进程挂到 timeout 124;此前跑该文件若经管道只看输出会误判通过。改为仓库 offscreen harness 惯例 `os._exit(code)`stdout 已 flush、临时文件已清理),EXIT=0 即时返回。
3. **`layer_ids()` 口径**:原返回启动正序;改为 running/done 返回真实显示顺序(便于测试直接断言所见即所得),"all" 保持原始正序。既有断言(单元素/`set()`/`len`)全部不受影响,140 项一次通过。
## 未验证项
无平台相关项(纯 UI 排序逻辑,offscreen 已全量覆盖)。
+48
View File
@@ -0,0 +1,48 @@
# P2-02 证据:右侧 Bash 面板滚动条与横纵交汇角
完成日期:2026-07-21(无人值守轮次)
结论:**完成**。右侧任务面板的代码框(`#bl_code`)与两栏 section 滚动区(`#bl_scroll`)滚动条统一为 8px、无箭头、handle 可见且 hover;横纵交汇角用 `QPlainTextEdit::corner` 子控件染成代码框背景 `#fbfcfe`,原生亮色 corner 方块消除。所有选择器均限定在 `#bl_code`/`#bl_scroll`,未添加任何无作用域的 `QScrollBar`/`QAbstractScrollArea` 规则。
## 交付物
| 文件 | 改动 |
|---|---|
| `ui/views/main_window.py` | 主窗口全局 QSS 中 `#bl_code` 规则后插入一段**完全限定作用域**的滚动条 + corner 规则(`QPlainTextEdit#bl_code QScrollBar:*``QPlainTextEdit#bl_code::corner``QScrollArea#bl_scroll QScrollBar:*``QScrollArea#bl_scroll::corner`);`#bl_code` 原有背景/边框/圆角/文本样式一字未动 |
| `tests/diag_panel_scrollbar.py` | 新建:离屏测量 + 断言 + 局部截图(面板全貌、代码框 render 图、角落 4x 放大) |
截图(`docs/agent-handoff/evidence/`):`p2-02-panel.png``p2-02-outbox-render.png``p2-02-codebox-corner-4x.png`
## 关键设计决策
1. **作用域 = objectName 限定,零全局规则。** 主窗口全局 QSS 此前没有任何 `QScrollBar` 规则(各弹窗/附件区各自 `setStyleSheet`),右侧面板因此落到原生 Windows 滚动条(带箭头、17px、亮色 corner 方块)。新增规则全部写成 `QPlainTextEdit#bl_code …` / `QScrollArea#bl_scroll …` 形式,只可能匹配右面板内的对象名,结构上不可能泄漏到其他控件。
2. **corner 用 `QAbstractScrollArea::corner` 子控件语法**`QPlainTextEdit#bl_code::corner { background-color: #fbfcfe; }`)——Qt 文档支持的子控件,与代码框背景同色,即 backlog「corner 与代码框背景一致」;未使用不存在的 `QScrollBar::corner`。section 滚动区 `::corner` 置透明(其横向滚动条恒关,corner 本不显示,属保险)。
3. **口径与仓库既有风格一致**8px 厚、`add-line/sub-line` 置 0 隐藏箭头、`#d0d0d0` handle + `#a0a0a0` hover、圆角 4px——与 `modern_scrollbar_qss` 及附件预览区风格同源,只是作用域不同。
## 验证(全部显式 timeout
| 套件 | 结果 | 预算 |
|---|---|---|
| `tests/diag_panel_scrollbar.py`(新) | **18 项 ALL PASS EXIT=0** | 180s |
| `tests/smoke_bash_panel.py` | **140 项 ALL PASS EXIT=0** | 240s |
| 回归 `tests/smoke_offscreen.py` | ALL PASS 8/8 EXIT=0 | 240s |
| 回归 `tests/run_tests.py` | 41/41 EXIT=0 | 300s |
| 回归 `smoke_timeline` / `smoke_midswitch` / `test_main_window_event_filter` | 11/11、7/7、ALL PASS,均 EXIT=0 | 各 ≤300s |
诊断脚本断言要点(对应 backlog「完成证据」):
- **厚度**:代码框横/纵滚动条实际几何 = 8px 且 `sizeHint` = 8pxS1/S1b/S2/S2b);section 竖滚动条实际 = 8pxS3);
- **箭头 extent**`subControlRect(CC_ScrollBar, SC_ScrollBarSubLine)` 在样式代理下 = 0(S4 三项)——即箭头子控件零尺寸;
- **corner**:代码框 `render()` 图中,右下角 8×8 交汇块渲染出 `#fbfcfe`9 px)、无 `(255,255,255)` 亮白像素(S5c/S5d);
- **无泄漏**:未命名 `QPlainTextEdit` 横滚动条仍为原生口径(14px,S6);附件预览滚动条保持自身 6px `sizeHint`S7);
- 截图三张落盘 evidence 目录,含角落 4x 放大图。
Windows 真机截图(backlog「完成证据」第二条):**已完成(2026-09-17Windows 11 真桌面,非 offscreen**`diag_panel_scrollbar.py` 直接运行 → EXIT=0、ALL PASS;实测代码框 H=8px V=8px、section V=8px、corner #fbfcfe 9 像素(与 offscreen 测量一致);三张截图已用真机渲染覆盖:`p2-02-panel.png``p2-02-outbox-render.png``p2-02-codebox-corner-4x.png`(07:20 时间戳)。运行期间生产 app 以 WebView2 在前台,diag 实例经 instance-lock 守卫自动回落 QtWebEngine,未误杀对方 WebView2 进程(T0 守卫真机验证)。Linux 真机截图仍待对应环境。
## 测试中发现的问题与教训
1. **offscreen 下 `widget.grab()` 对 `QPlainTextEdit` 的文档区不填充(黑图)**`out_box.grab()` 整块 (0,0,0),但 `panel.grab()` 正常。改用 `ob.render(painter)`(渲染到透明 QPixmap)后:文本色 `#243043`、边框 `#e6eaf2`、handle `#d0d0d0`、corner `#fbfcfe` 全部出现——**样式子控件在 offscreen 下正常渲染,只有文档区背景填充缺失**(离屏渲染怪癖,非产品 bug)。像素断言一律走 `render()`,且必须先做健全性检查(文本色/handle 色像素计数 > 0)防黑图假通过。
2. **PyQt6 API 坑(三连)**
- `Qt.Vertical`/`Qt.Horizontal` 短名已移除 → `Qt.Orientation.*`
- `QStyleOptionSlider(widget)` 构造器未绑定(只收无参/拷贝)→ 用 `QStyleOptionSlider()`
- `subControlRect` 参数序是 `(ComplexControl, QStyleOption, SubControl, widget)`,且滚动条箭头子控件在 PyQt6 枚举里叫 `SC_ScrollBarSubLine`/`SC_ScrollBarAddLine`(不是 C++ 文档里的 `SC_DownArrowButton`);`CC_ScrollBar` 属于 `QStyle.ComplexControl` 而非 `ControlElement`
- 裸控件(无样式表祖先)的 `style()` 是基础风格且空 option 下 `subControlRect` 返回 0 矩形——「原生参照」只能靠 `sizeHint`/实际几何(如原生横条 14px)对照,不能靠 subControlRect。
3. **测试数据**`out_box` 要同时出横纵滚动条,必须「超宽单行(NoWrap 触发横条)+ 足够行数(触发纵条)」,只给长单行时纵条不可见。
+95
View File
@@ -0,0 +1,95 @@
# P2-03 证据:WebView2 原生窗口遮挡层(重命名遮罩)
**任务**:修复 RenameOverlay 被 WebView2 原生子窗口压住的确定性缺陷,审计同类遮罩,只修可确认的原生窗口遮挡。
**结论**`RenameOverlay` 已重写为独立顶层透明 Tool 窗(与 `AttachmentPreviewOverlay` 同一验证过的模式);审计确认它是**唯一**受影响的遮罩,其余全部本就是顶层窗口,未改动。自动化目标测试全绿。
## 根因(源码级)
- `core/webview2.py`(L10-11 注释 + 子窗口发现实现):WebView2 的 `Chrome_WidgetWin_*` 是**主窗口 HWND 的原生子 HWND**EnumChildWindows 轮询发现,SetBoundsAndZoomFactor 定位)。
- Windows 上原生子 HWND 永远绘制在其父 HWND 内所有 Qt 渲染内容**之上**Qt 绘入主窗口 backing store,原生子窗在 z 序更高)。
-`RenameOverlay``bg_widget` 的**子控件**`QWidget(parent=bg_widget)` + `setGeometry(parent.rect())`):遮罩与卡片都是主窗口内的 Qt 绘制 → 在聊天区(WebView2 所在区域)内被原生 webview 窗盖住:遮罩不暗、卡片被压。
- 唯一可盖住它的结构:独立顶层窗口(独立 HWND)+ 逐像素 alpha`WA_TranslucentBackground`)。`AttachmentPreviewOverlay` 早已按此模式修复(其 docstring 明文记录同一缺陷),`RenameOverlay` 是遗留的旧模式。
## 修改(ui/views/main_window.py,仅 RenameOverlay 类,L1363 起整类替换)
| 项 | 旧 | 新 |
|---|---|---|
| 窗口类型 | `bg_widget` 子控件 | 顶层 `FramelessWindowHint \| Tool`(独立 HWND,拥有者=主窗口,Windows 上默认浮于拥有者之上,不入任务栏) |
| 透明 | 无(fillRect 半透明灰) | `WA_TranslucentBackground` + paintEvent 半透明灰(逐像素 alpha |
| 覆盖范围 | `parent.rect()`(客户区内嵌) | **主窗口客户区**`mapToGlobal(main.rect().topLeft())` + `main.rect().size()` —— 不含系统标题栏/边框,标题栏与窗口控制保持可操作 |
| 跟随 | `resizeEvent`(子控件自动跟随) | eventFilter 挂主窗口:`Move` → move`Resize` / `WindowStateChange`(最大化/还原;DPI 变化时 Qt 对主窗合成 move+resize,同路跟随)→ 客户区几何重同步 + 卡片重居中;`Close`/`Hide` → 关闭遮罩 |
| 入场动画 | QGraphicsOpacityEffect(顶层窗不可靠) | windowOpacity 属性动画 150ms(同附件预览层) |
| 行为 | 点空白/✕/取消 关闭、输入全选、Enter 提交 | 全部保留,**新增 Esc 关闭**keyPressEvent);`confirm()` 空标题不 emit |
| 释放 | `deleteLater` | `close_overlay()``removeEventFilter(main)` + `main.activateWindow()/raise_()`(焦点回主窗)+ `deleteLater``WA_DeleteOnClose``_closed` 幂等防重入 |
| 卡片拖拽 | 有(限父窗内) | 保留(限客户区内) |
`_rename_session` 调用点未改(仍 `RenameOverlay(current_title, self.bg_widget)``parent.window()` 取主窗口)。无新增辅助函数(几何同步逻辑与附件预览层各 10 行,不构成"真实重复",未抽公共函数)。
## 同类遮罩审计(只修可复现者)
| 遮罩/弹窗 | 位置 | 窗口类型 | 结论 |
|---|---|---|---|
| `AttachmentPreviewOverlay` | L53 | 顶层 Tool + `WA_TranslucentBackground` + eventFilter 跟随(frameGeometry | **不受影响**(已是正确模式,本次修复的参照) |
| `SettingsWindow` | L424 | 顶层 `FramelessWindowHint`(独立窗,内部虚化遮罩是其子控件) | **不受影响**(独立 HWND,天然在 webview 之上) |
| `SessionContextPopup` | L1267 | `Popup \| FramelessWindowHint` | **不受影响**Popup 为独立顶层原生窗) |
| `ModelSelectPopup` | L1624 | `Popup \| FramelessWindowHint`L1649 | **不受影响** |
| `SessionModePopup` | L2245 | `Popup \| FramelessWindowHint`L2260 | **不受影响** |
| `PdfModePopup` | L2368 | `Popup \| FramelessWindowHint`L2392 | **不受影响** |
| `RenameOverlay` | L1363 | ~~bg_widget 子控件~~ → 顶层 Tool + 透明 | **受影响,已修复**(唯一) |
未把普通 popup/dialog 重写成统一框架(硬约束)。
## 自动化验证(本机 Windows 11 10.0.26200 x64`.venv` CPython 3.10.21
新建 `tests/diag_rename_overlay.py`(隔离临时 DBoffscreen34 项断言,EXIT=0):
```
RESULT: 34/34 -> ALL PASS
R0 遮罩创建且为顶层窗口
R1 结构:isWindow / window() is self(非内嵌子控件)/ Tool / Frameless /
WA_TranslucentBackground / WA_DeleteOnClose / 主窗口未设透明
R2 几何:覆盖客户区左上角与尺寸(±2px)/ 卡片居中 / 输入框初始全选 / 预填旧标题
R3 跟随:主窗 move(+150,+80)→精确跟随 / resize(1200x700)→尺寸同步+卡片重居中 /
WindowStateChange 分支(最大化/还原同路)不崩溃且几何仍正确
R4 行为:Enter 提交→DB+侧栏标题更新 / 空标题 confirm 不改标题 /
Esc 关闭 / 点空白关闭 / ✕ 关闭 / 取消关闭 / 非确认关闭不改标题
R5 释放:顶层窗口消失、无残留 rename_form、主窗口存活可用
R6 焦点回主窗(offscreen 软检查,INFO 记录)
```
目标测试 + 回归(均 EXIT=0):
| 套件 | 结果 |
|---|---|
| `tests/diag_rename_overlay.py` | 34/34 ALL PASS |
| `tests/smoke_offscreen.py`QtWebEngine 路径) | 8/8 ALL PASS |
| `tests/smoke_copy_session.py`(侧栏/Popup 链路) | ALL PASS |
| `tests/smoke_bash_panel.py` | 140 项 ALL PASS |
| `tests/run_tests.py` | 41 passed, 0 failed |
## 真机 WebView2 验收(用户走查步骤)
真机 WebView2 自动化被有意放弃:`core/webview2.py``get_environment()``taskkill /F /IM msedgewebview2.exe`(会杀掉用户其他 WebView2 应用进程)且 SDK 怪癖要求默认共享 profile(无法重定向到临时目录)——无人值守自动化运行该路径风险不可接受。
**【2026-09-17 更新】本机 WebView2 加载阻塞已解除**`core/webview2.py` 加 byte[] 回落,见 evidence/P1-03.md):主程序现已能在本机以真实 WebView2 启动(Runtime 153.0.4234.32、controller ready、index.html NavigationCompleted、stderr 无错)。下表人工清单现可在本机真实 WebView2 模式下执行。
机制保证(与生产已验证的 AttachmentPreviewOverlay 完全同构):新遮罩是**独立顶层 HWND**WS_EX_TOOLWINDOW + 拥有者=主窗口);Windows z 序规则下,拥有者窗口之上的顶层窗永远绘制在拥有者的原生子 HWND(WebView2 `Chrome_WidgetWin_*`)之上,与 offscreen 平台无关。
人工验收清单(Windows 桌面,真实 WebView2 模式运行主程序后):
1. 会话右键 → 重命名:遮罩盖住**整个客户区**(含聊天区 webview,webview 变暗),标题栏(含最小化/最大化/关闭)仍可点。
2. 拖主窗口 / 拉边框缩放 / 最大化 / 还原:遮罩与卡片全程贴合客户区、卡片保持居中。
3. 多显示器间拖主窗 / 改缩放比后重开遮罩:几何正确(mapToGlobal 路径)。
4. Esc / 点空白 / ✕ / 取消 → 遮罩消失、焦点回主窗、侧栏可继续操作;Enter 或确定 → 标题更新。
5. 连续开/关 5 次:无残留遮罩、无卡顿、任务栏无新增图标。
## 教训(跨压缩持久)
- 【P2-03 发现】**offscreen/无真实事件循环时 `processEvents()` 不处理 `DeferredDelete`**`deleteLater()` 的控件必须显式 `QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)` 才会真正删除(探针证实:仅 processEvents 循环后对象仍 alive)。生产事件循环常驻不受影响,但所有 offscreen 测试的"已删除"断言前必须冲刷 DeferredDelete。
- 【P2-03 教训】PyQt6 `setGeometry``(QPoint, QSize)` 重载 → 构造 `QRect(tl, size)``QTest.mouseClick(widget, button, modifier, pos)` 第 3 参是 **modifier**(易误当 pos)。
- 【P2-03 发现】`core/webview2.py` `get_environment()``taskkill /F /IM msedgewebview2.exe` + 共享默认 profile 不可重定向 → 无人值守自动化不得走真实 WebView2 启动路径;真机验收走人工清单。
- 【P2-03 结构判据】"顶层窗可带 owner parent"`setWindowFlags(Tool|Frameless)``parentWidget()` 仍可非 None(owner 关系),判据是 `isWindow()` / `window() is self`,不是 `parentWidget() is None`
## 修改文件
- `ui/views/main_window.py`(仅 `RenameOverlay` 类整类替换,~170 行;调用点未改)
- `tests/diag_rename_overlay.py`(新,34 项断言)
+104
View File
@@ -0,0 +1,104 @@
# P2-04 证据:跨平台聚合测试入口
状态:COMPLETE
日期:2026-07-17(本机执行时间)
平台:Windows 11 10.0.26200 x64(主)+ WSL Ubuntu-22.04 / CPython 3.12.3Linux 路径验证)
## 交付物
- `tests/run_all.py`(新增,唯一新文件):跨平台聚合测试入口。
- `python tests/run_all.py --group logic|offscreen|all`(默认 all
- `--list` 只列条目;`--only <id,子串>` 跑子集(调试);`--keep-logs` 保留全部子日志(默认只留失败/超时)。
- 设计:每个子测试 = 独立子进程 + 独立临时目录(`haocode_RUN_<id>`/`haocode_CONF_<id>`POSIX 走 TMPDIR、Windows 走 TEMP+ 显式秒级 timeout`subprocess.run(timeout=...)`);offscreen 组子进程注入 `QT_QPA_PLATFORM=offscreen``HAOCODE_RENDER=software``QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu`(均 setdefault,不覆盖外层)。
- 退出码语义:任一 FAIL/TIMEOUT → 退出码 1(并打印"失败命令"清单);SKIP 不影响退出码。
- SKIP 双闸门:①依赖探测(`importlib.util.find_spec`node/openai/PyMuPDF/PyQt6,缺失即 SKIP 并给理由);②平台令牌(`platform:win32` 等,平台不适用即 SKIP 并给理由)。
- 聚合鲁棒性:子进程崩溃/超时只记 FAIL/TIMEOUT,不中断后续;输出统一 UTF-8(`errors=replace`Windows cp936/cp1252 宿主安全);WSL 时钟回拨保护(耗时 clamp ≥0)。
- 不要求 pytest / npm / 网络;聚合器自身零第三方依赖。
## 默认聚合范围(25 条)与排除项
- logic 12 条:run_tests.pyagent core 41)、test_tool_params、test_bash_stream、test_copy_session、test_compaction_persist、test_file_attach、test_cross_platform_shell、test_global_hotkey_platforms、test_wv2_guard、test_pdf_reader、test_math_extract.js、test_render_window.js。
- offscreen 13 条:smoke_offscreen、smoke_mode、smoke_copy_session、smoke_bash_panel、smoke_timeline、smoke_midswitch、test_main_window_event_filter、test_config_isolation、test_error_persist、test_think_code_neutral、test_debug_window、test_renderer_matrix、test_screen_capture_platforms。
- 默认排除(有注释理由,不进默认聚合):
- `diag_*`rename_overlay / panel_scrollbar / render_scale):人工/像素诊断,需真机或交互。
- `verify_*`:人工验证脚本。
- `tune_model_popup.py`:调参实验。
- `smoke_offscreen.py` 之外的旧式 real-DB 冒烟(引用真实 `data/haocode.db`,未接临时库)。
- `diag_live_agent.py``probe_agent_loop.py`:需真实 API 凭据。
- `_probe_*``_test_env.py`:探针/工具模块(被其他测试 import,非独立用例)。
- `run_all.py` 自身:聚合器不入聚合。
## 汇总结果(硬约束要求:Windows/Linux 各一份)
### Windows 11 / CPython 3.10.21.venvPyQt6+openai+PyMuPDF 齐全)
| 组 | 结果 | 耗时 |
|---|---|---|
| `--group logic` | **PASS 12 / FAIL 0 / SKIP 0**EXIT=0 | 43.9s |
| `--group offscreen` | **PASS 13 / FAIL 0 / SKIP 0**EXIT=0 | 81.9s |
| `--group all` | 25/25 PASSEXIT=0,两组顺序执行) | ~126s |
- 日志样例:`D:/tmp/runall_win_logic2.log``D:/tmp/runall_win_offscreen.log`
- offscreen 明细:smoke_offscreen 8.3s、smoke_bash_panel 30.3s、smoke_midswitch 8.7s、test_think_code_neutral 9.3s 等 13 条全 PASS。
### WSL Ubuntu-22.04 / CPython 3.12.3(系统 python3,无 PyQt6/openai/PyMuPDF,离线环境)
| 组 | 结果 | 耗时 |
|---|---|---|
| `--group all` | **PASS 4 / FAIL 0 / SKIP 21**EXIT=0 | 0.9s |
- PASS 4 = test_copy_session、test_file_attach、test_math_extract.js、test_render_window.js(纯 stdlib/node)。
- SKIP 21 全部带明确理由:无 openai(5,禁止联网安装)、无 PyQt6(14offscreen 组整体)、无 PyMuPDF1)、平台不适用 win32-only1test_wv2_guardmsvcrt 单实例互斥是 WebView2 守卫的 Windows 专属机制,Linux 无 WebView2 链路)、无 PyQt6 的 GUI 逻辑(1test_global_hotkey_platforms 含 QShortcut 构造)。
- 证明:聚合器在 Linux/3.12 上可运行、隔离约定在 POSIX(TMPDIR)成立、诚实 SKIP 不伪装通过、JS 条目跨平台运行。
- 注:WSL 无 .venv 且离线,依赖型条目按硬约束 SKIP;在按 VERIFICATION 矩阵备齐依赖的 Linux 真机上同命令即可执行全部条目(offscreen 组仍需真实 Linux 桌面环境完成 P1-03/P1-04 的平台验证,状态不变)。
## 故意失败夹具演示(硬约束:崩溃/超时不阻断汇总、退出码反映失败)
- 临时夹具(用后即删,未提交):
- 崩溃夹具(`raise RuntimeError`)→ 聚合器记 **FAIL** 并继续跑后续健康测试。
- 超时夹具(`time.sleep(60)` + budget 5s)→ 聚合器记 **TIMEOUT** 并继续。
- 演示运行:`总计 3: PASS 1 FAIL 1 TIMEOUT 1`,退出码 **1**,完整汇总 + 失败命令清单正常打印。
- 演示日志:`D:/tmp/runall_fixture_demo.log`;夹具文件已删除,仓库无残留(已 rg 复核)。
## 等价性(硬约束:单文件命令仍可直接运行,输出与聚合子进程一致)
- `test_copy_session.py`
- 独立运行:`===== 54/54 PASS ===== / ALL PASS`
- 聚合子进程(保留子日志 tail):`===== 54/54 PASS ===== / ALL PASS` —— 逐字一致。
- 其余条目同理(聚合即 `subprocess.run([sys.executable, ...原命令...])`,命令形态未变)。
## 聚合入口暴露并修复的两个真实缺陷
1. **`tests/test_compaction_persist.py` T9 陈旧断言**(跨平台共同项,Windows 上 FAIL):
- 断言用 `assertIn('msg["role"] not in', src)` 硬编码变量名,而 `ui/views/main_window.py` 现行代码用 `m["role"] not in`P1-01/P2-01 期间变量名演化,测试未跟进)。
- 修复:改为变量名无关的语义断言(同时接受 `msg["role"] not in` / `m["role"] not in` 两种等价写法,检查"摘要标记行被过滤"这一语义本身)。
- 验证:单跑 41/41 PASS;聚合 logic 组 12/12 PASS。
2. **`core/agent/compaction.py` dataclass 不可哈希默认值(Python 3.11+ 崩溃,真跨平台 bug**
- `CompactionPreparation.settings: CompactionSettings = DEFAULT_COMPACTION_SETTINGS`:默认值是 eq-dataclass 实例(`__hash__=None`)。Python ≤3.10 的 dataclass 只拒 list/dict/set 默认值 → 合法;Python 3.11+ 追加"不可哈希默认值"检查 → **import 即 ValueError**
- 影响面:Ubuntu 24.04 自带 CPython 3.12VERIFICATION 矩阵明确支持的 Linux 目标)—— 任何 import `core.agent` 的模块/测试在 3.11+ 全灭。由聚合入口在 WSL/3.12 上首次系统性暴露。
- 修复(1 行,语义完全等价):`settings: CompactionSettings = field(default_factory=lambda: DEFAULT_COMPACTION_SETTINGS)` —— 仍返回同一共享默认实例,3.10 行为不变。
- 验证:3.10 本地 `test_compaction_persist` 41/41 + `run_tests` 41/41 无回归;WSL/3.12 上 `core.agent` 链 import 通过(3 个 openai 依赖条目越过 compaction 后才在 openai 处 SKIP,证明 3.12 兼容)。
## 注册表元数据修正(诚实 SKIP 的前提)
- `test_tool_params.py` / `test_bash_stream.py` / `test_cross_platform_shell.py`:补 `openai` 依赖(均 import `core.agent.tools` → 顶层 `from openai import OpenAI`)。
- `test_global_hotkey_platforms.py`:补 `pyqt6` 依赖(含 QShortcut/overlay 构造)。
- `test_wv2_guard.py`:标 `platform:win32`T4 msvcrt 跨进程互斥为 Windows 专属;非 win32 上 `acquire_instance_lock()` 按设计返回 None)。
## 边界遵守
- 未动任何独立测试命令与既有入口(`run_tests.py` 原样保留)。
- 未要求 pytest/npm/网络;聚合器零第三方依赖。
- 默认聚合未启动任何真实 API / 人工诊断 / 真实桌面 / 凭据脚本(见排除清单)。
- 未读取真实配置(`data/config.json` 零访问);未删除用户运行数据;夹具用后即删。
- 产品代码改动仅 2 处且均为聚合暴露的缺陷修复:`tests/test_compaction_persist.py`(测试断言更新)+ `core/agent/compaction.py`1 行 3.11+ 兼容)。
## 复现命令
```text
python tests/run_all.py --list
python tests/run_all.py --group logic
python tests/run_all.py --group offscreen
python tests/run_all.py --group all
python tests/run_all.py --only test_copy_session --keep-logs
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+119
View File
@@ -0,0 +1,119 @@
# main.py
import io
import os
import sys
# ==================== 🌟 冻结/无控制台环境:stdout/stderr 安全化 ====================
# 🐛 打包成 exe 后踩到的真 bug(开发环境永远不会暴露):
# 打包版没有控制台,sys.stdout 可能是 None 或编码为 GBK 的流 →
# 任何 print 里的 emoji(🚀✅🛑…)都会抛 UnicodeEncodeError
# 而且是在 MainWindow() 之前就崩 → **主窗口根本不出现**。
# 统一改成 UTF-8 + errors=replace:打包版与开发版行为一致,
# 即使控制台是 GBK,也只会显示成 '?' 而不会崩。
for _name in ("stdout", "stderr"):
_s = getattr(sys, _name, None)
if _s is None:
try:
setattr(sys, _name,
open(os.devnull, "w", encoding="utf-8", errors="replace"))
except Exception:
pass
continue
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
try:
setattr(sys, _name, io.TextIOWrapper(
_s.buffer, encoding="utf-8", errors="replace", line_buffering=True))
except Exception:
pass
# ==================== 🌟 WebEngine 渲染配置(必须在 PyQt6 导入之前设置)====================
# 本机实测渲染模式:GPU 硬件加速(ANGLE → Direct3D11, AMD 核显),非 CPU 软渲染。
#
# 黑边/黑框背景(已实测):
# Windows 上窗口缩放的新暴露区域未出帧时,GPU 合成器露出黑色、软件合成器露出白色。
# 基准数据(重度页面 500 节点+滚动+强制重绘):
# GPU 合成 59.2 FPS(黑框)
# 软件合成 30.1 FPS(白框,视觉反差小;轻量聊天页实测跟手无差别)
# 因此默认采用“软件合成”(白框、好看);若遇重度页面掉帧,用 HAOCODE_COMPOSITING=gpu 回退。
#
# ⚠️ 2026-07 重大变更:默认改为全 CPU 软渲染(--disable-gpu)。
# 原因:本机 AMD 核显 GPU 上下文周期性丢失(context lost 实测复现),
# GPU 光栅化产物无法合成上屏 → 流式正文 DOM 已写入但屏幕不刷新。
# 软渲染在 Chromium 内全 CPU 完成,彻底绕开该故障模式;聊天页性能足够。
#
# 备选开关(二选一,或直接设置 QTWEBENGINE_CHROMIUM_FLAGS 完全覆盖):
# HAOCODE_RENDER=gpu → 旧默认:GPU 光栅化 + 软合成(需 GPU 驱动稳定)
# HAOCODE_COMPOSITING=gpu → 回退 GPU 合成(黑框、满帧)
if "QTWEBENGINE_CHROMIUM_FLAGS" not in os.environ:
_render_mode = os.environ.get("HAOCODE_RENDER", "").strip().lower()
_comp_mode = os.environ.get("HAOCODE_COMPOSITING", "soft").strip().lower()
if _render_mode in ("gpu", "hardware"):
# 旧默认:GPU 光栅化 + 软合成(仅当 GPU 驱动稳定时使用)
_flags = ["--enable-gpu-rasterization", "--enable-zero-copy", "--ignore-gpu-blocklist"]
if _comp_mode != "gpu": # 默认软合成:黑框→白框
_flags.append("--disable-gpu-compositing")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = " ".join(_flags)
else:
# 默认(含 software):全 CPU 软渲染,规避 GPU 上下文丢失导致的不刷新
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
print(f"[渲染] QTWEBENGINE_CHROMIUM_FLAGS = {os.environ.get('QTWEBENGINE_CHROMIUM_FLAGS', '(未设置)')}", flush=True)
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import Qt
# 确保 Python 能找到项目根目录下的模块
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
# P1-03Chromium sandbox 契约(在导入 PyQt6/QtWebEngine 前处理;renderer_backend 仅 stdlib
# - 默认保留 sandbox--no-sandbox 仅在显式配置 + root/容器场景下接受(高可见警告)
# - 普通桌面运行中的 --no-sandbox 被剥离并告警
from core import renderer_backend as _renderer_backend
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = _renderer_backend.sanitize_chromium_flags(
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"])
print(f"[渲染] 最终 QTWEBENGINE_CHROMIUM_FLAGS = {os.environ['QTWEBENGINE_CHROMIUM_FLAGS']!r}", flush=True)
# 导入主窗口类
from ui.views.main_window import MainWindow
def main():
# 1. 开启高 DPI 缩放支持 (让界面在 2K/4K 屏幕上不模糊)
# PyQt6 默认已经处理得很好了,但加上这句更稳妥
if hasattr(Qt, 'AA_EnableHighDpiScaling'):
QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
# 2. 初始化 QApplication 实例
app = QApplication(sys.argv)
# 极客细节:设置应用的全局字体 (可选)
# font = app.font()
# font.setFamily("Segoe UI") # Windows 推荐字体
# app.setFont(font)
# 防御:若系统解析出的默认字体 pointSize 无效(<=0),补一个合法值,
# 消除启动时 "QFont::setPointSize: Point size <= 0 (-1)" 的 Qt 警告(纯警告,非异常)
_app_font = app.font()
if _app_font.pointSize() <= 0:
_app_font.setPointSize(9)
app.setFont(_app_font)
print("🚀 GeekAgent-Studio 正在启动...")
print("--------------------------------------------------")
# 3. 实例化主窗口
window = MainWindow()
# 4. 显示窗口
window.show()
# 5. 进入主事件循环,并安全退出
sys.exit(app.exec())
if __name__ == "__main__":
main()
+301
View File
@@ -0,0 +1,301 @@
# haocode
> Windows 桌面 AI Agent 客户端 —— **PyQt6 外壳 + 本地 Web 渲染(WebView2 优先 / QtWebEngine 兜底)
> + 与 [pi](https://github.com/badlogic/pi) **1:1 对齐的 agent 核心**(工具调用 / 自动重试 / 上下文压缩)。**
能读写文件、执行 bash、带图片与 PDF 附件聊天、把「思考 / 工具调用 / 工具结果」按时间线持久化,
并在此基础上支持会话树分支、会话复制、实时上下文计量与右侧任务面板。
---
## 一、快速开始
### 1. 环境要求
| 项 | 要求 |
|---|---|
| 操作系统 | Windows 10 / 11 x64 |
| Python | **3.10**(开发环境:miniconda env `haocode` |
| WebView2 Runtime | Win11 自带;Win10 需装 [Evergreen Runtime](https://developer.microsoft.com/microsoft-edge/webview2/)(缺失会自动回落 QtWebEngine,仍可用) |
| Node.js | 可选,只有跑 `tests/test_math_extract.js` 需要 |
### 2. 安装依赖
```bash
pip install -r requirements.txt
```
> **不需要** pip 安装的运行时依赖(已随仓库提供,勿删):
> `vendor/webview2/`WebView2 .NET SDK + loader)、根目录 `WebView2Loader.dll`。
### 3. 配置 API Key
编辑 `data/config.json`
```jsonc
{
"providers": {
"deepseek": {
"api_key": "sk-填你自己的", // ← 交接版已清空,必须填
"base_url": "https://api.deepseek.com",
"models": ["deepseek-v4-flash"],
"model_contexts": { "deepseek-v4-flash": 1000000 } // 决定上下文用量条与压缩阈值
}
},
"default_provider": "deepseek",
"default_model": "deepseek-v4-flash",
"debug_window_autostart": true, // 启动时是否自动开「调试窗口」
"mode_switch": true, // 是否允许会话中途切换 chat ↔ worker
"webview_backend": "auto" // auto | webview2 | qtwebengine
}
```
### 4. 运行
```bash
python main.py
```
首次运行会自动在 `data/` 下创建**空的** `chat_history.db`(含一条初始对话)。
左侧栏「+ 新建对话」开始聊天;输入框上方可贴图/拖文件/截屏(Ctrl+Shift+S)。
### 5. 打包 exe(可选)
```bash
python -m PyInstaller haocode.spec --distpath "输出目录" --noconfirm
```
- 产物为 **onedir**(一个文件夹,里面 `haocode.exe` 双击即用)
- **不含数据库**:首次运行在 exe 同级 `data/` 自建空库
- 冻结版没有控制台,启动日志写 **exe 同级 `haocode.log`**`pyi_rth_trace.py` 钩子)
- ⚠️ 打包前务必确认:`main.py` 顶部的 stdout/stderr UTF-8 保护存在(否则 `print` 里的 emoji 会在 GBK 环境直接崩,表现为"双击没反应")
---
## 二、架构总览
```
┌──────────────────────────────────────────────────────────────────────────┐
│ ① 表现层 ui/views/*.py (PyQt6) │
│ main_window.py 主窗口 / bash_panel.py 右侧任务面板 / debug_window.py │
└───────────────┬──────────────────────────────────────────────────────────┘
│ QWebChannelchat_bridge.pyPython → JS 单向调用)
┌───────────────▼──────────────────────────────────────────────────────────┐
│ ② 渲染层 ui/web/index.html + app.js + style.css + KaTeX/highlight
│ 容器二选一:wv2_view.py(WebView2) / custom_web_page.py(QtWebEngine) │
└───────────────▲──────────────────────────────────────────────────────────┘
│ 事件信号(Qt Signal/Slot,跨线程 queued
┌───────────────┴──────────────────────────────────────────────────────────┐
│ ③ 引擎层 core/llm_engine.py │
│ AgentWorker(worker 模式) / ChatWorker(chat 模式) / TitleWorker(标题) │
└───────────────┬──────────────────────────────────────────────────────────┘
┌───────────────▼──────────────────────────────────────────────────────────┐
│ ④ Agent 核心 core/agent/pi 1:1 移植) │
│ loop.py 循环 → stream_fn.py 流式+异常分类 → recovery.py 重试/压缩决策 │
│ tools.py 四个工具(read/bash/write/edit · compaction.py 上下文压缩 │
│ context.py 上下文组装与计量 · types.py 数据结构 │
└───────────────┬──────────────────────────────────────────────────────────┘
┌───────────────▼──────────────────────────────────────────────────────────┐
│ ⑤ 存储层 core/db_manager.pySQLite:会话树 / 消息 / 压缩标记 / 附件) │
└──────────────────────────────────────────────────────────────────────────┘
```
**一句话数据流**
`输入框 → main_window 组装上下文(core/agent/context) → AgentWorker 跑 agent 循环 →
LLM 流式返回(stream_fn) → 工具在本地执行(tools) → 事件经 chat_bridge 推到 Web 层渲染 →
结束时整轮(正文/思考/工具时间线/usage)落库(db_manager),并成为会话树的新叶子`
---
## 三、目录树(每行说明这是什么)
```
haocode/
├── main.py 程序入口:渲染开关 → UTF-8 保护 → QApplication → MainWindow
├── SYSTEM_PROMPT.md ★ agent 系统提示词(每次请求注入,不进历史、不占压缩)
├── requirements.txt 依赖清单(只列代码真实用到的包)
├── haocode.spec PyInstaller 打包配置(onedir / 不含 db / 带启动日志钩子)
├── pyi_rth_trace.py 打包运行时钩子:冻结版 stdout+stderr → exe 同级 haocode.log
├── WebView2Loader.dll WebView2 loader(工作目录兜底路径)
├── core/ ★ 后端核心(无界面依赖)
│ ├── db_manager.py SQLite 存储层:会话/消息链表树/压缩标记/附件/复制会话
│ ├── llm_engine.py Qt 线程桥:AgentWorker / ChatWorker / TitleWorker + 配置与提示词加载
│ ├── webview2.py WebView2 集成:pythonnet 环境、单实例锁、无头守卫、残留进程清理
│ ├── debug_log.py 调试事件总线(供调试窗口「对话消息 / 应用日志」两页)
│ └── agent/ ★ pi 1:1 agent 核心(9 个文件,见 Frame.md
│ ├── agent.py Agent 对象:状态、事件订阅、prompt/continue、steering 队列
│ ├── loop.py agent 主循环:流式一轮 → 工具批执行 → 轮末钩子 → 停止判定
│ ├── stream_fn.py OpenAI 流式调用 + 异常分类(classify_error
│ ├── recovery.py 重试/压缩三路决策(对照 pi retry.ts / overflow.ts
│ ├── compaction.py 上下文压缩(摘要切点、保留尾巴、标记生成)
│ ├── context.py 上下文组装、token 估算、usage 锚定
│ ├── tools.py 四个工具实现:read / bash / write / edit+ 参数校验)
│ └── types.py ModelConfig / AgentConfig / AgentMessage / AgentTool… 数据结构
├── ui/ ★ 表现层
│ ├── views/ PyQt6 窗口与逻辑
│ │ ├── main_window.py ★主窗口(6000 行,全项目最核心):布局/样式/流式状态/附件/入库
│ │ ├── bash_panel.py 右侧「任务面板」:运行中/已完成两栏、层卡片、拖拽调宽
│ │ ├── chat_bridge.py QWebChannel 桥:把 Python 调用翻译成 JS 函数调用
│ │ ├── wv2_view.py WebView2 容器控件(QWidget + 原生子窗口)
│ │ ├── custom_web_page.py QtWebEngine 容器控件(回落路径,含 QWebChannel 注入)
│ │ ├── debug_window.py 调试窗口:事件流 + 应用日志 + 命令输入
│ │ └── system_tools/ 系统级能力(见 Frame.md
│ │ ├── file_reader.py 文本/代码文件读取与二进制黑名单
│ │ ├── global_hotkey.py 全局热键注册(Ctrl+Shift+S 截屏)
│ │ └── screen_capture.py 区域截屏
│ └── web/ 本地 Web 渲染层(离线,无 CDN)
│ ├── index.html 页面骨架 + 三个本地库的引入
│ ├── app.js ★前端全部逻辑:消息渲染/流式增量/时间线/KaTeX/滚动
│ ├── style.css 全部样式(气泡/思考块/工具 chip/紧凑模式)
│ ├── marked.min.js Markdown 渲染
│ ├── dompurify.min.js HTML 消毒(配合 marked
│ ├── highlight/ 代码高亮(highlight.js + atom-one-dark
│ └── katex/ 公式渲染(KaTeX + 字体)
├── data/ 运行时数据目录
│ ├── config.json 供应商/模型/开关配置(★ 需填 API Key)
│ ├── chat_history.db 聊天主库(**不在交接版内**,首次运行自建空库)
│ └── (运行后还会出现) debug_session.log · app_instance.lock · attachments/ · wv2_cache/
├── vendor/webview2/ WebView2 .NET SDKnet462 Core.dll + webview2loader_x64.dll
├── svg/ 界面图标 18 个(panel.svg 左栏 / panel_right.svg 右栏 / 模式图标…)
├── tools/builtin_tools/
│ └── pdf_reader.py PDF 解析(extract_pdf_text / extract_pdf_images,被主窗口 import
├── tests/ ★ 测试与调试工具(见第五节)
└── 历史文档(保留备查,非最新结构说明)
├── ARCHITECTURE.md 早期架构文档(其中部分目录已在交接版移除)
├── readme_our.md 早期设计/规划稿(973 行)
└── 黑边两现象分析报告.md Windows 窗口缩放黑边问题的分析记录
```
---
## 四、我要改 X,该去哪个文件?(功能 → 文件索引)
| 需求 | 文件 | 关键位置 |
|---|---|---|
| 改主界面布局 / 气泡 / 输入框 / 主题 | `ui/views/main_window.py` | `setup_ui()``setup_stylesheet()` |
| 改前端渲染(Markdown / 公式 / 流式增量) | `ui/web/app.js` + `style.css` | `marked.parse` 统一拦截点、`updateMessage` |
| 右侧任务面板(层卡片/状态) | `ui/views/bash_panel.py` | `BashPanel` / `BashLayer` / `_Section` |
| Python 调前端 JS | `ui/views/chat_bridge.py` | 每个方法 = 一个 JS 函数 |
| 数据库结构 / 会话树 / 附件 | `core/db_manager.py` | `add_message``get_message_chain``copy_session` |
| **系统提示词** | `SYSTEM_PROMPT.md` | 直接改,每次请求重新读取(无需重启) |
| 工具的实现与参数校验 | `core/agent/tools.py` | `tool_read` / `tool_bash` / `tool_write` / `tool_edit` |
| 工具调用循环 / 停止条件 | `core/agent/loop.py` | `run_agent_loop``execute_tool_calls` |
| 重试与压缩策略 | `core/agent/recovery.py` | `_handle_post_agent_run`(三路决策) |
| 上下文压缩算法 | `core/agent/compaction.py` | `compact_context``should_compact` |
| token 估算 / usage 锚定 | `core/agent/context.py` | `estimate_context_tokens``calculate_context_tokens` |
| 模型请求 / 流式解析 / 错误分类 | `core/agent/stream_fn.py` | `openai_stream``classify_error` |
| WebView2 行为(锁/无头/清场) | `core/webview2.py` | `get_environment``acquire_instance_lock` |
| 调试窗口 | `ui/views/debug_window.py` + `core/debug_log.py` | — |
| 打包 | `haocode.spec` + `pyi_rth_trace.py` | — |
---
## 五、测试方式
### 1. 两条铁律(写新测试必须遵守)
1. **不得污染真实数据库**:测试启动时必须把 `core.db_manager._DEFAULT_DB` 指向临时文件
```python
import core.db_manager as _dbm
_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"t_{os.getpid()}.db")
# 必须在 import MainWindow 之前
```
2. **不得污染真实配置**(会写 `config.json` 的功能):把 `HAOCODE_CONFIG_FILE` 指向临时文件
### 2. 跑测试(建议先设两个环境变量)
```bat
set PYTHONIOENCODING=utf-8
set QT_QPA_PLATFORM=offscreen :: 只有带 GUI 的 smoke_* 需要
```
**A. 纯逻辑测试(无 GUI,秒级)**
```bash
python tests/run_tests.py # agent 核心(流式/工具/重试/压缩) 41 项
python tests/test_tool_params.py # 四个工具的参数校验与错误串 35 项
python tests/test_compaction_persist.py # 压缩标记持久化与上下文截断 41 项
python tests/test_copy_session.py # 会话复制(含附件深拷贝、分支、标题递增) 54 项
python tests/test_bash_stream.py # bash 实时输出/超时杀进程树/50KB 截断 30 项
python tests/test_error_persist.py # 失败轮次入库与回放取舍(含旧库迁移安全) 39 项
python tests/test_wv2_guard.py # WebView2 双守卫(无头/多实例) 10 项
python tests/test_debug_window.py # 调试窗口 22 项
python tests/test_think_code_neutral.py # 思考块/代码块中性化 10 项
python tests/test_file_attach.py # 附件类型判定
python tests/test_pdf_reader.py # PDF 文本/图片解析
node tests/test_math_extract.js # 公式提取(前端 JS 逻辑) 39 项
```
**B. GUI 离屏端到端(需 `QT_QPA_PLATFORM=offscreen`**
```bash
python tests/smoke_offscreen.py # 主窗口起得来 + 三个基础链路 8 项
python tests/smoke_mode.py # chat ↔ worker 模式切换 16 项
python tests/smoke_copy_session.py # 复制会话 UI 全链路
python tests/smoke_bash_panel.py # 右侧任务面板全链路(含折叠/拖拽调宽) 116 项
```
**C. 需要真机/真 API 的(默认不用跑)**
`tests/smoke_live_guard.py`、`smoke_midswitch.py`、`smoke_persist.py`、`smoke_probe.py`、
`smoke_repro_real.py`、`smoke_timeline.py`、`smoke_manual.py`、`diag_live_*.py`、`verify_onscreen.py`
—— 这些是开发期在真窗口/真模型上跑的工具,交接后按需使用。
### 3. 基线(本次交接版实测全绿)
```
run_tests 41/41 · tool_params 35/35 · compaction 41/41 · copy_session 54/54
bash_stream 30/30 · error_persist 39/39 · wv2_guard 10/10 · debug_window 22/22
think_code_neutral 10/10 · math_extract 39/39
smoke_offscreen 8/8 · smoke_mode 16/16 · smoke_copy_session 全通过 · smoke_bash_panel 116/116
```
### 4. 辅助工具
```bash
python tests/check_db_migration.py <某个 .db 或备份 .zip>
# 只读校验:迁移(新增列)后旧库数据是否 100% 原样保留(消息数/父指针/叶子/分支点逐项比对)
```
---
## 六、已知限制
1. **仅 Windows**WebView2 与 `bash` 工具都按 Windows 语义实现(cmd.exe + taskkill /T)。
2. **`bash` 工具的真实 shell 是 cmd.exe**(不是 git-bash):`;` 不是命令分隔符、
`$VAR` 不展开、`cd` 不跨命令保持 —— 系统提示词里已写明这些陷阱与正确写法。
3. **HTTP 代理/流式**:模型请求走 openai SDK,单次 180s 超时,失败按 2s/4s/8s 重试 3 次。
4. **打包体积**:默认带 QtWebEngine 兜底,产物约 575 MB;若确定只用 WebView2
可从 `haocode.spec` 去掉 `PyQt6.QtWebEngine*` 的 `collect_all`(降到约 110 MB)。
5. `data/attachments/` 里的图片/PDF 是**文件系统**资源,删库不会删它们;删会话时才会连带清理。
6. 冻结版与源码版会共用同一个库的**唯一例外**:把 exe 放在源码树的 `dist/` 下时
`exe/../../data/` 存在即共用 `chat_history.db`);放到桌面等其它位置则用 exe 同级 `data/`。
7. **运行时会往项目根目录写诊断产物**(开发期排障用,可直接删):
`compaction_diag.log`(压缩决策日志)、`stream_diag.log`(前端流式体检)、
`diag_shot_*.png`(每轮回复结束时的画面快照)、`data/debug_session.log`(调试窗口日志)。
不想要可自行注释 `ui/views/main_window.py` 里的 `diag_log()` / `diag_shot` 调用点。
---
## 七、交接版说明(haocode_0 相对原项目做了什么)
| 动作 | 内容 |
|---|---|
| ✅ 保留 | 全部源码、`SYSTEM_PROMPT.md`、`vendor/webview2/`、18 个 svg、`ui/web/`(含 KaTeX/highlight)、正式测试套件、`haocode.spec` + 打包钩子 |
| 🗑 删除·空文件 | `agents/`3 个全空)、`workspace/`4 个全空)、`tools/{registry,conda_env}.py`、`tools/builtin_tools/{file_ops,web_search}.py`、`core/{async_sync,memory_manager,prompt_templates}.py`、`ui/views/components.py`、`ui/assets/`(空 style.qss)、`tests/{test_ast,test_replace}.py`、根目录 0 字节文件 —— **均已确认零引用**(`__init__.py` 属于包结构标记,全部保留) |
| 🗑 删除·测试产物 | 根目录 `diag_shot_*.png`(36)、`*.log`、`_fadechk2.py`、`inspect_*.py`、`read_all_py.py`、`ssh_helper.py`、`tmp_timeout_probe.py`、`flowkit.db`、`stress_report.json` 等;`tests/_tmp*`81 项,含 `_tmp_resize_vis/` 与全部临时日志) |
| 🗑 删除·数据库 | `data/chat_history.db` 及全部 `.bak/.pre_clean`、`data/attachments/`、`data/debug_session.log`、`data/wv2_cache/` → **首次运行自动新建空库(含初始对话)** |
| 🗑 删除·其它 | `__pycache__/` 全部、旧封装 `geekagent.spec`、未使用的 `untitled.ui` |
| ✏️ 重写 | `requirements.txt`AST 扫描核对)、`readme.md`(本文件)、`Frame.md`(逐目录细节) |
| ⚠️ 注意 | `data/config.json` 的 `api_key` **已清空**(交接安全),请填入自己的 Key;`.txt` 文本示例按你的要求**全部保留**(`_out.txt`、`_t0.txt` |
> 详细到"每个子文件夹/每个文件干什么、关键类与函数叫什么",见 **`Frame.md`**。
+58
View File
@@ -0,0 +1,58 @@
# haocode 运行依赖
# ---------------------------------------------------------------------------
# 环境:Windows 10/11 x64 + Python 3.10(开发环境为 miniconda env: haocode
# 安装:pip install -r requirements.txt
# 说明:本清单只列**代码真实 import 到**的第三方包(已用 AST 全量扫描核对),
# 不含开发机上其它项目的包(numpy / matplotlib / pillow / paramiko 等均未使用)。
# ---------------------------------------------------------------------------
# ---- GUI 框架(PyQt6----
PyQt6==6.10.2
PyQt6-Qt6==6.10.2
# ---- 渲染回退:QtWebEngineWebView2 不可用 / 无头 / 多实例时自动使用)----
# 注:WebView2 是首选渲染器,但它依赖微软运行时 + vendor/webview2/ 里的 .NET SDK
# 不走 pipQtWebEngine 是本项目自带的兜底渲染路径,必须装。
PyQt6-WebEngine==6.10.0
PyQt6-WebEngine-Qt6==6.10.2
# ---- 大模型 SDKOpenAI 兼容接口;DeepSeek / opencode-go / 自建反代均走它)----
openai==2.26.0
# ---- WebView2 绑定:pythonnetimport clr+ 其加载器(仅 WindowsLinux 自动跳过)----
pythonnet==3.1.0; sys_platform == "win32"
clr_loader==0.3.1; sys_platform == "win32"
# ---- PDF 附件解析(ui/views/main_window.py → tools/builtin_tools/pdf_reader.py----
PyMuPDF==1.28.0
# ===========================================================================
# Linux 源码运行(Ubuntu 22.04/24.04 x64,仅 QtWebEngine;不装 WebView2 任何东西)
# ===========================================================================
# 1) pip install -r requirements.txt # pythonnet/clr_loader 带平台 markerLinux 自动跳过
# 2) 安装 QtWebEngine 运行所需的系统库(apt,按需选装):
# sudo apt install -y libnss3 libxkbcommon0 libfontconfig1 libdbus-1-3 \
# libgl1 libegl1 libasound2t64 # 24.04 用 libasound2t6422.04 用 libasound2
# 离屏测试另需:QT_QPA_PLATFORM=offscreen
# 3) 运行:python3.10 main.py
# - 只使用 QtWebEngine;每实例独立 profiledata/webengine/profile_<pid>_*
# - root/容器启动若页面空白:显式 QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox" python3.10 main.py
# (启动会打印高可见风险警告;普通桌面不要加)
# ===========================================================================
# 以下仅在需要时安装
# ===========================================================================
# ---- 打包 exe(可选)----
# PyInstaller==6.21.0
# pyinstaller-hooks-contrib==2026.6
# ===========================================================================
# 不需要 pip 安装的运行时依赖(重要)
# ===========================================================================
# 1) vendor/webview2/ WebView2 .NET SDKCore.dll+ webview2loader_x64.dll
# 已随仓库提供,勿删;缺失 → 自动回落 QtWebEngine
# 2) WebView2Loader.dll 项目根目录的 loader(CWD 兜底路径)
# 3) WebView2 Runtime(系统级) Win11 自带;Win10 需安装 Evergreen Runtime
# 4) Node.js 仅 tests/test_math_extract.js 需要(可选)
# 5) pytest 不需要!tests/run_tests.py 自带 harness 并 stub 掉 pytest
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" role="img" aria-label="正确">
<path d="M14 24L21 31L34 17" stroke="#22C55E" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12">
<!-- 展开态箭头(朝下):圆头描边 chevron -->
<path d="M3.2 4.2 L6 7.4 L8.8 4.2" fill="none" stroke="#98a1b3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12">
<!-- 收起态箭头(朝右):圆头描边 chevron -->
<path d="M4.2 3.2 L7.4 6 L4.2 8.8" fill="none" stroke="#98a1b3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" role="img" aria-label="错误">
<path d="M14 14L34 34M34 14L14 34" stroke="#EF4444" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 230 B

+1
View File
@@ -0,0 +1 @@
<svg t="1787580199384" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9953" width="200" height="200"><path d="M266.3 750.6C70.3 715.1 67 476.9 67 476.9c-136.8 207.7-40.5 398.6 79.4 472.9 28.7 16.9 59.1 28.7 89.5 30.4 153.7 11.8 172.3-70.9 172.3-70.9s54-123.3-141.9-158.7zM77.1 296.2c-1.7 5.1-1.7 11.8-3.4 16.9-3.4 23.6-3.4 45.6-1.7 67.6 3.4-22 8.4-43.9 15.2-64.2l5.1-15.2c42.2-79.4 108.1-150.3 192.5-201-1.7-5.1-3.4-11.8-8.4-16.9C242.6 44.6 148 32.7 90.6 88.5 33.2 144.2 33.2 248.9 67 287.8c3.3 3.4 6.7 6.7 10.1 8.4zM718.9 96.9c1.7 0 1.7 1.7 3.4 1.7 86.1 43.9 157.1 108.1 204.4 184.1 10.1 0 20.3-5.1 28.7-15.2 28.7-40.5 20.3-145.2-40.5-195.9-62.6-52.4-153.8-33.8-185.9 6.7-1.7 0-3.4 0-5.1-1.7-11.8-1.7-23.6-3.4-33.8-5.1-23.6-1.7-45.6-1.7-67.6 0 22 1.7 43.9 6.8 65.9 13.5 10.2 3.5 20.4 6.9 30.5 11.9zM450.4 644.2c0 8.4 27 27 60.8 27s60.8-16.9 60.8-27c0-10.1-27-18.6-60.8-16.9-33.8 0-60.8 6.7-60.8 16.9zM798.3 559.7c42.2-22 59.1-136.8 13.5-222.9s-116.5-104.7-174-74.3c-57.4 30.4-81.1 101.3-33.8 185.8 45.7 86.1 152.1 135.1 194.3 111.4zM675 355.3c18.6 0 33.8 15.2 33.8 33.8 0 18.6-15.2 33.8-33.8 33.8-18.6 0-33.8-15.2-33.8-33.8 0-18.6 15.2-33.8 33.8-33.8zM401.4 262.4C344 232 273 250.6 227.4 336.8c-45.6 86.1-28.7 201 15.2 222.9 42.2 23.6 148.6-25.3 194.2-111.5 44-84.4 22-153.7-35.4-185.8z m-38.9 160.5c-18.6 0-33.8-15.2-33.8-33.8 0-18.6 15.2-33.8 33.8-33.8 18.6 0 33.8 15.2 33.8 33.8 0 18.6-15.2 33.8-33.8 33.8zM825.3 885.7s-185.8-42.2-211.1-47.3c-25.3-3.4-43.9 13.5-42.2 28.7 0 0-5.1 27 52.4 32.1 59 6.7 200.9-13.5 200.9-13.5zM803.4 600.2S511.2 762.3 472.4 786c-37.2 23.6-42.2 67.6-20.3 82.8 0 0 23.6 40.5 106.4-18.6 82.7-59.1 244.9-250 244.9-250z" fill="#2c2c2c" p-id="9954"></path><path d="M958.8 483.7s-5.1 238.1-202.7 271.9c-86.1 15.2-123.3 45.6-140.2 77.7 25.3 3.4 204.4 45.6 211.1 47.3l43.9 10.1-43.9 3.4c-5.1 0-111.5 15.2-177.3 15.2-10.1 0-18.6 0-27-1.7-5.1 0-10.1-1.7-13.5-1.7 1.7 3.4 3.4 5.1 3.4 5.1s18.6 82.8 170.6 72.6c153.6-10.1 342.8-239.8 175.6-499.9z" fill="#2c2c2c" p-id="9955"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788797540643" class="icon" viewBox="0 0 1025 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6475" xmlns:xlink="http://www.w3.org/1999/xlink" width="200.1953125" height="200"><path d="M304.607147 576c-17.066667 0-34.133333-8.533333-46.933334-17.066667-12.8-12.8-17.066667-29.866667-17.066666-46.933333 0-17.066667 8.533333-34.133333 17.066666-46.933333 4.266667-4.266667 12.8-12.8 21.333334-12.8 17.066667-8.533333 34.133333-8.533333 46.933333 0 8.533333 4.266667 12.8 8.533333 21.333333 12.8 12.8 12.8 17.066667 29.866667 17.066667 46.933333 0 17.066667-8.533333 34.133333-17.066667 46.933333-8.533333 8.533333-25.6 17.066667-42.666666 17.066667zM513.673813 576c-8.533333 0-17.066667 0-25.6-4.266667-8.533333-4.266667-12.8-8.533333-21.333333-12.8-12.8-12.8-17.066667-29.866667-17.066667-46.933333 0-17.066667 8.533333-34.133333 17.066667-46.933333 25.6-25.6 68.266667-25.6 89.6 0 12.8 12.8 17.066667 29.866667 17.066667 46.933333 0 17.066667-8.533333 34.133333-17.066667 46.933333-8.533333 4.266667-12.8 12.8-21.333333 12.8s-12.8 4.266667-21.333334 4.266667zM722.74048 576c-8.533333 0-17.066667 0-25.6-4.266667-8.533333-4.266667-12.8-8.533333-21.333333-12.8-12.8-12.8-17.066667-29.866667-17.066667-46.933333 0-17.066667 8.533333-34.133333 17.066667-46.933333 25.6-25.6 68.266667-25.6 89.6 0 12.8 12.8 17.066667 29.866667 17.066666 46.933333 0 17.066667-8.533333 34.133333-17.066666 46.933333-8.533333 4.266667-12.8 12.8-21.333334 12.8s-12.8 4.266667-21.333333 4.266667z" fill="#2c2c2c" p-id="6476"></path><path d="M44.34048 972.8c-12.8 0-25.6-4.266667-34.133333-17.066667-8.533333-12.8-12.8-29.866667-8.533334-42.666666l64-170.666667C23.007147 669.866667 1.673813 593.066667 1.673813 512 1.673813 260.266667 232.073813 51.2 513.673813 51.2S1025.673813 260.266667 1025.673813 512s-230.4 460.8-512 460.8c-81.066667 0-162.133333-17.066667-230.4-46.933333l-230.4 42.666666c-4.266667 4.266667-4.266667 4.266667-8.533333 4.266667z m243.2-132.266667c4.266667 0 12.8 0 17.066667 4.266667 59.733333 29.866667 132.266667 42.666667 204.8 42.666667 234.666667 0 426.666667-166.4 426.666666-375.466667s-192-375.466667-426.666666-375.466667S87.007147 307.2 87.007147 512c0 72.533333 21.333333 136.533333 64 200.533333 8.533333 12.8 8.533333 25.6 4.266666 38.4l-46.933333 123.733334 170.666667-34.133334h8.533333z" fill="#2c2c2c" p-id="6477"></path></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788830687889" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8728" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M814.933333 1009.066667c-38.4 0-74.666667-14.933333-104.533333-42.666667l-258.133333-256 76.8-76.8 12.8 12.8 29.866666-29.866667 46.933334 46.933334-46.933334 46.933333 196.266667 196.266667c23.466667 23.466667 64 23.466667 89.6 0l53.333333-53.333334c25.6-25.6 25.6-64 0-89.6l-196.266666-196.266666-46.933334 46.933333-46.933333-46.933333 29.866667-29.866667-10.666667-14.933333 76.8-76.8 256 258.133333c57.6 57.6 57.6 151.466667 0 209.066667l-53.333333 53.333333c-29.866667 29.866667-66.133333 42.666667-104.533334 42.666667zM328.533333 490.666667l-85.333333-83.2-113.066667-59.733334L6.4 189.866667 196.266667 0l157.866666 123.733333 59.733334 115.2 89.6 87.466667-59.733334 61.866667-130.133333-130.133334 10.666667-8.533333-36.266667-70.4-85.333333-64-81.066667 81.066667 64 85.333333 70.4 36.266667 8.533333-10.666667 123.733334 123.733333z" p-id="8729"></path><path d="M179.2 1009.066667c-29.866667 0-57.6-10.666667-81.066667-34.133334l-53.333333-53.333333c-44.8-44.8-44.8-117.333333 0-162.133333l452.266667-452.266667c-12.8-83.2 12.8-166.4 72.533333-226.133333C650.666667 0 772.266667-19.2 874.666667 34.133333l51.2 27.733334-149.333334 149.333333 32 32 149.333334-149.333333 27.733333 51.2c53.333333 102.4 34.133333 224-46.933333 305.066666-59.733333 59.733333-145.066667 85.333333-226.133334 72.533334L260.266667 977.066667c-21.333333 21.333333-51.2 32-81.066667 32z m576-917.333334c-44.8 0-89.6 17.066667-123.733333 51.2-44.8 44.8-61.866667 108.8-44.8 168.533334l6.4 23.466666L106.666667 821.333333c-10.666667 10.666667-10.666667 29.866667 0 40.533334l53.333333 53.333333c10.666667 10.666667 29.866667 10.666667 40.533333 0l486.4-486.4 23.466667 6.4c59.733333 17.066667 125.866667-2.133333 168.533333-44.8 38.4-38.4 57.6-93.866667 51.2-145.066667L810.666667 364.8 657.066667 213.333333l119.466666-119.466666c-6.4-2.133333-14.933333-2.133333-21.333333-2.133334z" p-id="8730"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg t="1788704740994" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7644" width="200" height="200"><path d="M789.333333 345.6c12.8 21.333333 4.266667 46.933333-12.8 59.733333l-226.133333 140.8v256c0 25.6-17.066667 42.666667-42.666667 42.666667s-42.666667-17.066667-42.666666-42.666667v-251.733333L230.4 409.6c-21.333333-12.8-25.6-38.4-12.8-59.733333 12.8-21.333333 38.4-25.6 59.733333-17.066667L512 469.333333l221.866667-140.8c17.066667-8.533333 42.666667 0 55.466666 17.066667z" fill="#707070" p-id="7645"></path><path d="M913.066667 230.4L554.666667 25.6c-25.6-17.066667-59.733333-17.066667-85.333334 0L110.933333 230.4c-25.6 17.066667-42.666667 42.666667-42.666666 72.533333v413.866667c0 29.866667 17.066667 59.733333 42.666666 72.533333l358.4 204.8c25.6 17.066667 59.733333 17.066667 85.333334 0l358.4-204.8c25.6-17.066667 42.666667-42.666667 42.666666-72.533333V307.2c0-34.133333-17.066667-59.733333-42.666666-76.8z m-42.666667 486.4L512 925.866667l-358.4-204.8V307.2L512 98.133333l358.4 204.8v413.866667z" fill="#707070" p-id="7646"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#5b6472" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="侧边栏">
<rect x="3" y="4" width="18" height="16" rx="2.5"/>
<line x1="9.5" y1="4" x2="9.5" y2="20"/>
</svg>

After

Width:  |  Height:  |  Size: 317 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#5b6472" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="任务面板">
<rect x="3" y="4" width="18" height="16" rx="2.5"/>
<line x1="14.5" y1="4" x2="14.5" y2="20"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788836090677" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5475" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M852.9 147.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V156.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V156.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="5476"></path><path d="M290.5 214h-60v60h60v-60zM393.5 214h-60v60h60v-60zM806 214H591v60h215v-60zM852.9 417.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V426.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V426.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="5477"></path><path d="M290.5 484h-60v60h60v-60zM393.5 484h-60v60h60v-60zM806 484H591v60h215v-60zM852.9 687.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V696.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V696.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="5478"></path><path d="M290.5 754h-60v60h60v-60zM393.5 754h-60v60h60v-60zM806 754H591v60h215v-60z" p-id="5479"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788836438084" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5804" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M864 691.2h-704c-51.2 0-96 44.8-96 96v64c0 51.2 44.8 96 96 96h704c51.2 0 96-44.8 96-96v-64c0-51.2-44.8-96-96-96z m32 160c0 19.2-12.8 32-32 32h-704c-19.2 0-32-12.8-32-32v-64c0-19.2 12.8-32 32-32h704c19.2 0 32 12.8 32 32v64z" fill="" p-id="5805"></path><path d="M812.8 819.2m-44.8 0a44.8 44.8 0 1 0 89.6 0 44.8 44.8 0 1 0-89.6 0Z" fill="" p-id="5806"></path><path d="M864 384h-704C108.8 384 64 428.8 64 480v64c0 51.2 44.8 96 96 96h704c51.2 0 96-44.8 96-96v-64c0-51.2-44.8-96-96-96z m32 160c0 19.2-12.8 32-32 32h-704c-19.2 0-32-12.8-32-32v-64c0-19.2 12.8-32 32-32h704c19.2 0 32 12.8 32 32v64z" fill="" p-id="5807"></path><path d="M812.8 512m-44.8 0a44.8 44.8 0 1 0 89.6 0 44.8 44.8 0 1 0-89.6 0Z" fill="" p-id="5808"></path><path d="M864 76.8h-704c-51.2 0-96 38.4-96 96v64c0 51.2 44.8 96 96 96h704c51.2 0 96-44.8 96-96v-64c0-57.6-44.8-96-96-96z m32 160c0 19.2-12.8 32-32 32h-704c-19.2 0-32-12.8-32-32v-64c0-19.2 12.8-32 32-32h704c19.2 0 32 12.8 32 32v64z" fill="" p-id="5809"></path><path d="M812.8 204.8m-44.8 0a44.8 44.8 0 1 0 89.6 0 44.8 44.8 0 1 0-89.6 0Z" fill="" p-id="5810"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1788836426771" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5639" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M960 42.666667H64c-12.8 0-21.333333 8.533333-21.333333 21.333333v896c0 12.8 8.533333 21.333333 21.333333 21.333333h896c12.8 0 21.333333-8.533333 21.333333-21.333333V64c0-12.8-8.533333-21.333333-21.333333-21.333333z m-21.333333 896H85.333333V682.666667h853.333334v256z m0-298.666667H85.333333V384h853.333334v256z m0-298.666667H85.333333V85.333333h853.333334v256zM298.666667 768h-42.666667v85.333333h42.666667v-85.333333z m-85.333334 0H170.666667v85.333333h42.666666v-85.333333z m661.333334 21.333333h-170.666667v42.666667h170.666667v-42.666667zM298.666667 469.333333h-42.666667v85.333334h42.666667v-85.333334z m-85.333334 0H170.666667v85.333334h42.666666v-85.333334z m661.333334 21.333334h-170.666667v42.666666h170.666667v-42.666666zM298.666667 170.666667h-42.666667v85.333333h42.666667V170.666667z m-85.333334 0H170.666667v85.333333h42.666666V170.666667z m661.333334 21.333333h-170.666667v42.666667h170.666667V192z" fill="#4D4D4D" p-id="5640"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M827.107556 170.652444c-2.673778 0-5.603556 0.426667-8.732445 1.479112l-629.617778 209.863111c-15.616 5.219556-17.848889 18.531556-18.062222 24.021333-0.227556 5.475556 0.867556 18.915556 16.014222 25.415111l242.986667 104.135111a111.317333 111.317333 0 0 1 58.737778 58.737778l104.120889 242.972444c6.485333 15.160889 20.010667 16.341333 25.429333 16.028445 5.475556-0.241778 18.787556-2.460444 23.992889-18.062222l209.877333-629.632c4.664889-14.008889-2.915556-23.608889-6.4-27.079111-2.688-2.716444-9.088-7.879111-18.346666-7.879112m-210.389334 768a110.876444 110.876444 0 0 1-102.599111-67.768888l-104.149333-242.986667a26.24 26.24 0 0 0-13.880889-13.880889L153.088 509.866667a111.004444 111.004444 0 0 1-67.655111-107.420445 110.961778 110.961778 0 0 1 76.344889-101.404444L791.395556 91.164444A111.160889 111.160889 0 0 1 905.813333 118.186667a111.146667 111.146667 0 0 1 27.022223 114.389333L722.915556 862.193778A110.961778 110.961778 0 0 1 616.718222 938.666667" fill="#333333"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M827.107556 170.652444c-2.673778 0-5.603556 0.426667-8.732445 1.479112l-629.617778 209.863111c-15.616 5.219556-17.848889 18.531556-18.062222 24.021333-0.227556 5.475556 0.867556 18.915556 16.014222 25.415111l242.986667 104.135111a111.317333 111.317333 0 0 1 58.737778 58.737778l104.120889 242.972444c6.485333 15.160889 20.010667 16.341333 25.429333 16.028445 5.475556-0.241778 18.787556-2.460444 23.992889-18.062222l209.877333-629.632c4.664889-14.008889-2.915556-23.608889-6.4-27.079111-2.688-2.716444-9.088-7.879111-18.346666-7.879112m-210.389334 768a110.876444 110.876444 0 0 1-102.599111-67.768888l-104.149333-242.986667a26.24 26.24 0 0 0-13.880889-13.880889L153.088 509.866667a111.004444 111.004444 0 0 1-67.655111-107.420445 110.961778 110.961778 0 0 1 76.344889-101.404444L791.395556 91.164444A111.160889 111.160889 0 0 1 905.813333 118.186667a111.146667 111.146667 0 0 1 27.022223 114.389333L722.915556 862.193778A110.961778 110.961778 0 0 1 616.718222 938.666667" fill="#ffffff"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M170.666667 256.256c0-140.586667 160.512-220.885333 273.024-136.533333l341.205333 255.658666c91.093333 68.266667 91.093333 204.885333 0 273.152L443.733333 904.234667C331.178667 988.586667 170.666667 908.288 170.666667 767.701333V256.213333z m221.866666-68.266667C336.213333 145.834667 256 185.984 256 256.256v511.402667c0 70.314667 80.213333 110.464 136.533333 68.266666l341.205334-255.658666a85.333333 85.333333 0 0 0 0-136.576L392.490667 187.989333z" fill="#333333"></path>
</svg>

After

Width:  |  Height:  |  Size: 582 B

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M170.666667 256.256c0-140.586667 160.512-220.885333 273.024-136.533333l341.205333 255.658666c91.093333 68.266667 91.093333 204.885333 0 273.152L443.733333 904.234667C331.178667 988.586667 170.666667 908.288 170.666667 767.701333V256.213333z m221.866666-68.266667C336.213333 145.834667 256 185.984 256 256.256v511.402667c0 70.314667 80.213333 110.464 136.533333 68.266666l341.205334-255.658666a85.333333 85.333333 0 0 0 0-136.576L392.490667 187.989333z" fill="#ffffff"></path>
</svg>

After

Width:  |  Height:  |  Size: 582 B

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M550.4 277.333333h260.266667c56.554667 0 102.4 45.845333 102.4 102.4v375.466667c0 56.554667-45.845333 102.4-102.4 102.4H213.333333c-56.554667 0-102.4-45.845333-102.4-102.4V234.666667c0-37.704533 30.562133-68.266667 68.266667-68.266667h264.789333a68.266667 68.266667 0 0 1 58.1376 32.4864L550.4 277.333333z m259.84 528.695467c28.279467 0 51.2-22.9248 51.2-51.2v-375.466667c0-28.2752-22.920533-51.2-51.2-51.2H162.56v426.666667c0 28.2752 22.9248 51.2 51.2 51.2h596.48zM465.7152 226.197333A17.066667 17.066667 0 0 0 450.897067 217.6H179.2a17.066667 17.066667 0 0 0-17.066667 17.066667v41.634133L494.933333 277.333333l-29.218133-51.136z" fill="#555555"></path>
</svg>

After

Width:  |  Height:  |  Size: 762 B

View File
+74
View File
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""tests/_test_env.py —— 自动化测试的统一临时环境(P0-01 新增)
铁律(VERIFICATION.md §凭据与运行数据隔离):
1. 每个测试进程独立临时目录 + 最小临时配置;
2. 在 import 任何可能间接加载 MainWindow 的模块之前设置 HAOCODE_CONFIG_FILE
3. 在 import MainWindow 之前把 core.db_manager._DEFAULT_DB 指向临时数据库;
4. 写入、迁移、附件和截图产物只落到临时目录。
用法(放在测试文件顶部、任何 UI import 之前):
from tests._test_env import isolate
isolate("bashpanel", config={"providers": {}, "mode_switch": True})
说明:
· 临时目录按 进程 pid 隔离,测试进程互不干扰;
· 默认临时配置为最小可启动结构(空 providers + 测试 provider);
· 本模块不 import 任何 PyQt 模块,可在无 GUI 环境中安全调用。
"""
import json
import os
import sys
import tempfile
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_TESTS_DIR)
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
def default_config() -> dict:
"""最小可启动临时配置(不含任何真实凭据)。"""
return {
"providers": {
"testprov": {
"api_key": "test-key-not-real",
"base_url": "http://127.0.0.1:9/v1",
"models": ["test-model"],
"model_contexts": {"test-model": 100000},
}
},
"default_provider": "testprov",
"default_model": "test-model",
}
def isolate(tag: str = "t", config: dict | None = None) -> dict:
"""创建临时配置 + 临时数据库并完成重定向。必须在 import MainWindow 之前调用。
返回 {"base": 临时目录, "config": 临时配置路径, "db": 临时数据库路径}。
"""
base = os.path.join(tempfile.gettempdir(),
f"haocode_test_{tag}_{os.getpid()}")
os.makedirs(base, exist_ok=True)
cfg_path = os.path.join(base, "config.json")
with open(cfg_path, "w", encoding="utf-8") as f:
json.dump(config if config is not None else default_config(),
f, ensure_ascii=False, indent=2)
db_path = os.path.join(base, "chat_history.db")
for p in (db_path, db_path + "-wal", db_path + "-shm"):
if os.path.exists(p):
os.remove(p)
os.environ["HAOCODE_CONFIG_FILE"] = cfg_path
# P1-03QtWebEngine 独立 profile 重定向到临时目录(源码运行才在 data/webengine/
os.environ.setdefault("HAOCODE_WEBENGINE_PROFILE_DIR", os.path.join(base, "webengine_profile"))
import core.db_manager as _dbm
_dbm._DEFAULT_DB = db_path
return {"base": base, "config": cfg_path, "db": db_path}
+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()
+209
View File
@@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
"""P2-02 诊断:右侧 Bash 面板滚动条与横纵交汇角(离屏)
测量并断言:
S1 代码框(#bl_code)横滚动条实际厚度 = 8px、sizeHint 一致
S2 代码框竖滚动条实际厚度 = 8px、sizeHint 一致
S3 section 滚动区(#bl_scroll)竖滚动条实际厚度 = 8px
S4 面板滚动条箭头 extent = 0(箭头隐藏)
S5 交汇角像素 = 代码框背景 #fbfcfe(无原生亮色 corner 方块)
S6 无泄漏:未命名 QPlainTextEdit 的滚动条仍是原生口径(≠8px、箭头>0)
S7 无泄漏:附件预览滚动条保持自身 6px 口径
并生成局部截图(面板全貌 + 代码框角落放大)打印测量值。
运行: QT_QPA_PLATFORM=offscreen python tests/diag_panel_scrollbar.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")
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
from tests._test_env import isolate # noqa: E402
_TMP = isolate("panelscroll", config={"providers": {}, "mode_switch": True})
_DB_TMP = _TMP["db"]
_CFG_TMP = _TMP["config"]
from PyQt6.QtWidgets import (QApplication, QPlainTextEdit, QStyle, # noqa: E402
QStyleOptionSlider) # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from PyQt6.QtCore import Qt # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
app = QApplication(sys.argv)
ok = True
OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "docs", "agent-handoff", "evidence")
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=300):
for _ in range(int(ms / 20) + 1):
app.processEvents()
QTest.qWait(20)
def arrow_extent(sb, orient=Qt.Orientation.Vertical):
"""滚动条箭头子控件(sub-line)的实际尺寸(px):QSS 把 add-line/sub-line 置 0 后应为 0。
用 sb.style()(样式表代理风格)才能反映 QSS 效果;PyQt6 参数序 = (cc, opt, sc, widget)。
返回 -2 表示无法测量(样式代理缺失等),调用方不得把 -2 当作 0。"""
try:
st = sb.style()
if orient == Qt.Orientation.Vertical:
return st.subControlRect(QStyle.ComplexControl.CC_ScrollBar,
QStyleOptionSlider(),
QStyle.SubControl.SC_ScrollBarSubLine, sb).height()
return st.subControlRect(QStyle.ComplexControl.CC_ScrollBar,
QStyleOptionSlider(),
QStyle.SubControl.SC_ScrollBarSubLine, sb).width()
except Exception as e:
print(f" (arrow_extent 测量异常: {e})", flush=True)
return -1
window = MainWindow()
window.resize(1400, 800)
window.show()
settle(400)
panel = window.bash_panel
panel.expand_btn.click()
settle(500)
# ---- 造数据:一个展开的层,out_box 同时触发横/纵滚动条 ----
panel.on_started("sc1", "bash", {"command": "python long_report.py --all --verbose"})
panel.on_finished("sc1", "bash", True,
"$ python long_report.py --all --verbose\n"
+ "\n".join(f"line-{i:03d}" for i in range(60))
+ "\n[exit 0] (1.2s)")
settle(300)
lay = panel._layers["sc1"]
lay.toggle()
settle(200)
# 超宽单行(NoWrap)→ 横向滚动条;再追加 60 行 → 同时触发纵向滚动条
panel._layers["sc1"].out_box.setPlainText("X" * 3000 + "\n"
+ "\n".join(f"tail-{i:03d}" for i in range(60)))
settle(200)
ob = lay.out_box
sbh = ob.horizontalScrollBar()
sbv = ob.verticalScrollBar()
check("S0.1 前置:横滚动条可见(超宽单行)", sbh.isVisible(), str(sbh.isVisible()))
check("S0.2 前置:竖滚动条可见(60+ 行超出 230px 上限)", sbv.isVisible(), str(sbv.isVisible()))
# ---- S1/S2 代码框滚动条厚度 ----
check("S1 代码框横滚动条实际厚度 = 8px", sbh.height() == 8, f"h={sbh.height()}")
check("S1b 代码框横滚动条 sizeHint 厚 = 8px", sbh.sizeHint().height() == 8,
f"{sbh.sizeHint().height()}")
check("S2 代码框竖滚动条实际厚度 = 8px", sbv.width() == 8, f"w={sbv.width()}")
check("S2b 代码框竖滚动条 sizeHint 宽 = 8px", sbv.sizeHint().width() == 8,
f"{sbv.sizeHint().width()}")
# ---- S3 section 滚动区竖滚动条厚度 ----
# 让运行中栏溢出:再加 12 个已完成层(复用 P11 的溢出手法不必,层数即可)
for i in range(12):
cid = f"scf{i}"
panel.on_started(cid, "bash", {"command": f"cmd-{cid}"})
panel.on_finished(cid, "bash", True, f"$ cmd-{cid}\nok\n[exit 0] (0.1s)")
settle(300)
sdv = panel.sec_done.scroll.verticalScrollBar()
check("S0.3 前置:已完成栏溢出、竖滚动条可见", sdv.isVisible(), str(sdv.isVisible()))
check("S3 section 竖滚动条实际厚度 = 8px", sdv.width() == 8, f"w={sdv.width()}")
# ---- S4 箭头 extent ----
print(f" 测量:代码框 H 箭头 extent = {arrow_extent(sbh, Qt.Orientation.Horizontal)}px, "
f"V = {arrow_extent(sbv)}px; section V = {arrow_extent(sdv)}px",
flush=True)
check("S4 代码框横滚动条箭头 extent = 0", arrow_extent(sbh, Qt.Orientation.Horizontal) == 0,
str(arrow_extent(sbh, Qt.Orientation.Horizontal)))
check("S4b 代码框竖滚动条箭头 extent = 0", arrow_extent(sbv) == 0, str(arrow_extent(sbv)))
check("S4c section 竖滚动条箭头 extent = 0", arrow_extent(sdv) == 0, str(arrow_extent(sdv)))
# ---- S5 交汇角(render 到透明 pixmap 取样;offscreen 下文档区背景不填充,
# 但样式子控件(滚动条/边框/corner)正常渲染,可直接断言)----
from PyQt6.QtGui import QPainter, QPixmap # noqa: E402
w, h = ob.width(), ob.height()
pm = QPixmap(w, h); pm.fill(Qt.GlobalColor.transparent)
pr = QPainter(pm); ob.render(pr); pr.end()
img = pm.toImage()
def px_at(x, y):
c = img.pixelColor(x, y)
return (c.red(), c.green(), c.blue())
CORNER_BG = (0xfb, 0xfc, 0xfe) # #bl_code 背景色 = QSS 里 corner 规则的目标色
n_bg = n_white = 0
for dy in range(8):
for dx in range(8):
c = px_at(w - 1 - dx, h - 1 - dy)
if c == CORNER_BG:
n_bg += 1
if c == (255, 255, 255):
n_white += 1
# 健全性:样式确已作用到该框(文本色 #243043 与 handle #d0d0d0 应在渲染图中出现)
n_text = sum(1 for y in range(0, h, 2) for x in range(0, w, 2)
if px_at(x, y) == (0x24, 0x30, 0x43))
n_handle = sum(1 for y in range(h - 10, h) for x in range(0, w - 12, 2)
if px_at(x, y) == (0xd0, 0xd0, 0xd0))
print(f" 测量:corner 8x8 内 #fbfcfe 像素 = {n_bg},亮白(255,255,255)像素 = {n_white}"
f"文本色像素 = {n_text}handle色像素 = {n_handle}", flush=True)
check("S5 健全性:样式已作用于该框(文本色出现)", n_text > 0, f"n_text={n_text}")
check("S5b 健全性:handle #d0d0d0 出现在横滚动条带", n_handle > 0, f"n_handle={n_handle}")
check("S5c 交汇角渲染出 #fbfcfe= 代码框背景,::corner 规则生效)",
n_bg >= 1, f"n_bg={n_bg}")
check("S5d 交汇角无原生亮白方块(255,255,255", n_white == 0, f"n_white={n_white}")
img.save(os.path.join(OUT_DIR, "p2-02-outbox-render.png"))
# ---- S6 无泄漏:未命名 QPlainTextEdit 仍为原生口径 ----
probe = QPlainTextEdit()
probe.setPlainText("Y" * 3000)
probe.resize(200, 120)
probe.show()
settle(150)
psb = probe.horizontalScrollBar()
probe_sb_extent = arrow_extent(psb, Qt.Orientation.Horizontal)
check("S6 未命名代码框横滚动条非面板口径(原生厚≠8 或 有箭头)",
(psb.height() != 8) or probe_sb_extent > 0,
f"h={psb.height()} extent={probe_sb_extent}")
probe.close()
# ---- S7 无泄漏:附件预览滚动条保持自身 6px 口径 ----
att_sb = window.attachment_scroll_area.horizontalScrollBar()
att_hint_h = att_sb.sizeHint().height()
check("S7 附件预览横滚动条保持 6px(自身 QSS 未被面板规则覆盖)",
att_hint_h == 6, f"sizeHint.h={att_hint_h}")
# ---- 截图(局部:面板全貌 + 代码框角落放大 4x)----
# 注意:offscreen 下 ob.grab() 的文档区不填充(黑图),角落放大图从 S5 的
# render 图(样式子控件已正常渲染)裁出,才有证据价值
os.makedirs(OUT_DIR, exist_ok=True)
panel_path = os.path.join(OUT_DIR, "p2-02-panel.png")
panel.grab().save(panel_path)
crop = img.copy(max(0, w - 60), max(0, h - 60), 60, 60)
scaled = crop.scaled(240, 240,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.FastTransformation)
corner_path = os.path.join(OUT_DIR, "p2-02-codebox-corner-4x.png")
scaled.save(corner_path)
print(f" 截图:{panel_path}", flush=True)
print(f" 截图:{corner_path}(代码框右下角 60x60 → 4x", flush=True)
# ---- 收尾测量汇总 ----
print(f"\n 汇总:代码框 H={sbh.height()}px V={sbv.width()}px | "
f"section V={sdv.width()}px | corner #fbfcfe 像素={n_bg} | "
f"未命名框 H={psb.height()}px extent={probe_sb_extent} | 附件 H hint={att_hint_h}px",
flush=True)
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)
# offscreen 铁律:os._exit 强制收尾(QtWebEngine 子进程可能不回收)
os._exit(0 if ok else 1)
+262
View File
@@ -0,0 +1,262 @@
# -*- coding: utf-8 -*-
"""P2-03 结构+行为诊断:重命名遮罩 = 独立顶层透明窗(可盖住 WebView2 原生子窗)。
覆盖修复目标与硬约束:
R1 结构:顶层窗(非 bg_widget 子控件)、Tool+无边框、WA_TranslucentBackground、WA_DeleteOnClose
R2 几何:覆盖主窗口客户区(标题栏/窗口控制不被盖)、卡片居中、输入框初始全选
R3 跟随:主窗口移动/缩放/窗口状态变化 → 遮罩同步(move/resize/WindowStateChange 事件过滤器)
R4 行为:Enter 提交(renamed 信号→DB+侧栏)、Esc 关闭、点空白关闭、✕ 关闭、取消关闭
R5 释放:关闭后顶层窗口消失、无残留(deleteLater + 事件过滤器卸载)
R6 焦点(软检查,仅打印):关闭后焦点回主窗口
隔离 + offscreenos._exit 收尾。
用法: QT_QPA_PLATFORM=offscreen HAOCODE_RENDER=software .venv/Scripts/python.exe tests/diag_rename_overlay.py
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
from tests._test_env import isolate
isolate()
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtTest import QTest
from ui.views.main_window import MainWindow, RenameOverlay
results = []
def check(name, cond, info=""):
ok = bool(cond)
results.append((name, ok))
print(f" [{'PASS' if ok else 'FAIL'}] {name} {info}", flush=True)
def settle(ms=120):
t0 = time.time()
while (time.time() - t0) * 1000 < ms:
QtWidgets.QApplication.processEvents()
time.sleep(0.01)
# offscreen 无真实事件循环:processEvents 不处理 DeferredDelete,显式冲刷(生产中事件循环常驻,deleteLater 正常)
QtCore.QCoreApplication.sendPostedEvents(None, QtCore.QEvent.Type.DeferredDelete)
QtWidgets.QApplication.processEvents()
def find_overlay():
for w in QtWidgets.QApplication.topLevelWidgets():
if isinstance(w, RenameOverlay):
return w
return None
def main():
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.resize(1400, 800)
window.show()
settle(400)
sid = window.db.create_session("旧标题A")["id"]
window.rebuild_sidebar()
settle(150)
# ---------- 打开遮罩 ----------
window._rename_session(sid)
settle(250) # 含 150ms 入场动画
ov = find_overlay()
check("R0.1 overlay 已创建且为顶层窗口", ov is not None,
"" if ov is not None else "topLevelWidgets 中找不到 RenameOverlay")
if ov is None:
print(f"\n{'='*60}\nRESULT: 1 FAIL -> FAIL\n{'='*60}", flush=True)
os._exit(1)
# ---------- R1 结构 ----------
print("R1 结构(独立顶层透明窗)", flush=True)
check("R1.1 isWindow()", ov.isWindow())
check("R1.2 自身即顶层窗口(非 bg_widget 内嵌子控件)", ov.window() is ov,
f"window() is ov={ov.window() is ov}")
check("R1.3 非主窗口自身", ov is not window)
check("R1.4 Tool 窗(不入任务栏)", bool(ov.windowFlags() & QtCore.Qt.WindowType.Tool))
check("R1.5 无边框", bool(ov.windowFlags() & QtCore.Qt.WindowType.FramelessWindowHint))
check("R1.6 WA_TranslucentBackground", ov.testAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground))
check("R1.7 WA_DeleteOnClose", ov.testAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose))
check("R1.8 主窗口未设 WA_TranslucentBackground(保持原生不透明底)",
not window.testAttribute(QtCore.Qt.WidgetAttribute.WA_TranslucentBackground))
# ---------- R2 几何 ----------
print("R2 几何(覆盖客户区,标题栏可操作)", flush=True)
exp_tl = window.mapToGlobal(window.rect().topLeft())
exp_size = window.rect().size()
g = ov.geometry()
check("R2.1 覆盖客户区左上角", abs(g.left() - exp_tl.x()) <= 2 and abs(g.top() - exp_tl.y()) <= 2,
f"overlay=({g.left()},{g.top()}) expect=({exp_tl.x()},{exp_tl.y()})")
check("R2.2 覆盖客户区尺寸", abs(g.width() - exp_size.width()) <= 2 and abs(g.height() - exp_size.height()) <= 2,
f"overlay={g.width()}x{g.height()} expect={exp_size.width()}x{exp_size.height()}")
fx, fy = (g.width() - ov.form.width()) // 2, (g.height() - ov.form.height()) // 2
check("R2.3 卡片居中", abs(ov.form.x() - fx) <= 2 and abs(ov.form.y() - fy) <= 2,
f"form=({ov.form.x()},{ov.form.y()}) expect=({fx},{fy})")
check("R2.4 输入框初始全选", ov.input.hasSelectedText(), f"text={ov.input.text()!r}")
check("R2.5 输入框预填旧标题", ov.input.text() == "旧标题A", f"text={ov.input.text()!r}")
# ---------- R3 跟随 ----------
print("R3 跟随(move/resize/状态变化)", flush=True)
base = window.pos()
window.move(base.x() + 150, base.y() + 80)
settle(150)
exp_tl2 = window.mapToGlobal(window.rect().topLeft())
check("R3.1 主窗口移动→遮罩跟随", abs(ov.geometry().left() - exp_tl2.x()) <= 2
and abs(ov.geometry().top() - exp_tl2.y()) <= 2,
f"overlay_tl=({ov.geometry().left()},{ov.geometry().top()}) expect=({exp_tl2.x()},{exp_tl2.y()})")
window.resize(1200, 700)
settle(150)
g2 = ov.geometry()
check("R3.2 主窗口缩放→遮罩同步尺寸",
abs(g2.width() - 1200) <= 4 and abs(g2.height() - 700) <= 4,
f"overlay={g2.width()}x{g2.height()} expect=1200x700")
fx2 = (g2.width() - ov.form.width()) // 2
check("R3.3 缩放后卡片重新居中", abs(ov.form.x() - fx2) <= 2, f"form.x={ov.form.x()} expect={fx2}")
# WindowStateChange 分支(最大化/还原走同一条 _sync_geometry 路径;offscreen 直接投递事件验证分支)
before = ov.geometry()
QtWidgets.QApplication.sendEvent(window, QtCore.QEvent(QtCore.QEvent.Type.WindowStateChange))
QtWidgets.QApplication.processEvents()
check("R3.4 WindowStateChange 分支不崩溃且几何仍正确",
abs(ov.geometry().width() - 1200) <= 4 and ov is find_overlay(),
f"geometry={ov.geometry().width()}x{ov.geometry().height()}")
# ---------- R4 行为 ----------
print("R4 行为", flush=True)
# R4.1 Enter 提交 → renamed 信号 → DB + 侧栏
ov.input.setText("P203新会话名")
ov.confirm()
settle(250)
sessions = {s["id"]: s for s in window.db.get_all_sessions()}
check("R4.1a confirm 后 DB 标题已更新", sessions.get(sid, {}).get("title") == "P203新会话名",
f"db_title={sessions.get(sid, {}).get('title')!r}")
side_ok = False
for i in range(window.history_list.count()):
it = window.history_list.item(i)
if it and it.data(QtCore.Qt.ItemDataRole.UserRole) == sid:
w = window.history_list.itemWidget(it)
if w and hasattr(w, "title_label") and w.title_label.text() == "P203新会话名":
side_ok = True
check("R4.1b confirm 后侧栏标题已更新", side_ok)
check("R4.1c 提交后 overlay 已从顶层窗口消失", find_overlay() is None)
# R4.2 空标题 confirm 不提交(只关闭)
window._rename_session(sid)
settle(200)
ovz = find_overlay()
if ovz:
ovz.input.clear()
ovz.confirm()
settle(200)
sessions = {s["id"]: s for s in window.db.get_all_sessions()}
check("R4.2 空标题 confirm 不改 DB 标题",
sessions.get(sid, {}).get("title") == "P203新会话名",
f"db_title={sessions.get(sid, {}).get('title')!r}")
check("R4.2b 空标题确认后 overlay 已消失", find_overlay() is None)
# R4.3 Esc 关闭
window._rename_session(sid)
settle(200)
ov2 = find_overlay()
check("R4.3a 重开 overlay", ov2 is not None)
if ov2:
QtWidgets.QApplication.sendEvent(
ov2, QtGui.QKeyEvent(QtCore.QEvent.Type.KeyPress, QtCore.Qt.Key.Key_Escape,
QtCore.Qt.KeyboardModifier.NoModifier, "Esc"))
QtWidgets.QApplication.sendEvent(
ov2, QtGui.QKeyEvent(QtCore.QEvent.Type.KeyRelease, QtCore.Qt.Key.Key_Escape,
QtCore.Qt.KeyboardModifier.NoModifier, "Esc"))
QtWidgets.QApplication.processEvents()
settle(200)
check("R4.3b Esc 关闭", find_overlay() is None)
# R4.4 点空白关闭
window._rename_session(sid)
settle(200)
ov3 = find_overlay()
check("R4.4a 重开 overlay", ov3 is not None)
if ov3:
QTest.mouseClick(ov3, QtCore.Qt.MouseButton.LeftButton,
QtCore.Qt.KeyboardModifier.NoModifier, QtCore.QPoint(10, 10))
settle(200)
check("R4.4b 点空白关闭", find_overlay() is None)
# R4.5 ✕ 关闭
window._rename_session(sid)
settle(200)
ov4 = find_overlay()
check("R4.5a 重开 overlay", ov4 is not None)
if ov4:
btn_x = None
for b in ov4.form.findChildren(QtWidgets.QPushButton):
if b.text() == "":
btn_x = b
break
check("R4.5b ✕ 按钮存在", btn_x is not None)
if btn_x:
btn_x.click()
settle(200)
check("R4.5c ✕ 关闭", find_overlay() is None)
# R4.6 取消按钮关闭
window._rename_session(sid)
settle(200)
ov5 = find_overlay()
if ov5:
for b in ov5.form.findChildren(QtWidgets.QPushButton):
if b.text() == "取消":
b.click()
break
settle(200)
check("R4.6 取消按钮关闭", find_overlay() is None)
# R4.7 非确认关闭(Esc/空白/✕/取消)均不改标题
sessions = {s["id"]: s for s in window.db.get_all_sessions()}
check("R4.7 非确认关闭不改 DB 标题",
sessions.get(sid, {}).get("title") == "P203新会话名",
f"db_title={sessions.get(sid, {}).get('title')!r}")
# ---------- R5 释放 ----------
print("R5 释放(无残留)", flush=True)
check("R5.1 无残留 rename_form 顶层窗口",
not [w for w in QtWidgets.QApplication.topLevelWidgets()
if w.findChild(QtWidgets.QFrame, "rename_form") is not None])
check("R5.2 主窗口仍存活可用", window.isVisible() and window.db.get_all_sessions() is not None)
# ---------- R6 焦点(软检查) ----------
print("R6 焦点回主窗口(软检查)", flush=True)
window.activateWindow()
settle(200)
print(f" [INFO] window.isActiveWindow()={window.isActiveWindow()} "
f"(offscreen 下不可靠,仅记录;真实机器人工走查确认)", flush=True)
window.close()
app.processEvents()
ok = all(o for _, o in results)
print(f"\n{'='*60}", flush=True)
print(f"RESULT: {sum(o for _, o in results)}/{len(results)} -> {'ALL PASS' if ok else 'FAIL'}", flush=True)
print(f"{'='*60}", flush=True)
sys.stdout.flush()
os._exit(0 if ok else 1)
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except BaseException:
import traceback
traceback.print_exc()
sys.stdout.flush()
os._exit(1)
+336
View File
@@ -0,0 +1,336 @@
# -*- coding: utf-8 -*-
"""P1-01 渲染窗口 400 条消息规模诊断(offscreen;临时 DB + 临时配置;不启动真实 LLM)
运行: QT_QPA_PLATFORM=offscreen python tests/diag_render_scale.py [N]
N 默认 400(位置参数可覆盖)
验证项(manual 模式保证确定性;末尾追加 auto 模式抽查):
1. 初始窗口:.message-wrapper == min(N, size) 且 ≤ size(默认 40);
窗口 = 最新 size 条;load-older 可见、load-newer 隐藏;
2. 向上分页至头部:每页锚点误差 ≤ 2px(绝对顶部例外:scrollTop==0 且露出新页);
全程窗口 ≤ size;到达头部后 hasMoreOlder=false
全部 N 条消息可达(各步渲染 id 并集 == 链 id 集),无重复 id;
3. 自头部向下回翻 2 页:锚点稳定(≤2px);
4. 规模报告:DOM 节点总数、页面高度、每页耗时采样(ms);
5. auto 模式抽查:切 auto + 滚到顶部 → 自动补页发生。
调试铁律:本脚本自带总超时(QTimer 300s),bash 侧以 timeout=360 运行。
"""
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")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu"
# 铁律:临时 DB + 临时配置(含 render_window_mode/size),先于任何 UI import
from tests._test_env import isolate, default_config # noqa: E402
N = int(sys.argv[1]) if len(sys.argv) > 1 else 400
_SIZE = 40
_PAGE = _SIZE // 2 # 页大小 = 半窗(与 main_window._rw_page_size 一致)
_cfg = dict(default_config())
_cfg["render_window_mode"] = "manual" # 确定性诊断;auto 行为末尾单独抽查
_cfg["render_window_size"] = _SIZE
_env = isolate("render_scale", config=_cfg)
import core.db_manager as _dbm # noqa: E402
from core.db_manager import DBManager # noqa: E402
# ---------- 造数:N 条链(含附件用户消息 / assistant 时间线 / 一个分支兄弟) ----------
db = DBManager()
sess = db.create_session(title=f"P1-01 scale {N}")
sid = sess["id"]
parent = None
chain_ids = []
for i in range(N):
role = "user" if i % 2 == 0 else "assistant"
content = f"消息 {i} —— " + "这是一段用于撑起 DOM 高度的填充段落。" * 6
atts = None
if role == "user" and i % 50 == 0:
atts = json.dumps({"user_text": content, "attachments": [
{"type": "pdf", "mode": "text", "name": f"spec_{i}.pdf",
"size_kb": 12, "pages": 3, "lines": 40, "content": "文本附件内容"}
]}, ensure_ascii=False)
timeline = None
if role == "assistant" and i % 75 == 3:
timeline = json.dumps([
{"t": "think", "text": f"思考片段 {i}"},
{"t": "text", "text": f"时间线正文 {i}"},
], ensure_ascii=False)
msg_id = f"scale-{i}"
db.add_message(session_id=sid, role=role, content=content, parent_id=parent,
msg_id=msg_id, attachment_metadata=atts, timeline=timeline)
parent = msg_id
chain_ids.append(msg_id)
# 分支兄弟:必须插在链中间(add_message 会把新消息设为叶子,
# 若放在主链之后会抢走叶子、截断可见链)
if i == 298 and N > 300:
db.add_message(session_id=sid, role="assistant", content="分支兄弟回复",
parent_id=parent, msg_id="scale-branch-sib")
db.mark_session_has_messages(sid)
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 = []
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
js = {"done": False}
def _run_js(code, timeout_s=15):
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"):
window.browser.execute_js_async(code, on_ret)
else:
window.browser.page().runJavaScript(code, on_ret)
t0 = time.time()
while not result["done"] and time.time() - t0 < timeout_s:
app.processEvents()
time.sleep(0.02)
assert result["done"], f"JS 执行超时: {code[:60]}"
return result["val"]
def wait_until(cond_js, timeout_s=30, desc=""):
t0 = time.time()
while time.time() - t0 < timeout_s:
if _run_js(cond_js, timeout_s=5):
return True
time.sleep(0.1)
raise AssertionError(f"等待超时: {desc}")
page_metrics = {"pages": [], "heights": [], "dom_nodes": []}
def try_load():
global window
window = MainWindow()
# offscreen 零视口(innerHeight=0)→ 给真实尺寸,滚动/锚点几何才有效
window.resize(1400, 950)
window.show()
for _ in range(10):
app.processEvents()
time.sleep(0.05)
# 等真实视口
wait_until("window.innerHeight > 0 && window.innerWidth > 0", 30, "视口尺寸")
# 等 JS 就绪 + 首轮(可能零视口)渲染完成
wait_until("window.jsReady === true", 30, "jsReady")
wait_until(f"document.querySelectorAll('#chat-container .message-wrapper').length >= {_SIZE}",
60, "首轮渲染")
# 真实视口下走完整 load_messages_to_web 路径重新窗口化加载
window.load_messages_to_web(window.current_session_id, show_loading=False)
wait_until(f"rwState !== null && rwState.generation >= 2 && "
f"document.querySelectorAll('#chat-container .message-wrapper').length >= {_SIZE}",
60, "视口重载")
wait_until("(window.__rwPageRendering === false && document.getElementById('load-older') !== null) ? 1 : 0",
10, "初始窗口收尾")
run_checks()
def run_checks():
sel = "#chat-container .message-wrapper"
# ---- 1) 初始窗口 ----
def initial():
cnt = _run_js(f"document.querySelectorAll('{sel}').length")
assert cnt == _SIZE, f"初始窗口 {cnt} != {_SIZE}"
st = _run_js("JSON.stringify({len: rwState.order.length, hidO: rwState.hiddenOlder, "
"hidN: rwState.hiddenNewer, older: rwState.hasMoreOlder, newer: rwState.hasMoreNewer, "
"mode: rwState.mode, size: rwState.size})")
assert st["mode"] == "manual" and st["size"] == _SIZE, st
assert st["len"] == _SIZE and st["hidO"] == N - _SIZE, st
assert st["newer"] is False and st["older"] is True, st
assert _run_js("document.getElementById('load-older').hidden") is False
assert _run_js("document.getElementById('load-newer').hidden") is True
# 窗口 = 最新 size 条
first = _run_js(f"document.querySelector('{sel}').id")
assert first == chain_ids[N - _SIZE], first
# 初始贴底
assert _run_js("isNearBottom()"), "初始窗口未对齐底部"
check(f"初始窗口 = 最新 {_SIZE} 条(链长 {N}", initial)
# ---- 2a) 中部锚点保持(真实"窗口中部换页"路径,误差 ≤2px) ----
seen = set(chain_ids[-_SIZE:])
anchor_errors = []
def page_older_once():
"""执行一次向上换页(调用方保证视口位置);断言窗口上限与锚点。"""
before = _run_js("JSON.stringify(rwCaptureAnchor() || {})")
before = json.loads(before) if isinstance(before, str) else (before or {})
scroll_before = _run_js("window.scrollY")
t0 = time.time()
_run_js("rwRequestPage('older')")
# 等待 pending 消费 + 批次渲染收尾
wait_until("rwState.pending === null && window.__rwPageRendering === false",
30, "换页完成")
dt = (time.time() - t0) * 1000
page_metrics["pages"].append(dt)
cnt = _run_js(f"document.querySelectorAll('{sel}').length")
assert cnt <= _SIZE, f"窗口超限 {cnt}"
if before.get("msgId"):
el = _run_js(f"document.getElementById({json.dumps(before['msgId'])}) ? "
f"document.getElementById({json.dumps(before['msgId'])}).getBoundingClientRect().top + window.scrollY : null")
assert el is not None, f"锚点消息被裁剪: {before['msgId']}"
if scroll_before <= 1:
# 绝对顶部:停在 0 露出新页(需求定义的例外:不做锚点断言)
assert _run_js("window.scrollY") <= 1, "绝对顶部未保持"
anchor_errors.append(0.0)
else:
expect = scroll_before + (el - before["docTop"])
actual = _run_js("window.scrollY")
err = abs(expect - actual)
anchor_errors.append(err)
assert err <= 2.0, f"锚点误差 {err}px (expect={expect} actual={actual})"
else:
anchor_errors.append(0.0)
ids_now = _run_js(f"Array.from(document.querySelectorAll('{sel}')).map(e => e.id)")
for i in ids_now:
seen.add(i)
page_metrics["dom_nodes"].append(_run_js("document.querySelectorAll('body *').length"))
page_metrics["heights"].append(_run_js("document.body.scrollHeight"))
def anchor_middle():
# 滚到窗口中部(35% 处):既非绝对顶部也非贴底 → 走完整锚点数学
_run_js("window.scrollTo(0, Math.max(1, document.body.scrollHeight * 0.15)); 1")
time.sleep(0.2)
app.processEvents()
scroll_before = _run_js("window.scrollY")
assert scroll_before > 10, f"未到中部: {scroll_before}"
assert not _run_js("isNearBottom()"), "中部位置不应贴底(防振荡前置条件)"
page_older_once()
st = _run_js("JSON.stringify({hidO: rwState.hiddenOlder, hidN: rwState.hiddenNewer})")
assert st["hidN"] > 0, f"向上换页后应裁出较新隐藏区(无振荡): {st}"
assert st["hidO"] == N - _SIZE - _PAGE, f"窗口应上移 _PAGE 条: {st}"
check("中部锚点保持(误差≤2px,无 newer 振荡)", anchor_middle)
# ---- 2b) 自顶部循环向上分页至头部(真实"load older"点击路径) ----
def page_up_to_head():
_run_js("window.scrollTo(0, 0); 1")
time.sleep(0.2)
app.processEvents()
steps = 0
while True:
if not _run_js("rwState.hasMoreOlder"):
break
page_older_once()
steps += 1
assert steps <= N // _SIZE + 10, "换页次数异常(疑似死循环)"
first = _run_js(f"document.querySelector('{sel}').id")
assert first == chain_ids[0], f"未到达头部: {first}"
check("自顶部向上分页至头部(绝对顶部例外)", page_up_to_head)
def head_state():
st = _run_js("JSON.stringify({len: rwState.order.length, hidO: rwState.hiddenOlder, "
"hidN: rwState.hiddenNewer, older: rwState.hasMoreOlder, newer: rwState.hasMoreNewer})")
assert st["hidO"] == 0 and st["older"] is False, st
assert st["newer"] is True and st["hidN"] == N - _SIZE, st
# 全链可达 + 无重复
all_ids = _run_js(f"Array.from(document.querySelectorAll('{sel}')).map(e => e.id)")
assert len(all_ids) == len(set(all_ids)), "DOM 出现重复消息 id"
assert len(seen) == N, f"可达 {len(seen)}/{N}"
check("头部状态 + 全链可达无重复", head_state)
# ---- 3) 自顶部向下回翻 2 页(窗口下移,视口留在顶部) ----
def page_down_two():
for k in range(2):
_run_js("window.scrollTo(0, 0); 1")
time.sleep(0.15)
app.processEvents()
_run_js("rwRequestPage('newer')")
wait_until("rwState.pending === null && window.__rwPageRendering === false",
30, f"向下换页{k}完成")
# 顶部回翻:锚点(最旧一条)随 trimHead 移除 → 视口留在顶部露出新页
assert _run_js("window.scrollY") <= 1, "顶部回翻应留在顶部"
first = _run_js(f"document.querySelector('{sel}').id")
assert first == chain_ids[_PAGE * (k + 1)], f"窗口未下移: {first}"
st = _run_js("rwState.hasMoreOlder")
assert st is True, "回翻后应仍有更旧消息"
check("自顶部向下回翻 2 页(窗口下移)", page_down_two)
# ---- 5) auto 模式抽查:顶部自动补页 ----
def auto_probe():
_run_js("window.scrollTo(0, 0); 1")
time.sleep(0.15)
app.processEvents()
_run_js("rwState.mode = 'auto'; 1")
hid_before = _run_js("rwState.hiddenOlder")
_run_js("rwAutoCheck(); 1") # 直接触发自动补页判定(事件接线另行覆盖)
# 给自动补页 12s(每页渲染约 0.5-2s)
t0 = time.time()
moved = False
while time.time() - t0 < 12:
app.processEvents()
time.sleep(0.2)
if _run_js("rwState.hiddenOlder") < hid_before:
moved = True
break
assert moved, "auto 模式顶部未自动补页"
check("auto 模式:顶部自动补页", auto_probe)
# ---- 4) 规模报告 ----
nodes = page_metrics["dom_nodes"]
heights = page_metrics["heights"]
pages_ms = page_metrics["pages"]
print("\n================ 规模报告 ================")
print(f"链长 N={N} | 窗口 size={_SIZE}")
print(f"换页数={len(pages_ms)} | 每页耗时 min/avg/max = "
f"{min(pages_ms):.0f}/{(sum(pages_ms) / len(pages_ms)):.0f}/{max(pages_ms):.0f} ms")
print(f"DOM 节点总数 min/max = {min(nodes)}/{max(nodes)}")
print(f"页面高度 min/max = {min(heights)}/{max(heights)} px")
if anchor_errors:
print(f"锚点误差 min/max = {min(anchor_errors):.2f}/{max(anchor_errors):.2f} px")
print("==========================================")
def finish(ok=None):
app.quit()
QTimer.singleShot(500, try_load)
QTimer.singleShot(300000, lambda: (print("[超时] 300s 总超时"), finish(False)))
app.exec()
try:
window.close()
except Exception:
pass
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====")
# QtWebEngine 退出段错误规避:汇总已打印,直接退出
os._exit(0 if all(results) else 1)
+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())
+265
View File
@@ -0,0 +1,265 @@
# -*- coding: utf-8 -*-
"""P2-04 跨平台聚合测试入口(无 pytest / npm / 网络要求)。
用法:
python tests/run_all.py --group logic
python tests/run_all.py --group offscreen
python tests/run_all.py --group all
python tests/run_all.py --group all --list
python tests/run_all.py --group all --keep-logs
python tests/run_all.py --group logic --only test_tool_params
设计(对应 REPAIR_BACKLOG.md P2-04 硬约束):
* 保留所有独立命令:每个条目就是 [解释器, 测试文件] 的普通子进程调用,
与各测试文件头部的“运行:”命令等价(同一解释器、同一 cwd、同组环境)。
* 聚合入口不要求 pytest / npm / 网络;缺 node 或第三方依赖的条目的
呈现方式(SKIP + 理由),不伪装通过。
* 每个子测试使用独立临时目录(Windows: TEMP/TMPPOSIX: TMPDIR),
GUI 测试的临时配置/临时数据库由各测试自身在 import MainWindow 前
通过 tests/_test_env.isolate() 完成(仓库既有惯例)。
* 默认不运行 diag_* / verify_* / tune_* / 真实 APIsmoke_live_*、
diag_live_*)/ 真实桌面 / 需要凭据的脚本,也不运行使用真实 DB 的
遗留脚本(smoke_persist、smoke_repro_real)。
* 平台不适用项以明确 SKIP + 理由呈现;平台共同项失败 → 退出码非零。
* 单个子测试崩溃或超时不阻止后续测试与最终汇总;退出码反映失败:
0 = 无 FAIL/TIMEOUTSKIP 不影响);1 = 存在 FAIL 或 TIMEOUT。
* 不读取真实配置、不访问网络、不删除用户运行数据。
"""
import argparse
import importlib.util
import os
import shutil
import subprocess
import sys
import tempfile
import time
from datetime import datetime
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PY = sys.executable # 用启动本入口的解释器跑所有 Python 子测试(与独立命令一致)
NODE = shutil.which("node")
# 控制台编码安全化(与 main.py 同惯例):git-bash/GBK/cp1252 终端下
# 聚合器自身的中文输出不得崩溃(子测试输出经 UTF-8 解码后同样安全打印)。
for _name in ("stdout", "stderr"):
_s = getattr(sys, _name, None)
if _s is not None:
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# ---------------------------------------------------------------------------
# 测试清单 / 分组元数据
# (id, groups, argv, timeout_sec, needs, note)
# needs 取值:
# "node" —— 需要 node 可执行文件
# "openai" —— 需要 openai 包(当前解释器)
# "pymupdf" —— 需要 PyMuPDF (fitz)
# "pyqt6" —— 需要 PyQt6GUI/offscreen 条目)
# ---------------------------------------------------------------------------
JS = lambda f: [NODE, os.path.join(REPO, "tests", f)] if NODE else [None]
PYT = lambda f: [PY, os.path.join(REPO, "tests", f)]
TESTS = [
# ---- logic:纯逻辑 / 无 GUI(平台共同项,失败不得豁免)----
("run_tests.py (agent core 41)", ("logic",), PYT("run_tests.py"), 300,
("openai",), "core.agent 单测套件(既有 run_tests.py 入口,原样保留)"),
("test_tool_params.py", ("logic",), PYT("test_tool_params.py"), 120,
("openai",), "工具参数解析(import core.agent.tools → openai"),
("test_bash_stream.py", ("logic",), PYT("test_bash_stream.py"), 180,
("openai",), "bash 流式 / 进程树(P1-02、P2-01"),
("test_copy_session.py", ("logic",), PYT("test_copy_session.py"), 180,
(), "会话复制 DB 逻辑"),
("test_compaction_persist.py", ("logic",), PYT("test_compaction_persist.py"), 180,
("openai",), "压缩持久化"),
("test_file_attach.py", ("logic",), PYT("test_file_attach.py"), 180,
(), "附件链路(P1-04"),
("test_cross_platform_shell.py", ("logic",), PYT("test_cross_platform_shell.py"), 240,
("openai",), "跨平台 shell 契约 + 进程树终止(P1-02)"),
("test_global_hotkey_platforms.py", ("logic",), PYT("test_global_hotkey_platforms.py"), 240,
("pyqt6",), "全局热键平台矩阵 mockP1-04;含 QShortcut/overlay 构造)"),
("test_wv2_guard.py", ("logic",), PYT("test_wv2_guard.py"), 240,
("platform:win32",), "WebView2 守卫(T0 事故锁死;msvcrt 单实例互斥为 Windows 专属,Linux 无 WebView2 链路)"),
("test_pdf_reader.py", ("logic",), PYT("test_pdf_reader.py"), 240,
("pymupdf",), "PDF 提取"),
("test_math_extract.js", ("logic",), JS("test_math_extract.js"), 120,
("node",), "数学提取 JS 单测(P1-03"),
("test_render_window.js", ("logic",), JS("test_render_window.js"), 180,
("node",), "渲染窗口状态机 JS 单测(P1-01)"),
# ---- offscreenQt GUIQT_QPA_PLATFORM=offscreen 子环境)----
("smoke_offscreen.py", ("offscreen",), PYT("smoke_offscreen.py"), 420,
("pyqt6",), "主窗口 + 核心链路冒烟"),
("smoke_mode.py", ("offscreen",), PYT("smoke_mode.py"), 420,
("pyqt6",), "模式切换(P0-02"),
("smoke_copy_session.py", ("offscreen",), PYT("smoke_copy_session.py"), 420,
("pyqt6",), "会话复制 UI 端到端"),
("smoke_bash_panel.py", ("offscreen",), PYT("smoke_bash_panel.py"), 480,
("pyqt6",), "右侧 Bash 面板端到端(P2-01、P2-02"),
("smoke_timeline.py", ("offscreen",), PYT("smoke_timeline.py"), 420,
("pyqt6",), "时间线持久化/还原"),
("smoke_midswitch.py", ("offscreen",), PYT("smoke_midswitch.py"), 420,
("pyqt6",), "流式中途切会话(P1-01"),
("test_main_window_event_filter.py", ("offscreen",), PYT("test_main_window_event_filter.py"), 300,
("pyqt6",), "MainWindow.eventFilterP0-02"),
("test_config_isolation.py", ("offscreen",), PYT("test_config_isolation.py"), 300,
("pyqt6",), "配置/DB 隔离铁律(P0-01"),
("test_error_persist.py", ("offscreen",), PYT("test_error_persist.py"), 300,
("pyqt6",), "错误持久化"),
("test_think_code_neutral.py", ("offscreen",), PYT("test_think_code_neutral.py"), 420,
("pyqt6",), "思考/代码块渲染中立性"),
("test_debug_window.py", ("offscreen",), PYT("test_debug_window.py"), 300,
("pyqt6",), "调试窗口"),
("test_renderer_matrix.py", ("offscreen",), PYT("test_renderer_matrix.py"), 600,
("pyqt6",), "渲染器矩阵(P1-03,含子进程并行)"),
("test_screen_capture_platforms.py", ("offscreen",), PYT("test_screen_capture_platforms.py"), 300,
("pyqt6",), "截图平台矩阵 mock + overlay 守卫(P1-04"),
]
# 默认不聚合(人工/半自动/真实资源):diag_*、verify_*、tune_*、
# smoke_live_guard.py、diag_live_*.py、smoke_manual.py、smoke_repro_real.py、
# smoke_persist.py(真实 DB)、smoke_probe.py、_probe_*.py、
# debug_inject.py、inject_math_demo.py、check_db_migration.py
def _has_module(name):
try:
return importlib.util.find_spec(name) is not None
except Exception:
return False
def skip_reason(entry):
"""返回 SKIP 理由;None 表示可运行。"""
_id, _groups, _argv, _to, needs, _note = entry
for need in needs:
if need.startswith("platform:"):
want = need.split(":", 1)[1]
if sys.platform != want:
return f"平台不适用:该条目仅 {want} 路径适用(当前 {sys.platform}"
continue
if need == "node" and not NODE:
return "node 不可用(聚合入口不要求 npm/node;安装 node 后自动纳入)"
if need == "openai" and not _has_module("openai"):
return "当前解释器无 openai 包(本环境不允许联网安装)"
if need == "pymupdf" and not _has_module("fitz"):
return "当前解释器无 PyMuPDF(本环境不允许联网安装)"
if need == "pyqt6" and not _has_module("PyQt6"):
return "当前解释器无 PyQt6(GUI 条目需在装有依赖的解释器运行)"
return None
def _child_env(extra_temp, offscreen):
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
env["PYTHONUNBUFFERED"] = "1"
if extra_temp:
if os.name == "nt":
env["TEMP"] = extra_temp
env["TMP"] = extra_temp
else:
env["TMPDIR"] = extra_temp
if offscreen:
env.setdefault("QT_QPA_PLATFORM", "offscreen")
env.setdefault("HAOCODE_RENDER", "software")
env.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
return env
def run_group(group, keep_logs, only):
entries = [e for e in TESTS if group in ("all",) or e[1][0] == group]
if only:
subs = [s.strip() for s in only.split(",") if s.strip()]
entries = [e for e in entries if any(s in e[0] for s in subs)]
if not entries:
print("没有匹配的测试条目(--group/--only 组合为空)")
return 1
run_dir = tempfile.mkdtemp(prefix="haocode_runall_")
print("=" * 72)
print("haocode 聚合测试入口 (P2-04)")
print(f"platform={sys.platform} python={sys.version.split()[0]} ({PY})")
print(f"group={group} entries={len(entries)} 开始={datetime.now():%H:%M:%S}")
print("=" * 72)
results = [] # (id, status, seconds, note, argv)
t_all = time.time()
for entry in entries:
tid, _groups, argv, budget, needs, note = entry
off = "offscreen" in entry[1]
reason = skip_reason(entry)
if reason:
results.append((tid, "SKIP", 0.0, reason, argv))
print(f" SKIP {tid} [{reason}]")
continue
t0 = time.time()
log_path = os.path.join(run_dir, tid.replace(" ", "_").replace("(", "").replace(")", "") + ".log")
try:
with tempfile.TemporaryDirectory(prefix="runall_t_") as td:
proc = subprocess.run(
argv, cwd=REPO, env=_child_env(td, off),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=budget)
rc, err = proc.returncode, None
out = proc.stdout
except subprocess.TimeoutExpired as e:
rc, out, err = 124, (e.stdout or b""), f"超时(预算 {budget}s"
except Exception as e: # 聚合器自身健壮性:单条崩溃不阻止汇总
rc, out, err = 125, b"", f"聚合器异常: {e!r}"
dt = max(0.0, time.time() - t0) # WSL/9P 时钟回拨保护
status = "PASS" if rc == 0 else ("TIMEOUT" if rc == 124 else "FAIL")
results.append((tid, status, dt, note, argv))
tag = f" {status:<7} {dt:7.1f}s {tid}"
print(tag, flush=True)
# 非 PASS:打印最后 15 行帮助定位
tail = (out or b"").decode("utf-8", "replace").splitlines()[-15:]
if status != "PASS" and tail:
for line in tail:
print(f" | {line}")
with open(log_path, "wb") as f:
f.write(b"# argv: " + " ".join(str(a) for a in argv).encode() + b"\n")
if err:
f.write(f"# {err}\n".encode())
f.write(out or b"")
dt_all = time.time() - t_all
n_pass = sum(1 for r in results if r[1] == "PASS")
n_fail = sum(1 for r in results if r[1] in ("FAIL", "TIMEOUT"))
n_skip = sum(1 for r in results if r[1] == "SKIP")
print("-" * 72)
print(f"总计 {len(results)}: PASS {n_pass} FAIL {n_fail} SKIP {n_skip}"
f" 总耗时 {dt_all:.1f}s")
if keep_logs or n_fail:
print(f"日志目录(保留): {run_dir}")
else:
shutil.rmtree(run_dir, ignore_errors=True)
print("日志目录(无失败,已清理)")
bad = [r for r in results if r[1] in ("FAIL", "TIMEOUT")]
if bad:
print("失败命令:")
for tid, status, dt, _note, argv in bad:
print(f" [{status}] {' '.join(str(a) for a in argv)}")
print("=" * 72)
return 1 if n_fail else 0
def main():
ap = argparse.ArgumentParser(description="haocode 跨平台聚合测试入口(P2-04)")
ap.add_argument("--group", choices=("logic", "offscreen", "all"), default="all",
help="logic=纯逻辑; offscreen=Qt GUI offscreen; all=两者(默认)")
ap.add_argument("--list", action="store_true", help="只列出条目,不执行")
ap.add_argument("--keep-logs", action="store_true", help="保留全部子测试日志")
ap.add_argument("--only", default="", help="只运行 id 含子串的条目(逗号分隔)")
args = ap.parse_args()
if args.list:
for tid, groups, argv, budget, needs, note in TESTS:
mark = "SKIP?" if skip_reason((tid, groups, argv, budget, needs, note)) else "run "
print(f" [{mark}] {groups[0]:<10} {budget:>4}s {tid} {note}")
return 0
sys.exit(run_group(args.group, args.keep_logs, args.only))
if __name__ == "__main__":
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)
+737
View File
@@ -0,0 +1,737 @@
# -*- 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")
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
# (临时配置保留本套件的 mode_switch=true 语义)
from tests._test_env import isolate # noqa: E402
_TMP = isolate("bashpanel", config={"providers": {}, "mode_switch": True})
_DB_TMP = _TMP["db"]
_CFG_TMP = _TMP["config"]
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))
# ======================================================================
# 11) 🆕 P2-01:两栏按【启动顺序倒序】显示 + 重排复用实例/状态保持
# · 排序键恒为启动序号(绝不用完成时间重排)
# · 重排复用同一批 BashLayer:展开态、实时输出、代码框滚动、栏滚动位置保持
# ======================================================================
panel.clear_all()
settle(120)
# ---- 11.1 运行中栏:最新启动在第一项 ----
panel.on_started("s1", "bash", {"command": "cmd-s1"})
panel.on_started("s2", "bash", {"command": "cmd-s2"})
panel.on_started("s3", "bash", {"command": "cmd-s3"})
settle(150)
check("P11.1 运行中栏按启动倒序(s3 最新在第一项)",
panel.layer_ids("running") == ["s3", "s2", "s1"],
str(panel.layer_ids("running")))
_rendered_run = [w for w in (panel.sec_running.lay.itemAt(i).widget()
for i in range(panel.sec_running.lay.count()))
if w is not None]
check("P11.2 实际布局顺序与显示顺序一致(widget 复用、顺序反转)",
[w.call_id for w in _rendered_run] == ["s3", "s2", "s1"],
str([w.call_id for w in _rendered_run]))
# ---- 11.2 新任务触发重排:实例/展开态/实时输出/代码框滚动 全部保持 ----
lay2 = panel._layers["s2"]
lay2.toggle() # 展开 s2
panel.on_output("s2", "x" * 2000 + "\n") # 实时输出(NoWrap → 有水平滚动范围)
settle(120) # 等布局落定(真实用户滚动必然在渲染后;
# 否则首帧布局 flush 会把水平滚动归零——测试时序伪影,非产品 bug)
hbar = lay2.out_box.horizontalScrollBar()
hbar.setValue(500)
v0 = hbar.value()
dv0 = panel.sec_done.scroll.verticalScrollBar().value()
check("P11.3 前置:水平滚动处于非 0 位置", v0 > 0, f"v0={v0}")
panel.on_started("s4", "bash", {"command": "cmd-s4"}) # 新任务 → 两栏重排
settle(150)
lay2b = panel._layers["s2"]
check("P11.4 重排后 s2 仍是同一个 BashLayer 实例(不重建)", lay2b is lay2)
check("P11.5 展开/折叠状态保持", lay2b.expanded is True and not lay2b.body.isHidden())
check("P11.6 实时输出保持(未因重排丢失/重渲染)", lay2b._live == "x" * 2000 + "\n",
repr(lay2b._live[:20]))
check("P11.7 代码框水平滚动值保持", hbar.value() == v0, f"{hbar.value()} vs {v0}")
check("P11.8 已完成栏 section 滚动位置保持",
panel.sec_done.scroll.verticalScrollBar().value() == dv0)
check("P11.9 运行中栏重排后仍启动倒序(s4 顶到第一项)",
panel.layer_ids("running") == ["s4", "s3", "s2", "s1"],
str(panel.layer_ids("running")))
# ---- 11.3 完成时间【不是】排序键:s2 启动更晚却先完成 → 仍在 s1 之上 ----
panel.on_finished("s2", "bash", True, "$ cmd-s2\nok2\n[exit 0] (0.1s)")
panel.on_finished("s1", "bash", True, "$ cmd-s1\nok1\n[exit 0] (0.2s)")
settle(150)
check("P11.10 已完成栏按启动倒序(s2 先完成仍在第一项,不按完成时间)",
panel.layer_ids("done") == ["s2", "s1"], str(panel.layer_ids("done")))
check("P11.11 完成后从运行中栏消失(启动位置不变,仅换栏)",
panel.layer_ids("running") == ["s4", "s3"], str(panel.layer_ids("running")))
# ---- 11.4 运行中→已完成 仍占原启动位置(晚完成不顶到最上) ----
panel.on_started("s6", "bash", {"command": "cmd-s6"})
panel.on_finished("s6", "bash", True, "$ cmd-s6\nok6\n[exit 0] (0.1s)")
check("P11.12 s6 最后启动 → 顶到已完成栏第一项",
panel.layer_ids("done") == ["s6", "s2", "s1"], str(panel.layer_ids("done")))
panel.on_finished("s3", "bash", True, "$ cmd-s3\nok3\n[exit 0] (0.3s)") # s3 最后完成
settle(150)
check("P11.13 s3 最后完成但按启动位置插入第二项(不顶到最上)",
panel.layer_ids("done") == ["s6", "s3", "s2", "s1"], str(panel.layer_ids("done")))
# ---- 11.4b 已完成栏 section 滚动位置保持(有实际滚动范围时) ----
_long = "\n".join(f"line-{j}" for j in range(20)) # 20 行 → 撑满 out_box 230px 上限
panel._layers["s6"].set_finished(True, f"$ cmd-s6\n{_long}\n[exit 0] (0.1s)")
panel._layers["s3"].set_finished(True, f"$ cmd-s3\n{_long}\n[exit 0] (0.2s)")
panel._layers["s6"].toggle() # 展开两层 → 内容必然超出栏高,产生真实滚动范围
panel._layers["s3"].toggle()
settle(150)
sbar = panel.sec_done.scroll.verticalScrollBar()
sbar.setValue(30)
sv0 = sbar.value()
check("P11.13b 前置:已完成栏存在真实滚动范围(v>0)", sv0 > 0, f"sv0={sv0} max={sbar.maximum()}")
panel.on_started("t0", "bash", {"command": "cmd-t0"}) # 新任务 → 两栏重排
panel.on_finished("t0", "bash", True, "$ cmd-t0\nok\n[exit 0] (0.1s)")
settle(150)
check("P11.13c 重排后已完成栏 section 滚动值保持", sbar.value() == sv0,
f"{sbar.value()} vs {sv0}")
check("P11.13d 展开态在重排后仍保持", panel._layers["s6"].expanded is True
and panel._layers["s3"].expanded is True)
# ---- 11.5 限量窗口:窗口成员不变,仅显示顺序反转(顶层 = 最新启动) ----
for i in range(1, 32):
cid = f"t{i}"
panel.on_started(cid, "bash", {"command": f"cmd-{cid}"})
panel.on_finished(cid, "bash", True, f"$ cmd-{cid}\nok\n[exit 0] (0.1s)")
settle(200)
check("P11.14 内部仍保留全部 36 个已完成(5 手工 + 31 批量)", len(panel.layer_ids("done")) == 36,
str(len(panel.layer_ids("done"))))
check("P11.15 限量显示仍为 30 层", panel.sec_done.count.text() == str(LAYER_LIMIT),
panel.sec_done.count.text())
check("P11.16 「仅显示最近 N 层」提示保留", "仅显示最近 30 层" in panel.sec_done.hint.text(),
panel.sec_done.hint.text())
_rendered_done = [w for w in (panel.sec_done.lay.itemAt(i).widget()
for i in range(panel.sec_done.lay.count()))
if w is not None]
check("P11.17 渲染窗口 = 最近启动的 30 个(不含最早启动的 s1/s2/s3/s6/t0/t1",
[w.call_id for w in _rendered_done][0] == "t31"
and "s1" not in [w.call_id for w in _rendered_done]
and "s2" not in [w.call_id for w in _rendered_done]
and len(_rendered_done) == 30,
str([w.call_id for w in _rendered_done][:5]))
check("P11.18 已完成栏顶层 = 最新启动(t31),底层 = 窗口内最早(t2)",
[w.call_id for w in _rendered_done][0] == "t31"
and [w.call_id for w in _rendered_done][-1] == "t2",
str([w.call_id for w in _rendered_done][:2] + [w.call_id for w in _rendered_done][-1:]))
# ---- 11.6 DB 重建(切换会话):启动序号 = 消息链顺序 + 时间线内顺序 → 显示倒序 ----
sid5 = window.db.create_session("P2-01 启动倒序")["id"]
leaf5 = window.db.get_session_leaf(sid5)
tl5 = json.dumps([{"t": "tool", "id": f"db{i}", "name": "bash",
"args": json.dumps({"command": f"db-cmd{i}"}),
"ok": True, "result": f"$ db-cmd{i}\nok\n[exit 0] (0.1s)"}
for i in range(3)], ensure_ascii=False)
window.db.add_message(sid5, "assistant", "a", leaf5, timeline=tl5)
window.load_messages_to_web(sid5)
settle(300)
check("P11.19 DB 重建后已完成栏按启动倒序(db2 顶层、db0 底层)",
panel.layer_ids("done") == ["db2", "db1", "db0"], str(panel.layer_ids("done")))
check("P11.20 「all」仍返回原始启动序号(正序,调试口径不变)",
panel.layer_ids() == ["db0", "db1", "db2"], str(panel.layer_ids()))
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)
# offscreen 铁律:QtWebEngine 渲染/GPU 子进程在解释器退出时可能不回收 → 挂起;
# 与 smoke_offscreen 等 offscreen harness 一致,用 os._exit 强制收尾(stdout 已 flush
os._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 返回真实挂起 IDtruthy但回调永不被调用
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] 打印)=====")
+203
View File
@@ -0,0 +1,203 @@
# -*- 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
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
from tests._test_env import isolate # noqa: E402
_TMP = isolate("midswitch") # noqa: E402
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)
+206
View File
@@ -0,0 +1,206 @@
"""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")
# P1-03QtWebEngine 独立 profile 重定向到临时目录(不占项目 data/webengine/
os.environ.setdefault(
"HAOCODE_WEBENGINE_PROFILE_DIR",
os.path.join(_tf.gettempdir(), f"haocode_test_smoke_offscreen_{os.getpid()}_profile"))
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)
+195
View File
@@ -0,0 +1,195 @@
# -*- 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
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
from tests._test_env import isolate # noqa: E402
_TMP = isolate("timeline") # noqa: E402
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)
+289
View File
@@ -0,0 +1,289 @@
# -*- 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()
# 注意:探针与循环变量名解耦(源码列表推导式用 `m` 或 `msg` 均可),
# 只锁死“渲染过滤排除 system/compaction”这一语义。
check("T9.渲染过滤含 compaction", '["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)
+259
View File
@@ -0,0 +1,259 @@
# -*- coding: utf-8 -*-
"""tests/test_config_isolation.py —— P0-01 配置路径与测试隔离回归
运行: python tests/test_config_isolation.py
仓库惯例 pytest 依赖独立可跑GUI 部分自动走 offscreen
完成证据REPAIR_BACKLOG.md P0-01
1. 打开路径拦截器记录到的配置打开路径全部位于临时目录真实配置路径从未被打开
该断言只用路径字符串比较不读取不散列真实配置文件
2. 临时配置读写用例通过临时数据库之外没有数据库写入
3. 缺失配置与损坏配置各有一个回归用例日志含明确警告返回安全默认值无异常
4. 静态扫描源码中不存在绕过统一路径解析core/config_paths的运行时配置读取
"""
import ast
import contextlib
import io
import json
import os
import sqlite3
import sys
_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_TESTS_DIR)
sys.path.insert(0, _ROOT)
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("HAOCODE_RENDER", "software")
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
from tests._test_env import isolate, default_config # noqa: E402
_TMP = isolate("cfgiso") # 必须在 import MainWindow 之前
_REAL_CONFIG = os.path.join(_ROOT, "data", "config.json") # 仅作路径字符串,永不打开
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
# ======================================================================
# 拦截器:记录 builtins.open 与 sqlite3.connect 触碰的路径(不读取真实配置内容)
# ======================================================================
class OpenInterceptor:
def __init__(self):
self.ops = [] # (path, mode)
self._real_open = None
def __enter__(self):
self._real_open = __builtins__["open"] if isinstance(
__builtins__, dict) else __builtins__.open
import builtins
builtins.open = self._open
return self
def _open(self, file, mode="r", *args, **kwargs):
try:
p = os.fspath(file)
except TypeError:
p = None
if p is not None:
self.ops.append((os.path.abspath(str(p)), str(mode)))
return self._real_open(file, mode, *args, **kwargs)
def __exit__(self, *exc):
import builtins
builtins.open = self._real_open
return False
# ---- 断言辅助 ----
def config_ops(self):
# 含原子写的临时文件 config.json.tmp
return [(p, m) for p, m in self.ops
if os.path.basename(p).startswith("config.json")]
def real_config_opened(self):
return [op for op in self.ops
if os.path.normcase(op[0]) == os.path.normcase(_REAL_CONFIG)]
class SqliteInterceptor:
def __init__(self):
self.paths = []
self._real_connect = None
def __enter__(self):
self._real_connect = sqlite3.connect
sqlite3.connect = self._connect
return self
def _connect(self, database, *args, **kwargs):
self.paths.append(str(database))
return self._real_connect(database, *args, **kwargs)
def __exit__(self, *exc):
sqlite3.connect = self._real_connect
return False
# ======================================================================
# 1) 静态扫描:不得存在绕过统一路径解析的配置读取
# (源码中不允许出现 "config.json" 字符串字面量,core/config_paths.py 除外;
# 文档字符串与注释不计)
# ======================================================================
def _docstring_values(tree) -> set:
vals = set()
def _first_expr_body(body):
if body and isinstance(body[0], ast.Expr) and \
isinstance(body[0].value, ast.Constant) and \
isinstance(body[0].value.value, str):
vals.add(body[0].value.value)
_first_expr_body(tree.body)
for node in ast.walk(tree):
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
_first_expr_body(node.body)
return vals
def static_scan():
allowed = os.path.normcase(os.path.join(_ROOT, "core", "config_paths.py"))
bad = []
scan_dirs = [os.path.join(_ROOT, d) for d in ("core", "ui", "tools")]
scan_files = [os.path.join(_ROOT, "main.py")]
targets = list(scan_files)
for d in scan_dirs:
for dirpath, _dirnames, filenames in os.walk(d):
if "__pycache__" in dirpath:
continue
for fn in filenames:
if fn.endswith(".py"):
targets.append(os.path.join(dirpath, fn))
for path in targets:
with open(path, "r", encoding="utf-8") as f:
src = f.read()
tree = ast.parse(src, filename=path)
docstrings = _docstring_values(tree)
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str) \
and "config.json" in node.value \
and node.value not in docstrings:
if os.path.normcase(path) != allowed:
bad.append(f"{os.path.relpath(path, _ROOT)}:{node.lineno}")
return bad
bad_literal = static_scan()
check("静态:源码无绕过统一解析的 config.json 字面量(仅 core/config_paths.py 允许)",
not bad_literal, f"发现: {bad_literal}")
# ======================================================================
# 2) 缺失配置 / 损坏配置:可见警告 + 安全默认值 + 无异常
# ======================================================================
from core.config_paths import config_path, load_config # noqa: E402
import core.llm_engine as le # noqa: E402
saved_env = os.environ.get("HAOCODE_CONFIG_FILE")
_missing = os.path.join(_TMP["base"], "no_such_config.json")
os.environ["HAOCODE_CONFIG_FILE"] = _missing
_buf = io.StringIO()
with contextlib.redirect_stdout(_buf):
_res = load_config()
_log = _buf.getvalue()
check("缺失配置:返回安全空 dict", _res == {})
check("缺失配置:日志含明确警告", "缺失" in _log and "config" in _log, _log)
_corrupt = os.path.join(_TMP["base"], "corrupt.json")
with open(_corrupt, "w", encoding="utf-8") as f:
f.write("{ this is not valid json")
os.environ["HAOCODE_CONFIG_FILE"] = _corrupt
_buf = io.StringIO()
with contextlib.redirect_stdout(_buf):
_res = load_config()
_log = _buf.getvalue()
check("损坏配置:返回安全空 dict", _res == {})
check("损坏配置:日志含明确警告", "解析失败" in _log or "读取/解析失败" in _log, _log)
_notdict = os.path.join(_TMP["base"], "notdict.json")
with open(_notdict, "w", encoding="utf-8") as f:
f.write("[1, 2, 3]")
os.environ["HAOCODE_CONFIG_FILE"] = _notdict
_buf = io.StringIO()
with contextlib.redirect_stdout(_buf):
_res = load_config()
_log = _buf.getvalue()
check("非对象配置:返回安全空 dict 且有警告", _res == {} and "不是 JSON 对象" in _log, _log)
os.environ["HAOCODE_CONFIG_FILE"] = saved_env # 恢复临时配置
check("环境变量恢复:config_path 回到临时目录",
os.path.normcase(config_path()) == os.path.normcase(_TMP["config"]))
# ======================================================================
# 3) 临时配置读取(llm_engine 统一入口)
# ======================================================================
_cfg_now = le._load_config()
check("llm_engine._load_config 读取临时配置(含测试 provider)",
"testprov" in _cfg_now.get("providers", {}))
# ======================================================================
# 4) MainWindow 全链路:构造主窗口期间,配置打开/数据库连接全部落在临时目录
# ======================================================================
from PyQt6.QtWidgets import QApplication # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
import ui.views.bash_panel as bp # noqa: E402
app = QApplication.instance() or QApplication(sys.argv)
oi = OpenInterceptor()
si = SqliteInterceptor()
with oi, si:
win = MainWindow()
# 宽度记录写回(config.json 写路径)
bp.save_panel_width(340)
win.close()
win.deleteLater()
app.processEvents()
check("MainWindow 构造期间真实配置从未被打开",
not oi.real_config_opened(), f"打开记录: {oi.real_config_opened()}")
_cfg_ops = oi.config_ops()
check("MainWindow 链路至少发生一次配置读取(路径被验证而非空转)",
len(_cfg_ops) >= 1, f"记录: {_cfg_ops}")
_check_base = os.path.normcase(os.path.abspath(_TMP["base"]))
_outside = [op for op in _cfg_ops
if not os.path.normcase(op[0]).startswith(_check_base)]
check("全部 config.json 打开路径均位于临时目录", not _outside, f"越界: {_outside}")
_write_ops = [op for op in _cfg_ops if "w" in op[1]]
check("配置写路径(bash_panel 宽度)落在临时目录",
bool(_write_ops) and not [op for op in _write_ops
if not os.path.normcase(op[0]).startswith(_check_base)],
f"写记录: {_write_ops}")
check("save_panel_width 写回可被读回", bp.load_panel_width() == 340)
_db_outside = [p for p in si.paths
if not (os.path.normcase(os.path.abspath(p)).startswith(_check_base)
or p in (":memory:", ""))]
check("临时数据库之外没有数据库连接/写入", not _db_outside, f"越界: {_db_outside}")
check("MainWindow.config_data 来自临时配置(providers 无真实凭据)",
set(win.config_data.get("providers", {}).keys()) == {"testprov"})
# ======================================================================
# 汇总
# 注:QtWebEngine 在 offscreen 下的 C++ 静态析构可能在正常 sys.exit 后段错误,
# 测试进程用 os._exit 直接退出(退出码已在上方断言中确定),不改变测试结果。
# ======================================================================
print(f"\n===== test_config_isolation: {'ALL PASS' if ok else 'HAS FAILURES'} =====", flush=True)
os._exit(0 if ok 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)
+253
View File
@@ -0,0 +1,253 @@
# -*- coding: utf-8 -*-
"""
P1-02 跨平台 shell 契约单测纯函数级 + 真实进程级 UI 依赖
覆盖
A. 平台参数Windows `cmd.exe /d /c` argv / Linux `/bin/bash -lc` argv
Linux Popen 独立进程组start_new_sessionWindows 无该参数
B. 提示词平台段通用正文完全相同只插入对应平台段互不串段
load_system_prompt() 与文件内容一致当前平台
C. 进程树真实进程+
- 当前平台超时 父与孙都不存在
- 当前平台主动中止 父与孙都不存在
- Linux 专属独立进程组整组终止Windows 上跳过
D. kill_process_tree 安全边界已退出的进程 / 无独立组 不抛异常不误杀
运行: PYTHONIOENCODING=utf-8 python tests/test_cross_platform_shell.py
"""
import os
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core import platform_shell # noqa: E402
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)
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))
_TMP = tempfile.mkdtemp(prefix="haocode_crossshell_")
PY = sys.executable
IS_WIN = os.name == "nt"
# ======================================================================
# A. 平台参数(mock 平台,纯逻辑)
# ======================================================================
_orig_is_win = platform_shell.is_windows
def _mock_win(v):
platform_shell.is_windows = lambda: v
try:
_mock_win(True)
check("A1 Windows 命令 = cmd.exe /d /s /c \"<cmd>\"",
platform_shell.shell_command("echo hi")
== 'cmd.exe /d /s /c "echo hi"',
repr(platform_shell.shell_command("echo hi")))
check("A2 Windows popen_flags 无进程组参数",
platform_shell.popen_flags() == {}, repr(platform_shell.popen_flags()))
_mock_win(False)
check("A3 Linux argv = /bin/bash -lc <cmd>",
platform_shell.shell_command("echo hi") == ["/bin/bash", "-lc", "echo hi"],
repr(platform_shell.shell_command("echo hi")))
check("A4 Linux popen_flags 独立进程组",
platform_shell.popen_flags() == {"start_new_session": True},
repr(platform_shell.popen_flags()))
finally:
platform_shell.is_windows = _orig_is_win
# ======================================================================
# B. 提示词平台段
# ======================================================================
_PROMPT_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"SYSTEM_PROMPT.md")
with open(_PROMPT_FILE, "r", encoding="utf-8") as f:
_PROMPT_TEXT = f.read().strip()
check("B1 通用正文含占位符",
platform_shell.SHELL_SECTION_PLACEHOLDER in _PROMPT_TEXT)
_GENERIC_NO_LEAK = ("cmd.exe" not in _PROMPT_TEXT
and "/bin/bash" not in _PROMPT_TEXT)
check("B2 通用正文无平台泄漏(无 cmd.exe / 无 /bin/bash", _GENERIC_NO_LEAK)
try:
_mock_win(True)
full_win = platform_shell.apply_platform_section(_PROMPT_TEXT)
sec_win = platform_shell.shell_prompt_section()
_mock_win(False)
full_lin = platform_shell.apply_platform_section(_PROMPT_TEXT)
sec_lin = platform_shell.shell_prompt_section()
finally:
platform_shell.is_windows = _orig_is_win
check("B3 Windows 段含 cmd.exe、无 /bin/bash",
"cmd.exe" in sec_win and "/bin/bash" not in sec_win)
check("B4 Linux 段含 /bin/bash -lc、无 cmd.exe",
"/bin/bash -lc" in sec_lin and "cmd.exe" not in sec_lin)
check("B5 只有对应平台段被插入",
full_win == _PROMPT_TEXT.replace(platform_shell.SHELL_SECTION_PLACEHOLDER, sec_win)
and full_lin == _PROMPT_TEXT.replace(platform_shell.SHELL_SECTION_PLACEHOLDER, sec_lin))
check("B6 通用正文两平台完全相同",
full_win.replace(sec_win, "§SEC§") == full_lin.replace(sec_lin, "§SEC§"))
check("B7 无占位符文本原样返回",
platform_shell.apply_platform_section("无占位符的兜底文本") == "无占位符的兜底文本")
# load_system_prompt()(当前平台,真实文件)
from core import llm_engine # noqa: E402 (PyQt6/openai 已装,无需 QApplication)
check("B8 load_system_prompt = 文件内容 + 当前平台段",
llm_engine.load_system_prompt()
== platform_shell.apply_platform_section(_PROMPT_TEXT))
# ======================================================================
# C. 进程树(真实进程:父 + 孙,心跳文件证明生死)
# ======================================================================
_CHILD = os.path.join(_TMP, "child.py")
with open(_CHILD, "w", encoding="utf-8") as f:
f.write(
"import sys, time\n"
"hb = open(sys.argv[1], 'a', encoding='utf-8')\n"
"t0 = time.time()\n"
"while time.time() - t0 < 30:\n"
" hb.write(f'{time.time():.3f}\\n')\n"
" hb.flush()\n"
" time.sleep(0.2)\n"
"open(sys.argv[2], 'w').write('done')\n"
)
def _spawn_parent(hb_path, done_path, out_path):
"""写一个父进程脚本(启动孙进程后挂 30s),返回 bash 命令字符串。"""
parent = os.path.join(_TMP, f"parent_{os.path.basename(hb_path)}.py")
with open(parent, "w", encoding="utf-8") as f:
f.write(
"import subprocess, sys, time\n"
"p = subprocess.Popen([sys.executable, "
+ repr(_CHILD) + ", sys.argv[1], sys.argv[2]], "
"stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n"
"time.sleep(30)\n"
)
cmd = f'"{PY}" -u "{parent}" "{hb_path}" "{done_path}"'
return cmd
def _hb_last(hb_path):
try:
with open(hb_path, "r", encoding="utf-8") as f:
lines = [l.strip() for l in f if l.strip()]
return float(lines[-1]) if lines else None
except FileNotFoundError:
return None
def _tree_dead(hb_path, done_path, quiesce=1.2):
"""等待静默后判断:孙进程不再有心跳、且未跑完 → 进程树已死。"""
time.sleep(quiesce)
last = _hb_last(hb_path)
return last is not None and (time.time() - last) > 0.6, os.path.exists(done_path)
def run_tree_case(tag, timeout=None, abort_after=None):
hb = os.path.join(_TMP, f"hb_{tag}.txt")
done = os.path.join(_TMP, f"done_{tag}.txt")
out = os.path.join(_TMP, f"out_{tag}.log")
cmd = _spawn_parent(hb, done, out)
updates, timers = [], []
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()
t0 = time.time()
res = tool_bash(f"call_{tag}", {"command": cmd, "timeout": timeout or 60}, sig,
lambda t: updates.append(t),
{"cwd": _TMP, "on_timer": lambda e, tt: timers.append((e, tt))})
dur = time.time() - t0
return res, dur, hb, done, timers
# ---- C1: 超时 → 父与孙都不存在 ----
res, dur, hb, done, timers = run_tree_case("timeout", timeout=3)
txt = text_of(res)
dead, finished = _tree_dead(hb, done)
check("C1.1 超时返回错误结果", res.is_error and "超时" in txt, f"{txt!r}")
check("C1.2 超时快速返回(<15s,含 taskkill 缓冲)", dur < 15, f"dur={dur:.1f}s")
check("C1.3 超时后父+孙都不存在(心跳停止且未跑完)", dead and not finished,
f"hb_last={_hb_last(hb)} finished={finished}")
check("C1.4 读秒滴答仍工作", len(timers) >= 2, f"timers={timers}")
# ---- C2: 主动中止 → 父与孙都不存在 ----
res, dur, hb, done, _timers2 = run_tree_case("abort", timeout=60, abort_after=1.5)
txt = text_of(res)
dead, finished = _tree_dead(hb, done)
check("C2.1 中止返回错误结果", res.is_error and "中止" in txt, f"{txt!r}")
check("C2.2 中止后父+孙都不存在(心跳停止且未跑完)", dead and not finished,
f"hb_last={_hb_last(hb)} finished={finished}")
# ---- C3: Linux 独立进程组整组终止(Windows 跳过) ----
if not IS_WIN:
proc = subprocess.Popen(
platform_shell.shell_command("sleep 30"),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
**platform_shell.popen_flags())
time.sleep(0.3)
try:
pgid = os.getpgid(proc.pid)
check("C3.1 start_new_session 生效(pgid == 子 pid", pgid == proc.pid,
f"pgid={pgid} pid={proc.pid}")
platform_shell.kill_process_tree(proc, grace_s=1.0)
time.sleep(0.5)
check("C3.2 整组终止后子进程不存在",
proc.poll() is not None, f"poll={proc.poll()}")
except Exception as e:
try:
platform_shell.kill_process_tree(proc, grace_s=1.0)
except Exception:
pass
check("C3 Linux 进程组用例", False, repr(e))
else:
print("SKIP C3 Linux 进程组用例(当前为 WindowsWindows 用例见 C1/C2", flush=True)
# ======================================================================
# D. kill_process_tree 安全边界
# ======================================================================
_p = subprocess.Popen([PY, "-c", "pass"])
_p.wait(timeout=5)
try:
platform_shell.kill_process_tree(_p, grace_s=0.5)
check("D1 已退出进程:不抛异常", True)
except Exception as e:
check("D1 已退出进程:不抛异常", False, repr(e))
try:
platform_shell.kill_process_tree(None)
check("D2 None 输入:不抛异常", True)
except Exception as e:
check("D2 None 输入:不抛异常", False, repr(e))
# ======================================================================
print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True)
sys.exit(0 if all(ok for _, ok in RESULTS) 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)
+361
View File
@@ -0,0 +1,361 @@
# -*- 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")
# 铁律(P0-01):临时数据库 + 临时配置必须在 import MainWindow 之前完成
from tests._test_env import isolate # noqa: E402
_TMP = isolate("errpersist", config={"providers": {}})
_DB_TMP = _TMP["db"]
_CFG_TMP = _TMP["config"]
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()
+279
View File
@@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
"""
P1-04 全局热键平台矩阵单测适配器替身不依赖真实 X11 显示 / 不新增依赖
覆盖
H1 session_kind 矩阵win32 / x11 / wayland / offscreen-unknownmock 环境变量 + sys.platform
H2 hotkey_plan 路由win32RegisterHotKey 线程x11XGrabKey 线程wayland/offscreenNone+明确日志
H3 X11HotkeyThread 成功路径fake libX11XGrabKey 参数正确命中 keycode 发射 triggered
stop XUngrabKey/XCloseDisplay 释放
H4 X11 注册失败XGrabKey=0 键被占用 / 无显示 / 不支持的组合 _registered=False线程安静退出
H5 Windows GlobalHotkeyThread 行为保持 Windows run() 静默就绪退出Windows 常量完整
运行: PYTHONIOENCODING=utf-8 python tests/test_global_hotkey_platforms.py
"""
import contextlib
import io
import os
import socket
import sys
import threading
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication # noqa: E402
from ui.views.system_tools import desktop_session as ds # noqa: E402
from ui.views.system_tools import x11_hotkey as xh # noqa: E402
from ui.views.system_tools import global_hotkey as gh # noqa: E402
RESULTS = []
APP = QApplication.instance() or QApplication(sys.argv)
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)
def with_env(patch: dict, fn):
saved = {k: os.environ.get(k) for k in patch}
for k, v in patch.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
try:
return fn()
finally:
for k, v in saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
# ======================================================================
# H1. session_kind 矩阵
# ======================================================================
_orig_platform = ds.sys.platform
def _set_platform(p):
ds.sys = type("FakeSys", (), {"platform": p})()
try:
_set_platform("win32")
check("H1.1 win32", ds.session_kind() == "win32")
_set_platform("linux")
check("H1.2 X11DISPLAY 有、无 WAYLAND_DISPLAY",
with_env({"DISPLAY": ":0", "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": None,
"QT_QPA_PLATFORM": "xcb"}, lambda: ds.session_kind()) == "x11")
check("H1.3 WaylandWAYLAND_DISPLAY 有)",
with_env({"WAYLAND_DISPLAY": "wayland-0", "DISPLAY": ":0", "XDG_SESSION_TYPE": None,
"QT_QPA_PLATFORM": "wayland"}, lambda: ds.session_kind()) == "wayland")
check("H1.4 QT_QPA_PLATFORM=wayland(无 WAYLAND_DISPLAY",
with_env({"WAYLAND_DISPLAY": None, "DISPLAY": None, "XDG_SESSION_TYPE": None,
"QT_QPA_PLATFORM": "wayland"}, lambda: ds.session_kind()) == "wayland")
check("H1.5 offscreen → unknown(即使有 DISPLAY",
with_env({"DISPLAY": ":0", "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": None,
"QT_QPA_PLATFORM": "offscreen"}, lambda: ds.session_kind()) == "unknown")
check("H1.6 无显示无 WAYLAND → unknown",
with_env({"DISPLAY": None, "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": None,
"QT_QPA_PLATFORM": None}, lambda: ds.session_kind()) == "unknown")
check("H1.7 XDG_SESSION_TYPE=wayland(无 WAYLAND_DISPLAY/QT 平台)",
with_env({"DISPLAY": None, "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": "wayland",
"QT_QPA_PLATFORM": None}, lambda: ds.session_kind()) == "wayland")
check("H1.8 XDG_SESSION_TYPE=x11(无 DISPLAY",
with_env({"DISPLAY": None, "WAYLAND_DISPLAY": None, "XDG_SESSION_TYPE": "x11",
"QT_QPA_PLATFORM": None}, lambda: ds.session_kind()) == "x11")
check("H1.9 矛盾时 WAYLAND_DISPLAY 优先于 XDG_SESSION_TYPE=x11",
with_env({"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0", "XDG_SESSION_TYPE": "x11",
"QT_QPA_PLATFORM": None}, lambda: ds.session_kind()) == "wayland")
finally:
ds.sys = sys # 还原
# ======================================================================
# H2. hotkey_plan 路由
# ======================================================================
try:
_set_platform("win32")
factory, msg = ds.hotkey_plan("win32")
check("H2.1 win32 → GlobalHotkeyThread 工厂 + 说明",
factory is gh.GlobalHotkeyThread and "Windows" in msg, repr((factory, msg)))
finally:
ds.sys = sys
factory, msg = ds.hotkey_plan("x11")
check("H2.2 x11 → X11HotkeyThread 工厂 + 说明",
factory is xh.X11HotkeyThread and "X11" in msg, repr((factory, msg)))
factory, msg = ds.hotkey_plan("wayland")
check("H2.3 wayland → None + 明确不可用说明(不绕过 compositor)",
factory is None and "Wayland" in msg and "Alt+S" in msg, repr((factory, msg)))
factory, msg = ds.hotkey_plan("unknown")
check("H2.4 unknown → None + 明确不可用说明",
factory is None and "不可用" in msg, repr((factory, msg)))
# ======================================================================
# H3H4. X11HotkeyThreadfake libX11
# ======================================================================
class FakeX11:
def __init__(self, grab_rc=1, open_rc=0xAB, keycode=39, unsupported_vk=False):
self.grab_rc = grab_rc
self.open_rc = open_rc
self.keycode = keycode
self.unsupported_vk = unsupported_vk
self.grab_calls = []
self.ungrab_calls = []
self.close_calls = 0
self.select_calls = 0
self.opened = False
self.pending_left = 1
# socketpair:跨平台可被 select() 监听(Windows 上 os.pipe 的 fd 不行)
self._a, self._b = socket.socketpair()
self._b.send(b"\x01") # 让 a 有可读数据 → select 首次就绪
self._event = xh.XEvent()
self._event.type = xh.KeyPress
self._event.keycode = self.keycode
# -- libX11 API(鸭子类型替身) --
def XOpenDisplay(self, name):
self.opened = True
return self.open_rc
def XCloseDisplay(self, d):
self.close_calls += 1
def XConnectionNumber(self, d):
return self._a.fileno()
def XDefaultRootWindow(self, d):
return 123
def XKeysymToKeycode(self, d, keysym):
if self.unsupported_vk:
return 0
return self.keycode
def XSelectInput(self, d, w, mask):
self.select_calls += 1
def XGrabKey(self, d, kc, mod, w, owner):
self.grab_calls.append((kc, mod, w, owner))
return self.grab_rc
def XUngrabKey(self, d, kc, mod, w):
self.ungrab_calls.append((kc, mod, w))
def XPending(self, d):
if self.pending_left > 0:
self.pending_left -= 1
return 1
return 0
def XNextEvent(self, d, evp):
# 生产代码传入 ctypes.byref(ev)CArgObject);真实 CDLL 自行解引用,
# 替身可调用对象则通过 ._obj 拿回原始 struct
import ctypes as _ct
target = getattr(evp, "_obj", evp)
_ct.memmove(_ct.byref(target), _ct.byref(self._event), _ct.sizeof(xh.XEvent))
self._event.type = 0 # 之后无事件
def close(self):
for s in (self._a, self._b):
try:
s.close()
except Exception:
pass
def run_hotkey_case(fake, **kw):
"""启动 X11HotkeyThread,等待 triggered 或退出,回收。返回 (got_signal, thread)"""
orig_open = xh._open_x11
xh._open_x11 = lambda: (fake, fake.open_rc)
try:
t = xh.X11HotkeyThread(**kw)
got = threading.Event()
t.triggered.connect(lambda: got.set())
t.start()
t.wait_ready(3.0)
deadline = time.time() + 6.0
while not got.is_set() and time.time() < deadline:
APP.processEvents()
time.sleep(0.02)
t.stop()
t.wait(3000)
return got.is_set(), t
finally:
xh._open_x11 = orig_open
fake.close()
# H3.1 成功路径:grab 参数正确 + 命中发射 + stop 释放
fake = FakeX11()
got, t = run_hotkey_case(fake)
check("H3.1 命中 Alt+S → triggered 发射", got)
check("H3.2 XGrabKey 参数(keycode=39, Mod1Mask=1, root=123, owner_events=1",
fake.grab_calls == [(39, xh.Mod1Mask, 123, 1)], repr(fake.grab_calls))
check("H3.3 stop 后 XUngrabKey + XCloseDisplay 释放",
len(fake.ungrab_calls) == 1 and fake.close_calls == 1,
repr((fake.ungrab_calls, fake.close_calls)))
check("H3.4 注册成功标志 _registered", t._registered is False) # cleanup 后复位为 False
# H4.1 键被占用(XGrabKey → 0
fake = FakeX11(grab_rc=0)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
got, t = run_hotkey_case(fake)
check("H4.1 注册失败 → 无信号、_registered=False、明确日志",
not got and t._registered is False and ("占用" in buf.getvalue() or "失败" in buf.getvalue()),
buf.getvalue()[-200:])
# H4.2 无显示(XOpenDisplay → None
fake = FakeX11(open_rc=None)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
got, t = run_hotkey_case(fake)
check("H4.2 无 X11 显示 → 安静退出 + 明确日志",
not got and t._registered is False and "XOpenDisplay" in buf.getvalue(),
buf.getvalue()[-200:])
# H4.3 不支持的组合(vk 不在窄映射表)
fake = FakeX11()
orig_open = xh._open_x11
t_probe = xh.X11HotkeyThread(vk=0x41)
xh._open_x11 = lambda: (fake, 0xAB)
try:
t_probe.start()
t_probe.wait_ready(3.0)
deadline = time.time() + 5.0
while t_probe.isRunning() and time.time() < deadline:
time.sleep(0.02)
t_probe.stop()
t_probe.wait(2000)
finally:
xh._open_x11 = orig_open
fake.close()
check("H4.3 不支持的组合 → 不打开显示即退出",
t_probe._registered is False and not fake.opened, repr(t_probe._registered))
# ======================================================================
# H5. Windows 路径保持
# ======================================================================
check("H5.1 GlobalHotkeyThread 常量完整(MOD_ALT/VK_S/WM_HOTKEY",
gh.MOD_ALT == 0x0001 and gh.VK_S == 0x53 and gh.WM_HOTKEY == 0x0312)
t_win = gh.GlobalHotkeyThread()
t_win.start()
t_win._ready.wait(3.0)
if gh._is_windows:
# 本机 Windows:真实 RegisterHotKey + GetMessage 循环 = 行为保持的存活检查(随后立即释放 Alt+S)
check("H5.2 WindowsRegisterHotKey 线程运行中(行为保持)", t_win.isRunning())
t_win.stop()
t_win.wait(3000)
check("H5.3 Windowsstop() 干净退出(释放热键)", not t_win.isRunning())
else:
t_win.wait(3000)
check("H5.2 非 Windowsrun() 静默就绪退出(不注册、不崩)",
not t_win.isRunning() and t_win._ready.is_set(),
f"running={t_win.isRunning()}")
print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True)
sys.exit(0 if all(ok for _, ok in RESULTS) else 1)

Some files were not shown because too many files have changed in this diff Show More