@emseepea/testing 0.20.4 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/package.json +2 -2
- package/semantic/cli.mjs +12 -6
- package/semantic/provider.mjs +230 -12
- package/semantic/test.d.mts +2 -0
- package/semantic/test.mjs +27 -4
package/README.md
CHANGED
|
@@ -57,6 +57,29 @@ advertised-tool text, an answer wrapper, or prepared MCP material. Exact tool
|
|
|
57
57
|
assertions come from the provider's native MCP events. Follow-up messages use
|
|
58
58
|
the same conversation.
|
|
59
59
|
|
|
60
|
+
Call `await conversation.fresh()` to discard provider transcript history while
|
|
61
|
+
keeping the same running test application and its saved data. The next `send`
|
|
62
|
+
starts a new provider-native session in each trial.
|
|
63
|
+
|
|
64
|
+
The runner supports `claude-local`, `claude-ci`, `codex-local`, and `codex-ci`.
|
|
65
|
+
Codex providers require an explicit `EMSEEPEA_CODEX_MODEL`. For example:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
EMSEEPEA_CODEX_MODEL=gpt-5.5 emseepea-test --provider codex-local eval
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`codex-local` uses the existing Codex CLI sign-in without copying it.
|
|
72
|
+
`codex-ci` requires `OPENAI_API_KEY` from the CI secret store. Both providers
|
|
73
|
+
run `codex exec --json`, resume the actual Codex session for follow-ups, ignore
|
|
74
|
+
user configuration, and pre-approve only the discovered tools from the test
|
|
75
|
+
server.
|
|
76
|
+
|
|
77
|
+
An isolated command policy denies shell launches and spawned shell environments
|
|
78
|
+
inherit no provider credentials.
|
|
79
|
+
|
|
80
|
+
Codex CLI evidence proves that CLI journey. It does not prove native ChatGPT
|
|
81
|
+
connection, attachment, retrieval, or MCP App rendering behavior.
|
|
82
|
+
|
|
60
83
|
Optional `context` is application context, not test guidance. Use it only when
|
|
61
84
|
the deployed application supplies the same context. Leaving it out is the best
|
|
62
85
|
default for testing whether tool names, descriptions, and schemas stand on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emseepea/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "MCP integration and semantic testing helpers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"test:built": "node --test test/*.test.mjs"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@emseepea/server": "0.22.
|
|
50
|
+
"@emseepea/server": "0.22.1",
|
|
51
51
|
"@modelcontextprotocol/client": "2.0.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/semantic/provider.mjs
CHANGED
|
@@ -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
|
|
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
|
|
24
|
-
const
|
|
25
|
-
|
|
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?.[
|
|
61
|
-
if (usage?.canonicalModel !==
|
|
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?.[
|
|
137
|
-
if (usage?.canonicalModel !==
|
|
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",
|
|
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",
|
|
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
|
|
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
|
+
}
|
package/semantic/test.d.mts
CHANGED
|
@@ -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
|
-
|
|
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",
|