@tea-agent/loop-agent 0.32.1 → 0.33.1

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 +78 -0
  2. package/dist/executors/model-routing.js +14 -4
  3. package/dist/governance/manifest-types.js +34 -7
  4. package/dist/worker/console/chat/model-resolver.js +114 -34
  5. package/dist/worker/console/chat/workspace-landing.js +58 -22
  6. package/dist/worker/console/doctor.js +1 -0
  7. package/dist/worker/console/night-aux-ticker.js +5 -0
  8. package/dist/worker/console/operator-surface-health.js +1 -0
  9. package/dist/worker/console/pi-readiness.js +26 -17
  10. package/dist/worker/console/server.js +2 -0
  11. package/dist/worker/console/static/assets/index-CnUXAqxG.css +1 -0
  12. package/dist/worker/console/static/assets/index-PzYzcuFG.js +29 -0
  13. package/dist/worker/console/static/index.html +3 -2
  14. package/dist/worker/console/static-src/app/useRecoveryConsole.js +3 -2
  15. package/dist/worker/console/static-src/night/useNightBoard.js +0 -31
  16. package/dist/worker/observability/read-model.js +106 -0
  17. package/dist/worker/observe/routes.js +22 -9
  18. package/dist/worker/observe/static/api.js +42 -3
  19. package/dist/worker/observe/static/app.js +15 -0
  20. package/dist/worker/observe/static/constants.js +10 -0
  21. package/dist/worker/observe/static/custom-select.js +567 -0
  22. package/dist/worker/observe/static/index.html +56 -47
  23. package/dist/worker/observe/static/kpi.js +2 -24
  24. package/dist/worker/observe/static/operator-chrome.css +480 -0
  25. package/dist/worker/observe/static/operator-chrome.d.ts +82 -0
  26. package/dist/worker/observe/static/operator-chrome.js +554 -0
  27. package/dist/worker/observe/static/router.js +54 -1
  28. package/dist/worker/observe/static/shell-chrome.js +1 -11
  29. package/dist/worker/observe/static/state.js +20 -0
  30. package/dist/worker/observe/static/styles.css +680 -299
  31. package/dist/worker/observe/static/views/dag-inspector.js +20 -17
  32. package/dist/worker/observe/static/views/dag.js +136 -59
  33. package/dist/worker/observe/static/views/dags.js +877 -0
  34. package/dist/worker/observe/static/views/dashboard.js +67 -8
  35. package/dist/workflows/dag/init-hybrid.js +58 -29
  36. package/docs/templates/harness.schema.json +5 -0
  37. package/harness.json +4 -4
  38. package/package.json +1 -1
  39. package/dist/worker/console/static/assets/index-Bpa2qrc-.js +0 -29
  40. package/dist/worker/console/static/assets/index-BqfFDdnG.css +0 -1
@@ -16,7 +16,7 @@ import {
16
16
  DAG_EFFECTIVE_STATUS_LABELS,
17
17
  STATUS_LABELS,
18
18
  } from "../constants.js";
19
- import { el, reconcileKeyed, syncCompatibleNode } from "../dom.js";
19
+ import { clearNode, el, reconcileKeyed, syncCompatibleNode } from "../dom.js";
20
20
  import {
21
21
  badge,
22
22
  badgeClass,
@@ -65,7 +65,7 @@ import {
65
65
  } from "../dag-model.js";
66
66
  import { layoutDag } from "../dag-layout.js";
67
67
  import { showView, setBreadcrumb, updateHeaderRefresh } from "../shell-chrome.js";
68
- import { kpiCard, renderRepoBanner, renderProjectionFault, failuresInboxLink } from "../kpi.js";
68
+ import { kpiCard, renderProjectionFault, failuresInboxLink } from "../kpi.js";
69
69
  import {
70
70
  dagSortTime,
71
71
  compareActiveDagUrgency,
@@ -82,6 +82,55 @@ let kpiDagGroup = null;
82
82
  // removed once on first success so subsequent polls never see it again.
83
83
  let dashboardLoadingNode = null;
84
84
 
85
+ /**
86
+ * 「昨夜班次」条带:夜间结果在观测侧的唯一一级呈现(替代旧「夜间任务」tab)。
87
+ * 管理动作(晨审处置)始终落在操作侧 /#/night,保持 Inspect 只读不变式。
88
+ */
89
+ async function renderNightStrip() {
90
+ const strip = document.getElementById("dashboard-night");
91
+ if (!strip) return;
92
+ const morning = await fetchJson("/api/night-jobs/morning");
93
+ const summary = morning?.summary ?? null;
94
+ if (!morning || !summary || !(summary.total > 0)) {
95
+ strip.hidden = true;
96
+ clearNode(strip);
97
+ return;
98
+ }
99
+ clearNode(strip);
100
+ strip.hidden = false;
101
+
102
+ const iconWrap = el("span", "night-strip-icon");
103
+ const moon = el("i", "ri-moon-clear-line");
104
+ moon.setAttribute("aria-hidden", "true");
105
+ iconWrap.appendChild(moon);
106
+ strip.appendChild(iconWrap);
107
+
108
+ const main = el("div", "night-strip-main");
109
+ main.appendChild(el("div", "night-strip-title", `昨夜班次 · ${morning.date ?? ""}`));
110
+ const parts = [
111
+ `${summary.succeeded ?? 0} 通过`,
112
+ `${summary.failed ?? 0} 失败`,
113
+ ];
114
+ if (summary.humanRequired > 0) parts.push(`${summary.humanRequired} 需人工`);
115
+ if (summary.pendingHarvest > 0) parts.push(`${summary.pendingHarvest} 待 harvest`);
116
+ main.appendChild(el("div", "night-strip-sub", parts.join(" · ")));
117
+ strip.appendChild(main);
118
+
119
+ const actions = el("div", "night-strip-actions");
120
+ const detail = el("a", "page-action-btn", "夜间详情");
121
+ detail.href = "#/night";
122
+ detail.addEventListener("click", (e) => {
123
+ e.preventDefault();
124
+ navigate("/night");
125
+ });
126
+ actions.appendChild(detail);
127
+ const manage = el("a", "page-action-btn night-strip-manage", "晨审处置 →");
128
+ manage.href = "/#/night";
129
+ manage.title = "夜间计划的管理与晨审处置在操作台完成(Inspect 保持只读)";
130
+ actions.appendChild(manage);
131
+ strip.appendChild(actions);
132
+ }
133
+
85
134
  export async function renderDashboard(scrollTo) {
86
135
  showView("dashboard");
87
136
  setBreadcrumb([{ label: UI_TEXT.dashboard }]);
@@ -92,7 +141,6 @@ export async function renderDashboard(scrollTo) {
92
141
  const riskEl = document.getElementById("dashboard-risk");
93
142
  const dagsEl = document.getElementById("dashboard-dags");
94
143
  const batchesEl = document.getElementById("dashboard-batches");
95
- const repoEl = document.getElementById("dashboard-repo");
96
144
  const host = kpiEl || featureEl || activeEl;
97
145
  // Loading only mounts before the first successful snapshot. Subsequent
98
146
  // successful polls reuse the stable DOM and never flash the loading state.
@@ -211,7 +259,7 @@ export async function renderDashboard(scrollTo) {
211
259
  },
212
260
  );
213
261
 
214
- renderRepoBanner(repoEl, snapshot);
262
+ renderNightStrip();
215
263
 
216
264
  const dagHealth = snapshot.health?.dag;
217
265
  const dagActiveRuns = dagHealth?.activeRuns ?? activeDags.length;
@@ -504,7 +552,19 @@ export async function renderDashboard(scrollTo) {
504
552
  reconcileKeyed(dagsEl, [{ key: "__heading__" }], {
505
553
  getKey: () => "__heading__",
506
554
  scope: "heading",
507
- create: () => el("h3", "section-heading", UI_TEXT.recentDags),
555
+ create: () => {
556
+ const head = el("div", "section-heading-row");
557
+ head.appendChild(el("h3", "section-heading", UI_TEXT.recentDags));
558
+ const allLink = el("a", "section-heading-action", UI_TEXT.viewAllDags);
559
+ allLink.href = "#/dags?page=1&pageSize=20";
560
+ allLink.setAttribute("aria-label", "查看全部 DAG 运行历史");
561
+ allLink.addEventListener("click", (event) => {
562
+ event.preventDefault();
563
+ navigate("/dags?page=1&pageSize=20");
564
+ });
565
+ head.appendChild(allLink);
566
+ return head;
567
+ },
508
568
  update: () => {},
509
569
  });
510
570
  reconcileKeyed(
@@ -588,9 +648,8 @@ export async function renderDashboard(scrollTo) {
588
648
  },
589
649
  );
590
650
 
591
- if (scrollTo === "dags") {
592
- dagsEl.scrollIntoView({ behavior: "smooth" });
593
- }
651
+ // Legacy scrollTo=dags alias retired: #/dags is an independent history page.
652
+ void scrollTo;
594
653
  }
595
654
 
596
655
  function syncKpiGroup(group, legendText, cards) {
@@ -955,11 +955,7 @@ function verifyCommandKey(command) {
955
955
  .sort(([left], [right]) => left.localeCompare(right))
956
956
  .map(([key, value]) => `${key}=${value}`)
957
957
  .join("\0");
958
- return [
959
- cwdKey,
960
- normalizedArgs.join("\0"),
961
- envKey,
962
- ].join("\u0001");
958
+ return [cwdKey, normalizedArgs.join("\0"), envKey].join("\u0001");
963
959
  }
964
960
  function normalizeVerifyCommandArgs(args) {
965
961
  if (args.length === 3 &&
@@ -1055,7 +1051,10 @@ function canonicalizeVerificationCommands(commands) {
1055
1051
  const canonicalKey = verifyCommandKey(command);
1056
1052
  const retained = byKey.get(canonicalKey);
1057
1053
  if (!retained) {
1058
- byKey.set(canonicalKey, { ...command, env: command.env ? { ...command.env } : undefined });
1054
+ byKey.set(canonicalKey, {
1055
+ ...command,
1056
+ env: command.env ? { ...command.env } : undefined,
1057
+ });
1059
1058
  merged.set(canonicalKey, {
1060
1059
  canonicalKey,
1061
1060
  keptLabel: command.label,
@@ -1079,7 +1078,10 @@ function canonicalizeVerificationCommands(commands) {
1079
1078
  ? retained.filter((command) => {
1080
1079
  if (!isCheckRepoCoveredCommand(command))
1081
1080
  return true;
1082
- covered.push({ aggregateLabel: aggregate.label, coveredLabel: command.label });
1081
+ covered.push({
1082
+ aggregateLabel: aggregate.label,
1083
+ coveredLabel: command.label,
1084
+ });
1083
1085
  return false;
1084
1086
  })
1085
1087
  : retained;
@@ -1110,7 +1112,8 @@ function assertVerificationPlanPreflight(input) {
1110
1112
  }
1111
1113
  function isWithinRepo(root, candidate) {
1112
1114
  const relative = path.relative(root, candidate);
1113
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
1115
+ return (relative === "" ||
1116
+ (!relative.startsWith("..") && !path.isAbsolute(relative)));
1114
1117
  }
1115
1118
  function taskVerifyCommands(repoRoot, taskConfig) {
1116
1119
  if (!repoRoot)
@@ -1173,7 +1176,10 @@ function buildVerifyEvidence(input) {
1173
1176
  selectionReasons: selectedCommands?.map((command) => input.phase === "intermediate" && isFullSuiteVerifyCommand(command)
1174
1177
  ? `${command.label}: deferred-heavy`
1175
1178
  : `${command.label}: kept`) ?? [],
1176
- preflight: selectedCommands?.map((command) => ({ label: command.label, status: "ok" })) ?? [],
1179
+ preflight: selectedCommands?.map((command) => ({
1180
+ label: command.label,
1181
+ status: "ok",
1182
+ })) ?? [],
1177
1183
  commandTimeoutMs: input.commandTimeoutMs,
1178
1184
  totalTimeoutBudgetMs: commandCount * input.commandTimeoutMs,
1179
1185
  finalFullRequired: input.finalFullRequired,
@@ -1433,9 +1439,7 @@ function buildSourceContextBlock(sources) {
1433
1439
  const requirementExcerpt = excerptMarkdown(sources.requirementMarkdown, {
1434
1440
  sourceRef: requirementRef,
1435
1441
  });
1436
- const boundReadPaths = [
1437
- `- requirement: ${requirementRef}`,
1438
- ];
1442
+ const boundReadPaths = [`- requirement: ${requirementRef}`];
1439
1443
  const parts = [
1440
1444
  `## Task source: 需求.md`,
1441
1445
  `Bound readPath (use for Pi read-tool calls): ${requirementRef}`,
@@ -1571,10 +1575,7 @@ export async function loadTaskHybridSources(repoRoot, taskId) {
1571
1575
  ...taskVerifyCommands(repoRoot, taskConfig),
1572
1576
  ...adapterIntermediate,
1573
1577
  ],
1574
- final: [
1575
- ...taskVerifyCommands(repoRoot, taskConfig),
1576
- ...adapterFinal,
1577
- ],
1578
+ final: [...taskVerifyCommands(repoRoot, taskConfig), ...adapterFinal],
1578
1579
  };
1579
1580
  if (verifyCommands.final.length === 0) {
1580
1581
  throw new Error("adapter returned no final verification commands");
@@ -2637,7 +2638,10 @@ async function buildFrontendHybridDagFromTask(sources) {
2637
2638
  frontendPrewriteGate: {
2638
2639
  schemaVersion: 1,
2639
2640
  planFromNodeId: "frontend-contract-json-pi",
2640
- planFallbackFromNodeIds: ["frontend-plan-revision-pi", "frontend-plan-pi"],
2641
+ planFallbackFromNodeIds: [
2642
+ "frontend-plan-revision-pi",
2643
+ "frontend-plan-pi",
2644
+ ],
2641
2645
  reviewFromNodeId: "frontend-final-design-review-pi",
2642
2646
  reviewFallbackFromNodeIds: ["frontend-design-review-pi"],
2643
2647
  requiredRequirementIds: requirementIds,
@@ -3520,7 +3524,7 @@ const seen=new Set();const modules=[];
3520
3524
  for(const r of raw){const st=norm(r);if(!seen.has(st)){seen.add(st);modules.push({stem:st});}}
3521
3525
  process.stdout.write(JSON.stringify({modules}));
3522
3526
  `;
3523
- const encoded = Buffer.from(script, 'utf8').toString('base64');
3527
+ const encoded = Buffer.from(script, "utf8").toString("base64");
3524
3528
  return `node -e "eval(Buffer.from('${encoded}','base64').toString('utf8'))"`;
3525
3529
  }
3526
3530
  const BACKEND_TEST_SKILLS_BY_ROLE = {
@@ -3632,7 +3636,9 @@ async function buildBackendTestHybridDag(sources) {
3632
3636
  forbiddenPaths: forbidden,
3633
3637
  outputContract: "Serial aggregate of sharded Markdown module case-card writers. Each child writes exactly one testcase/md/<stem>.md with its own 16K Pi budget.",
3634
3638
  subtask_prompt: "Expand the README module manifest into one sharded Markdown writer child per module and run them serially. Child failures fail-close the map barrier.",
3635
- static: { resultMarkdown: "Backend-test Markdown case-card map expansion barrier." },
3639
+ static: {
3640
+ resultMarkdown: "Backend-test Markdown case-card map expansion barrier.",
3641
+ },
3636
3642
  dynamicExpansion: {
3637
3643
  type: "map_agent",
3638
3644
  workflowNodeId: "generate-backend-md-cases-map",
@@ -3663,7 +3669,7 @@ async function buildBackendTestHybridDag(sources) {
3663
3669
  "Output budget protocol (hard, max output <=16K per turn): Never paste full Matrix, other modules' case bodies, or source text into assistant chat. Each write/edit tool call touches at most one file (this module). Compact tables/lists are required; omitting required sections or in-scope variants is forbidden. If a Completeness Gate / OUTPUT_LIMIT_RECOVERY retry is injected, continue only listed target paths.",
3664
3670
  "The first non-empty response line must be exactly IMPLEMENTATION_OUTCOME: changed after the module file has been written, or IMPLEMENTATION_OUTCOME: blocked when precise missing evidence prevents safe generation. already-satisfied is not valid for this node.",
3665
3671
  "Write human-readable content in Simplified Chinese by default. Keep English only for machine-readable IDs and technical literals such as Case/AC/REQ/BR IDs, HTTP methods, paths, field names, enum values, commands, filenames, code symbols and exact source citations.",
3666
- "Write the module {{item.stem}} as readable case cards covering every in-scope rule/Test Point the README Coverage Matrix assigns to this module. Every case starts with `## BE-<MODULE>-<NNN>|<中文用例名称>`. `<NNN>` is exactly three zero-padded digits (`001`, `002`, ...), never two digits (`01`), a bare number, or an alphabetic suffix such as `011A`. Every case must include `### 覆盖规则`, `### 测试点`, `### 场景类型`, `### 前置条件`, `### 操作步骤`, `### 预期结果`, and `### 自动化映射` Do not group cases under \"## 测试类 ...\" (or any h2 grouping) headings that force Cases down to h3; each Case must be a direct h2 (`##`), and its seven sections must be h3 (`###`) children of that Case. If you need to convey a pytest class, state it inside the Case's `### 自动化映射` instead. Forbidden: `## 测试类 X` then `### BE-PD-001` and `### 覆盖规则` at the same h3 level. Required: `## BE-PD-001` then `### 覆盖规则`.; `覆盖规则` and `测试点` must reference exact Matrix Rule Keys/Test Points. Add `测试目的`, `验收标准`, `需求依据`, and `测试数据` for readable evidence. The `验收标准` section must list the exact applicable `AC-...` IDs, and every explicit task AC must appear in at least one Case. Every automatable case explicitly names its target pytest script and exactly one primary symbol so traceability scans only that script/symbol.",
3672
+ 'Write the module {{item.stem}} as readable case cards covering every in-scope rule/Test Point the README Coverage Matrix assigns to this module. Every case starts with `## BE-<MODULE>-<NNN>|<中文用例名称>`. `<NNN>` is exactly three zero-padded digits (`001`, `002`, ...), never two digits (`01`), a bare number, or an alphabetic suffix such as `011A`. Every case must include `### 覆盖规则`, `### 测试点`, `### 场景类型`, `### 前置条件`, `### 操作步骤`, `### 预期结果`, and `### 自动化映射` Do not group cases under "## 测试类 ..." (or any h2 grouping) headings that force Cases down to h3; each Case must be a direct h2 (`##`), and its seven sections must be h3 (`###`) children of that Case. If you need to convey a pytest class, state it inside the Case\'s `### 自动化映射` instead. Forbidden: `## 测试类 X` then `### BE-PD-001` and `### 覆盖规则` at the same h3 level. Required: `## BE-PD-001` then `### 覆盖规则`.; `覆盖规则` and `测试点` must reference exact Matrix Rule Keys/Test Points. Add `测试目的`, `验收标准`, `需求依据`, and `测试数据` for readable evidence. The `验收标准` section must list the exact applicable `AC-...` IDs, and every explicit task AC must appear in at least one Case. Every automatable case explicitly names its target pytest script and exactly one primary symbol so traceability scans only that script/symbol.',
3667
3673
  "Name this module file with the stable lowercase business stem `{{item.stem}}` (filename `testcase/md/{{item.stem}}.md`). Do not use Case-ID-like module filenames. For every automatable case, `自动化映射` must name exactly `testcase/test_{{item.stem}}.py`, where the module stem is this Markdown filename without `.md`, lowercased, with non-alphanumeric characters replaced by underscores. Example: `health` → `testcase/test_health.py`; `resource_notes` → `testcase/test_resource_notes.py`. Never invent a different pytest path in Markdown than the module stem implies.",
3668
3674
  "Every Case must keep at least one numbered executable line under `### 操作步骤`; a compact variant/result table may follow but must not replace the numbered action anchor. Keep numbered/bulleted independently assertable results under `### 预期结果`. The exact `### 操作步骤` and `### 预期结果` headings must remain present for every Case, including compact/table-based Cases; never compress later Cases by dropping required headings. Every result must name the observable HTTP status, response field/value, state transition or membership condition, never vague wording such as ‘符合预期’.",
3669
3675
  "In every `自动化映射`, use exactly these machine-readable list labels: `脚本`, `primary symbol`, `变体测试点`, `场景断言测试点`, `横切证据测试点`. Each Test Point from `### 测试点` must appear in exactly one binding list, and every Test Point named in any binding list must also be declared in that Case's `### 测试点`; write `无` for an empty list. A variant Test Point is atomic: one exact endpoint/input/precondition/outcome row equals one exact pytest item and one exact TP ID. If a parameter table has five rows, declare five distinct variant TP IDs in Markdown; never declare one family TP and append row suffixes only in pytest. Classify as `variant` only when endpoint, request input, precondition business state, or expected outcome genuinely changes and therefore needs an independent pytest parameter item. Classify CRUD checkpoints, status/body/header/schema assertions and multiple checks over the same response/journey as `assertion`; classify shared HTTP logging/redaction/truncation evidence as `cross-cutting`. Never create a Test Point merely to parameterize a checkpoint. Every non-cross-cutting TP ID is owned by exactly one Case; when the same response/schema/error assertion is needed in different Cases, use distinct Case-specific TP IDs instead of reusing one assertion TP across Cases. Keep the script path identical to the module one-to-one path and declare exactly one primary symbol named with the canonical Case prefix, for example `BE-RN-003` → `test_BE_RN_003_<description>`; non-Case-prefixed primary symbols are forbidden because parameterized item association must remain deterministic. For redaction scenarios, list sensitive header/field key names only. Never write any header-name-and-value pair, credential placeholder, fake token, anti-example, or other secret-shaped literal in Markdown; state only that a test-only value is supplied at runtime and omitted. Put implementation-only restrictions in a concise `<details>` block rather than dominating the main case flow. Use only environment-supported fixtures/targets/isolation, record evidence gaps in Chinese, and do not emit JSON, pytest, or execute commands.",
@@ -3760,7 +3766,9 @@ async function buildBackendTestHybridDag(sources) {
3760
3766
  forbiddenPaths: forbidden,
3761
3767
  outputContract: "Serial aggregate of sharded pytest module writers. Each child writes exactly one self-contained testcase/test_<stem>.py with its own 16K Pi budget and no generated shared-asset dependency.",
3762
3768
  subtask_prompt: "Expand the README module manifest into one sharded pytest writer child per module and run them serially. Child failures fail-close the map barrier.",
3763
- static: { resultMarkdown: "Backend-test pytest module map expansion barrier." },
3769
+ static: {
3770
+ resultMarkdown: "Backend-test pytest module map expansion barrier.",
3771
+ },
3764
3772
  dynamicExpansion: {
3765
3773
  type: "map_agent",
3766
3774
  workflowNodeId: "generate-backend-pytest-cases-map",
@@ -4156,7 +4164,10 @@ function buildFrontendTestHybridDag(sources) {
4156
4164
  subtask_prompt: "Prepare frontend-test package: materialize standard-scenarios.v1.json into the RAG package.",
4157
4165
  shell: {
4158
4166
  commands: [
4159
- ["node -e", JSON.stringify("const fs=require('fs'),path=require('path');const dest='testcase/frontend/rag/standard-scenarios.v1.json';const candidates=[path.join('docs','templates','frontend-test-standard-scenarios.v1.json')];let src=null;for(const c of candidates){if(fs.existsSync(c)){src=c;break;}}fs.mkdirSync(path.dirname(dest),{recursive:true});if(src){fs.copyFileSync(src,dest);process.stdout.write(JSON.stringify({status:'copied',from:src,to:dest}));}else{const minimal={schemaVersion:1,id:'frontend-test-standard-scenarios-v1',scenarios:[{id:'STD-FE-SMOKE-ENTRY',title:'入口可打开',category:'smoke',priority:'must',testPoints:['open'],minCases:1}]};fs.writeFileSync(dest,JSON.stringify(minimal,null,2)+'\n');process.stdout.write(JSON.stringify({status:'fallback',to:dest}));}")].join(" "),
4167
+ [
4168
+ "node -e",
4169
+ JSON.stringify("const fs=require('fs'),path=require('path');const dest='testcase/frontend/rag/standard-scenarios.v1.json';const candidates=[path.join('docs','templates','frontend-test-standard-scenarios.v1.json')];let src=null;for(const c of candidates){if(fs.existsSync(c)){src=c;break;}}fs.mkdirSync(path.dirname(dest),{recursive:true});if(src){fs.copyFileSync(src,dest);process.stdout.write(JSON.stringify({status:'copied',from:src,to:dest}));}else{const minimal={schemaVersion:1,id:'frontend-test-standard-scenarios-v1',scenarios:[{id:'STD-FE-SMOKE-ENTRY',title:'入口可打开',category:'smoke',priority:'must',testPoints:['open'],minCases:1}]};fs.writeFileSync(dest,JSON.stringify(minimal,null,2)+'\n');process.stdout.write(JSON.stringify({status:'fallback',to:dest}));}"),
4170
+ ].join(" "),
4160
4171
  ],
4161
4172
  cwd: ".",
4162
4173
  timeoutMs: 60_000,
@@ -4403,7 +4414,12 @@ function buildFrontendTestHybridDag(sources) {
4403
4414
  subtask_prompt: "Select frontend-test cases eligible for bounded rerun.",
4404
4415
  shell: {
4405
4416
  commands: [
4406
- ["node -e", 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<" + maxRerunAttempts + ";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}));")].join(" "),
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(" "),
4407
4423
  ],
4408
4424
  cwd: ".",
4409
4425
  timeoutMs: 120_000,
@@ -4419,7 +4435,9 @@ function buildFrontendTestHybridDag(sources) {
4419
4435
  forbiddenPaths: forbidden,
4420
4436
  outputContract: "Serial rerun of blocked/missing-result frontend cases.",
4421
4437
  subtask_prompt: "Expand rerun candidates into serial browser case children.",
4422
- static: { resultMarkdown: "Frontend case rerun map expansion barrier." },
4438
+ static: {
4439
+ resultMarkdown: "Frontend case rerun map expansion barrier.",
4440
+ },
4423
4441
  dynamicExpansion: {
4424
4442
  type: "map_agent",
4425
4443
  workflowNodeId: "rerun-frontend-cases-map",
@@ -4467,7 +4485,11 @@ function buildFrontendTestHybridDag(sources) {
4467
4485
  }
4468
4486
  tasks.push({
4469
4487
  id: "finalize-frontend-test-result-shell",
4470
- depends_on: [maxRerunAttempts > 0 ? "rerun-frontend-cases-map" : "execute-frontend-cases-map"],
4488
+ depends_on: [
4489
+ maxRerunAttempts > 0
4490
+ ? "rerun-frontend-cases-map"
4491
+ : "execute-frontend-cases-map",
4492
+ ],
4471
4493
  role: "verifier",
4472
4494
  executor: "shell",
4473
4495
  complexity: "LOW",
@@ -4528,7 +4550,10 @@ function buildFrontendTestHybridDag(sources) {
4528
4550
  if (enableRetrospect) {
4529
4551
  tasks.push({
4530
4552
  id: "frontend-test-retrospect-pi",
4531
- depends_on: ["finalize-frontend-test-result-shell", "frontend-test-reports-shell"],
4553
+ depends_on: [
4554
+ "finalize-frontend-test-result-shell",
4555
+ "frontend-test-reports-shell",
4556
+ ],
4532
4557
  role: "closeout",
4533
4558
  executor: "pi",
4534
4559
  toolProfile: "write",
@@ -6049,13 +6074,15 @@ async function buildSoftVerifyNode(sources) {
6049
6074
  const implementId = implementationNodeId();
6050
6075
  const strategy = resolveDagVerifyStrategy(sources.taskConfig, "1");
6051
6076
  const fallbackCommands = sources.repoRoot
6052
- ? (await discoverFrontendFallbackVerifyCommands(sources.repoRoot)).staticCommands
6077
+ ? (await discoverFrontendFallbackVerifyCommands(sources.repoRoot))
6078
+ .staticCommands
6053
6079
  : [];
6054
6080
  const candidatePlan = canonicalizeVerificationCommands([
6055
6081
  ...taskVerifyCommands(sources.repoRoot, sources.taskConfig),
6056
6082
  ...(sources.verifyCommands?.intermediate ?? []),
6057
6083
  ]);
6058
- const focusedCandidates = candidatePlan.commands.filter((command) => !isFullSuiteVerifyCommand(command) && !isFrontendLintVerifyCommand(command));
6084
+ const focusedCandidates = candidatePlan.commands.filter((command) => !isFullSuiteVerifyCommand(command) &&
6085
+ !isFrontendLintVerifyCommand(command));
6059
6086
  const quota = verifyQuotaLimit(strategy.intermediateQuota ?? "full");
6060
6087
  const focusedIntermediate = quota
6061
6088
  ? focusedCandidates.slice(0, quota)
@@ -6072,7 +6099,9 @@ async function buildSoftVerifyNode(sources) {
6072
6099
  });
6073
6100
  const commands = commandsWithoutFallback.length > 0
6074
6101
  ? commandsWithoutFallback
6075
- : ["node -e \"console.log('No project verification command configured; verification skipped')\""];
6102
+ : [
6103
+ "node -e \"console.log('No project verification command configured; verification skipped')\"",
6104
+ ];
6076
6105
  return {
6077
6106
  id: "soft-verify-shell",
6078
6107
  depends_on: [implementId],
@@ -302,6 +302,11 @@
302
302
  "additionalProperties": false,
303
303
  "required": ["model"],
304
304
  "properties": {
305
+ "provider": {
306
+ "type": "string",
307
+ "minLength": 1,
308
+ "description": "可选 Pi provider id。与 model 组合为无歧义的 provider/model 路由;省略时从 model 字符串中的 provider/model 或运行时 available 列表推断。"
309
+ },
305
310
  "model": {
306
311
  "type": "string",
307
312
  "minLength": 1,
package/harness.json CHANGED
@@ -79,10 +79,10 @@
79
79
  },
80
80
  "executors": {
81
81
  "pi": {
82
- "description": "Pi planning, review, diagnosis, and bounded writing when DAG toolProfile=write",
83
- "LOW": "minimax-m3",
84
- "MED": "grok-4.5",
85
- "HIGH": "gpt-5.6-sol"
82
+ "description": "Pi 负责规划、评审、诊断;当 DAG toolProfile=write 时也可做有界写入。模型按复杂度三档配置,格式为 provider/model 字符串(例:wizard-local/grok-4.5);斜杠前为 Pi provider,后为 modelId,勿只写裸 modelId。",
83
+ "LOW": "wizard-local/minimax-m3",
84
+ "MED": "wizard-local/grok-4.5",
85
+ "HIGH": "wizard-local/gpt-5.6-sol"
86
86
  }
87
87
  }
88
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.32.1",
3
+ "version": "0.33.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",