diff --git a/.gitignore b/.gitignore index c32edbd..84830ee 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ scratch-* docs/fix-issue-*-plan.md docs/video-script.md .npm-cache/ + +# E2E run artifacts (temp DSH home, decoded logs, credentials env-file — never commit) +scripts/e2e/out/ diff --git a/docs/README.md b/docs/README.md index a8132d1..6fdad09 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ Model-driven context management (Active Context Pruning / ACP) for the DeepSeek | [Porting verification report](dsh-porting-verification.md) | The verified evidence behind every claim, plus the **v0.1.1 long-session battle report** (6 bugs found and fixed in real use) | | [Configurable prompts design](configurable-prompts-design.md) | Design review draft: per-stage prompt overrides (nudge / range table / system prompt / tool descriptions) via `config.prompts`, template + named placeholders, build-time validation | | [Shadow-price host-vocabulary design](shadow-price-host-vocabulary-design.md) | Why `shadowedTokenCount` claims must speak the host token-meter's fixed-heuristic vocabulary (issue #54: CJK sessions bricked when priced with the CJK-aware `defaultCountTokens`; issue #103: image sessions bricked when priced with the route-repriced `node.tokens` — the claim reads `heuristicTokens ?? tokens`); meter-first pricing with an exact mirror fallback; L2 upstream direction | +| [E2E testing design](e2e-testing-design.md) | Automated integration tests via a dedicated headless profile (`scripts/e2e/`): a temp `DSH_HOME` environment carrying only this plugin, one-shot `dsh --profile acp-e2e` runs, session-log assertions (compaction event pairing, non-negative shadow prices, no bricked projections); deterministic mock-LLM tier as the follow-up | ## 🗂 Source layout diff --git a/docs/e2e-testing-design.md b/docs/e2e-testing-design.md new file mode 100644 index 0000000..0ef9b4c --- /dev/null +++ b/docs/e2e-testing-design.md @@ -0,0 +1,253 @@ +# 设计说明:headless profile 自动化集成测试(acp-e2e) + +> 状态:设计稿 v4(已实现待验证)。层 A 落地为 `scripts/e2e/`,层 B(mock LLM)为后续方向。 +> v2:容器化运行(Docker)已在本机 OrbStack(linux/arm64)全链路验证通过。 +> v3:按评审定案——容器是**唯一**执行方式;权限用 `DSH_PERMISSION_MODE=danger-full-access` 完全开放(已实测);launcher 只测覆盖线最新版。 +> v4:按首测反馈定案——并行分道(多容器,weight 均衡);默认只跑 smoke 集,全量显式选择;`npm run e2e` / `npm run e2e:full` 双入口。 +> 结论先行:**不需要改 `src/`** —— 环境组装、运行、断言全部使用 DSH 原生机制,本插件只作为被安装、被观察的普通包参与。 + +## 问题 + +每个 PR 目前的验证流程是手工的:构建 → 把 worktree 以 `file:` 方式装进 web profile → 开一个真实会话跑任务 → 人肉观察 nudge 是否触发、压缩是否落盘、搜索是否命中 → 翻会话日志确认投影没被染成负数。这个流程有三个痛点: + +1. **慢**——每个 PR 都要人来开环境、跑会话、盯输出; +2. **不可重复**——同样的场景每次手跑,观察点靠记忆; +3. **容易漏**——issue #54 那类"会话砖化"(投影计数变负 → 后续每个 turn 被 zod 拒绝)在真实长会话里跑了很久才暴露,肉眼几乎不可能第一时间发现。 + +## 现状与缺口 + +- 已有 189 个单测覆盖引擎内部算法(CJK 计价、范围求解、孤儿工具清理、影子价、包产物契约等),fixture 按 AGENTS.md rule 5 模拟真实结构。 +- 单测覆盖不了的缺口正是"集成": + 1. **真实宿主接线**——`dsh-session` 事件形状、agent 循环、token-meter/projection 的真实行为(单测用的是 fixture 复制品); + 2. **真实 provider 消息流**——模型真的会调 `compress` 吗?调用的参数经过真实 schema 校验吗? + 3. **安装即生效契约**——bundle patch 在真实 launcher 组装下是否零配置生效(AGENTS.md 设计决策 8)。 + +DSH 原生提供了补上这个缺口需要的全部拼图: + +| 拼图 | 事实(已在 dsh 0.1.2-rc.1 上验证) | +|---|---| +| 一次性运行器 | `@deepseek-ai/dsh-headless`:`dsh --profile headless ""` 跑一个任务,最终回答打 stdout、推理过程打 stderr,退出码 0=完成 / 1=中止或出错;无端口、无 GUI、进程自清理 | +| profile 即环境 | `~/.dsh/profiles/<名字>/` 就是一个 pnpm 包目录:`dsh.profile.bundles` 声明组合层,`cordis.patch.yml` 是用户覆盖层;`@deepseek-ai/*` bundle 从 launcher 自带的 node_modules 解析,第三方插件装在 profile 目录里 | +| 干净组合 | headless profile 的组合就是 `["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-headless"]`,没有任何多余 surface——加上本插件即为"只带我们插件的环境" | +| 隔离 | `DSH_HOME` 指向临时目录即可让会话日志、storage、凭据全部落在一次性目录里,多个场景可并行 | +| 断言面(oracle) | 会话日志是 append-only JSONL(`session.jsonl.zstd`):`turn/start`、`step/*`、`tool/call`、`tool/result`、`request/context`,以及本插件写入的 `compaction/start` / `compaction/summary` / `compaction/prune` / `compaction/end`——事件即协议,可程序化断言 | + +## 方案总览:两层 + +| | 层 A:headless E2E | 层 B:mock LLM 确定性回放(后续) | +|---|---|---| +| 模型 | 真实模型(API 计费,单场景约几美分) | 本地脚本化 OpenAI 兼容 mock(零成本) | +| 覆盖 | 真实全链路:launcher 组装、bundle patch 生效、真实 provider 流、模型行为 | 确定性回放:脚本规定"模型"每一步做什么,断言可精确到请求体 | +| 确定性 | 引擎侧断言确定;模型行为是软断言 | 完全确定 | +| 时机 | 先做(本文档的主体) | 层 A 验证有效后 | + +## 层 A 详细设计 + +### 环境:一个只带本插件的 profile + +runner 在临时 `$DSH_HOME/profiles/acp-e2e/` 下自建 profile(**不**用 `dsh plugin add`,理由见决策 2),内容等价于: + +```jsonc +// profiles/acp-e2e/package.json(runner 生成) +{ + "name": "dsh-profile-acp-e2e", + "private": true, + "dependencies": { + "billion-context-dsh": "file:<仓库绝对路径>" // dist/ 已入库(v0.2.17 起),装上即用 + }, + "dsh": { + "profile": { + "bundles": [ + "@deepseek-ai/dsh-base", // 最小宿主:llm/session/agent/settings/credentials/持久化 + "@deepseek-ai/dsh-headless", // 一次性运行器(exit code 契约) + "billion-context-dsh" // 唯一的第三方插件 + ] + } + } +} +``` + +- 生成后在该目录跑 `pnpm install`,然后 `dsh --profile acp-e2e ""` 即启动。 +- 本插件的 bundle patch(`cordis.patch.yml`)自动成为组合层:禁 `compaction-basic` + 挂 `compaction-acp`,**零手工配置**——这条"安装即生效"契约本身就是被测对象之一。 +- 每次运行前 runner 整目录重建(幂等),不依赖机器上任何残留状态。 + +### 隔离与凭据 + +- `DSH_HOME=$(mktemp -d)`:会话日志、storage、遥测全部落在一次性目录,场景间互不污染、可并行。 +- 凭据走环境继承(credentials 链的第一优先级是进程环境):CI 里注入 `DEEPSEEK_API_KEY`,本地直接复用现有环境。 +- `DSH_TELEMETRY_DISABLED=1` 关闭遥测上传。 +- 模型路由:runner 生成 `$DSH_HOME/settings.yaml` 的 `agent-default-model` 段;默认用 launcher 自带路由(deepseek-official / deepseek-v4-flash),`ACP_E2E_PROVIDER` / `ACP_E2E_MODEL` 环境变量可覆盖(想用便宜模型跑常规回归、用目标模型验证特定行为)。 + +### 触发杠杆:把窗口压小 + +nudge 普通触发线有下限(`minContextLimitPct` 0.45,`src/index.ts` 配置注释),调阈值不能低于它;正确杠杆是**显式压小窗口**: + +```yaml +# runner 通过 launcher 的 --patch 注入(repeatable,作用于 profile 层之后) +- id: compaction-acp + config: + modelContextLimit: 8000 # 显式小窗口;显式值优先于自动探测(windowFor 链) +``` + +45% 线 = 3600 tokens:两三次大工具输出就过线,场景不再需要真实几十万 token 的会话。per-scenario 差异(比如某个场景要验证 emergency 0.85 线)也走 `--patch`,profile 本身保持通用。 + +### 场景清单 + +每个场景 = 一次 headless run = 一个独立 session。场景集中定义在 `scripts/e2e/scenarios.mjs`(见「场景定义与断言读点」),fixture(大文件等)由 runner 在场景专用 cwd(临时目录)里物化。 + +| 场景 | 任务 | 硬断言 | 软断言(报告,不计失败) | +|---|---|---|---| +| S1 压力触发 | 依次完整读取 8 个大 fixture 文件并汇总字数 | exit 0;nudge 过线后出现 `compaction/*` 事件 | 模型真的调了 `compress` | +| S2 压缩后可用性 | S1 的任务延长版:压缩发生后要求模型检索早期内容 | `search_context` 工具调用发生且返回非空;exit 0 | 检索命中被压缩内容 | +| S3 批量鲁棒性 | 任务引导模型连续多次 compress | 每次 `compaction/summary` 的 `shadowedTokenCount ≥ 0`;事件序列配对 | — | +| S4 空会话基线 | 一个极短任务(一次问答) | exit 0;**无**任何 `compaction/*` 事件 | — | + +### 断言(oracle = 会话日志) + +运行后 `zstd -dc` 解包 `sessions//session-*/session.jsonl.zstd` 逐行断言: + +- **硬断言**(失败即场景失败): + 1. 进程退出码 0,stdout 有非空最终回答; + 2. `compaction/start` … `compaction/end` 严格配对,无悬挂 `start`(对应"悬挂 compaction start 恢复"那类修复的回归哨兵); + 3. 所有 `compaction/summary` / `compaction/prune` 的 `shadowedTokenCount ≥ 0`; + 4. 全程无 turn error——出现 `Too small: expected number to be >=0` 之类的投影 zod 拒绝 = #54 类回归,立刻红; + 5. 场景专属断言(上表"硬断言"列)。 +- **nudge 是否注入**:nudge 在 `agent/pre-step` 注入请求侧,不一定以独立事件落日志;实现时先查 `request/context` 事件是否携带,查不到就把这条断言归入层 B(层 B 直接看请求体)。层 A 不为它加日志事件(决策 4)。 +- **解包注意**:日志是**多帧 zstd 拼接**(headless 的 durability barrier 会拆帧;已实测 Node 的 `zstdDecompressSync` 只解出第一帧)——必须用 `zstd` CLI 或逐帧解码。 +- **失败分型**:连接错误/限流/模型不可用标记为"环境性失败"(不计入插件回归),断言不符才是真失败。 + +### 场景定义与断言读点(可读性已验证) + +**场景即代码,单一事实源**:所有场景集中定义在 `scripts/e2e/scenarios.mjs`(每个场景一个对象:`id`、`issue`(关联 issue/PR 列表)、`task`(任务文本)、`patch`(可选 --patch 覆盖)、`fixtures()`(物化到场景 cwd 的文件)、`assert(events)`(断言函数)),不另设 md 描述文件——避免"文档说一套、代码跑一套"。基线场景沿用 S1–S4 编号,bug 回归场景用 `S` 编号(见下节)。 + +**断言读什么**——日志三种读点(形状已用真实会话日志核实): + +| 读点 | 日志形状 | 能断言什么 | +|---|---|---| +| 事件级 | `{type, seq, data}` | `compaction/*` 序列合法性、`shadowedTokenCount ≥ 0`、`turn/end` 理由、`sandbox/mode` / `approval/policy` 生效值 | +| 工具调用级 | `tool/call` → `data: {callId, name, arguments}`(arguments 为模型原始参数 JSON 字符串) | 模型传了什么参数、什么形态(普通 / wrapped `{arguments}` 信封 / scope=compressed / bN、mN 引用) | +| 工具结果级 | `tool/result` → `data.message.content[].text`(工具返回给模型的**完整原文**) | **acp_status 渲染了什么内容、是否正确**——kernel `buildStatusReport` 的每一行都逐字在日志里 | + +**模型是可脚本化的驱动器**:任务文本直接指示模型"调用哪个工具、传什么参数形态"(模型对明确的格式指令执行度很高)。引擎契约类断言是硬的;"模型是否自发做某事"仍是软断言——但"模型被明确指示后是否照做"升级为硬断言,因为它考验的是我们的工具调用链路(schema 校验、信封拆解、引用解析),不是模型自由意志。 + +### bug 修复的回归表达(S 场景) + +每个 bug 修复落地一个同名场景,文件头元数据钉住三元组:**症状(用户看到什么坏了)→ 修复(哪个 PR 改了什么)→ 断言读点(日志里哪一段证明修好了)**。bug → 测试可追溯,跑 E2E 即"重演一遍当时的病灶"。已知 bug 的映射示例: + +| 场景 | 症状(issue) | 场景怎么制造前置条件 | 断言读点 | +|---|---|---|---| +| `S54-shadow-price` | CJK 会话投影变负、每个 turn 被 zod 拒(#54) | CJK 大文件读取场景,`modelContextLimit: 8000` 逼出多次压缩 | 所有 `compaction/summary`/`prune` 的 `shadowedTokenCount ≥ 0`;日志无 `Too small: expected number to be >=0`;`turn/end: completed` | +| `S47-status-all-blocks` | `/acp status` 只显示最老 10 个块(#47/#48) | 任务引导模型压缩 12 段后**调用 acp_status** | tool/result 原文含全部 12 个块行(b1…b12),而非只有前 10 | +| `S60-checkpoint-seqs` | ACTIVE 块缺 `Checkpoint seqs` 行,模型无从蒸馏(#60) | 压缩 1 段后调用 acp_status | tool/result 含 `Checkpoint seqs` 行且 seq 落在日志实际范围内 | +| `S9-envelope` | wrapped `{arguments}` 信封静默丢参(rule 9) | 任务**明确要求**以 `{"arguments":{"scope":"compressed"}}` 形态调用 acp_status | tool/call 的 `arguments` 原文确为包裹形态;tool/result 是 drilldown 报告(有 COMPRESSED BLOCKS 段、无 Nudge 决策行)——直接回归 `unwrapEnvelope` | +| `S35-mn-ref` | drilldown 的 mN 引用压缩失败(#35) | drilldown 后让模型用返回的 mN 边界调 compress | 对应 `compaction/summary` 落盘、surface 收缩 | + +**边界**:不能通过正常使用路径触达的深状态 bug(崩溃孤儿清理、legacy 日志自愈——需要在运行中途注入残缺状态),继续由单测钉住(AGENTS.md 已有"每个 fix 必带回归测试"的惯例),E2E 不重复也不强求;上表回归的都是"用户/模型可见的协议面"。 + + +### 运行器与产物 + +``` +scripts/e2e/ +├── run.mjs # 无新依赖的 Node runner:构建镜像→起容器→逐场景跑→断言→报告 +├── Dockerfile # node:22-slim + zstd/git/pnpm10 + 钉版本的 @deepseek-ai/dsh +├── scenarios.mjs # 场景即代码:id/issue/task/patch/fixtures/assert 单一事实源 +└── out/ # 运行产物(gitignore):容器内 /out 挂载点、解包日志、报告 +``` + +流程(宿主机要求 Docker + Node):`run.mjs` 先在**宿主**跑 `npm run build` 保证 dist 是当前源码(容器不能用宿主 node_modules 构建——esbuild 等是平台二进制)→ `docker build` 镜像(有层缓存)→ `docker run` 挂载仓库(`/repo`)与产物目录(`/out`)→ **容器内** `container-main.mjs`:组装临时 home 与 profile(pnpm 安装 file:/repo,装的是刚构建的 dist)→ 逐场景 `dsh --profile acp-e2e` → 解包断言(容器自带 zstd + node)→ 结果 JSON 落 `/out` → 宿主机 runner 汇总报告(场景 / 退出码 / 各断言结果 / compaction 事件计数),任何硬断言失败则退出码非零。本地 `npm run e2e` 一条命令;`.gitignore` 已含 `scripts/e2e/out/`(临时 home、解包日志、凭据 env-file 永不入库)。 + +凭据注入细节:白名单内的宿主环境变量(`DEEPSEEK_API_KEY`、`AGICTO_API_KEY` 等 + `ACP_E2E_*` 路由描述)自动透传进容器;或写 `scripts/e2e/out/e2e.env` 作 `--env-file`。缺省路由 deepseek-official/deepseek-v4-flash;自定义路由用 `ACP_E2E_PROVIDER` + `ACP_E2E_BASE_URL` + `ACP_E2E_API_KEY_ENV`(settings.yaml 由执行器生成,只含路由信息不含密钥)。 + +实现状态(v3 同步):`scripts/e2e/{Dockerfile, run.mjs, scenarios.mjs, container-main.mjs}` 已落地;场景 9 个(S4-baseline、S1-pressure、S2-search-after-compress、S3-batch-compress、S54-shadow-price、S47-status-all-blocks、S60-checkpoint-seqs、S9-envelope-drilldown、S35-mn-ref),每个带 `set`(smoke/full)与 `weight`(实测秒数,用于分道负载均衡)。运行入口:`npm run e2e`(默认 smoke 集:S4/S54/S60,约 1 分钟)、`npm run e2e:full`(全量)、`ACP_E2E_SCENARIOS=...`(显式列表)、`ACP_E2E_CONCURRENCY`(并行道数,默认 3)。 + +**并行模型(v4)**:场景按 weight 降序轮流发牌均衡分到 N 条"道",每条道一个独立容器(独立 DSH_HOME、独立产物目录 `out/runs/<时间戳>/lane-N/`),道内串行、道间并行,宿主合并各道报告出汇总表。全量 9 场景墙钟从 ~15 分钟压到 ~4.6 分钟(3 道:259s/267s/274s)。道数默认 3 是 provider 限流与并行收益的折中。 + +### CI + +可选 job(需要 `DEEPSEEK_API_KEY` secret):手动触发或 nightly,不在每个 PR 上强制(成本与抖动考量);层 B 成熟后接为每 PR 必跑。容器唯一化之后 CI 侧没有任何额外要求——`ubuntu-latest` 自带 Docker,job 就是"build 镜像 → run → 断言退出码",key 走 secret 转 `--env-file`。 + +### 容器化运行(Docker,已验证;唯一执行方式) + +E2E **每次都在容器里跑**:launcher、Node、pnpm、zstd 全部钉进镜像,宿主机只需要 Docker 和本仓库,不要求安装 `dsh` CLI。运行时与被测组合一起可复现,也顺带消掉了"宿主机没装 CLI"这一类环境问题。**已在 OrbStack(linux/arm64)实测全链路通过**,验证日期同本文档 v2: + +1. 镜像(Node 22 slim + zstd/git/pnpm10 + `npm i -g @deepseek-ai/dsh@0.1.2-rc.1`,钉版本)构建成功; +2. 仓库以 volume 挂进容器,`file:` 安装进 profile(6.7s,`billion-context-dsh 0.2.19`),bundle patch 生效——`--dump-config` 显示 `compaction-acp` 已挂载、`compaction-basic` 已 `disabled: true`; +3. headless 应用可达(`--profile acp-e2e --help` 正常输出); +4. **真实 LLM 任务跑通**:容器内 `dsh --profile acp-e2e "只回复两个字:正常"` → stdout `正常`、exit 0、会话日志完整(`turn/start … turn/end`)、且请求里可见 `"name":"compress"` 工具与 `acp_status`——插件确实到达了模型请求侧。 + +镜像配方(实现时落到 `scripts/e2e/Dockerfile`,以下为已验证的等价物): + +```dockerfile +FROM node:22-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends zstd ca-certificates git \ + && rm -rf /var/lib/apt/lists/* +RUN npm i -g pnpm@10 +RUN npm i -g @deepseek-ai/dsh@0.1.2-rc.1 # 被测 launcher,钉版本 → 镜像可复现 +ENV DSH_TELEMETRY_DISABLED=1 +``` + +运行形态(等价验证命令): + +```sh +docker run --rm \ + --env-file <凭据env文件,用后即删> \ + -v <仓库>:/repo \ + -v :/out \ + acp-e2e bash /out/run.sh # 组装 profile → 逐场景 dsh --profile acp-e2e → 断言 +``` + +容器模式带来的额外好处与注意点: + +| | 说明 | +|---|---| +| 凭据注入 | API key 用 `--env-file` 传入(credentials 链第一优先级是进程环境),宿主机凭据文件**不需要**进容器;用最小 settings.yaml(只带 `agent-default-model` + 所需 `llm-pi-ai` provider 段,无密钥)即可点亮路由——实测 agicto 路由容器内可用 | +| 机器级隔离 | 容器内的写入只落在挂载卷(`/out` 产物、`/repo` 只读即可——pnpm `file:` 安装是复制,不写源目录) | +| 层 B 联动 | mock server 与被测进程同容器走 localhost;进阶可以 `--network none` 完全断网跑层 B,密封且零 API 依赖 | +| 实测坑 | ① pnpm 10 移除了 `--no-fund/--no-audit` 旗标;② `node:22-slim` 无 python3——断言脚本统一用 node 写;③ macOS 上 docker buildx 要写 `~/.docker/`,受限环境可用 `DOCKER_CONFIG=<临时目录>` 引开;④ 镜像体积可观(dsh CLI 全量依赖),首次构建几分钟,之后有层缓存 | + +容器模式定位为**唯一执行方式**(`npm run e2e` 内部就是一次 `docker run`):场景与断言代码只有一套,宿主机直跑模式不保留——双入口意味着两套行为差异要维护,而容器内跑的额外成本(首次镜像构建几分钟,之后全是层缓存)是一次性的。 + +#### 权限与审批:容器内完全开放(已验证) + +headless 无人应答审批:`approval=ask` 在没有应答者时**必然 fail closed**(工具调用直接被拒),所以"受限权限 + 默认放行"这个中间态在 headless 结构上不成立。E2E 采用官方内置的环境开关**完全开放**——反正隔离在容器里: + +```sh +DSH_PERMISSION_MODE=danger-full-access # dsh-base 的两个行都读它: +# sandbox-policy.config.mode → danger-full-access(文件效果边界全开) +# approval.config.policy → never(无审批拦截) +``` + +容器实测:跑"运行 bash 命令 echo acp-e2e-ok 并告诉我输出"——模型真实执行了 bash、stdout 返回 `acp-e2e-ok`、exit 0,日志里 `permission/preset: danger-full-access`、`sandbox/mode: danger-full-access`、`approval/policy: never` 三个事件与预期完全一致。runner 把这个变量固定写进 `docker run -e`。 + +## 层 B 详细设计(后续) + +- 机制:`$DSH_HOME/settings.yaml` 写一个 `llm-pi-ai` provider 段(`baseURL: http://127.0.0.1:/v1` + `apiKeyEnv` 指向假变量名)——`dsh-llm-pi-ai` 的设计就是"组合决定适配器存在,settings 决定 provider 运行",无需改组合。 +- 本地 mock server 脚本化回放:固定脚本依次返回"大工具输出 → compress 调用 → search_context 调用",从而**确定性**断言: + - 请求体里 nudge 文本在压力过线后出现(层 A 做不到的断言); + - `compress` 参数(含 wrapped `{ arguments }` 信封形态)经过真实 schema 校验; + - 同一场景每次运行字节级可比对。 +- 局限:mock 与真实 provider 线格式可能漂移——用真实 OpenAI SDK 形状的流式响应、并在层 A 保留真实模型冒烟来对冲。 +- 更远的备选:in-process cordis boot 复用 dsh-base patch 进 `npm test`。首选仍是独立进程方案:走真实 launcher,"组装"本身在被测范围里。 + +## 决策记录 + +1. **为什么用 headless 而不是 web profile 做自动化**:headless 有进程级契约(stdout=最终回答、exit code=结果)、无端口无 GUI 自清理;web 面向交互,自动化要对付浏览器层。web profile 保留给人肉 exploratory 测试。 +2. **为什么 runner 自建 profile 而不用 `dsh plugin add`**:`dsh plugin add` 的 scaffold 模板按 profile 名选取(`PROFILE_TEMPLATES[name]`,已验证),非模板名只能得到 `[dsh-base, <被装包>]`,要补 `@deepseek-ai/dsh-headless` 仍得手改 manifest;且后续 `dsh plugin add` 的 bundles reconcile 行为可能与手改项冲突。自建目录让 runner 对环境有完整所有权、幂等可重建。(备选:临时 home 里用名为 `headless` 的 profile 再 add 插件——同样得到目标组合,但名字歧义、多场景配置不便,弃。) +3. **为什么 oracle 是会话日志**:append-only、格式稳定、`compaction/*` 事件本来就是我们写入的对外协议——测"协议消费方看到的世界",而不是引擎内部状态。 +4. **为什么不改 `src/`**:所有断言消费现有事件与现有配置项;将来若需要更深观测,优先用层 B(请求体直接可见)而不是给引擎加日志事件——测试需求不应该反向污染运行时协议。 +5. **为什么压窗口而不是压阈值**:`nudgeMaxContextLimitPct` 有 0.45 下限约束;`modelContextLimit` 显式值本来就优先于自动探测,是现成且合规的杠杆。 +6. **为什么容器是唯一执行方式**:把 Node 版本、launcher 版本、pnpm、zstd 全部钉进镜像,运行时与被测组合一起可复现,且宿主机不要求装 `dsh` CLI——环境差异类失败被整体消掉。代价(首次镜像构建几分钟)是一次性的,不值得为它保留双入口。 +7. **为什么容器内完全开放权限**:headless 下 `approval=ask` 无应答者必然 fail closed,"受限 + 自动放行"的中间态不存在;`DSH_PERMISSION_MODE=danger-full-access` 是官方开关(同时设 sandbox 与 approval 两个旋钮),且容器本身提供隔离边界——开放只影响容器内的一次性文件系统。 +8. **launcher 版本线怎么测**:只测 peer range 覆盖线里的**最新版**(当前即 0.1.2-rc.1,镜像钉住它);不逐版本回归。只有当用户报告或上游发布引入了新版本兼容性问题时,才针对那个具体版本加测(必要时再建一个钉该版本的镜像变体)。 +9. **并行分道 + 默认精简集**:场景彼此独立(各自容器、各自 DSH_HOME),按 weight 降序均衡分道并行,重场景(S47/S3)不再决定总墙钟;日常回归默认只跑 smoke 集(S4/S54/S60——覆盖基线、#54 影子价哨兵、#60 蒸馏入口,约 1 分钟),全量(`npm run e2e:full`)留给发布前或专项排查。首次全量实测的证据支持这个分层:9 场景里 4 个失败全是测试侧问题,且重场景耗时是轻场景的 10 倍以上。 + +## 风险与开放问题 + +- **审批/沙箱策略在 headless 下如何表现**:web 会话里有 `permission/preset` / `approval/policy` 事件;headless 无人应答审批,场景任务的工具调用必须落在默认放行范围内。首次实现时确认;若被拦,用 profile patch / 环境变量显式设置放行策略。 +- **launcher 版本线**:只测覆盖线最新版(当前 0.1.2-rc.1,镜像钉住;决策 8)。peer range 已覆盖该线(`^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.4`,issue #68);新版本兼容性问题出现时再针对该版本专项加测。 +- **模型抖动**:同一场景两次运行的模型行为可能不同——硬断言只钉引擎契约,模型行为一律软断言;环境性失败(网络/限流)单独分型。 +- **~~`dsh` CLI 不在 PATH 的环境~~(已消解)**:容器是唯一执行方式(决策 6),镜像自带全部运行时,宿主机只需要 Docker。 +- **审批/沙箱在 headless 下如何表现(已解决)**:`approval=ask` 无应答者 fail closed,中间态不存在;runner 固定注入 `DSH_PERMISSION_MODE=danger-full-access`(官方开关,容器内实测通过,见「权限与审批」小节)。 +- **凭据与容器**:API key 只经 `--env-file` 进入容器进程环境,宿主机凭据文件不挂载进容器;env-file 用后即删、产物目录 gitignore。CI 中 key 走 secret 注入,不落盘。 diff --git a/package.json b/package.json index 8b2a8ed..30b1dbb 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,9 @@ "scripts": { "typecheck": "tsc --noEmit", "build": "tsup && tsc --emitDeclarationOnly", - "test": "node --import tsx --test tests/*.test.ts tests/kernel-upstream/*.test.ts" + "test": "node --import tsx --test tests/*.test.ts tests/kernel-upstream/*.test.ts", + "e2e": "node scripts/e2e/run.mjs", + "e2e:full": "ACP_E2E_SET=full node scripts/e2e/run.mjs" }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1", diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile new file mode 100644 index 0000000..c4f7400 --- /dev/null +++ b/scripts/e2e/Dockerfile @@ -0,0 +1,21 @@ +# E2E 运行时镜像:钉版本的被测 launcher + 日志解码/断言工具。 +# 设计依据 docs/e2e-testing-design.md「容器化运行」——本镜像已在 +# OrbStack(linux/arm64)上全链路验证;宿主机只需要 Docker + Node(宿主 +# 构建 dist),不要求安装 dsh CLI / pnpm / zstd。 +# +# 升级被测 launcher 时改这里的版本号并重建镜像(决策 8:只测覆盖线最新版)。 +FROM node:22-slim + +# zstd 解会话日志(多帧拼接,Node 内置 zstd 解不了);git 供 dsh 环境探测 +RUN apt-get update \ + && apt-get install -y --no-install-recommends zstd ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +# pnpm 供 profile 目录安装第三方插件(本插件以 file:/repo 方式安装) +RUN npm i -g pnpm@10 + +# 被测 launcher 本体:钉版本 → 运行时可复现 +RUN npm i -g @deepseek-ai/dsh@0.1.2-rc.1 + +# 测试进程不回传遥测 +ENV DSH_TELEMETRY_DISABLED=1 diff --git a/scripts/e2e/container-main.mjs b/scripts/e2e/container-main.mjs new file mode 100644 index 0000000..e832df4 --- /dev/null +++ b/scripts/e2e/container-main.mjs @@ -0,0 +1,216 @@ +// E2E 容器内执行器(docs/e2e-testing-design.md「运行器与产物」)。 +// +// 由宿主机 run.mjs 通过 `docker run` 调起,前置条件: +// - /repo 挂载本仓库(dist 已由宿主 npm run build 构建为最新) +// - /out 挂载产物目录(宿主 scripts/e2e/out):dshhome/、results/、report.json +// - 环境变量:DSH_PERMISSION_MODE=danger-full-access(run.mjs 固定注入); +// API key 经 --env-file / docker -e 传入本进程环境(credentials 链第一优先级) +// - ACP_E2E_SCENARIOS(可选,逗号分隔 id,缺省全跑)、ACP_E2E_TIMEOUT_MS(单场景超时) +// - ACP_E2E_PROVIDER / ACP_E2E_MODEL / ACP_E2E_BASE_URL / ACP_E2E_API / +// ACP_E2E_API_KEY_ENV(可选;缺省用 launcher 自带 deepseek-official 路由) +// +// 本进程只依赖 Node 内置模块 + 容器内已装好的 dsh/pnpm/zstd。 + +import { spawnSync, execSync } from 'node:child_process' +import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { SCENARIOS, selectScenarios, parseSessionLog, indexToolCalls, compactionEvents } from './scenarios.mjs' + +const HOME = '/out/dshhome' +const OUT = '/out' +const PROFILE = join(HOME, 'profiles', 'acp-e2e') + +const log = (msg) => process.stdout.write(`[e2e] ${msg}\n`) + +// ── 1. settings.yaml:模型路由(JSON 是合法 YAML,直接 stringify)────────── + +function writeSettings() { + const provider = process.env.ACP_E2E_PROVIDER || 'deepseek-official' + const model = process.env.ACP_E2E_MODEL || 'deepseek-v4-flash' + const doc = { 'agent-default-model': { provider, model } } + if (provider !== 'deepseek-official') { + // dsh-llm-pi-ai 的设计:组合决定适配器存在,settings 决定 provider 运行。 + // key 按名字引用环境变量(credentials 链:进程环境优先),本文件不含密钥。 + const keyEnv = process.env.ACP_E2E_API_KEY_ENV || 'ACP_E2E_API_KEY' + const baseURL = process.env.ACP_E2E_BASE_URL + if (!baseURL) throw new Error('ACP_E2E_PROVIDER 非 deepseek-official 时必须提供 ACP_E2E_BASE_URL') + doc['llm-pi-ai'] = { + providers: { + [provider]: { + api: process.env.ACP_E2E_API || 'openai-completions', + baseURL, + apiKeyEnv: keyEnv, + models: [{ id: model, name: model }], + }, + }, + } + } + writeFileSync(join(HOME, 'settings.yaml'), JSON.stringify(doc, null, 2)) + log(`settings.yaml: provider=${provider} model=${model}`) +} + +// ── 2. profile 组装(runner 自建,幂等;决策 2:不用 dsh plugin add)────────── + +function assembleProfile() { + rmSync(join(HOME, 'profiles'), { recursive: true, force: true }) + mkdirSync(PROFILE, { recursive: true }) + writeFileSync(join(PROFILE, 'package.json'), JSON.stringify({ + name: 'dsh-profile-acp-e2e', + private: true, + dependencies: { 'billion-context-dsh': 'file:/repo' }, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless', 'billion-context-dsh'] } }, + }, null, 2)) + // 我们的 bundle patch(cordis.patch.yml,随 file: 安装生效)自动禁 compaction-basic + // 并挂 compaction-acp —— "安装即生效"契约本身就是被测对象,用户层保持空。 + writeFileSync(join(PROFILE, 'cordis.patch.yml'), '[]\n') + const r = spawnSync('bash', ['-lc', `cd '${PROFILE}' && pnpm install --silent`], { encoding: 'utf8' }) + if (r.status !== 0) throw new Error(`profile pnpm install 失败:\n${r.stdout}\n${r.stderr}`) + log(`profile 组装完成: ${PROFILE}`) +} + +// ── 3. 会话日志定位与解码 ────────────────────────────────────────────── + +function sessionDirNames() { + // diff 的粒度必须是 slug/session-uuid 两级:同一 cwd 的 slug 目录会跨运行 + // 复用(headless 每次新建 session-,但 slug 目录同名)——首测教训: + // 只 diff slug 层会把"同 slug 下的新 uuid"误判为旧目录,丢掉整份日志。 + const root = join(HOME, 'sessions') + const out = new Set() + if (!existsSync(root)) return out + for (const slug of readdirSync(root)) { + const sd = join(root, slug) + let subs = [] + try { subs = readdirSync(sd) } catch { continue } + for (const s of subs) out.add(slug + '/' + s) + } + return out +} + +/** 找出本次运行新产生的(before 里没有的)最新 session.jsonl.zstd。 */ +function newestSessionLog(before) { + const root = join(HOME, 'sessions') + if (!existsSync(root)) return null + let best = null + for (const slug of readdirSync(root)) { + const sd = join(root, slug) + let subs = [] + try { subs = readdirSync(sd) } catch { continue } + for (const s of subs) { + if (before.has(slug + '/' + s)) continue + const candidate = join(sd, s, 'session.jsonl.zstd') + if (!existsSync(candidate)) continue + const m = statSync(candidate).mtimeMs + if (!best || m > best.m) best = { log: candidate, m } + } + } + return best?.log ?? null +} + +function decodeLog(logPath) { + // 多帧 zstd 拼接:Node 的 zstdDecompressSync 只解第一帧,必须用 zstd CLI。 + return execSync(`zstd -dc '${logPath}'`, { maxBuffer: 256 * 1024 * 1024 }).toString() +} + +// ── 4. 逐场景执行 ────────────────────────────────────────────── + +function runScenario(sc) { + const cwd = join(OUT, 'scen', sc.id, 'cwd') + rmSync(join(OUT, 'scen', sc.id), { recursive: true, force: true }) + mkdirSync(cwd, { recursive: true }) + sc.fixturesOf = sc.fixtures ? sc.fixtures.call(sc, cwd) : null + const task = typeof sc.task === 'function' ? sc.task.call(sc) : sc.task + + const args = ['--profile', 'acp-e2e'] + if (sc.patch) { + const patchPath = join(OUT, 'scen', sc.id, 'patch.yml') + writeFileSync(patchPath, sc.patch + '\n') + args.push('--patch', patchPath) + } + args.push(task) + + const before = sessionDirNames() + const t0 = Date.now() + const r = spawnSync('dsh', args, { + cwd, + encoding: 'utf8', + timeout: Number(process.env.ACP_E2E_TIMEOUT_MS || 420000), + maxBuffer: 256 * 1024 * 1024, + env: { ...process.env, DSH_HOME: HOME }, + }) + const durationMs = Date.now() - t0 + + const sessionLog = newestSessionLog(before) + let events = [] + let rawLogText = '' + if (sessionLog) { + rawLogText = decodeLog(sessionLog) + events = parseSessionLog(rawLogText) + } else { + log(`⚠ ${sc.id}: 未找到新产生的会话日志`) + } + + const ctx = { + exitCode: r.status ?? -1, + stdout: r.stdout ?? '', + stderr: r.stderr ?? '', + events, + rawLogText, + sessionLog, + tools: indexToolCalls(events), + comp: compactionEvents(events), + } + const results = sc.assert.call(sc, ctx) + const hardFailed = results.filter((x) => !x.soft && !x.pass) + return { + id: sc.id, + title: sc.title, + issues: sc.issues, + exitCode: ctx.exitCode, + durationMs, + sessionLog: ctx.sessionLog, + summaryCount: ctx.comp.summaries.length, + pruneCount: ctx.comp.prunes.length, + task, + results, + passed: hardFailed.length === 0 && ctx.sessionLog !== null, + } +} + +// ── main ────────────────────────────────────────────── + +mkdirSync(HOME, { recursive: true }) +mkdirSync(join(OUT, 'results'), { recursive: true }) +assembleProfile() +writeSettings() + +const scenarios = selectScenarios(process.env.ACP_E2E_SCENARIOS) +if (scenarios.length === 0) throw new Error(`ACP_E2E_SCENARIOS 没有匹配到任何场景: ${process.env.ACP_E2E_SCENARIOS}`) +log(`共 ${scenarios.length} 个场景: ${scenarios.map((s) => s.id).join(', ')}`) + +const runs = [] +for (const sc of scenarios) { + log(`▶ ${sc.id} — ${sc.title}`) + let run + try { + run = runScenario(sc) + } catch (err) { + run = { id: sc.id, title: sc.title, issues: sc.issues, exitCode: -1, durationMs: 0, sessionLog: null, + summaryCount: 0, pruneCount: 0, task: '', results: [{ name: '执行器异常', pass: false, detail: String(err?.stack || err), soft: false }], passed: false } + } + runs.push(run) + // 每个场景的结果立即落盘:中途失败/被杀时,已完成场景的证据不丢 + //(首测教训:只写最终 report.json,中途诊断只能去翻原始日志)。 + writeFileSync(join(OUT, 'results', `${run.id}.json`), JSON.stringify(run, null, 2)) + const failed = run.results.filter((x) => !x.pass) + log(`${run.passed ? '✓' : '✗'} ${sc.id} (${Math.round(run.durationMs / 1000)}s, summary=${run.summaryCount})` + + (failed.length ? ` — 失败: ${failed.map((f) => f.name).join('; ')}` : '')) +} + +const report = { + passed: runs.every((r) => r.passed), + generatedAt: new Date().toISOString(), + scenarios: runs, +} +writeFileSync(join(OUT, 'report.json'), JSON.stringify(report, null, 2)) +log(`报告: ${join(OUT, 'report.json')} — ${report.passed ? '全部通过' : '存在失败'}`) +process.exit(report.passed ? 0 : 1) diff --git a/scripts/e2e/run.mjs b/scripts/e2e/run.mjs new file mode 100644 index 0000000..fada59e --- /dev/null +++ b/scripts/e2e/run.mjs @@ -0,0 +1,152 @@ +// E2E 宿主机编排(docs/e2e-testing-design.md)。 +// +// 宿主机要求:Docker + Node(构建 dist;开发者环境本来就有)。 +// 不要求安装 dsh CLI / pnpm / zstd —— 全部在镜像里(容器唯一执行方式,决策 6)。 +// +// 并行模型(决策 9):场景按 weight 降序均衡分到 N 条"道"(lane), +// 每条道一个独立容器(独立 DSH_HOME 与产物目录),道内串行、道间并行。 +// N = ACP_E2E_CONCURRENCY(默认 3;兼顾 provider 限流)。 +// +// 场景选择(决策 9:日常不必全量): +// ACP_E2E_SCENARIOS=S4-baseline,... 显式列表(最高优先级) +// ACP_E2E_SET=full|all 全量 9 场景(或 npm run e2e:full) +// 缺省 smoke 集(S4/S54/S60,~1 分钟量级) +// +// 模型凭据(二选一,都不落盘入库): +// 1. 宿主环境直接 export(白名单内的变量自动透传进容器) +// 2. 写进 scripts/e2e/out/e2e.env(gitignore;KEY=value 每行一条,作 --env-file) +// +// 缺省路由 deepseek-official/deepseek-v4-flash(只需 DEEPSEEK_API_KEY); +// 自定义路由示例(pi-ai provider,本机当前即此配置): +// ACP_E2E_PROVIDER=xiaomi-token-plan-cn ACP_E2E_MODEL=mimo-v2.5 \ +// ACP_E2E_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1 ACP_E2E_API_KEY_ENV=LLM_API_KEY + +import { spawn, spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { scenariosForSet, selectScenarios } from './scenarios.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const repo = dirname(dirname(here)) +const outDir = join(here, 'out') +const IMAGE = 'billion-context-dsh-e2e:latest' + +const step = (msg) => process.stdout.write(`\n[e2e] ${msg}\n`) + +// 透传进容器的宿主环境变量白名单:key 本体 + 路由描述(ACP_E2E_SCENARIOS +// 除外——每条道由宿主显式注入自己的场景列表)。 +const ENV_PASSTHROUGH = [ + 'DEEPSEEK_API_KEY', 'AGICTO_API_KEY', 'OPENROUTER_API_KEY', 'ZAI_API_KEY', 'LLM_API_KEY', + 'ACP_E2E_PROVIDER', 'ACP_E2E_MODEL', 'ACP_E2E_BASE_URL', 'ACP_E2E_API', + 'ACP_E2E_API_KEY_ENV', 'ACP_E2E_API_KEY', 'ACP_E2E_TIMEOUT_MS', +] + +// 0. preflight:docker daemon 必须可用 +const dockerInfo = spawnSync('docker', ['info', '--format', '{{.ServerVersion}}'], { encoding: 'utf8' }) +if (dockerInfo.status !== 0) { + console.error('[e2e] Docker daemon 不可用(docker info 失败)。请先启动 Docker/OrbStack。') + process.exit(2) +} + +// 1. 构建被测对象:当前源码 → dist(容器无法用宿主 node_modules 构建:平台二进制不同) +if (process.env.ACP_E2E_NO_BUILD !== '1') { + step('构建 dist(npm run build)') + const build = spawnSync('npm', ['run', 'build'], { cwd: repo, stdio: 'inherit' }) + if (build.status !== 0) { + console.error('[e2e] npm run build 失败,终止。') + process.exit(2) + } +} else if (!existsSync(join(repo, 'dist', 'index.js'))) { + console.error('[e2e] ACP_E2E_NO_BUILD=1 但 dist/index.js 不存在,终止。') + process.exit(2) +} + +// 2. 构建镜像(层缓存:内容不变时秒级完成) +step(`构建镜像 ${IMAGE}`) +const img = spawnSync('docker', ['build', '-t', IMAGE, here], { stdio: ['ignore', 'ignore', 'inherit'] }) +if (img.status !== 0) { + console.error('[e2e] docker build 失败,终止。') + process.exit(2) +} + +// 3. 解析场景集合与分道 +const explicit = process.env.ACP_E2E_SCENARIOS +const scenarios = explicit ? selectScenarios(explicit) : scenariosForSet(process.env.ACP_E2E_SET || 'smoke') +if (scenarios.length === 0) { + console.error('[e2e] 没有匹配到任何场景。检查 ACP_E2E_SCENARIOS / ACP_E2E_SET。') + process.exit(2) +} +const concurrency = Math.max(1, Math.min(Number(process.env.ACP_E2E_CONCURRENCY || 3), scenarios.length)) +// 按 weight 降序轮流发牌:重场景(S47/S3)均摊到不同道,墙钟时间≈最重的一道 +const lanes = Array.from({ length: concurrency }, () => []) +for (const s of [...scenarios].sort((a, b) => (b.weight || 0) - (a.weight || 0))) { + lanes.sort((a, b) => a.reduce((t, x) => t + (x.weight || 0), 0) - b.reduce((t, x) => t + (x.weight || 0), 0)) + lanes[0].push(s) +} +step(`并行 ${concurrency} 道,共 ${scenarios.length} 个场景:${lanes.map((l, i) => `道${i + 1}[${l.map((s) => s.id).join(',')}]`).join(' ')}`) + +// 4. 每条道一个容器,同时启动 +const runId = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) +const envFile = process.env.ACP_E2E_ENV_FILE || join(outDir, 'e2e.env') + +function runLane(laneIndex, lane) { + const laneOut = join(outDir, 'runs', runId, `lane-${laneIndex + 1}`) + mkdirSync(laneOut, { recursive: true }) + const args = [ + 'run', '--rm', + '-e', 'DSH_PERMISSION_MODE=danger-full-access', // 容器内完全开放(headless 下 ask 必然 fail closed) + '-v', `${repo}:/repo`, + '-v', `${laneOut}:/out`, + '-e', `ACP_E2E_SCENARIOS=${lane.map((s) => s.id).join(',')}`, + ] + if (existsSync(envFile)) args.push('--env-file', envFile) + for (const k of ENV_PASSTHROUGH) { + if (process.env[k] !== undefined) args.push('-e', `${k}=${process.env[k]}`) + } + args.push(IMAGE, 'bash', '-lc', 'node /repo/scripts/e2e/container-main.mjs') + return new Promise((resolve) => { + const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] }) + child.stdout.on('data', (d) => process.stdout.write(`[道${laneIndex + 1}] ${d}`)) + child.stderr.on('data', (d) => process.stderr.write(`[道${laneIndex + 1}] ${d}`)) + child.on('exit', (code) => resolve({ laneIndex, code, laneOut })) + }) +} + +step('执行场景(并行容器内)') +const t0 = Date.now() +const laneResults = await Promise.all(lanes.map((lane, i) => runLane(i, lane))) +const wallSec = Math.round((Date.now() - t0) / 1000) + +// 5. 合并各道报告 → 汇总 +const merged = [] +for (const r of laneResults) { + const p = join(r.laneOut, 'report.json') + if (!existsSync(p)) continue + try { merged.push(...JSON.parse(readFileSync(p, 'utf8')).scenarios) } catch { /* 该道容器在写报告前失败 */ } +} +const missing = scenarios.filter((s) => !merged.some((m) => m.id === s.id)) +for (const s of missing) { + merged.push({ id: s.id, title: s.title, issues: s.issues, exitCode: -1, durationMs: 0, + summaryCount: 0, pruneCount: 0, passed: false, + results: [{ name: '容器未产出结果', pass: false, detail: '该道在写报告前失败,向上翻容器输出', soft: false }] }) +} +const report = { passed: merged.every((r) => r.passed), wallSec, concurrency, generatedAt: new Date().toISOString(), scenarios: merged } +mkdirSync(outDir, { recursive: true }) +writeFileSync(join(outDir, 'report.json'), JSON.stringify(report, null, 2)) + +// 6. 打印表格 +process.stdout.write(`\n══════ E2E 报告(并行 ${concurrency} 道,墙钟 ${wallSec}s)══════\n`) +for (const s of report.scenarios) { + process.stdout.write(`\n${s.passed ? '✓' : '✗'} ${s.id} — ${s.title}` + + `(exit=${s.exitCode},${Math.round(s.durationMs / 1000)}s,summary=${s.summaryCount},prune=${s.pruneCount})\n`) + for (const a of s.results) { + const mark = a.pass ? '✓' : (a.soft ? '~' : '✗') + const suffix = a.detail ? ` — ${a.detail}` : '' + process.stdout.write(` ${mark} ${a.name}${suffix}\n`) + } + if (s.issues?.length) process.stdout.write(` 关联 issue: #${s.issues.join(', #')}\n`) +} +const hardFailed = report.scenarios.flatMap((s) => s.results.filter((a) => !a.pass && !a.soft)) +process.stdout.write(`\n结论: ${report.passed ? '全部通过' : `失败 ${hardFailed.length} 项硬断言`}(产物: scripts/e2e/runs/${runId}/)\n`) +process.exit(report.passed ? 0 : 1) diff --git a/scripts/e2e/scenarios.mjs b/scripts/e2e/scenarios.mjs new file mode 100644 index 0000000..49df39d --- /dev/null +++ b/scripts/e2e/scenarios.mjs @@ -0,0 +1,507 @@ +// E2E 场景与断言的单一事实源(docs/e2e-testing-design.md「场景定义与断言读点」)。 +// +// 每个场景一个对象,由容器内执行器 container-main.mjs 消费: +// id / title / issues —— 场景标识与关联的 issue(bug 回归场景用 S 编号) +// patch —— 可选的 launcher --patch 覆盖(YAML 字符串;典型用途:压小窗口) +// fixtures(dir) —— 在场景 cwd 物化测试文件(确定性生成,可复现);返回值存入 this.fixturesOf +// task —— 任务文本(字符串,或用 this.fixturesOf 的函数);模型是被脚本化的 +// 驱动器:任务文本明确指示调用哪个工具、传什么参数形态 +// assert(ctx) —— 断言函数;返回 [{name, pass, detail, soft}] +// +// 硬/软断言分界(设计文档决策):引擎契约 = 硬;模型自由意志 = 软; +// "模型被明确指示后是否照做" = 硬(考验的是我们的工具调用链路:schema 校验、 +// 信封拆解、引用解析),不是模型意愿。 +// +// 所有字段名与锚点字符串均从真实会话日志与 src/ 核实过: +// tool/call: data{callId, name, arguments};tool/result: data.message.content[] 内 +// {type:'tool-result', toolCallId, content:[{type:'text',text}], isError} +// turn/end: data.reason = {kind:'completed'|...}(对象,不是字符串) +// compaction/summary: data.shadowedTokenCount / kernelBlockId / tier ... +// compaction/prune: data.shadowedTokenCount ... +// acp_status 渲染自 kernel buildStatusReport("CONTEXT BREAKDOWN" / "COMPRESSED BLOCKS") +// 加引擎附加行:`Nudge: ACTIVE|idle — ...`(src/tools.ts:751)、 +// `Checkpoint seqs (active blocks — ...)`(src/tools.ts:764)、`Surface: ...`(:767) + +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +// ── 会话日志解析助手 ────────────────────────────────────────────── + +/** 解包后的日志文本 → 事件数组(跳过截断的坏行,不因单行损坏丢掉整个场景)。 */ +export function parseSessionLog(text) { + const events = [] + for (const line of String(text ?? '').split('\n')) { + const t = line.trim() + if (!t) continue + try { events.push(JSON.parse(t)) } catch { /* 截断行:跳过 */ } + } + return events +} + +/** callId → {name, arguments, resultText, isError, seq}。call 与 result 靠同一 callId 配对。 */ +export function indexToolCalls(events) { + const byCallId = new Map() + const ensure = (id) => { + if (!byCallId.has(id)) byCallId.set(id, { callId: id, name: '', arguments: '', resultText: '', isError: false, seq: -1 }) + return byCallId.get(id) + } + for (const e of events) { + if (e.type === 'tool/call') { + const t = ensure(e.data?.callId) + t.name = e.data?.name ?? '' + t.arguments = typeof e.data?.arguments === 'string' ? e.data.arguments : JSON.stringify(e.data?.arguments ?? {}) + t.seq = e.seq ?? -1 + } else if (e.type === 'tool/result') { + const blocks = e.data?.message?.content ?? [] + for (const b of blocks) { + if (b?.type !== 'tool-result') continue + const t = ensure(b.toolCallId ?? e.data?.message?.source?.callId ?? '') + t.resultText = (b.content ?? []).map((c) => (typeof c === 'string' ? c : c?.text ?? '')).join('\n') + t.isError = Boolean(b.isError) + } + } + } + return byCallId +} + +export function allToolCalls(index, name) { + return [...index.values()].filter((t) => t.name === name) +} + +export function compactionEvents(events) { + const starts = [], summaries = [], prunes = [], ends = [] + for (const e of events) { + if (e.type === 'compaction/start') starts.push(e) + else if (e.type === 'compaction/summary') summaries.push(e) + else if (e.type === 'compaction/prune') prunes.push(e) + else if (e.type === 'compaction/end') ends.push(e) + } + return { starts, summaries, prunes, ends } +} + +export function finalTurnReason(events) { + let kind = null + for (const e of events) if (e.type === 'turn/end') kind = e.data?.reason?.kind ?? null + return kind +} + +// ── 断言结果助手 ────────────────────────────────────────────── + +const ok = (name, pass, detail = '') => ({ name, pass, detail, soft: false }) +const soft = (name, pass, detail = '') => ({ name, pass, detail, soft: true }) + +/** + * 通用完整性检查(每个场景都跑): + * - exit 0 + stdout 非空 + turn completed + * - compaction start/end 严格配对 —— 悬挂 start 是"悬挂 compaction start 恢复" + * 那类修复的回归哨兵。 + */ +function integrityResults(ctx) { + const { exitCode, stdout, events, comp } = ctx + const results = [ + ok('进程退出码 0', exitCode === 0, `exit=${exitCode}`), + ok('stdout 有非空最终回答', String(stdout ?? '').trim().length > 0), + ok('最终 turn 理由 completed', finalTurnReason(events) === 'completed', `kind=${finalTurnReason(events)}`), + ok( + 'compaction start/end 配对(无悬挂 start)', + comp.starts.length === comp.ends.length, + `start=${comp.starts.length} end=${comp.ends.length}`, + ), + ] + return results +} + +/** + * 影子价与投影完整性(issue #54 的回归哨兵,凡有 compaction 事件的场景都跑): + * - 所有 shadowedTokenCount ≥ 0 + * - 日志/输出任何位置不出现投影 zod 拒绝串(出现 = 会话已砖化) + */ +function shadowPriceResults(ctx) { + const { comp, rawLogText, stdout, stderr } = ctx + const all = [...comp.summaries, ...comp.prunes] + if (all.length === 0) return [] + const bad = all.filter((e) => typeof e.data?.shadowedTokenCount !== 'number' || !(e.data.shadowedTokenCount >= 0)) + const brickMark = 'Too small: expected number to be >=0' + const bricked = [rawLogText, stdout, stderr].some((s) => String(s ?? '').includes(brickMark)) + return [ + ok( + `所有 shadowedTokenCount ≥ 0(${all.length} 个事件)`, + bad.length === 0, + bad.length ? `异常值: ${bad.map((e) => JSON.stringify(e.data?.shadowedTokenCount)).join(', ')}` : '全部非负', + ), + ok('无投影 zod 拒绝(未砖化)', !bricked, brickMark), + ] +} + +/** 最近一次名为 name 的工具调用返回给模型的原文;没调用过返回 ''。 */ +function lastResultText(ctx, name) { + const calls = allToolCalls(ctx.tools, name) + return calls.length ? calls[calls.length - 1].resultText : '' +} + +/** + * acp_status 报告是"调用时刻"的快照:与事件对比必须做时间对齐(只统计调用 + * 之前落盘的 summary),并扣除已被 tier-2/3 蒸馏吸收的父块(parentBlockIds + * 是 compactionId,要经此前事件的 compactionId→kernelBlockId 映射回 bN)。 + * 首次全量实测的教训:拿最终事件状态对比中途快照,会误判 #47 回归(实测 + * 模型压了 10 块并自发蒸馏出 tier-2 b6,报告"3 active"是正确答案)。 + */ +function activeBlocksAtCall(ctx) { + const calls = allToolCalls(ctx.tools, 'acp_status') + const last = calls[calls.length - 1] + if (!last || last.seq < 0) return null + const compIdToBlock = new Map() + const absorbedParents = new Set() + const active = [] + for (const e of ctx.events) { + if (e.seq >= last.seq) break + if (e.type !== 'compaction/summary') continue + compIdToBlock.set(e.data?.compactionId, e.data?.kernelBlockId) + active.push(e.data?.kernelBlockId) + if ((e.data?.tier ?? 1) > 1) for (const p of e.data?.parentBlockIds ?? []) absorbedParents.add(p) + } + const absorbedBlocks = new Set([...absorbedParents].map((id) => compIdToBlock.get(id) ?? id)) + return { active: active.filter((id) => id && !absorbedBlocks.has(id)) } +} + +// ── fixture 生成(确定性:固定种子,两次运行内容一致、可 diff)────────── + +function mulberry32(seed) { + let a = seed >>> 0 + return () => { + a |= 0; a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const CJK_SENTENCES = [ + '上下文窗口是模型一次能读取的全部内容的上限,超过之后最早的内容就会被挤出。', + '主动上下文压缩的理念是由模型自己决定什么时候压缩、压缩哪些内容。', + '自动策略从不代替模型做总结,它只负责在合适的时机提醒模型注意上下文压力。', + '会话日志是一条只增不改的流水,压缩只是把一段原始内容替换成摘要节点。', + '被压缩的原始内容并没有消失,解压缩和全文检索都能从日志里把它们找回来。', + '每一条影子价记录都必须使用宿主侧的计价口径,否则计数会被悄悄透支。', + '压缩范围的两端如果已经被更早的压缩覆盖,引擎会把引用重映射到仍然存活的内容上。', + '工具调用的配对完整性比任何优化都重要,一条孤儿结果就能让下一次请求直接报错。', + '状态报告的价值在于让模型看见自己的上下文构成,从而做出更好的压缩决策。', + '检索命中会同时覆盖摘要与被遮蔽的原文,摘要里没有的细节依然可以按分数召回。', + '长会话里最贵的错误是压掉了后面还要逐字引用的内容,宁可晚压也不要误压。', + '每次压缩落盘的事件序列必须严格配对,悬挂的开启标记会让后续事务无法开启。', +] + +/** 生成一个约 targetChars 字符的中文 fixture(≈ targetChars 个 token,CJK 1 字/token)。 */ +export function makeCjkFixture(seed, targetChars, token = null) { + const rng = mulberry32(seed) + const lines = [] + let chars = 0 + let i = 0 + while (chars < targetChars) { + const s = CJK_SENTENCES[Math.floor(rng() * CJK_SENTENCES.length)] + lines.push(`${String(++i).padStart(3, '0')} ${s}`) + chars += s.length + 4 + } + if (token) lines.push(`本文件暗号:${token}`) + return lines.join('\n') + '\n' +} + +// ── 场景共用小工具 ────────────────────────────────────────────── + +const WINDOW = 16000 // 显式压小窗口(显式值优先于自动探测);45% nudge 线 = 7200 token +const FILE_CHARS = 4000 // 每个文件 ≈ 4000 token:读两三个文件就过 nudge 线 + +const windowPatch = [ + '- id: compaction-acp', + ' config:', + ` modelContextLimit: ${WINDOW}`, +].join('\n') + +function materializeFixtures(dir, count, seedBase, { token = false } = {}) { + const sub = join(dir, 'fixture') + mkdirSync(sub, { recursive: true }) + const paths = [] + const tokens = [] + for (let i = 1; i <= count; i++) { + const tok = token ? `ACPE2E-${seedBase}-${String(i).padStart(2, '0')}` : null + writeFileSync(join(sub, `a${i}.txt`), makeCjkFixture(seedBase * 100 + i, FILE_CHARS, tok)) + paths.push(`fixture/a${i}.txt`) + if (tok) tokens.push(tok) + } + return { paths, tokens } +} + +const readAll = (paths) => + `请用 read 工具依次完整读取以下 ${paths.length} 个文件(严格按顺序,每个都要读):${paths.join('、')}。` + +// ── 场景定义 ────────────────────────────────────────────── + +export const SCENARIOS = [ + { + id: 'S4-baseline', + set: 'smoke', weight: 5, + title: '空会话基线:极短任务不被 ACP 干扰', + issues: [], + patch: null, + fixtures: null, + task: '只回复两个字:正常', + assert(ctx) { + const results = integrityResults(ctx) + const n = ctx.comp.starts.length + ctx.comp.summaries.length + ctx.comp.prunes.length + ctx.comp.ends.length + results.push(ok('无任何 compaction 事件(低压会话不写压缩协议)', n === 0, `compaction 事件数=${n}`)) + return results + }, + }, + + { + id: 'S1-pressure', + set: 'full', weight: 30, + title: '自然压力路径:大工具输出过线后出现压缩事件', + issues: [], + patch: windowPatch, + fixtures(dir) { return materializeFixtures(dir, 6, 101) }, + task() { + return `${readAll(this.fixturesOf.paths)}全部读完后,简要汇报每个文件的行数。` + }, + assert(ctx) { + const results = [...integrityResults(ctx), ...shadowPriceResults(ctx)] + // 注:request/context 事件披露的是 provider 路由的窗口(如 1000000), + // 不是 modelContextLimit 配置值——配置杠杆只作用于引擎侧压力计算, + // 在层 A 日志里没有直接观测点(归层 B 看请求体)。首测教训:这里 + // 曾错误断言 contextWindow===16000。 + // 任务文本不提 compress:是否压缩是模型对自然 nudge 的自发响应 → 软。 + results.push( + soft('出现 compaction/summary(模型对自然 nudge 的自发响应)', ctx.comp.summaries.length >= 1, `summary=${ctx.comp.summaries.length}`), + ) + return results + }, + }, + + { + id: 'S2-search-after-compress', + set: 'full', weight: 45, + title: '压缩后检索:search_context 命中被遮蔽原文里的暗号', + issues: [], + patch: windowPatch, + fixtures(dir) { return materializeFixtures(dir, 6, 202, { token: true }) }, + task() { + return [ + readAll(this.fixturesOf.paths), + '读完后,用 compress 工具压缩前面已读完的文件内容(至少压缩一次)。', + `然后用 search_context 工具搜索暗号 ${this.fixturesOf.tokens[0]},把搜索到的暗号原文告诉我。`, + ].join('\n') + }, + assert(ctx) { + const results = [...integrityResults(ctx), ...shadowPriceResults(ctx)] + results.push(ok('出现 compaction/summary(任务明确指示压缩)', ctx.comp.summaries.length >= 1, `summary=${ctx.comp.summaries.length}`)) + const searches = allToolCalls(ctx.tools, 'search_context') + results.push(ok('发生了 search_context 调用(任务明确指示检索)', searches.length >= 1, `search 调用=${searches.length}`)) + const last = searches.length ? searches[searches.length - 1].resultText : '' + const token = this.fixturesOf.tokens[0] + // 结果文本带 160 字符 preview(src/tools.ts handleSearch)——暗号出现说明 + // 被遮蔽的原文进了检索文档集且排到了前面:这是 kernel+引擎的契约,硬断言。 + results.push(ok('检索命中暗号(被遮蔽原文仍可召回)', last.includes(token), last ? '结果含暗号' : '结果不含暗号')) + return results + }, + }, + + { + id: 'S3-batch-compress', + set: 'full', weight: 224, + title: '连续多次压缩:多笔事务全部合法', + issues: [], + patch: windowPatch, + fixtures(dir) { return materializeFixtures(dir, 4, 303) }, + task() { + return [ + readAll(this.fixturesOf.paths), + '每读完一个文件,就立即用 compress 工具把刚读完的内容压缩掉(要求至少完成 3 次 compress 调用,逐个进行)。', + '全部完成后,汇报每个文件的行数。', + ].join('\n') + }, + assert(ctx) { + const results = [...integrityResults(ctx), ...shadowPriceResults(ctx)] + results.push(ok('至少 2 次 compaction/summary(连续多笔事务)', ctx.comp.summaries.length >= 2, `summary=${ctx.comp.summaries.length}`)) + const compressCalls = allToolCalls(ctx.tools, 'compress') + results.push(soft('compress 调用 ≥ 3 次(指示值;少压只说明模型保守)', compressCalls.length >= 3, `compress=${compressCalls.length}`)) + return results + }, + }, + + { + id: 'S54-shadow-price', + set: 'smoke', weight: 32, + title: 'issue #54 回归:CJK 会话影子价不透支、投影不砖化', + issues: [54], + patch: windowPatch, + fixtures(dir) { return materializeFixtures(dir, 6, 404) }, + task() { + return [ + readAll(this.fixturesOf.paths), + '读完后用 compress 工具把已读完的文件内容压缩掉(至少一次)。', + '最后回复:done', + ].join('\n') + }, + assert(ctx) { + // S54 的核心就是 shadowPriceResults:CJK 重负载下影子价必须始终非负、 + // 投影绝不出现 "Too small" zod 拒绝(那意味着会话已砖化、后续 turn 全挂)。 + const results = [...integrityResults(ctx), ...shadowPriceResults(ctx)] + results.push(ok('出现 compaction/summary(CJK 重负载触发压缩)', ctx.comp.summaries.length >= 1, `summary=${ctx.comp.summaries.length}`)) + return results + }, + }, + + { + id: 'S47-status-all-blocks', + set: 'full', weight: 267, + title: 'issue #47/#48 回归:acp_status 列出全部块,而非只有最老 10 个', + issues: [47, 48], + patch: windowPatch, + fixtures(dir) { return materializeFixtures(dir, 4, 505) }, + task() { + return [ + readAll(this.fixturesOf.paths), + '每读完一个文件就立即用 compress 压缩它(4 个文件共 4 次)。', + '全部读完后,调用 acp_status 工具查看上下文构成,然后回复:ok', + ].join('\n') + }, + assert(ctx) { + const results = [...integrityResults(ctx), ...shadowPriceResults(ctx)] + results.push(ok('至少 2 次 compaction/summary', ctx.comp.summaries.length >= 2, `summary=${ctx.comp.summaries.length}`)) + const report = lastResultText(ctx, 'acp_status') + results.push(ok('acp_status 返回 CONTEXT BREAKDOWN 报告', report.includes('CONTEXT BREAKDOWN'), report ? '有报告' : '未调用 acp_status')) + // 时间对齐对照(activeBlocksAtCall):调用时刻的活跃块(扣除蒸馏吸收的 + // 父块)必须逐块出现在报告里——这正是 #47/#48(slice(0,10) 截断)的 + // 回归哨兵。首次实测即验证了该断言设计的必要性:模型压了 10 块并自发 + // 蒸馏出 tier-2 b6,报告"3 active"是正确答案而非回归。 + const at = activeBlocksAtCall(ctx) + if (!at) { + results.push(soft('调用了 acp_status(任务明确指示)', false, '未调用 acp_status')) + } else { + const header = report.match(/COMPRESSED BLOCKS — (\d+) active/) + const shown = new Set([...report.matchAll(/\bb\d+\b/g)].map((m) => m[0])) + const missing = at.active.filter((id) => !shown.has(id)) + results.push( + ok(`调用时刻的活跃块全部显示(${at.active.length} 个,已扣除蒸馏吸收)`, + Boolean(header) && Number(header?.[1]) === at.active.length && missing.length === 0, + `header=${header?.[1] ?? '无'} 活跃=${at.active.join(',') || '无'} 缺失=${missing.join(',') || '无'}`), + ) + } + results.push( + soft('模型自发做过 tier-2/3 蒸馏(超出指示的加分信号)', ctx.comp.summaries.some((e) => (e.data?.tier ?? 1) > 1), + `tier 分布=${ctx.comp.summaries.map((e) => e.data?.tier ?? 1).join(',')}`), + ) + results.push(soft('压缩数达到指示的 4 次', ctx.comp.summaries.length >= 4, `summary=${ctx.comp.summaries.length}`)) + return results + }, + }, + + { + id: 'S60-checkpoint-seqs', + set: 'smoke', weight: 27, + title: 'issue #60 回归:ACTIVE 块带 Checkpoint seqs 行(T2/T3 蒸馏入口)', + issues: [60], + patch: windowPatch, + fixtures(dir) { return materializeFixtures(dir, 2, 606) }, + task() { + return [ + readAll(this.fixturesOf.paths), + '读完后用 compress 工具压缩已读完的内容(至少一次),然后调用 acp_status 工具,最后回复:ok', + ].join('\n') + }, + assert(ctx) { + const results = [...integrityResults(ctx), ...shadowPriceResults(ctx)] + results.push(ok('出现 compaction/summary', ctx.comp.summaries.length >= 1, `summary=${ctx.comp.summaries.length}`)) + const report = lastResultText(ctx, 'acp_status') + results.push(ok('报告含 CONTEXT BREAKDOWN', report.includes('CONTEXT BREAKDOWN'), report ? '有报告' : '未调用 acp_status')) + results.push( + ok('ACTIVE 块带 Checkpoint seqs 行(蒸馏入口可见)', + report.includes('Checkpoint seqs (active blocks'), + report.includes('Checkpoint seqs') ? '有行' : '缺失(#60 P2 回归)'), + ) + return results + }, + }, + + { + id: 'S9-envelope-drilldown', + set: 'full', weight: 50, + title: 'rule 9 回归:wrapped {arguments} 信封不被静默吞参(drilldown 生效)', + issues: [9], + patch: windowPatch, + fixtures(dir) { return materializeFixtures(dir, 2, 707) }, + task() { + return [ + readAll(this.fixturesOf.paths), + '读完后用 compress 压缩已读完的内容(至少一次)。', + '然后调用 acp_status 工具,参数必须写成这样的包裹形态(顶层只有一个 arguments 键):', + '{"arguments": {"scope": "compressed"}}', + '调用后回复:ok', + ].join('\n') + }, + assert(ctx) { + const results = [...integrityResults(ctx), ...shadowPriceResults(ctx)] + results.push(ok('出现 compaction/summary', ctx.comp.summaries.length >= 1, `summary=${ctx.comp.summaries.length}`)) + // 首测教训:即使任务给了逐字参数形态,模型也不保证照做——"模型是否发出 + // wrapped 形态"是模型意愿,降级为软断言;信封链路的确定性回归归层 B + // (mock 直接控制调用形态,字节级断言 unwrapEnvelope 的行为)。 + const wrapped = allToolCalls(ctx.tools, 'acp_status').filter((t) => { + try { return t.arguments && 'arguments' in JSON.parse(t.arguments) } catch { return false } + }) + results.push( + soft('模型按指示发出 wrapped {arguments} 形态的调用', + wrapped.length >= 1, wrapped.length ? '有包裹调用' : '模型未按包裹形态调用(层 A 软断言)'), + ) + // 若模型确实发出了包裹调用,则拆解行为是引擎契约,硬断言: + const report = wrapped.length ? wrapped[wrapped.length - 1].resultText : '' + results.push( + ok('wrapped 调用被拆解并生效:drilldown 报告(含块段、无 Nudge 决策行)', + wrapped.length === 0 || (report.includes('COMPRESSED BLOCKS') && !/Nudge: (ACTIVE|idle) — /.test(report)), + wrapped.length ? `含块段=${report.includes('COMPRESSED BLOCKS')} 含Nudge行=${/Nudge: (ACTIVE|idle) — /.test(report)}` : '模型未发出包裹调用(软断言已记录)'), + ) + return results + }, + }, + + { + id: 'S35-mn-ref', + set: 'full', weight: 120, + title: 'issue #35 回归:drilldown 的 mN 引用可以直接作为 compress 边界', + issues: [35], + patch: windowPatch, + fixtures(dir) { return materializeFixtures(dir, 3, 808) }, + task() { + return [ + readAll(this.fixturesOf.paths), + '第一步:用 compress 压缩 a1 相关的内容(至少一次)。', + '第二步:调用 acp_status 工具,参数为 {"scope": "compressed"},在 drilldown 报告里找到仍存活消息行的 mN 编号。', + '第三步:再次调用 compress,content 里那一项的 startSeq 和 endSeq 就填那些 mN 编号(把 a2 相关的消息压掉)。', + '完成后回复:ok', + ].join('\n') + }, + assert(ctx) { + const results = [...integrityResults(ctx), ...shadowPriceResults(ctx)] + // mN 引用若解析失败,handleCompress 会拒绝且不会落盘第二笔事务—— + // 所以"第二笔 summary 存在"就是 mN 链路修好的 observable 证明。 + results.push(ok('至少 2 次 compaction/summary(第二次经 mN 引用落盘)', ctx.comp.summaries.length >= 2, `summary=${ctx.comp.summaries.length}`)) + const mnCalls = allToolCalls(ctx.tools, 'compress').filter((t) => /"m\d+/.test(t.arguments) || /\bm\d+\b/.test(t.arguments)) + results.push( + soft('第二次 compress 的参数里出现 mN 引用', mnCalls.length >= 1, mnCalls.length ? '有 mN 参数' : '模型用了其他引用形态'), + ) + return results + }, + }, +] + +/** 按 id 挑选场景:ACP_E2E_SCENARIOS=S4-baseline,S54-shadow-price。 */ +export function selectScenarios(filter) { + if (!filter) return SCENARIOS + const wanted = new Set(String(filter).split(',').map((s) => s.trim()).filter(Boolean)) + return SCENARIOS.filter((s) => wanted.has(s.id)) +} + +/** 按集合挑选:'smoke'(默认,日常跑)| 'full'/'all'(全量)。 */ +export function scenariosForSet(set) { + if (set === 'full' || set === 'all') return SCENARIOS + return SCENARIOS.filter((s) => s.set === 'smoke') +}