@tea-agent/loop-agent 0.16.3 → 0.16.5

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
@@ -15,6 +15,18 @@
15
15
  - Pi SDK 执行长推理或大段结构化输出时不再把高频流式增量事件无界累积到内存;同一响应在多个生命周期事件中重复出现的 Token 用量只统计一次,避免 `Invalid string length` 和成本数据虚高。
16
16
  - 后端测试复合执行节点继续保持 clean environment、失败分类和 fail-closed outcome,并为 initial/final Result、repair eligibility、traceability 与 Observe 投影保留结构化运行证据。
17
17
 
18
+ ## [0.16.5] - 2026-07-20
19
+
20
+ ### 修复
21
+
22
+ - 后端测试分析/执行合同物化支持 free-form 环境侦察与 GWT/嵌套 endpoint 形状的严格 schema 归一化,避免 BE-TEST 在 contracts 门被模型字段漂移误拦。
23
+
24
+ ## [0.16.4] - 2026-07-19
25
+
26
+ ### 修复
27
+
28
+ - 后端测试 `backend-test-semantic-final-gate-shell` 改为复合 pipeline `semantic-effective`,不再手写读 contracts 的 inline shell,避免 `--strict-governance` 误判为 fragile verdict gate 并阻断 BE-TEST DAG 校验。
29
+
18
30
  ## [0.16.3] - 2026-07-19
19
31
 
20
32
  ### 修复
@@ -334,6 +334,20 @@ async function executeBackendTestPipeline(input, meta) {
334
334
  throw new Error("backend pytest semantic review did not pass");
335
335
  }
336
336
  }
337
+ else if (pipeline === "semantic-effective") {
338
+ // Prefer final review contract when revision ran; otherwise accept initial pass.
339
+ const finalPath = path.join(meta.runDir, "contracts", "backend-test-semantic-review-final.json");
340
+ const initialPath = path.join(meta.runDir, "contracts", "backend-test-semantic-review.json");
341
+ const chosen = existsSync(finalPath) ? finalPath : initialPath;
342
+ if (!existsSync(chosen)) {
343
+ throw new Error("missing backend-test semantic review contract for effective gate");
344
+ }
345
+ const parsed = JSON.parse(await readFile(chosen, "utf8"));
346
+ if (parsed.verdict !== "pass") {
347
+ throw new Error("backend pytest semantic review did not pass");
348
+ }
349
+ outputs.push(`semantic=${chosen}`, `verdict=${parsed.verdict}`);
350
+ }
337
351
  else if (pipeline === "execute-parse-initial") {
338
352
  const results = await executePipelineCommands(input, meta);
339
353
  if (!results.every((result) => result.ok)) {
@@ -108,25 +108,317 @@ export function extractStrictJsonObject(text) {
108
108
  }
109
109
  return JSON.parse(blocks[0][1]);
110
110
  }
111
+ function asRecord(value) {
112
+ return value && typeof value === "object" && !Array.isArray(value)
113
+ ? value
114
+ : null;
115
+ }
116
+ function asArray(value) {
117
+ return Array.isArray(value) ? value : [];
118
+ }
119
+ function firstSourceRef(value, fallback = "source/需求.md") {
120
+ const record = asRecord(value);
121
+ if (!record)
122
+ return fallback;
123
+ if (typeof record.sourceRef === "string" && record.sourceRef.trim())
124
+ return record.sourceRef.trim();
125
+ const refs = asArray(record.sourceRefs).filter((item) => typeof item === "string" && item.trim().length > 0);
126
+ return refs[0]?.trim() || fallback;
127
+ }
128
+ function coerceField(value) {
129
+ const record = asRecord(value);
130
+ if (!record || typeof record.name !== "string" || !record.name.trim())
131
+ return null;
132
+ const sourceRefs = asArray(record.sourceRefs).filter((item) => typeof item === "string" && item.trim().length > 0);
133
+ const field = {
134
+ name: record.name.trim(),
135
+ sourceRefs,
136
+ };
137
+ if (typeof record.type === "string" && record.type.trim())
138
+ field.type = record.type.trim();
139
+ if (typeof record.required === "boolean")
140
+ field.required = record.required;
141
+ if (typeof record.description === "string")
142
+ field.description = record.description;
143
+ else if (typeof record.notes === "string")
144
+ field.description = record.notes;
145
+ if (typeof record.format === "string" && record.format.trim())
146
+ field.format = record.format.trim();
147
+ if (record.comparison === "exact" || record.comparison === "parseable-only" || record.comparison === "semantic") {
148
+ field.comparison = record.comparison;
149
+ }
150
+ if (typeof record.precision === "string" && record.precision.trim())
151
+ field.precision = record.precision.trim();
152
+ return field;
153
+ }
154
+ function coerceAcceptanceCriterion(value) {
155
+ const record = asRecord(value);
156
+ if (!record || typeof record.id !== "string" || !record.id.trim())
157
+ return null;
158
+ const textParts = [];
159
+ if (typeof record.text === "string" && record.text.trim())
160
+ textParts.push(record.text.trim());
161
+ else {
162
+ if (typeof record.title === "string" && record.title.trim())
163
+ textParts.push(record.title.trim());
164
+ const gwt = ["given", "when", "then"]
165
+ .map((key) => (typeof record[key] === "string" && record[key].trim() ? `${key}: ${String(record[key]).trim()}` : ""))
166
+ .filter(Boolean);
167
+ if (gwt.length)
168
+ textParts.push(gwt.join("; "));
169
+ else if (typeof record.description === "string" && record.description.trim())
170
+ textParts.push(record.description.trim());
171
+ }
172
+ const text = textParts.join(" — ").trim();
173
+ if (!text)
174
+ return null;
175
+ return { id: record.id.trim(), text, sourceRef: firstSourceRef(record) };
176
+ }
177
+ function coerceEndpoint(value) {
178
+ const record = asRecord(value);
179
+ if (!record || typeof record.id !== "string" || !record.id.trim())
180
+ return null;
181
+ if (typeof record.method !== "string" || typeof record.path !== "string")
182
+ return null;
183
+ const method = record.method.trim().toUpperCase();
184
+ const pathValue = record.path.trim();
185
+ if (!pathValue.startsWith("/"))
186
+ return null;
187
+ const request = asRecord(record.request);
188
+ const successResponse = asRecord(record.successResponse);
189
+ const nestedBody = asRecord(successResponse?.responseBody) ?? asRecord(record.responseBody);
190
+ const requestFields = [
191
+ ...asArray(record.requestFields),
192
+ ...asArray(request?.headers),
193
+ ...asArray(request?.query),
194
+ ...asArray(request?.pathParams),
195
+ ]
196
+ .map(coerceField)
197
+ .filter((item) => item !== null);
198
+ const responseFields = [
199
+ ...asArray(record.responseFields),
200
+ ...asArray(successResponse?.fields),
201
+ ]
202
+ .map(coerceField)
203
+ .filter((item) => item !== null);
204
+ const successStatuses = asArray(record.successStatuses)
205
+ .map((item) => (typeof item === "number" ? item : Number.NaN))
206
+ .filter((item) => Number.isInteger(item) && item >= 100 && item <= 399);
207
+ if (typeof successResponse?.status === "number" && successResponse.status >= 100 && successResponse.status <= 399) {
208
+ successStatuses.push(successResponse.status);
209
+ }
210
+ const uniqueSuccess = [...new Set(successStatuses)];
211
+ const errorCases = [
212
+ ...asArray(record.errorCases),
213
+ ...asArray(record.errorResponses),
214
+ ]
215
+ .map((item) => {
216
+ const err = asRecord(item);
217
+ if (!err)
218
+ return null;
219
+ const description = (typeof err.description === "string" && err.description.trim()) ||
220
+ (typeof err.message === "string" && err.message.trim()) ||
221
+ (typeof err.code === "string" && err.code.trim()) ||
222
+ (typeof err.status === "number" ? `HTTP ${err.status}` : "");
223
+ if (!description)
224
+ return null;
225
+ const out = { description };
226
+ if (typeof err.status === "number" && err.status >= 400 && err.status <= 599)
227
+ out.status = err.status;
228
+ if (typeof err.code === "string" && err.code.trim())
229
+ out.code = err.code.trim();
230
+ if (typeof err.messageField === "string" && err.messageField.trim())
231
+ out.messageField = err.messageField.trim();
232
+ return out;
233
+ })
234
+ .filter((item) => item !== null);
235
+ const kind = nestedBody && typeof nestedBody.kind === "string" && ["array", "object", "scalar", "empty", "unknown"].includes(nestedBody.kind)
236
+ ? nestedBody.kind
237
+ : "unknown";
238
+ const ordering = nestedBody && typeof nestedBody.ordering === "string" && ["specified", "unspecified", "not-applicable"].includes(nestedBody.ordering)
239
+ ? nestedBody.ordering
240
+ : "unspecified";
241
+ const responseBody = { kind, ordering };
242
+ if (nestedBody && typeof nestedBody.itemSchemaRef === "string" && nestedBody.itemSchemaRef.trim()) {
243
+ responseBody.itemSchemaRef = nestedBody.itemSchemaRef.trim();
244
+ }
245
+ if (nestedBody && typeof nestedBody.description === "string" && nestedBody.description.trim()) {
246
+ responseBody.description = nestedBody.description.trim();
247
+ }
248
+ else if (nestedBody && typeof nestedBody.schemaRef === "string" && nestedBody.schemaRef.trim()) {
249
+ responseBody.description = `schemaRef=${nestedBody.schemaRef.trim()}`;
250
+ }
251
+ const sourceRefs = [
252
+ ...asArray(record.sourceRefs),
253
+ ...asArray(successResponse?.sourceRefs),
254
+ ].filter((item) => typeof item === "string" && item.trim().length > 0);
255
+ return {
256
+ id: record.id.trim(),
257
+ method,
258
+ path: pathValue,
259
+ requestFields,
260
+ responseFields,
261
+ responseBody,
262
+ successStatuses: uniqueSuccess.length ? uniqueSuccess : [200],
263
+ errorCases,
264
+ sourceRefs,
265
+ };
266
+ }
267
+ function coerceEvidencedIdItem(value, textKey) {
268
+ const record = asRecord(value);
269
+ if (!record)
270
+ return null;
271
+ const id = (typeof record.id === "string" && record.id.trim()) ||
272
+ (typeof record.name === "string" && record.name.trim()) ||
273
+ "";
274
+ if (!id)
275
+ return null;
276
+ const text = (typeof record[textKey] === "string" && String(record[textKey]).trim()) ||
277
+ (typeof record.description === "string" && record.description.trim()) ||
278
+ (typeof record.text === "string" && record.text.trim()) ||
279
+ (typeof record.notes === "string" && record.notes.trim()) ||
280
+ (typeof record.title === "string" && record.title.trim()) ||
281
+ "";
282
+ if (!text)
283
+ return null;
284
+ return { id, [textKey]: text, sourceRef: firstSourceRef(record) };
285
+ }
286
+ function coerceBoundary(value) {
287
+ const record = asRecord(value);
288
+ if (!record)
289
+ return null;
290
+ const field = (typeof record.field === "string" && record.field.trim()) ||
291
+ (typeof record.id === "string" && record.id.trim()) ||
292
+ (typeof record.name === "string" && record.name.trim()) ||
293
+ "";
294
+ const constraint = (typeof record.constraint === "string" && record.constraint.trim()) ||
295
+ (typeof record.description === "string" && record.description.trim()) ||
296
+ (typeof record.text === "string" && record.text.trim()) ||
297
+ "";
298
+ if (!field || !constraint)
299
+ return null;
300
+ return { field, constraint, sourceRef: firstSourceRef(record) };
301
+ }
302
+ const OPTIONAL_EVIDENCE_KNOWN_KEYS = new Set([
303
+ "name",
304
+ "id",
305
+ "description",
306
+ "notes",
307
+ "text",
308
+ "title",
309
+ "sourceRef",
310
+ "sourceRefs",
311
+ "severity",
312
+ "mitigation",
313
+ "level",
314
+ "impact",
315
+ "kind",
316
+ "required",
317
+ ]);
318
+ function coerceOptionalEvidence(value) {
319
+ const record = asRecord(value);
320
+ if (!record)
321
+ return null;
322
+ // Fail closed on unknown keys so near-schema payloads cannot strip extras and pass.
323
+ for (const key of Object.keys(record)) {
324
+ if (!OPTIONAL_EVIDENCE_KNOWN_KEYS.has(key)) {
325
+ throw new Error(`optional evidence has unrecognized key: ${key}`);
326
+ }
327
+ }
328
+ const description = (typeof record.description === "string" && record.description.trim()) ||
329
+ (typeof record.notes === "string" && record.notes.trim()) ||
330
+ (typeof record.text === "string" && record.text.trim()) ||
331
+ (typeof record.title === "string" && record.title.trim()) ||
332
+ "";
333
+ if (!description)
334
+ return null;
335
+ const out = { description };
336
+ const name = (typeof record.name === "string" && record.name.trim()) ||
337
+ (typeof record.id === "string" && record.id.trim()) ||
338
+ "";
339
+ if (name)
340
+ out.name = name;
341
+ const sourceRef = firstSourceRef(record, "");
342
+ if (sourceRef)
343
+ out.sourceRef = sourceRef;
344
+ return out;
345
+ }
346
+ function coerceEvidenceGap(value) {
347
+ const record = asRecord(value);
348
+ if (!record)
349
+ return null;
350
+ const description = (typeof record.description === "string" && record.description.trim()) ||
351
+ (typeof record.text === "string" && record.text.trim()) ||
352
+ "";
353
+ if (!description)
354
+ return null;
355
+ const out = { description };
356
+ if (typeof record.requirementId === "string" && record.requirementId.trim())
357
+ out.requirementId = record.requirementId.trim();
358
+ else if (typeof record.acId === "string" && record.acId.trim())
359
+ out.requirementId = record.acId.trim();
360
+ const sourceRef = firstSourceRef(record, "");
361
+ if (sourceRef)
362
+ out.sourceRef = sourceRef;
363
+ return out;
364
+ }
365
+ /** Coerce common free-form model shapes into Backend Test Analysis v2 before strict parse. */
366
+ export function coerceBackendTestAnalysisInput(value) {
367
+ const record = asRecord(value);
368
+ if (!record)
369
+ return value;
370
+ const next = { ...record, schemaVersion: 2 };
371
+ next.acceptanceCriteria = asArray(record.acceptanceCriteria)
372
+ .map(coerceAcceptanceCriterion)
373
+ .filter((item) => item !== null);
374
+ next.endpoints = asArray(record.endpoints)
375
+ .map(coerceEndpoint)
376
+ .filter((item) => item !== null);
377
+ next.dataModels = asArray(record.dataModels)
378
+ .map((item) => coerceEvidencedIdItem(item, "description"))
379
+ .filter((item) => item !== null);
380
+ next.businessRules = asArray(record.businessRules)
381
+ .map((item) => coerceEvidencedIdItem(item, "text"))
382
+ .filter((item) => item !== null);
383
+ next.stateTransitions = asArray(record.stateTransitions);
384
+ next.boundaryConstraints = asArray(record.boundaryConstraints)
385
+ .map(coerceBoundary)
386
+ .filter((item) => item !== null);
387
+ next.externalDependencies = asArray(record.externalDependencies)
388
+ .map(coerceOptionalEvidence)
389
+ .filter((item) => item !== null);
390
+ next.risks = asArray(record.risks)
391
+ .map(coerceOptionalEvidence)
392
+ .filter((item) => item !== null);
393
+ next.evidenceGaps = asArray(record.evidenceGaps)
394
+ .map(coerceEvidenceGap)
395
+ .filter((item) => item !== null);
396
+ return next;
397
+ }
111
398
  function normalizeAnalysis(value) {
112
- const v2 = backendTestAnalysisContractSchema.safeParse(value);
113
- if (v2.success)
114
- return v2.data;
115
- const v1 = analysisV1Schema.safeParse(value);
116
- if (!v1.success) {
117
- throw new Error(v2.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "));
399
+ const candidates = [value, coerceBackendTestAnalysisInput(value)];
400
+ let lastError = "invalid analysis contract";
401
+ for (const candidate of candidates) {
402
+ const v2 = backendTestAnalysisContractSchema.safeParse(candidate);
403
+ if (v2.success)
404
+ return v2.data;
405
+ lastError = v2.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
406
+ const v1 = analysisV1Schema.safeParse(candidate);
407
+ if (v1.success) {
408
+ return backendTestAnalysisContractSchema.parse({
409
+ ...v1.data,
410
+ schemaVersion: 2,
411
+ endpoints: v1.data.endpoints.map((endpoint) => ({
412
+ ...endpoint,
413
+ requestFields: endpoint.requestFields.map((field) => ({ ...field, sourceRefs: [] })),
414
+ responseFields: endpoint.responseFields.map((field) => ({ ...field, sourceRefs: [] })),
415
+ responseBody: { kind: "unknown", ordering: "unspecified" },
416
+ sourceRefs: [],
417
+ })),
418
+ });
419
+ }
118
420
  }
119
- return backendTestAnalysisContractSchema.parse({
120
- ...v1.data,
121
- schemaVersion: 2,
122
- endpoints: v1.data.endpoints.map((endpoint) => ({
123
- ...endpoint,
124
- requestFields: endpoint.requestFields.map((field) => ({ ...field, sourceRefs: [] })),
125
- responseFields: endpoint.responseFields.map((field) => ({ ...field, sourceRefs: [] })),
126
- responseBody: { kind: "unknown", ordering: "unspecified" },
127
- sourceRefs: [],
128
- })),
129
- });
421
+ throw new Error(lastError);
130
422
  }
131
423
  function assertSourceBinding(contract, binding) {
132
424
  const requirement = binding.sources.find((source) => source.kind === "requirement");
@@ -324,6 +324,171 @@ export function assertBackendTestExecutionPreflight(input) {
324
324
  workingDirectory,
325
325
  };
326
326
  }
327
+ function asRecord(value) {
328
+ return value && typeof value === "object" && !Array.isArray(value)
329
+ ? value
330
+ : null;
331
+ }
332
+ /**
333
+ * Coerce free-form environment scout JSON into Backend Test Execution Contract v1.
334
+ * Prefer exact schema payloads; otherwise map common discovery shapes onto the
335
+ * pytest-centric runtime contract without inventing secrets or managed commands.
336
+ */
337
+ export function coerceBackendTestExecutionInput(value) {
338
+ const direct = backendTestExecutionContractSchema.safeParse(value);
339
+ if (direct.success)
340
+ return direct.data;
341
+ const record = asRecord(value);
342
+ if (!record)
343
+ return value;
344
+ // Near-schema payloads (string framework + runner + testRoot) must stay fail-closed.
345
+ // Only free-form discovery envelopes are rewritten onto the pytest contract.
346
+ const looksSchemaShaped = typeof record.framework === "string" &&
347
+ asRecord(record.runner) !== null &&
348
+ typeof record.testRoot === "string";
349
+ if (looksSchemaShaped)
350
+ return value;
351
+ if (typeof record.framework === "string" && record.framework !== "pytest") {
352
+ return value;
353
+ }
354
+ const frameworkObj = asRecord(record.framework);
355
+ const discovered = asRecord(record.discoveredFixtures);
356
+ const verification = asRecord(record.verification);
357
+ const layout = asRecord(record.repositoryLayout);
358
+ const environment = asRecord(record.environment);
359
+ const commandHints = [];
360
+ if (typeof record.primaryCommand === "string" && record.primaryCommand.trim()) {
361
+ commandHints.push(record.primaryCommand.trim());
362
+ }
363
+ if (frameworkObj && typeof frameworkObj.primaryCommand === "string" && frameworkObj.primaryCommand.trim()) {
364
+ commandHints.push(frameworkObj.primaryCommand.trim());
365
+ }
366
+ for (const item of Array.isArray(verification?.commands) ? verification.commands : []) {
367
+ if (typeof item === "string" && item.trim())
368
+ commandHints.push(item.trim());
369
+ }
370
+ if (commandHints.length === 0) {
371
+ commandHints.push(`python -m pytest ${BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT}/ -v`);
372
+ }
373
+ const existingFixtures = [];
374
+ for (const item of Array.isArray(record.existingFixtures) ? record.existingFixtures : []) {
375
+ const fixture = asRecord(item);
376
+ if (!fixture)
377
+ continue;
378
+ if (typeof fixture.name === "string" && typeof fixture.sourcePath === "string" && typeof fixture.kind === "string") {
379
+ existingFixtures.push({
380
+ name: fixture.name,
381
+ sourcePath: fixture.sourcePath,
382
+ kind: fixture.kind,
383
+ });
384
+ }
385
+ }
386
+ if (existingFixtures.length === 0 && discovered) {
387
+ const bootstrap = asRecord(discovered.serverBootstrap);
388
+ if (bootstrap && typeof bootstrap.module === "string" && bootstrap.module.trim()) {
389
+ existingFixtures.push({
390
+ name: typeof bootstrap.symbol === "string" && bootstrap.symbol.trim()
391
+ ? bootstrap.symbol.trim()
392
+ : "server-bootstrap",
393
+ sourcePath: bootstrap.module.trim(),
394
+ kind: "server-bootstrap",
395
+ });
396
+ }
397
+ for (const key of ["auth", "database", "remoteServices"]) {
398
+ const val = discovered[key];
399
+ if (typeof val === "string" && val.trim() && val.trim() !== "none") {
400
+ existingFixtures.push({
401
+ name: key,
402
+ sourcePath: ".",
403
+ kind: val.trim(),
404
+ });
405
+ }
406
+ }
407
+ }
408
+ if (existingFixtures.length === 0) {
409
+ // Fail-closed in-process contract still needs one fixture entry; use default test root as a non-secret anchor.
410
+ existingFixtures.push({
411
+ name: "pytest-test-root",
412
+ sourcePath: BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT,
413
+ kind: "test-root",
414
+ });
415
+ }
416
+ const authenticationMode = (typeof record.authenticationMode === "string" && record.authenticationMode.trim()) ||
417
+ (typeof discovered?.auth === "string" && discovered.auth.trim()) ||
418
+ "none";
419
+ const evidenceRefs = [];
420
+ for (const item of Array.isArray(record.evidenceRefs) ? record.evidenceRefs : []) {
421
+ if (typeof item === "string" && item.trim())
422
+ evidenceRefs.push(item.trim());
423
+ }
424
+ if (layout) {
425
+ for (const key of [
426
+ "apiContractDoc",
427
+ "routeImplementation",
428
+ "serviceImplementation",
429
+ "serverEntry",
430
+ ]) {
431
+ const val = layout[key];
432
+ if (typeof val === "string" && val.trim())
433
+ evidenceRefs.push(val.trim());
434
+ }
435
+ for (const item of Array.isArray(layout.existingApiTests) ? layout.existingApiTests : []) {
436
+ if (typeof item === "string" && item.trim())
437
+ evidenceRefs.push(item.trim());
438
+ }
439
+ }
440
+ const targetMode = record.targetMode === "external-running-service" ||
441
+ record.targetMode === "managed-command" ||
442
+ record.targetMode === "in-process"
443
+ ? record.targetMode
444
+ : "in-process";
445
+ const coerced = {
446
+ schemaVersion: 1,
447
+ framework: "pytest",
448
+ runner: {
449
+ frozenCommandHints: [...new Set(commandHints)],
450
+ },
451
+ testRoot: (typeof record.testRoot === "string" && record.testRoot.trim()) ||
452
+ BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT,
453
+ workingDirectory: (typeof record.workingDirectory === "string" && record.workingDirectory.trim()) ||
454
+ ".",
455
+ report: asRecord(record.report) ?? {
456
+ format: "junit",
457
+ relativeHint: "reports/backend-test-junit.xml",
458
+ },
459
+ targetMode,
460
+ existingFixtures,
461
+ authenticationMode,
462
+ requiredEnvNames: Array.isArray(record.requiredEnvNames)
463
+ ? record.requiredEnvNames.filter((item) => typeof item === "string" && item.trim().length > 0)
464
+ : [],
465
+ dataIsolation: asRecord(record.dataIsolation) ?? {
466
+ mode: "ephemeral-local",
467
+ evidence: "coerced from free-form environment scout; no durable shared fixtures",
468
+ },
469
+ evidenceGaps: Array.isArray(record.evidenceGaps) ? record.evidenceGaps : [],
470
+ evidenceRefs: [...new Set(evidenceRefs)],
471
+ };
472
+ if (typeof record.baseUrlEnvName === "string" && record.baseUrlEnvName.trim()) {
473
+ coerced.baseUrlEnvName = record.baseUrlEnvName.trim();
474
+ }
475
+ if (Array.isArray(record.readiness))
476
+ coerced.readiness = record.readiness;
477
+ if (asRecord(record.managedCommand))
478
+ coerced.managedCommand = record.managedCommand;
479
+ // Preserve free-form discovery notes as non-blocking evidence gaps when useful.
480
+ if (frameworkObj &&
481
+ typeof frameworkObj.testRunner === "string" &&
482
+ frameworkObj.testRunner !== "pytest") {
483
+ const gaps = Array.isArray(coerced.evidenceGaps) ? [...coerced.evidenceGaps] : [];
484
+ gaps.push({
485
+ description: `environment scout reported testRunner=${frameworkObj.testRunner}; runtime contract remains pytest with default testRoot=${BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT}`,
486
+ sourceRef: typeof environment?.cwd === "string" ? "package.json" : "source/需求.md",
487
+ });
488
+ coerced.evidenceGaps = gaps;
489
+ }
490
+ return coerced;
491
+ }
327
492
  export async function materializeBackendTestExecutionContract(input) {
328
493
  if (!/^[a-z0-9][a-z0-9._-]*\.json$/.test(input.artifactName) ||
329
494
  !/^[a-z0-9][a-z0-9._-]*$/.test(input.outputDir)) {
@@ -339,17 +504,29 @@ export async function materializeBackendTestExecutionContract(input) {
339
504
  catch (error) {
340
505
  throw new Error(`invalid-output: ${error instanceof Error ? error.message : String(error)}`);
341
506
  }
342
- const secrets = secretIssues(parsed);
343
- if (secrets.length) {
344
- throw new Error(`invalid-output: ${secrets.join("; ")}`);
345
- }
346
- const result = backendTestExecutionContractSchema.safeParse(parsed);
347
- if (!result.success) {
348
- throw new Error(`invalid-output: ${result.error.issues
507
+ const candidates = [parsed, coerceBackendTestExecutionInput(parsed)];
508
+ let accepted = null;
509
+ let lastSchemaError = "invalid execution contract";
510
+ let lastSecretError = "";
511
+ for (const candidate of candidates) {
512
+ const secrets = secretIssues(candidate);
513
+ if (secrets.length) {
514
+ lastSecretError = secrets.join("; ");
515
+ continue;
516
+ }
517
+ const result = backendTestExecutionContractSchema.safeParse(candidate);
518
+ if (result.success) {
519
+ accepted = result.data;
520
+ break;
521
+ }
522
+ lastSchemaError = result.error.issues
349
523
  .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
350
- .join("; ")}`);
524
+ .join("; ");
525
+ }
526
+ if (!accepted) {
527
+ throw new Error(`invalid-output: ${lastSecretError || lastSchemaError}`);
351
528
  }
352
- const normalized = normalizeContractPaths(result.data);
529
+ const normalized = normalizeContractPaths(accepted);
353
530
  // evidenceGaps may record greenfield/incomplete discovery (no test_*.py yet,
354
531
  // missing pytest.ini, projected schema path, etc.). Do not block materialize:
355
532
  // generate-functional-cases / generate-pytest are expected to fill automation.
@@ -3128,10 +3128,8 @@ function buildBackendTestSemanticFinalGateNode(sources) {
3128
3128
  outputContract: "Pass-only semantic authorization for traceability and initial pytest.",
3129
3129
  subtask_prompt: "Accept initial pass path or final semantic review pass; fail closed otherwise.",
3130
3130
  shell: {
3131
- commands: [[
3132
- 'test -n "${HARNESS_DAG_RUN_DIR:-}" || exit 2',
3133
- 'node -e \'const fs=require("fs"),path=require("path");const r=process.env.HARNESS_DAG_RUN_DIR;const final=path.join(r,"contracts","backend-test-semantic-review-final.json");const first=path.join(r,"contracts","backend-test-semantic-review.json");const p=fs.existsSync(final)?final:first;const v=JSON.parse(fs.readFileSync(p,"utf8"));if(v.verdict!=="pass")throw new Error("backend pytest semantic review did not pass");console.log("backend pytest semantic gate: pass");\'',
3134
- ].join("; ")],
3131
+ commands: [],
3132
+ backendTestPipeline: "semantic-effective",
3135
3133
  cwd: ".",
3136
3134
  timeoutMs: 60000,
3137
3135
  },
@@ -3566,9 +3564,26 @@ function buildBackendTestHybridDag(sources) {
3566
3564
  revisePytest.runIf = "$.nodes['validate-semantic-review-and-traceability-shell'].json.verdict == 'request-revision'";
3567
3565
  const finalSemanticReview = buildBackendTestSemanticReviewNode(sources, { id: "review-generated-backend-pytest-final-pi", dependsOn: [revisePytest.id], final: true });
3568
3566
  finalSemanticReview.runIf = revisePytest.runIf;
3569
- const semanticFinal = { id: "backend-test-semantic-final-gate-shell", depends_on: [semanticInitial.id, finalSemanticReview.id], dependsPolicy: "all-or-condition-skip", role: "verifier", executor: "shell", complexity: "LOW", writePolicy: "read-only", allowedPaths: commonReadOnlyPaths(sources), forbiddenPaths: commonForbiddenPaths(sources), outputContract: "Pass-only effective semantic review gate with final traceability after revision.", subtask_prompt: "Accept initial semantic pass or validate the single final review and traceability.", shell: { commands: [['test -n "${HARNESS_DAG_RUN_DIR:-}" || exit 2', 'node -e \'const fs=require("fs"),path=require("path");const r=process.env.HARNESS_DAG_RUN_DIR;const f=path.join(r,"contracts","backend-test-semantic-review-final.json");const i=path.join(r,"contracts","backend-test-semantic-review.json");const v=JSON.parse(fs.readFileSync(fs.existsSync(f)?f:i,"utf8"));if(v.verdict!=="pass")throw new Error("backend pytest semantic review did not pass");\''].join("; ")], cwd: ".", timeoutMs: 60000 } };
3570
3567
  const finalSemanticMaterialize = { id: "materialize-final-semantic-review-shell", depends_on: [finalSemanticReview.id], role: "verifier", executor: "shell", complexity: "LOW", writePolicy: "read-only", allowedPaths: commonReadOnlyPaths(sources), forbiddenPaths: commonForbiddenPaths(sources), runIf: revisePytest.runIf, outputContract: "Materialize final semantic review and re-check traceability.", subtask_prompt: "Validate final semantic review and traceability.", shell: { commands: [], backendTestPipeline: "semantic-final", cwd: ".", timeoutMs: 60000 } };
3571
- semanticFinal.depends_on = [semanticInitial.id, finalSemanticMaterialize.id];
3568
+ const semanticFinal = {
3569
+ id: "backend-test-semantic-final-gate-shell",
3570
+ depends_on: [semanticInitial.id, finalSemanticMaterialize.id],
3571
+ dependsPolicy: "all-or-condition-skip",
3572
+ role: "verifier",
3573
+ executor: "shell",
3574
+ complexity: "LOW",
3575
+ writePolicy: "read-only",
3576
+ allowedPaths: commonReadOnlyPaths(sources),
3577
+ forbiddenPaths: commonForbiddenPaths(sources),
3578
+ outputContract: "Pass-only effective semantic review gate with final traceability after revision.",
3579
+ subtask_prompt: "Accept initial semantic pass or validate the single final review and traceability.",
3580
+ shell: {
3581
+ commands: [],
3582
+ backendTestPipeline: "semantic-effective",
3583
+ cwd: ".",
3584
+ timeoutMs: 60000,
3585
+ },
3586
+ };
3572
3587
  const executeInitial = buildExecuteBackendPytestNode(sources, { id: "execute-and-parse-backend-pytest-initial-shell", dependsOn: [semanticFinal.id, contracts.id], reportStem: "backend-test-initial" });
3573
3588
  executeInitial.shell.backendTestPipeline = "execute-parse-initial";
3574
3589
  const classify = buildClassifyBackendTestResultNode(sources);
@@ -145,6 +145,7 @@ export const dagBackendTestPipelineSchema = z.enum([
145
145
  "contracts",
146
146
  "semantic-initial",
147
147
  "semantic-final",
148
+ "semantic-effective",
148
149
  "execute-parse-initial",
149
150
  "classification-eligibility",
150
151
  "repair-safety-traceability",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",