From a35cc916cbbfebb31faf0ed07577730f64c913d0 Mon Sep 17 00:00:00 2001 From: laily Date: Mon, 12 Oct 2026 01:14:52 +0800 Subject: [PATCH] feat(tester): initialize agent target from Orca selectors --- skills/tester/README.md | 17 +- skills/tester/SKILL.md | 17 +- skills/tester/references/execution.md | 176 ++++++++++++++ skills/tester/references/run.md | 83 ++++++- skills/tester/references/schema.md | 29 ++- skills/tester/scripts/catalog.py | 89 ++++++- skills/tester/scripts/resolve_agent.py | 258 +++++++++++++++++++++ skills/tester/templates/catalog.yaml | 13 ++ skills/tester/templates/local.yaml.example | 16 ++ 9 files changed, 688 insertions(+), 10 deletions(-) create mode 100644 skills/tester/references/execution.md create mode 100755 skills/tester/scripts/resolve_agent.py diff --git a/skills/tester/README.md b/skills/tester/README.md index 47d3ec4..89c36f7 100644 --- a/skills/tester/README.md +++ b/skills/tester/README.md @@ -17,6 +17,9 @@ - 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`;模板默认 `HOST: "127.0.0.1"`。如果目标不是本机,显式改成目标地址。也可以用环境变量 `HX_TEST_HOST` 覆盖。 +- 测试默认在当前项目执行。也可以直接提供 Orca project id、项目名称或 terminal id; + tester 会自动解析 Agent、环境、项目和目录,写入 `local.yaml.agent`,并输出更新后的配置列表。 + 每轮测试会创建新的 terminal,结束后自动关闭;不会复用或关闭已有 terminal。 - 会动硬件、会改数据的用例带风险级别(`readonly` / `mutating` / `destructive`)。默认只跑最低档 `readonly`。要跑更高风险,需要你明确说,例如「风险到 mutating」;碰哪个子系统另由 `gate`(如 hardware / weld)控制。 ## 使用示例 @@ -33,6 +36,14 @@ 跑 INF 分类的 smoke ``` +```text +用 terminal term_2cfb291c-8769-4613-af52-1fcc2f209eaa 的环境跑 iOS smoke,先把 Agent 配置列出来确认 +``` + +```text +用 Orca project temp-macmini 跑移动测试,自动初始化 tester Agent 配置并先确认 +``` + ```text 跑 TC-CAM-001,TC-CAM-005,风险保持默认 ``` @@ -41,9 +52,11 @@ 1. 用脚本读 `.pouch/tester/`,按分类统计或筛出你点名的用例,不会把整库塞进对话。 2. 改用例前先给你看将要写入的内容,你确认再保存。 -3. 执行时:脚本类直接跑命令;浏览器和移动模拟器先初始化一次,同一轮里复用。 +3. 执行时:先解析并更新 Agent 配置,输出生效列表,再准备 Orca 项目并创建临时 terminal;脚本类、 + 浏览器和移动模拟器都在该 Agent 环境中执行。 4. 超出你允许的风险级别会跳过,不会偷偷执行。 -5. 跑完给出按分类计数的结果,以及 `pass / fail / environment-fail / skipped / not-run`。 +5. 无论测试成功、失败或异常,都会关闭本轮创建的 terminal;跑完给出按分类计数的 + 结果,以及 `pass / fail / environment-fail / skipped / not-run`。 ## 如何判断完成 diff --git a/skills/tester/SKILL.md b/skills/tester/SKILL.md index a5e751f..689f4a0 100644 --- a/skills/tester/SKILL.md +++ b/skills/tester/SKILL.md @@ -2,9 +2,10 @@ name: tester description: >- 维护并执行项目黑盒测试目录(.pouch/tester):列出/增改用例,按分类或 - id 跑脚本、浏览器或移动模拟器。触发词:/tester、列出测试、完善测试用例、 - 按分类测、跑 smoke、现场复测。默认风险最低档;local 配置的 HOST 默认是 - 127.0.0.1。 + id 跑脚本、浏览器或移动模拟器;支持用 Orca project id、项目名称或 terminal id + 定位 Agent 执行环境并创建临时 terminal。触发词:/tester、列出测试、完善测试用例、 + 按分类测、跑 smoke、现场复测。 + 默认风险最低档;local 配置的 HOST 默认是 127.0.0.1。 --- # Tester:项目测试目录 @@ -19,6 +20,10 @@ python3 /scripts/catalog.py [参数] ``` +运行目标可用 `resolve_agent.py` 按 Orca project id/名称或 terminal id 自动解析并写入 +`.pouch/tester/local.yaml` 的 `agent` 配置;配置更新完成后输出生效配置列表。该列表是 +更新结果,不是测试启动前的人工确认门槛。 + ## 选择模式 - 还没有 `.pouch/tester/catalog.yaml`,或用户说初始化 tester:执行「初始化」。 @@ -38,7 +43,11 @@ python3 /scripts/catalog.py /scripts/catalog.py show --root --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。 diff --git a/skills/tester/references/execution.md b/skills/tester/references/execution.md new file mode 100644 index 0000000..130f46e --- /dev/null +++ b/skills/tester/references/execution.md @@ -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 /scripts/resolve_agent.py \ + --root --terminal [--environment ] --write + +python3 /scripts/resolve_agent.py \ + --root --project [--environment ] --write + +python3 /scripts/resolve_agent.py \ + --root --project \ + --environment --path --write +``` + +脚本会跨已保存的 environment 查询 terminal/project,写入 +`.pouch/tester/local.yaml` 的 `agent` mapping,并打印配置更新列表。没有 `--write` 时只解析 +和预览,适合先检查结果。 + +1. 如果给出 terminal id,枚举当前和已保存的 Orca environment,调用 + `orca terminal show --environment --terminal --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 --json` + - 保存的远程环境:在命令上加 `--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: | project: | current-project +Agent: +角色 / 档位: test / standard +模型: +推理强度: +权限: +Orca 环境: +执行节点: +项目: (<项目名称>) +目录: +Worktree: +Terminal: 每轮新建,结束后关闭,不复用当前 terminal + +以上是本次更新后的 Agent 生效配置;如需调整,直接修改 `local.yaml.agent` 后重新运行 tester。 +``` + +只有项目或目录存在多个候选时才标记 `待确认` 并停下,要求补充 `--path` 或 terminal +id,不能按分支或路径猜测。配置更新本身不创建 terminal,也不产生测试副作用。 + +## Terminal 生命周期 + +每次 tester 运行只创建一个新的 terminal,整轮用完后关闭。不能为了方便复用已有 +terminal,也不能关闭运行开始前已经存在的 terminal。 + +创建命令的形状如下。`` 必须是上一步按环境、主机和路径精确解析 +出的 worktree selector;不要直接用可能跨机器冲突的 `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 --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`。 diff --git a/skills/tester/references/run.md b/skills/tester/references/run.md index cd1fdf3..54107ae 100644 --- a/skills/tester/references/run.md +++ b/skills/tester/references/run.md @@ -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. 选范围 未指定范围 → `--tag smoke`,风险上限 = `default_max_risk`,`automation` 默认只跑 `automated`。 @@ -42,6 +56,66 @@ python3 /scripts/catalog.py select --root \ - 探测不可达:用它的分类标 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 -b` 确认完成启动;真机用 `xcrun xctrace list devices` 确认已连接、已信任且允许开发。 +3. 被测 IPA/App 已安装,Bundle ID 与 catalog 的变量一致;模拟器可用 `xcrun simctl get_app_container ` 验证。 +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 -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 看命中条用到的 `session` 键: @@ -71,7 +145,11 @@ python3 /scripts/catalog.py select --root \ python3 /scripts/catalog.py run-script --root --id ``` -`run-script` 只跑命令,返回 `executed` + stdout/exit(或 skipped / environment-fail)。**pass/fail 仍由 Agent 对照 `expected` 判定**,不要把 curl 退出码 0 当成产品通过。 +这个命令必须发送到本轮创建的 terminal 中执行;`` 是目标机器上的项目根, +不是发起端当前目录。远程目标上的 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 里没有的调用。 @@ -100,6 +178,9 @@ python3 /scripts/generate_run_csv.py --root --run 范围: … 风险上限: … session: browser=复用|新开|未用;mobile=… +Agent: / / +执行目标: /:: +terminal: 新建 ,已关闭|清理失败 计数: pass=N fail=N environment-fail=N skipped=N not-run=N 失败: id + 一句证据 跳过: id + 原因 diff --git a/skills/tester/references/schema.md b/skills/tester/references/schema.md index c0c8424..9c504de 100644 --- a/skills/tester/references/schema.md +++ b/skills/tester/references/schema.md @@ -5,8 +5,8 @@ 项目根 `.pouch/tester/`: ``` -catalog.yaml 分类、风险阶梯、session、变量 -local.yaml.example HOST 与授权开关模板(默认 127.0.0.1) +catalog.yaml 分类、风险阶梯、session、变量、默认 Agent 目标 +local.yaml.example HOST、授权开关和 Agent 目标模板(默认 127.0.0.1) local.yaml 本机覆盖,不提交 cases/.yaml 一个分类一个文件(执行源) cases.csv 给人看的扁平清单;只由 cases/*.yaml 生成,不包含运行结果 @@ -33,6 +33,31 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对 `127.0.0.1`;远程目标必须显式覆盖。查找顺序:环境变量 > `local.yaml` > `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 ` 关闭本轮创建的 terminal。 +运行快照保存解析后的 Agent、目标、worktree 和 terminal handle。旧版 `execution` 字段 +只作为兼容输入读取,不再作为新配置写出。详见 [execution.md](execution.md)。 + 运行记录使用 UTC 时间加随机后缀命名,例如 `20260920T101530Z-smoke-a13f9c.yaml`,并在 YAML 顶层保存同一个 `runId`。 不要使用固定的 `latest.yaml` 或覆盖已有运行文件。 diff --git a/skills/tester/scripts/catalog.py b/skills/tester/scripts/catalog.py index 21863ef..c344de7 100755 --- a/skills/tester/scripts/catalog.py +++ b/skills/tester/scripts/catalog.py @@ -37,6 +37,18 @@ EXPECTED_KINDS = ( "url", "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: @@ -84,6 +96,41 @@ def load_local(root: Path) -> dict[str, Any]: 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]: d = catalog_dir(root) / "cases" 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) local = load_local(root) for key, value in local.items(): - if value is None: + if value is None or isinstance(value, (dict, list)): continue merged[str(key)] = str(value).lower() if isinstance(value, bool) else str(value) auth_keys = set() @@ -338,6 +385,44 @@ def probe_errors(where: str, probe: Any) -> list[str]: 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]]: raw = meta.get("run_together") or [] if not isinstance(raw, list): @@ -505,6 +590,7 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int: "skipped": skipped, "run_together": together, "status_probe": status_probes, + "agent": resolve_agent(root, meta), "cases": selected, }, ensure_ascii=False, @@ -533,6 +619,7 @@ def validate(root: Path) -> list[str]: meta = load_catalog_meta(root) except SystemExit as exc: return [str(exc)] + errors.extend(agent_errors(root, meta)) cats = meta.get("categories") if not isinstance(cats, list): diff --git a/skills/tester/scripts/resolve_agent.py b/skills/tester/scripts/resolve_agent.py new file mode 100755 index 0000000..183841a --- /dev/null +++ b/skills/tester/scripts/resolve_agent.py @@ -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()) diff --git a/skills/tester/templates/catalog.yaml b/skills/tester/templates/catalog.yaml index 06e1bf9..87e98bb 100644 --- a/skills/tester/templates/catalog.yaml +++ b/skills/tester/templates/catalog.yaml @@ -2,6 +2,19 @@ version: 2 updatedAt: "1970-01-01T00:00:00+00:00" project: 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-]+$" categories: [] risks: diff --git a/skills/tester/templates/local.yaml.example b/skills/tester/templates/local.yaml.example index 254a1e7..b9167ad 100644 --- a/skills/tester/templates/local.yaml.example +++ b/skills/tester/templates/local.yaml.example @@ -2,3 +2,19 @@ HOST: "127.0.0.1" AUTHORIZE_MUTATING: 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