Document AI testing contract updates

This commit is contained in:
hy committed 2026-03-06 11:21:04 +08:00
1 parent 49560e5677
commit 89347fe307
24 files changed
+988 -169

No files matched your search

+7 -4
View File
@@ -77,12 +77,15 @@ human-live 使用根目录 `.env` 配置:
- `events.jsonl`
- `assertions.json`
- `metrics.json`
- `signals.json`
- `summary.md`
`metrics.json`/`summary.md` 必须包含:
- `failure_category`
- `suspected_root_cause`
- `optimization_hints`
消费原则:
- `events.jsonl`:原始事件流,作为可回放证据。
- `assertions.json`:结构化断言结果,包含 `caseStatus`、失败断言 ID 与断言明细。
- `metrics.json`:统一标量指标,至少包含 `caseStatus`、`caseErrorCode`、`executionStatus`、`executionResultCode` 及步骤、审批、产物计数。
- `signals.json`:结构化信号,至少包含 `toolSequence`、`toolCounts`、`toolResultCodeCounts`、`failedAssertionIds`,并在适用时补充 `expectedOutcome`、`scenarioTags`、`finalHumanAction`。
- `summary.md`:事实性摘要,只转写上述结构化数据,不包含固定根因或优化建议。
## 7. 扩展规则
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-06
@@ -0,0 +1,181 @@
## Context
当前 AI 测试报告由 `tests/ai/harness/reporter.ts` 统一写入 `events.jsonl`、`assertions.json`、`metrics.json`、`summary.md`。其中:
- `events.jsonl` 是原始事件流,信息完整,但消费成本高。
- `assertions.json` 只记录断言列表与最终通过/失败状态。
- `metrics.json` 除基础统计外,还混入了 `failure_category`、`suspected_root_cause`、`optimization_hints` 等固定分析字段。
- `summary.md` 将这些固定分析字段再次转写为 Markdown。
该设计存在三个问题:
1. 分析字段不是从原始证据推导出来的,而是由 harness 根据 `passed/failed` 写死模板文本,无法代表真实执行语义。
2. 报告没有区分“case 自己是否通过”和“被测系统实际执行结果”。在负向测试中,`case passed` 与 `execution blocked` 会被混淆。
3. 后续希望由 Codex 读取整批报告做诊断与优化建议,因此报告更需要稳定、确定性的结构化输入,而不是单次运行的伪分析结论。
这次变更会同时影响 capability 与 human-live 两类测试。两者共享同一套报告写入器,但 execution 语义来源不同:
- human-live 可直接从 `HumanTaskOutput` 提取执行状态与结果码。
- capability 用例直接调用生产工具入口,没有统一任务结果类型,需要额外设计报告注入点来表达执行语义。
## Goals / Non-Goals
**Goals:**
- 将测试报告收敛为“原始证据 + 结构化信号 + 事实摘要”三层模型,移除固定分析文案。
- 在报告契约中明确区分 `caseStatus` 与 `executionOutcome`,支持正向、负向、阻塞、显式能力缺口等场景。
- 为后续 Codex 批量分析提供稳定输入,至少覆盖失败断言、工具序列、工具结果码、审批统计、产物统计、执行状态等核心信号。
- 让 capability 与 human-live 两套 harness 都输出统一的报告主 schema,降低后续聚合器复杂度。
- 保持 `events.jsonl` 作为最高保真证据来源,不改变现有 AI 运行时事件模型。
**Non-Goals:**
- 本次不实现“自动生成优化建议”的 batch analyzer;该能力留给后续 Codex 或独立分析脚本。
- 本次不追求为所有历史报告目录做兼容迁移;旧报告可继续保留,新运行生成新契约。
- 本次不修改 AI runtime 的事件定义与事件发送链路,除非为了补齐极少量确定性统计而必须调整。
- 本次不重构具体测试 case 的业务断言逻辑,只调整其报告声明与消费方式。
## Decisions
### 1. 报告采用“三层结构”,移除固定分析字段
新的报告结构划分为:
- `events.jsonl`:原始证据层,保持逐事件回放能力。
- `assertions.json`:断言结果层,记录 case verdict、失败断言与断言明细。
- `metrics.json`:标量聚合层,记录耗时、步骤数、审批次数、产物次数、case 状态、execution 状态等确定性指标。
- `signals.json`:非标量结构化信号层,记录工具序列、按工具聚合的结果码、标签、失败断言 ID、最终 human 动作等。
- `summary.md`:事实摘要层,只转写关键信号,不再包含根因或优化建议。
选择该方案的原因:
- 让“证据”和“解释”解耦,避免将伪分析写死进报告。
- 保留对人类友好的摘要,同时确保程序消费优先读取 JSON。
- `signals.json` 可以容纳 `toolSequence`、`toolResultCodeCounts` 等结构化数据,避免把复杂对象硬塞进 `metrics.json`。
考虑过的替代方案:
- 保留现有 `analysis` 字段但改成空字符串或 `none`:仍然保留错误语义,且没有信息增量。
- 将所有新信号都塞进 `metrics.json`:会让标量指标与复杂对象混杂,降低可读性与稳定性。
### 2. 显式拆分 case verdict 与 execution outcome
统一引入两组语义:
- `caseStatus` / `caseErrorCode`
- 表达测试 case 自己是否成功执行完成,以及如果失败,失败属于 fixture 缺失、断言失败、环境缺失还是运行时异常。
- `executionStatus` / `executionResultCode`
- 表达被测系统在该用例中的实际执行语义,如 `completed`、`blocked`、`failed`、`not_run`,以及对应结果码。
推荐的基础分类:
- `caseStatus`: `passed | failed`
- `caseErrorCode`: `none | fixture_missing | live_config_missing | assertion_failed | runtime_exception`
- `executionStatus`: `completed | blocked | failed | not_run | unknown`
其中:
- human-live 由 `HumanTaskOutput` 自动填充 `executionStatus` / `executionResultCode`。
- capability 通过新的 harness 注入点声明 execution outcome;如果 case 未声明,则落为 `unknown`,推动后续逐步补齐。
选择该方案的原因:
- 能正确表达“审批拒绝型 case 通过,但 execution 被阻塞”的语义。
- 避免后续分析器把负向测试误判成“系统正常完成”或“报告无失败”。
考虑过的替代方案:
- 沿用单一 `status` 字段:无法表达 case verdict 与业务执行的分离。
- 仅保留 `executionResultCode` 不保留 `caseErrorCode`:会丢失测试 harness 自身失败的诊断能力。
### 3. 事件聚合逻辑集中在 reporter 层,case 只声明少量元信息
报告生成分成两部分:
- reporter 从 `events.jsonl` 与断言结果统一推导通用信号:
- `toolCallCount`
- `approvalRequiredCount` / `approvalApprovedCount` / `approvalDeniedCount`
- `fileArtifactCount`
- `toolSequence`
- `toolCounts`
- `toolResultCodeCounts`
- `failedAssertionIds`
- case/harness 仅补充 reporter 无法从事件中可靠推导的信息:
- `executionStatus` / `executionResultCode`
- `scenarioTags`
- `expectedOutcome`
- capability 专有的执行语义说明
这要求在 harness 层新增统一的 `CaseReportSignals` / `CaseExecutionOutcome` 类型,并在 `runCapabilityCase`、`runHumanLiveCase` 中汇总后交给 reporter。
选择该方案的原因:
- 把通用推导留在中心化组件,降低每个 case 重复造轮子。
- 保证同类指标在 capability 与 human-live 之间有一致口径。
考虑过的替代方案:
- 让每个 case 自己手工填写全部 signals:灵活但不一致,维护成本高。
- 仅从事件自动推导所有字段:capability 缺少统一任务输出,无法可靠表达最终 execution outcome。
### 4. capability 与 human-live 采用同一主 schema,但允许不同信号来源
统一后的主 schema 适用于两个 suite:
- 共同字段:`suite`、`caseId`、`runId`、`taskId`、`caseStatus`、`caseErrorCode`、`executionStatus`、`executionResultCode`、统计指标、断言摘要、工具信号。
- human-live 专有字段:`finalHumanAction`、`humanStepCount`、与 `HumanTaskOutput` 对齐的结果摘要。
- capability 专有字段:通过 case 注入的 execution outcome,以及必要时的 `expectedOutcome` / `scenarioTags`。
这意味着读取器 `reportInspector` 也要升级为读取统一 schema,而不是只读取旧的 `metrics.failure_category`。
选择该方案的原因:
- 统一下游消费接口,后续 Codex 不需要按 suite 写两套解析逻辑。
- 保留 suite 差异,但将差异压缩到局部扩展字段。
考虑过的替代方案:
- 为 capability 与 human-live 维护两套完全独立的报告格式:会增加后续分析器复杂度。
### 5. 以破坏式契约升级替换兼容别名
本次变更不保留 `failure_category`、`suspected_root_cause`、`optimization_hints` 作为兼容别名,也不在新报告中写入占位空值。
迁移方式:
- 更新 harness 类型、落盘逻辑、读取逻辑与依赖旧字段的测试。
- 更新 `doc/testing.md`、`tests/ai/README.md` 与对应 specs。
- 历史报告目录不迁移,作为旧契约产物保留。
选择该方案的原因:
- 报告产物是测试执行生成物,不是长期对外 API;保持新契约纯净比兼容旧字段更重要。
- 兼容别名会继续让消费者误用旧语义,延长契约混乱期。
考虑过的替代方案:
- 新旧字段并存一段时间:迁移风险更低,但会持续制造语义冲突。
## Risks / Trade-offs
- [Risk] capability 用例的 execution outcome 没有统一来源,初期可能出现大量 `unknown`。 → Mitigation:在 harness 中引入显式注入点,并优先补齐现有高价值 capability case。
- [Risk] 新增 `signals.json` 后,报告文件数量增多,维护成本上升。 → Mitigation:限制 `signals.json` 只承载后续分析真正需要的结构化信号,避免复制 `events.jsonl` 全量内容。
- [Risk] 破坏式改动会让依赖旧字段的测试或脚本失效。 → Mitigation:同步更新 `reportInspector`、fail-fast 回归用例和 README/spec 文档,在一个变更中完成全量切换。
- [Risk] 仅保留数据可能让人类直接阅读报告时感知门槛升高。 → Mitigation:保留 `summary.md`,但其内容严格从结构化信号生成,提供事实摘要而非推断。
- [Risk] 事件聚合算法如果设计不当,可能把多步工具链误解为最终结论。 → Mitigation:将“可自动推导”和“需要 case 明确声明”的字段分离,避免 reporter 过度猜测。
## Migration Plan
1. 在 `tests/ai/harness/types.ts` 中引入新的报告类型,移除旧的 `CaseAnalysis` 结构。
2. 重写 `reporter.ts` 的落盘逻辑,新增 `signals.json`,并将 `summary.md` 改为事实摘要模板。
3. 在 `runCapabilityCase` 与 `runHumanLiveCase` 中接入新的 `caseErrorCode` 与 `executionOutcome` 采集逻辑。
4. 更新 `reportInspector.ts` 与依赖旧字段的回归测试,改为断言新 schema。
5. 更新 `doc/testing.md`、`tests/ai/README.md` 与相关 OpenSpec specs,使文档与实现口径一致。
6. 不迁移历史 `tests/report/**` 目录;新执行生成的新报告自然使用新格式。
7. 如需回滚,只需回退 harness 与文档变更并重新执行测试,无需做数据回滚。
## Open Questions
- capability 用例的 `executionOutcome` 最终采用“显式注入 API”还是“由 execute 返回结构中约定字段”作为标准入口,仍需在实现前确定。
- `signals.json` 是否需要收录 `warningMessages` 原文,还是只保留 `warningCount` 与让消费者回读 `events.jsonl`,需要在实现复杂度与消费便利性之间取舍。
- `summary.md` 是否要包含 `expectedOutcome` / `scenarioTags`,还是只保留运行结果摘要,仍可在实现时根据可读性再收敛一次。
@@ -0,0 +1,29 @@
## Why
当前 AI 测试报告在 `metrics.json` 和 `summary.md` 中预写了固定的分析字段,但这些字段并不基于真实事件流、断言结果或执行输出生成,信息增量很低,还会混淆“测试用例是否通过”和“被测系统实际发生了什么”。在准备扩展 docx 与 human-live 测试覆盖前,需要先把报告契约调整为“数据优先、分析后置”,让 Codex 能直接基于结构化证据做批量诊断与优化建议。
## What Changes
- 将 AI 测试报告从“固定分析文案”调整为“原始证据 + 结构化信号”契约,删除预写死的根因和优化建议文本。
- 将 case verdict 与 execution outcome 拆开表达,避免负向测试通过时被误读为“系统没有任何失败语义”。
- 为 capability 与 human-live 两类测试补充机器可读的报告语义,至少覆盖 case 错误分类、执行状态、执行结果码、失败断言、工具序列、审批与产物统计等分析输入。
- 保留事件流与断言文件作为原始证据来源,并将摘要文件收敛为事实性概览,不再承载推断性分析。
- **BREAKING**:调整现有 AI 测试报告 JSON/Markdown 契约及其读取逻辑、回归断言与相关文档说明。
## Capabilities
### New Capabilities
- 无
### Modified Capabilities
- `ai-test-reporting-contract`: 将测试报告从固定分析键契约调整为数据优先的结构化信号契约,并明确原始证据、聚合指标与执行语义的边界。
- `capability-tool-test-suite`: 要求 capability 用例输出可供后续分析器消费的确定性报告信号,并支持区分正向验证、负向验证与显式能力缺口场景。
- `human-live-test-suite`: 要求 human-live 用例将 case verdict 与实际 execution outcome 分离表达,避免阻塞型预期用例被报告误判。
## Impact
- 受影响代码:`tests/ai/harness/*` 报告类型、写入逻辑、读取逻辑与回归测试;相关 suite 用例的断言与报告消费方式。
- 受影响文档与规格:`doc/testing.md`、`tests/ai/README.md`、现有 OpenSpec 测试报告与测试套件相关 specs。
- 受影响系统:后续基于测试报告做聚合分析、优化建议生成或自动化诊断的 Codex/AI 流程。
@@ -0,0 +1,68 @@
## MODIFIED Requirements
### Requirement: 测试框架 MUST 输出可回放事件流与结构化断言结果
每个测试用例执行后 MUST 输出完整事件流与结构化断言结果,确保结果可回放、可审计、可定位;断言结果 MUST 明确包含 `suite`、`caseId`、`runId`、`taskId`、`caseStatus`、断言总数、失败断言 ID 与断言明细。
#### Scenario: 用例执行完成
- **WHEN** 任一 capability 或 human live 用例结束
- **THEN** 系统 MUST 产出事件流文件与断言结果文件,且两者可按 `suite/caseId/runId/taskId` 关联
#### Scenario: 用例失败定位
- **WHEN** 用例断言失败
- **THEN** 报告 MUST 提供对应失败断言 ID、断言明细、关联事件片段与关键上下文字段
### Requirement: 测试框架 MUST 输出统一指标文件用于横向比较
每个测试用例 MUST 输出统一标量指标结构,至少包含耗时、步骤数、工具调用统计、审批统计、产物统计、`caseStatus`、`caseErrorCode`、`executionStatus`、`executionResultCode`,便于跨运行与跨 suite 比较。
#### Scenario: 多次运行同一用例
- **WHEN** 同一用例被执行多次
- **THEN** 指标文件 MUST 使用统一字段结构,以支持跨次结果比较
#### Scenario: 负向用例通过
- **WHEN** 用例验证审批拒绝、工作区阻塞或其他预期负向路径且断言通过
- **THEN** 指标文件 MUST 将 `caseStatus` 记录为 `passed`,同时保留真实 `executionStatus` 与 `executionResultCode`
#### Scenario: 执行未启动
- **WHEN** 用例因 fixture 缺失或 live 配置缺失而在执行前 fail-fast
- **THEN** 指标文件 MUST 将 `executionStatus` 记录为 `not_run`,并以确定性的 `caseErrorCode` 表达测试失败原因
### Requirement: 报告落盘路径与命名 MUST 标准化
测试结果 MUST 按统一目录层级落盘到 `tests/report/<suite>/<case>/<timestamp>/` 下,并包含标准文件集合 `events.jsonl`、`assertions.json`、`metrics.json`、`signals.json`、`summary.md`,避免结果覆盖并支持历史追踪。
#### Scenario: 生成 capability 用例报告
- **WHEN** capability 用例执行结束
- **THEN** 报告 MUST 写入 `tests/report/<suite>/<case>/<timestamp>/` 下的标准文件集合
#### Scenario: 生成 human live 用例报告
- **WHEN** human live 用例执行结束
- **THEN** 报告 MUST 与 capability 用例使用同一目录规范并可区分 suite 类型
## ADDED Requirements
### Requirement: 报告契约 MUST 输出结构化信号文件供后续分析器消费
测试结果契约 MUST 输出 `signals.json` 作为非标量结构化信号文件,至少包含 `toolSequence`、`toolCounts`、`toolResultCodeCounts`、`failedAssertionIds`,并在适用时包含 `scenarioTags`、`expectedOutcome` 与 suite 专有的最终动作信号。
#### Scenario: 多工具用例生成结构化信号
- **WHEN** capability 或 human live 用例执行多个工具步骤
- **THEN** `signals.json` MUST 保留工具调用顺序、按工具聚合的调用次数与结果码统计
#### Scenario: human 阻塞用例生成结构化信号
- **WHEN** human live 用例以阻塞终态收敛
- **THEN** `signals.json` MUST 包含最终 human 动作、阻塞结果码以及相关审批或工作区证据的结构化索引
### Requirement: 摘要文件 MUST 仅转写事实性报告信号
`summary.md` MUST 只转写结构化报告中的事实性信号,用于人工快速阅读;摘要文件 MUST NOT 预写根因推断、优化建议或其他解释性分析文本。
#### Scenario: 生成事实摘要
- **WHEN** 任一测试用例完成并写入摘要文件
- **THEN** 摘要 MUST 仅包含 suite、caseStatus、executionStatus、关键统计与失败断言等事实信息
#### Scenario: 读取摘要进行人工核对
- **WHEN** 开发者查看 `summary.md`
- **THEN** 其内容 MUST 与 `assertions.json`、`metrics.json`、`signals.json` 保持一致,且不包含固定根因或优化建议段落
## REMOVED Requirements
### Requirement: 报告契约 MUST 包含 AI 优化建议所需的分析键
**Reason**: 固定分析键由测试 harness 按模板写死,无法代表真实执行语义,会污染后续基于报告数据进行的批量分析。
**Migration**: 后续分析器 MUST 改为读取 `assertions.json`、`metrics.json`、`signals.json` 与 `events.jsonl` 的结构化数据,自行生成根因判断与优化建议。
@@ -0,0 +1,36 @@
## MODIFIED Requirements
### Requirement: Capability 测试 MUST 显式校验并声明 fixture 与 workspace 前置条件
每个 capability 用例 MUST 声明执行所需输入(例如 docx 文件、workspace 目录),测试运行器 MUST 在执行前校验前置条件并在缺失时 fail-fast 输出缺失清单;报告同时 MUST 将 `caseStatus` 标记为 `failed`、`caseErrorCode` 标记为确定性的前置条件错误码,并将 `executionStatus` 标记为 `not_run`。
#### Scenario: docx fixture 缺失
- **WHEN** `docx_replace_text` 用例声明的源 docx 文件不存在
- **THEN** 测试运行器 MUST 终止该用例执行,输出明确的缺失文件路径与修复提示,并将执行结果记录为 `not_run`
#### Scenario: 前置条件满足后执行
- **WHEN** 用例声明的 fixture 与 workspace 均可用
- **THEN** 测试运行器 MUST 进入 `setup -> run -> assert` 流程并产生带有 execution outcome 的完整测试结果
## ADDED Requirements
### Requirement: Capability 报告 MUST 分离 case verdict 与 execution outcome
capability 测试报告 MUST 将测试 case 是否通过与能力实际执行语义分离表达,以支持负向验证与阻塞路径在断言通过时仍保留真实执行状态。
#### Scenario: 覆盖保存审批拒绝用例通过
- **WHEN** capability 用例验证覆盖保存被审批拒绝,且断言符合预期
- **THEN** 报告 MUST 将 `caseStatus` 记录为 `passed`,并将 `executionStatus` 记录为 `blocked`、`executionResultCode` 记录为 `approval_denied`
#### Scenario: 工作区边界拒绝用例通过
- **WHEN** capability 用例验证工作区缺失或边界拒绝路径,且断言符合预期
- **THEN** 报告 MUST 将 `caseStatus` 记录为 `passed`,并保留真实的 `workspace_required` 或等价执行结果码
### Requirement: Capability 测试 MUST 支持显式能力缺口与受限行为验证
capability 测试套件 MUST 支持将“保留但未实现”或“当前版本受限”的行为作为显式能力缺口进行验证;这类用例 MUST 通过 `expectedOutcome`、`scenarioTags` 或等价结构化信号标记为 `explicit_gap`,而不是被误判为 harness 自身失败。
#### Scenario: semantic search 保留但未实现
- **WHEN** 用例验证 `docx_search` 的 `semantic` 模式当前未实现
- **THEN** 报告 MUST 记录真实执行结果码,并以结构化信号标记该场景为 `explicit_gap`
#### Scenario: 预览格式当前受限
- **WHEN** 用例验证 `docx_render_preview` 对 `png` 或 `pdf` 预览的受限行为
- **THEN** 报告 MUST 记录该受限结果,并将该场景标记为显式能力缺口而非测试框架错误
@@ -0,0 +1,36 @@
## MODIFIED Requirements
### Requirement: Human live 测试 MUST 评估渐进式解决问题能力
human live 用例结果 MUST 通过结构化报告信号评估“渐进式解决问题”行为,至少覆盖失败恢复、策略切换、完成收敛三个维度;固定分析文案 MUST NOT 作为唯一评估依据。
#### Scenario: 遇到可恢复失败后继续推进
- **WHEN** human 步骤中出现可恢复失败
- **THEN** 结果评估 MUST 通过事件流与结构化信号验证后续步骤存在调整策略并继续执行的证据
#### Scenario: 命中不可恢复阻塞后收敛
- **WHEN** human 步骤命中不可恢复条件(如审批拒绝或工作区缺失)
- **THEN** 结果评估 MUST 通过 `executionStatus`、`executionResultCode`、最终 human 动作与相关事件证据验证任务以阻塞终态明确收敛
## ADDED Requirements
### Requirement: Human live 报告 MUST 分离 case verdict 与 execution outcome
human live 测试报告 MUST 将测试 case 自身是否通过与 human 实际执行结果分离表达,避免预期阻塞路径或前置环境失败被误读为同一种结果。
#### Scenario: 审批拒绝是预期结果
- **WHEN** human live 用例以“审批拒绝后阻塞收敛”为目标,且断言符合预期
- **THEN** 报告 MUST 将 `caseStatus` 记录为 `passed`,并将 `executionStatus` 记录为 `blocked`、`executionResultCode` 记录为 `approval_denied`
#### Scenario: 缺失 live 配置导致未执行
- **WHEN** human live 用例在启动前缺少必需模型配置
- **THEN** 报告 MUST 将 `caseStatus` 记录为 `failed`、`caseErrorCode` 记录为 `live_config_missing`,并将 `executionStatus` 记录为 `not_run`
### Requirement: Human live 报告 MUST 输出结构化步骤信号供后续分析
human live 测试结果 MUST 输出适合后续分析器消费的结构化步骤信号,至少包含 `toolSequence`、失败断言 ID、最终 human 动作,以及与完成或阻塞结果相关的关键执行信号。
#### Scenario: 任务完成并调用 complete_task
- **WHEN** human live 用例通过若干工具步骤完成目标
- **THEN** `signals.json` MUST 包含工具调用顺序、最终 human 动作 `complete_task` 与完成结果码
#### Scenario: 任务阻塞并调用 block_task
- **WHEN** human live 用例在审批、工作区或其他不可恢复条件下阻塞
- **THEN** `signals.json` MUST 包含最终 human 动作 `block_task` 以及与阻塞结果相关的步骤信号
@@ -0,0 +1,25 @@
## 1. 报告契约与类型重构
- [x] 1.1 在 `tests/ai/harness/types.ts` 中移除旧的固定分析类型,定义新的 `caseStatus`、`caseErrorCode`、`executionStatus`、`executionResultCode`、`signals` 相关类型
- [x] 1.2 重构 `tests/ai/harness/reporter.ts` 的落盘输入结构,使其支持输出 `events.jsonl`、`assertions.json`、`metrics.json`、`signals.json`、`summary.md`
- [x] 1.3 重构 `tests/ai/harness/reportInspector.ts` 的读取类型与解析逻辑,使其消费新报告 schema 而不是旧的分析字段
## 2. 结构化信号生成与执行语义接入
- [x] 2.1 在 reporter 中实现从事件流与断言结果推导通用指标与结构化信号的逻辑,包括工具序列、工具计数、工具结果码统计、失败断言 ID、审批统计与产物统计
- [x] 2.2 在 `runCapabilityCase` 中接入新的 case 错误分类与 execution outcome 注入机制,支持 capability 用例显式声明真实执行语义
- [x] 2.3 在 `runHumanLiveCase` 中接入新的 case 错误分类与 execution outcome 汇总逻辑,并将 `HumanTaskOutput` 映射到统一报告字段
- [x] 2.4 重写 `summary.md` 生成功能,确保摘要仅转写事实性信号,不再输出固定根因或优化建议
## 3. 回归用例与报告消费迁移
- [x] 3.1 更新依赖旧 `failure_category` 字段的 capability fail-fast 回归用例,改为断言新的 `caseErrorCode` 与 `executionStatus`
- [x] 3.2 更新依赖旧 `failure_category` 字段的 human-live fail-fast 回归用例,改为断言新的 `caseErrorCode` 与 `executionStatus`
- [x] 3.3 更新现有 docx 与 human-live 正负向用例,使其在需要时补充 `expectedOutcome`、`scenarioTags` 或 execution outcome 声明
- [x] 3.4 运行 AI 测试相关回归,确认新报告文件集、断言读取逻辑与负向场景语义均符合预期
## 4. 文档与规范同步
- [x] 4.1 更新 `doc/testing.md`,将报告契约说明改为“原始证据 + 结构化信号 + 事实摘要”
- [x] 4.2 更新 `tests/ai/README.md`,同步新的报告文件集合、关键字段与推荐消费方式
- [x] 4.3 核对实现与本 change 下的 delta specs 保持一致,必要时补充字段命名或场景说明以消除歧义
@@ -4,46 +4,61 @@
TBD - created by archiving change add-script-first-ai-testing-harness. Update Purpose after archive.
## Requirements
### Requirement: 测试框架 MUST 输出可回放事件流与结构化断言结果
每个测试用例执行后 MUST 输出完整事件流与断言明细,确保结果可回放、可审计、可定位。
每个测试用例执行后 MUST 输出完整事件流与结构化断言结果,确保结果可回放、可审计、可定位;断言结果 MUST 明确包含 `suite`、`caseId`、`runId`、`taskId`、`caseStatus`、断言总数、失败断言 ID 与断言明细。
#### Scenario: 用例执行完成
- **WHEN** 任一 capability 或 human live 用例结束
- **THEN** 系统 MUST 产出事件流文件与断言结果文件,且两者可按 runId/caseId 关联
- **THEN** 系统 MUST 产出事件流文件与断言结果文件,且两者可按 `suite/caseId/runId/taskId` 关联
#### Scenario: 用例失败定位
- **WHEN** 用例断言失败
- **THEN** 报告 MUST 提供对应失败断言、关联事件片段与关键上下文字段
- **THEN** 报告 MUST 提供对应失败断言 ID、断言明细、关联事件片段与关键上下文字段
### Requirement: 测试框架 MUST 输出统一指标文件用于横向比较
每个测试用例 MUST 输出统一指标结构,至少包含步骤数、失败码、耗时、工具调用统计与产物统计,便于跨运行对比。
每个测试用例 MUST 输出统一标量指标结构,至少包含耗时、步骤数、工具调用统计、审批统计、产物统计、`caseStatus`、`caseErrorCode`、`executionStatus`、`executionResultCode`,便于跨运行与跨 suite 比较。
#### Scenario: 多次运行同一用例
- **WHEN** 同一用例被执行多次
- **THEN** 指标文件 MUST 使用统一字段结构,以支持跨次结果比较
#### Scenario: 运行包含文件产物的用例
- **WHEN** 用例执行产生或更新文件
- **THEN** 指标输出 MUST 包含产物数量与操作类型统计
#### Scenario: 负向用例通过
- **WHEN** 用例验证审批拒绝、工作区阻塞或其他预期负向路径且断言通过
- **THEN** 指标文件 MUST 将 `caseStatus` 记录为 `passed`,同时保留真实 `executionStatus` 与 `executionResultCode`
### Requirement: 报告契约 MUST 包含 AI 优化建议所需的分析键
测试结果契约 MUST 提供固定分析字段,至少包含 `failure_category`、`suspected_root_cause`、`optimization_hints`,以支持 AI 基于测试输出自动给出改进建议。
#### Scenario: 失败用例生成分析输入
- **WHEN** 用例失败并生成报告
- **THEN** 报告 MUST 填充失败分类与疑似根因字段,并提供可执行的优化提示列表
#### Scenario: 通过用例生成分析输入
- **WHEN** 用例通过并生成报告
- **THEN** 报告 MUST 保留分析键字段且以“无异常/可优化建议”形式输出
#### Scenario: 执行未启动
- **WHEN** 用例因 fixture 缺失或 live 配置缺失而在执行前 fail-fast
- **THEN** 指标文件 MUST 将 `executionStatus` 记录为 `not_run`,并以确定性的 `caseErrorCode` 表达测试失败原因
### Requirement: 报告落盘路径与命名 MUST 标准化
测试结果 MUST 按统一目录层级落盘,目录结构 MUST 至少包含 suite、case 与时间戳维度,避免结果覆盖并支持历史追踪。
测试结果 MUST 按统一目录层级落盘到 `tests/report/<suite>/<case>/<timestamp>/` 下,并包含标准文件集合 `events.jsonl`、`assertions.json`、`metrics.json`、`signals.json`、`summary.md`,避免结果覆盖并支持历史追踪。
#### Scenario: 生成 capability 用例报告
- **WHEN** capability 用例执行结束
- **THEN** 报告 MUST 写入 `artifacts/ai-test-results/<suite>/<case>/<timestamp>/` 下的标准文件集合
- **THEN** 报告 MUST 写入 `tests/report/<suite>/<case>/<timestamp>/` 下的标准文件集合
#### Scenario: 生成 human live 用例报告
- **WHEN** human live 用例执行结束
- **THEN** 报告 MUST 与 capability 用例使用同一目录规范并可区分 suite 类型
### Requirement: 报告契约 MUST 输出结构化信号文件供后续分析器消费
测试结果契约 MUST 输出 `signals.json` 作为非标量结构化信号文件,至少包含 `toolSequence`、`toolCounts`、`toolResultCodeCounts`、`failedAssertionIds`,并在适用时包含 `scenarioTags`、`expectedOutcome` 与 suite 专有的最终动作信号。
#### Scenario: 多工具用例生成结构化信号
- **WHEN** capability 或 human live 用例执行多个工具步骤
- **THEN** `signals.json` MUST 保留工具调用顺序、按工具聚合的调用次数与结果码统计
#### Scenario: human 阻塞用例生成结构化信号
- **WHEN** human live 用例以阻塞终态收敛
- **THEN** `signals.json` MUST 包含最终 human 动作、阻塞结果码以及相关审批或工作区证据的结构化索引
### Requirement: 摘要文件 MUST 仅转写事实性报告信号
`summary.md` MUST 只转写结构化报告中的事实性信号,用于人工快速阅读;摘要文件 MUST NOT 预写根因推断、优化建议或其他解释性分析文本。
#### Scenario: 生成事实摘要
- **WHEN** 任一测试用例完成并写入摘要文件
- **THEN** 摘要 MUST 仅包含 suite、caseStatus、executionStatus、关键统计与失败断言等事实信息
#### Scenario: 读取摘要进行人工核对
- **WHEN** 开发者查看 `summary.md`
- **THEN** 其内容 MUST 与 `assertions.json`、`metrics.json`、`signals.json` 保持一致,且不包含固定根因或优化建议段落
@@ -26,15 +26,15 @@ capability 测试执行路径 MUST 直接调用生产代码中的 tool 入口与
- **THEN** 边界校验与风险审批逻辑 MUST 来自生产实现而非测试内 mock 规则
### Requirement: Capability 测试 MUST 显式校验并声明 fixture 与 workspace 前置条件
每个 capability 用例 MUST 声明执行所需输入(例如 docx 文件、workspace 目录),测试运行器 MUST 在执行前校验前置条件并在缺失时 fail-fast 输出缺失清单。
每个 capability 用例 MUST 声明执行所需输入(例如 docx 文件、workspace 目录),测试运行器 MUST 在执行前校验前置条件并在缺失时 fail-fast 输出缺失清单;报告同时 MUST 将 `caseStatus` 标记为 `failed`、`caseErrorCode` 标记为确定性的前置条件错误码,并将 `executionStatus` 标记为 `not_run`。
#### Scenario: docx fixture 缺失
- **WHEN** `docx_replace_text` 用例声明的源 docx 文件不存在
- **THEN** 测试运行器 MUST 终止该用例执行并输出明确的缺失文件路径与修复提示
- **THEN** 测试运行器 MUST 终止该用例执行,输出明确的缺失文件路径与修复提示,并将执行结果记录为 `not_run`
#### Scenario: 前置条件满足后执行
- **WHEN** 用例声明的 fixture 与 workspace 均可用
- **THEN** 测试运行器 MUST 进入 `setup -> run -> assert` 流程并产生完整测试结果
- **THEN** 测试运行器 MUST 进入 `setup -> run -> assert` 流程并产生带有 execution outcome 的完整测试结果
### Requirement: Capability 测试 MUST 覆盖单工具与组合编辑流程两类验证
测试套件 MUST 同时支持单工具行为验证(如搜索、替换)与多工具组合流程验证(如打开-搜索-编辑-保存-校验)。
@@ -47,3 +47,25 @@ capability 测试执行路径 MUST 直接调用生产代码中的 tool 入口与
- **WHEN** 用例执行 `docx_open -> docx_search -> docx_apply_ops -> docx_save -> docx_validate`
- **THEN** 测试 MUST 断言流程成功收敛且输出产物符合预期
### Requirement: Capability 报告 MUST 分离 case verdict 与 execution outcome
capability 测试报告 MUST 将测试 case 是否通过与能力实际执行语义分离表达,以支持负向验证与阻塞路径在断言通过时仍保留真实执行状态。
#### Scenario: 覆盖保存审批拒绝用例通过
- **WHEN** capability 用例验证覆盖保存被审批拒绝,且断言符合预期
- **THEN** 报告 MUST 将 `caseStatus` 记录为 `passed`,并将 `executionStatus` 记录为 `blocked`、`executionResultCode` 记录为 `approval_denied`
#### Scenario: 工作区边界拒绝用例通过
- **WHEN** capability 用例验证工作区缺失或边界拒绝路径,且断言符合预期
- **THEN** 报告 MUST 将 `caseStatus` 记录为 `passed`,并保留真实的 `workspace_required` 或等价执行结果码
### Requirement: Capability 测试 MUST 支持显式能力缺口与受限行为验证
capability 测试套件 MUST 支持将“保留但未实现”或“当前版本受限”的行为作为显式能力缺口进行验证;这类用例 MUST 通过 `expectedOutcome`、`scenarioTags` 或等价结构化信号标记为 `explicit_gap`,而不是被误判为 harness 自身失败。
#### Scenario: semantic search 保留但未实现
- **WHEN** 用例验证 `docx_search` 的 `semantic` 模式当前未实现
- **THEN** 报告 MUST 记录真实执行结果码,并以结构化信号标记该场景为 `explicit_gap`
#### Scenario: 预览格式当前受限
- **WHEN** 用例验证 `docx_render_preview` 对 `png` 或 `pdf` 预览的受限行为
- **THEN** 报告 MUST 记录该受限结果,并将该场景标记为显式能力缺口而非测试框架错误
+25 -3
View File
@@ -26,15 +26,15 @@ human live 用例 MUST 以真实任务目标作为输入(例如读取 workspac
- **THEN** 测试 MUST 断言任务输出状态、关键结果字段与目标文件变更
### Requirement: Human live 测试 MUST 评估渐进式解决问题能力
human live 用例结果 MUST 包含对“渐进式解决问题”行为的评估,至少覆盖失败恢复、策略切换、完成收敛三个维度。
human live 用例结果 MUST 通过结构化报告信号评估“渐进式解决问题”行为,至少覆盖失败恢复、策略切换、完成收敛三个维度;固定分析文案 MUST NOT 作为唯一评估依据。
#### Scenario: 遇到可恢复失败后继续推进
- **WHEN** human 步骤中出现可恢复失败
- **THEN** 结果评估 MUST 验证后续步骤存在调整策略并继续执行的证据
- **THEN** 结果评估 MUST 通过事件流与结构化信号验证后续步骤存在调整策略并继续执行的证据
#### Scenario: 命中不可恢复阻塞后收敛
- **WHEN** human 步骤命中不可恢复条件(如审批拒绝或工作区缺失)
- **THEN** 结果评估 MUST 验证任务以阻塞终态明确收敛并给出阻塞原因
- **THEN** 结果评估 MUST 通过 `executionStatus`、`executionResultCode`、最终 human 动作与相关事件证据验证任务以阻塞终态明确收敛
### Requirement: Human live 测试 MUST 显式声明前置输入并支持审批策略注入
每个 human live 用例 MUST 声明所需 workspace 与输入文件前置条件,且测试运行器 MUST 支持按用例注入审批策略(如自动通过或风险拒绝)。
@@ -47,3 +47,25 @@ human live 用例结果 MUST 包含对“渐进式解决问题”行为的评估
- **WHEN** 用例配置为拒绝中高风险审批
- **THEN** 测试结果 MUST 呈现阻塞路径并包含审批相关证据
### Requirement: Human live 报告 MUST 分离 case verdict 与 execution outcome
human live 测试报告 MUST 将测试 case 自身是否通过与 human 实际执行结果分离表达,避免预期阻塞路径或前置环境失败被误读为同一种结果。
#### Scenario: 审批拒绝是预期结果
- **WHEN** human live 用例以“审批拒绝后阻塞收敛”为目标,且断言符合预期
- **THEN** 报告 MUST 将 `caseStatus` 记录为 `passed`,并将 `executionStatus` 记录为 `blocked`、`executionResultCode` 记录为 `approval_denied`
#### Scenario: 缺失 live 配置导致未执行
- **WHEN** human live 用例在启动前缺少必需模型配置
- **THEN** 报告 MUST 将 `caseStatus` 记录为 `failed`、`caseErrorCode` 记录为 `live_config_missing`,并将 `executionStatus` 记录为 `not_run`
### Requirement: Human live 报告 MUST 输出结构化步骤信号供后续分析
human live 测试结果 MUST 输出适合后续分析器消费的结构化步骤信号,至少包含 `toolSequence`、失败断言 ID、最终 human 动作,以及与完成或阻塞结果相关的关键执行信号。
#### Scenario: 任务完成并调用 complete_task
- **WHEN** human live 用例通过若干工具步骤完成目标
- **THEN** `signals.json` MUST 包含工具调用顺序、最终 human 动作 `complete_task` 与完成结果码
#### Scenario: 任务阻塞并调用 block_task
- **WHEN** human live 用例在审批、工作区或其他不可恢复条件下阻塞
- **THEN** `signals.json` MUST 包含最终 human 动作 `block_task` 以及与阻塞结果相关的步骤信号
+7 -4
View File
@@ -41,12 +41,15 @@
- `events.jsonl`
- `assertions.json`
- `metrics.json`
- `signals.json`
- `summary.md`
报告中固定包含 AI 分析键:
- `failure_category`
- `suspected_root_cause`
- `optimization_hints`
推荐消费方式:
- `events.jsonl`:读取原始执行事件,适合回放与定位。
- `assertions.json`:读取 `caseStatus`、断言总数、失败断言 ID 与断言明细。
- `metrics.json`:读取统一标量指标,重点关注 `caseErrorCode`、`executionStatus`、`executionResultCode`。
- `signals.json`:读取 `toolSequence`、`toolCounts`、`toolResultCodeCounts`、`expectedOutcome`、`scenarioTags`、`finalHumanAction` 等结构化信号。
- `summary.md`:仅用于人工快速浏览,不承担根因分析或优化建议输出。
## 如何新增一个 tool 测试用例(最小步骤)
+38 -16
View File
@@ -1,6 +1,12 @@
import { randomUUID } from 'node:crypto'
import type { AiRunContext } from '../../../src/main/services/ai/types'
import type { CaseAssertion } from './types'
import { fallbackExecutionOutcome, resolveCaseErrorCode } from './caseErrorCodes'
import type {
CaseAssertion,
CaseErrorCode,
CaseExecutionOutcome,
CaseExpectedOutcome
} from './types'
import { EventCollector } from './eventCollector'
import { assertRequiredFixtures } from './fixtureGuard'
import { createTestAiRunContext } from './contextFactory'
@@ -27,6 +33,12 @@ export const runCapabilityCase = async <TResult>(input: {
requiredFixtures: string[]
fixtureCopies: FixtureCopy[]
approvalPolicy?: 'auto-approve' | 'deny-medium-high' | 'deny-all'
expectedOutcome?: CaseExpectedOutcome
scenarioTags?: string[]
buildExecutionOutcome?: (
result: TResult,
runtime: CapabilityCaseRuntime
) => CaseExecutionOutcome
execute: (runtime: CapabilityCaseRuntime) => Promise<TResult>
assert: (result: TResult, runtime: CapabilityCaseRuntime) => Promise<void>
}): Promise<void> => {
@@ -37,7 +49,12 @@ export const runCapabilityCase = async <TResult>(input: {
const assertions: CaseAssertion[] = []
const eventCollector = new EventCollector()
let workspaceRoot = ''
let status: 'passed' | 'failed' = 'passed'
let caseStatus: 'passed' | 'failed' = 'passed'
let caseErrorCode: CaseErrorCode = 'none'
let execution: CaseExecutionOutcome = {
status: 'unknown',
resultCode: 'unknown'
}
let reportDir = ''
try {
@@ -60,10 +77,20 @@ export const runCapabilityCase = async <TResult>(input: {
}
const result = await input.execute(runtime)
execution = input.buildExecutionOutcome
? input.buildExecutionOutcome(result, runtime)
: execution
await input.assert(result, runtime)
} catch (error) {
status = 'failed'
notes.push(error instanceof Error ? error.message : String(error))
caseStatus = 'failed'
const message = error instanceof Error ? error.message : String(error)
caseErrorCode = resolveCaseErrorCode(message)
execution = fallbackExecutionOutcome({
current: execution,
caseErrorCode,
hadEvents: eventCollector.snapshot().length > 0
})
notes.push(message)
throw error
} finally {
const finishedAt = Date.now()
@@ -74,20 +101,15 @@ export const runCapabilityCase = async <TResult>(input: {
taskId,
startedAt,
finishedAt,
status,
caseStatus,
caseErrorCode,
execution,
signalOverrides: {
expectedOutcome: input.expectedOutcome,
scenarioTags: input.scenarioTags
},
assertions,
events: eventCollector.snapshot(),
analysis: {
failure_category: status === 'passed' ? 'none' : 'capability_execution_error',
suspected_root_cause:
status === 'passed'
? 'none'
: '检查 assertions.json 与 events.jsonl 中的失败断言和事件链路',
optimization_hints:
status === 'passed'
? ['增加更多边界输入覆盖以持续提升稳定性']
: ['检查前置 fixture 是否正确', '检查 tool 返回码与错误摘要', '检查事件流是否出现 workspace 或 approval 阻塞']
},
notes
})
notes.push(`report: ${reportDir}`)
+51
View File
@@ -0,0 +1,51 @@
import type { CaseErrorCode, CaseExecutionOutcome } from './types'
const FIXTURE_MISSING_PREFIX = '缺少测试前置文件'
const LIVE_CONFIG_MISSING_PREFIX = 'human-live 缺少模型配置'
export const resolveCaseErrorCode = (message: string): CaseErrorCode => {
const normalized = String(message || '').trim()
if (normalized.startsWith(FIXTURE_MISSING_PREFIX)) return 'fixture_missing'
if (normalized.startsWith(LIVE_CONFIG_MISSING_PREFIX)) return 'live_config_missing'
if (normalized.includes(' failed')) return 'assertion_failed'
return 'runtime_exception'
}
export const fallbackExecutionOutcome = (input: {
current: CaseExecutionOutcome
caseErrorCode: CaseErrorCode
hadEvents: boolean
}): CaseExecutionOutcome => {
if (input.current.status !== 'unknown' || input.current.resultCode !== 'unknown') {
return input.current
}
if (input.caseErrorCode === 'fixture_missing' || input.caseErrorCode === 'live_config_missing') {
return {
status: 'not_run',
resultCode: 'not_run'
}
}
if (input.caseErrorCode === 'assertion_failed') {
return input.hadEvents
? {
status: 'failed',
resultCode: 'assertion_failed'
}
: {
status: 'unknown',
resultCode: 'unknown'
}
}
return input.hadEvents
? {
status: 'failed',
resultCode: 'runtime_exception'
}
: {
status: 'unknown',
resultCode: 'unknown'
}
}
+37 -16
View File
@@ -2,7 +2,13 @@ import { randomUUID } from 'node:crypto'
import type { HumanTaskOutput } from '../../../src/shared/types'
import type { AiRunContext } from '../../../src/main/services/ai/types'
import { createHumanGraphRunner } from '../../../src/main/services/ai/graph/humanGraph'
import type { CaseAssertion } from './types'
import { fallbackExecutionOutcome, resolveCaseErrorCode } from './caseErrorCodes'
import type {
CaseAssertion,
CaseErrorCode,
CaseExecutionOutcome,
CaseExpectedOutcome
} from './types'
import { EventCollector } from './eventCollector'
import { assertRequiredFixtures } from './fixtureGuard'
import { createTestAiRunContext } from './contextFactory'
@@ -32,6 +38,8 @@ export const runHumanLiveCase = async (input: {
fixtureCopies: FixtureCopy[]
approvalPolicy?: 'auto-approve' | 'deny-medium-high' | 'deny-all'
maxSteps?: number
expectedOutcome?: CaseExpectedOutcome
scenarioTags?: string[]
assert: (result: HumanTaskOutput, runtime: HumanLiveRuntime) => Promise<void>
}): Promise<void> => {
const runId = randomUUID()
@@ -41,7 +49,12 @@ export const runHumanLiveCase = async (input: {
const assertions: CaseAssertion[] = []
const eventCollector = new EventCollector()
let workspaceRoot = ''
let status: 'passed' | 'failed' = 'passed'
let caseStatus: 'passed' | 'failed' = 'passed'
let caseErrorCode: CaseErrorCode = 'none'
let execution: CaseExecutionOutcome = {
status: 'unknown',
resultCode: 'unknown'
}
try {
await assertRequiredFixtures(input.requiredFixtures)
@@ -74,10 +87,23 @@ export const runHumanLiveCase = async (input: {
objective: input.objective
})
execution = {
status: result.status === 'completed' ? 'completed' : 'blocked',
resultCode: result.resultCode,
summary: result.summary
}
await input.assert(result, runtime)
} catch (error) {
status = 'failed'
notes.push(error instanceof Error ? error.message : String(error))
caseStatus = 'failed'
const message = error instanceof Error ? error.message : String(error)
caseErrorCode = resolveCaseErrorCode(message)
execution = fallbackExecutionOutcome({
current: execution,
caseErrorCode,
hadEvents: eventCollector.snapshot().length > 0
})
notes.push(message)
throw error
} finally {
const finishedAt = Date.now()
@@ -88,20 +114,15 @@ export const runHumanLiveCase = async (input: {
taskId,
startedAt,
finishedAt,
status,
caseStatus,
caseErrorCode,
execution,
signalOverrides: {
expectedOutcome: input.expectedOutcome,
scenarioTags: input.scenarioTags
},
assertions,
events: eventCollector.snapshot(),
analysis: {
failure_category: status === 'passed' ? 'none' : 'human_live_execution_error',
suspected_root_cause:
status === 'passed'
? 'none'
: '检查 human_step、tool_call_result 与 approval 事件序列',
optimization_hints:
status === 'passed'
? ['增加复杂目标以持续验证渐进式求解能力']
: ['检查 LLM 配置与网络可用性', '检查 objective 是否明确可执行', '检查审批策略是否阻断关键步骤']
},
notes
})
if (workspaceRoot) {
+11 -35
View File
@@ -1,39 +1,13 @@
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import type { AiRunEvent } from '../../../src/shared/types'
type CaseAssertionsReport = {
runId: string
taskId: string
status: 'passed' | 'failed'
assertions: Array<{
id: string
pass: boolean
details?: string
}>
}
type CaseMetricsReport = {
runId: string
taskId: string
durationMs: number
eventCount: number
toolCallCount: number
humanStepCount: number
terminalStepCount: number
fileArtifactCount: number
failure_category: string
suspected_root_cause: string
optimization_hints: string[]
}
export type CaseReportArtifacts = {
reportDir: string
summary: string
assertions: CaseAssertionsReport
metrics: CaseMetricsReport
events: AiRunEvent[]
}
import type {
CaseAssertionsReport,
CaseMetricsReport,
CaseReportArtifacts,
CaseSuite,
CaseSignalsReport
} from './types'
const readJson = async <T>(filePath: string): Promise<T> => {
const raw = await readFile(filePath, 'utf8')
@@ -65,14 +39,15 @@ const latestReportDir = async (suite: string, caseId: string): Promise<string> =
}
export const readLatestCaseReportArtifacts = async (
suite: string,
suite: CaseSuite,
caseId: string
): Promise<CaseReportArtifacts> => {
const reportDir = await latestReportDir(suite, caseId)
const [summary, assertions, metrics, events] = await Promise.all([
const [summary, assertions, metrics, signals, events] = await Promise.all([
readFile(path.join(reportDir, 'summary.md'), 'utf8'),
readJson<CaseAssertionsReport>(path.join(reportDir, 'assertions.json')),
readJson<CaseMetricsReport>(path.join(reportDir, 'metrics.json')),
readJson<CaseSignalsReport>(path.join(reportDir, 'signals.json')),
readEvents(path.join(reportDir, 'events.jsonl'))
])
@@ -81,6 +56,7 @@ export const readLatestCaseReportArtifacts = async (
summary,
assertions,
metrics,
signals,
events
}
}
+235 -50
View File
@@ -1,7 +1,16 @@
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import type { AiRunEvent } from '../../../src/shared/types'
import type { CaseReportInput, CaseRuntimeMetrics } from './types'
import type { AiRunEvent, IpcJsonValue } from '../../../src/shared/types'
import type {
ArtifactOperationCounts,
CaseAssertionsReport,
CaseMetricsReport,
CaseReportInput,
CaseRuntimeMetrics,
CaseSignalsReport,
ToolCounts,
ToolResultCodeCounts
} from './types'
const ensureDir = async (target: string): Promise<void> => {
await mkdir(target, { recursive: true })
@@ -24,44 +33,232 @@ const toJsonLines = (events: AiRunEvent[]): string => {
return events.map((event) => JSON.stringify(event)).join('\n')
}
const buildMetrics = (input: {
startedAt: number
finishedAt: number
events: AiRunEvent[]
}): CaseRuntimeMetrics => {
const events = input.events
const isJsonRecord = (value: IpcJsonValue): value is Record<string, IpcJsonValue> => {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
const isStringArray = (value: IpcJsonValue | undefined): value is string[] => {
return Array.isArray(value) && value.every((item) => typeof item === 'string')
}
const incrementCount = (counts: Record<string, number>, key: string): void => {
counts[key] = (counts[key] || 0) + 1
}
const incrementNestedCount = (
counts: ToolResultCodeCounts,
toolName: string,
resultCode: string
): void => {
const current = counts[toolName] || {}
current[resultCode] = (current[resultCode] || 0) + 1
counts[toolName] = current
}
const extractToolResultCode = (event: Extract<AiRunEvent, { type: 'tool_call_result' }>): string => {
if (isJsonRecord(event.outputPreview)) {
const code = event.outputPreview.code
if (typeof code === 'string' && code.trim()) return code
}
if (event.error) return 'error'
return 'unknown'
}
const extractWarnings = (event: Extract<AiRunEvent, { type: 'tool_call_result' }>): string[] => {
if (!isJsonRecord(event.outputPreview)) return []
const warnings = event.outputPreview.warnings
return isStringArray(warnings) ? warnings : []
}
const inspectEvents = (events: AiRunEvent[]) => {
const toolSequence: string[] = []
const toolCounts: ToolCounts = {}
const toolResultCodeCounts: ToolResultCodeCounts = {}
const artifactOperationCounts: ArtifactOperationCounts = {
created: 0,
updated: 0
}
let warningCount = 0
let approvalRequiredCount = 0
let approvalApprovedCount = 0
let approvalDeniedCount = 0
let workspaceRequiredCount = 0
let finalHumanAction: CaseSignalsReport['finalHumanAction']
for (const event of events) {
if (event.type === 'tool_call_started') {
toolSequence.push(event.toolName)
incrementCount(toolCounts, event.toolName)
continue
}
if (event.type === 'tool_call_result') {
incrementNestedCount(toolResultCodeCounts, event.toolName, extractToolResultCode(event))
warningCount += extractWarnings(event).length
continue
}
if (event.type === 'file_artifact') {
artifactOperationCounts[event.operation] += 1
continue
}
if (event.type === 'approval_required') {
approvalRequiredCount += 1
continue
}
if (event.type === 'approval_decision') {
if (event.approved) approvalApprovedCount += 1
else approvalDeniedCount += 1
continue
}
if (event.type === 'workspace_required') {
workspaceRequiredCount += 1
continue
}
if (event.type === 'human_step') {
finalHumanAction = event.action
}
}
return {
durationMs: Math.max(0, input.finishedAt - input.startedAt),
toolSequence,
toolCounts,
toolResultCodeCounts,
artifactOperationCounts,
warningCount,
approvalRequiredCount,
approvalApprovedCount,
approvalDeniedCount,
workspaceRequiredCount,
finalHumanAction
}
}
const resolveFinalHumanAction = (input: {
suite: CaseReportInput['suite']
observedAction: CaseSignalsReport['finalHumanAction']
executionStatus: CaseReportInput['execution']['status']
}): CaseSignalsReport['finalHumanAction'] => {
if (input.suite !== 'human-live') return input.observedAction
if (input.executionStatus === 'completed') return 'complete_task'
if (input.executionStatus === 'blocked') return 'block_task'
return input.observedAction
}
const buildMetrics = (input: { report: CaseReportInput }): CaseRuntimeMetrics => {
const events = input.report.events
const inspected = inspectEvents(events)
return {
durationMs: Math.max(0, input.report.finishedAt - input.report.startedAt),
eventCount: events.length,
toolCallCount: events.filter((event) => event.type === 'tool_call_started').length,
humanStepCount: events.filter((event) => event.type === 'human_step').length,
terminalStepCount: events.filter((event) => event.type.startsWith('terminal_step_')).length,
fileArtifactCount: events.filter((event) => event.type === 'file_artifact').length
fileArtifactCount: events.filter((event) => event.type === 'file_artifact').length,
approvalRequiredCount: inspected.approvalRequiredCount,
approvalApprovedCount: inspected.approvalApprovedCount,
approvalDeniedCount: inspected.approvalDeniedCount,
workspaceRequiredCount: inspected.workspaceRequiredCount,
warningCount: inspected.warningCount
}
}
const buildAssertionsReport = (input: CaseReportInput): CaseAssertionsReport => {
const assertionTotal = input.assertions.length
const failedAssertionIds = input.assertions.filter((item) => !item.pass).map((item) => item.id)
const assertionFailed = failedAssertionIds.length
return {
suite: input.suite,
caseId: input.caseId,
runId: input.runId,
taskId: input.taskId,
caseStatus: input.caseStatus,
assertionTotal,
assertionPassed: assertionTotal - assertionFailed,
assertionFailed,
failedAssertionIds,
assertions: input.assertions
}
}
const buildMetricsReport = (input: {
report: CaseReportInput
metrics: CaseRuntimeMetrics
}): CaseMetricsReport => {
return {
suite: input.report.suite,
caseId: input.report.caseId,
runId: input.report.runId,
taskId: input.report.taskId,
startedAt: input.report.startedAt,
finishedAt: input.report.finishedAt,
caseStatus: input.report.caseStatus,
caseErrorCode: input.report.caseErrorCode,
executionStatus: input.report.execution.status,
executionResultCode: input.report.execution.resultCode,
...input.metrics
}
}
const buildSignalsReport = (input: {
report: CaseReportInput
assertions: CaseAssertionsReport
}): CaseSignalsReport => {
const inspected = inspectEvents(input.report.events)
return {
suite: input.report.suite,
caseId: input.report.caseId,
runId: input.report.runId,
taskId: input.report.taskId,
expectedOutcome: input.report.signalOverrides?.expectedOutcome,
scenarioTags: input.report.signalOverrides?.scenarioTags || [],
toolSequence: inspected.toolSequence,
toolCounts: inspected.toolCounts,
toolResultCodeCounts: inspected.toolResultCodeCounts,
failedAssertionIds: input.assertions.failedAssertionIds,
artifactOperationCounts: inspected.artifactOperationCounts,
finalHumanAction: resolveFinalHumanAction({
suite: input.report.suite,
observedAction: inspected.finalHumanAction,
executionStatus: input.report.execution.status
}),
executionSummary: input.report.execution.summary
}
}
const buildSummaryMarkdown = (input: {
report: CaseReportInput
metrics: CaseRuntimeMetrics
assertions: CaseAssertionsReport
metrics: CaseMetricsReport
signals: CaseSignalsReport
}): string => {
const { report, metrics } = input
const assertionPassed = report.assertions.filter((item) => item.pass).length
const assertionTotal = report.assertions.length
const { report, assertions, metrics, signals } = input
const toolSequenceText =
signals.toolSequence.length > 0 ? signals.toolSequence.join(' -> ') : 'none'
const failedAssertionText =
assertions.failedAssertionIds.length > 0 ? assertions.failedAssertionIds.join(', ') : 'none'
const scenarioTagText = signals.scenarioTags.length > 0 ? signals.scenarioTags.join(', ') : 'none'
return [
`# ${report.caseId}`,
'',
`- suite: ${report.suite}`,
`- status: ${report.status}`,
`- caseStatus: ${report.caseStatus}`,
`- caseErrorCode: ${report.caseErrorCode}`,
`- executionStatus: ${report.execution.status}`,
`- executionResultCode: ${report.execution.resultCode}`,
`- runId: ${report.runId}`,
`- taskId: ${report.taskId}`,
`- durationMs: ${metrics.durationMs}`,
`- assertions: ${assertionPassed}/${assertionTotal}`,
'',
'## Analysis',
`- failure_category: ${report.analysis.failure_category}`,
`- suspected_root_cause: ${report.analysis.suspected_root_cause}`,
'- optimization_hints:',
...report.analysis.optimization_hints.map((hint) => ` - ${hint}`),
`- assertions: ${assertions.assertionPassed}/${assertions.assertionTotal}`,
`- failedAssertionIds: ${failedAssertionText}`,
`- toolSequence: ${toolSequenceText}`,
`- scenarioTags: ${scenarioTagText}`,
`- approvalDeniedCount: ${metrics.approvalDeniedCount}`,
`- warningCount: ${metrics.warningCount}`,
'',
'## Notes',
...report.notes.map((note) => `- ${note}`)
@@ -79,46 +276,34 @@ export const writeCaseReport = async (input: CaseReportInput): Promise<string> =
)
await ensureDir(reportRoot)
const metrics = buildMetrics({
startedAt: input.startedAt,
finishedAt: input.finishedAt,
events: input.events
const metrics = buildMetrics({ report: input })
const assertions = buildAssertionsReport(input)
const metricsReport = buildMetricsReport({
report: input,
metrics
})
const signals = buildSignalsReport({
report: input,
assertions
})
const assertionsPath = path.join(reportRoot, 'assertions.json')
const eventsPath = path.join(reportRoot, 'events.jsonl')
const metricsPath = path.join(reportRoot, 'metrics.json')
const signalsPath = path.join(reportRoot, 'signals.json')
const summaryPath = path.join(reportRoot, 'summary.md')
const assertionsPayload = JSON.stringify(
{
runId: input.runId,
taskId: input.taskId,
status: input.status,
assertions: input.assertions
},
null,
2
)
const metricsPayload = JSON.stringify(
{
runId: input.runId,
taskId: input.taskId,
...metrics,
...input.analysis
},
null,
2
)
await writeFile(eventsPath, toJsonLines(input.events), 'utf8')
await writeFile(assertionsPath, assertionsPayload, 'utf8')
await writeFile(metricsPath, metricsPayload, 'utf8')
await writeFile(assertionsPath, JSON.stringify(assertions, null, 2), 'utf8')
await writeFile(metricsPath, JSON.stringify(metricsReport, null, 2), 'utf8')
await writeFile(signalsPath, JSON.stringify(signals, null, 2), 'utf8')
await writeFile(
summaryPath,
buildSummaryMarkdown({
report: input,
metrics
assertions,
metrics: metricsReport,
signals
}),
'utf8'
)
+85 -9
View File
@@ -1,5 +1,5 @@
import type { AiApprovalDecision, AiApprovalRequest } from '../../../src/main/services/ai/types'
import type { AiRunEvent, LLMConfig } from '../../../src/shared/types'
import type { AiRunEvent, BrainStepAction, LLMConfig } from '../../../src/shared/types'
export type ApprovalPolicy = 'auto-approve' | 'deny-medium-high' | 'deny-all'
@@ -11,7 +11,27 @@ export type CaseAssertion = {
details?: string
}
export type CaseSuite = 'capability' | 'human-live'
export type CaseStatus = 'passed' | 'failed'
export type CaseErrorCode =
| 'none'
| 'fixture_missing'
| 'live_config_missing'
| 'assertion_failed'
| 'runtime_exception'
export type CaseExecutionStatus = 'completed' | 'blocked' | 'failed' | 'not_run' | 'unknown'
export type CaseExpectedOutcome = 'success' | 'blocked' | 'explicit_gap' | 'failure'
export type CaseExecutionOutcome = {
status: CaseExecutionStatus
resultCode: string
summary?: string
}
export type CaseSignalOverrides = {
scenarioTags?: string[]
expectedOutcome?: CaseExpectedOutcome
}
export type CaseRuntimeMetrics = {
durationMs: number
@@ -20,28 +40,84 @@ export type CaseRuntimeMetrics = {
humanStepCount: number
terminalStepCount: number
fileArtifactCount: number
approvalRequiredCount: number
approvalApprovedCount: number
approvalDeniedCount: number
workspaceRequiredCount: number
warningCount: number
}
export type CaseAnalysis = {
failure_category: string
suspected_root_cause: string
optimization_hints: string[]
export type ToolCounts = Record<string, number>
export type ToolResultCodeCounts = Record<string, Record<string, number>>
export type ArtifactOperationCounts = Record<'created' | 'updated', number>
export type CaseAssertionsReport = {
suite: CaseSuite
caseId: string
runId: string
taskId: string
caseStatus: CaseStatus
assertionTotal: number
assertionPassed: number
assertionFailed: number
failedAssertionIds: string[]
assertions: CaseAssertion[]
}
export type CaseReportInput = {
suite: string
export type CaseMetricsReport = CaseRuntimeMetrics & {
suite: CaseSuite
caseId: string
runId: string
taskId: string
startedAt: number
finishedAt: number
status: CaseStatus
caseStatus: CaseStatus
caseErrorCode: CaseErrorCode
executionStatus: CaseExecutionStatus
executionResultCode: string
}
export type CaseSignalsReport = {
suite: CaseSuite
caseId: string
runId: string
taskId: string
expectedOutcome?: CaseExpectedOutcome
scenarioTags: string[]
toolSequence: string[]
toolCounts: ToolCounts
toolResultCodeCounts: ToolResultCodeCounts
failedAssertionIds: string[]
artifactOperationCounts: ArtifactOperationCounts
finalHumanAction?: BrainStepAction['action']
executionSummary?: string
}
export type CaseReportInput = {
suite: CaseSuite
caseId: string
runId: string
taskId: string
startedAt: number
finishedAt: number
caseStatus: CaseStatus
caseErrorCode: CaseErrorCode
execution: CaseExecutionOutcome
signalOverrides?: CaseSignalOverrides
assertions: CaseAssertion[]
events: AiRunEvent[]
analysis: CaseAnalysis
notes: string[]
}
export type CaseReportArtifacts = {
reportDir: string
summary: string
assertions: CaseAssertionsReport
metrics: CaseMetricsReport
signals: CaseSignalsReport
events: AiRunEvent[]
}
export type LiveLlmConfig = Pick<LLMConfig, 'base_url' | 'model_name' | 'api_key'> & {
id: string
name: string
@@ -25,8 +25,9 @@ describe('capability/regression', () => {
).rejects.toThrow('缺少测试前置文件')
const report = await readLatestCaseReportArtifacts('capability', CASE_ID)
expect(report.assertions.status).toBe('failed')
expect(report.metrics.failure_category).toBe('capability_execution_error')
expect(report.assertions.caseStatus).toBe('failed')
expect(report.metrics.caseErrorCode).toBe('fixture_missing')
expect(report.metrics.executionStatus).toBe('not_run')
expect(report.summary).toContain('缺少测试前置文件')
expect(report.summary).toContain(MISSING_FIXTURE)
})
@@ -27,8 +27,22 @@ describe('capability/docx', () => {
caseId: 'docx-apply-ops-replace-text',
description:
'验证 docx 打开、搜索、replace_text、保存、校验的组合链路,并输出结构化报告。',
expectedOutcome: 'success',
scenarioTags: ['docx', 'edit-flow', 'replace-text'],
requiredFixtures: [FIXTURE_SOURCE],
fixtureCopies: [{ from: FIXTURE_SOURCE, to: 'input.docx' }],
buildExecutionOutcome: (result) => {
const save = asRecord(result.saveResult)
const validate = asRecord(result.validateResult)
const validateCode = asString(validate.code)
const saveCode = asString(save.code)
const resultCode = validateCode || saveCode || 'unknown'
return {
status: resultCode === 'ok' ? 'completed' : 'failed',
resultCode,
summary: `save=${saveCode || 'unknown'}, validate=${validateCode || 'unknown'}`
}
},
execute: async (runtime) => {
const built = buildDocxCapabilityTools({
ctx: runtime.ctx,
@@ -24,12 +24,22 @@ describe('capability/docx', () => {
await runCapabilityCase({
caseId: CASE_ID,
description: '验证 docx 覆盖保存在审批拒绝时的失败路径输出。',
expectedOutcome: 'blocked',
scenarioTags: ['docx', 'safe-save', 'negative-path'],
requiredFixtures: [FIXTURE_SOURCE],
fixtureCopies: [
{ from: FIXTURE_SOURCE, to: 'input.docx' },
{ from: FIXTURE_SOURCE, to: 'output.docx' }
],
approvalPolicy: 'deny-medium-high',
buildExecutionOutcome: (result) => {
const code = asString(result.saveResult.code as JsonRecord[keyof JsonRecord]) || 'unknown'
return {
status: code === 'approval_denied' ? 'blocked' : code === 'ok' ? 'completed' : 'failed',
resultCode: code,
summary: asString(result.saveResult.error as JsonRecord[keyof JsonRecord]) || undefined
}
},
execute: async (runtime) => {
const built = buildDocxCapabilityTools({
ctx: runtime.ctx,
@@ -91,8 +101,12 @@ describe('capability/docx', () => {
String(event.taskId || '').includes(CASE_ID)
)
expect(report.assertions.status).toBe('passed')
expect(report.assertions.caseStatus).toBe('passed')
expect(report.metrics.executionStatus).toBe('blocked')
expect(report.metrics.executionResultCode).toBe('approval_denied')
expect(report.signals.expectedOutcome).toBe('blocked')
expect(report.signals.scenarioTags).toContain('safe-save')
expect(denied).toBe(true)
expect(report.summary).toContain('审批拒绝')
expect(report.summary).toContain('executionResultCode: approval_denied')
})
})
@@ -1,18 +1,22 @@
import path from 'node:path'
import { describe, test } from 'vitest'
import { describe, expect, test } from 'vitest'
import { isLiveSuiteEnabled } from '../../harness/llmConfig'
import { recordAssertion } from '../../harness/assertions'
import { runHumanLiveCase } from '../../harness/humanLiveCase'
import { readLatestCaseReportArtifacts } from '../../harness/reportInspector'
const FIXTURE_SOURCE = path.resolve(process.cwd(), 'tests/ai/fixtures/workspace/human-input.txt')
const CASE_ID = 'human-approval-denied-block'
describe('human-live', () => {
test.skipIf(!isLiveSuiteEnabled())('human 在风险审批拒绝时应阻塞收敛', async () => {
await runHumanLiveCase({
caseId: 'human-approval-denied-block',
caseId: CASE_ID,
description: '验证 human-live 场景下审批策略注入为 deny 时的阻塞收敛路径。',
objective:
'请在当前 workspace 中创建一个新目录 tmp-created-dir,然后完成任务。若无法执行,请按规则阻塞并说明原因。',
expectedOutcome: 'blocked',
scenarioTags: ['human-live', 'approval', 'negative-path'],
requiredFixtures: [FIXTURE_SOURCE],
fixtureCopies: [{ from: FIXTURE_SOURCE, to: 'input.txt' }],
approvalPolicy: 'deny-medium-high',
@@ -43,5 +47,9 @@ describe('human-live', () => {
})
}
})
const report = await readLatestCaseReportArtifacts('human-live', CASE_ID)
expect(report.metrics.executionStatus).toBe('blocked')
expect(report.signals.finalHumanAction).toBe('block_task')
})
})
@@ -50,8 +50,9 @@ describe('human-live/regression', () => {
})
const report = await readLatestCaseReportArtifacts('human-live', CASE_ID)
expect(report.assertions.status).toBe('failed')
expect(report.metrics.failure_category).toBe('human_live_execution_error')
expect(report.assertions.caseStatus).toBe('failed')
expect(report.metrics.caseErrorCode).toBe('live_config_missing')
expect(report.metrics.executionStatus).toBe('not_run')
expect(report.summary).toContain('AI_LIVE_BASE_URL')
expect(report.summary).toContain('AI_LIVE_MODEL_NAME')
expect(report.summary).toContain('AI_LIVE_API_KEY')
@@ -1,18 +1,22 @@
import path from 'node:path'
import { describe, test } from 'vitest'
import { describe, expect, test } from 'vitest'
import { isLiveSuiteEnabled } from '../../harness/llmConfig'
import { recordAssertion } from '../../harness/assertions'
import { runHumanLiveCase } from '../../harness/humanLiveCase'
import { readLatestCaseReportArtifacts } from '../../harness/reportInspector'
const FIXTURE_SOURCE = path.resolve(process.cwd(), 'tests/ai/fixtures/workspace/human-input.txt')
const CASE_ID = 'human-read-workspace-file'
describe('human-live', () => {
test.skipIf(!isLiveSuiteEnabled())('human 应能读取 workspace 文件并完成任务', async () => {
await runHumanLiveCase({
caseId: 'human-read-workspace-file',
caseId: CASE_ID,
description: '验证 human 子图在真实模型下通过 terminal 工具读取文件并完成任务。',
objective:
'请在当前 workspace 中读取 input.txt 的第一行,并调用 complete_task 结束任务;outputs 必须包含 first_line 字段。',
expectedOutcome: 'success',
scenarioTags: ['human-live', 'workspace-read', 'success-path'],
requiredFixtures: [FIXTURE_SOURCE],
fixtureCopies: [{ from: FIXTURE_SOURCE, to: 'input.txt' }],
approvalPolicy: 'auto-approve',
@@ -44,5 +48,9 @@ describe('human-live', () => {
})
}
})
const report = await readLatestCaseReportArtifacts('human-live', CASE_ID)
expect(report.metrics.executionStatus).toBe('completed')
expect(report.signals.finalHumanAction).toBe('complete_task')
})
})