@emseepea/testing 0.23.1 → 0.23.2

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/README.md CHANGED
@@ -116,6 +116,15 @@ server.
116
116
  An isolated command policy denies shell launches and spawned shell environments
117
117
  inherit no provider credentials.
118
118
 
119
+ Failed Codex runs retain only a fixed failure category: authentication,
120
+ configuration, host storage, permission or sandbox, provider, transport, or
121
+ unknown. The runner inspects at most 32 KiB of standard error to derive that
122
+ category and does not store the standard error.
123
+
124
+ Rejected native items retain only capped counts from a fixed item-type list.
125
+ Commands, arguments, results, paths, server and tool names, and unknown
126
+ item-type strings are excluded.
127
+
119
128
  Codex CLI evidence proves that CLI journey. It does not prove native ChatGPT
120
129
  connection, attachment, retrieval, or MCP App rendering behavior.
121
130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emseepea/testing",
3
- "version": "0.23.1",
3
+ "version": "0.23.2",
4
4
  "description": "MCP integration and semantic testing helpers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,6 +13,17 @@ const providerErrorMessages = new Map([
13
13
  ["error_max_turns", "Model command exceeded its turn limit"],
14
14
  ]);
15
15
  const authenticationFailure = /not logged in|failed to authenticate|oauth session expired/i;
16
+ const codexFailureCategories = new Set([
17
+ "authentication",
18
+ "configuration",
19
+ "host-storage",
20
+ "permission-or-sandbox",
21
+ "provider",
22
+ "transport",
23
+ "unknown",
24
+ ]);
25
+ const stderrLimit = 32 * 1024;
26
+ const forbiddenItemLimit = 64;
16
27
  const hasAuthenticationFailure = (events) => events.some((event) => (
17
28
  (Array.isArray(event.message?.content)
18
29
  && event.message.content.some(({ type, text }) => type === "text" && /not logged in/i.test(text ?? "")))
@@ -388,14 +399,17 @@ export async function runModel(provider, prompt, directory, signal) {
388
399
  if (execution.outputLimitExceeded) throw new Error("Model command output exceeded its limit");
389
400
  if (execution.errorCode === "ABORT_ERR") throw new Error("Model command was cancelled");
390
401
  if (execution.errorCode) throw new Error("Model command could not start");
391
- if (execution.code !== 0 && !execution.stdout) throw new Error(`Model command exited ${execution.code}`);
402
+ if (execution.code !== 0 && !execution.stdout) {
403
+ throw codexProcessError(execution.code, execution.providerFailureCategory);
404
+ }
392
405
  return provider.startsWith("codex-")
393
406
  ? parseCodexEvents(execution.stdout, [], undefined, execution.code,
394
- providerModel(provider), invocation.providerSecrets)
407
+ providerModel(provider), invocation.providerSecrets, execution.providerFailureCategory)
395
408
  : parseClaudeEvents(execution.stdout, execution.code);
396
409
  }
397
410
 
398
- export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, processExitCode = 0, selectedModel = "test-model", secrets = []) {
411
+ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, processExitCode = 0,
412
+ selectedModel = "test-model", secrets = [], providerFailureCategory) {
399
413
  let events;
400
414
  try {
401
415
  events = typeof stdout === "string"
@@ -412,11 +426,13 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
412
426
  const authentication = /(?:not logged in|authentication|unauthorized|invalid api key|oauth)/i;
413
427
  const errorText = events.flatMap((event) => [event.message, event.error?.message, event.item?.message])
414
428
  .filter((value) => typeof value === "string").join("\n");
415
- if (authentication.test(errorText)) throw new Error("Model command is not signed in");
429
+ if (authentication.test(errorText)) {
430
+ throw codexEvidenceError("Model command is not signed in", "authentication");
431
+ }
416
432
  const failedTool = events.some(({ item }) => item?.type === "mcp_tool_call"
417
433
  && (item.status === "failed" || item.result?.isError === true || item.result?.is_error === true));
418
434
  if (failedTool) throw new Error("Model command reported a failed MCP tool call");
419
- if (processExitCode !== 0) throw new Error(`Model command exited ${processExitCode}`);
435
+ if (processExitCode !== 0) throw codexProcessError(processExitCode, providerFailureCategory);
420
436
  if (events.some(({ type }) => type === "turn.failed" || type === "error")) {
421
437
  throw new Error("Model command reported an error");
422
438
  }
@@ -435,12 +451,12 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
435
451
  const advertised = new Set(advertisedTools.map(({ name }) => name));
436
452
  const completed = events.filter(({ type }) => type === "item.completed").map(({ item }) => item);
437
453
  const forbidden = completed.filter((item) => !["agent_message", "reasoning", "mcp_tool_call"].includes(item?.type));
438
- if (forbidden.length) throw new Error("Model command used a forbidden tool");
454
+ if (forbidden.length) throw forbiddenItemError(forbidden);
439
455
  const toolItems = completed.filter(({ type }) => type === "mcp_tool_call");
440
456
  const calls = toolItems.map((item) => {
441
457
  if (item.server !== mcpServerName || !advertised.has(item.tool)
442
458
  || !item.arguments || typeof item.arguments !== "object" || Array.isArray(item.arguments)) {
443
- throw new Error("Model command used a forbidden tool");
459
+ throw forbiddenItemError([item]);
444
460
  }
445
461
  if (!item.result || typeof item.result !== "object" || item.status === "failed"
446
462
  || item.result.isError === true || item.result.is_error === true) {
@@ -492,7 +508,8 @@ function startCodexConversation(provider, directory, url, tools, authToken, cont
492
508
  if (execution.errorCode === "ABORT_ERR") throw new Error("Model command was cancelled");
493
509
  if (execution.errorCode) throw new Error("Model conversation could not start");
494
510
  const turn = parseCodexEvents(execution.stdout, tools, threadId, execution.code,
495
- providerModel(provider), [...invocation.providerSecrets, authToken, ...serverSecrets]);
511
+ providerModel(provider), [...invocation.providerSecrets, authToken, ...serverSecrets],
512
+ execution.providerFailureCategory);
496
513
  threadId = turn.threadId;
497
514
  return { ...turn, elicitations: [] };
498
515
  },
@@ -559,8 +576,10 @@ function runProcess(command, args, options) {
559
576
  return new Promise((resolve) => {
560
577
  const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], ...options });
561
578
  let stdout = "";
579
+ let stderr = Buffer.alloc(0);
562
580
  let timedOut = false;
563
581
  let outputLimitExceeded = false;
582
+ let settled = false;
564
583
  const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, 180_000);
565
584
  timer.unref();
566
585
  child.stdout.on("data", (chunk) => {
@@ -570,18 +589,88 @@ function runProcess(command, args, options) {
570
589
  child.kill("SIGKILL");
571
590
  }
572
591
  });
573
- child.stderr.resume();
574
- child.once("error", (error) => {
592
+ child.stderr.on("data", (chunk) => {
593
+ if (stderr.length >= stderrLimit) return;
594
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
595
+ stderr = Buffer.concat([stderr, bytes.subarray(0, stderrLimit - stderr.length)]);
596
+ });
597
+ const finish = (result) => {
598
+ if (settled) return;
599
+ settled = true;
575
600
  clearTimeout(timer);
576
- resolve({ code: 1, errorCode: error.code, outputLimitExceeded, stdout, timedOut });
601
+ const providerFailureCategory = classifyCodexFailure(stderr.toString("utf8"));
602
+ stderr = Buffer.alloc(0);
603
+ resolve({ ...result, providerFailureCategory });
604
+ };
605
+ child.once("error", (error) => {
606
+ finish({ code: 1, errorCode: error.code, outputLimitExceeded, stdout, timedOut });
577
607
  });
578
608
  child.once("close", (code) => {
579
- clearTimeout(timer);
580
- resolve({ code: code ?? 1, outputLimitExceeded, stdout, timedOut });
609
+ finish({ code: code ?? 1, outputLimitExceeded, stdout, timedOut });
581
610
  });
582
611
  });
583
612
  }
584
613
 
614
+ function classifyCodexFailure(stderr) {
615
+ if (/no space left|disk (?:is )?full|storage (?:capacity|quota)|\benospc\b/i.test(stderr)) {
616
+ return "host-storage";
617
+ }
618
+ if (/not logged in|authenticat|unauthorized|invalid api key|oauth/i.test(stderr)) {
619
+ return "authentication";
620
+ }
621
+ if (/permission denied|operation not permitted|sandbox|\beacces\b|\beperm\b/i.test(stderr)) {
622
+ return "permission-or-sandbox";
623
+ }
624
+ if (/configuration|config(?:uration)? error|unknown (?:feature|field|key)|invalid (?:config|value)/i.test(stderr)) {
625
+ return "configuration";
626
+ }
627
+ if (/connection (?:refused|reset)|network|transport|timed? out|\btls\b|certificate|\bdns\b|\beconn/i.test(stderr)) {
628
+ return "transport";
629
+ }
630
+ if (/provider (?:error|failure|unavailable)|model (?:is )?(?:unavailable|overloaded)|rate limit|service unavailable/i.test(stderr)) {
631
+ return "provider";
632
+ }
633
+ return "unknown";
634
+ }
635
+
636
+ function codexEvidenceError(message, category) {
637
+ return Object.assign(new Error(message), {
638
+ providerFailureCategory: codexFailureCategories.has(category) ? category : "unknown",
639
+ });
640
+ }
641
+
642
+ function codexProcessError(code, category) {
643
+ return codexEvidenceError(`Model command exited ${code}`, category);
644
+ }
645
+
646
+ function forbiddenItemError(items) {
647
+ const counts = {
648
+ commandExecution: 0,
649
+ computerUse: 0,
650
+ fileChange: 0,
651
+ mcpToolCall: 0,
652
+ other: 0,
653
+ webSearch: 0,
654
+ };
655
+ for (const item of items.slice(0, forbiddenItemLimit)) {
656
+ const key = new Map([
657
+ ["command_execution", "commandExecution"],
658
+ ["computer_tool", "computerUse"],
659
+ ["file_change", "fileChange"],
660
+ ["mcp_tool_call", "mcpToolCall"],
661
+ ["web_search", "webSearch"],
662
+ ]).get(item?.type) ?? "other";
663
+ counts[key] += 1;
664
+ }
665
+ return Object.assign(new Error("Model command used a forbidden tool"), {
666
+ forbiddenItemEvidence: Object.freeze({
667
+ counts: Object.freeze(counts),
668
+ inspectedCount: Math.min(items.length, forbiddenItemLimit),
669
+ truncated: items.length > forbiddenItemLimit,
670
+ }),
671
+ });
672
+ }
673
+
585
674
  function modelEnvironment(extra) {
586
675
  return Object.fromEntries(Object.entries({
587
676
  CI: "true",
package/semantic/test.mjs CHANGED
@@ -178,7 +178,7 @@ export async function createConversation(testContext, options) {
178
178
  return;
179
179
  }
180
180
  const trial = state.trials[index];
181
- trial.record.error = safeModelFailure(result.reason);
181
+ recordModelFailure(trial.record, result.reason);
182
182
  if (Array.isArray(result.reason?.elicitations)) trial.record.elicitations = result.reason.elicitations;
183
183
  if (Array.isArray(result.reason?.attemptedToolCalls)) {
184
184
  trial.record.attemptedToolCalls = result.reason.attemptedToolCalls;
@@ -508,9 +508,11 @@ async function judgeTrialMeaning(trial, trialIndex, expected) {
508
508
  record.verdict = verdict;
509
509
  outcomes.push({ failed: !verdict.pass, record });
510
510
  } catch (error) {
511
- record.error = error instanceof SyntaxError || error.message === "Judge returned an invalid verdict"
512
- ? "invalid judge verdict"
513
- : safeModelFailure(error);
511
+ if (error instanceof SyntaxError || error.message === "Judge returned an invalid verdict") {
512
+ record.error = "invalid judge verdict";
513
+ } else {
514
+ recordModelFailure(record, error);
515
+ }
514
516
  outcomes.push({ failed: true, record });
515
517
  }
516
518
  }
@@ -570,6 +572,24 @@ function safeModelFailure(error) {
570
572
  return "model invocation failed";
571
573
  }
572
574
 
575
+ function recordModelFailure(record, error) {
576
+ record.error = safeModelFailure(error);
577
+ const category = error?.providerFailureCategory;
578
+ if ([
579
+ "authentication",
580
+ "configuration",
581
+ "host-storage",
582
+ "permission-or-sandbox",
583
+ "provider",
584
+ "transport",
585
+ "unknown",
586
+ ].includes(category)) record.providerFailureCategory = category;
587
+ const evidence = error?.forbiddenItemEvidence;
588
+ if (evidence && typeof evidence === "object") {
589
+ record.forbiddenItemEvidence = structuredClone(evidence);
590
+ }
591
+ }
592
+
573
593
  async function isolatedModel(provider, prompt, prefix, signal) {
574
594
  const directory = await mkdtemp(join(tmpdir(), prefix));
575
595
  try {