@tea-agent/loop-agent 0.33.6 → 0.33.7-beta.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +23 -23
  2. package/dist/application/task-lifecycle/advance.js +4 -254
  3. package/dist/application/task-lifecycle/gates.js +0 -50
  4. package/dist/application/task-lifecycle/observe.js +2 -11
  5. package/dist/commands/init-upgrade.js +1 -32
  6. package/dist/commands/init.js +3 -94
  7. package/dist/executors/shell-executor.js +91 -4
  8. package/dist/executors/shell-write-guard.js +8 -26
  9. package/dist/shared/operator/capabilities.js +42 -72
  10. package/dist/task/source-prepare/index.js +0 -2
  11. package/dist/task/source-prepare/parse-intent.js +10 -58
  12. package/dist/task/source-prepare/prepare.js +16 -180
  13. package/dist/task/source-prepare/reference-integrity.js +2 -18
  14. package/dist/worker/console/app-data.js +0 -2
  15. package/dist/worker/console/chat/chat-event-store.js +25 -190
  16. package/dist/worker/console/chat/instruction-skills.js +217 -0
  17. package/dist/worker/console/chat/pi-console-config.js +32 -250
  18. package/dist/worker/console/chat/pi-runtime.js +71 -625
  19. package/dist/worker/console/chat/resource-loader.js +4 -5
  20. package/dist/worker/console/chat/routes.js +146 -324
  21. package/dist/worker/console/chat/runtime-context.js +12 -48
  22. package/dist/worker/console/chat/runtime-selection.js +0 -59
  23. package/dist/worker/console/chat/shortcuts.js +0 -1
  24. package/dist/worker/console/chat/tool-adapter.js +3 -9
  25. package/dist/worker/console/chat/tools.js +1 -5
  26. package/dist/worker/console/operator-actions.js +68 -559
  27. package/dist/worker/console/server.js +15 -8
  28. package/dist/worker/console/static/assets/index-CnUXAqxG.css +1 -0
  29. package/dist/worker/console/static/assets/index-CteJFFL2.js +29 -0
  30. package/dist/worker/console/static/index.html +2 -2
  31. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +8 -45
  32. package/dist/worker/console/static-src/operator-chat/refs.js +0 -9
  33. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +0 -16
  34. package/dist/worker/console/static-src/operator-chat/useChatStream.js +184 -210
  35. package/dist/worker/console/static-src/operator-chat/useChatThread.js +5 -49
  36. package/dist/worker/console/static-src/operator-chat/useComposer.js +0 -17
  37. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +74 -225
  38. package/dist/worker/delivery/final-verification.js +5 -13
  39. package/dist/worker/delivery/package.js +19 -31
  40. package/dist/worker/delivery/verification-bundle.js +4 -6
  41. package/dist/worker/observe/static/operator-chrome.css +2 -5
  42. package/dist/worker/observe/static/operator-chrome.js +1 -6
  43. package/dist/worker/observe/static/styles.css +9 -39
  44. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +462 -33
  45. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  46. package/dist/workflows/dag/backend-test-markdown-workflow.js +25 -1
  47. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  48. package/dist/workflows/dag/backend-test-pytest-collection.js +345 -24
  49. package/dist/workflows/dag/backend-test-scenario-param.js +269 -82
  50. package/dist/workflows/dag/backend-test-writer-completeness.js +47 -16
  51. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  52. package/dist/workflows/dag/frontend-worktree-diff.js +27 -12
  53. package/dist/workflows/dag/init-hybrid.js +46 -34
  54. package/dist/workflows/dag/types.js +7 -0
  55. package/dist/workflows/dag/workspace-checkpoint.js +27 -8
  56. package/docs/templates/backend-test-dag.json +32 -29
  57. package/harness.json +1 -1
  58. package/package.json +1 -1
  59. package/skills/loop-agent/references/command-reference.md +1 -3
  60. package/skills/loop-agent/references/source-and-plan-practice.md +0 -13
  61. package/skills/loop-agent/references/task-workflow.md +0 -4
  62. package/dist/shared/resilient-git.js +0 -133
  63. package/dist/task/source-prepare/artifact-meta.js +0 -137
  64. package/dist/task/source-prepare/semantic-intake.js +0 -404
  65. package/dist/worker/console/dag-execution-receipt.js +0 -380
  66. package/dist/worker/console/static/assets/index-BUOLppPr.js +0 -28
  67. package/dist/worker/console/static/assets/index-C1KzazY5.css +0 -1
  68. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +0 -257
  69. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +0 -196
@@ -98,10 +98,16 @@ async function listMarkdownCases(workspaceRoot) {
98
98
  const allPoints = orderedUnique(body.match(/\bTP-[A-Z0-9-]+\b/g) ?? []);
99
99
  const variantLabel = body.match(/(?:Variant Test Points|\u53d8\u4f53\u6d4b\u8bd5\u70b9)\s*[::]\s*([^\n]+)/i)?.[1] ??
100
100
  "";
101
+ const variantLabelDeclared = variantLabel.trim().length > 0;
102
+ const variantLabelIsNone = /^(?:none|无|n\/a|not-applicable)\b/i.test(variantLabel.trim());
101
103
  const variantFromLabel = orderedUnique((variantLabel.match(/\bTP-[A-Z0-9-]+\b/g) ?? []).filter((item) => item.toUpperCase() !== "NONE"));
102
- const variantTestPoints = variantFromLabel.length > 0
103
- ? variantFromLabel
104
- : allPoints.filter((tp) => /(EMPTY|MISSING|NULL|MIN|MAX|INVALID|WRONG|ENUM|PATTERN|NOMINAL|BOUNDARY)/i.test(tp));
104
+ const variantTestPoints = variantLabelIsNone
105
+ ? []
106
+ : variantFromLabel.length > 0
107
+ ? variantFromLabel
108
+ : variantLabelDeclared
109
+ ? []
110
+ : allPoints.filter((tp) => /(EMPTY|MISSING|NULL|MIN|MAX|INVALID|WRONG|ENUM|PATTERN|NOMINAL|BOUNDARY)/i.test(tp));
105
111
  const scriptMatch = body.match(/`?(testcase\/[A-Za-z0-9_./-]*test_[A-Za-z0-9_.-]*\.py)`?/i);
106
112
  cases.push({
107
113
  caseId,
@@ -115,23 +121,123 @@ async function listMarkdownCases(workspaceRoot) {
115
121
  }
116
122
  return cases;
117
123
  }
124
+ function parseScenarioIntentLine(line) {
125
+ // Target may contain commas (body.title,body.status). Intent may contain `|` / spaces.
126
+ // Stop field values only at known trailing keys or end-of-line.
127
+ const precise = /场景意图\s*[::]\s*(TP-[A-Z0-9-]+)\s*[|;]\s*operation\s*=\s*([^;\n]+?)\s*[|;]\s*target\s*=\s*([^;\n]+?)\s*[|;]\s*intent\s*=\s*(.+?)(?:\s*[|;]\s*bound\s*=\s*(\d+))?(?:\s*[|;]\s*example\s*=\s*([^;\n]+))?(?:\s*[|;]\s*expectedCode\s*=\s*[^;\n]+)?\s*$/i.exec(line.trim());
128
+ if (precise) {
129
+ return {
130
+ tpId: precise[1],
131
+ rawIntent: (precise[4] ?? "unknown").trim(),
132
+ target: precise[3]?.trim(),
133
+ bound: precise[5] ? Number(precise[5]) : undefined,
134
+ example: precise[6]?.trim(),
135
+ legacy: false,
136
+ };
137
+ }
138
+ const legacy = /场景意图\s*[::]\s*([a-z0-9+._:-]+)(?:\s*[|;,,]\s*field\s*=\s*([A-Za-z0-9_.]+))?(?:\s*[|;,,]\s*bound\s*=\s*(\d+))?(?:\s*[|;,,]\s*example\s*=\s*([^|\n]+))?/i.exec(line.trim());
139
+ if (legacy) {
140
+ return {
141
+ rawIntent: (legacy[1] ?? "unknown").trim(),
142
+ field: legacy[2],
143
+ bound: legacy[3] ? Number(legacy[3]) : undefined,
144
+ example: legacy[4]?.trim(),
145
+ legacy: true,
146
+ };
147
+ }
148
+ return undefined;
149
+ }
150
+ function resolveScenarioIntentField(target, field) {
151
+ if (field)
152
+ return field;
153
+ if (!target || target === "request")
154
+ return undefined;
155
+ const bodyTargets = target
156
+ .split(/\s*,\s*/)
157
+ .map((item) => item.trim())
158
+ .filter((item) => /^(?:body|json|payload)\./i.test(item));
159
+ const preferred = bodyTargets.find((item) => /\.status$/i.test(item)) ??
160
+ bodyTargets[0] ??
161
+ target.split(/\s*,\s*/)[0];
162
+ return preferred?.split(".").at(-1);
163
+ }
164
+ function resolveCustomLiteralIntent(rawIntent, field) {
165
+ const lower = rawIntent.toLowerCase();
166
+ if (!lower.startsWith("custom-literal:")) {
167
+ return CLOSED_SET.has(lower) ? lower : "unknown";
168
+ }
169
+ // Preserve literal case: enum values such as ACTIVE must not be lowercased.
170
+ const prefixLength = rawIntent.toLowerCase().startsWith("custom-literal:")
171
+ ? rawIntent.indexOf(":") + 1
172
+ : "custom-literal:".length;
173
+ const payload = rawIntent.slice(prefixLength);
174
+ if (!payload.includes("|") && !payload.includes(",")) {
175
+ return `custom-literal:${payload}`;
176
+ }
177
+ const parts = payload.split(/\s*[|,]\s*/).map((item) => item.trim()).filter(Boolean);
178
+ if (field) {
179
+ const byField = parts.find((part) => part.toLowerCase().startsWith(`${field.toLowerCase()}=`));
180
+ if (byField) {
181
+ return `custom-literal:${byField.slice(field.length + 1)}`;
182
+ }
183
+ if (/^status$/i.test(field)) {
184
+ const enumLike = parts.find((part) => /^(?:ACTIVE|ARCHIVED|INACTIVE|DISABLED|ENABLED)$/i.test(part));
185
+ if (enumLike)
186
+ return `custom-literal:${enumLike}`;
187
+ }
188
+ if (/^title$/i.test(field)) {
189
+ const titleLike = parts.find((part) => !/^(?:ACTIVE|ARCHIVED|INACTIVE|DISABLED|ENABLED)$/i.test(part));
190
+ if (titleLike)
191
+ return `custom-literal:${titleLike}`;
192
+ }
193
+ }
194
+ return parts.length === 1
195
+ ? `custom-literal:${parts[0]}`
196
+ : "unknown";
197
+ }
118
198
  export function inferScenarioParamIntent(input) {
119
199
  const { tpId, caseBody } = input;
120
- const preciseLine = /场景意图\s*[::]\s*(TP-[A-Z0-9-]+)\s*[|;,,]\s*operation\s*=\s*([^|;,,\n]+)\s*[|;,,]\s*target\s*=\s*([A-Za-z0-9_.-]+)\s*[|;,,]\s*intent\s*=\s*([a-z0-9+._:-]+)(?:\s*[|;,,]\s*bound\s*=\s*(\d+))?(?:\s*[|;,,]\s*example\s*=\s*([^|\n]+))?/i.exec(caseBody);
121
- const legacyLine = /场景意图\s*[::]\s*([a-z0-9+._:-]+)(?:\s*[|;,,]\s*field\s*=\s*([A-Za-z0-9_.]+))?(?:\s*[|;,,]\s*bound\s*=\s*(\d+))?(?:\s*[|;,,]\s*example\s*=\s*([^|\n]+))?/i.exec(caseBody);
122
- const fallbackLine = new RegExp(`${tpId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,160}?intent\\s*[=:]\\s*([a-z0-9+._:-]+)`, "i").exec(caseBody);
123
- const machineLine = preciseLine ?? legacyLine ?? fallbackLine;
124
- if (machineLine) {
125
- const rawIntent = (preciseLine ? preciseLine[4] : machineLine[1] ?? "unknown").toLowerCase();
126
- const target = preciseLine?.[3];
127
- const field = target && target !== "request" ? target.split(".").at(-1) : legacyLine?.[2];
128
- const boundRaw = preciseLine?.[5] ?? legacyLine?.[3];
129
- const example = (preciseLine?.[6] ?? legacyLine?.[4])?.trim();
130
- const bound = boundRaw ? Number(boundRaw) : undefined;
131
- const normalizedIntent = rawIntent === "nominal-operation" ? "nominal" : rawIntent;
132
- if (normalizedIntent.startsWith("custom-literal:")) {
200
+ const intentLines = caseBody
201
+ .split(/\r?\n/)
202
+ .map((line) => line.trim())
203
+ .filter((line) => /场景意图\s*[::]/.test(line));
204
+ const parsedLines = intentLines
205
+ .map((line) => parseScenarioIntentLine(line))
206
+ .filter((item) => Boolean(item));
207
+ const preciseForTp = parsedLines.find((item) => item.tpId && item.tpId.toUpperCase() === tpId.toUpperCase());
208
+ const legacyLine = parsedLines.find((item) => item.legacy);
209
+ const fallbackLine = new RegExp(`${tpId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,200}?intent\\s*[=:]\\s*([^|;,\uff0c\\n]+)`, "i").exec(caseBody);
210
+ const machine = preciseForTp ??
211
+ (legacyLine && parsedLines.filter((item) => !item.legacy).length === 0 ? legacyLine : undefined) ??
212
+ (fallbackLine
213
+ ? {
214
+ rawIntent: fallbackLine[1] ?? "unknown",
215
+ legacy: true,
216
+ }
217
+ : undefined);
218
+ if (machine) {
219
+ const field = resolveScenarioIntentField(machine.target, machine.field);
220
+ const rawIntent = machine.rawIntent.trim();
221
+ const example = machine.example;
222
+ const lower = rawIntent.toLowerCase();
223
+ const normalizedIntent = lower === "nominal-operation" ? "nominal" : lower;
224
+ const dataSection = sectionBody(caseBody, ["测试数据", "Test Data", "操作步骤", "Steps"]);
225
+ const fieldScopedBound = field
226
+ ? new RegExp(`${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,80}?(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})`, "i").exec(`${caseBody}\n${dataSection}`) ??
227
+ new RegExp(`(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})[^\\n]{0,80}?${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i").exec(`${caseBody}\n${dataSection}`)
228
+ : undefined;
229
+ const boundFromBody = fieldScopedBound ??
230
+ /(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`) ??
231
+ /(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`);
232
+ const bound = machine.bound && machine.bound > 0
233
+ ? machine.bound
234
+ : boundFromBody
235
+ ? Number(boundFromBody[1])
236
+ : machine.bound;
237
+ if (rawIntent.toLowerCase().startsWith("custom-literal:")) {
238
+ const intent = resolveCustomLiteralIntent(rawIntent, field);
133
239
  return {
134
- intent: normalizedIntent,
240
+ intent,
135
241
  field,
136
242
  bound,
137
243
  example,
@@ -175,7 +281,13 @@ export function inferScenarioParamIntent(input) {
175
281
  ? fieldFromTp.charAt(0).toLowerCase() + fieldFromTp.slice(1)
176
282
  : undefined;
177
283
  const dataSection = sectionBody(caseBody, ["测试数据", "Test Data", "操作步骤", "Steps"]);
178
- const boundMatch = /(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`) ?? /(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`);
284
+ const fieldScopedBound = field
285
+ ? new RegExp(`${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]{0,80}?(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})`, "i").exec(`${caseBody}\n${dataSection}`) ??
286
+ new RegExp(`(?:maxLength|max(?:imum)?|上限|最大长度)\\s*[=::]?\\s*(\\d{1,4})[^\\n]{0,80}?${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i").exec(`${caseBody}\n${dataSection}`)
287
+ : undefined;
288
+ const boundMatch = fieldScopedBound ??
289
+ /(?:maxLength|max(?:imum)?|上限|最大长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`) ??
290
+ /(?:minLength|min(?:imum)?|下限|最小长度)\s*[=::]?\s*(\d{1,4})/i.exec(`${caseBody}\n${dataSection}`);
179
291
  const bound = boundMatch ? Number(boundMatch[1]) : undefined;
180
292
  const exampleMatch = /(?:反例|invalid example|example)\s*[=::]\s*[`"]?([^`"\n]+)[`"]?/i.exec(caseBody);
181
293
  const example = exampleMatch?.[1]?.trim();
@@ -248,18 +360,27 @@ export function observeParamFeatures(block, field) {
248
360
  if (/\bFaker\b|\bfake\b|\brandom\b|\buuid4\b|\bfactory\b/i.test(block)) {
249
361
  return { kind: "call", text: "dynamic-or-faker" };
250
362
  }
251
- // Prefer dict-like payload extraction.
363
+ // Prefer dict-like payload extraction when a real object literal is present.
364
+ // Writers often build the payload in the test body and only pass scalar intent
365
+ // values in pytest.param; in that case fall through to positional observation.
252
366
  const dictMatch = /\{\s*([\s\S]*?)\s*\}/.exec(block) ??
253
367
  /(?:payload|body|data|json)\s*=\s*(\{[\s\S]*?\})/.exec(block);
254
- if (dictMatch && !field) {
255
- return { kind: "dict", text: (dictMatch[0] ?? dictMatch[1] ?? "{}").replace(/\s+/g, " ").slice(0, 160) };
368
+ const dictText = dictMatch?.[0] ?? dictMatch?.[1] ?? "";
369
+ const fieldPattern = field
370
+ ? new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:`)
371
+ : undefined;
372
+ const dictHasField = Boolean(fieldPattern && fieldPattern.test(dictText));
373
+ const dictLooksLikePayload = Boolean(dictMatch) &&
374
+ (/["'][A-Za-z_][A-Za-z0-9_]*["']\s*:/.test(dictText) ||
375
+ /(?:payload|body|data|json)\s*=/.test(block));
376
+ if (dictLooksLikePayload && !field) {
377
+ return { kind: "dict", text: dictText.replace(/\s+/g, " ").slice(0, 160) };
256
378
  }
257
- if (dictMatch && field) {
258
- const dict = dictMatch[0] ?? dictMatch[1] ?? "";
259
- const fieldPattern = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:`);
260
- if (!fieldPattern.test(dict)) {
379
+ if (dictLooksLikePayload && field) {
380
+ if (!dictHasField) {
261
381
  return { kind: "missing-key", text: `missing:${field}` };
262
382
  }
383
+ const dict = dictText;
263
384
  const valueMatch = new RegExp(`["']${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']\\s*:\\s*([^,}\\n]+)`).exec(dict);
264
385
  const raw = valueMatch?.[1]?.trim() ?? "";
265
386
  if (raw === "None" || raw === "null") {
@@ -268,6 +389,19 @@ export function observeParamFeatures(block, field) {
268
389
  if (raw === '""' || raw === "''") {
269
390
  return { kind: "empty-string", text: '""', length: 0, literal: "" };
270
391
  }
392
+ const repeatedValue = /^(["'])([^"'\\])\1\s*\*\s*(\d{1,5})$/.exec(raw);
393
+ if (repeatedValue) {
394
+ const ch = repeatedValue[2] ?? "x";
395
+ const length = Number(repeatedValue[3] ?? "0");
396
+ const value = ch.repeat(Math.max(0, length));
397
+ return {
398
+ kind: length === 0 ? "empty-string" : "string",
399
+ text: JSON.stringify(value),
400
+ length,
401
+ hasUppercase: /[A-Z]/.test(value),
402
+ literal: value,
403
+ };
404
+ }
271
405
  const str = /^["']([\s\S]*)["']$/.exec(raw);
272
406
  if (str) {
273
407
  const value = str[1] ?? "";
@@ -296,31 +430,83 @@ export function observeParamFeatures(block, field) {
296
430
  }
297
431
  return { kind: "unknown", text: raw || compact.slice(0, 120) };
298
432
  }
299
- // Positional first arg string/None.
300
- const firstArg = /pytest\.param\s*\(\s*(None|True|False|-?\d+(?:\.\d+)?|"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/.exec(block);
301
- if (firstArg) {
302
- const raw = firstArg[1];
303
- if (raw === "None")
304
- return { kind: "none", text: "None", literal: "None" };
305
- if (raw === '""' || raw === "''") {
306
- return { kind: "empty-string", text: '""', length: 0, literal: "" };
433
+ // Repeated string literal: "a" * 100 / 'x'*101
434
+ const repeated = /pytest\.param\s*\(\s*(["'])([^"'\\])\1\s*\*\s*(\d{1,5})/.exec(block) ??
435
+ /(["'])([^"'\\])\1\s*\*\s*(\d{1,5})/.exec(block);
436
+ if (repeated && (!field || !/\{/.test(block))) {
437
+ const ch = repeated[2] ?? "x";
438
+ const length = Number(repeated[3] ?? "0");
439
+ const value = ch.repeat(Math.max(0, length));
440
+ return {
441
+ kind: length === 0 ? "empty-string" : "string",
442
+ text: JSON.stringify(value),
443
+ length,
444
+ hasUppercase: /[A-Z]/.test(value),
445
+ literal: value,
446
+ };
447
+ }
448
+ // Positional args: skip a leading TP-id label when writers emit
449
+ // pytest.param("TP-...", actual_value, id="TP-...").
450
+ const paramOpen = /pytest\.param\s*\(/.exec(block);
451
+ if (paramOpen) {
452
+ const afterOpen = block.slice((paramOpen.index ?? 0) + paramOpen[0].length);
453
+ const positionalLiterals = [];
454
+ const literalRe = /\s*(None|True|False|-?\d+(?:\.\d+)?|"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*(?:,|$)/g;
455
+ let match;
456
+ while ((match = literalRe.exec(afterOpen)) !== null) {
457
+ // Stop once keyword args begin (id=/marks=) after a comma segment without a pure literal.
458
+ const between = afterOpen.slice(0, match.index);
459
+ if (/(?:^|,)\s*(?:id|marks)\s*=/.test(between + match[0]))
460
+ break;
461
+ positionalLiterals.push(match[1]);
462
+ if (positionalLiterals.length >= 4)
463
+ break;
307
464
  }
308
- const str = /^["']([\s\S]*)["']$/.exec(raw) ?? /^("""|''')([\s\S]*)\1$/.exec(raw);
309
- if (str) {
465
+ const idMatch = /\bid\s*=\s*["'](TP-[A-Z0-9-]+)["']/i.exec(block);
466
+ const idTp = idMatch?.[1]?.toUpperCase();
467
+ const candidates = positionalLiterals.filter((raw) => {
468
+ const str = /^["']([\s\S]*)["']$/.exec(raw) ?? /^("""|''')([\s\S]*)\1$/.exec(raw);
469
+ if (!str)
470
+ return true;
310
471
  const value = (str[2] ?? str[1] ?? "").replace(/^["']|["']$/g, "");
311
- return {
312
- kind: "string",
313
- text: JSON.stringify(value),
314
- length: value.length,
315
- hasUppercase: /[A-Z]/.test(value),
316
- literal: value,
317
- };
472
+ // Drop pure TP label positionals that only echo the param id.
473
+ if (/^TP-[A-Z0-9-]+$/i.test(value) && (!idTp || value.toUpperCase() === idTp)) {
474
+ return false;
475
+ }
476
+ return true;
477
+ });
478
+ const raw = candidates[0] ?? positionalLiterals[0];
479
+ if (raw) {
480
+ if (raw === "None")
481
+ return { kind: "none", text: "None", literal: "None" };
482
+ if (raw === '""' || raw === "''") {
483
+ return { kind: "empty-string", text: '""', length: 0, literal: "" };
484
+ }
485
+ const str = /^["']([\s\S]*)["']$/.exec(raw) ?? /^("""|''')([\s\S]*)\1$/.exec(raw);
486
+ if (str) {
487
+ const value = (str[2] ?? str[1] ?? "").replace(/^["']|["']$/g, "");
488
+ return {
489
+ kind: value.length === 0
490
+ ? "empty-string"
491
+ : "string",
492
+ text: JSON.stringify(value),
493
+ length: value.length,
494
+ hasUppercase: /[A-Z]/.test(value),
495
+ literal: value,
496
+ };
497
+ }
498
+ if (/^-?\d+(?:\.\d+)?$/.test(raw)) {
499
+ return { kind: "number", text: raw, literal: raw };
500
+ }
501
+ if (raw === "True" || raw === "False") {
502
+ return { kind: "boolean", text: raw, literal: raw };
503
+ }
504
+ return { kind: "unknown", text: raw };
318
505
  }
319
- return { kind: "unknown", text: raw };
320
506
  }
321
507
  return { kind: "unknown", text: compact.slice(0, 160) };
322
508
  }
323
- function compareIntent(intent, observed, bound, example) {
509
+ function compareIntent(intent, observed, bound, example, field) {
324
510
  if (intent === "unknown")
325
511
  return "UNDETERMINED";
326
512
  if (observed.kind === "call" || observed.kind === "name") {
@@ -329,7 +515,10 @@ function compareIntent(intent, observed, bound, example) {
329
515
  switch (intent) {
330
516
  case "empty":
331
517
  return observed.kind === "empty-string" ||
332
- (observed.kind === "string" && observed.length === 0)
518
+ (observed.kind === "string" &&
519
+ (observed.length === 0 ||
520
+ (typeof observed.literal === "string" &&
521
+ /^\s+$/.test(observed.literal))))
333
522
  ? "MATCH"
334
523
  : observed.kind === "unknown"
335
524
  ? "UNDETERMINED"
@@ -347,45 +536,39 @@ function compareIntent(intent, observed, bound, example) {
347
536
  ? "UNDETERMINED"
348
537
  : "MISMATCH";
349
538
  case "max":
350
- return bound !== undefined &&
351
- observed.kind === "string" &&
352
- observed.length === bound
353
- ? "MATCH"
354
- : bound === undefined
355
- ? "UNDETERMINED"
356
- : observed.kind === "string"
357
- ? "MISMATCH"
358
- : "UNDETERMINED";
539
+ if (bound === undefined)
540
+ return "UNDETERMINED";
541
+ if (observed.kind === "string" && observed.length === bound)
542
+ return "MATCH";
543
+ // Writers may pass the exact length as a numeric positional instead of a repeated string.
544
+ if (observed.kind === "number" && Number(observed.literal) === bound)
545
+ return "MATCH";
546
+ return observed.kind === "string" || observed.kind === "number" ? "MISMATCH" : "UNDETERMINED";
359
547
  case "max+1":
360
- return bound !== undefined &&
361
- observed.kind === "string" &&
362
- observed.length === bound + 1
363
- ? "MATCH"
364
- : bound === undefined
365
- ? "UNDETERMINED"
366
- : observed.kind === "string"
367
- ? "MISMATCH"
368
- : "UNDETERMINED";
548
+ if (bound === undefined)
549
+ return "UNDETERMINED";
550
+ if (observed.kind === "string" && observed.length === bound + 1)
551
+ return "MATCH";
552
+ if (observed.kind === "number" && Number(observed.literal) === bound + 1)
553
+ return "MATCH";
554
+ return observed.kind === "string" || observed.kind === "number" ? "MISMATCH" : "UNDETERMINED";
369
555
  case "min":
370
- return bound !== undefined &&
371
- observed.kind === "string" &&
372
- observed.length === bound
373
- ? "MATCH"
374
- : bound === undefined
375
- ? "UNDETERMINED"
376
- : observed.kind === "string"
377
- ? "MISMATCH"
378
- : "UNDETERMINED";
556
+ if (bound === undefined)
557
+ return "UNDETERMINED";
558
+ if (observed.kind === "string" && observed.length === bound)
559
+ return "MATCH";
560
+ if (observed.kind === "number" && Number(observed.literal) === bound)
561
+ return "MATCH";
562
+ return observed.kind === "string" || observed.kind === "number" ? "MISMATCH" : "UNDETERMINED";
379
563
  case "min-1":
380
- return bound !== undefined &&
381
- observed.kind === "string" &&
382
- observed.length === Math.max(0, bound - 1)
383
- ? "MATCH"
384
- : bound === undefined
385
- ? "UNDETERMINED"
386
- : observed.kind === "string"
387
- ? "MISMATCH"
388
- : "UNDETERMINED";
564
+ if (bound === undefined)
565
+ return "UNDETERMINED";
566
+ const expectedMin1 = Math.max(0, bound - 1);
567
+ if (observed.kind === "string" && observed.length === expectedMin1)
568
+ return "MATCH";
569
+ if (observed.kind === "number" && Number(observed.literal) === expectedMin1)
570
+ return "MATCH";
571
+ return observed.kind === "string" || observed.kind === "number" ? "MISMATCH" : "UNDETERMINED";
389
572
  case "pattern-invalid":
390
573
  if (observed.kind === "string" && example !== undefined) {
391
574
  return observed.literal === example ? "MATCH" : "MISMATCH";
@@ -405,6 +588,10 @@ function compareIntent(intent, observed, bound, example) {
405
588
  ? "UNDETERMINED"
406
589
  : "UNDETERMINED";
407
590
  case "nominal":
591
+ // Request-level nominal (no body field) may legitimately pass None/empty query filters.
592
+ if (!field && (observed.kind === "none" || observed.kind === "missing-key")) {
593
+ return "MATCH";
594
+ }
408
595
  return observed.kind === "string" ||
409
596
  observed.kind === "number" ||
410
597
  observed.kind === "boolean" ||
@@ -512,7 +699,7 @@ export async function assessBackendScenarioParamConsistency(input) {
512
699
  const observed = observeParamFeatures(block, inferred.field);
513
700
  const status = !source || fallbackSourced
514
701
  ? "UNDETERMINED"
515
- : compareIntent(inferred.intent, observed, inferred.bound, inferred.example);
702
+ : compareIntent(inferred.intent, observed, inferred.bound, inferred.example, inferred.field);
516
703
  const repairability = repairabilityFor(status, inferred.intent, observed, inferred.example);
517
704
  entries.push({
518
705
  caseId: testCase.caseId,
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
- import { isPriorityOnlyBackendTestModuleStem } from "./backend-test-module-stem.js";
5
+ import { BACKEND_TEST_MAX_MODULE_COUNT, isOpaqueHashBackendTestModuleStem, isPriorityOnlyBackendTestModuleStem, } from "./backend-test-module-stem.js";
6
6
  /**
7
7
  * Maps a backend-test generation writer task id to its writer-progress role.
8
8
  * Returns undefined for nodes that are not completeness-gated generators.
@@ -103,6 +103,8 @@ function moduleStemInvalidReason(raw) {
103
103
  const stem = normalizeBackendTestModuleStem(raw);
104
104
  if (isPriorityOnlyBackendTestModuleStem(stem))
105
105
  return "priority-only-module-stem";
106
+ if (isOpaqueHashBackendTestModuleStem(stem))
107
+ return "opaque-hash-module-stem";
106
108
  if (stem.toLowerCase() === "readme")
107
109
  return "reserved-module-stem";
108
110
  if (!/^[a-z][a-z0-9_]*$/.test(stem))
@@ -114,28 +116,40 @@ function moduleStemInvalidReason(raw) {
114
116
  function looksLikeValidModuleStem(raw) {
115
117
  return moduleStemInvalidReason(raw) === undefined;
116
118
  }
117
- /** Inspect only authoritative README module-index candidates. */
118
- export function inspectModuleStemsFromReadme(readme) {
119
- const candidates = [];
120
- const tableRowLines = readme
119
+ function extractCanonicalModuleIndexSection(readme) {
120
+ const lines = readme
121
121
  .replaceAll("\r\n", "\n")
122
122
  .replaceAll("\r", "\n")
123
- .split("\n")
124
- .filter((line) => line.includes("|"));
123
+ .split("\n");
124
+ const headings = lines.flatMap((line, index) => line.trim() === "## Module Index" ? [index] : []);
125
+ if (headings.length === 0) {
126
+ return { section: "", structureIssues: ["missing-module-index"] };
127
+ }
128
+ if (headings.length > 1) {
129
+ return { section: "", structureIssues: ["duplicate-module-index"] };
130
+ }
131
+ const start = headings[0] + 1;
132
+ const relativeEnd = lines.slice(start).findIndex((line) => /^##\s+\S/.test(line.trim()));
133
+ const end = relativeEnd < 0 ? lines.length : start + relativeEnd;
134
+ return { section: lines.slice(start, end).join("\n"), structureIssues: [] };
135
+ }
136
+ /** Inspect only candidates inside the unique exact `## Module Index` section. */
137
+ export function inspectModuleStemsFromReadme(readme) {
138
+ const extracted = extractCanonicalModuleIndexSection(readme);
139
+ const candidates = [];
140
+ const tableRowLines = extracted.section.split("\n").filter((line) => line.includes("|"));
125
141
  for (const line of tableRowLines) {
126
142
  for (const match of line.matchAll(/`?testcase\/md\/([A-Za-z0-9_.-]+)\.md`?/g)) {
127
143
  candidates.push(match[1]);
128
144
  }
129
145
  for (const match of line.matchAll(/\|\s*`?([A-Za-z0-9_.-]+)`?\s*\|\s*`?testcase\/test_/g)) {
130
- // This compatibility shape can also capture a numeric count column.
131
- // Keep valid stems and the priority-only defect we diagnose, while
132
- // ignoring non-stem incidental cells such as `1`.
133
- if (looksLikeValidModuleStem(match[1]) || isPriorityOnlyBackendTestModuleStem(match[1])) {
134
- candidates.push(match[1]);
146
+ const raw = match[1];
147
+ if (looksLikeValidModuleStem(raw) || moduleStemInvalidReason(raw) !== "invalid-syntax") {
148
+ candidates.push(raw);
135
149
  }
136
150
  }
137
151
  }
138
- for (const match of readme.matchAll(/\[[^\]]+\]\(\.\/([A-Za-z0-9_.-]+)\.md\)/g)) {
152
+ for (const match of extracted.section.matchAll(/\[[^\]]+\]\(\.\/([A-Za-z0-9_.-]+)\.md\)/g)) {
139
153
  candidates.push(match[1]);
140
154
  }
141
155
  const uniqueCandidates = orderedUnique(candidates);
@@ -145,12 +159,18 @@ export function inspectModuleStemsFromReadme(readme) {
145
159
  ? [{ raw, normalized: normalizeBackendTestModuleStem(raw), reasonCode }]
146
160
  : [];
147
161
  });
162
+ const validStems = orderedUnique(uniqueCandidates
163
+ .filter(looksLikeValidModuleStem)
164
+ .map((stem) => normalizeBackendTestModuleStem(stem)));
165
+ const structureIssues = [...extracted.structureIssues];
166
+ if (validStems.length > BACKEND_TEST_MAX_MODULE_COUNT) {
167
+ structureIssues.push(`excessive-module-count: ${validStems.length} > ${BACKEND_TEST_MAX_MODULE_COUNT}`);
168
+ }
148
169
  return {
149
170
  candidates: uniqueCandidates,
150
- validStems: orderedUnique(uniqueCandidates
151
- .filter(looksLikeValidModuleStem)
152
- .map((stem) => normalizeBackendTestModuleStem(stem))),
171
+ validStems,
153
172
  invalid,
173
+ structureIssues,
154
174
  };
155
175
  }
156
176
  /**
@@ -917,6 +937,17 @@ export async function assessBackendTestMdPlanCompleteness(workspaceRoot) {
917
937
  // are produced by map_agent children and are NOT required here.
918
938
  const stemInspection = inspectModuleStemsFromReadme(readme);
919
939
  const indexedStems = stemInspection.validStems;
940
+ for (const structureIssue of stemInspection.structureIssues) {
941
+ if (!brokenPaths.includes("testcase/md/README.md")) {
942
+ brokenPaths.push("testcase/md/README.md");
943
+ }
944
+ issues.push({
945
+ code: "T5",
946
+ path: "testcase/md/README.md",
947
+ detail: structureIssue,
948
+ recoverable: true,
949
+ });
950
+ }
920
951
  for (const invalid of stemInspection.invalid) {
921
952
  if (!brokenPaths.includes("testcase/md/README.md")) {
922
953
  brokenPaths.push("testcase/md/README.md");
@@ -367,19 +367,26 @@ export async function executeDynamicMapExpansion(input) {
367
367
  }
368
368
  }
369
369
  const budgetBlockedIds = new Set(blockedChildren.map((entry) => entry.nodeId));
370
+ const failOnTokenBudgetExhaustion = input.expansion.failOnTokenBudgetExhaustion === true;
370
371
  const failedChildren = tolerateChildFailures
371
372
  ? []
372
373
  : childNodeIds.filter((nodeId) => {
373
374
  const record = input.state.nodes[nodeId];
374
375
  if (!record || record.status === "FINISHED")
375
376
  return false;
376
- if (budgetBlockedIds.has(nodeId) || isBudgetBlockedRecord(record))
377
- return false;
377
+ if (budgetBlockedIds.has(nodeId) || isBudgetBlockedRecord(record)) {
378
+ return failOnTokenBudgetExhaustion;
379
+ }
378
380
  return true;
379
381
  });
382
+ const finishedItemCount = childNodeIds.filter((nodeId) => input.state.nodes[nodeId]?.status === "FINISHED").length;
383
+ const blockedItemCount = budgetBlockedIds.size;
380
384
  const aggregate = {
381
385
  workflowNodeId: input.expansion.workflowNodeId,
382
386
  itemCount: items.length,
387
+ expectedItemCount: items.length,
388
+ finishedItemCount,
389
+ blockedItemCount,
383
390
  children: childNodeIds.map((nodeId, index) => ({
384
391
  nodeId,
385
392
  item: items[index],
@@ -399,21 +406,30 @@ export async function executeDynamicMapExpansion(input) {
399
406
  ?.reason,
400
407
  })),
401
408
  tokensUsed: totalTokensUsed,
409
+ maxTotalTokens: tokenBudget?.maxTotalTokens,
402
410
  caseOutcomeNotes: caseOutcomeNotes.length > 0 ? caseOutcomeNotes : undefined,
403
411
  };
412
+ const tokenBudgetFailure = failOnTokenBudgetExhaustion && blockedItemCount > 0;
404
413
  return {
405
- // With tolerateChildFailures: barrier always succeeds; product outcomes live in
406
- // case evidence + result materialization. Without it: real child failures fail-close.
407
- ok: failedChildren.length === 0,
414
+ // With tolerateChildFailures: barrier succeeds and product outcomes live in
415
+ // case evidence. Backend-test opts into token-budget fail-closed because a
416
+ // partial generated module set is never a valid downstream input.
417
+ ok: failedChildren.length === 0 && !tokenBudgetFailure,
408
418
  stdout: JSON.stringify(aggregate),
409
419
  stderr: caseOutcomeNotes.length > 0
410
420
  ? `map children recorded as case outcomes: ${caseOutcomeNotes
411
421
  .map((entry) => `${entry.nodeId}=${entry.status}:${entry.reason}`)
412
422
  .join(", ")}`
423
+ : tokenBudgetFailure
424
+ ? `dynamic map token budget exhausted: expected=${items.length} finished=${finishedItemCount} blocked=${blockedItemCount} tokensUsed=${totalTokensUsed} maxTotalTokens=${tokenBudget?.maxTotalTokens ?? "unbounded"}`
425
+ : failedChildren.length > 0
426
+ ? `dynamic map children failed: ${failedChildren.join(", ")}`
427
+ : "",
428
+ failureCategory: tokenBudgetFailure
429
+ ? "dynamic-expansion-token-budget-exhausted"
413
430
  : failedChildren.length > 0
414
- ? `dynamic map children failed: ${failedChildren.join(", ")}`
415
- : "",
416
- failureCategory: failedChildren.length > 0 ? "dynamic-expansion-child-failed" : "success",
431
+ ? "dynamic-expansion-child-failed"
432
+ : "success",
417
433
  durationMs: Date.now() - started,
418
434
  };
419
435
  }