feat(tester): initialize agent target from Orca selectors

This commit is contained in:
2026-10-12 01:14:52 +08:00
parent 5ba1d8e3d6
commit a35cc916cb
9 changed files with 688 additions and 10 deletions
+15 -2
View File
@@ -17,6 +17,9 @@
- 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`;模板默认 - 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`;模板默认
`HOST: "127.0.0.1"`。如果目标不是本机,显式改成目标地址。也可以用环境变量 `HOST: "127.0.0.1"`。如果目标不是本机,显式改成目标地址。也可以用环境变量
`HX_TEST_HOST` 覆盖。 `HX_TEST_HOST` 覆盖。
- 测试默认在当前项目执行。也可以直接提供 Orca project id、项目名称或 terminal id;
tester 会自动解析 Agent、环境、项目和目录,写入 `local.yaml.agent`,并输出更新后的配置列表。
每轮测试会创建新的 terminal,结束后自动关闭;不会复用或关闭已有 terminal。
- 会动硬件、会改数据的用例带风险级别(`readonly` / `mutating` / `destructive`)。默认只跑最低档 `readonly`。要跑更高风险,需要你明确说,例如「风险到 mutating」;碰哪个子系统另由 `gate`(如 hardware / weld)控制。 - 会动硬件、会改数据的用例带风险级别(`readonly` / `mutating` / `destructive`)。默认只跑最低档 `readonly`。要跑更高风险,需要你明确说,例如「风险到 mutating」;碰哪个子系统另由 `gate`(如 hardware / weld)控制。
## 使用示例 ## 使用示例
@@ -33,6 +36,14 @@
跑 INF 分类的 smoke 跑 INF 分类的 smoke
``` ```
```text
用 terminal term_2cfb291c-8769-4613-af52-1fcc2f209eaa 的环境跑 iOS smoke,先把 Agent 配置列出来确认
```
```text
用 Orca project temp-macmini 跑移动测试,自动初始化 tester Agent 配置并先确认
```
```text ```text
跑 TC-CAM-001,TC-CAM-005,风险保持默认 跑 TC-CAM-001,TC-CAM-005,风险保持默认
``` ```
@@ -41,9 +52,11 @@
1. 用脚本读 `.pouch/tester/`,按分类统计或筛出你点名的用例,不会把整库塞进对话。 1. 用脚本读 `.pouch/tester/`,按分类统计或筛出你点名的用例,不会把整库塞进对话。
2. 改用例前先给你看将要写入的内容,你确认再保存。 2. 改用例前先给你看将要写入的内容,你确认再保存。
3. 执行时:脚本类直接跑命令;浏览器和移动模拟器先初始化一次,同一轮里复用。 3. 执行时:先解析并更新 Agent 配置,输出生效列表,再准备 Orca 项目并创建临时 terminal;脚本类、
浏览器和移动模拟器都在该 Agent 环境中执行。
4. 超出你允许的风险级别会跳过,不会偷偷执行。 4. 超出你允许的风险级别会跳过,不会偷偷执行。
5. 跑完给出按分类计数的结果,以及 `pass / fail / environment-fail / skipped / not-run`。 5. 无论测试成功、失败或异常,都会关闭本轮创建的 terminal;跑完给出按分类计数的
结果,以及 `pass / fail / environment-fail / skipped / not-run`。
## 如何判断完成 ## 如何判断完成
+13 -4
View File
@@ -2,9 +2,10 @@
name: tester name: tester
description: >- description: >-
维护并执行项目黑盒测试目录(.pouch/tester):列出/增改用例,按分类或 维护并执行项目黑盒测试目录(.pouch/tester):列出/增改用例,按分类或
id 跑脚本、浏览器或移动模拟器。触发词:/tester、列出测试、完善测试用例、 id 跑脚本、浏览器或移动模拟器;支持用 Orca project id、项目名称或 terminal id
按分类测、跑 smoke、现场复测。默认风险最低档;local 配置的 HOST 默认是 定位 Agent 执行环境并创建临时 terminal。触发词:/tester、列出测试、完善测试用例、
127.0.0.1。 按分类测、跑 smoke、现场复测。
默认风险最低档;local 配置的 HOST 默认是 127.0.0.1。
--- ---
# Tester:项目测试目录 # Tester:项目测试目录
@@ -19,6 +20,10 @@ python3 <skill-dir>/scripts/catalog.py <list|select|show|validate|next-id|run-sc
--root <project-root> [参数] --root <project-root> [参数]
``` ```
运行目标可用 `resolve_agent.py` 按 Orca project id/名称或 terminal id 自动解析并写入
`.pouch/tester/local.yaml` 的 `agent` 配置;配置更新完成后输出生效配置列表。该列表是
更新结果,不是测试启动前的人工确认门槛。
## 选择模式 ## 选择模式
- 还没有 `.pouch/tester/catalog.yaml`,或用户说初始化 tester:执行「初始化」。 - 还没有 `.pouch/tester/catalog.yaml`,或用户说初始化 tester:执行「初始化」。
@@ -38,7 +43,11 @@ python3 <skill-dir>/scripts/catalog.py <list|select|show|validate|next-id|run-sc
- `driver=browser` 必须开真实页面;`driver=mobile` 必须挂模拟器 session。二者都不得改成只打 API。 - `driver=browser` 必须开真实页面;`driver=mobile` 必须挂模拟器 session。二者都不得改成只打 API。
- `automation=assisted|manual` 需要人在场:无人值守时记 `not-run`,不要自己代跑人工步骤。见 [concepts.md](references/concepts.md)。 - `automation=assisted|manual` 需要人在场:无人值守时记 `not-run`,不要自己代跑人工步骤。见 [concepts.md](references/concepts.md)。
- 浏览器、移动模拟器按 catalog `sessions` 初始化一次,同一次运行内复用。 - 浏览器、移动模拟器按 catalog `sessions` 初始化一次,同一次运行内复用。
- Agent、环境、项目目录和 Orca terminal 生命周期见 [execution.md](references/execution.md):可以从 project id/名称或 terminal id 自动解析,更新后输出配置列表;每轮都新建 terminal,结束后关闭本轮创建的 terminal,不复用或关闭已有 terminal。
- `HOST` 只表示被测服务地址;远程执行目标使用 `agent` 配置。目标目录必须已有项目内容,找不到 Orca 项目记录时直接登记已有目录;目录本身不存在时停止,不创建空项目。
- 环境失败(连不上、缺浏览器、下游超时、session 挂了)不是产品失败。 - 环境失败(连不上、缺浏览器、下游超时、session 挂了)不是产品失败。
- 移动端运行必须先通过 [run.md](references/run.md) 的平台环境闸门;Android 与 iOS 分开探测。目标平台没有可用设备/模拟器时,先停止移动用例,记 `environment-fail`,并按 run.md 的模板引导用户补齐环境,不能只报一个失败状态。
- 环境引导必须写明平台、具体缺失项、用户可执行的修复动作、验证命令和重跑方式。不得用 API 调用替代真实 Android/iOS session,也不得把环境缺失判成产品 `fail`。
- 不改产品代码,不提交,不把成绩单当操作手册。 - 不改产品代码,不提交,不把成绩单当操作手册。
- 禁止把完整 `cases/` 贴进 prompt。筛选用 `select`,单条用 `show`。 - 禁止把完整 `cases/` 贴进 prompt。筛选用 `select`,单条用 `show`。
@@ -91,4 +100,4 @@ python3 <skill-dir>/scripts/catalog.py show --root <project-root> --id <CASE_ID>
## 运行 ## 运行
读 [run.md](references/run.md)。先 `select` 得到命中列表,再按 driver 分流。未指定范围时等同「tag=smoke 且风险上限=默认且 automation=automated」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。 读 [run.md](references/run.md) 和 [execution.md](references/execution.md)。先 `select` 得到命中列表,再按 agent 解析目标项目、目录和 Agent,更新配置并输出生效列表,随后创建临时 terminal 并按 driver 分流。未指定范围时等同「tag=smoke 且风险上限=默认且 automation=automated」。`select` 的 `run_together` 组按 [run.md](references/run.md) 整组执行。移动端还要先识别目标平台并完成 Android 或 iOS 环境闸门;闸门未通过时输出对应的用户引导,不继续猜测或代跑。无论运行结果如何,结束前关闭本轮创建的 terminal。
+176
View File
@@ -0,0 +1,176 @@
# Tester Agent 执行目标
只在用户要运行测试时读取本文件。测试目录仍由发起运行的项目决定;`execution`
不再单独作为终端配置。`agent` 统一决定使用哪个 Agent、在哪个 Orca 环境、哪个
项目/目录中执行。
## 配置
`catalog.yaml` 提供可提交的默认值,`.pouch/tester/local.yaml` 可以覆盖同名字段。
机器名、远程环境、项目路径和 Agent 选择优先写在 `local.yaml`,不要把个人机器路径
或临时 Agent 选择提交到项目。
```yaml
agent:
environment: "" # Orca 保存的远程环境;空值表示当前环境
host: local # 该环境中的目标 host
project: "" # Orca project id 或名称;可由 terminal id 反查填入
path: "" # 目标机器上的项目绝对路径;为空表示当前项目
role: test # tester 固定为 test
cli: codex # codex/cursor-agent/grok/omp/pi
tier: standard # standard/strong
model: null # 精确模型 ID;null 表示使用该 CLI 的默认模型
reasoningEffort: medium # low/medium/high/xhigh 或 null
permissionMode: workspace-write # read-only/workspace-write
```
`HOST` 仍然是被测服务地址,不能用来表示执行机器。执行目标没有配置时,使用当前
项目根目录和默认 test Agent,但仍然创建一个全新的 terminal;不查找、不复用已有
terminal。
旧版 `execution` 配置仍可读取并迁移为 `agent` 的环境、host、path 字段,但新配置
不再保存自由 `command` 或 `wait`。Agent CLI 的启动参数由 tester/Orca 的可信映射
生成,不能从项目文本中拼接 shell 命令。
## 解析 Agent、项目和目录
用户可以直接给 tester 以下任一种定位信息:
- Orca project id 或项目名称;
- Orca terminal id,例如 `term_...`;
- 不提供定位信息,使用当前项目。
有定位信息时优先使用解析脚本,避免手工拼接 Orca JSON:
```bash
python3 <skill-dir>/scripts/resolve_agent.py \
--root <project-root> --terminal <terminal-handle> [--environment <name>] --write
python3 <skill-dir>/scripts/resolve_agent.py \
--root <project-root> --project <project-id-or-name> [--environment <name>] --write
python3 <skill-dir>/scripts/resolve_agent.py \
--root <project-root> --project <project-id-or-name> \
--environment <name> --path <absolute-target-path> --write
```
脚本会跨已保存的 environment 查询 terminal/project,写入
`.pouch/tester/local.yaml` 的 `agent` mapping,并打印配置更新列表。没有 `--write` 时只解析
和预览,适合先检查结果。
1. 如果给出 terminal id,枚举当前和已保存的 Orca environment,调用
`orca terminal show --environment <env> --terminal <handle> --json`。从结果读取
`executionHostId`、`worktreeId`、`worktreePath`、`agentIdentity`,terminal id 只
用于定位,不能作为本轮执行 terminal 复用。
2. 如果给出 project id/名称,枚举对应 environment 的 project 和 worktree,先精确匹配
project id/name,再精确匹配 worktree id/name/path。一个项目有多个 worktree 时,
展示候选并停下;用户可以补充 `--path` 或改用 terminal id,不按分支或路径猜测。
3. 将解析结果写入 `local.yaml.agent`。保留用户已有的 `HOST`、授权和其它字段;只
更新 agent 配置。terminal 推导出的 `cli` 只在 metadata 明确提供
`agentIdentity` 时覆盖已有值。模型、reasoning、权限等无法从 Orca 证明的字段,
优先保留已有配置,否则使用 tester 模板默认值,并在更新列表中明确展示;不能把它们
伪装成 terminal 的真实 metadata。
4. `path` 为空时解析为发起运行的项目根目录;配置了远程 environment 时,必须解析
为该远程环境上的绝对路径。
5. 用 Orca 查询目标项目/worktree 是否已注册,匹配必须是
`environment + host + 规范化 path` 的精确匹配,不得按标题、分支或模糊路径猜测。
6. 找不到 Orca 项目记录时,直接为该已有目录建立项目记录:
- 当前环境的 Git 目录:`orca repo add --path <path> --json`
- 保存的远程环境:在命令上加 `--environment <agent.environment>`。
`host` 不是当前环境的 local 时,先用 `orca host list --json` 确认目标主机;如果
该目录只能通过已有 project setup 导入,则使用 `orca project setup-existing-folder`
为该 project/host 建立 setup,再重新查询 worktree。不能把一个 local worktree
当成远程 host 的项目使用。
之后重新查询并取得 `worktree` selector。项目准备失败要记为
`environment-fail`,不要继续创建 terminal。
这里的“没有项目就创建”指创建/登记 Orca 的项目记录,不是删除或重建用户目录,
也不是创建新的 Git worktree。目标目录必须已经存在且包含待测项目;如果目录本身
不存在,停止并说明需要先在目标机器准备项目。
## 配置更新结果
完成解析和 `local.yaml.agent` 初始化后,立即向用户展示更新后的生效配置列表。
这是配置更新结果,不是测试启动前的人工确认门槛。列表至少包含:
```text
## tester Agent 配置更新结果
来源: terminal:<handle> | project:<selector> | current-project
Agent: <cli>
角色 / 档位: test / standard
模型: <model 或 n/a>
推理强度: <reasoningEffort 或 n/a>
权限: <read-only | workspace-write>
Orca 环境: <environment>
执行节点: <host>
项目: <project id>(<项目名称>)
目录: <absolute path>
Worktree: <worktree id>
Terminal: 每轮新建,结束后关闭,不复用当前 terminal
以上是本次更新后的 Agent 生效配置;如需调整,直接修改 `local.yaml.agent` 后重新运行 tester。
```
只有项目或目录存在多个候选时才标记 `待确认` 并停下,要求补充 `--path` 或 terminal
id,不能按分支或路径猜测。配置更新本身不创建 terminal,也不产生测试副作用。
## Terminal 生命周期
每次 tester 运行只创建一个新的 terminal,整轮用完后关闭。不能为了方便复用已有
terminal,也不能关闭运行开始前已经存在的 terminal。
创建命令的形状如下。`<worktree-selector>` 必须是上一步按环境、主机和路径精确解析
出的 worktree selector;不要直接用可能跨机器冲突的 `path:<target-path>` 猜测。
Agent 的 CLI、模型、推理强度和权限必须来自 `agent` 的结构化字段,由 Orca/Agent
启动映射转换为实际启动参数;不要把自由 `command`、argv 或 shell 片段写入配置。
如果当前 Orca 版本只有 `orca terminal create --command` 接口,也只能使用 skill
维护的 CLI allowlist 映射生成这个参数,不能把配置中的任意字符串直接作为命令执行。
从 JSON 读取返回的 `handle`,后续只使用这个 handle;不要重新 list 后凭预览选择
terminal。
- 脚本、浏览器、移动模拟器都交给新建的 test Agent;发送简短的用例范围和执行要求,
目标 Agent 在目标目录读取 tester catalog。不要把完整 `cases/` 塞进 prompt。
- `permissionMode=read-only` 只能执行不产生测试产物的用例;需要写运行快照、构建或
设备状态变更时使用 `workspace-write`,但仍受 tester 的风险授权约束。
- terminal 创建成功后,无论测试通过、失败、超时、用户中断还是读取输出失败,
都必须执行清理:
```text
orca terminal close --terminal <created-handle> --json
```
`close` 是删除本次临时 terminal 的操作,不是删除项目或 worktree。清理失败时
保留 handle,向用户报告 `cleanup-fail`,不得换一个 handle 再关闭。
如果创建失败,没有 terminal 需要清理,直接报告 `environment-fail`。如果创建成功
但中途出现异常,把清理放在 finally 语义的收尾步骤中,再生成运行记录。
## 运行记录
在运行快照顶层保存本次 Agent 执行目标,至少包含:
```yaml
agent:
environment: ""
host: local
project: "..."
path: /absolute/project/path
role: test
cli: codex
tier: standard
model: null
reasoningEffort: medium
permissionMode: workspace-write
worktree_id: "..."
terminal_handle: "..."
terminal_created: true
terminal_closed: true
```
不要把 token、配对码或完整环境变量写入快照。terminal 输出只保留能支持结果判定
的摘要;测试结果仍按主运行规范使用 `pass`、`fail`、`environment-fail`、`skipped`
和 `not-run`。
+82 -1
View File
@@ -2,6 +2,20 @@
只在用户要跑测试时读本文件。 只在用户要跑测试时读本文件。
Agent、执行目标和临时 terminal 的完整协议见 [execution.md](execution.md)。每轮运行
都要先解析并更新 `agent`,输出生效配置列表,准备目标项目并创建一个全新的 terminal;不能复用
已有 terminal。
测试结束、失败、超时或中断时,都要关闭本轮创建的 terminal。
## 0. 准备执行目标
先按 `execution.md` 处理目标机器和项目目录,输出配置更新结果,再进入测试流程。目标项目必须包含
可读取的 `.pouch/tester/`;如果目标目录没有测试目录,记为 `environment-fail`,不要
偷偷回退到当前项目,也不要把测试文件复制过去。
`catalog.py select` 输出的 `agent` 是本轮使用的配置快照。记录中要保留 Agent、目标
环境、主机、项目、路径、worktree id、terminal handle,以及 terminal 是否成功关闭。
## 1. 选范围 ## 1. 选范围
未指定范围 → `--tag smoke`,风险上限 = `default_max_risk`,`automation` 默认只跑 `automated`。 未指定范围 → `--tag smoke`,风险上限 = `default_max_risk`,`automation` 默认只跑 `automated`。
@@ -42,6 +56,66 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
- 探测不可达:用它的分类标 environment-fail,其余分类照跑。 - 探测不可达:用它的分类标 environment-fail,其余分类照跑。
- 命中 `stop_if`:把用它的分类标 environment-fail 并记 `observed` 值,其余分类照跑;所有选中分类都被挡住时,整轮结论才是 environment-fail。 - 命中 `stop_if`:把用它的分类标 environment-fail 并记 `observed` 值,其余分类照跑;所有选中分类都被挡住时,整轮结论才是 environment-fail。
## 2.1 移动端平台环境闸门与用户引导
`driver=mobile` 不是一个可以互换的平台。运行前必须根据 catalog session、用例变量或用户指定,明确本轮是 Android 还是 iOS;无法判断时先停下询问平台,不得猜测。两类平台分别执行以下闸门:
### Android
按顺序确认:
1. `adb` 可执行,且版本与当前 SDK 配置一致。
2. `adb devices -l` 至少有一个状态为 `device` 的目标;`unauthorized`、`offline` 或空列表都不能继续。多个目标时必须显式设置 `ANDROID_SERIAL`。
3. 被测 APK 已安装,包名与 catalog 的变量一致;可用 `adb -s "$ANDROID_SERIAL" shell pm path "$PACKAGE"` 验证。
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从设备访问。
### iOS
按顺序确认:
1. 运行节点是 macOS,并且 `xcrun`、Xcode Command Line Tools 和 `simctl` 可用。Linux/Windows 节点不能伪装成 iOS 环境;需要 macOS 或可访问的远程 Mac。
2. 模拟器用 `xcrun simctl list devices available` 确认目标存在且已启动,再用 `xcrun simctl bootstatus <UDID> -b` 确认完成启动;真机用 `xcrun xctrace list devices` 确认已连接、已信任且允许开发。
3. 被测 IPA/App 已安装,Bundle ID 与 catalog 的变量一致;模拟器可用 `xcrun simctl get_app_container <UDID> <BUNDLE_ID>` 验证。
4. 测试所需的 HOST、端口、局域网/DNS 和权限可从该模拟器/真机访问。
任一项不满足,都要在执行记录中把受影响的移动用例标为 `environment-fail`,不能改成 API 测试、不能把它们标成产品 `fail`,也不要继续初始化后续移动 session。记录实际探测命令及简短输出,不要把整段设备日志塞进对话。
### 环境缺失时的用户引导
对话中必须输出一段可执行的引导,至少包含以下字段:
```text
## tester 环境引导:需要补齐
平台: Android | iOS
状态: environment-fail
缺失: <具体探测项和观测结果>
请完成:
1. <修复动作;不要只写“检查设备”>
2. <如需连接/启动/安装,写出动作>
验证:
<一条或多条可复制的验证命令>
完成后: 回复“环境已就绪”,然后重跑原 tester 命令。
```
可按实际缺失替换为以下平台化提示:
- Android 无设备:提示用户连接并解锁设备、开启 USB 调试、接受 RSA 授权;多个设备时设置 `ANDROID_SERIAL`;再运行 `adb devices -l`,直到目标状态为 `device`。
- Android 未安装 APK:提示用户构建/安装与当前测试环境匹配的 APK,并用 `adb -s "$ANDROID_SERIAL" shell pm path "$PACKAGE"` 验证包名。
- iOS 非 macOS 或无 Xcode:明确说明 iOS 测试需要 macOS + Xcode/Command Line Tools,提示用户切换到本机 Mac 或配置可访问的远程 Mac;不要建议在 Linux 上安装一个同名命令来绕过检查。
- iOS 模拟器未启动:提示用户在 Xcode 或 `simctl` 中创建/启动可用设备,使用 `xcrun simctl bootstatus <UDID> -b` 验证。
- iOS 真机未就绪:提示用户连接数据线、解锁并信任此电脑、开启开发者模式并确认设备出现在 `xcrun xctrace list devices`。
以下是两个可复现的引导示例,测试 skill 行为时应得到同类结果:
1. Android:`adb devices -l` 只有 `List of devices attached`。期望状态为 `environment-fail`,引导连接/授权设备并给出 `adb devices -l` 验证命令。
2. iOS:Linux 节点没有 `xcrun`。期望状态为 `environment-fail`,引导切换到 macOS + Xcode 节点,并说明不能用 API 或 Android session 代替。
环境引导只负责让用户补齐前置条件;除 catalog 已明确授权的 session 初始化外,不自动安装 SDK、配对真机、修改系统安全设置或上传产物。
## 3. Session ## 3. Session
看命中条用到的 `session` 键: 看命中条用到的 `session` 键:
@@ -71,7 +145,11 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
python3 <skill-dir>/scripts/catalog.py run-script --root <root> --id <id> python3 <skill-dir>/scripts/catalog.py run-script --root <root> --id <id>
``` ```
`run-script` 只跑命令,返回 `executed` + stdout/exit(或 skipped / environment-fail)。**pass/fail 仍由 Agent 对照 `expected` 判定**,不要把 curl 退出码 0 当成产品通过。 这个命令必须发送到本轮创建的 terminal 中执行;`<root>` 是目标机器上的项目根,
不是发起端当前目录。远程目标上的 skill 脚本不可用时,直接在该 terminal 中执行
`how.run` 展开后的命令,并保留同样的 stdout/exit 证据。`run-script` 只跑命令,返回
`executed` + stdout/exit(或 skipped / environment-fail)。**pass/fail 仍由 Agent 对照
`expected` 判定**,不要把 curl 退出码 0 当成产品通过。
没有 `run`:按 `how.steps` 做,证据对照 `expected`。不要发明 steps 里没有的调用。 没有 `run`:按 `how.steps` 做,证据对照 `expected`。不要发明 steps 里没有的调用。
@@ -100,6 +178,9 @@ python3 <skill-dir>/scripts/generate_run_csv.py --root <root> --run <yaml>
范围: … 范围: …
风险上限: … 风险上限: …
session: browser=复用|新开|未用;mobile=… session: browser=复用|新开|未用;mobile=…
Agent: <cli> / <model> / <permissionMode>
执行目标: <environment>/<host>:<project>:<path>
terminal: 新建 <handle>,已关闭|清理失败
计数: pass=N fail=N environment-fail=N skipped=N not-run=N 计数: pass=N fail=N environment-fail=N skipped=N not-run=N
失败: id + 一句证据 失败: id + 一句证据
跳过: id + 原因 跳过: id + 原因
+27 -2
View File
@@ -5,8 +5,8 @@
项目根 `.pouch/tester/`: 项目根 `.pouch/tester/`:
``` ```
catalog.yaml 分类、风险阶梯、session、变量 catalog.yaml 分类、风险阶梯、session、变量、默认 Agent 目标
local.yaml.example HOST 与授权开关模板(默认 127.0.0.1) local.yaml.example HOST、授权开关和 Agent 目标模板(默认 127.0.0.1)
local.yaml 本机覆盖,不提交 local.yaml 本机覆盖,不提交
cases/<category>.yaml 一个分类一个文件(执行源) cases/<category>.yaml 一个分类一个文件(执行源)
cases.csv 给人看的扁平清单;只由 cases/*.yaml 生成,不包含运行结果 cases.csv 给人看的扁平清单;只由 cases/*.yaml 生成,不包含运行结果
@@ -33,6 +33,31 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对
`127.0.0.1`;远程目标必须显式覆盖。查找顺序:环境变量 > `local.yaml` > `127.0.0.1`;远程目标必须显式覆盖。查找顺序:环境变量 > `local.yaml` >
`catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。 `catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。
## agent
`catalog.yaml.agent` 是可提交的默认 Agent 目标,`local.yaml.agent` 对其做浅层覆盖。
它不参与 `HOST` 变量解析,也不会把远程机器路径写入环境变量。机器路径和临时 Agent
选择优先写在不提交的 `local.yaml`。
| 字段 | 含义 |
|------|------|
| `environment` | Orca 保存的远程环境选择器;空值使用当前环境 |
| `host` | 目标机器标识,默认 `local` |
| `project` | Orca project id 或名称;也可由 terminal id 自动解析 |
| `path` | 目标机器上的项目路径;空值表示当前项目根目录 |
| `role` | Agent 角色,tester 固定为 `test` |
| `cli` | `codex` / `cursor-agent` / `grok` / `omp` / `pi` |
| `tier` | `standard` / `strong` |
| `model` | 精确模型 ID;`null` 表示使用 CLI 默认模型 |
| `reasoningEffort` | `low` / `medium` / `high` / `xhigh` 或 `null` |
| `permissionMode` | `read-only` / `workspace-write` |
用户给 project id、项目名称或 terminal id 时,tester 先解析并写入 `local.yaml.agent`,
再展示更新后的 Agent/环境/项目/目录生效列表。每轮创建新 terminal,不复用既有
terminal;运行结束用 `orca terminal close --terminal <handle>` 关闭本轮创建的 terminal。
运行快照保存解析后的 Agent、目标、worktree 和 terminal handle。旧版 `execution` 字段
只作为兼容输入读取,不再作为新配置写出。详见 [execution.md](execution.md)。
运行记录使用 UTC 时间加随机后缀命名,例如 运行记录使用 UTC 时间加随机后缀命名,例如
`20260920T101530Z-smoke-a13f9c.yaml`,并在 YAML 顶层保存同一个 `runId`。 `20260920T101530Z-smoke-a13f9c.yaml`,并在 YAML 顶层保存同一个 `runId`。
不要使用固定的 `latest.yaml` 或覆盖已有运行文件。 不要使用固定的 `latest.yaml` 或覆盖已有运行文件。
+88 -1
View File
@@ -37,6 +37,18 @@ EXPECTED_KINDS = (
"url", "url",
"interaction", "interaction",
) )
AGENT_DEFAULTS = {
"environment": "",
"host": "local",
"project": "",
"path": "",
"role": "test",
"cli": "codex",
"tier": "standard",
"model": None,
"reasoningEffort": "medium",
"permissionMode": "workspace-write",
}
def catalog_dir(root: Path) -> Path: def catalog_dir(root: Path) -> Path:
@@ -84,6 +96,41 @@ def load_local(root: Path) -> dict[str, Any]:
return data return data
def resolve_agent(root: Path, meta: dict[str, Any]) -> dict[str, Any]:
"""Resolve the structured agent target used by one tester run.
Stable defaults may live in catalog.yaml. Machine-specific values belong in
local.yaml and override the mapping shallowly. The old execution mapping is
accepted as a compatibility input and is projected onto the agent target.
"""
agent: dict[str, Any] = dict(AGENT_DEFAULTS)
local = load_local(root)
found_agent = False
for source_name, source in (
("catalog.agent", meta.get("agent")),
("local.agent", local.get("agent")),
):
if source is None:
continue
found_agent = True
if not isinstance(source, dict):
raise SystemExit(f"{source_name} 必须是 mapping")
agent.update(source)
if not found_agent:
for source_name, source in (
("catalog.execution", meta.get("execution")),
("local.execution", local.get("execution")),
):
if source is None:
continue
if not isinstance(source, dict):
raise SystemExit(f"{source_name} 必须是 mapping")
for key in ("environment", "host", "path"):
if key in source:
agent[key] = source[key]
return agent
def case_files(root: Path) -> list[Path]: def case_files(root: Path) -> list[Path]:
d = catalog_dir(root) / "cases" d = catalog_dir(root) / "cases"
if not d.is_dir(): if not d.is_dir():
@@ -143,7 +190,7 @@ def resolve_vars(root: Path, meta: dict[str, Any]) -> dict[str, str]:
merged[str(key)] = "" if value is None else str(value) merged[str(key)] = "" if value is None else str(value)
local = load_local(root) local = load_local(root)
for key, value in local.items(): for key, value in local.items():
if value is None: if value is None or isinstance(value, (dict, list)):
continue continue
merged[str(key)] = str(value).lower() if isinstance(value, bool) else str(value) merged[str(key)] = str(value).lower() if isinstance(value, bool) else str(value)
auth_keys = set() auth_keys = set()
@@ -338,6 +385,44 @@ def probe_errors(where: str, probe: Any) -> list[str]:
return errors return errors
def agent_errors(root: Path, meta: dict[str, Any]) -> list[str]:
errors: list[str] = []
try:
agent = resolve_agent(root, meta)
except SystemExit as exc:
return [str(exc)]
for key in (
"environment",
"host",
"project",
"path",
"role",
"cli",
"tier",
"model",
"reasoningEffort",
"permissionMode",
):
if key not in agent:
errors.append(f"agent.{key} 缺失")
for key in ("environment", "host", "project", "path", "role", "cli", "tier", "permissionMode"):
if not isinstance(agent.get(key), str):
errors.append(f"agent.{key} 必须是字符串")
if agent.get("model") is not None and not isinstance(agent.get("model"), str):
errors.append("agent.model 必须是字符串或 null")
if agent.get("role") != "test":
errors.append("agent.role 必须是 test")
if agent.get("cli") not in ("codex", "cursor-agent", "grok", "omp", "pi"):
errors.append("agent.cli 只能是 codex/cursor-agent/grok/omp/pi")
if agent.get("tier") not in ("standard", "strong"):
errors.append("agent.tier 只能是 standard 或 strong")
if agent.get("reasoningEffort") not in (None, "low", "medium", "high", "xhigh"):
errors.append("agent.reasoningEffort 只能是 low/medium/high/xhigh 或 null")
if agent.get("permissionMode") not in ("read-only", "workspace-write"):
errors.append("agent.permissionMode 只能是 read-only 或 workspace-write")
return errors
def run_together_groups(meta: dict[str, Any]) -> list[dict[str, Any]]: def run_together_groups(meta: dict[str, Any]) -> list[dict[str, Any]]:
raw = meta.get("run_together") or [] raw = meta.get("run_together") or []
if not isinstance(raw, list): if not isinstance(raw, list):
@@ -505,6 +590,7 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
"skipped": skipped, "skipped": skipped,
"run_together": together, "run_together": together,
"status_probe": status_probes, "status_probe": status_probes,
"agent": resolve_agent(root, meta),
"cases": selected, "cases": selected,
}, },
ensure_ascii=False, ensure_ascii=False,
@@ -533,6 +619,7 @@ def validate(root: Path) -> list[str]:
meta = load_catalog_meta(root) meta = load_catalog_meta(root)
except SystemExit as exc: except SystemExit as exc:
return [str(exc)] return [str(exc)]
errors.extend(agent_errors(root, meta))
cats = meta.get("categories") cats = meta.get("categories")
if not isinstance(cats, list): if not isinstance(cats, list):
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""Resolve an Orca project or terminal into tester's structured agent config."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError: # pragma: no cover
print("需要 PyYAML: python3 -m pip install pyyaml", file=sys.stderr)
raise SystemExit(2)
SUPPORTED_CLI = {"codex", "cursor-agent", "grok", "omp", "pi"}
def run_orca(args: list[str], environment: str | None = None) -> dict[str, Any]:
command = ["orca", *args]
if environment and environment != "local":
command.extend(["--environment", environment])
command.append("--json")
proc = subprocess.run(command, capture_output=True, text=True, check=False)
raw = (proc.stdout or "").strip()
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
detail = (proc.stderr or raw or str(exc)).strip()
raise RuntimeError(f"orca 命令没有返回 JSON: {' '.join(command)}\n{detail}") from exc
if not data.get("ok"):
error = data.get("error") or {}
message = error.get("message") or error.get("code") or "unknown error"
raise RuntimeError(f"{' '.join(command)}: {message}")
result = data.get("result")
return result if isinstance(result, dict) else {}
def environments() -> list[str]:
result = run_orca(["environment", "list"])
names = ["local"]
for item in result.get("environments") or []:
if isinstance(item, dict) and item.get("name"):
names.append(str(item["name"]))
return names
def worktree_from_terminal(terminal: dict[str, Any], environment: str) -> dict[str, Any]:
worktree_id = str(terminal.get("worktreeId") or "")
if not worktree_id:
raise RuntimeError("terminal 没有 worktreeId")
result = run_orca(["worktree", "show", "--worktree", worktree_id], environment)
worktree = result.get("worktree")
if not isinstance(worktree, dict):
raise RuntimeError("找不到 terminal 对应的 worktree")
return worktree
def config_from_terminal(handle: str, requested_environment: str | None) -> dict[str, Any]:
candidates = [requested_environment] if requested_environment else environments()
errors: list[str] = []
for environment in candidates:
if not environment:
continue
try:
result = run_orca(["terminal", "show", "--terminal", handle], environment)
terminal = result.get("terminal")
if not isinstance(terminal, dict):
raise RuntimeError("返回缺少 terminal")
worktree = worktree_from_terminal(terminal, environment)
identity = str(terminal.get("agentIdentity") or "")
cli = identity if identity in SUPPORTED_CLI else "codex"
return {
"environment": "" if environment == "local" else environment,
"host": str(terminal.get("executionHostId") or worktree.get("hostId") or "local"),
"project": str(worktree.get("projectId") or worktree.get("repoId") or ""),
"path": str(terminal.get("worktreePath") or worktree.get("path") or ""),
"role": "test",
"cli": cli,
"tier": "standard",
"model": None,
"reasoningEffort": "medium",
"permissionMode": "workspace-write",
"_source": f"terminal:{handle}",
"_project_name": str(worktree.get("displayName") or ""),
"_terminal_agent_identity": identity,
}
except RuntimeError as exc:
errors.append(f"{environment}: {exc}")
raise RuntimeError("找不到可用 terminal: " + ";".join(errors))
def worktrees(environment: str) -> list[dict[str, Any]]:
result = run_orca(["worktree", "list", "--limit", "200"], environment)
return [item for item in result.get("worktrees") or [] if isinstance(item, dict)]
def projects(environment: str) -> list[dict[str, Any]]:
result = run_orca(["project", "list"], environment)
return [item for item in result.get("projects") or [] if isinstance(item, dict)]
def config_from_project(
selector: str,
requested_environment: str | None,
requested_path: str | None = None,
) -> dict[str, Any]:
environments_to_check = [requested_environment] if requested_environment else environments()
matches: list[tuple[str, dict[str, Any]]] = []
for environment in environments_to_check:
if not environment:
continue
project_ids: set[str] = set()
for project in projects(environment):
project_values = {
str(project.get("id") or ""),
str(project.get("displayName") or ""),
}
if selector in project_values:
project_ids.add(str(project.get("id") or ""))
project_ids.update(str(repo_id) for repo_id in project.get("sourceRepoIds") or [])
for item in worktrees(environment):
values = {
str(item.get("id") or ""),
str(item.get("projectId") or ""),
str(item.get("repoId") or ""),
str(item.get("displayName") or ""),
str(item.get("path") or ""),
Path(str(item.get("path") or "")).name,
}
direct_match = selector in values
project_match = bool(
project_ids
and project_ids.intersection(
{
str(item.get("projectId") or ""),
str(item.get("repoId") or ""),
}
)
)
path_match = not requested_path or str(item.get("path") or "") == requested_path
if path_match and (direct_match or project_match):
matches.append((environment, item))
if not matches:
raise RuntimeError(f"找不到 project id/名称: {selector}")
if len(matches) > 1:
lines = [
f"{env}: {item.get('projectId') or item.get('repoId')} "
f"{item.get('displayName') or ''} {item.get('path') or ''}"
for env, item in matches
]
raise RuntimeError("project 匹配多个 worktree,请补充 environment 或 path:\n" + "\n".join(lines))
environment, item = matches[0]
return {
"environment": "" if environment == "local" else environment,
"host": str(item.get("hostId") or "local"),
"project": str(item.get("projectId") or item.get("repoId") or item.get("id") or ""),
"path": str(item.get("path") or ""),
"role": "test",
"cli": "codex",
"tier": "standard",
"model": None,
"reasoningEffort": "medium",
"permissionMode": "workspace-write",
"_source": f"project:{selector}",
"_project_name": str(item.get("displayName") or ""),
}
def clean_config(config: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in config.items() if not key.startswith("_")}
def write_local(root: Path, config: dict[str, Any]) -> tuple[Path, dict[str, Any]]:
path = root / ".pouch" / "tester" / "local.yaml"
path.parent.mkdir(parents=True, exist_ok=True)
data: dict[str, Any] = {}
if path.is_file():
loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(loaded, dict):
raise RuntimeError(f"{path} 必须是 mapping")
data = loaded
current_agent = data.get("agent")
if current_agent is None:
current_agent = {}
if not isinstance(current_agent, dict):
raise RuntimeError(f"{path} 的 agent 必须是 mapping")
resolved = clean_config(config)
# Target location always comes from the user's selector. Keep explicit local
# choices for model/policy fields, so resolving a terminal cannot erase them.
location_fields = {"environment", "host", "project", "path"}
terminal_cli = bool(config.get("_terminal_agent_identity"))
merged = dict(current_agent)
for key, value in resolved.items():
if key in location_fields or key not in merged or (key == "cli" and terminal_cli):
merged[key] = value
data["agent"] = merged
path.write_text(yaml.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
return path, merged
def print_confirmation(config: dict[str, Any], written: bool) -> None:
model = config.get("model") or "CLI 默认模型"
print("## tester Agent 配置更新结果" if written else "## tester Agent 配置解析预览")
print()
print(f"来源: {config.get('_source', '配置文件')}")
print(f"Agent: {config.get('cli')}")
print(f"角色 / 档位: {config.get('role')} / {config.get('tier')}")
print(f"模型: {model}")
print(f"推理强度: {config.get('reasoningEffort') or 'n/a'}")
print(f"权限: {config.get('permissionMode') or 'n/a'}")
print(f"Orca 环境: {config.get('environment') or 'local'}")
print(f"执行节点: {config.get('host') or 'local'}")
print(f"项目: {config.get('project') or 'n/a'}({config.get('_project_name') or 'n/a'})")
print(f"目录: {config.get('path') or 'n/a'}")
print("Terminal: 每轮新建,结束后关闭,不复用输入的 terminal")
print()
if written:
print("以上是已写入 local.yaml.agent 的生效配置;如需调整,直接修改配置后重新运行 tester。")
else:
print("以上是解析结果预览;加上 --write 后会写入 local.yaml.agent。")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="解析 Orca project/terminal 为 tester Agent 配置")
parser.add_argument("--root", default=".", help="项目根")
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--terminal", help="Orca terminal handle")
source.add_argument("--project", help="Orca project id、名称或路径")
parser.add_argument("--path", help="项目有多个 worktree 时按目标机器上的绝对路径消歧")
parser.add_argument("--environment", help="限定 Orca environment")
parser.add_argument("--write", action="store_true", help="写入 .pouch/tester/local.yaml")
args = parser.parse_args(argv)
try:
config = (
config_from_terminal(args.terminal, args.environment)
if args.terminal
else config_from_project(args.project, args.environment, args.path)
)
if args.write:
path, saved_agent = write_local(Path(args.root).resolve(), config)
config = {**config, **saved_agent}
print(f"已写入: {path}")
print_confirmation(config, written=args.write)
return 0
except RuntimeError as exc:
print(f"environment-fail: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+13
View File
@@ -2,6 +2,19 @@ version: 2
updatedAt: "1970-01-01T00:00:00+00:00" updatedAt: "1970-01-01T00:00:00+00:00"
project: project:
name: "" name: ""
# tester 每轮都会为 agent 创建一个新的 Orca terminal,运行结束后关闭它。
# 机器、项目和 agent 等环境相关值可以在 local.yaml 的 agent 中覆盖。
agent:
environment: ""
host: local
project: ""
path: ""
role: test
cli: codex
tier: standard
model: null
reasoningEffort: medium
permissionMode: workspace-write
id_pattern: "^[A-Z]+-[A-Za-z0-9-]+$" id_pattern: "^[A-Z]+-[A-Za-z0-9-]+$"
categories: [] categories: []
risks: risks:
@@ -2,3 +2,19 @@
HOST: "127.0.0.1" HOST: "127.0.0.1"
AUTHORIZE_MUTATING: false AUTHORIZE_MUTATING: false
AUTHORIZE_DESTRUCTIVE: false AUTHORIZE_DESTRUCTIVE: false
# Agent 执行目标。不填或保持默认时,使用当前项目和 Codex。
# environment 是 Orca 保存的远程环境名称;host 是该环境中的目标主机。
# project 可以填写 Orca project id 或名称;path 是目标机器上的项目绝对路径。
# 给出 terminal id 时,tester 会自动解析并填入这些字段;terminal 本身不会被复用。
agent:
environment: ""
host: local
project: ""
path: ""
role: test
cli: codex
tier: standard
model: null
reasoningEffort: medium
permissionMode: workspace-write