Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


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

2026-09-14。用户定的方向:「**只是将入口从 UI 换成了宿主**」——宿主理论上要能覆盖
Web UI 上的每一个操作;执行层模型尽量用缓存省 token;Claude Code / Codex / Web
三个入口都要能正常工作。按这个目标把整个流程跑了三遍,每遍加深。

## 为什么先做检查,而不是先写工具

实测差距是 **183 条真实端点 vs 27 个 MCP 工具**,而且没有任何机制会告诉你差在哪、
差多少,也没有东西拦着它继续变差。这个仓库已经被「同一件事写两遍、其中一份悄悄落后」
咬过好几次(`sourceRefs` 同名不同义、宿主 plugin 副本落后四个文件两天没人知道)。

所以先照 `check-drift` 的套路把覆盖做成**可检查的**:每条路由必须三选一——
`host:"<tool>.<action>"` / `status:"todo"` / `uiOnly:"<理由>"`,分不了类就红,
覆盖条数只能涨不能跌。清单 `server/host-parity.json`,检查
`scripts/check-host-parity.mjs`,进了 CI。

**它当场抓到的第一件事就是路由清单本身不对**:

| 抓到的 | 后果 |
|---|---|
| 单引号注册的路由整个看不见(正则只认双引号与反引号) | 3 条真实操作完全不在清单里 |
| 循环注册的 `` `/stages/${action}` `` 没展开 | 少 17 条真实端点,而覆盖率会**显得好看** |
| 我照记忆写的 10 条路径只有 2 条真实存在 | 参数叫 `:id` 不是 `:projectId`;材料是全局 `/api/materials` |

## 三遍

| | 覆盖 | 域 | 这一遍撞到的 |
|---|---|---|---|
| 地基 | 18/183(10.0%) | — | 上表三条 |
| 第一遍 | 61/183(33.3%) | 7 | 路径靠记忆写必错;registry 每行现在都要对应真实路由 |
| 第二遍 | 121/183(66.1%) | 12 | `--write` 只刷基线不重算归属——**一张要手工同步的表,正是它要防的那种漂移** |
| 第三遍 | **181/181(100%)** | 17 | 退化检查比四舍五入过的比率,报出「从 91.3% 掉到 91.3%」;测试的「摘要长度>4」点名 60 条 |

最后一条值得记:测试要求「摘要长度 > 4」,点名的 60 个动作里有「取消一次运行」这种
本来就说清楚了的——**硬凑字数是为了过测试,不是为了让 agent 看懂**。判据改成信息量:
说出它动的是什么东西,而不只是一个光杆动词。

2 条如实标了 `uiOnly`:`GET /api/events`(SSE 长连接,宿主是请求-响应的,
进度改用 `tp_run.checkpoint` / `tp_unit.status` 轮询拿同一份状态)、
`POST /api/chat`(UI 自己的对话框,它做的事正是宿主本身在做的)。

## 分层

```
host/api.ts 只搬字节。失败时把服务端的话原样带回去——「树没冻结」「出处对不上」
是写给人看的,压成 host_api_failed 会让 agent 只知道失败、不知道为什么。
host/registry.ts 声明式路由表,一行 = 一个 UI 操作。
host/tools.ts 一个域一个工具、动作是枚举。180 条路由摊成 180 个工具,
agent 的清单会长到没法读。
```

**业务逻辑一行都没复制**:宿主走 UI 同一批 HTTP 接口,所以这三遍**服务端接口一行没改**,
UI 不需要同步。三个入口都验过:UI 首页 200、API 200、两个宿主 plugin 与真源一致。

人做的决定仍然是人做的:冻结模块树、批准用例、立基线——工具暴露出来了(人在 chat 里说
和人在 UI 上点是同一件事,agent 只是那只手),但摘要里必须写明「这是人的决定」,
测试钉着这一条。

## 执行层缓存:机制一直在,但它几乎不命中

`MIDSCENE_CACHE=1` 早就开着,缓存也确实在写(10 条用例写了 10 个文件,里面是每一步的
规划结果)。但同一批用例重跑,**47 次模型调用只降到 28 次**,而降到 0 的三条是运气——
截图恰好落在数据还没渲染出来的骨架期。

原因在缓存键:

```js
scopedCacheId(cacheId, context, {
url,
dom: await page.content(), // 整份 HTML——含价格、倒计时、盘口行
scene: cacheDigest(截图 PNG 的 base64), // 一个像素变了就变
})
```

在实时行情页上这两样**每秒都在变**,键几乎必然每次不同。一个只在骨架期命中的缓存
等于没有缓存。

但也不能干脆不看页面:缓存里存的是「点哪个元素」的计划,页面结构变了还照着点,会点到
不存在的东西,而那种失败**看起来像产品坏了**(memory 里记过:缓存 xpath 随 DOM 过时)。

所以改成看**结构**不看内容——可见控件的身份(标签 + 角色),标签里的数字串抹掉:

- 价格跳动、倒计时走字 → 键不变,缓存正确地命中;
- 控件增删、改名、换角色、被藏起来 → 键变,缓存正确地失效。

`Positions (1)` 与 `Positions (7)` 因此是同一个控件。这丢掉了「持仓数变了」这个信号,
但那个信号本来就该由用例的判据去抓,不该由缓存键去抓——键的职责是
「这一屏还是不是原来那一屏」。

判定逻辑抽成了纯函数(`controlIdentity` / `structureOf`),探针只负责采集:
这两条性质是整个设计的意义所在,不该只能靠真跑一次浏览器来验。
`CACHE_POLICY_VERSION` 跳到 `structure-v5`,旧缓存全部失效。

### 实测:同一批 10 条用例,四次执行

| | 缓存键 | 冷/热 | 模型调用 | 零调用 | 耗时 |
|---|---|---|---|---|---|
| ① | 旧(DOM + 截图) | 冷 | 47 | 0 | 286s |
| ② | 旧 | 热 | 28(−40%) | 3 | 231s |
| ③ | 新(结构指纹) | 冷 | **47** | 0 | 279s |
| ④ | 新 | 热 | **13(−72%)** | **7 / 10** | **191s** |

**③ 和 ① 完全相同是这里最要紧的一条对照**:缓存空的时候新键没有任何差别,
说明键没有被改松到误命中。省下来的全部来自「价格在动,但这一屏还是原来那一屏」
这个正确判断。

剩下 3 条仍然要调模型,原因也清楚:它们的步骤会**改变页面结构**——点开弹窗、
切换面板,第二步之后那一屏的控件集合和缓存时不同,键正确地失效了。
这不是缺陷,是设计要的行为。
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"check:i18n": "node scripts/check-i18n.mjs",
"test:hooks": "node --test \"plugins/testpilot/hooks/test/*.test.mjs\"",
"check:drift": "node scripts/check-drift.mjs",
"check:host-parity": "tsx scripts/check-host-parity.mjs",
"build:claude-plugin": "node scripts/build-claude-plugin.mjs",
"cost:report": "node scripts/cost-report.mjs",
"doctor": "node scripts/testpilot-setup.mjs doctor",
Expand Down
58 changes: 55 additions & 3 deletions packages/harness-testing/src/exec/cache.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,60 @@
import { createHash } from 'node:crypto';
import { canonicalJSON } from '@testpilot/harness-core/run-contracts';
export const CACHE_POLICY_VERSION='context-v4-viewport';

/**
* 改这个版本号 = 让此前所有缓存失效。键的构成一变就必须跳,否则旧缓存会被当成新键的命中。
*/
export const CACHE_POLICY_VERSION='structure-v5';

export function cacheDigest(value:unknown):string { return createHash('sha256').update(canonicalJSON(value)).digest('hex'); }
/** Full context and initial scene. Old unscoped caches are deliberately not replayed. */
export function scopedCacheId(id:string|undefined, context:unknown, page:{url:string;dom:string;scene:string}):string|undefined {

/**
* 缓存键 = 这条用例 + 它的上下文 + **首屏的结构**。
*
* 2026-09-14 实测:原来的键把 `page.content()`(整份 HTML)和一张截图的哈希放了进去。
* 在实时行情页上这两样每秒都在变——价格、倒计时、盘口行——于是缓存键几乎必然每次不同。
* 同一批 10 条用例重跑,47 次模型调用只降到 28 次,而其中三条降到 0 的是**运气**
* (截图恰好落在数据还没渲染出来的骨架期)。一个只在骨架期命中的缓存等于没有缓存。
*
* 但也不能干脆不看页面:缓存里存的是「点哪个元素」的计划,页面结构变了还照着点,
* 会点到不存在的东西,而那种失败看起来像产品坏了(memory:缓存 xpath 随 DOM 过时)。
*
* 所以看**结构**不看内容:可见控件的身份(标签 + 角色)。控件增删、改名、换位置,
* 键就变,缓存正确地失效;价格跳动、倒计时走字,键不变,缓存正确地命中。
*/
export function scopedCacheId(id:string|undefined, context:unknown, page:{url:string;structure:string}):string|undefined {
return id ? `tp-${CACHE_POLICY_VERSION}-${cacheDigest({id,context,page})}` : undefined;
}

/**
* 首屏的结构指纹。
*
* 只取**可见的交互控件**,取它们的角色与标签,排序后哈希:
* - 不取正文:整页文本里全是会动的数字;
* - 不取截图:一个像素变了就变;
* - **标签里的数字串抹掉**:`Positions (1)` 与 `Positions (2)` 是同一个控件。
* 这会丢掉「持仓数变了」这个信号,但那个信号本来就该由用例的判据去抓,
* 不该由缓存键去抓——键的职责是「这一屏还是不是原来那一屏」。
*/
export const CONTROL_SELECTOR =
'button,a,input,select,textarea,summary,[role=button],[role=tab],[role=link],[role=checkbox],[role=switch]';

/**
* 一个控件的身份。**抽成纯函数**是为了它能被测到:探针本体要在浏览器里执行,
* 而「数字变了指纹不变、控件变了指纹变」这两条性质才是这个设计的全部意义,
* 它们不该只能靠真跑一次浏览器来验。
*/
export function controlIdentity(el:{tag:string;role?:string|null;label?:string|null}):string {
return `${el.tag}:${el.role ?? ''}:${(el.label ?? '').trim().replace(/[0-9]+/g,'#').slice(0,48)}`;
}

/** 一屏控件 → 指纹。排序让 DOM 顺序的抖动不影响结果。 */
export function structureOf(controls:ReadonlyArray<{tag:string;role?:string|null;label?:string|null}>):string {
return controls.map(controlIdentity).sort().join('|');
}

/** 在页面里执行的探针。它只负责采集,判定逻辑在上面那两个纯函数里(同一套规则写两遍会漂)。 */
export const STRUCTURE_PROBE = `[...document.querySelectorAll(${JSON.stringify(CONTROL_SELECTOR)})]
.filter((el) => el.offsetParent !== null)
.map((el) => ({ tag: el.tagName, role: el.getAttribute('role'),
label: el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.textContent }))`;
19 changes: 17 additions & 2 deletions packages/harness-testing/src/exec/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cacheDigest, scopedCacheId } from './cache.js';
import { STRUCTURE_PROBE, cacheDigest, scopedCacheId, structureOf } from './cache.js';
import { visibleContextTree } from './visibleContext.js';
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -227,7 +227,22 @@ export function newAgent(page: Page, cacheId?: string, executorModel?: RoleModel
}

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

Expand Down
72 changes: 66 additions & 6 deletions packages/harness-testing/test/cache-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,67 @@
import{expect,it}from'vitest';import{scopedCacheId}from'../src/exec/cache.js';
it('binds cache replay to model, intent, URL, DOM and rendered scene',()=>{
const page={url:'https://fixture.test',dom:'<button>Go</button>',scene:'pixels-a'},ctx={model:'m1',intent:'revision1'};const first=scopedCacheId('c1',ctx,page);
expect(first).toBe(scopedCacheId('c1',{intent:'revision1',model:'m1'},page));
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);
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();
import { expect, it } from "vitest";
import { scopedCacheId, structureOf } from "../src/exec/cache.js";

/**
* 缓存回放绑在「这条用例 + 它的上下文 + 首屏结构」上。
* 2026-09-14 之前绑的是整份 DOM 与截图哈希——在实时行情页上那两样每秒都变,
* 缓存几乎必然不命中(实测 47 → 28 次调用,而命中的三条是撞在骨架期的运气)。
*/
it("键绑在模型、意图、地址与首屏结构上", () => {
const page = { url: "https://fixture.test", structure: "struct-a" };
const ctx = { model: "m1", intent: "revision1" };
const first = scopedCacheId("c1", ctx, page);
// 上下文只看内容不看键序。
expect(first).toBe(scopedCacheId("c1", { intent: "revision1", model: "m1" }, page));
for (const changed of [{ ...page, url: page.url + "/v2" }, { ...page, structure: "struct-b" }])
expect(scopedCacheId("c1", ctx, changed)).not.toBe(first);
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();
});

/**
* 结构指纹的两条性质,直接决定缓存有没有用:
* 价格跳动不该让它变(否则永远不命中),控件增删必须让它变(否则照着点不存在的东西)。
*/
it("数字在变、控件没变 → 指纹不变;控件变了 → 指纹变", () => {
const price = { tag: "SPAN", label: "2475.20" };
void price; // 正文不是控件,本来就不进指纹
const a = structureOf([{ tag: "BUTTON", label: "Positions (1)" }, { tag: "BUTTON", label: "Buy / Long" }]);
const b = structureOf([{ tag: "BUTTON", label: "Positions (7)" }, { tag: "BUTTON", label: "Buy / Long" }]);
expect(b, "数字变了,指纹不该变").toBe(a);

const c = structureOf([{ tag: "BUTTON", label: "Positions (1)" }, { tag: "BUTTON", label: "Buy / Long" }, { tag: "BUTTON", label: "Close All" }]);
expect(c, "多了一个控件,指纹必须变").not.toBe(a);

const d = structureOf([{ tag: "BUTTON", label: "Buy / Long" }]);
expect(d, "少了一个控件,指纹必须变").not.toBe(a);

const e = structureOf([{ tag: "BUTTON", role: "tab", label: "Positions (1)" }, { tag: "BUTTON", label: "Buy / Long" }]);
expect(e, "角色变了,指纹必须变").not.toBe(a);

// DOM 顺序抖动不算变化——排序就是为了这个。
expect(structureOf([{ tag: "BUTTON", label: "Buy / Long" }, { tag: "BUTTON", label: "Positions (1)" }])).toBe(a);
});

it("标签过长时截断,但截断点之前的差异仍然区分得开", () => {
const long = (suffix: string) => structureOf([{ tag: "BUTTON", label: "A".repeat(40) + suffix }]);
expect(long("X")).not.toBe(long("Y"));
expect(structureOf([{ tag: "BUTTON", label: "A".repeat(80) }])).toBe(structureOf([{ tag: "BUTTON", label: "A".repeat(60) }]));
});

/**
* 采不到结构就不缓存——而不是带垮这次执行,也不是退回一个凑合的键。
*
* 2026-09-15:给缓存键加结构指纹之后,本机四条 runner 测试全绿,CI 上三条红,
* 其中一条正是「导航还没完成就取消」。页面正在导航时执行上下文已经销毁,
* `page.evaluate` 会抛;CI 慢,正好撞进那个窗口。
*
* 这里钉的是那条判断本身:`structure` 没拿到,就没有 cacheId。
*/
it("结构采不到就没有缓存键——宁可这次不缓存,也不拿别的屏的计划来用", () => {
const ctx = { model: "m1", intent: "r1" };
expect(scopedCacheId(undefined, ctx, { url: "https://x.test", structure: "s" })).toBeUndefined();
// 键的三个输入任意一个变了就是另一个键;没有「差不多就算命中」这回事。
const base = scopedCacheId("c1", ctx, { url: "https://x.test", structure: "s" });
expect(scopedCacheId("c2", ctx, { url: "https://x.test", structure: "s" })).not.toBe(base);
});
Loading