@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.
@@ -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-PzYzcuFG.js"></script>
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 (!replay && document.hidden)
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
- }, [refs, setStreaming, setStreamingAssistantId]);
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 && refs.inlineStreamSessionIdsRef.current.has(session.sessionId)))
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" && observed.hasUppercase)
387
- return "MATCH";
388
- if (observed.kind === "string")
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 === "pattern-invalid" ||
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 ?? "INVALID")}`
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: suggestedFixFor(inferred.intent, inferred.field, inferred.bound, inferred.example),
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
- valueLiteral = JSON.stringify(entry.example ?? "INVALID");
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:")) {