@tea-agent/loop-agent 0.33.1 → 0.33.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/workflows/dag/frontend-test-html-report.js +38 -23
  3. package/dist/workflows/dag/frontend-test-result-contract.js +77 -6
  4. package/dist/workflows/dag/init-hybrid.js +150 -50
  5. package/dist/workflows/dag/retry-policy.js +5 -0
  6. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +1 -1
  7. package/docs/templates/frontend-test-dag.json +11 -10
  8. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
  9. package/package.json +1 -1
  10. package/skills/analyze-product-dependencies/SKILL.md +74 -33
  11. package/skills/analyze-product-dependencies/references/api-documentation-schema.md +16 -11
  12. package/skills/analyze-product-dependencies/references/dependency-analysis-schema.md +20 -10
  13. package/skills/analyze-product-dependencies/references/example.md +9 -9
  14. package/skills/analyze-product-dependencies/references/forward-test-cases.md +93 -18
  15. package/skills/analyze-product-dependencies/references/input-contract.md +27 -4
  16. package/skills/analyze-product-dependencies/references/kb-integration.md +64 -0
  17. package/skills/analyze-product-dependencies/references/scouting-rules.md +25 -10
  18. package/skills/analyze-product-dependencies/scripts/test-validators.mjs +247 -54
  19. package/skills/analyze-product-dependencies/scripts/validate-api-documentation.mjs +122 -29
  20. package/skills/analyze-product-dependencies/scripts/validate-dependency-analysis.mjs +94 -36
  21. package/skills/analyze-product-dependencies/scripts/validate-product-requirement-input.mjs +37 -23
  22. package/skills/analyze-product-dependencies/scripts/validation-helpers.mjs +35 -43
  23. package/skills/analyze-product-requirements/SKILL.md +98 -43
  24. package/skills/analyze-product-requirements/references/acceptance-criteria.md +8 -12
  25. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +28 -14
  26. package/skills/analyze-product-requirements/references/example.md +24 -6
  27. package/skills/analyze-product-requirements/references/forward-test-cases.md +87 -9
  28. package/skills/analyze-product-requirements/references/kb-integration.md +56 -0
  29. package/skills/analyze-product-requirements/references/product-analysis-schema.md +19 -10
  30. package/skills/analyze-product-requirements/references/product-requirement-schema.md +21 -12
  31. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +45 -14
  32. package/skills/analyze-product-requirements/scripts/compute-source-identity.mjs +35 -0
  33. package/skills/analyze-product-requirements/scripts/test-validators.mjs +337 -29
  34. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +38 -7
  35. package/skills/analyze-product-requirements/scripts/validate-product-requirement.mjs +41 -29
  36. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +33 -22
  37. package/skills/analyze-product-requirements/scripts/validation-helpers.mjs +43 -24
  38. package/skills/playwright-cli/SKILL.md +7 -0
  39. package/skills/analyze-product-dependencies/agents/openai.yaml +0 -4
  40. package/skills/analyze-product-requirements/agents/openai.yaml +0 -4
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.33.3] - 2026-08-10
6
+
7
+ ### 重点更新
8
+
9
+ - 修复 Windows 环境下 frontend-test 配置 URL 冻结异常的问题,确保跨平台地址识别行为一致
10
+
11
+ ### 改进
12
+
13
+ - 改进 frontend-test 的 Windows URL 回归测试,使其具备更好的跨平台可移植性
14
+
15
+ ### 修复
16
+
17
+ - 修复 Windows 环境下 frontend-test 配置 URL(如 baseUrl、targetUrl、loginUrl)无法正确冻结的问题,冲突候选现安全 fail closed,仅在完全未配置时回退 localhost
18
+
19
+ ## [0.33.2] - 2026-08-10
20
+
21
+ ### 重点更新
22
+
23
+ - 更新 analyze-product 技能
24
+ - 针对结构化产物的可恢复失败自动重试
25
+
26
+ ### 改进
27
+
28
+ - 更新 analyze-product 技能
29
+ - 结构化产物在遇到可恢复失败时会自动进行重试
30
+
5
31
  ## [0.33.1] - 2026-08-10
6
32
 
7
33
  ### 重点更新
@@ -25,21 +25,24 @@ function writeMetric(label, value, color = "#172033") {
25
25
  }
26
26
  function renderCaseCard(item) {
27
27
  const colors = statusColor(item.status);
28
- const rerun = typeof item.rerunAttempt === "number" &&
29
- item.rerunAttempt > 0
28
+ const rerun = typeof item.rerunAttempt === "number" && item.rerunAttempt > 0
30
29
  ? `<span class="badge" style="color:#175cd3;background:#eff8ff">重跑 ${item.rerunAttempt}</span>`
31
30
  : "";
32
31
  const error = item.status === "passed"
33
32
  ? ""
34
33
  : `<div class="error-box"><strong>原因分析</strong><p>${escapeHtml(item.errorAnalysis ?? item.blockedReason ?? "用例未能完成执行。")}</p></div>`;
35
- const execution = item.status === "passed"
34
+ const testPoints = item.testPoints ?? [];
35
+ const executionSummary = item.executionSummary ?? "";
36
+ const executionSteps = item.executionSteps ?? [];
37
+ const hasExecution = executionSummary.trim().length > 0 || executionSteps.length > 0;
38
+ const execution = !hasExecution
36
39
  ? ""
37
- : `<div class="error-box"><strong>执行记录</strong><p>${escapeHtml(item.executionSummary ??
38
- item.errorAnalysis ??
39
- item.blockedReason ??
40
- "见 evidence 目录 execution.md / case-result.json")}</p></div>`;
41
- const testPoints = item.testPoints;
42
- const testPointsHtml = Array.isArray(testPoints) && testPoints.length
40
+ : `<div class="execution-box"><strong>执行记录</strong>${executionSummary.trim().length > 0
41
+ ? `<p>${escapeHtml(executionSummary)}</p>`
42
+ : ""}${executionSteps.length > 0
43
+ ? listHtml(executionSteps)
44
+ : ""}</div>`;
45
+ const testPointsHtml = testPoints.length
43
46
  ? `<div><h3>测试点</h3>${listHtml(testPoints)}</div>`
44
47
  : "";
45
48
  return `<details class="case-card" data-status="${item.status}" ${item.status !== "passed" ? "open" : ""}>
@@ -53,8 +56,8 @@ function renderCaseCard(item) {
53
56
  <div><h3>测试步骤</h3>${listHtml(item.caseContent.steps)}</div>
54
57
  <div><h3>预期结果</h3>${listHtml(item.caseContent.expectedResults)}</div>
55
58
  </div>
56
- ${error}
57
59
  ${execution}
60
+ ${error}
58
61
  </div>
59
62
  </details>`;
60
63
  }
@@ -87,18 +90,30 @@ export async function renderFrontendTestHtmlReport(input) {
87
90
  ? (result.acceptanceCoverage.covered.length / result.sourceBinding.requirementIds.length) * 100
88
91
  : 0;
89
92
  const outcomeColors = result.outcome === "passed" ? statusColor("passed") : result.outcome === "failed" ? statusColor("failed") : statusColor("blocked");
90
- const markdownCases = result.cases.map((item) => [
91
- `## ${item.caseId}`,
92
- "",
93
- `- 执行结果:${statusLabel(item.status)}`,
94
- `- 验收标准:${item.acIds.join("、")}`,
95
- "",
96
- "### 测试目的", "", item.caseContent.purpose,
97
- "", "### 前置条件", "", listMarkdown(item.caseContent.preconditions),
98
- "", "### 操作步骤", "", listMarkdown(item.caseContent.steps),
99
- "", "### 预期结果", "", listMarkdown(item.caseContent.expectedResults),
100
- ...(item.status === "passed" ? [] : ["", "### 错误分析", "", item.errorAnalysis ?? item.blockedReason ?? "用例未能完成执行。"]),
101
- ].join("\n")).join("\n\n");
93
+ const markdownCases = result.cases.map((item) => {
94
+ const testPoints = item.testPoints ?? [];
95
+ const executionSummary = item.executionSummary ?? "";
96
+ const executionSteps = item.executionSteps ?? [];
97
+ return [
98
+ `## ${item.caseId}`,
99
+ "",
100
+ `- 执行结果:${statusLabel(item.status)}`,
101
+ `- 验收标准:${item.acIds.join("、")}`,
102
+ ...(typeof item.rerunAttempt === "number" && item.rerunAttempt > 0
103
+ ? [`- 重跑次数:${item.rerunAttempt}`]
104
+ : []),
105
+ "",
106
+ "### 测试目的", "", item.caseContent.purpose,
107
+ ...(testPoints.length ? ["", "### 测试点", "", listMarkdown(testPoints)] : []),
108
+ "", "### 前置条件", "", listMarkdown(item.caseContent.preconditions),
109
+ "", "### 测试步骤", "", listMarkdown(item.caseContent.steps),
110
+ "", "### 预期结果", "", listMarkdown(item.caseContent.expectedResults),
111
+ ...(executionSummary.trim().length || executionSteps.length
112
+ ? ["", "### 执行记录", "", executionSummary.trim(), ...(executionSteps.length ? ["", listMarkdown(executionSteps)] : [])]
113
+ : []),
114
+ ...(item.status === "passed" ? [] : ["", "### 错误分析", "", item.errorAnalysis ?? item.blockedReason ?? "用例未能完成执行。"]),
115
+ ];
116
+ }).map((lines) => lines.filter((line) => line !== undefined).join("\n")).join("\n\n");
102
117
  const markdown = [
103
118
  "# 前端功能测试报告", "",
104
119
  `- 测试结论:${outcomeLabel}`,
@@ -117,7 +132,7 @@ export async function renderFrontendTestHtmlReport(input) {
117
132
  ? `<section class="panel"><div class="section-heading"><h2>失败概览</h2><span>${failedCount} 条未通过用例</span></div><div class="failure-list">${result.cases.filter((item) => item.status !== "passed").map((item) => `<div class="failure-row"><span class="case-id">${escapeHtml(item.caseId)}</span><span>${escapeHtml(item.errorAnalysis ?? item.blockedReason ?? "用例未能完成执行。")}</span></div>`).join("")}</div></section>`
118
133
  : "";
119
134
  const html = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>前端功能测试报告</title><style>
120
- body{margin:0;background:radial-gradient(circle at 8% 0,#edf4ff 0,transparent 36rem),#f5f7fb;color:#17243b;font:15px/1.6 Inter,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif}main{max-width:1180px;margin:0 auto;padding:42px 28px 72px}.hero{padding:30px 34px;border:1px solid #294b78;border-radius:24px;background:linear-gradient(125deg,#102849 0%,#173d6d 57%,#245b91 100%);box-shadow:0 18px 40px rgba(16,40,73,.18);display:flex;align-items:flex-start;justify-content:space-between;gap:28px}.eyebrow{color:#9fc6ee;font-size:11px;font-weight:800;letter-spacing:.2em}.hero h1{margin:10px 0 8px;color:#f7fbff;font-size:clamp(28px,3.8vw,44px);line-height:1.1}.hero p{margin:0;color:#bdd2e9;font-size:13px}.decision{min-width:190px;padding:15px 17px;border:1px solid ${outcomeColors.border};border-radius:16px;background:${outcomeColors.bg};color:${outcomeColors.fg}}.decision strong{display:block;font-size:17px}.decision span{display:block;margin-top:4px;font-size:12px}.panel{background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:18px}.metrics{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-top:18px}.metric{position:relative;min-height:92px;padding:17px 18px;background:#fff;border:1px solid #e1e8f2;border-radius:13px}.metric:before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:#4775ef;border-radius:13px 13px 0 0}.metric span{display:block;color:#748198;font-size:12px}.metric strong{display:block;margin-top:12px;font-size:28px;line-height:1;font-weight:800}.section-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px}.section-heading h2{margin:0;color:#17365d;font-size:19px}.section-heading span{color:#718097;font-size:12px}.signal-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.signal{padding:14px;border:1px solid #e4e9f1;border-radius:11px;background:#fbfcfe}.signal header{display:flex;justify-content:space-between;color:#2a3c5a;font-size:12px}.bar{height:7px;margin:10px 0 6px;background:#edf1f6;border-radius:99px;overflow:hidden}.bar i{display:block;height:100%;border-radius:inherit}.signal small{color:#718097;font-size:11px}.case-card{border:1px solid #e4e9f1;border-radius:13px;background:#fff;overflow:hidden;margin-top:10px}.case-card summary{display:flex;align-items:center;gap:12px;padding:14px 16px;cursor:pointer;list-style:none}.case-card summary::-webkit-details-marker{display:none}.case-id{color:#2b3a55;font:700 12px ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap}.case-title{flex:1;min-width:0;color:#172033;font-weight:650;overflow:hidden;text-overflow:ellipsis}.badge{padding:3px 10px;border-radius:999px;font-size:11px;font-weight:800;white-space:nowrap}.chevron{color:#aab2bf}.case-body{padding:3px 16px 17px;border-top:1px solid #f0f3f7}.case-meta{margin-top:12px;padding:9px 11px;background:#f6f8fa;border-radius:8px;color:#667085;font-size:11px}.case-meta strong{margin-left:8px;color:#344054}.case-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:13px}.case-grid>div{padding:11px 12px;border:1px solid #edf0f4;border-radius:9px;background:#fbfcfe}.case-grid h3{margin:0 0 5px;color:#667085;font-size:11px}.case-grid p,.case-grid ol{margin:0;color:#475467;font-size:13px}.case-grid ol{padding-left:20px}.muted{color:#98a2b3!important}.error-box{margin-top:13px;padding:11px 13px;border:1px solid #fda29b;border-radius:9px;background:#fef3f2;color:#b42318}.error-box strong{font-size:12px}.error-box p{margin:4px 0 0;white-space:pre-wrap;font-size:13px}.failure-list{display:grid;gap:9px}.failure-row{display:flex;align-items:baseline;gap:12px;padding:10px 12px;border-left:4px solid #d34661;border-radius:8px;background:#fff8f9;color:#475467;font-size:13px}.success-note{padding:11px 13px;border-radius:9px;background:#ecfdf3;color:#067647;font-size:13px}.finding-list{padding:11px 13px;border-radius:9px;background:#fffaeb;color:#946200;font-size:13px}.finding-list ol{margin:5px 0 0;padding-left:20px}@media(max-width:850px){.hero{display:block}.decision{margin-top:20px;min-width:0}.metrics{grid-template-columns:repeat(2,1fr)}.signal-grid,.case-grid{grid-template-columns:1fr}}@media(max-width:520px){main{padding:22px 14px 42px}.metrics{grid-template-columns:1fr}.case-card summary{flex-wrap:wrap}.case-title{order:3;flex-basis:100%}}
135
+ body{margin:0;background:radial-gradient(circle at 8% 0,#edf4ff 0,transparent 36rem),#f5f7fb;color:#17243b;font:15px/1.6 Inter,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif}main{max-width:1180px;margin:0 auto;padding:42px 28px 72px}.hero{padding:30px 34px;border:1px solid #294b78;border-radius:24px;background:linear-gradient(125deg,#102849 0%,#173d6d 57%,#245b91 100%);box-shadow:0 18px 40px rgba(16,40,73,.18);display:flex;align-items:flex-start;justify-content:space-between;gap:28px}.eyebrow{color:#9fc6ee;font-size:11px;font-weight:800;letter-spacing:.2em}.hero h1{margin:10px 0 8px;color:#f7fbff;font-size:clamp(28px,3.8vw,44px);line-height:1.1}.hero p{margin:0;color:#bdd2e9;font-size:13px}.decision{min-width:190px;padding:15px 17px;border:1px solid ${outcomeColors.border};border-radius:16px;background:${outcomeColors.bg};color:${outcomeColors.fg}}.decision strong{display:block;font-size:17px}.decision span{display:block;margin-top:4px;font-size:12px}.panel{background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:18px}.metrics{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-top:18px}.metric{position:relative;min-height:92px;padding:17px 18px;background:#fff;border:1px solid #e1e8f2;border-radius:13px}.metric:before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:#4775ef;border-radius:13px 13px 0 0}.metric span{display:block;color:#748198;font-size:12px}.metric strong{display:block;margin-top:12px;font-size:28px;line-height:1;font-weight:800}.section-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px}.section-heading h2{margin:0;color:#17365d;font-size:19px}.section-heading span{color:#718097;font-size:12px}.signal-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.signal{padding:14px;border:1px solid #e4e9f1;border-radius:11px;background:#fbfcfe}.signal header{display:flex;justify-content:space-between;color:#2a3c5a;font-size:12px}.bar{height:7px;margin:10px 0 6px;background:#edf1f6;border-radius:99px;overflow:hidden}.bar i{display:block;height:100%;border-radius:inherit}.signal small{color:#718097;font-size:11px}.case-card{border:1px solid #e4e9f1;border-radius:13px;background:#fff;overflow:hidden;margin-top:10px}.case-card summary{display:flex;align-items:center;gap:12px;padding:14px 16px;cursor:pointer;list-style:none}.case-card summary::-webkit-details-marker{display:none}.case-id{color:#2b3a55;font:700 12px ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap}.case-title{flex:1;min-width:0;color:#172033;font-weight:650;overflow:hidden;text-overflow:ellipsis}.badge{padding:3px 10px;border-radius:999px;font-size:11px;font-weight:800;white-space:nowrap}.chevron{color:#aab2bf}.case-body{padding:3px 16px 17px;border-top:1px solid #f0f3f7}.case-meta{margin-top:12px;padding:9px 11px;background:#f6f8fa;border-radius:8px;color:#667085;font-size:11px}.case-meta strong{margin-left:8px;color:#344054}.case-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:13px}.case-grid>div{padding:11px 12px;border:1px solid #edf0f4;border-radius:9px;background:#fbfcfe}.case-grid h3{margin:0 0 5px;color:#667085;font-size:11px}.case-grid p,.case-grid ol{margin:0;color:#475467;font-size:13px}.case-grid ol{padding-left:20px}.muted{color:#98a2b3!important}.error-box{margin-top:13px;padding:11px 13px;border:1px solid #fda29b;border-radius:9px;background:#fef3f2;color:#b42318}.execution-box{margin-top:13px;padding:11px 13px;border:1px solid #b9d6f5;border-radius:9px;background:#f5f9ff;color:#1f4e79}.execution-box strong{font-size:12px}.execution-box p{margin:4px 0 0;white-space:pre-wrap;font-size:13px}.execution-box ol{margin:6px 0 0;padding-left:20px;color:#28456b;font-size:13px}.error-box strong{font-size:12px}.error-box p{margin:4px 0 0;white-space:pre-wrap;font-size:13px}.failure-list{display:grid;gap:9px}.failure-row{display:flex;align-items:baseline;gap:12px;padding:10px 12px;border-left:4px solid #d34661;border-radius:8px;background:#fff8f9;color:#475467;font-size:13px}.success-note{padding:11px 13px;border-radius:9px;background:#ecfdf3;color:#067647;font-size:13px}.finding-list{padding:11px 13px;border-radius:9px;background:#fffaeb;color:#946200;font-size:13px}.finding-list ol{margin:5px 0 0;padding-left:20px}@media(max-width:850px){.hero{display:block}.decision{margin-top:20px;min-width:0}.metrics{grid-template-columns:repeat(2,1fr)}.signal-grid,.case-grid{grid-template-columns:1fr}}@media(max-width:520px){main{padding:22px 14px 42px}.metrics{grid-template-columns:1fr}.case-card summary{flex-wrap:wrap}.case-title{order:3;flex-basis:100%}}
121
136
 
122
137
  .filter-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:12px 0 16px}
123
138
  .filter-label{font-size:13px;color:#475467}
@@ -37,6 +37,7 @@ export const frontendTestResultContractSchema = z.object({
37
37
  rerunAttempt: z.number().int().nonnegative().optional(),
38
38
  testPoints: z.array(z.string()).optional(),
39
39
  executionSummary: z.string().optional(),
40
+ executionSteps: z.array(z.string()).optional(),
40
41
  caseContent: z.object({
41
42
  purpose: z.string(),
42
43
  preconditions: z.array(z.string()),
@@ -233,18 +234,56 @@ function markdownList(value) {
233
234
  const items = value.split(/\r?\n/).map((line) => line.replace(/^\s*(?:\d+[.)]|[-*+])\s+/, "").trim()).filter(Boolean);
234
235
  return items;
235
236
  }
237
+ /**
238
+ * Read planned case facts from the case Markdown. Recognizes both English and
239
+ * Chinese headings, including `测试点` (test points) and `测试步骤` (planned
240
+ * test steps), alongside the legacy `操作步骤`/`Steps` headings.
241
+ */
236
242
  async function readFrontendCaseContent(workspaceRoot, casePath) {
237
243
  if (!safeRelativePathSchema.safeParse(casePath).success || !casePath.startsWith("testcase/frontend/cases/")) {
238
244
  throw new Error(`unsafe frontend case path: ${casePath}`);
239
245
  }
240
246
  const body = await readFile(path.resolve(workspaceRoot, casePath), "utf8");
241
247
  return {
242
- purpose: markdownSection(body, ["Test Purpose", "测试目的", "测试场景"]) || "未提供测试目的",
243
- preconditions: markdownList(markdownSection(body, ["Preconditions", "前置条件"])),
244
- steps: markdownList(markdownSection(body, ["Steps", "操作步骤"])),
245
- expectedResults: markdownList(markdownSection(body, ["Expected Results", "预期结果"])),
248
+ caseContent: {
249
+ purpose: markdownSection(body, ["Test Purpose", "测试目的", "测试场景"]) || "未提供测试目的",
250
+ preconditions: markdownList(markdownSection(body, ["Preconditions", "前置条件"])),
251
+ steps: markdownList(markdownSection(body, ["Test Steps", "测试步骤", "Steps", "操作步骤"])),
252
+ expectedResults: markdownList(markdownSection(body, ["Expected Results", "预期结果"])),
253
+ },
254
+ testPoints: markdownList(markdownSection(body, ["Test Points", "测试点"])),
246
255
  };
247
256
  }
257
+ /**
258
+ * Parse bounded fixed sections from execution.md. Only a small allowlist of
259
+ * headings is recognized and each section is length-capped so model prose
260
+ * cannot flood the report. Missing sections return empty values (advisory only).
261
+ */
262
+ const EXECUTION_SUMMARY_MAX_LENGTH = 1200;
263
+ const EXECUTION_STEPS_MAX_ITEMS = 40;
264
+ function parseExecutionMarkdown(body) {
265
+ const summarySection = markdownSection(body, [
266
+ "Execution Summary",
267
+ "Actual Execution Summary",
268
+ "执行摘要",
269
+ "实际执行摘要",
270
+ "执行总结",
271
+ ]);
272
+ const rawSummary = summarySection.replace(/\r\n/g, "\n").trim();
273
+ const summary = rawSummary.length > EXECUTION_SUMMARY_MAX_LENGTH
274
+ ? `${rawSummary.slice(0, EXECUTION_SUMMARY_MAX_LENGTH - 1)}…`
275
+ : rawSummary;
276
+ const stepsSection = markdownSection(body, [
277
+ "Actual Steps",
278
+ "Actual Execution Steps",
279
+ "Execution Steps",
280
+ "实际执行步骤",
281
+ "实际步骤",
282
+ "执行步骤",
283
+ ]);
284
+ const steps = markdownList(stepsSection).slice(0, EXECUTION_STEPS_MAX_ITEMS);
285
+ return { summary, steps };
286
+ }
248
287
  function assertInside(root, candidate, label) {
249
288
  const relative = path.relative(root, candidate);
250
289
  if (relative.startsWith("..") || path.isAbsolute(relative)) {
@@ -371,7 +410,23 @@ export async function materializeFrontendTestResult(input) {
371
410
  });
372
411
  }
373
412
  }
374
- const caseContent = await readFrontendCaseContent(input.workspaceRoot, item.casePath);
413
+ const { caseContent, testPoints } = await readFrontendCaseContent(input.workspaceRoot, item.casePath);
414
+ // Parse bounded fixed sections from execution.md. Missing optional
415
+ // summary/steps are advisory only and never downgrade a passed status.
416
+ let executionSummary = "";
417
+ let executionSteps = [];
418
+ try {
419
+ const executionBody = await readFile(path.join(evidenceRoot, "execution.md"), "utf8");
420
+ const parsed = parseExecutionMarkdown(executionBody);
421
+ executionSummary = parsed.summary;
422
+ executionSteps = parsed.steps;
423
+ if (!executionSummary && executionSteps.length === 0) {
424
+ advisoryFindings.push({ ruleId: "execution-summary-unavailable", caseId: item.caseId, detail: "execution.md has no bounded execution summary/steps; reporting remains status-driven" });
425
+ }
426
+ }
427
+ catch {
428
+ advisoryFindings.push({ ruleId: "execution-summary-unavailable", caseId: item.caseId, detail: "execution.md summary could not be parsed; reporting remains status-driven" });
429
+ }
375
430
  const explicitAnalysis = typeof resultRaw.errorAnalysis === "string" && resultRaw.errorAnalysis.trim()
376
431
  ? resultRaw.errorAnalysis.trim()
377
432
  : typeof resultRaw.errorSummary === "string" && resultRaw.errorSummary.trim()
@@ -380,7 +435,23 @@ export async function materializeFrontendTestResult(input) {
380
435
  const errorAnalysis = status === "passed"
381
436
  ? undefined
382
437
  : explicitAnalysis ?? (status === "blocked" ? `用例因 ${blockedReason ?? "未知原因"} 未能完成执行。` : "用例执行失败,但执行结果未提供详细错误分析。");
383
- cases.push({ caseId: item.caseId, acIds: item.acIds, status, evidence, caseContent, ...(blockedReason ? { blockedReason } : {}), ...(errorAnalysis ? { errorAnalysis } : {}) });
438
+ const rerunAttemptRaw = resultRaw.rerunAttempt;
439
+ const rerunAttempt = typeof rerunAttemptRaw === "number" && Number.isFinite(rerunAttemptRaw) && rerunAttemptRaw >= 0
440
+ ? Math.trunc(rerunAttemptRaw)
441
+ : undefined;
442
+ cases.push({
443
+ caseId: item.caseId,
444
+ acIds: item.acIds,
445
+ status,
446
+ evidence,
447
+ caseContent,
448
+ ...(blockedReason ? { blockedReason } : {}),
449
+ ...(errorAnalysis ? { errorAnalysis } : {}),
450
+ ...(rerunAttempt !== undefined ? { rerunAttempt } : {}),
451
+ ...(testPoints.length > 0 ? { testPoints } : {}),
452
+ ...(executionSummary ? { executionSummary } : {}),
453
+ ...(executionSteps.length > 0 ? { executionSteps } : {}),
454
+ });
384
455
  }
385
456
  const missing = [...requiredIds].filter((id) => !covered.has(id));
386
457
  const totals = {
@@ -3939,34 +3939,129 @@ async function buildBackendTestHybridDag(sources) {
3939
3939
  assertValidDagSpec(spec);
3940
3940
  return spec;
3941
3941
  }
3942
+ // Explicit allowlist of controller-owned reference filenames that may declare
3943
+ // a non-production browser URL. Hash-bound task sources are the only authority;
3944
+ // model-authored RAG/case/evidence files are never consulted.
3945
+ const FRONTEND_BASE_URL_DOCUMENT_ALLOWLIST = [
3946
+ /(?:^|[\\/])config\.md$/i,
3947
+ /(?:^|[\\/])environment\.md$/i,
3948
+ /(?:^|[\\/])env\.md$/i,
3949
+ /(?:^|[\\/])urls?\.md$/i,
3950
+ /(?:^|[\\/])references\.md$/i,
3951
+ /(?:^|[\\/])task-reference\.md$/i,
3952
+ /(?:^|[\\/])task-source\.md$/i,
3953
+ ];
3954
+ // Explicit allowlist of URL keys. The semantic name drives a deterministic
3955
+ // priority so two conflicting same-priority URLs fail closed instead of being
3956
+ // silently picked.
3957
+ const FRONTEND_BASE_URL_KEY_ALLOWLIST = [
3958
+ // The primary frontend browser entry point is the strongest signal.
3959
+ { name: "baseurl", priority: 0 },
3960
+ { name: "base url", priority: 0 },
3961
+ { name: "targeturl", priority: 0 },
3962
+ { name: "target url", priority: 0 },
3963
+ { name: "frontendbaseurl", priority: 1 },
3964
+ { name: "frontend base url", priority: 1 },
3965
+ { name: "frontendurl", priority: 1 },
3966
+ { name: "frontend url", priority: 1 },
3967
+ { name: "loginurl", priority: 2 },
3968
+ { name: "login url", priority: 2 },
3969
+ ];
3970
+ const DEFAULT_FRONTEND_BASE_URL = "http://localhost:5173/";
3971
+ const DEFAULT_FRONTEND_BASE_URL_SOURCE = "default-localhost-5173";
3972
+ function isAllowedFrontendBaseUrlDocument(documentPath) {
3973
+ return FRONTEND_BASE_URL_DOCUMENT_ALLOWLIST.some((pattern) => pattern.test(documentPath));
3974
+ }
3975
+ function normalizeFrontendBaseUrlKey(raw) {
3976
+ // Normalize separators: kebab/snake/space variants collapse to a comparable
3977
+ // token. Lowercase so casing differences do not bypass the allowlist.
3978
+ return raw
3979
+ .toLowerCase()
3980
+ .replace(/[_\-]+/g, " ")
3981
+ .replace(/\s+/g, " ")
3982
+ .trim();
3983
+ }
3942
3984
  /**
3943
3985
  * Resolve the browser origin only from controller-owned task source bytes.
3944
3986
  * Model-authored RAG/case/evidence files are intentionally excluded.
3987
+ *
3988
+ * Safety contract:
3989
+ * - Only hash-bound controller reference documents on the filename allowlist
3990
+ * may declare a URL.
3991
+ * - Only the explicit key allowlist (baseUrl / targetUrl / loginUrl + common
3992
+ * case/separator variants) is recognized.
3993
+ * - Candidates are ranked deterministically by key priority then by document
3994
+ * order; equal-priority conflicting URLs fail closed.
3995
+ * - The resolved URL must be non-production http(s) without credentials, query,
3996
+ * or fragment. Only when zero candidates exist does the localhost default
3997
+ * apply.
3945
3998
  */
3946
3999
  export function resolveControllerFrontendBaseUrl(sources) {
3947
- const candidates = (sources.referenceDocuments ?? [])
3948
- .filter((document) => /(?:^|[\\/])config\.md$/i.test(document.path))
3949
- .map((document) => ({
3950
- markdown: document.markdown,
3951
- source: toDagSourcePath(sources, document.path),
3952
- }));
3953
- let rawBaseUrl = "http://localhost:5173";
3954
- let baseUrlSource = "default-localhost-5173";
3955
- for (const candidate of candidates) {
3956
- const match = candidate.markdown.match(/(?:frontend[_-]?base[_-]?url|base[_-]?url|base[- ]url)\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i);
3957
- if (!match?.[1]) {
3958
- if (/(?:frontend[_-]?base[_-]?url|base[_-]?url|base[- ]url)\s*[:=]/i.test(candidate.markdown)) {
3959
- throw new Error(`frontend-test controller baseUrl is invalid (${candidate.source})`);
4000
+ const allowedKeyByNormalized = new Map(FRONTEND_BASE_URL_KEY_ALLOWLIST.map((variant) => [
4001
+ variant.name,
4002
+ variant.priority,
4003
+ ]));
4004
+ const candidates = [];
4005
+ const documents = sources.referenceDocuments ?? [];
4006
+ documents.forEach((document, documentIndex) => {
4007
+ if (!isAllowedFrontendBaseUrlDocument(document.path))
4008
+ return;
4009
+ const source = toDagSourcePath(sources, document.path);
4010
+ // Only accept `key: value` / `key = value` lines. A colon or equals
4011
+ // sign after a known key bounds the URL; free-form prose is ignored.
4012
+ const keyPattern = /^[ ]*(?:#>*)?[ ]*([A-Za-z][A-Za-z0-9 _\-]*?)[ ]*[:=][ ]*["'`]?((?:https?:)?\/\/[\S]+?)(?:["'`])?[ ]*(?:#.*)?$/gm;
4013
+ let match;
4014
+ while ((match = keyPattern.exec(document.markdown)) !== null) {
4015
+ const rawKey = match[1].trim();
4016
+ const normalizedKey = normalizeFrontendBaseUrlKey(rawKey);
4017
+ const priority = allowedKeyByNormalized.get(normalizedKey);
4018
+ if (priority === undefined)
4019
+ continue;
4020
+ let rawUrl = match[2].replace(/[)\]},.;]+$/, "");
4021
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(rawUrl)) {
4022
+ rawUrl = `https://${rawUrl.replace(/^\/\/+/, "")}`;
3960
4023
  }
3961
- continue;
4024
+ candidates.push({
4025
+ url: rawUrl,
4026
+ source,
4027
+ keyName: normalizedKey,
4028
+ priority,
4029
+ documentIndex,
4030
+ });
3962
4031
  }
3963
- rawBaseUrl = match[1].replace(/[)\]},.;]+$/, "");
3964
- baseUrlSource = candidate.source;
3965
- break;
4032
+ });
4033
+ if (candidates.length === 0) {
4034
+ return {
4035
+ baseUrl: DEFAULT_FRONTEND_BASE_URL,
4036
+ baseUrlSource: DEFAULT_FRONTEND_BASE_URL_SOURCE,
4037
+ };
3966
4038
  }
4039
+ // Deterministic ranking: lowest priority number first, then earliest
4040
+ // document, then earliest occurrence in that document.
4041
+ candidates.sort((left, right) => {
4042
+ if (left.priority !== right.priority) {
4043
+ return left.priority - right.priority;
4044
+ }
4045
+ if (left.documentIndex !== right.documentIndex) {
4046
+ return left.documentIndex - right.documentIndex;
4047
+ }
4048
+ return 0;
4049
+ });
4050
+ const bestPriority = candidates[0].priority;
4051
+ const bestPriorityCandidates = candidates.filter((candidate) => candidate.priority === bestPriority);
4052
+ // Fail closed: two distinct URLs at the same priority level are ambiguous.
4053
+ const distinctUrls = new Set(bestPriorityCandidates.map((candidate) => candidate.url));
4054
+ if (distinctUrls.size > 1) {
4055
+ const conflictingSources = bestPriorityCandidates
4056
+ .map((candidate) => `${candidate.source}:${candidate.keyName}`)
4057
+ .join(", ");
4058
+ throw new Error(`frontend-test controller baseUrl is ambiguous (${conflictingSources})`);
4059
+ }
4060
+ const selected = bestPriorityCandidates[0];
4061
+ const baseUrlSource = selected.source;
3967
4062
  let parsed;
3968
4063
  try {
3969
- parsed = new URL(rawBaseUrl);
4064
+ parsed = new URL(selected.url);
3970
4065
  }
3971
4066
  catch {
3972
4067
  throw new Error(`frontend-test controller baseUrl is invalid (${baseUrlSource})`);
@@ -4399,10 +4494,28 @@ function buildFrontendTestHybridDag(sources) {
4399
4494
  },
4400
4495
  },
4401
4496
  });
4402
- if (maxRerunAttempts > 0) {
4497
+ let rerunDependency = "execute-frontend-cases-map";
4498
+ for (let round = 1; round <= maxRerunAttempts; round += 1) {
4499
+ const selectorId = round === 1
4500
+ ? "select-frontend-rerun-candidates-shell"
4501
+ : `select-frontend-rerun-candidates-round${round}-shell`;
4502
+ const mapId = round === 1
4503
+ ? "rerun-frontend-cases-map"
4504
+ : `rerun-frontend-cases-round${round}-map`;
4505
+ const candidateArtifact = round === 1
4506
+ ? "rerun-candidates.json"
4507
+ : `rerun-candidates-round${round}.json`;
4508
+ const selectCommand = [
4509
+ "const fs=require('fs'),path=require('path');",
4510
+ "const manifestPath='testcase/frontend/cases/manifest.json';",
4511
+ "if(!fs.existsSync(manifestPath)){process.stdout.write(JSON.stringify({cases:[]}));process.exit(0);}",
4512
+ "const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));const cases=[];",
4513
+ "for(const c of (manifest.cases||[])){const evidenceDir=(c.evidenceDir||('testcase/frontend/evidence/'+c.caseId+'/')).replace(/\/+$/,'')+'/';const resultPath=path.join(evidenceDir,'case-result.json');const execPath=path.join(evidenceDir,'execution.md');let reason=null;let attempt=0;let missing=false;if(!fs.existsSync(resultPath)){missing=true;reason='missing-result-files';}else{try{const r=JSON.parse(fs.readFileSync(resultPath,'utf8'));attempt=Number(r.rerunAttempt||0)||0;if(r.status==='blocked')reason='blocked';if(!r.status){missing=true;reason='missing-result-files';}}catch(_){missing=true;reason='missing-result-files';}}if(!fs.existsSync(execPath)&&reason!=='blocked'){missing=true;reason=reason||'missing-result-files';}const should=(reason==='blocked'||missing)&&attempt<" + String(round) + ";if(should){cases.push({caseId:c.caseId,casePath:c.casePath||('testcase/frontend/cases/'+c.caseId+'.md'),evidenceDir,dimension:c.dimension||'core',acIds:c.acIds||[],rerunAttempt:attempt+1,reason:reason||'blocked'});}}",
4514
+ `fs.mkdirSync('testcase/frontend/evidence',{recursive:true});fs.writeFileSync('testcase/frontend/evidence/${candidateArtifact}',JSON.stringify({schemaVersion:1,cases},null,2)+'\n');process.stdout.write(JSON.stringify({cases}));`,
4515
+ ].join("");
4403
4516
  tasks.push({
4404
- id: "select-frontend-rerun-candidates-shell",
4405
- depends_on: ["execute-frontend-cases-map"],
4517
+ id: selectorId,
4518
+ depends_on: [rerunDependency],
4406
4519
  role: "verifier",
4407
4520
  executor: "shell",
4408
4521
  complexity: "LOW",
@@ -4410,42 +4523,33 @@ function buildFrontendTestHybridDag(sources) {
4410
4523
  writeSet: ["testcase/frontend/evidence/**"],
4411
4524
  allowedPaths: [...casesWriteSet, "testcase/frontend/evidence/**"],
4412
4525
  forbiddenPaths: forbidden,
4413
- outputContract: "Stdout JSON {cases} for blocked or missing-result-file cases with rerunAttempt < maxRerunAttempts.",
4414
- subtask_prompt: "Select frontend-test cases eligible for bounded rerun.",
4526
+ outputContract: `Stdout JSON {cases} for blocked or missing-result-file cases eligible for rerun round ${round}/${maxRerunAttempts}.`,
4527
+ subtask_prompt: "Select frontend-test cases eligible for this bounded rerun round.",
4415
4528
  shell: {
4416
- commands: [
4417
- [
4418
- "node -e",
4419
- JSON.stringify("const fs=require('fs'),path=require('path');const manifestPath='testcase/frontend/cases/manifest.json';if(!fs.existsSync(manifestPath)){process.stdout.write(JSON.stringify({cases:[]}));process.exit(0);}const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));const cases=[];for(const c of (manifest.cases||[])){const evidenceDir=(c.evidenceDir||('testcase/frontend/evidence/'+c.caseId+'/')).replace(/\\/+$/,'')+'/';const resultPath=path.join(evidenceDir,'case-result.json');const execPath=path.join(evidenceDir,'execution.md');let reason=null;let attempt=0;let missing=false;if(!fs.existsSync(resultPath)){missing=true;reason='missing-result-files';}else{try{const r=JSON.parse(fs.readFileSync(resultPath,'utf8'));attempt=Number(r.rerunAttempt||0)||0;if(r.status==='blocked')reason='blocked';if(!r.status){missing=true;reason='missing-result-files';}}catch(e){missing=true;reason='missing-result-files';}}if(!fs.existsSync(execPath)&&reason!=='blocked'){missing=true;reason=reason||'missing-result-files';}const should=(reason==='blocked'||missing)&&attempt<" +
4420
- maxRerunAttempts +
4421
- ";if(should){cases.push({caseId:c.caseId,casePath:c.casePath||('testcase/frontend/cases/'+c.caseId+'.md'),evidenceDir,dimension:c.dimension||'core',acIds:c.acIds||[],rerunAttempt:attempt+1,reason:reason||'blocked'});}}fs.mkdirSync('testcase/frontend/evidence',{recursive:true});fs.writeFileSync('testcase/frontend/evidence/rerun-candidates.json',JSON.stringify({schemaVersion:1,cases},null,2)+'\\n');process.stdout.write(JSON.stringify({cases}));"),
4422
- ].join(" "),
4423
- ],
4529
+ commands: [["node -e", JSON.stringify(selectCommand)].join(" ")],
4424
4530
  cwd: ".",
4425
4531
  timeoutMs: 120_000,
4426
4532
  },
4427
4533
  }, {
4428
- id: "rerun-frontend-cases-map",
4429
- depends_on: ["select-frontend-rerun-candidates-shell"],
4534
+ id: mapId,
4535
+ depends_on: [selectorId],
4430
4536
  role: "verifier",
4431
4537
  executor: "static",
4432
4538
  complexity: "LOW",
4433
4539
  writePolicy: "none",
4434
4540
  allowedPaths: [],
4435
4541
  forbiddenPaths: forbidden,
4436
- outputContract: "Serial rerun of blocked/missing-result frontend cases.",
4542
+ outputContract: `Serial rerun round ${round}/${maxRerunAttempts} of blocked or missing-result frontend cases.`,
4437
4543
  subtask_prompt: "Expand rerun candidates into serial browser case children.",
4438
- static: {
4439
- resultMarkdown: "Frontend case rerun map expansion barrier.",
4440
- },
4544
+ static: { resultMarkdown: `Frontend case rerun round ${round} map expansion barrier.` },
4441
4545
  dynamicExpansion: {
4442
4546
  type: "map_agent",
4443
- workflowNodeId: "rerun-frontend-cases-map",
4444
- itemsFrom: "$.nodes['select-frontend-rerun-candidates-shell'].output.cases",
4547
+ workflowNodeId: mapId,
4548
+ itemsFrom: `$.nodes['${selectorId}'].output.cases`,
4445
4549
  itemName: "case",
4446
4550
  maxItems: config.maxCasesPerBatch,
4447
4551
  maxExpandedNodes: config.maxCasesPerBatch,
4448
- childIdPrefix: "rerun-frontend-case",
4552
+ childIdPrefix: `rerun-frontend-case-r${round}`,
4449
4553
  workspaceTemplate: "{{case.evidenceDir}}",
4450
4554
  tolerateChildFailures: true,
4451
4555
  tokenBudget: {
@@ -4457,10 +4561,7 @@ function buildFrontendTestHybridDag(sources) {
4457
4561
  role: "implementer",
4458
4562
  skills: ["playwright-cli"],
4459
4563
  toolProfile: "write",
4460
- commandPolicy: {
4461
- mode: "capability-allowlist",
4462
- capabilities: ["playwright-cli"],
4463
- },
4564
+ commandPolicy: { mode: "capability-allowlist", capabilities: ["playwright-cli"] },
4464
4565
  complexity: "MED",
4465
4566
  writePolicy: "exclusive",
4466
4567
  writeGuardPolicy: "tools-only",
@@ -4472,23 +4573,22 @@ function buildFrontendTestHybridDag(sources) {
4472
4573
  ],
4473
4574
  forbiddenPaths: forbidden,
4474
4575
  writeSet: [`${evidenceRoot}/{{case.caseId}}/**`],
4475
- outputContract: "Compact JSON <=1200 characters with case status, evidence paths, error summary, tokens, and rerunAttempt.",
4576
+ outputContract: "Compact JSON <=1200 characters with final case status, evidence paths, error summary, tokens, and rerunAttempt.",
4476
4577
  subtaskPromptTemplate: [
4477
- "RERUN attempt {{case.rerunAttempt}} for {{case.caseId}} (reason={{case.reason}}). Rewrite authoritative evidence; set rerunAttempt={{case.rerunAttempt}}.",
4578
+ "RERUN attempt {{case.rerunAttempt}} for {{case.caseId}} (reason={{case.reason}}). Rewrite the authoritative execution.md and case-result.json; its final status replaces the earlier case result.",
4478
4579
  "Primary job: EXECUTE case {{case.caseId}} from {{case.casePath}} with skill playwright-cli (fresh Pi session). Use structured playwright_cli only; headless open.",
4479
4580
  `Start via playwright_cli command=open with args [--browser=chrome, ${controllerFrontend.baseUrl}]. Passed requires open → find → cleanup receipts.`,
4480
- "Always write {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json with caseId, status, evidencePaths, rerunAttempt={{case.rerunAttempt}}.",
4581
+ "Always write {{case.evidenceDir}}execution.md and {{case.evidenceDir}}case-result.json with caseId, status, evidencePaths, rerunAttempt={{case.rerunAttempt}}. Write fixed sections `### 执行摘要` and `### 实际执行步骤` to execution.md when available.",
4481
4582
  ].join("\n\n"),
4482
4583
  },
4483
4584
  },
4484
4585
  });
4586
+ rerunDependency = mapId;
4485
4587
  }
4486
4588
  tasks.push({
4487
4589
  id: "finalize-frontend-test-result-shell",
4488
4590
  depends_on: [
4489
- maxRerunAttempts > 0
4490
- ? "rerun-frontend-cases-map"
4491
- : "execute-frontend-cases-map",
4591
+ rerunDependency,
4492
4592
  ],
4493
4593
  role: "verifier",
4494
4594
  executor: "shell",
@@ -19,6 +19,8 @@ export const DEFAULT_DAG_RETRY_CATEGORIES = [
19
19
  ];
20
20
  export const STRUCTURED_OUTPUT_RETRY_CATEGORY = "output-too-large";
21
21
  export const PROTOCOL_INVALID_RETRY_CATEGORY = "protocol-invalid";
22
+ /** Recoverable model artifact/schema formatting failure on read-only structured nodes. */
23
+ export const STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY = "invalid-output";
22
24
  /** Retry only a proven no-op from an explicitly opt-in bounded Pi writer. */
23
25
  export const WRITER_EMPTY_DIFF_RETRY_CATEGORY = "writer-empty-diff";
24
26
  /** Retry when a backend-test writer finished but Completeness Gate found missing/broken targets. */
@@ -26,16 +28,19 @@ export const INCOMPLETE_WRITE_SET_RETRY_CATEGORY = "incomplete-write-set";
26
28
  export const STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES = [
27
29
  ...DEFAULT_DAG_RETRY_CATEGORIES,
28
30
  STRUCTURED_OUTPUT_RETRY_CATEGORY,
31
+ STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY,
29
32
  ];
30
33
  /** Categories allowed on nodes that declare a machine-readable outputProtocol. */
31
34
  export const PROTOCOL_AWARE_DAG_RETRY_CATEGORIES = [
32
35
  ...DEFAULT_DAG_RETRY_CATEGORIES,
33
36
  PROTOCOL_INVALID_RETRY_CATEGORY,
37
+ STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY,
34
38
  ];
35
39
  export const ALL_DAG_RETRY_CATEGORIES = [
36
40
  ...DEFAULT_DAG_RETRY_CATEGORIES,
37
41
  STRUCTURED_OUTPUT_RETRY_CATEGORY,
38
42
  PROTOCOL_INVALID_RETRY_CATEGORY,
43
+ STRUCTURED_ARTIFACT_INVALID_RETRY_CATEGORY,
39
44
  WRITER_EMPTY_DIFF_RETRY_CATEGORY,
40
45
  INCOMPLETE_WRITE_SET_RETRY_CATEGORY,
41
46
  ];
@@ -52,7 +52,7 @@ Each case must require the executor to persist, even when blocked:
52
52
  - `testcase/frontend/evidence/<case-id>/execution.md`
53
53
  - `testcase/frontend/evidence/<case-id>/case-result.json`
54
54
 
55
- `case-result.json` must be valid JSON containing the matching `caseId`, `status` (`passed`, `failed`, or `blocked`), and an `evidencePaths` array. A blocked result must include a non-empty `blockedReason`, such as `isolated-test-environment-unavailable` or `token-budget-exhausted`, and must never claim or imply a pass. `execution.md` records attempted or blocked steps, base-URL safety decision, fixture/reset and request-observation availability, timestamps, and the evidence-file list. Available evidence files are required only when actually produced and must stay under the same case evidence directory.
55
+ `case-result.json` must be valid JSON containing the matching `caseId`, final `status` (`passed`, `failed`, or `blocked`), and an `evidencePaths` array. A rerun must overwrite this same file with its final status and integer `rerunAttempt`; the final report consumes that latest file. A blocked result must include a non-empty `blockedReason`, such as `isolated-test-environment-unavailable` or `token-budget-exhausted`, and must never claim or imply a pass. `execution.md` records attempted or blocked steps, base-URL safety decision, fixture/reset and request-observation availability, timestamps, and the evidence-file list. When available, use fixed headings `### 执行摘要` and `### 实际执行步骤` so the bounded deterministic report extractor can show actual execution facts. Available evidence files are required only when actually produced and must stay under the same case evidence directory.
56
56
 
57
57
 
58
58
  ## Standard scenarios (controller)