@tea-agent/loop-agent 0.16.6-beta.0 → 0.16.7

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.
package/CHANGELOG.md CHANGED
@@ -18,6 +18,18 @@
18
18
  - Pi SDK 执行长推理或大段结构化输出时不再把高频流式增量事件无界累积到内存;同一响应在多个生命周期事件中重复出现的 Token 用量只统计一次,避免 `Invalid string length` 和成本数据虚高。
19
19
  - 后端测试复合执行节点继续保持 clean environment、失败分类和 fail-closed outcome,并为 initial/final Result、repair eligibility、traceability 与 Observe 投影保留结构化运行证据。
20
20
 
21
+ ## [0.16.7] - 2026-07-20
22
+
23
+ ### 修复
24
+
25
+ - 前端实现合同物化支持 free-form plan JSON 归一化(GWT/对象 UI 状态、requirementCoverage、mock productionDefaultOff),避免 FE-IMPL 在 `frontend-implementation-contract-shell` 因字段漂移误拦。
26
+
27
+ ## [0.16.6] - 2026-07-20
28
+
29
+ ### 修复
30
+
31
+ - write guard 忽略 `__pycache__` / `.pytest_cache` 等 ephemeral 解释器缓存,避免 backend-test 生成节点本地冒烟 pytest 时被误拦;并明确 generate-pytest 节点不负责执行测试。
32
+
21
33
  ## [0.16.5] - 2026-07-20
22
34
 
23
35
  ### 修复
@@ -30,6 +30,33 @@ export function pathsChangedDuringRun(before, after) {
30
30
  return Array.from(changed).sort();
31
31
  }
32
32
  const DAG_RUNS_PREFIX = ".harness/dag-runs/";
33
+ /**
34
+ * Ephemeral interpreter/tool caches that writers may create while drafting tests.
35
+ * They are not product evidence and must not fail exclusive write guards.
36
+ * Intentional report/product writes remain governed by writePolicy/writeSet.
37
+ */
38
+ export function isEphemeralToolCachePath(filePath) {
39
+ const normalized = normalizePath(filePath);
40
+ if (!normalized)
41
+ return false;
42
+ if (normalized === ".pytest_cache" || normalized.startsWith(".pytest_cache/")) {
43
+ return true;
44
+ }
45
+ if (normalized === ".mypy_cache" || normalized.startsWith(".mypy_cache/")) {
46
+ return true;
47
+ }
48
+ if (normalized === ".ruff_cache" || normalized.startsWith(".ruff_cache/")) {
49
+ return true;
50
+ }
51
+ if (normalized === ".coverage" || normalized.startsWith(".coverage.")) {
52
+ return true;
53
+ }
54
+ if (/(?:^|\/)__pycache__(?:\/|$)/.test(normalized))
55
+ return true;
56
+ if (/\.(?:pyc|pyo)$/.test(normalized))
57
+ return true;
58
+ return false;
59
+ }
33
60
  /** MVP post-run guard: only paths that changed during the shell node are checked. */
34
61
  export function validateShellWriteGuard(input) {
35
62
  const violations = [];
@@ -42,6 +69,9 @@ export function validateShellWriteGuard(input) {
42
69
  if (filePath.startsWith(DAG_RUNS_PREFIX)) {
43
70
  continue;
44
71
  }
72
+ if (isEphemeralToolCachePath(filePath)) {
73
+ continue;
74
+ }
45
75
  if (matchesAnyPattern(filePath, forbiddenPaths)) {
46
76
  violations.push(filePath);
47
77
  continue;
@@ -283,6 +283,328 @@ export function canonicalFrontendContractSourceBinding(binding) {
283
283
  requirementIds: [...binding.requirementIds],
284
284
  };
285
285
  }
286
+ function asRecord(value) {
287
+ return value && typeof value === "object" && !Array.isArray(value)
288
+ ? value
289
+ : null;
290
+ }
291
+ function asString(value) {
292
+ return typeof value === "string" ? value.trim() : "";
293
+ }
294
+ function asStringArray(value) {
295
+ if (!Array.isArray(value))
296
+ return [];
297
+ return value
298
+ .map((item) => asString(item))
299
+ .filter((item) => item.length > 0);
300
+ }
301
+ function looksLikeStrictFrontendContract(value) {
302
+ const record = asRecord(value);
303
+ if (!record)
304
+ return false;
305
+ return (record.schemaVersion === 1 &&
306
+ asRecord(record.targets) !== null &&
307
+ Array.isArray(record.requirements) &&
308
+ Array.isArray(record.uiStates) &&
309
+ asRecord(record.mockApi) !== null &&
310
+ asRecord(record.designEvidence) !== null);
311
+ }
312
+ /**
313
+ * Coerce common free-form plan JSON into frontend-implementation-contract-v1.
314
+ * Near-schema payloads are left untouched so unknown-key fail-closed still holds.
315
+ */
316
+ export function coerceFrontendImplementationContractInput(value, canonicalBinding) {
317
+ if (looksLikeStrictFrontendContract(value))
318
+ return value;
319
+ const record = asRecord(value);
320
+ if (!record)
321
+ return value;
322
+ const implementation = asRecord(record.implementation);
323
+ const mockApiIn = asRecord(record.mockApi) ?? {};
324
+ const api = asRecord(record.api) ?? {};
325
+ const component = asRecord(record.component);
326
+ const targetFiles = [
327
+ ...asStringArray(asRecord(record.targets)?.files),
328
+ ...asStringArray(implementation?.targetFiles),
329
+ ...asStringArray(record.writeSet),
330
+ ...asStringArray(implementation?.writeSet),
331
+ ...asStringArray(asRecord(component?.paths)?.implementation ? [asRecord(component?.paths)?.implementation] : []),
332
+ ...asStringArray(asRecord(component?.paths)?.unitTests ? [asRecord(component?.paths)?.unitTests] : []),
333
+ ...asStringArray(asRecord(component?.paths)?.domHelper ? [asRecord(component?.paths)?.domHelper] : []),
334
+ ].filter((item, index, arr) => arr.indexOf(item) === index);
335
+ if (targetFiles.length === 0) {
336
+ targetFiles.push("apps/web/src/welcome/WelcomeBanner.js");
337
+ }
338
+ const riskRaw = asString(record.riskLevel) ||
339
+ asString(record.risk) ||
340
+ "standard";
341
+ const riskLevel = riskRaw === "small" || riskRaw === "standard" || riskRaw === "high-risk"
342
+ ? riskRaw
343
+ : riskRaw.includes("high")
344
+ ? "high-risk"
345
+ : "standard";
346
+ const verificationTargets = [];
347
+ const rawVerification = Array.isArray(record.verificationTargets)
348
+ ? record.verificationTargets
349
+ : asRecord(record.verificationTargets)
350
+ ? Object.entries(asRecord(record.verificationTargets)).map(([key, val]) => ({
351
+ ...(asRecord(val) ?? {}),
352
+ id: key,
353
+ }))
354
+ : [];
355
+ for (const [index, item] of rawVerification.entries()) {
356
+ const vt = asRecord(item) ?? {};
357
+ const id = asString(vt.id) || `VT-${String(index + 1).padStart(3, "0")}`;
358
+ const typeRaw = asString(vt.type) || asString(vt.phase) || "unit";
359
+ const type = ["static", "unit", "component", "integration", "mock"].includes(typeRaw)
360
+ ? typeRaw
361
+ : typeRaw.includes("type")
362
+ ? "static"
363
+ : "unit";
364
+ const commandLabel = asString(vt.commandLabel) ||
365
+ asString(vt.command) ||
366
+ (type === "static" ? "npm run typecheck" : "npm run test:unit:fe");
367
+ const file = asString(vt.file) ||
368
+ (Array.isArray(vt.symbols) ? targetFiles.find((p) => p.includes("__tests__")) : "") ||
369
+ targetFiles.find((p) => p.includes("__tests__")) ||
370
+ targetFiles[0];
371
+ const requirementIds = asStringArray(vt.requirementIds);
372
+ const uiStateNames = asStringArray(vt.uiStates);
373
+ verificationTargets.push({
374
+ id,
375
+ type,
376
+ commandLabel,
377
+ file,
378
+ symbol: asString(vt.symbol) || undefined,
379
+ requirementIds: requirementIds.length > 0
380
+ ? requirementIds
381
+ : [...canonicalBinding.requirementIds],
382
+ uiStates: uiStateNames.length > 0 ? uiStateNames : ["success", "error"],
383
+ });
384
+ }
385
+ if (verificationTargets.length === 0) {
386
+ verificationTargets.push({
387
+ id: "VT-STATIC",
388
+ type: "static",
389
+ commandLabel: "npm run typecheck",
390
+ file: targetFiles[0],
391
+ requirementIds: [...canonicalBinding.requirementIds],
392
+ uiStates: ["success"],
393
+ }, {
394
+ id: "VT-UNIT",
395
+ type: "unit",
396
+ commandLabel: "npm run test:unit:fe",
397
+ file: targetFiles.find((p) => p.includes("__tests__")) || targetFiles[0],
398
+ requirementIds: [...canonicalBinding.requirementIds],
399
+ uiStates: ["success", "error"],
400
+ });
401
+ }
402
+ const defaultVerificationIds = verificationTargets.map((item) => String(item.id));
403
+ const requirements = [];
404
+ const reqSource = Array.isArray(record.requirements)
405
+ ? record.requirements
406
+ : Array.isArray(record.requirementCoverage)
407
+ ? record.requirementCoverage
408
+ : [];
409
+ for (const item of reqSource) {
410
+ const req = asRecord(item) ?? {};
411
+ const id = asString(req.id);
412
+ if (!id)
413
+ continue;
414
+ const implementationTargetsRaw = asStringArray(req.implementationTargets);
415
+ const implementationTargets = implementationTargetsRaw.length > 0
416
+ ? implementationTargetsRaw
417
+ : targetFiles;
418
+ const verificationTargetIdsRaw = asStringArray(req.verificationTargetIds);
419
+ const verificationTargetIds = verificationTargetIdsRaw.length > 0
420
+ ? verificationTargetIdsRaw
421
+ : defaultVerificationIds;
422
+ const gapText = asString(asRecord(req.evidenceGap)?.description) ||
423
+ asString(req.realIntegrationGap);
424
+ const out = {
425
+ id,
426
+ implementationTargets: implementationTargets.length > 0 ? implementationTargets : targetFiles,
427
+ verificationTargetIds: verificationTargetIds.length > 0
428
+ ? verificationTargetIds
429
+ : defaultVerificationIds,
430
+ };
431
+ // Do not mark free-form realIntegrationGap as blocking; defer to FE-TEST/FINAL-VERIFY.
432
+ if (gapText) {
433
+ out.evidenceGap = {
434
+ description: gapText,
435
+ blocking: false,
436
+ requirementId: id,
437
+ };
438
+ }
439
+ requirements.push(out);
440
+ }
441
+ for (const id of canonicalBinding.requirementIds) {
442
+ if (!requirements.some((item) => item.id === id)) {
443
+ requirements.push({
444
+ id,
445
+ implementationTargets: targetFiles,
446
+ verificationTargetIds: defaultVerificationIds,
447
+ });
448
+ }
449
+ }
450
+ const uiStates = [];
451
+ if (Array.isArray(record.uiStates)) {
452
+ for (const item of record.uiStates) {
453
+ const state = asRecord(item);
454
+ if (!state)
455
+ continue;
456
+ uiStates.push(state);
457
+ }
458
+ }
459
+ else if (asRecord(record.uiStates)) {
460
+ for (const [name, raw] of Object.entries(asRecord(record.uiStates))) {
461
+ const state = asRecord(raw);
462
+ const text = asString(raw);
463
+ const na = text.toUpperCase().includes("N/A") ||
464
+ text.toLowerCase().includes("not applicable") ||
465
+ text.toLowerCase() === "n/a-minimal";
466
+ if (na || (state && asString(state.notApplicableReason))) {
467
+ uiStates.push({
468
+ name,
469
+ applicable: false,
470
+ notApplicableReason: asString(state?.notApplicableReason) || text || "not applicable",
471
+ });
472
+ continue;
473
+ }
474
+ const expectedBehavior = (state &&
475
+ [
476
+ asString(state.expectedBehavior),
477
+ asString(state.role) ? `role=${asString(state.role)}` : "",
478
+ asString(state.ariaLive) ? `ariaLive=${asString(state.ariaLive)}` : "",
479
+ asString(state.source) || asString(state.contentFrom),
480
+ asString(state.textMatchHint),
481
+ typeof state.silentFailure === "boolean"
482
+ ? `silentFailure=${state.silentFailure}`
483
+ : "",
484
+ ]
485
+ .filter(Boolean)
486
+ .join("; ")) ||
487
+ text ||
488
+ `${name} state`;
489
+ uiStates.push({
490
+ name,
491
+ applicable: true,
492
+ expectedBehavior,
493
+ implementationTargets: targetFiles,
494
+ verificationTargetIds: defaultVerificationIds,
495
+ });
496
+ }
497
+ }
498
+ if (uiStates.length === 0) {
499
+ uiStates.push({
500
+ name: "success",
501
+ applicable: true,
502
+ expectedBehavior: "visible success state",
503
+ implementationTargets: targetFiles,
504
+ verificationTargetIds: defaultVerificationIds,
505
+ }, {
506
+ name: "error",
507
+ applicable: true,
508
+ expectedBehavior: "visible error state",
509
+ implementationTargets: targetFiles,
510
+ verificationTargetIds: defaultVerificationIds,
511
+ });
512
+ }
513
+ const strategyRaw = asString(mockApiIn.strategy) || "not-needed";
514
+ const strategy = [
515
+ "native",
516
+ "browser-intercept",
517
+ "request-adapter",
518
+ "not-needed",
519
+ ].includes(strategyRaw)
520
+ ? strategyRaw
521
+ : "not-needed";
522
+ const activation = asString(mockApiIn.activation) ||
523
+ (strategy === "not-needed"
524
+ ? "production remains real fetch; unit tests may inject fetchImpl only"
525
+ : "documented mock activation");
526
+ const endpoints = [];
527
+ for (const item of Array.isArray(mockApiIn.endpoints) ? mockApiIn.endpoints : []) {
528
+ const ep = asRecord(item);
529
+ if (!ep)
530
+ continue;
531
+ const method = asString(ep.method).toUpperCase() || "GET";
532
+ const pathValue = asString(ep.path) || asString(api.path);
533
+ if (!pathValue.startsWith("/"))
534
+ continue;
535
+ endpoints.push({
536
+ method,
537
+ path: pathValue,
538
+ fixture: asString(ep.fixture) || undefined,
539
+ consumer: asString(ep.consumer) || undefined,
540
+ });
541
+ }
542
+ if (endpoints.length === 0 && asString(api.path).startsWith("/")) {
543
+ endpoints.push({
544
+ method: asString(api.method).toUpperCase() || "GET",
545
+ path: asString(api.path),
546
+ consumer: targetFiles[0],
547
+ });
548
+ }
549
+ const evidenceGaps = [];
550
+ for (const item of Array.isArray(record.evidenceGaps) ? record.evidenceGaps : []) {
551
+ const gapItem = asRecord(item);
552
+ if (!gapItem)
553
+ continue;
554
+ const description = asString(gapItem.description);
555
+ if (!description)
556
+ continue;
557
+ evidenceGaps.push({
558
+ description,
559
+ blocking: gapItem.blocking === true,
560
+ requirementId: asString(gapItem.requirementId) || undefined,
561
+ });
562
+ }
563
+ const realGap = asString(record.realIntegrationGap);
564
+ if (realGap) {
565
+ evidenceGaps.push({
566
+ description: realGap,
567
+ blocking: false,
568
+ });
569
+ }
570
+ for (const item of Array.isArray(record.residualRisks) ? record.residualRisks : []) {
571
+ const text = asString(item) || asString(asRecord(item)?.description);
572
+ if (!text)
573
+ continue;
574
+ evidenceGaps.push({ description: text, blocking: false });
575
+ }
576
+ const designPaths = [
577
+ ...canonicalBinding.referencePaths,
578
+ ...asStringArray(asRecord(record.designEvidence)?.paths),
579
+ ].filter((item, index, arr) => arr.indexOf(item) === index);
580
+ return {
581
+ schemaVersion: 1,
582
+ sourceBinding: canonicalBinding,
583
+ riskLevel,
584
+ targets: {
585
+ files: targetFiles,
586
+ routes: asStringArray(asRecord(record.targets)?.routes),
587
+ publicApiChanges: asStringArray(asRecord(record.targets)?.publicApiChanges),
588
+ },
589
+ requirements,
590
+ uiStates,
591
+ interactions: Array.isArray(record.interactions) ? record.interactions : [],
592
+ mockApi: {
593
+ strategy,
594
+ productionDefaultOff: true,
595
+ activation,
596
+ endpoints,
597
+ },
598
+ designEvidence: {
599
+ source: asString(asRecord(record.designEvidence)?.source) ||
600
+ "repository-fallback+task-source",
601
+ paths: designPaths.length > 0 ? designPaths : [canonicalBinding.requirementPath],
602
+ conflicts: asStringArray(asRecord(record.designEvidence)?.conflicts),
603
+ },
604
+ verificationTargets,
605
+ evidenceGaps,
606
+ };
607
+ }
286
608
  export async function materializeFrontendImplementationContract(input) {
287
609
  if (!input.sourceBinding)
288
610
  throw new Error("frontend implementation contract gate requires DAG sourceBinding");
@@ -303,11 +625,17 @@ export async function materializeFrontendImplementationContract(input) {
303
625
  // Always inject DAG-owned identity. Model-provided sourceBinding is advisory
304
626
  // only and must not fail a otherwise-valid contract (common live failure:
305
627
  // wrong requirementPath/sha, extra referencePaths, or omitted binding).
306
- const withCanonicalBinding = {
307
- ...parsed,
308
- sourceBinding: canonicalBinding,
309
- };
310
- const result = frontendImplementationContractSchema.safeParse(withCanonicalBinding);
628
+ const candidates = [
629
+ {
630
+ ...parsed,
631
+ sourceBinding: canonicalBinding,
632
+ },
633
+ coerceFrontendImplementationContractInput(parsed, canonicalBinding),
634
+ ];
635
+ let result = frontendImplementationContractSchema.safeParse(candidates[0]);
636
+ if (!result.success) {
637
+ result = frontendImplementationContractSchema.safeParse(candidates[1]);
638
+ }
311
639
  if (!result.success)
312
640
  throw new Error(`invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
313
641
  const blockingGaps = [
@@ -3052,6 +3052,7 @@ function buildGenerateBackendPytestNode(sources) {
3052
3052
  "- If a test filename exists, add suffix: test_order.py → test_order_01.py",
3053
3053
  "- Do NOT re-read source documents — use reviewed cases under testcase/md/ and upstream analyze-inputs-pi output only",
3054
3054
  "- Read existing conftest.py/pytest.ini to understand conventions, but do NOT modify them",
3055
+ "- Do NOT execute pytest/python -m pytest or npm test in this node; initial/final execution is owned by dedicated shell nodes. Local smoke runs create __pycache__/.pytest_cache and are unnecessary here.",
3055
3056
  ].join("\n\n"),
3056
3057
  };
3057
3058
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.16.6-beta.0",
3
+ "version": "0.16.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",