@emseepea/testing 0.20.5 → 0.21.1

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/semantic/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promis
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, join, relative, resolve } from "node:path";
7
7
  import { discoverTests } from "./discover.mjs";
8
- import { modelVersion } from "./provider.mjs";
8
+ import { modelVersion, providerModel, providerSettings } from "./provider.mjs";
9
9
 
10
10
  const negativeFeedbackObservations = new Set([
11
11
  "error",
@@ -32,26 +32,29 @@ for (let i = 2; i < process.argv.length; i += 1) {
32
32
  else if (arg.startsWith("--")) throw new Error(`Unknown option: ${arg}`);
33
33
  else paths.push(arg);
34
34
  }
35
- if (!["claude-local", "claude-ci"].includes(provider)) throw new Error("Unsupported provider");
35
+ if (!["claude-local", "claude-ci", "codex-local", "codex-ci"].includes(provider)) throw new Error("Unsupported provider");
36
36
  if ((smoke && provider === "claude-ci") || (modelCommand && !smoke)) throw new Error("Custom model commands are smoke-only");
37
37
  if (!paths.length) paths.push("eval");
38
38
  const files = await discoverTests(paths);
39
39
  const directory = await mkdtemp(join(tmpdir(), "emseepea-evidence-"));
40
40
  const evidence = { authoritative: provider === "claude-ci", provider, smoke,
41
- model: "claude-sonnet-4-6", semanticRetries: 0, revision: process.env.GITHUB_SHA,
41
+ configuredModel: providerModel(provider), modelEvidence: provider.startsWith("codex-") ? "configured" : "observed",
42
+ settings: providerSettings(provider), semanticRetries: 0, revision: process.env.GITHUB_SHA,
42
43
  status: "failed", cases: {}, errors: [], startedAt: new Date().toISOString() };
43
44
  try {
44
45
  const client = JSON.parse(await readFile(new URL("../package.json", import.meta.resolve("@modelcontextprotocol/client")), "utf8"));
45
46
  if (client.name !== "@modelcontextprotocol/client" || client.version !== "2.0.0") throw new Error("Unexpected MCP client version");
46
- evidence.dependencies = { mcpClient: client.version, claudeCli: smoke ? "simulated" : await modelVersion() };
47
+ const cliName = provider.startsWith("codex-") ? "codexCli" : "claudeCli";
48
+ evidence.dependencies = { mcpClient: client.version, [cliName]: smoke ? "simulated" : await modelVersion(provider) };
47
49
  if (provider === "claude-ci" && evidence.dependencies.claudeCli !== "2.1.248") throw new Error("Unexpected Claude CLI version");
48
50
  let interrupted = false;
49
51
  for (const file of files) {
50
52
  const displayFile = relative(process.cwd(), file);
51
53
  const code = await new Promise((resolveCode) => {
52
54
  const environment = { ...process.env, EMSEEPEA_EVAL_PROVIDER: provider, EMSEEPEA_EVAL_SMOKE: smoke ? "1" : "0",
53
- EMSEEPEA_EVIDENCE_DIR: directory, EMSEEPEA_TEST_FILE: displayFile,
54
- EMSEEPEA_MODEL_COMMAND: modelCommand ? resolve(modelCommand) : "claude" };
55
+ EMSEEPEA_EVIDENCE_DIR: directory, EMSEEPEA_TEST_FILE: displayFile };
56
+ if (modelCommand) environment.EMSEEPEA_MODEL_COMMAND = resolve(modelCommand);
57
+ else delete environment.EMSEEPEA_MODEL_COMMAND;
55
58
  delete environment.NODE_TEST_CONTEXT;
56
59
  const child = spawn(process.execPath, ["--test", "--test-concurrency=1", file], {
57
60
  stdio: "inherit",
@@ -115,6 +118,9 @@ console.log(`Semantic checks ${evidence.status}; evidence: ${output}`);
115
118
  function validRecord(record, authoritative, smoke) {
116
119
  const isHash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
117
120
  if (record.status !== "passed" || record.authoritative !== authoritative || record.smoke !== smoke
121
+ || record.provider !== evidence.provider || record.configuredModel !== evidence.configuredModel
122
+ || record.modelEvidence !== evidence.modelEvidence
123
+ || JSON.stringify(record.settings) !== JSON.stringify(evidence.settings)
118
124
  || record.mode !== "conversation" || record.answerTrials?.length !== 3
119
125
  || !Number.isInteger(record.judgeVerdicts?.length) || record.judgeVerdicts.length < 9
120
126
  || record.judgeVerdicts.length % 3 !== 0
@@ -1,9 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { scriptedElicitations } from "./elicitation.mjs";
5
6
 
6
- const model = "claude-sonnet-4-6";
7
+ const claudeModel = "claude-sonnet-4-6";
7
8
  const mcpServerName = "emseepea_eval";
8
9
  const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
9
10
  const providerErrorMessages = new Map([
@@ -19,13 +20,39 @@ const hasAuthenticationFailure = (events) => events.some((event) => (
19
20
  && typeof event.result === "string" && authenticationFailure.test(event.result))
20
21
  ));
21
22
 
22
- export async function modelVersion() {
23
- const result = await runProcess("claude", ["--version"], { env: modelEnvironment({}) });
24
- const version = result.stdout.trim().match(/^(\d+\.\d+\.\d+)\b/)?.[1];
25
- if (result.code !== 0 || !version) throw new Error("Could not verify Claude CLI version");
23
+ export async function modelVersion(provider = "claude-local") {
24
+ const codex = provider.startsWith("codex-");
25
+ const command = codex
26
+ ? process.env.EMSEEPEA_CODEX_COMMAND ?? process.env.EMSEEPEA_MODEL_COMMAND ?? "codex"
27
+ : process.env.EMSEEPEA_MODEL_COMMAND ?? "claude";
28
+ const result = await runProcess(command, ["--version"], { env: modelEnvironment({}) });
29
+ const version = result.stdout.trim().match(/(?:codex-cli\s+)?(\d+\.\d+\.\d+)\b/)?.[1];
30
+ if (result.code !== 0 || !version) throw new Error(`Could not verify ${codex ? "Codex" : "Claude"} CLI version`);
26
31
  return version;
27
32
  }
28
33
 
34
+ export function providerModel(provider) {
35
+ if (!provider.startsWith("codex-")) return claudeModel;
36
+ const selected = process.env.EMSEEPEA_CODEX_MODEL?.trim();
37
+ if (!selected) throw new Error("Codex model is not configured");
38
+ return selected;
39
+ }
40
+
41
+ export function providerSettings(provider) {
42
+ return provider.startsWith("codex-")
43
+ ? {
44
+ approvalPolicy: "never",
45
+ loginShell: false,
46
+ multiAgent: false,
47
+ sandbox: "read-only",
48
+ shell: "forbidden",
49
+ shellEnvironment: "empty",
50
+ userConfig: "ignored",
51
+ webSearch: false,
52
+ }
53
+ : { effort: "low", maxTurns: 4, permissionMode: "dontAsk", userSettings: false };
54
+ }
55
+
29
56
  export function parseClaudeEvents(stdout, processExitCode = 0) {
30
57
  let events;
31
58
  try {
@@ -57,8 +84,8 @@ export function parseClaudeEvents(stdout, processExitCode = 0) {
57
84
  if (!Number.isInteger(result.num_turns)) throw new Error("Model command returned an invalid turn count");
58
85
  if (result.num_turns !== expectedTurns) throw new Error("Model command used an unexpected number of turns");
59
86
  if ((result.permission_denials?.length ?? 0) > 0) throw new Error("Model command attempted a forbidden action");
60
- const usage = result.modelUsage?.[model];
61
- if (usage?.canonicalModel !== model || usage.provider !== "firstParty") {
87
+ const usage = result.modelUsage?.[claudeModel];
88
+ if (usage?.canonicalModel !== claudeModel || usage.provider !== "firstParty") {
62
89
  throw new Error("Model command did not use the required model");
63
90
  }
64
91
  return {
@@ -133,8 +160,8 @@ export function parseNativeClaudeEvents(stdout, advertisedTools, requireInit = f
133
160
  if (result.num_turns !== toolUses.length + 1) {
134
161
  throw new Error("Model command used an unexpected number of turns");
135
162
  }
136
- const usage = result.modelUsage?.[model];
137
- if (usage?.canonicalModel !== model || usage.provider !== "firstParty") {
163
+ const usage = result.modelUsage?.[claudeModel];
164
+ if (usage?.canonicalModel !== claudeModel || usage.provider !== "firstParty") {
138
165
  throw new Error("Model command did not use the required model");
139
166
  }
140
167
  return {
@@ -167,6 +194,7 @@ export function parseJudgeVerdict(output) {
167
194
  }
168
195
 
169
196
  export function modelInvocation(provider, prompt, directory) {
197
+ if (provider.startsWith("codex-")) return codexInvocation(provider, prompt, directory);
170
198
  const token = provider === "claude-ci" ? process.env.CLAUDE_CODE_OAUTH_TOKEN?.trim() : undefined;
171
199
  if (provider === "claude-ci" && !token) throw new Error("Claude subscription authentication is unavailable");
172
200
  const localHome = provider === "claude-local" ? process.env.HOME : undefined;
@@ -177,7 +205,7 @@ export function modelInvocation(provider, prompt, directory) {
177
205
  command: process.env.EMSEEPEA_MODEL_COMMAND ?? "claude",
178
206
  args: [
179
207
  "--print", prompt,
180
- "--model", model,
208
+ "--model", claudeModel,
181
209
  "--effort", "low",
182
210
  "--max-turns", "4",
183
211
  "--safe-mode",
@@ -200,6 +228,9 @@ export function modelInvocation(provider, prompt, directory) {
200
228
  }
201
229
 
202
230
  export function conversationInvocation(provider, directory, url, tools, authToken, context) {
231
+ if (provider.startsWith("codex-")) {
232
+ return codexInvocation(provider, undefined, directory, { url, tools, authToken, context });
233
+ }
203
234
  const nativeTools = tools.map(({ name }) => nativeToolName(name));
204
235
  const config = nativeTools.length ? {
205
236
  mcpServers: {
@@ -218,7 +249,7 @@ export function conversationInvocation(provider, directory, url, tools, authToke
218
249
  "--input-format", "stream-json",
219
250
  "--output-format", "stream-json",
220
251
  "--verbose",
221
- "--model", model,
252
+ "--model", claudeModel,
222
253
  "--effort", "low",
223
254
  "--max-turns", "4",
224
255
  "--strict-mcp-config",
@@ -238,6 +269,9 @@ export function conversationInvocation(provider, directory, url, tools, authToke
238
269
  }
239
270
 
240
271
  export function startModelConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets = []) {
272
+ if (provider.startsWith("codex-")) {
273
+ return startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets);
274
+ }
241
275
  signal?.throwIfAborted();
242
276
  const invocation = conversationInvocation(provider, directory, url, tools, authToken, context);
243
277
  const child = spawn(invocation.command, invocation.args, {
@@ -362,7 +396,170 @@ export async function runModel(provider, prompt, directory, signal) {
362
396
  if (execution.errorCode === "ABORT_ERR") throw new Error("Model command was cancelled");
363
397
  if (execution.errorCode) throw new Error("Model command could not start");
364
398
  if (execution.code !== 0 && !execution.stdout) throw new Error(`Model command exited ${execution.code}`);
365
- return parseClaudeEvents(execution.stdout, execution.code);
399
+ return provider.startsWith("codex-")
400
+ ? parseCodexEvents(execution.stdout, [], undefined, execution.code,
401
+ providerModel(provider), invocation.providerSecrets)
402
+ : parseClaudeEvents(execution.stdout, execution.code);
403
+ }
404
+
405
+ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, processExitCode = 0, selectedModel = "test-model", secrets = []) {
406
+ let events;
407
+ try {
408
+ events = typeof stdout === "string"
409
+ ? stdout.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line))
410
+ : stdout;
411
+ } catch {
412
+ throw new Error("Model command returned invalid event data");
413
+ }
414
+ if (!Array.isArray(events)) throw new Error("Model command returned invalid event data");
415
+ const serialized = JSON.stringify(events);
416
+ if (secrets.some((secret) => typeof secret === "string" && secret.length > 0 && serialized.includes(secret))) {
417
+ throw new Error("Model command exposed a configured secret");
418
+ }
419
+ const authentication = /(?:not logged in|authentication|unauthorized|invalid api key|oauth)/i;
420
+ const errorText = events.flatMap((event) => [event.message, event.error?.message, event.item?.message])
421
+ .filter((value) => typeof value === "string").join("\n");
422
+ if (authentication.test(errorText)) throw new Error("Model command is not signed in");
423
+ const failedTool = events.some(({ item }) => item?.type === "mcp_tool_call"
424
+ && (item.status === "failed" || item.result?.isError === true || item.result?.is_error === true));
425
+ if (failedTool) throw new Error("Model command reported a failed MCP tool call");
426
+ if (processExitCode !== 0) throw new Error(`Model command exited ${processExitCode}`);
427
+ if (events.some(({ type }) => type === "turn.failed" || type === "error")) {
428
+ throw new Error("Model command reported an error");
429
+ }
430
+ const started = events.filter(({ type }) => type === "thread.started");
431
+ const threadId = started[0]?.thread_id;
432
+ if (started.length !== 1 || typeof threadId !== "string" || !threadId) {
433
+ throw new Error("Model command omitted its session event");
434
+ }
435
+ if (expectedThreadId && threadId !== expectedThreadId) {
436
+ throw new Error("Model command did not resume the required session");
437
+ }
438
+ if (events.filter(({ type }) => type === "turn.started").length !== 1
439
+ || events.filter(({ type }) => type === "turn.completed").length !== 1) {
440
+ throw new Error("Model command returned an incomplete turn");
441
+ }
442
+ const advertised = new Set(advertisedTools.map(({ name }) => name));
443
+ const completed = events.filter(({ type }) => type === "item.completed").map(({ item }) => item);
444
+ const forbidden = completed.filter((item) => !["agent_message", "reasoning", "mcp_tool_call"].includes(item?.type));
445
+ if (forbidden.length) throw new Error("Model command used a forbidden tool");
446
+ const toolItems = completed.filter(({ type }) => type === "mcp_tool_call");
447
+ if (toolItems.length > 3) throw new Error("Model command used more than three tools");
448
+ const calls = toolItems.map((item) => {
449
+ if (item.server !== mcpServerName || !advertised.has(item.tool)
450
+ || !item.arguments || typeof item.arguments !== "object" || Array.isArray(item.arguments)) {
451
+ throw new Error("Model command used a forbidden tool");
452
+ }
453
+ if (!item.result || typeof item.result !== "object" || item.status === "failed"
454
+ || item.result.isError === true || item.result.is_error === true) {
455
+ throw new Error("Model command reported a failed MCP tool call");
456
+ }
457
+ return { name: item.tool, arguments: item.arguments };
458
+ });
459
+ const messages = completed.filter(({ type }) => type === "agent_message");
460
+ const answer = messages.at(-1)?.text;
461
+ if (typeof answer !== "string") throw new Error("Model command returned no answer");
462
+ const toolResults = toolItems.map(({ result }) => ({ content: result.content ?? result, isError: false }));
463
+ return {
464
+ answer,
465
+ calls,
466
+ toolResults,
467
+ pathEvidence: calls.map((call, index) => ({
468
+ method: "tools/call",
469
+ target: call.name,
470
+ requestSha256: hash({ method: "tools/call", name: call.name, arguments: call.arguments }),
471
+ responseSha256: hash(toolResults[index].content),
472
+ })),
473
+ models: [selectedModel],
474
+ modelEvidence: "configured",
475
+ turnCount: 1,
476
+ providerTurnCount: calls.length + 1,
477
+ providerToolCount: calls.length,
478
+ threadId,
479
+ };
480
+ }
481
+
482
+ function startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets) {
483
+ let threadId;
484
+ let closed = false;
485
+ return Object.freeze({
486
+ async send(prompt, options) {
487
+ if (closed) throw new Error("Model conversation is closed");
488
+ if (options?.elicitations?.length) throw new Error("Codex provider does not support scripted elicitation");
489
+ const invocation = conversationInvocation(provider, directory, url, tools, authToken, context);
490
+ const args = threadId
491
+ ? ["exec", "resume", threadId,
492
+ ...withoutOption(withoutOption(invocation.args.slice(1), "--cd"), "--sandbox")]
493
+ : invocation.args;
494
+ args.push(prompt);
495
+ const execution = await runProcess(invocation.command, args, {
496
+ cwd: invocation.cwd, env: invocation.env, signal, killSignal: "SIGKILL",
497
+ });
498
+ if (execution.timedOut) throw new Error("Model command timed out");
499
+ if (execution.outputLimitExceeded) throw new Error("Model command output exceeded its limit");
500
+ if (execution.errorCode === "ABORT_ERR") throw new Error("Model command was cancelled");
501
+ if (execution.errorCode) throw new Error("Model conversation could not start");
502
+ const turn = parseCodexEvents(execution.stdout, tools, threadId, execution.code,
503
+ providerModel(provider), [...invocation.providerSecrets, authToken, ...serverSecrets]);
504
+ threadId = turn.threadId;
505
+ return { ...turn, elicitations: [] };
506
+ },
507
+ async close() { closed = true; },
508
+ });
509
+ }
510
+
511
+ function codexInvocation(provider, prompt, directory, conversation) {
512
+ const model = providerModel(provider);
513
+ const sourceHome = process.env.CODEX_HOME ?? (process.env.HOME ? join(process.env.HOME, ".codex") : undefined);
514
+ const sourceAuth = sourceHome ? join(sourceHome, "auth.json") : undefined;
515
+ const apiKey = process.env.OPENAI_API_KEY?.trim();
516
+ if (provider === "codex-ci" && !apiKey) throw new Error("Codex CI authentication is unavailable");
517
+ if (provider === "codex-local" && !apiKey && (!sourceAuth || !existsSync(sourceAuth))) {
518
+ throw new Error("Codex authentication is unavailable");
519
+ }
520
+ const codexHome = join(directory, "codex-home");
521
+ mkdirSync(codexHome, { recursive: true, mode: 0o700 });
522
+ const rules = join(codexHome, "rules");
523
+ mkdirSync(rules, { recursive: true, mode: 0o700 });
524
+ writeFileSync(join(rules, "default.rules"), `prefix_rule(
525
+ pattern = [["bash", "zsh", "sh", "/bin/bash", "/bin/zsh", "/bin/sh", "/usr/bin/bash", "/usr/bin/zsh", "/usr/bin/sh"], ["-c", "-lc"]],
526
+ decision = "forbidden",
527
+ justification = "Only the configured MCP tools are allowed.",
528
+ )\n`, { mode: 0o600 });
529
+ const isolatedAuth = join(codexHome, "auth.json");
530
+ if (!apiKey && sourceAuth && !existsSync(isolatedAuth)) symlinkSync(sourceAuth, isolatedAuth);
531
+ const authSecrets = !apiKey && sourceAuth ? credentialValues(sourceAuth) : [];
532
+ const args = [
533
+ "exec", "--json", "--ignore-user-config", "--skip-git-repo-check", "--sandbox", "read-only",
534
+ "--model", model, "--cd", directory,
535
+ "-c", 'approval_policy="never"',
536
+ "-c", 'web_search="disabled"',
537
+ "-c", 'shell_environment_policy.inherit="none"',
538
+ "-c", "allow_login_shell=false",
539
+ "-c", "features.multi_agent=false",
540
+ "-c", "features.unified_exec=false",
541
+ ];
542
+ if (conversation?.context) args.push("-c", `developer_instructions=${JSON.stringify(conversation.context)}`);
543
+ if (conversation?.tools.length) {
544
+ args.push("-c", `mcp_servers.${mcpServerName}.url=${JSON.stringify(conversation.url)}`);
545
+ args.push("-c", `mcp_servers.${mcpServerName}.enabled_tools=${JSON.stringify(conversation.tools.map(({ name }) => name))}`);
546
+ args.push("-c", `mcp_servers.${mcpServerName}.default_tools_approval_mode="approve"`);
547
+ for (const { name } of conversation.tools) {
548
+ args.push("-c", `mcp_servers.${mcpServerName}.tools.${JSON.stringify(name)}.approval_mode="approve"`);
549
+ }
550
+ if (conversation.authToken) {
551
+ args.push("-c", `mcp_servers.${mcpServerName}.bearer_token_env_var="EMSEEPEA_SEMANTIC_MCP_TOKEN"`);
552
+ }
553
+ }
554
+ if (prompt !== undefined) args.push(prompt);
555
+ return {
556
+ command: process.env.EMSEEPEA_CODEX_COMMAND ?? process.env.EMSEEPEA_MODEL_COMMAND ?? "codex",
557
+ args,
558
+ cwd: directory,
559
+ providerSecrets: [apiKey, ...authSecrets].filter(Boolean),
560
+ env: modelEnvironment({ CODEX_HOME: codexHome, ...(apiKey ? { OPENAI_API_KEY: apiKey } : {}),
561
+ ...(conversation?.authToken ? { EMSEEPEA_SEMANTIC_MCP_TOKEN: conversation.authToken } : {}) }),
562
+ };
366
563
  }
367
564
 
368
565
  function runProcess(command, args, options) {
@@ -410,3 +607,24 @@ function modelEnvironment(extra) {
410
607
  function nativeToolName(name) {
411
608
  return `mcp__${mcpServerName}__${name}`;
412
609
  }
610
+
611
+ function withoutOption(args, name) {
612
+ const index = args.indexOf(name);
613
+ return index < 0 ? [...args] : [...args.slice(0, index), ...args.slice(index + 2)];
614
+ }
615
+
616
+ function credentialValues(path) {
617
+ try {
618
+ const values = [];
619
+ const visit = (value, key = "") => {
620
+ if (typeof value === "string" && /(?:token|secret|key|credential)/i.test(key)) values.push(value);
621
+ else if (value && typeof value === "object") {
622
+ for (const [childKey, child] of Object.entries(value)) visit(child, childKey);
623
+ }
624
+ };
625
+ visit(JSON.parse(readFileSync(path, "utf8")));
626
+ return values;
627
+ } catch {
628
+ return [];
629
+ }
630
+ }
@@ -38,6 +38,8 @@ export interface ElicitationEvidence {
38
38
  export interface SemanticConversation {
39
39
  /** Sends this exact user message through the same provider-native MCP conversation. */
40
40
  send(prompt: string, options?: { elicitations: readonly ScriptedElicitation[] }): Promise<ConversationTurn>;
41
+ /** Starts a provider-native conversation with no transcript history against the same running test application. */
42
+ fresh(): Promise<void>;
41
43
  }
42
44
 
43
45
  export function createConversation(
package/semantic/test.mjs CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  startSemanticServer,
11
11
  stopSemanticServer,
12
12
  } from "./material.mjs";
13
- import { parseJudgeVerdict, runModel, startModelConversation } from "./provider.mjs";
13
+ import { parseJudgeVerdict, providerModel, providerSettings, runModel, startModelConversation } from "./provider.mjs";
14
14
 
15
15
  const hash = (value) => createHash("sha256").update(value).digest("hex");
16
16
  const names = new Set();
@@ -28,7 +28,7 @@ export async function createConversation(testContext, options) {
28
28
  names.add(key);
29
29
 
30
30
  const provider = process.env.EMSEEPEA_EVAL_PROVIDER ?? "claude-local";
31
- if (!["claude-local", "claude-ci"].includes(provider)) throw new Error("Unsupported model provider");
31
+ if (!["claude-local", "claude-ci", "codex-local", "codex-ci"].includes(provider)) throw new Error("Unsupported model provider");
32
32
  const smoke = process.env.EMSEEPEA_EVAL_SMOKE === "1";
33
33
  if (smoke && provider === "claude-ci") throw new Error("Smoke tests cannot qualify a release");
34
34
  const file = process.env.EMSEEPEA_TEST_FILE;
@@ -43,7 +43,9 @@ export async function createConversation(testContext, options) {
43
43
  authoritative: provider === "claude-ci",
44
44
  smoke,
45
45
  provider,
46
- model: "claude-sonnet-4-6",
46
+ configuredModel: providerModel(provider),
47
+ modelEvidence: provider.startsWith("codex-") ? "configured" : "observed",
48
+ settings: providerSettings(provider),
47
49
  semanticRetries: 0,
48
50
  status: "failed",
49
51
  caseSha256: hash(JSON.stringify({
@@ -72,7 +74,7 @@ export async function createConversation(testContext, options) {
72
74
  const serverSecrets = Object.entries(environment ?? {})
73
75
  .filter(([key]) => /token|password|secret|credential|api.?key/i.test(key))
74
76
  .map(([, value]) => value);
75
- state.trials.push({ running, tools, record, directory, history: [], model: undefined, serverSecrets });
77
+ state.trials.push({ running, tools, record, directory, history: [], model: undefined, serverSecrets, conversation: 1 });
76
78
  evidence.answerTrials.push(record);
77
79
  } catch (error) {
78
80
  await stopSemanticServer(running.child);
@@ -113,6 +115,7 @@ export async function createConversation(testContext, options) {
113
115
  });
114
116
  const record = {
115
117
  turn: trial.record.turns.length + 1,
118
+ conversation: trial.conversation,
116
119
  interactionMode: "native-mcp",
117
120
  prompt,
118
121
  response: answer.answer,
@@ -125,6 +128,7 @@ export async function createConversation(testContext, options) {
125
128
  promptSha256: hash(prompt),
126
129
  answerSha256: hash(answer.answer),
127
130
  answerModels: answer.models,
131
+ answerModelEvidence: answer.modelEvidence ?? "observed",
128
132
  answerTurnCount: answer.turnCount,
129
133
  answerProviderTurnCount: answer.providerTurnCount,
130
134
  answerProviderToolCount: answer.providerToolCount,
@@ -176,6 +180,15 @@ export async function createConversation(testContext, options) {
176
180
  [privateTurn]: trials,
177
181
  });
178
182
  },
183
+ async fresh() {
184
+ ensureOpen(state);
185
+ await Promise.all(state.trials.map(async (trial) => {
186
+ await trial.model?.close();
187
+ trial.model = undefined;
188
+ trial.history = [];
189
+ trial.conversation += 1;
190
+ }));
191
+ },
179
192
  });
180
193
  }
181
194
 
@@ -428,6 +441,7 @@ async function judgeTrialMeaning(trial, trialIndex, expected) {
428
441
  const response = await isolatedModel(trial.provider, request, "emseepea-judge-", trial.signal);
429
442
  Object.assign(record, {
430
443
  models: response.models,
444
+ modelEvidence: response.modelEvidence ?? "observed",
431
445
  turnCount: response.turnCount,
432
446
  providerTurnCount: response.providerTurnCount,
433
447
  providerToolCount: response.providerToolCount,
@@ -453,6 +467,10 @@ function safeModelFailure(error) {
453
467
  const message = error instanceof Error ? error.message : "";
454
468
  const safeMessages = new Set([
455
469
  "Claude subscription authentication is unavailable",
470
+ "Codex authentication is unavailable",
471
+ "Codex CI authentication is unavailable",
472
+ "Codex model is not configured",
473
+ "Codex provider does not support scripted elicitation",
456
474
  "Model command attempted a forbidden action",
457
475
  "Model command could not start",
458
476
  "Model command exceeded its budget",
@@ -460,6 +478,7 @@ function safeModelFailure(error) {
460
478
  "Model command failed during execution",
461
479
  "Model command is not signed in",
462
480
  "Model command omitted its result event",
481
+ "Model command omitted its session event",
463
482
  "Model command output exceeded its limit",
464
483
  "Model command reported an error",
465
484
  "Model command returned a non-text answer",
@@ -473,6 +492,10 @@ function safeModelFailure(error) {
473
492
  "Model command omitted MCP initialization evidence",
474
493
  "Model command omitted an MCP tool result",
475
494
  "Model command returned no answer",
495
+ "Model command returned an incomplete turn",
496
+ "Model command did not resume the required session",
497
+ "Model command reported a failed MCP tool call",
498
+ "Model command exposed a configured secret",
476
499
  "Model command used more than three tools",
477
500
  "Model conversation already has a pending turn",
478
501
  "Model conversation could not start",