@tea-agent/loop-agent 0.19.0 → 0.20.0

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.
@@ -178,8 +178,10 @@ export function parseJunitXml(xml) {
178
178
  if (openedCases !== closedCases + selfClosingCases) {
179
179
  throw new Error("invalid junit xml: unclosed testcase element");
180
180
  }
181
- // Prefer root testsuites aggregates when present.
181
+ // Prefer root testsuites aggregates when present, then fall back to the first
182
+ // testsuite aggregate. Pytest commonly emits time only on <testsuite>.
182
183
  const suitesOpen = trimmed.match(/<testsuites\b[^>]*>/i)?.[0];
184
+ const firstSuiteOpen = trimmed.match(/<testsuite\b[^>]*>/i)?.[0];
183
185
  let tests = 0;
184
186
  let failed = 0;
185
187
  let errors = 0;
@@ -202,9 +204,15 @@ export function parseJunitXml(xml) {
202
204
  if (time !== undefined && time !== "")
203
205
  timeSec = Number(time);
204
206
  }
207
+ if (timeSec === undefined && firstSuiteOpen) {
208
+ const time = attr(firstSuiteOpen, "time");
209
+ if (time !== undefined && time !== "")
210
+ timeSec = Number(time);
211
+ }
205
212
  // Self-closing first so empty cases ending with /> are not greedily paired with a later </testcase>.
206
213
  const caseRe = /<testcase\b([^>]*?)\/>|<testcase\b([^>]*)>([\s\S]*?)<\/testcase>/gi;
207
214
  const failures = [];
215
+ const cases = [];
208
216
  let caseCount = 0;
209
217
  let caseFailed = 0;
210
218
  let caseErrors = 0;
@@ -216,35 +224,49 @@ export function parseJunitXml(xml) {
216
224
  const body = match[3] ?? "";
217
225
  const classname = attr(openAttrs, "classname") || "unknown";
218
226
  const name = attr(openAttrs, "name") || "unknown";
227
+ const caseTime = attr(openAttrs, "time");
228
+ const caseDurationMs = caseTime !== undefined && caseTime !== "" && !Number.isNaN(Number(caseTime))
229
+ ? Math.round(Number(caseTime) * 1000)
230
+ : undefined;
219
231
  const failureTag = body.match(/<failure\b([^>]*)>([\s\S]*?)<\/failure>|<failure\b([^>]*)\/>/i);
220
232
  const errorTag = body.match(/<error\b([^>]*)>([\s\S]*?)<\/error>|<error\b([^>]*)\/>/i);
221
233
  const skippedTag = /<skipped\b/i.test(body);
222
234
  if (failureTag) {
223
235
  caseFailed += 1;
224
236
  const fAttrs = failureTag[1] ?? failureTag[3] ?? "";
225
- const fBody = failureTag[2] ?? "";
226
- const message = attr(fAttrs, "message") || fBody || "failure";
237
+ const fBody = decodeXmlEntities(failureTag[2] ?? "").trim();
238
+ const message = decodeXmlEntities(attr(fAttrs, "message") || fBody || "failure");
239
+ const summary = truncate(message);
227
240
  failures.push({
228
241
  classname,
229
242
  name,
230
- message: truncate(message),
243
+ message: summary,
231
244
  kind: "failure",
232
245
  });
246
+ cases.push({ classname, name, durationMs: caseDurationMs, status: "failure", message: summary, details: fBody || summary });
233
247
  }
234
248
  else if (errorTag) {
235
249
  caseErrors += 1;
236
250
  const eAttrs = errorTag[1] ?? errorTag[3] ?? "";
237
- const eBody = errorTag[2] ?? "";
238
- const message = attr(eAttrs, "message") || eBody || "error";
251
+ const eBody = decodeXmlEntities(errorTag[2] ?? "").trim();
252
+ const message = decodeXmlEntities(attr(eAttrs, "message") || eBody || "error");
253
+ const summary = truncate(message);
239
254
  failures.push({
240
255
  classname,
241
256
  name,
242
- message: truncate(message),
257
+ message: summary,
243
258
  kind: "error",
244
259
  });
260
+ cases.push({ classname, name, durationMs: caseDurationMs, status: "error", message: summary, details: eBody || summary });
245
261
  }
246
262
  else if (skippedTag) {
247
263
  caseSkipped += 1;
264
+ const skippedAttrs = body.match(/<skipped\b([^>]*)/i)?.[1] ?? "";
265
+ const message = attr(skippedAttrs, "message");
266
+ cases.push({ classname, name, durationMs: caseDurationMs, status: "skipped", ...(message ? { message } : {}) });
267
+ }
268
+ else {
269
+ cases.push({ classname, name, durationMs: caseDurationMs, status: "passed" });
248
270
  }
249
271
  match = caseRe.exec(trimmed);
250
272
  }
@@ -322,6 +344,7 @@ export function parseJunitXml(xml) {
322
344
  durationMs: timeSec !== undefined && !Number.isNaN(timeSec)
323
345
  ? Math.round(timeSec * 1000)
324
346
  : undefined,
347
+ cases,
325
348
  failures: failures.slice(0, MAX_FAILURES),
326
349
  };
327
350
  }
@@ -0,0 +1,77 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { frontendImplementationContractSchema, materializeFrontendImplementationContract, } from "./frontend-implementation-contract.js";
4
+ async function selectNode(runDir, primary, fallbacks) {
5
+ for (const nodeId of [primary, ...fallbacks.filter((id) => id !== primary)]) {
6
+ try {
7
+ await stat(path.join(runDir, `${nodeId}.json`));
8
+ return nodeId;
9
+ }
10
+ catch (error) {
11
+ if (error.code === "ENOENT")
12
+ continue;
13
+ throw error;
14
+ }
15
+ }
16
+ throw new Error(`frontend prewrite gate missing node output: ${[primary, ...fallbacks].join(", ")}`);
17
+ }
18
+ async function readNodeText(runDir, nodeId) {
19
+ const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
20
+ const text = record.assistantText?.trim() || record.stdout?.trim() || "";
21
+ if (!text)
22
+ throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
23
+ return text;
24
+ }
25
+ function firstVerdictLine(text) {
26
+ for (const raw of text.split(/\r?\n/)) {
27
+ const line = raw.trim().replace(/^\*{1,3}\s*(VERDICT:[^*]+?)\s*\*{1,3}$/, "$1").trim();
28
+ if (line.startsWith("VERDICT:"))
29
+ return line;
30
+ }
31
+ return "";
32
+ }
33
+ export async function runFrontendPrewriteGate(input) {
34
+ const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
35
+ const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
36
+ const planText = await readNodeText(input.runDir, planNodeId);
37
+ const reviewText = await readNodeText(input.runDir, reviewNodeId);
38
+ const verdict = firstVerdictLine(reviewText);
39
+ if (verdict !== "VERDICT: pass") {
40
+ throw new Error(`frontend prewrite gate blocked by ${reviewNodeId}: ${verdict || "missing VERDICT"}`);
41
+ }
42
+ const missingIds = input.config.requiredRequirementIds.filter((id) => !planText.includes(id));
43
+ if (missingIds.length > 0) {
44
+ throw new Error(`frontend prewrite gate missing requirement ids: ${missingIds.join(", ")}`);
45
+ }
46
+ const artifact = await materializeFrontendImplementationContract({
47
+ runDir: input.runDir,
48
+ fromNodeId: planNodeId,
49
+ artifactName: input.config.artifactName,
50
+ outputDir: input.config.outputDir,
51
+ sourceBinding: input.sourceBinding,
52
+ });
53
+ const raw = JSON.parse(await readFile(artifact.path, "utf8"));
54
+ const contract = frontendImplementationContractSchema.parse(raw);
55
+ if (!input.config.allowedMockStrategies.includes(contract.mockApi.strategy)) {
56
+ throw new Error(`frontend prewrite gate blocked mock strategy ${contract.mockApi.strategy}; allowed=${input.config.allowedMockStrategies.join(",")}`);
57
+ }
58
+ return {
59
+ ok: true,
60
+ planNodeId,
61
+ reviewNodeId,
62
+ verdict,
63
+ mockStrategy: contract.mockApi.strategy,
64
+ artifact,
65
+ };
66
+ }
67
+ export function formatFrontendPrewriteGateStdout(result) {
68
+ return [
69
+ "Frontend prewrite gate: pass",
70
+ `Plan: ${result.planNodeId}`,
71
+ `Review: ${result.reviewNodeId}`,
72
+ `Mock strategy: ${result.mockStrategy}`,
73
+ `Structured artifact: ${result.artifact.path}`,
74
+ `Schema: ${result.artifact.schemaId}`,
75
+ `SHA-256: ${result.artifact.sha256}`,
76
+ ].join("\n");
77
+ }
@@ -190,6 +190,8 @@ export async function runFrontendFailureAssessGate(input) {
190
190
  throw new Error("assess: missing or invalid contracts/frontend-implementation-contract.json");
191
191
  }
192
192
  const candidateNodeIds = [
193
+ "frontend-verify-assess-shell",
194
+ "frontend-reverify-shell",
193
195
  "frontend-static-verify-shell",
194
196
  "frontend-behavior-verify-shell",
195
197
  "frontend-verification-trace-shell",
@@ -197,8 +199,12 @@ export async function runFrontendFailureAssessGate(input) {
197
199
  "frontend-behavior-reverify-shell",
198
200
  "frontend-verification-retrace-shell",
199
201
  ];
200
- const failed = [];
202
+ const failed = [
203
+ ...(input.failureFacts ?? []).filter(({ record }) => nodeHadFailure(record) || recordIndicatesCommandFailure(record)),
204
+ ];
201
205
  for (const nodeId of candidateNodeIds) {
206
+ if (failed.some((item) => item.nodeId === nodeId))
207
+ continue;
202
208
  const nodePath = path.join(input.runDir, `${nodeId}.json`);
203
209
  try {
204
210
  const record = JSON.parse(await readFile(nodePath, "utf8"));
@@ -0,0 +1,43 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
4
+ import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "./frontend-worktree-diff.js";
5
+ export const FRONTEND_REVIEW_CONTEXT_SCHEMA_ID = "frontend-review-context-v1";
6
+ async function readRequiredJson(runDir, relativePath) {
7
+ try {
8
+ return JSON.parse(await readFile(path.join(runDir, relativePath), "utf8"));
9
+ }
10
+ catch {
11
+ throw new Error(`frontend review context missing or invalid ${relativePath}`);
12
+ }
13
+ }
14
+ export async function runFrontendReviewContextGate(input) {
15
+ const diff = await runFrontendWorktreeDiffGate(input);
16
+ const contract = await readRequiredJson(input.runDir, "contracts/frontend-implementation-contract.json");
17
+ const verificationTrace = await readRequiredJson(input.runDir, "contracts/frontend-verification-trace.json");
18
+ const repairAssessment = await readRequiredJson(input.runDir, "contracts/frontend-repair-assessment.json");
19
+ const payload = {
20
+ schemaVersion: 1,
21
+ schemaId: FRONTEND_REVIEW_CONTEXT_SCHEMA_ID,
22
+ contract,
23
+ verificationTrace,
24
+ repairAssessment,
25
+ diff: {
26
+ schemaId: diff.schemaId,
27
+ patchPath: diff.patchPath,
28
+ patchSha256: diff.patchSha256,
29
+ changedFiles: diff.changedFiles,
30
+ untrackedFiles: diff.untrackedFiles,
31
+ empty: diff.empty,
32
+ },
33
+ };
34
+ const artifactPath = await writeDagRunJsonArtifact(input.runDir, "contracts/frontend-review-context.json", payload);
35
+ return { ok: true, artifactPath, diff };
36
+ }
37
+ export function formatFrontendReviewContextStdout(result) {
38
+ return [
39
+ "Frontend review context: pass",
40
+ `Artifact: ${result.artifactPath}`,
41
+ formatFrontendWorktreeDiffStdout(result.diff),
42
+ ].join("\n");
43
+ }
@@ -95,26 +95,45 @@ export async function runFrontendVerificationTraceGate(input) {
95
95
  }
96
96
  throw new Error(`trace: missing static/behavior verify node records (tried ${errors.join(", ")})`);
97
97
  }
98
- const staticNode = await loadFirstNode(staticCandidates);
99
- const behaviorNode = await loadFirstNode(behaviorCandidates);
98
+ const staticNode = input.evidence
99
+ ? {
100
+ nodeId: input.evidence.static.nodeId,
101
+ record: {
102
+ status: "FINISHED",
103
+ verifyEvidence: { commandLabels: input.evidence.static.commandLabels },
104
+ },
105
+ }
106
+ : await loadFirstNode(staticCandidates);
107
+ const behaviorNode = input.evidence
108
+ ? {
109
+ nodeId: input.evidence.behavior.nodeId,
110
+ record: {
111
+ status: "FINISHED",
112
+ verifyEvidence: { commandLabels: input.evidence.behavior.commandLabels },
113
+ },
114
+ }
115
+ : await loadFirstNode(behaviorCandidates);
100
116
  assertNodeFinished(staticNode.nodeId, staticNode.record);
101
117
  assertNodeFinished(behaviorNode.nodeId, behaviorNode.record);
102
118
  const labelOwners = collectCommandLabels({ nodeId: staticNode.nodeId, record: staticNode.record }, { nodeId: behaviorNode.nodeId, record: behaviorNode.record });
103
- const assessPath = path.join(input.runDir, "frontend-mock-assess-pi.json");
104
- try {
105
- const assess = (await readJsonFile(assessPath));
106
- const text = assess.assistantText?.trim() || assess.stdout?.trim() || "";
107
- const strategy = parseMockStrategyFromAssess(text);
108
- if (strategy && strategy !== contract.mockApi.strategy) {
109
- throw new Error(`trace: mock strategy mismatch: assess=${strategy} contract=${contract.mockApi.strategy}`);
119
+ // Legacy runs may still carry a standalone Mock assessment. New slim runs
120
+ // bind the strategy directly through the validated implementation contract.
121
+ if (!input.evidence) {
122
+ const assessPath = path.join(input.runDir, "frontend-mock-assess-pi.json");
123
+ try {
124
+ const assess = (await readJsonFile(assessPath));
125
+ const text = assess.assistantText?.trim() || assess.stdout?.trim() || "";
126
+ const strategy = parseMockStrategyFromAssess(text);
127
+ if (strategy && strategy !== contract.mockApi.strategy) {
128
+ throw new Error(`trace: mock strategy mismatch: assess=${strategy} contract=${contract.mockApi.strategy}`);
129
+ }
110
130
  }
111
- }
112
- catch (error) {
113
- if (error instanceof Error &&
114
- error.message.startsWith("trace: mock strategy mismatch")) {
115
- throw error;
131
+ catch (error) {
132
+ if (error instanceof Error &&
133
+ error.message.startsWith("trace: mock strategy mismatch")) {
134
+ throw error;
135
+ }
116
136
  }
117
- // assess optional for pure unit fixtures without mock node
118
137
  }
119
138
  const targets = [];
120
139
  const hardIssues = [];
@@ -104,16 +104,24 @@ function writeSetMatchesAnyPattern(writeSet, patterns) {
104
104
  return undefined;
105
105
  }
106
106
  function shellCommandLooksDeterministic(command) {
107
- return (/\bnpx vitest run\b/.test(command) ||
108
- /\bnpm run (lint|typecheck|test)\b/.test(command) ||
109
- /check-repo\.sh/.test(command) ||
110
- /\bshell\.preset\b/.test(command) ||
111
- /loop-agent-standard-verify/.test(command));
107
+ const normalized = command.replace(/["']/g, "");
108
+ return (/\bnpx vitest run\b/.test(normalized) ||
109
+ /\bnpm (?:run )?(?:lint|typecheck|test)\b/.test(normalized) ||
110
+ /check-repo\.sh/.test(normalized) ||
111
+ /\bshell\.preset\b/.test(normalized) ||
112
+ /loop-agent-standard-verify/.test(normalized));
112
113
  }
113
114
  function taskHasDeterministicShellVerification(task) {
114
115
  if (task.executor !== "shell")
115
116
  return false;
116
- const commands = resolveShellCommands(task.shell ?? { commands: [] });
117
+ const bundle = task.shell?.frontendVerificationBundle;
118
+ const commands = bundle
119
+ ? [
120
+ ...bundle.mockCommands,
121
+ ...bundle.staticCommands,
122
+ ...bundle.behaviorCommands,
123
+ ]
124
+ : resolveShellCommands(task.shell ?? { commands: [] });
117
125
  return commands.some(shellCommandLooksDeterministic);
118
126
  }
119
127
  function collectExclusiveWriters(spec) {