@tea-agent/loop-agent 0.33.4 → 0.33.6-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 +36 -0
- package/dist/executors/dag-pi-executor.js +34 -12
- package/dist/executors/shell-executor.js +26 -66
- package/dist/task/config-types.js +2 -2
- package/dist/worker/console/chat/chat-event-store.js +57 -18
- package/dist/worker/console/chat/routes.js +850 -170
- package/dist/worker/console/static/assets/{index-PzYzcuFG.js → index-CteJFFL2.js} +17 -17
- package/dist/worker/console/static/index.html +1 -1
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +33 -8
- package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +80 -2
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +27 -2
- package/dist/workflows/dag/backend-test-markdown-workflow.js +17 -0
- package/dist/workflows/dag/backend-test-module-stem.js +26 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +44 -0
- package/dist/workflows/dag/backend-test-writer-completeness.js +165 -49
- package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
- package/dist/workflows/dag/init-hybrid.js +44 -167
- package/dist/workflows/dag/types.js +7 -0
- package/docs/templates/backend-test-dag.json +10 -10
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +2 -2
- package/docs/templates/frontend-test-dag.json +9 -9
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -7
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/playwright-cli/SKILL.md +2 -3
|
@@ -2,6 +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 { BACKEND_TEST_MAX_MODULE_COUNT, isOpaqueHashBackendTestModuleStem, isPriorityOnlyBackendTestModuleStem, } from "./backend-test-module-stem.js";
|
|
5
6
|
/**
|
|
6
7
|
* Maps a backend-test generation writer task id to its writer-progress role.
|
|
7
8
|
* Returns undefined for nodes that are not completeness-gated generators.
|
|
@@ -96,51 +97,89 @@ function hasMarkdownTable(section, headerNeedle) {
|
|
|
96
97
|
* phantom missing modules when the model discusses forbidden filenames
|
|
97
98
|
* ("do not create 1.md / 9.md") inside the README body.
|
|
98
99
|
*/
|
|
99
|
-
function
|
|
100
|
+
function moduleStemInvalidReason(raw) {
|
|
100
101
|
if (!raw)
|
|
101
|
-
return
|
|
102
|
+
return "invalid-syntax";
|
|
102
103
|
const stem = normalizeBackendTestModuleStem(raw);
|
|
104
|
+
if (isPriorityOnlyBackendTestModuleStem(stem))
|
|
105
|
+
return "priority-only-module-stem";
|
|
106
|
+
if (isOpaqueHashBackendTestModuleStem(stem))
|
|
107
|
+
return "opaque-hash-module-stem";
|
|
103
108
|
if (stem.toLowerCase() === "readme")
|
|
104
|
-
return
|
|
109
|
+
return "reserved-module-stem";
|
|
105
110
|
if (!/^[a-z][a-z0-9_]*$/.test(stem))
|
|
106
|
-
return
|
|
111
|
+
return "invalid-syntax";
|
|
107
112
|
if (/^(?:be|tp|ac|req|br)[_-]/i.test(stem))
|
|
108
|
-
return
|
|
109
|
-
return
|
|
113
|
+
return "case-like-module-stem";
|
|
114
|
+
return undefined;
|
|
110
115
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
*/
|
|
117
|
-
export function extractModuleStemsFromReadme(readme) {
|
|
118
|
-
const stems = [];
|
|
119
|
-
// Only trust testcase/md/<stem>.md mentions that appear inside markdown
|
|
120
|
-
// table rows (`| ... testcase/md/x.md ... |`) or as canonical relative
|
|
121
|
-
// links (`[label](./x.md)`). Free-form prose mentions such as a recovery
|
|
122
|
-
// note listing `testcase/md/1.md` must NOT be treated as authoritative
|
|
123
|
-
// module references, otherwise the gate invents phantom missing modules.
|
|
124
|
-
const tableRowLines = readme
|
|
116
|
+
function looksLikeValidModuleStem(raw) {
|
|
117
|
+
return moduleStemInvalidReason(raw) === undefined;
|
|
118
|
+
}
|
|
119
|
+
function extractCanonicalModuleIndexSection(readme) {
|
|
120
|
+
const lines = readme
|
|
125
121
|
.replaceAll("\r\n", "\n")
|
|
126
122
|
.replaceAll("\r", "\n")
|
|
127
|
-
.split("\n")
|
|
128
|
-
|
|
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("|"));
|
|
129
141
|
for (const line of tableRowLines) {
|
|
130
142
|
for (const match of line.matchAll(/`?testcase\/md\/([A-Za-z0-9_.-]+)\.md`?/g)) {
|
|
131
|
-
|
|
132
|
-
stems.push(match[1]);
|
|
143
|
+
candidates.push(match[1]);
|
|
133
144
|
}
|
|
134
145
|
for (const match of line.matchAll(/\|\s*`?([A-Za-z0-9_.-]+)`?\s*\|\s*`?testcase\/test_/g)) {
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
const raw = match[1];
|
|
147
|
+
if (looksLikeValidModuleStem(raw) || moduleStemInvalidReason(raw) !== "invalid-syntax") {
|
|
148
|
+
candidates.push(raw);
|
|
149
|
+
}
|
|
137
150
|
}
|
|
138
151
|
}
|
|
139
|
-
for (const match of
|
|
140
|
-
|
|
141
|
-
|
|
152
|
+
for (const match of extracted.section.matchAll(/\[[^\]]+\]\(\.\/([A-Za-z0-9_.-]+)\.md\)/g)) {
|
|
153
|
+
candidates.push(match[1]);
|
|
154
|
+
}
|
|
155
|
+
const uniqueCandidates = orderedUnique(candidates);
|
|
156
|
+
const invalid = uniqueCandidates.flatMap((raw) => {
|
|
157
|
+
const reasonCode = moduleStemInvalidReason(raw);
|
|
158
|
+
return reasonCode
|
|
159
|
+
? [{ raw, normalized: normalizeBackendTestModuleStem(raw), reasonCode }]
|
|
160
|
+
: [];
|
|
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}`);
|
|
142
168
|
}
|
|
143
|
-
return
|
|
169
|
+
return {
|
|
170
|
+
candidates: uniqueCandidates,
|
|
171
|
+
validStems,
|
|
172
|
+
invalid,
|
|
173
|
+
structureIssues,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Exported for reuse by callers that only need the trusted shard set.
|
|
178
|
+
* Completeness gates must use inspectModuleStemsFromReadme so invalid
|
|
179
|
+
* authoritative candidates cannot be silently dropped.
|
|
180
|
+
*/
|
|
181
|
+
export function extractModuleStemsFromReadme(readme) {
|
|
182
|
+
return inspectModuleStemsFromReadme(readme).validStems;
|
|
144
183
|
}
|
|
145
184
|
async function listMarkdownModules(workspaceRoot) {
|
|
146
185
|
const root = path.join(workspaceRoot, "testcase", "md");
|
|
@@ -242,12 +281,80 @@ function moduleStructuralDetail(result) {
|
|
|
242
281
|
return "module file is structurally complete";
|
|
243
282
|
return `module file is empty or has structural issues: ${result.reasons.join("; ")}`;
|
|
244
283
|
}
|
|
284
|
+
function pythonStructureOutsideStrings(source) {
|
|
285
|
+
const input = source.replace(/\r\n/g, "\n");
|
|
286
|
+
let output = "";
|
|
287
|
+
let index = 0;
|
|
288
|
+
let quote;
|
|
289
|
+
let triple = false;
|
|
290
|
+
let comment = false;
|
|
291
|
+
while (index < input.length) {
|
|
292
|
+
const char = input[index];
|
|
293
|
+
if (comment) {
|
|
294
|
+
if (char === "\n") {
|
|
295
|
+
comment = false;
|
|
296
|
+
output += "\n";
|
|
297
|
+
}
|
|
298
|
+
else
|
|
299
|
+
output += " ";
|
|
300
|
+
index += 1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (quote) {
|
|
304
|
+
if (char === "\\") {
|
|
305
|
+
output += " ";
|
|
306
|
+
if (index + 1 < input.length)
|
|
307
|
+
output += input[index + 1] === "\n" ? "\n" : " ";
|
|
308
|
+
index += 2;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (triple && input.slice(index, index + 3) === quote.repeat(3)) {
|
|
312
|
+
output += " ";
|
|
313
|
+
index += 3;
|
|
314
|
+
quote = undefined;
|
|
315
|
+
triple = false;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!triple && char === quote) {
|
|
319
|
+
output += " ";
|
|
320
|
+
index += 1;
|
|
321
|
+
quote = undefined;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (!triple && char === "\n")
|
|
325
|
+
return { text: output, stringsClosed: false };
|
|
326
|
+
output += char === "\n" ? "\n" : " ";
|
|
327
|
+
index += 1;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (char === "#") {
|
|
331
|
+
comment = true;
|
|
332
|
+
output += " ";
|
|
333
|
+
index += 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (char === "'" || char === '"') {
|
|
337
|
+
quote = char;
|
|
338
|
+
triple = input.slice(index, index + 3) === char.repeat(3);
|
|
339
|
+
output += triple ? " " : " ";
|
|
340
|
+
index += triple ? 3 : 1;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
output += char;
|
|
344
|
+
index += 1;
|
|
345
|
+
}
|
|
346
|
+
return { text: output, stringsClosed: quote === undefined };
|
|
347
|
+
}
|
|
245
348
|
function pythonParseable(source) {
|
|
246
|
-
const
|
|
247
|
-
if (!
|
|
349
|
+
const normalized = source.replace(/\r\n/g, "\n");
|
|
350
|
+
if (!normalized.trim())
|
|
248
351
|
return false;
|
|
249
|
-
if (/^\s*(?:def|class|import|from)\b/m.test(
|
|
352
|
+
if (/^\s*(?:def|class|import|from)\b/m.test(normalized) === false)
|
|
250
353
|
return false;
|
|
354
|
+
const structural = pythonStructureOutsideStrings(normalized);
|
|
355
|
+
if (!structural.stringsClosed)
|
|
356
|
+
return false;
|
|
357
|
+
const text = structural.text;
|
|
251
358
|
const openParens = (text.match(/\(/g) ?? []).length;
|
|
252
359
|
const closeParens = (text.match(/\)/g) ?? []).length;
|
|
253
360
|
const openBrackets = (text.match(/\[/g) ?? []).length;
|
|
@@ -260,15 +367,6 @@ function pythonParseable(source) {
|
|
|
260
367
|
return false;
|
|
261
368
|
if (openBraces !== closeBraces)
|
|
262
369
|
return false;
|
|
263
|
-
if (/("""|''')[\s\S]*$/.test(text)) {
|
|
264
|
-
const triples = text.match(/("""|''')/g) ?? [];
|
|
265
|
-
if (triples.length % 2 !== 0)
|
|
266
|
-
return false;
|
|
267
|
-
}
|
|
268
|
-
// The balanced-bracket counts above already catch a genuinely truncated
|
|
269
|
-
// file (an unclosed def/call leaves unbalanced parens). The previous
|
|
270
|
-
// per-line "def ... ( ... $" heuristic was a false-positive source for
|
|
271
|
-
// legal multi-line definitions such as `def f(\n x,\n):` — removed.
|
|
272
370
|
return true;
|
|
273
371
|
}
|
|
274
372
|
/**
|
|
@@ -318,12 +416,7 @@ function looksLikeValidPythonModule(source) {
|
|
|
318
416
|
return false;
|
|
319
417
|
if (/^\s*(?:def|class|import|from)\b/m.test(text) === false)
|
|
320
418
|
return false;
|
|
321
|
-
|
|
322
|
-
const triples = text.match(/("""|''')/g) ?? [];
|
|
323
|
-
if (triples.length % 2 !== 0)
|
|
324
|
-
return false;
|
|
325
|
-
}
|
|
326
|
-
return true;
|
|
419
|
+
return pythonStructureOutsideStrings(text).stringsClosed;
|
|
327
420
|
}
|
|
328
421
|
export function buildOutputLimitRecoveryPrompt(input) {
|
|
329
422
|
return buildOutputLimitRecoverySection(input);
|
|
@@ -842,7 +935,30 @@ export async function assessBackendTestMdPlanCompleteness(workspaceRoot) {
|
|
|
842
935
|
// The README must advertise a non-empty module index so the downstream
|
|
843
936
|
// manifest shell has stems to shard; the indexed module files themselves
|
|
844
937
|
// are produced by map_agent children and are NOT required here.
|
|
845
|
-
const
|
|
938
|
+
const stemInspection = inspectModuleStemsFromReadme(readme);
|
|
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
|
+
}
|
|
951
|
+
for (const invalid of stemInspection.invalid) {
|
|
952
|
+
if (!brokenPaths.includes("testcase/md/README.md")) {
|
|
953
|
+
brokenPaths.push("testcase/md/README.md");
|
|
954
|
+
}
|
|
955
|
+
issues.push({
|
|
956
|
+
code: "T5",
|
|
957
|
+
path: "testcase/md/README.md",
|
|
958
|
+
detail: `${invalid.reasonCode}: ${invalid.normalized}; use a stable lowercase business resource/domain stem`,
|
|
959
|
+
recoverable: true,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
846
962
|
if (indexedStems.length === 0) {
|
|
847
963
|
if (!brokenPaths.includes("testcase/md/README.md")) {
|
|
848
964
|
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
|
}
|