@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.
- package/CHANGELOG.md +23 -23
- package/dist/application/task-lifecycle/advance.js +4 -254
- package/dist/application/task-lifecycle/gates.js +0 -50
- package/dist/application/task-lifecycle/observe.js +2 -11
- package/dist/commands/init-upgrade.js +1 -32
- package/dist/commands/init.js +3 -94
- package/dist/executors/shell-executor.js +91 -4
- package/dist/executors/shell-write-guard.js +8 -26
- package/dist/shared/operator/capabilities.js +42 -72
- package/dist/task/source-prepare/index.js +0 -2
- package/dist/task/source-prepare/parse-intent.js +10 -58
- package/dist/task/source-prepare/prepare.js +16 -180
- package/dist/task/source-prepare/reference-integrity.js +2 -18
- package/dist/worker/console/app-data.js +0 -2
- package/dist/worker/console/chat/chat-event-store.js +25 -190
- package/dist/worker/console/chat/instruction-skills.js +217 -0
- package/dist/worker/console/chat/pi-console-config.js +32 -250
- package/dist/worker/console/chat/pi-runtime.js +71 -625
- package/dist/worker/console/chat/resource-loader.js +4 -5
- package/dist/worker/console/chat/routes.js +146 -324
- package/dist/worker/console/chat/runtime-context.js +12 -48
- package/dist/worker/console/chat/runtime-selection.js +0 -59
- package/dist/worker/console/chat/shortcuts.js +0 -1
- package/dist/worker/console/chat/tool-adapter.js +3 -9
- package/dist/worker/console/chat/tools.js +1 -5
- package/dist/worker/console/operator-actions.js +68 -559
- package/dist/worker/console/server.js +15 -8
- package/dist/worker/console/static/assets/index-CnUXAqxG.css +1 -0
- package/dist/worker/console/static/assets/index-CteJFFL2.js +29 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +8 -45
- package/dist/worker/console/static-src/operator-chat/refs.js +0 -9
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +0 -16
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +184 -210
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +5 -49
- package/dist/worker/console/static-src/operator-chat/useComposer.js +0 -17
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +74 -225
- package/dist/worker/delivery/final-verification.js +5 -13
- package/dist/worker/delivery/package.js +19 -31
- package/dist/worker/delivery/verification-bundle.js +4 -6
- package/dist/worker/observe/static/operator-chrome.css +2 -5
- package/dist/worker/observe/static/operator-chrome.js +1 -6
- package/dist/worker/observe/static/styles.css +9 -39
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +462 -33
- package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +25 -1
- package/dist/workflows/dag/backend-test-module-stem.js +5 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +345 -24
- package/dist/workflows/dag/backend-test-scenario-param.js +269 -82
- package/dist/workflows/dag/backend-test-writer-completeness.js +47 -16
- package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
- package/dist/workflows/dag/frontend-worktree-diff.js +27 -12
- package/dist/workflows/dag/init-hybrid.js +46 -34
- package/dist/workflows/dag/types.js +7 -0
- package/dist/workflows/dag/workspace-checkpoint.js +27 -8
- package/docs/templates/backend-test-dag.json +32 -29
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -3
- package/skills/loop-agent/references/source-and-plan-practice.md +0 -13
- package/skills/loop-agent/references/task-workflow.md +0 -4
- package/dist/shared/resilient-git.js +0 -133
- package/dist/task/source-prepare/artifact-meta.js +0 -137
- package/dist/task/source-prepare/semantic-intake.js +0 -404
- package/dist/worker/console/dag-execution-receipt.js +0 -380
- package/dist/worker/console/static/assets/index-BUOLppPr.js +0 -28
- package/dist/worker/console/static/assets/index-C1KzazY5.css +0 -1
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +0 -257
- 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 =
|
|
103
|
-
?
|
|
104
|
-
:
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
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 (
|
|
258
|
-
|
|
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
|
-
//
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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" &&
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
406
|
-
// case evidence
|
|
407
|
-
|
|
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
|
-
?
|
|
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
|
}
|