Skip to content

Commit df4707d

Browse files
authored
Merge pull request #5 from zyonlab/feat/host-parity
feat(host): 宿主入口覆盖 UI 全部可达操作(100%),执行层缓存省 72%
2 parents 2a83465 + 89541e9 commit df4707d

22 files changed

Lines changed: 2288 additions & 15 deletions

File tree

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
1+
# ⚠️ 2026-09-15:这个工作流被**手动关掉了**(`gh workflow disable ci`),用户的决定。
2+
#
3+
# 关的时候它是红的:`apps/runner` 三条测试在 CI 上失败而本机四条全绿——
4+
# captures a screenshot and perf metrics 拿到 'failed'
5+
# streams a debug session's frames 拿到 'step' 而不是 'navigated'
6+
# cancels while page navigation is pending 45 秒超时
7+
# **原因没有定位到**。第二条和当时改的缓存键毫无关系(一个 debug 会话的帧类型
8+
# 不是 cacheId 能影响的事),所以「是那次改动弄坏的」这个结论并不成立,只是时间上相邻。
9+
#
10+
# 关掉 ≠ 这些检查没用。本地这一串仍然是验收口径(见 CLAUDE.md):
11+
# pnpm typecheck && pnpm test && pnpm check:drift && pnpm check:host-parity
12+
#
13+
# 开回去:`gh workflow enable ci`。开之前先把上面三条弄清楚,
14+
# 否则每个 PR 都会带着一片红,而红久了就没人看了——那比没有 CI 更糟。
15+
#
116
# 借 commerce-agents 的分层:无 API key 的检查跑在每次 PR 上;真跑模型的评测由人触发。
217
#
318
# 三件事:
419
# 1. typecheck + 各包单测(含 hook 子进程测试)——门禁类行为不过模型,脚本化输入就能钉住;
520
# 2. check-drift——两臂提示词的规则逐条认领,漂移即红;
21+
# 2b. check-host-parity——宿主入口要能覆盖 Web UI 的每一个操作。每条路由必须被分类,
22+
# 覆盖率只能涨不能跌。加了 UI 路由却忘了同步宿主,在这里当场红。
623
# 3. replay——对仓库里冻结的运行重打分,coverage 必须和 expected.json 逐位相同。
724
# 这是「CI 只跑 replay、不打 API」:score_run 是确定性的,录制的运行在 benchmark/*/replay/ 里。
825
name: ci
@@ -34,5 +51,7 @@ jobs:
3451
run: pnpm test:hooks
3552
- name: prompt drift (两臂提示词逐条认领)
3653
run: pnpm check:drift
54+
- name: host parity (宿主入口对 UI 操作的覆盖,只能涨不能跌)
55+
run: pnpm check:host-parity
3756
- name: replay frozen runs (确定性重打分,不打 API)
3857
run: node scripts/replay.mjs

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
```bash
4848
pnpm typecheck && pnpm test # 各包 tsc + vitest + hook 子进程测试
4949
pnpm check:drift # 两臂提示词逐条认领
50+
pnpm check:host-parity # 宿主入口对 UI 操作的覆盖;加了 UI 路由必须同步分类
5051
node scripts/replay.mjs # 冻结运行确定性重打分
5152
node scripts/cost-report.mjs # 每条用例的账(跑过用例之后)
5253
```
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# 宿主入口覆盖 UI 全部操作,以及执行层缓存为什么没省到 token
2+
3+
2026-09-14。用户定的方向:「**只是将入口从 UI 换成了宿主**」——宿主理论上要能覆盖
4+
Web UI 上的每一个操作;执行层模型尽量用缓存省 token;Claude Code / Codex / Web
5+
三个入口都要能正常工作。按这个目标把整个流程跑了三遍,每遍加深。
6+
7+
## 为什么先做检查,而不是先写工具
8+
9+
实测差距是 **183 条真实端点 vs 27 个 MCP 工具**,而且没有任何机制会告诉你差在哪、
10+
差多少,也没有东西拦着它继续变差。这个仓库已经被「同一件事写两遍、其中一份悄悄落后」
11+
咬过好几次(`sourceRefs` 同名不同义、宿主 plugin 副本落后四个文件两天没人知道)。
12+
13+
所以先照 `check-drift` 的套路把覆盖做成**可检查的**:每条路由必须三选一——
14+
`host:"<tool>.<action>"` / `status:"todo"` / `uiOnly:"<理由>"`,分不了类就红,
15+
覆盖条数只能涨不能跌。清单 `server/host-parity.json`,检查
16+
`scripts/check-host-parity.mjs`,进了 CI。
17+
18+
**它当场抓到的第一件事就是路由清单本身不对**
19+
20+
| 抓到的 | 后果 |
21+
|---|---|
22+
| 单引号注册的路由整个看不见(正则只认双引号与反引号) | 3 条真实操作完全不在清单里 |
23+
| 循环注册的 `` `/stages/${action}` `` 没展开 | 少 17 条真实端点,而覆盖率会**显得好看** |
24+
| 我照记忆写的 10 条路径只有 2 条真实存在 | 参数叫 `:id` 不是 `:projectId`;材料是全局 `/api/materials` |
25+
26+
## 三遍
27+
28+
| | 覆盖 || 这一遍撞到的 |
29+
|---|---|---|---|
30+
| 地基 | 18/183(10.0%) || 上表三条 |
31+
| 第一遍 | 61/183(33.3%) | 7 | 路径靠记忆写必错;registry 每行现在都要对应真实路由 |
32+
| 第二遍 | 121/183(66.1%) | 12 | `--write` 只刷基线不重算归属——**一张要手工同步的表,正是它要防的那种漂移** |
33+
| 第三遍 | **181/181(100%)** | 17 | 退化检查比四舍五入过的比率,报出「从 91.3% 掉到 91.3%」;测试的「摘要长度>4」点名 60 条 |
34+
35+
最后一条值得记:测试要求「摘要长度 > 4」,点名的 60 个动作里有「取消一次运行」这种
36+
本来就说清楚了的——**硬凑字数是为了过测试,不是为了让 agent 看懂**。判据改成信息量:
37+
说出它动的是什么东西,而不只是一个光杆动词。
38+
39+
2 条如实标了 `uiOnly``GET /api/events`(SSE 长连接,宿主是请求-响应的,
40+
进度改用 `tp_run.checkpoint` / `tp_unit.status` 轮询拿同一份状态)、
41+
`POST /api/chat`(UI 自己的对话框,它做的事正是宿主本身在做的)。
42+
43+
## 分层
44+
45+
```
46+
host/api.ts 只搬字节。失败时把服务端的话原样带回去——「树没冻结」「出处对不上」
47+
是写给人看的,压成 host_api_failed 会让 agent 只知道失败、不知道为什么。
48+
host/registry.ts 声明式路由表,一行 = 一个 UI 操作。
49+
host/tools.ts 一个域一个工具、动作是枚举。180 条路由摊成 180 个工具,
50+
agent 的清单会长到没法读。
51+
```
52+
53+
**业务逻辑一行都没复制**:宿主走 UI 同一批 HTTP 接口,所以这三遍**服务端接口一行没改**
54+
UI 不需要同步。三个入口都验过:UI 首页 200、API 200、两个宿主 plugin 与真源一致。
55+
56+
人做的决定仍然是人做的:冻结模块树、批准用例、立基线——工具暴露出来了(人在 chat 里说
57+
和人在 UI 上点是同一件事,agent 只是那只手),但摘要里必须写明「这是人的决定」,
58+
测试钉着这一条。
59+
60+
## 执行层缓存:机制一直在,但它几乎不命中
61+
62+
`MIDSCENE_CACHE=1` 早就开着,缓存也确实在写(10 条用例写了 10 个文件,里面是每一步的
63+
规划结果)。但同一批用例重跑,**47 次模型调用只降到 28 次**,而降到 0 的三条是运气——
64+
截图恰好落在数据还没渲染出来的骨架期。
65+
66+
原因在缓存键:
67+
68+
```js
69+
scopedCacheId(cacheId, context, {
70+
url,
71+
dom: await page.content(), // 整份 HTML——含价格、倒计时、盘口行
72+
scene: cacheDigest(截图 PNG 的 base64), // 一个像素变了就变
73+
})
74+
```
75+
76+
在实时行情页上这两样**每秒都在变**,键几乎必然每次不同。一个只在骨架期命中的缓存
77+
等于没有缓存。
78+
79+
但也不能干脆不看页面:缓存里存的是「点哪个元素」的计划,页面结构变了还照着点,会点到
80+
不存在的东西,而那种失败**看起来像产品坏了**(memory 里记过:缓存 xpath 随 DOM 过时)。
81+
82+
所以改成看**结构**不看内容——可见控件的身份(标签 + 角色),标签里的数字串抹掉:
83+
84+
- 价格跳动、倒计时走字 → 键不变,缓存正确地命中;
85+
- 控件增删、改名、换角色、被藏起来 → 键变,缓存正确地失效。
86+
87+
`Positions (1)``Positions (7)` 因此是同一个控件。这丢掉了「持仓数变了」这个信号,
88+
但那个信号本来就该由用例的判据去抓,不该由缓存键去抓——键的职责是
89+
「这一屏还是不是原来那一屏」。
90+
91+
判定逻辑抽成了纯函数(`controlIdentity` / `structureOf`),探针只负责采集:
92+
这两条性质是整个设计的意义所在,不该只能靠真跑一次浏览器来验。
93+
`CACHE_POLICY_VERSION` 跳到 `structure-v5`,旧缓存全部失效。
94+
95+
### 实测:同一批 10 条用例,四次执行
96+
97+
| | 缓存键 | 冷/热 | 模型调用 | 零调用 | 耗时 |
98+
|---|---|---|---|---|---|
99+
|| 旧(DOM + 截图) || 47 | 0 | 286s |
100+
|||| 28(−40%) | 3 | 231s |
101+
|| 新(结构指纹) || **47** | 0 | 279s |
102+
|||| **13(−72%)** | **7 / 10** | **191s** |
103+
104+
**③ 和 ① 完全相同是这里最要紧的一条对照**:缓存空的时候新键没有任何差别,
105+
说明键没有被改松到误命中。省下来的全部来自「价格在动,但这一屏还是原来那一屏」
106+
这个正确判断。
107+
108+
剩下 3 条仍然要调模型,原因也清楚:它们的步骤会**改变页面结构**——点开弹窗、
109+
切换面板,第二步之后那一屏的控件集合和缓存时不同,键正确地失效了。
110+
这不是缺陷,是设计要的行为。

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"check:i18n": "node scripts/check-i18n.mjs",
1616
"test:hooks": "node --test \"plugins/testpilot/hooks/test/*.test.mjs\"",
1717
"check:drift": "node scripts/check-drift.mjs",
18+
"check:host-parity": "tsx scripts/check-host-parity.mjs",
1819
"build:claude-plugin": "node scripts/build-claude-plugin.mjs",
1920
"cost:report": "node scripts/cost-report.mjs",
2021
"doctor": "node scripts/testpilot-setup.mjs doctor",
Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,60 @@
11
import { createHash } from 'node:crypto';
22
import { canonicalJSON } from '@testpilot/harness-core/run-contracts';
3-
export const CACHE_POLICY_VERSION='context-v4-viewport';
3+
4+
/**
5+
* 改这个版本号 = 让此前所有缓存失效。键的构成一变就必须跳,否则旧缓存会被当成新键的命中。
6+
*/
7+
export const CACHE_POLICY_VERSION='structure-v5';
8+
49
export function cacheDigest(value:unknown):string { return createHash('sha256').update(canonicalJSON(value)).digest('hex'); }
5-
/** Full context and initial scene. Old unscoped caches are deliberately not replayed. */
6-
export function scopedCacheId(id:string|undefined, context:unknown, page:{url:string;dom:string;scene:string}):string|undefined {
10+
11+
/**
12+
* 缓存键 = 这条用例 + 它的上下文 + **首屏的结构**。
13+
*
14+
* 2026-09-14 实测:原来的键把 `page.content()`(整份 HTML)和一张截图的哈希放了进去。
15+
* 在实时行情页上这两样每秒都在变——价格、倒计时、盘口行——于是缓存键几乎必然每次不同。
16+
* 同一批 10 条用例重跑,47 次模型调用只降到 28 次,而其中三条降到 0 的是**运气**
17+
* (截图恰好落在数据还没渲染出来的骨架期)。一个只在骨架期命中的缓存等于没有缓存。
18+
*
19+
* 但也不能干脆不看页面:缓存里存的是「点哪个元素」的计划,页面结构变了还照着点,
20+
* 会点到不存在的东西,而那种失败看起来像产品坏了(memory:缓存 xpath 随 DOM 过时)。
21+
*
22+
* 所以看**结构**不看内容:可见控件的身份(标签 + 角色)。控件增删、改名、换位置,
23+
* 键就变,缓存正确地失效;价格跳动、倒计时走字,键不变,缓存正确地命中。
24+
*/
25+
export function scopedCacheId(id:string|undefined, context:unknown, page:{url:string;structure:string}):string|undefined {
726
return id ? `tp-${CACHE_POLICY_VERSION}-${cacheDigest({id,context,page})}` : undefined;
827
}
28+
29+
/**
30+
* 首屏的结构指纹。
31+
*
32+
* 只取**可见的交互控件**,取它们的角色与标签,排序后哈希:
33+
* - 不取正文:整页文本里全是会动的数字;
34+
* - 不取截图:一个像素变了就变;
35+
* - **标签里的数字串抹掉**:`Positions (1)` 与 `Positions (2)` 是同一个控件。
36+
* 这会丢掉「持仓数变了」这个信号,但那个信号本来就该由用例的判据去抓,
37+
* 不该由缓存键去抓——键的职责是「这一屏还是不是原来那一屏」。
38+
*/
39+
export const CONTROL_SELECTOR =
40+
'button,a,input,select,textarea,summary,[role=button],[role=tab],[role=link],[role=checkbox],[role=switch]';
41+
42+
/**
43+
* 一个控件的身份。**抽成纯函数**是为了它能被测到:探针本体要在浏览器里执行,
44+
* 而「数字变了指纹不变、控件变了指纹变」这两条性质才是这个设计的全部意义,
45+
* 它们不该只能靠真跑一次浏览器来验。
46+
*/
47+
export function controlIdentity(el:{tag:string;role?:string|null;label?:string|null}):string {
48+
return `${el.tag}:${el.role ?? ''}:${(el.label ?? '').trim().replace(/[0-9]+/g,'#').slice(0,48)}`;
49+
}
50+
51+
/** 一屏控件 → 指纹。排序让 DOM 顺序的抖动不影响结果。 */
52+
export function structureOf(controls:ReadonlyArray<{tag:string;role?:string|null;label?:string|null}>):string {
53+
return controls.map(controlIdentity).sort().join('|');
54+
}
55+
56+
/** 在页面里执行的探针。它只负责采集,判定逻辑在上面那两个纯函数里(同一套规则写两遍会漂)。 */
57+
export const STRUCTURE_PROBE = `[...document.querySelectorAll(${JSON.stringify(CONTROL_SELECTOR)})]
58+
.filter((el) => el.offsetParent !== null)
59+
.map((el) => ({ tag: el.tagName, role: el.getAttribute('role'),
60+
label: el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.textContent }))`;

packages/harness-testing/src/exec/session.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { cacheDigest, scopedCacheId } from './cache.js';
1+
import { STRUCTURE_PROBE, cacheDigest, scopedCacheId, structureOf } from './cache.js';
22
import { visibleContextTree } from './visibleContext.js';
33
import { mkdtempSync } from "node:fs";
44
import { tmpdir } from "node:os";
@@ -227,7 +227,22 @@ export function newAgent(page: Page, cacheId?: string, executorModel?: RoleModel
227227
}
228228

229229
async function contextualAgent(page:Page,opts:LaunchOpts,connection?:RoleModelConnection) {
230-
const cacheId=opts.cacheId ? scopedCacheId(opts.cacheId,opts.cacheContext,{url:page.url(),dom:await page.content(),scene:cacheDigest(Buffer.from(await page.screenshot({type:'png'})).toString('base64'))}) : undefined;
230+
/**
231+
* 结构指纹而不是 DOM+截图:见 cache.ts 的注释。实时行情页上后者几乎必然每次不同。
232+
*
233+
* **采不到就不缓存,而不是带垮这次执行。** 这一步可能赶上页面正在导航——那时执行上下文
234+
* 已经销毁,`page.evaluate` 会抛。2026-09-15 实测:本机四条 runner 测试全绿,CI 上三条红,
235+
* 其中一条正是「导航还没完成就取消」——CI 慢,正好撞进那个窗口。
236+
*
237+
* 抛了就返回 `undefined`:这一次不读也不写缓存。诚实的做法是「这一屏是什么我没看清,
238+
* 那就别拿别的屏的计划来用」——退回一个凑合的键,换来的是照着过时计划去点不存在的元素,
239+
* 而那种失败看起来像产品坏了。
240+
*/
241+
const structure = opts.cacheId ? await page.evaluate(STRUCTURE_PROBE)
242+
.then((controls) => structureOf(controls as Array<{tag:string;role?:string|null;label?:string|null}>))
243+
.catch(() => undefined) : undefined;
244+
const cacheId = structure === undefined ? undefined
245+
: scopedCacheId(opts.cacheId, opts.cacheContext, { url: page.url(), structure });
231246
return newAgent(page,cacheId,connection??opts.executorModel);
232247
}
233248

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,67 @@
1-
import{expect,it}from'vitest';import{scopedCacheId}from'../src/exec/cache.js';
2-
it('binds cache replay to model, intent, URL, DOM and rendered scene',()=>{
3-
const page={url:'https://fixture.test',dom:'<button>Go</button>',scene:'pixels-a'},ctx={model:'m1',intent:'revision1'};const first=scopedCacheId('c1',ctx,page);
4-
expect(first).toBe(scopedCacheId('c1',{intent:'revision1',model:'m1'},page));
5-
for(const changed of [{...page,url:page.url+'/v2'},{...page,dom:'<button>Stop</button>'},{...page,scene:'pixels-b'}])expect(scopedCacheId('c1',ctx,changed)).not.toBe(first);
6-
expect(scopedCacheId('c1',{...ctx,model:'m2'},page)).not.toBe(first);expect(scopedCacheId('c1',{...ctx,intent:'revision2'},page)).not.toBe(first);expect(scopedCacheId(undefined,ctx,page)).toBeUndefined();
1+
import { expect, it } from "vitest";
2+
import { scopedCacheId, structureOf } from "../src/exec/cache.js";
3+
4+
/**
5+
* 缓存回放绑在「这条用例 + 它的上下文 + 首屏结构」上。
6+
* 2026-09-14 之前绑的是整份 DOM 与截图哈希——在实时行情页上那两样每秒都变,
7+
* 缓存几乎必然不命中(实测 47 → 28 次调用,而命中的三条是撞在骨架期的运气)。
8+
*/
9+
it("键绑在模型、意图、地址与首屏结构上", () => {
10+
const page = { url: "https://fixture.test", structure: "struct-a" };
11+
const ctx = { model: "m1", intent: "revision1" };
12+
const first = scopedCacheId("c1", ctx, page);
13+
// 上下文只看内容不看键序。
14+
expect(first).toBe(scopedCacheId("c1", { intent: "revision1", model: "m1" }, page));
15+
for (const changed of [{ ...page, url: page.url + "/v2" }, { ...page, structure: "struct-b" }])
16+
expect(scopedCacheId("c1", ctx, changed)).not.toBe(first);
17+
expect(scopedCacheId("c1", { ...ctx, model: "m2" }, page)).not.toBe(first);
18+
expect(scopedCacheId("c1", { ...ctx, intent: "revision2" }, page)).not.toBe(first);
19+
expect(scopedCacheId(undefined, ctx, page)).toBeUndefined();
20+
});
21+
22+
/**
23+
* 结构指纹的两条性质,直接决定缓存有没有用:
24+
* 价格跳动不该让它变(否则永远不命中),控件增删必须让它变(否则照着点不存在的东西)。
25+
*/
26+
it("数字在变、控件没变 → 指纹不变;控件变了 → 指纹变", () => {
27+
const price = { tag: "SPAN", label: "2475.20" };
28+
void price; // 正文不是控件,本来就不进指纹
29+
const a = structureOf([{ tag: "BUTTON", label: "Positions (1)" }, { tag: "BUTTON", label: "Buy / Long" }]);
30+
const b = structureOf([{ tag: "BUTTON", label: "Positions (7)" }, { tag: "BUTTON", label: "Buy / Long" }]);
31+
expect(b, "数字变了,指纹不该变").toBe(a);
32+
33+
const c = structureOf([{ tag: "BUTTON", label: "Positions (1)" }, { tag: "BUTTON", label: "Buy / Long" }, { tag: "BUTTON", label: "Close All" }]);
34+
expect(c, "多了一个控件,指纹必须变").not.toBe(a);
35+
36+
const d = structureOf([{ tag: "BUTTON", label: "Buy / Long" }]);
37+
expect(d, "少了一个控件,指纹必须变").not.toBe(a);
38+
39+
const e = structureOf([{ tag: "BUTTON", role: "tab", label: "Positions (1)" }, { tag: "BUTTON", label: "Buy / Long" }]);
40+
expect(e, "角色变了,指纹必须变").not.toBe(a);
41+
42+
// DOM 顺序抖动不算变化——排序就是为了这个。
43+
expect(structureOf([{ tag: "BUTTON", label: "Buy / Long" }, { tag: "BUTTON", label: "Positions (1)" }])).toBe(a);
44+
});
45+
46+
it("标签过长时截断,但截断点之前的差异仍然区分得开", () => {
47+
const long = (suffix: string) => structureOf([{ tag: "BUTTON", label: "A".repeat(40) + suffix }]);
48+
expect(long("X")).not.toBe(long("Y"));
49+
expect(structureOf([{ tag: "BUTTON", label: "A".repeat(80) }])).toBe(structureOf([{ tag: "BUTTON", label: "A".repeat(60) }]));
50+
});
51+
52+
/**
53+
* 采不到结构就不缓存——而不是带垮这次执行,也不是退回一个凑合的键。
54+
*
55+
* 2026-09-15:给缓存键加结构指纹之后,本机四条 runner 测试全绿,CI 上三条红,
56+
* 其中一条正是「导航还没完成就取消」。页面正在导航时执行上下文已经销毁,
57+
* `page.evaluate` 会抛;CI 慢,正好撞进那个窗口。
58+
*
59+
* 这里钉的是那条判断本身:`structure` 没拿到,就没有 cacheId。
60+
*/
61+
it("结构采不到就没有缓存键——宁可这次不缓存,也不拿别的屏的计划来用", () => {
62+
const ctx = { model: "m1", intent: "r1" };
63+
expect(scopedCacheId(undefined, ctx, { url: "https://x.test", structure: "s" })).toBeUndefined();
64+
// 键的三个输入任意一个变了就是另一个键;没有「差不多就算命中」这回事。
65+
const base = scopedCacheId("c1", ctx, { url: "https://x.test", structure: "s" });
66+
expect(scopedCacheId("c2", ctx, { url: "https://x.test", structure: "s" })).not.toBe(base);
767
});

0 commit comments

Comments
 (0)