@tea-agent/loop-agent 0.33.3 → 0.33.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 +39 -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-markdown-workflow.js +17 -0
- package/dist/workflows/dag/backend-test-module-stem.js +21 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +44 -0
- package/dist/workflows/dag/backend-test-scenario-param.js +39 -31
- package/dist/workflows/dag/backend-test-writer-completeness.js +130 -45
- package/dist/workflows/dag/init-hybrid.js +29 -160
- package/docs/templates/backend-test-dag.json +3 -3
- 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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<link rel="stylesheet" href="/inspect/operator-chrome.css" />
|
|
8
8
|
<title>Loop 操作台 · Operator Console</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-CteJFFL2.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/assets/index-CnUXAqxG.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
@@ -37,9 +37,7 @@ export function createChatSseEventApplier(deps) {
|
|
|
37
37
|
interviewSessionId: typeof payload.interviewSessionId === "string"
|
|
38
38
|
? payload.interviewSessionId
|
|
39
39
|
: current?.interviewSessionId,
|
|
40
|
-
taskId: typeof payload.taskId === "string"
|
|
41
|
-
? payload.taskId
|
|
42
|
-
: current?.taskId,
|
|
40
|
+
taskId: typeof payload.taskId === "string" ? payload.taskId : current?.taskId,
|
|
43
41
|
state: typeof payload.state === "string" ? payload.state : current?.state,
|
|
44
42
|
question: payload.question && typeof payload.question === "object"
|
|
45
43
|
? payload.question
|
|
@@ -50,9 +48,7 @@ export function createChatSseEventApplier(deps) {
|
|
|
50
48
|
if (eventName === "draft") {
|
|
51
49
|
setInterview((current) => ({
|
|
52
50
|
...(current ?? {}),
|
|
53
|
-
taskId: typeof payload.taskId === "string"
|
|
54
|
-
? payload.taskId
|
|
55
|
-
: current?.taskId,
|
|
51
|
+
taskId: typeof payload.taskId === "string" ? payload.taskId : current?.taskId,
|
|
56
52
|
draft: payload.draft && typeof payload.draft === "object"
|
|
57
53
|
? payload.draft
|
|
58
54
|
: current?.draft,
|
|
@@ -95,15 +91,44 @@ export function createChatSseEventApplier(deps) {
|
|
|
95
91
|
return;
|
|
96
92
|
}
|
|
97
93
|
if (eventName === "agent_start") {
|
|
94
|
+
// P2: a replayed agent_start must not resurrect an already-finished
|
|
95
|
+
// turn (aborted turns have no agent_settled in the ring, so replay
|
|
96
|
+
// would otherwise leave the UI streaming forever). Only the server's
|
|
97
|
+
// active turn may flip streaming on during replay; every other
|
|
98
|
+
// agent_start (including all of them when the server reports no
|
|
99
|
+
// active turn) is stale and ignored.
|
|
100
|
+
if (replay &&
|
|
101
|
+
turnId !== undefined &&
|
|
102
|
+
turnId !== refs.activeTurnIdRef.current) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (turnId)
|
|
106
|
+
refs.activeTurnIdRef.current = turnId;
|
|
98
107
|
setStreaming(true);
|
|
99
108
|
setStreamingAssistantId(resolvedAssistantId);
|
|
100
109
|
return;
|
|
101
110
|
}
|
|
102
|
-
if (eventName === "agent_settled") {
|
|
111
|
+
if (eventName === "agent_settled" || eventName === "agent_end") {
|
|
112
|
+
// Both are terminal: agent_end = SDK run finished, agent_settled =
|
|
113
|
+
// post-turn continuation idle. Clear streaming and drop an empty
|
|
114
|
+
// placeholder bubble so a settled turn never shows a stuck "…" row.
|
|
103
115
|
setStreaming(false);
|
|
104
116
|
setStreamingAssistantId(null);
|
|
105
|
-
if (
|
|
117
|
+
if (eventName === "agent_settled" &&
|
|
118
|
+
!replay &&
|
|
119
|
+
typeof document !== "undefined" &&
|
|
120
|
+
document.hidden) {
|
|
106
121
|
setUnread((current) => current + 1);
|
|
122
|
+
}
|
|
123
|
+
setMessages((m) => m.filter((msg) => !(msg.id === resolvedAssistantId &&
|
|
124
|
+
msg.role === "assistant" &&
|
|
125
|
+
!msg.text.trim())));
|
|
126
|
+
// Close out tool calls that never got a tool_result (abort/fail):
|
|
127
|
+
// without this the process rail keeps showing "运行中" forever even
|
|
128
|
+
// though the turn is over.
|
|
129
|
+
setToolCalls((prev) => prev.map((t) => t.status === "running" && t.assistantMessageId === resolvedAssistantId
|
|
130
|
+
? { ...t, status: "error", finishedAt: Date.now() }
|
|
131
|
+
: t));
|
|
107
132
|
return;
|
|
108
133
|
}
|
|
109
134
|
if (eventName === "operation-ref" || eventName === "operation-status") {
|
|
@@ -10,6 +10,7 @@ export function useChatRefs() {
|
|
|
10
10
|
const seenEventIdsRef = useRef(new Set());
|
|
11
11
|
const turnAssistantIdsRef = useRef(new Map());
|
|
12
12
|
const reconnectAbortRef = useRef(null);
|
|
13
|
+
const activeTurnIdRef = useRef(null);
|
|
13
14
|
const usageResponseKeysRef = useRef(new Set());
|
|
14
15
|
const isComposingRef = useRef(false);
|
|
15
16
|
const compositionEndedAtRef = useRef(0);
|
|
@@ -26,6 +27,7 @@ export function useChatRefs() {
|
|
|
26
27
|
seenEventIdsRef,
|
|
27
28
|
turnAssistantIdsRef,
|
|
28
29
|
reconnectAbortRef,
|
|
30
|
+
activeTurnIdRef,
|
|
29
31
|
usageResponseKeysRef,
|
|
30
32
|
isComposingRef,
|
|
31
33
|
compositionEndedAtRef,
|
|
@@ -40,6 +42,7 @@ export function useChatRefs() {
|
|
|
40
42
|
seenEventIdsRef,
|
|
41
43
|
turnAssistantIdsRef,
|
|
42
44
|
reconnectAbortRef,
|
|
45
|
+
activeTurnIdRef,
|
|
43
46
|
usageResponseKeysRef,
|
|
44
47
|
isComposingRef,
|
|
45
48
|
compositionEndedAtRef,
|
|
@@ -23,6 +23,7 @@ export function useChatSessions(params) {
|
|
|
23
23
|
refs.lastEventIdRef.current = null;
|
|
24
24
|
refs.seenEventIdsRef.current = new Set();
|
|
25
25
|
refs.turnAssistantIdsRef.current = new Map();
|
|
26
|
+
refs.activeTurnIdRef.current = null;
|
|
26
27
|
resetForNewSession();
|
|
27
28
|
setInput("");
|
|
28
29
|
setPendingImages([]);
|
|
@@ -125,6 +126,7 @@ export function useChatSessions(params) {
|
|
|
125
126
|
};
|
|
126
127
|
refs.sessionRef.current = next;
|
|
127
128
|
refs.streamingRef.current = Boolean(body.session.activeTurnId);
|
|
129
|
+
refs.activeTurnIdRef.current = body.session.activeTurnId ?? null;
|
|
128
130
|
refs.seenEventIdsRef.current = new Set();
|
|
129
131
|
refs.turnAssistantIdsRef.current = new Map();
|
|
130
132
|
refs.lastEventIdRef.current = null;
|
|
@@ -192,6 +194,7 @@ export function useChatSessions(params) {
|
|
|
192
194
|
// from the durable store, but tool/gate/operation projections live
|
|
193
195
|
// in the event ring and must replay once. applySseBlock dedups by
|
|
194
196
|
// eventId and marks replay so usage/unread/sound do not double-count.
|
|
197
|
+
refs.activeTurnIdRef.current = body.session.activeTurnId ?? null;
|
|
195
198
|
refs.seenEventIdsRef.current = new Set();
|
|
196
199
|
refs.turnAssistantIdsRef.current = new Map();
|
|
197
200
|
refs.lastEventIdRef.current = null;
|
|
@@ -123,7 +123,84 @@ export function useChatStream(params) {
|
|
|
123
123
|
refs.streamingRef.current = false;
|
|
124
124
|
setStreaming(false);
|
|
125
125
|
setStreamingAssistantId(null);
|
|
126
|
-
|
|
126
|
+
// User-initiated stop: immediately drop the still-empty placeholder
|
|
127
|
+
// bubble (no text yet) instead of leaving a forever "…" row (pi-web
|
|
128
|
+
// hides empty assistant rows on abort).
|
|
129
|
+
setMessages((m) => m.filter((msg) => !(msg.id === refs.assistantIdRef.current &&
|
|
130
|
+
msg.role === "assistant" &&
|
|
131
|
+
!msg.text.trim())));
|
|
132
|
+
}, [refs, setStreaming, setStreamingAssistantId, setMessages]);
|
|
133
|
+
// Reconcile client streaming state with the server (pi-web get_state loop).
|
|
134
|
+
// When SSE events are missed (network drop, backgrounded tab, half-open
|
|
135
|
+
// connection) agent_end/agent_settled never arrive and the UI stays
|
|
136
|
+
// streaming forever. If the server reports no active turn while we still
|
|
137
|
+
// think a turn is streaming, force the same finish a terminal event would
|
|
138
|
+
// have produced. Also refreshes activeTurnIdRef so replayed agent_start
|
|
139
|
+
// frames are judged against current server truth.
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!session || !streaming)
|
|
142
|
+
return;
|
|
143
|
+
const sid = session.sessionId;
|
|
144
|
+
let cancelled = false;
|
|
145
|
+
const sync = async () => {
|
|
146
|
+
if (cancelled)
|
|
147
|
+
return;
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sid)}/state`, { credentials: "include" });
|
|
150
|
+
if (!res.ok)
|
|
151
|
+
return;
|
|
152
|
+
const body = (await res.json());
|
|
153
|
+
const activeTurnId = body.state?.activeTurnId ?? null;
|
|
154
|
+
refs.activeTurnIdRef.current = activeTurnId;
|
|
155
|
+
if (activeTurnId !== null || !refs.streamingRef.current)
|
|
156
|
+
return;
|
|
157
|
+
// Server idle while the client still thinks a turn is streaming.
|
|
158
|
+
// Skip when an inline prompt request is still in flight — the
|
|
159
|
+
// server may not have created its turn lease yet (POST prompt
|
|
160
|
+
// creates the turn server-side after the request lands), and
|
|
161
|
+
// force-finishing then would kill a just-sent prompt.
|
|
162
|
+
if (refs.inlineStreamSessionIdsRef.current.has(sid))
|
|
163
|
+
return;
|
|
164
|
+
const pendingAssistantId = refs.assistantIdRef.current;
|
|
165
|
+
refs.streamingRef.current = false;
|
|
166
|
+
setStreaming(false);
|
|
167
|
+
setStreamingAssistantId(null);
|
|
168
|
+
refs.abortRef.current?.abort();
|
|
169
|
+
refs.abortRef.current = null;
|
|
170
|
+
refs.assistantIdRef.current = null;
|
|
171
|
+
setMessages((m) => m.filter((msg) => !(msg.id === pendingAssistantId &&
|
|
172
|
+
msg.role === "assistant" &&
|
|
173
|
+
!msg.text.trim())));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// Network still down — the next poll / visibility / online tick retries.
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const onVisible = () => {
|
|
180
|
+
if (document.visibilityState === "visible")
|
|
181
|
+
void sync();
|
|
182
|
+
};
|
|
183
|
+
const onOnline = () => {
|
|
184
|
+
void sync();
|
|
185
|
+
};
|
|
186
|
+
const interval = setInterval(() => void sync(), 15_000);
|
|
187
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
188
|
+
window.addEventListener("online", onOnline);
|
|
189
|
+
return () => {
|
|
190
|
+
cancelled = true;
|
|
191
|
+
clearInterval(interval);
|
|
192
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
193
|
+
window.removeEventListener("online", onOnline);
|
|
194
|
+
};
|
|
195
|
+
}, [
|
|
196
|
+
origin,
|
|
197
|
+
refs,
|
|
198
|
+
session,
|
|
199
|
+
streaming,
|
|
200
|
+
setStreaming,
|
|
201
|
+
setStreamingAssistantId,
|
|
202
|
+
setMessages,
|
|
203
|
+
]);
|
|
127
204
|
/** Open the reconnectable GET /events stream for a session and apply events
|
|
128
205
|
* until it closes, then reopen with the latest Last-Event-ID (M1-T04/T05).
|
|
129
206
|
*
|
|
@@ -194,7 +271,8 @@ export function useChatStream(params) {
|
|
|
194
271
|
// leak a dangling connection. It resumes after settle/stop to catch the tail.
|
|
195
272
|
useEffect(() => {
|
|
196
273
|
if (!session ||
|
|
197
|
-
(streaming &&
|
|
274
|
+
(streaming &&
|
|
275
|
+
refs.inlineStreamSessionIdsRef.current.has(session.sessionId)))
|
|
198
276
|
return;
|
|
199
277
|
const ac = new AbortController();
|
|
200
278
|
refs.reconnectAbortRef.current = ac;
|
|
@@ -5,6 +5,7 @@ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { createWriteStream } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
+
import { isPriorityOnlyBackendTestModuleStem } from "./backend-test-module-stem.js";
|
|
8
9
|
import { parseJacocoXml, } from "./backend-test-coverage-contract.js";
|
|
9
10
|
import { backendTestCaseManifestSchema, computeCaseManifestCoverageSummary, } from "./backend-test-case-manifest.js";
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
@@ -398,9 +399,13 @@ function markdownSecretFindings(relativeFile, markdown) {
|
|
|
398
399
|
export function hasBlockingBackendMarkdownSafetyFindings(report) {
|
|
399
400
|
return /^## Safety Status\s*\r?\n\s*BLOCKED\s*$/m.test(report);
|
|
400
401
|
}
|
|
402
|
+
export function hasBlockingBackendMarkdownModuleStemFindings(report) {
|
|
403
|
+
return /^## Module Stem Status\s*\r?\n\s*BLOCKED\s*$/m.test(report);
|
|
404
|
+
}
|
|
401
405
|
export async function validateBackendMarkdownCases(input) {
|
|
402
406
|
const findings = [];
|
|
403
407
|
const safetyFindings = [];
|
|
408
|
+
const moduleStemFindings = [];
|
|
404
409
|
let files = [];
|
|
405
410
|
try {
|
|
406
411
|
files = await markdownFiles(input.workspaceRoot);
|
|
@@ -419,6 +424,9 @@ export async function validateBackendMarkdownCases(input) {
|
|
|
419
424
|
let caseCount = 0;
|
|
420
425
|
for (const file of moduleFiles) {
|
|
421
426
|
const relativeFile = path.relative(input.workspaceRoot, file).replaceAll(path.sep, "/");
|
|
427
|
+
if (isPriorityOnlyBackendTestModuleStem(path.basename(file, path.extname(file)))) {
|
|
428
|
+
moduleStemFindings.push(`priority-only-module-stem: ${relativeFile}; regenerate the README Module Index with a stable business resource/domain stem`);
|
|
429
|
+
}
|
|
422
430
|
const markdown = await readFile(file, "utf8");
|
|
423
431
|
safetyFindings.push(...markdownSecretFindings(relativeFile, markdown));
|
|
424
432
|
if (!markdown.trim()) {
|
|
@@ -479,6 +487,7 @@ export async function validateBackendMarkdownCases(input) {
|
|
|
479
487
|
if (missing.length > 0) {
|
|
480
488
|
findings.push(`required AC IDs are not covered by final Markdown cases: ${missing.join(", ")}`);
|
|
481
489
|
}
|
|
490
|
+
findings.push(...moduleStemFindings);
|
|
482
491
|
const status = findings.length === 0 ? "PASS" : "FAIL";
|
|
483
492
|
const report = [
|
|
484
493
|
"# Backend Markdown Case Validation",
|
|
@@ -497,6 +506,14 @@ export async function validateBackendMarkdownCases(input) {
|
|
|
497
506
|
"- Source-reference validity check: not applied",
|
|
498
507
|
"- Markdown quality sensitive-information finding: not advisory; see Safety Status",
|
|
499
508
|
"",
|
|
509
|
+
"## Module Stem Status",
|
|
510
|
+
"",
|
|
511
|
+
moduleStemFindings.length > 0 ? "BLOCKED" : "PASS",
|
|
512
|
+
"",
|
|
513
|
+
...(moduleStemFindings.length > 0
|
|
514
|
+
? moduleStemFindings.map((finding) => `- ${finding}`)
|
|
515
|
+
: ["- All Markdown modules use non-priority business stems."]),
|
|
516
|
+
"",
|
|
500
517
|
"## Safety Status",
|
|
501
518
|
"",
|
|
502
519
|
safetyFindings.length > 0 ? "BLOCKED" : "PASS",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
const PRIORITY_ONLY_MODULE_STEMS = new Set(["p0", "p1", "p2"]);
|
|
3
|
+
export function normalizeBackendTestModuleStemCandidate(raw) {
|
|
4
|
+
return path.basename(raw)
|
|
5
|
+
.replace(/\.(?:md|py)$/i, "")
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
8
|
+
.replace(/^_+|_+$/g, "")
|
|
9
|
+
.replace(/_+/g, "_");
|
|
10
|
+
}
|
|
11
|
+
export function isPriorityOnlyBackendTestModuleStem(raw) {
|
|
12
|
+
return PRIORITY_ONLY_MODULE_STEMS.has(normalizeBackendTestModuleStemCandidate(raw));
|
|
13
|
+
}
|
|
14
|
+
export function isPriorityOnlyBackendPytestScript(script) {
|
|
15
|
+
const basename = path.posix.basename(script.replaceAll("\\", "/"));
|
|
16
|
+
const match = /^test_(.+)\.py$/i.exec(basename);
|
|
17
|
+
return match ? isPriorityOnlyBackendTestModuleStem(match[1]) : false;
|
|
18
|
+
}
|
|
19
|
+
export function priorityOnlyBackendPytestScripts(paths) {
|
|
20
|
+
return [...new Set(paths.map((item) => item.replaceAll("\\", "/")).filter(isPriorityOnlyBackendPytestScript))].sort();
|
|
21
|
+
}
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { priorityOnlyBackendPytestScripts } from "./backend-test-module-stem.js";
|
|
5
6
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
6
7
|
const MAX_DIAGNOSTIC_CHARS = 12_000;
|
|
7
8
|
export const backendPytestCollectionFindingSchema = z.object({
|
|
@@ -273,6 +274,41 @@ export function assessBackendPytestCollection(input) {
|
|
|
273
274
|
fixtureStderrExcerpt: "",
|
|
274
275
|
});
|
|
275
276
|
}
|
|
277
|
+
export function assessPriorityOnlyBackendPytestModules(input) {
|
|
278
|
+
const invalidScripts = priorityOnlyBackendPytestScripts(input.inventory.assetFiles);
|
|
279
|
+
if (invalidScripts.length === 0)
|
|
280
|
+
return undefined;
|
|
281
|
+
return backendPytestCollectionFactsSchema.parse({
|
|
282
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
283
|
+
phase: input.phase,
|
|
284
|
+
status: "BLOCKED",
|
|
285
|
+
repairEligible: false,
|
|
286
|
+
repairAttempt: input.phase === "final" ? 1 : 0,
|
|
287
|
+
collectionAttempted: false,
|
|
288
|
+
fixtureResolutionAttempted: false,
|
|
289
|
+
fixtureResolutionStatus: "NOT_RUN",
|
|
290
|
+
fixtureResolutionExitCode: null,
|
|
291
|
+
repairPaths: [],
|
|
292
|
+
mappedScripts: [...input.mappedScripts],
|
|
293
|
+
existingMappedScripts: input.inventory.mappedScripts,
|
|
294
|
+
missingMappedScripts: [],
|
|
295
|
+
assetFiles: input.inventory.assetFiles,
|
|
296
|
+
inputHashes: input.inventory.inputHashes,
|
|
297
|
+
pytestExitCode: null,
|
|
298
|
+
collectedItemCount: 0,
|
|
299
|
+
collectedItemIds: [],
|
|
300
|
+
findings: invalidScripts.map((script) => ({
|
|
301
|
+
kind: "priority-only-pytest-module",
|
|
302
|
+
classification: "test-asset-defect",
|
|
303
|
+
repairability: "blocked",
|
|
304
|
+
detail: `priority-only pytest module is forbidden and cannot be auto-renamed: ${script}`,
|
|
305
|
+
})),
|
|
306
|
+
stdoutExcerpt: "",
|
|
307
|
+
stderrExcerpt: "",
|
|
308
|
+
fixtureStdoutExcerpt: "",
|
|
309
|
+
fixtureStderrExcerpt: "",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
276
312
|
export function assessMissingBackendPytestScripts(input) {
|
|
277
313
|
return backendPytestCollectionFactsSchema.parse({
|
|
278
314
|
schemaId: "backend-test-pytest-collection-v3",
|
|
@@ -408,6 +444,10 @@ export async function materializeBackendTestExecutionReadiness(input) {
|
|
|
408
444
|
throw new Error("backend-test execution readiness requires effective collection and fixture-resolution PASS");
|
|
409
445
|
}
|
|
410
446
|
const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.effective.mappedScripts);
|
|
447
|
+
const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
|
|
448
|
+
if (priorityOnly.length > 0) {
|
|
449
|
+
throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
|
|
450
|
+
}
|
|
411
451
|
assertSameInventory(input.effective, current);
|
|
412
452
|
const status = input.scenarioParamStatus === "FAIL"
|
|
413
453
|
? "BLOCKED"
|
|
@@ -442,6 +482,10 @@ export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, re
|
|
|
442
482
|
throw new Error(`backend pytest execution readiness is ${readiness.status}`);
|
|
443
483
|
}
|
|
444
484
|
const current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
|
|
485
|
+
const priorityOnly = priorityOnlyBackendPytestScripts(current.assetFiles);
|
|
486
|
+
if (priorityOnly.length > 0) {
|
|
487
|
+
throw new Error(`priority-only-pytest-module: ${priorityOnly.join(", ")}`);
|
|
488
|
+
}
|
|
445
489
|
assertSameInventory({ mappedScripts: readiness.mappedScripts, assetFiles: Object.keys(readiness.assetHashes), inputHashes: readiness.assetHashes }, current);
|
|
446
490
|
}
|
|
447
491
|
export async function assertBackendPytestCollectionFresh(workspaceRoot, effective) {
|
|
@@ -135,6 +135,7 @@ export function inferScenarioParamIntent(input) {
|
|
|
135
135
|
field,
|
|
136
136
|
bound,
|
|
137
137
|
example,
|
|
138
|
+
intentSource: "machine-line",
|
|
138
139
|
};
|
|
139
140
|
}
|
|
140
141
|
return {
|
|
@@ -142,6 +143,7 @@ export function inferScenarioParamIntent(input) {
|
|
|
142
143
|
field,
|
|
143
144
|
bound,
|
|
144
145
|
example,
|
|
146
|
+
intentSource: "machine-line",
|
|
145
147
|
};
|
|
146
148
|
}
|
|
147
149
|
const upper = tpId.toUpperCase();
|
|
@@ -178,37 +180,39 @@ export function inferScenarioParamIntent(input) {
|
|
|
178
180
|
const exampleMatch = /(?:反例|invalid example|example)\s*[=::]\s*[`"]?([^`"\n]+)[`"]?/i.exec(caseBody);
|
|
179
181
|
const example = exampleMatch?.[1]?.trim();
|
|
180
182
|
if (/EMPTY|空白|空串|空字符串/.test(upper) || /空字符串|空串|empty string/i.test(caseBody)) {
|
|
181
|
-
return { intent: "empty", field, bound, example };
|
|
183
|
+
return { intent: "empty", field, bound, example, intentSource: "tp-fallback" };
|
|
182
184
|
}
|
|
183
185
|
if (/MISSING|缺省|缺失|省略/.test(upper) || /缺少字段|缺失字段|missing field/i.test(caseBody)) {
|
|
184
|
-
return { intent: "missing", field, bound, example };
|
|
186
|
+
return { intent: "missing", field, bound, example, intentSource: "tp-fallback" };
|
|
185
187
|
}
|
|
186
188
|
if (/\bNULL\b|空值/.test(upper) || /\bnull\b/i.test(caseBody)) {
|
|
187
|
-
return { intent: "null", field, bound, example };
|
|
189
|
+
return { intent: "null", field, bound, example, intentSource: "tp-fallback" };
|
|
188
190
|
}
|
|
189
191
|
if (/MAX[-_]?PLUS[-_]?1|MAX\+1|超长|越界/.test(upper)) {
|
|
190
|
-
return { intent: "max+1", field, bound, example };
|
|
192
|
+
return { intent: "max+1", field, bound, example, intentSource: "tp-fallback" };
|
|
193
|
+
}
|
|
194
|
+
if (/\bMAX\b|最大/.test(upper)) {
|
|
195
|
+
return { intent: "max", field, bound, example, intentSource: "tp-fallback" };
|
|
191
196
|
}
|
|
192
|
-
if (/\bMAX\b|最大/.test(upper))
|
|
193
|
-
return { intent: "max", field, bound, example };
|
|
194
197
|
if (/MIN[-_]?1|最小减|min-1/i.test(upper)) {
|
|
195
|
-
return { intent: "min-1", field, bound, example };
|
|
198
|
+
return { intent: "min-1", field, bound, example, intentSource: "tp-fallback" };
|
|
199
|
+
}
|
|
200
|
+
if (/\bMIN\b|最小/.test(upper)) {
|
|
201
|
+
return { intent: "min", field, bound, example, intentSource: "tp-fallback" };
|
|
196
202
|
}
|
|
197
|
-
if (/\bMIN\b|最小/.test(upper))
|
|
198
|
-
return { intent: "min", field, bound, example };
|
|
199
203
|
if (/WRONG[-_]?TYPE|类型错误/.test(upper)) {
|
|
200
|
-
return { intent: "wrong-type", field, bound, example };
|
|
204
|
+
return { intent: "wrong-type", field, bound, example, intentSource: "tp-fallback" };
|
|
201
205
|
}
|
|
202
206
|
if (/ENUM.*INVALID|INVALID.*ENUM|非法枚举/.test(upper)) {
|
|
203
|
-
return { intent: "enum-invalid", field, bound, example };
|
|
207
|
+
return { intent: "enum-invalid", field, bound, example, intentSource: "tp-fallback" };
|
|
204
208
|
}
|
|
205
209
|
if (/PATTERN|FORMAT|UPPERCASE|大写|非法格式/.test(upper)) {
|
|
206
|
-
return { intent: "pattern-invalid", field, bound, example };
|
|
210
|
+
return { intent: "pattern-invalid", field, bound, example, intentSource: "tp-fallback" };
|
|
207
211
|
}
|
|
208
212
|
if (/NOMINAL|正常|合法|主路径|SUCCESS/.test(upper)) {
|
|
209
|
-
return { intent: "nominal", field, bound, example };
|
|
213
|
+
return { intent: "nominal", field, bound, example, intentSource: "tp-fallback" };
|
|
210
214
|
}
|
|
211
|
-
return { intent: "unknown", field, bound, example };
|
|
215
|
+
return { intent: "unknown", field, bound, example, intentSource: "tp-fallback" };
|
|
212
216
|
}
|
|
213
217
|
export function extractPytestParamBlock(source, tpId) {
|
|
214
218
|
const idPattern = new RegExp(`id\\s*=\\s*["']${tpId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`);
|
|
@@ -316,7 +320,7 @@ export function observeParamFeatures(block, field) {
|
|
|
316
320
|
}
|
|
317
321
|
return { kind: "unknown", text: compact.slice(0, 160) };
|
|
318
322
|
}
|
|
319
|
-
function compareIntent(intent, observed, bound) {
|
|
323
|
+
function compareIntent(intent, observed, bound, example) {
|
|
320
324
|
if (intent === "unknown")
|
|
321
325
|
return "UNDETERMINED";
|
|
322
326
|
if (observed.kind === "call" || observed.kind === "name") {
|
|
@@ -383,10 +387,9 @@ function compareIntent(intent, observed, bound) {
|
|
|
383
387
|
? "MISMATCH"
|
|
384
388
|
: "UNDETERMINED";
|
|
385
389
|
case "pattern-invalid":
|
|
386
|
-
if (observed.kind === "string" &&
|
|
387
|
-
return "MATCH";
|
|
388
|
-
|
|
389
|
-
return "MISMATCH";
|
|
390
|
+
if (observed.kind === "string" && example !== undefined) {
|
|
391
|
+
return observed.literal === example ? "MATCH" : "MISMATCH";
|
|
392
|
+
}
|
|
390
393
|
return "UNDETERMINED";
|
|
391
394
|
case "wrong-type":
|
|
392
395
|
return observed.kind === "number" ||
|
|
@@ -433,14 +436,9 @@ function repairabilityFor(status, intent, observed, example) {
|
|
|
433
436
|
}
|
|
434
437
|
if (intent === "unknown")
|
|
435
438
|
return "blocked";
|
|
436
|
-
if ((intent === "
|
|
437
|
-
intent === "enum-invalid" ||
|
|
438
|
-
intent === "wrong-type") &&
|
|
439
|
+
if ((intent === "enum-invalid" || intent === "wrong-type") &&
|
|
439
440
|
!example &&
|
|
440
441
|
!intent.startsWith("custom-literal:")) {
|
|
441
|
-
// pattern-invalid with uppercase heuristic is still repairable to a fixed anti-example.
|
|
442
|
-
if (intent === "pattern-invalid")
|
|
443
|
-
return "repairable";
|
|
444
442
|
return "blocked";
|
|
445
443
|
}
|
|
446
444
|
if (CLOSED_SET.has(intent) || intent.startsWith("custom-literal:")) {
|
|
@@ -475,8 +473,8 @@ function suggestedFixFor(intent, field, bound, example) {
|
|
|
475
473
|
? `set ${field}="x"*${Math.max(0, bound - 1)}`
|
|
476
474
|
: undefined;
|
|
477
475
|
case "pattern-invalid":
|
|
478
|
-
return field
|
|
479
|
-
? `set ${field}=${JSON.stringify(example
|
|
476
|
+
return field && example
|
|
477
|
+
? `set ${field}=${JSON.stringify(example)}`
|
|
480
478
|
: undefined;
|
|
481
479
|
case "enum-invalid":
|
|
482
480
|
case "wrong-type":
|
|
@@ -509,11 +507,12 @@ export async function assessBackendScenarioParamConsistency(input) {
|
|
|
509
507
|
tpId,
|
|
510
508
|
caseBody: testCase.body,
|
|
511
509
|
});
|
|
510
|
+
const fallbackSourced = inferred.intentSource === "tp-fallback";
|
|
512
511
|
const block = source ? extractPytestParamBlock(source, tpId) : undefined;
|
|
513
512
|
const observed = observeParamFeatures(block, inferred.field);
|
|
514
|
-
const status = !source
|
|
513
|
+
const status = !source || fallbackSourced
|
|
515
514
|
? "UNDETERMINED"
|
|
516
|
-
: compareIntent(inferred.intent, observed, inferred.bound);
|
|
515
|
+
: compareIntent(inferred.intent, observed, inferred.bound, inferred.example);
|
|
517
516
|
const repairability = repairabilityFor(status, inferred.intent, observed, inferred.example);
|
|
518
517
|
entries.push({
|
|
519
518
|
caseId: testCase.caseId,
|
|
@@ -523,7 +522,9 @@ export async function assessBackendScenarioParamConsistency(input) {
|
|
|
523
522
|
observed: observed.text,
|
|
524
523
|
status,
|
|
525
524
|
repairability,
|
|
526
|
-
suggestedFix:
|
|
525
|
+
suggestedFix: fallbackSourced
|
|
526
|
+
? undefined
|
|
527
|
+
: suggestedFixFor(inferred.intent, inferred.field, inferred.bound, inferred.example),
|
|
527
528
|
scriptPath: testCase.scriptPath,
|
|
528
529
|
bound: inferred.bound,
|
|
529
530
|
example: inferred.example,
|
|
@@ -691,7 +692,14 @@ export function deterministicRewriteScenarioParam(input) {
|
|
|
691
692
|
valueLiteral = `"x" * ${Math.max(0, bound - 1)}`;
|
|
692
693
|
break;
|
|
693
694
|
case "pattern-invalid":
|
|
694
|
-
|
|
695
|
+
if (entry.example === undefined) {
|
|
696
|
+
return {
|
|
697
|
+
ok: false,
|
|
698
|
+
source: input.source,
|
|
699
|
+
detail: "missing example for pattern-invalid",
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
valueLiteral = JSON.stringify(entry.example);
|
|
695
703
|
break;
|
|
696
704
|
default:
|
|
697
705
|
if (entry.intent.startsWith("custom-literal:")) {
|