@tea-agent/loop-agent 0.16.4 → 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,12 @@
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
+
18
24
  ## [0.16.4] - 2026-07-19
19
25
 
20
26
  ### 修复
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.16.4",
3
+ "version": "0.16.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",