docs: add agent handoff docs, verification guide, and repair evidence
Add docs/agent-handoff (backlog, execution state, verification, platform plan, per-task evidence), repo AGENTS.md, and architecture notes updated for the dual-renderer design.
This commit is contained in:
@@ -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 worker(Agent/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 PASS(14 项断言) |
|
||||
| `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 人工脚本,默认聚合排除。
|
||||
@@ -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 PASS(18 项断言) |
|
||||
| `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 PASS(14 项) |
|
||||
| 回归 `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 被禁用**(B1–B3):`btn_send.setEnabled(False)` → 至多一次调用且无流、输入内容保留(规则在 `send_message` 内生效);
|
||||
- **流式时 Enter 被拦截**(C1–C3):注入假流 → 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 · Node(test_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 PASS(39 项) |
|
||||
| `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 PASS(14 项,P0-01 新增) |
|
||||
| `python tests/test_main_window_event_filter.py` | ALL PASS(18 项,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 PASS(116 项) |
|
||||
|
||||
JS(1 个套件,EXIT=0):`node tests/test_math_extract.js` — 39 passed, 0 failed。
|
||||
|
||||
## 判定
|
||||
|
||||
P0 阶段回归通过:测试不访问真实配置/数据库(拦截器证明 + 临时环境),`MainWindow`
|
||||
输入行为无回归(P0-02 四态断言 + 既有 116 项 bash 面板回归)。真实桌面矩阵属
|
||||
P1/P2 阶段(PLATFORM_PLAN 验收矩阵),本阶段不声称桌面已验证。
|
||||
@@ -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,非法静默回落 40;mode `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 规模诊断(真实 viewport:resize+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 ms(min/avg/max);DOM 节点 1057–1080;页面高度 7127–7213 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)只作真机基准参考,非硬阈值(符合任务要求)。
|
||||
- 帧耗时真机人工基准报告留待人工验收环节。
|
||||
@@ -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>"` 作为**字符串**交给 CreateProcessW,cmd 按 /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 x64,CPython 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 PASS(EXIT=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` 分派。
|
||||
@@ -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.1–R1.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.1–R4.3** 两个 offscreen 子进程**并行**各建 `QWebEngineProfile`(`HAOCODE_WEBENGINE_PROFILE_DIR` 同基目录、各自 `parallel_<pid>` 子目录)+ `CustomWebPage(profile, view)` 载入本地 HTML:均 `WORKER_OK loaded=True`;两 profile 目录不同;进程退出后目录仍在(无互相清理)。
|
||||
- **R5.1–R5.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.py,diag 实例因 instance-lock 被占自动回落 QtWebEngine,未误杀主程序 WebView2 进程。
|
||||
@@ -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→X11HotkeyThread;wayland/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 libX11:XGrabKey 参数 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=0(timeout 120) |
|
||||
| `python tests/test_screen_capture_platforms.py` | **17/17 PASS** EXIT=0(timeout 180) |
|
||||
| `python tests/test_file_attach.py`(回归) | 9 tests OK(timeout 90) |
|
||||
| `python tests/smoke_offscreen.py`(回归,offscreen+software+--disable-gpu) | **ALL PASS 8/8**(timeout 240),日志含 `[GlobalHotkey] Windows:系统级全局热键 Alt+S(RegisterHotKey)` |
|
||||
| `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 + 临时 config,timeout 45→124 kill 预期) | 无 traceback;`[GlobalHotkey] Windows:系统级全局热键 Alt+S` 打印;到达"JS 引擎已就绪";`data/config.json` mtime 前后一致(未触碰);error 行仅为 offscreen 已知 GPU 回落噪音 |
|
||||
|
||||
## 测试点细节(对应目标测试要求)
|
||||
|
||||
- **平台路由矩阵**:H1(session_kind 9 分支,含 XDG_SESSION_TYPE 与矛盾优先级)+ H2(hotkey_plan 4 路由)+ C1(capture_plan 4 路由)。
|
||||
- **X11 替身**:FakeX11 鸭子类型 libX11(socketpair 提供可 select 的 fd;XEvent 用 `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+S(XGrabKey)`;窗口失焦按 Alt+S 弹出覆盖层、框选截图成功;XGrabKey 被占用时日志明确。
|
||||
2. Wayland 会话(GNOME/KDE):启动日志 `[GlobalHotkey] Wayland:…未启用 → 仅提供应用内 Alt+S…`;点截图按钮(或应用内 Alt+S)→ xdg-desktop-portal 授权窗口出现;批准 → 文件进入附件;取消 → 日志"portal 截图未完成…已取消";无 portal 时日志"portal 不可用"。
|
||||
3. 其他发行版/DE 标记"未验证"(硬约束:不做发行版泛化)。
|
||||
|
||||
## 教训(持久)
|
||||
|
||||
- **PyQt6:QThread.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 对象。
|
||||
@@ -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.1–P11.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.13b–d)。
|
||||
|
||||
## 验证(全部显式 timeout)
|
||||
|
||||
| 套件 | 结果 | 预算 |
|
||||
|---|---|---|
|
||||
| `tests/smoke_bash_panel.py`(含新增 P11 节) | **ALL PASS(140 项断言)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.9–P11.13);
|
||||
- **完成中间任务前后状态保持**:对象 identity(`is`)、展开态、实时输出文本、代码框水平滚动值(先强制非 0)、section 垂直滚动值(用 20 行内容撑出真实滚动范围后设 30)在重排后逐项相等(P11.3–P11.8、P11.13b–d);
|
||||
- **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 已全量覆盖)。
|
||||
@@ -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` = 8px(S1/S1b/S2/S2b);section 竖滚动条实际 = 8px(S3);
|
||||
- **箭头 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-17,Windows 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 触发横条)+ 足够行数(触发纵条)」,只给长单行时纵条不可见。
|
||||
@@ -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`(隔离临时 DB,offscreen,34 项断言,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 项断言)
|
||||
@@ -0,0 +1,104 @@
|
||||
# P2-04 证据:跨平台聚合测试入口
|
||||
|
||||
状态:COMPLETE
|
||||
日期:2026-07-17(本机执行时间)
|
||||
平台:Windows 11 10.0.26200 x64(主)+ WSL Ubuntu-22.04 / CPython 3.12.3(Linux 路径验证)
|
||||
|
||||
## 交付物
|
||||
|
||||
- `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.py(agent 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(.venv,PyQt6+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 PASS(EXIT=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(14,offscreen 组整体)、无 PyMuPDF(1)、平台不适用 win32-only(1,test_wv2_guard:msvcrt 单实例互斥是 WebView2 守卫的 Windows 专属机制,Linux 无 WebView2 链路)、无 PyQt6 的 GUI 逻辑(1,test_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.12(VERIFICATION 矩阵明确支持的 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 |
Reference in New Issue
Block a user