@emseepea/testing 0.24.0 → 0.25.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 +25 -2
- package/package.json +5 -3
- package/semantic/case.mjs +3 -0
- package/semantic/cli.mjs +3 -4
- package/semantic/evidence.mjs +53 -7
- package/semantic/material.mjs +12 -0
- package/semantic/provider.mjs +40 -19
- package/semantic/resources.mjs +98 -0
- package/semantic/test.d.mts +11 -0
- package/semantic/test.mjs +17 -2
package/README.md
CHANGED
|
@@ -134,12 +134,35 @@ Native-client events are authoritative for the tools that were actually used:
|
|
|
134
134
|
a turn is accepted only when every application MCP call belongs to the
|
|
135
135
|
configured test server and its expected tools.
|
|
136
136
|
|
|
137
|
-
Codex may make its two built-in resource-discovery calls through the CLI's
|
|
137
|
+
With resource capture left off, Codex may make its two built-in resource-discovery calls through the CLI's
|
|
138
138
|
`codex` server. The runner accepts them only when their native results prove the
|
|
139
139
|
resource and template inventories are empty, and excludes them from application
|
|
140
140
|
assertions and raw evidence.
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
For a Codex conversation that reads resources, set `nativeResources: true` in
|
|
143
|
+
`createConversation` options. This option is off by default; other providers
|
|
144
|
+
reject it.
|
|
145
|
+
|
|
146
|
+
The runner obtains a bounded resource catalogue using the conversation's
|
|
147
|
+
authentication and records recognized native resource operations separately
|
|
148
|
+
from application-tool calls. `turn.resourceOperations` contains one operation
|
|
149
|
+
array per trial; `assertNoToolCalls` still refers to application tools.
|
|
150
|
+
Unscoped empty Codex discovery remains excluded from resource evidence.
|
|
151
|
+
|
|
152
|
+
Registered reads retain their actual returned contents and request order in
|
|
153
|
+
the evidence and meaning-judge context. Aggregate contents may have distinct
|
|
154
|
+
URIs, and successful empty reads remain empty. Returned URIs do not authorize
|
|
155
|
+
another read. Failed calls, unknown requested URIs, foreign servers and helper
|
|
156
|
+
names that collide with application tools still fail qualification.
|
|
157
|
+
|
|
158
|
+
The [retained genuine AI proof](https://github.com/emseepea/emseepea/blob/main/docs/reviews/native-resource-evidence-2026-10-13.json) covers a synthetic mixed tool/resource journey
|
|
159
|
+
across resumed and fresh model sessions. It does not qualify general resource-only
|
|
160
|
+
discovery, authenticated HTTP correlation or a production learner journey.
|
|
161
|
+
|
|
162
|
+
Fresh model sessions use the same running test application. This option does
|
|
163
|
+
not recover completed turns from a failed model invocation.
|
|
164
|
+
|
|
165
|
+
Any other non-empty, unrecognised, account-app, plugin, or foreign call is
|
|
143
166
|
rejected without storing raw account metadata.
|
|
144
167
|
|
|
145
168
|
Failed Codex runs retain only a fixed failure category: authentication,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emseepea/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "MCP integration and semantic testing helpers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -57,8 +57,10 @@
|
|
|
57
57
|
"test:llm:built": "emseepea-test eval"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@emseepea/server": "0.23.
|
|
61
|
-
"@modelcontextprotocol/client": "2.2.0"
|
|
60
|
+
"@emseepea/server": "0.23.1",
|
|
61
|
+
"@modelcontextprotocol/client": "2.2.0",
|
|
62
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
63
|
+
"@modelcontextprotocol/core": "2.0.0"
|
|
62
64
|
},
|
|
63
65
|
"devDependencies": {
|
|
64
66
|
"playwright": "1.62.1"
|
package/semantic/case.mjs
CHANGED
|
@@ -9,6 +9,9 @@ export function validateConversationOptions(value) {
|
|
|
9
9
|
if (value.context !== undefined && typeof value.context !== "string") {
|
|
10
10
|
throw new Error("context must be text");
|
|
11
11
|
}
|
|
12
|
+
if (value.nativeResources !== undefined && typeof value.nativeResources !== "boolean") {
|
|
13
|
+
throw new Error("nativeResources must be boolean");
|
|
14
|
+
}
|
|
12
15
|
for (const key of ["authToken", "authTokenEnvironment"]) {
|
|
13
16
|
if (value[key] !== undefined && (typeof value[key] !== "string" || !value[key].trim())) {
|
|
14
17
|
throw new Error(`${key} must be non-empty text`);
|
package/semantic/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { dirname, join, relative, resolve } from "node:path";
|
|
6
6
|
import { validRecord } from "./evidence.mjs";
|
|
7
7
|
import { discoverTests } from "./discover.mjs";
|
|
8
|
-
import { modelVersion, providerModel, providerSettings } from "./provider.mjs";
|
|
8
|
+
import { mcpClientVersion, modelVersion, providerModel, providerSettings } from "./provider.mjs";
|
|
9
9
|
|
|
10
10
|
const paths = [];
|
|
11
11
|
let provider = process.env.EMSEEPEA_EVAL_PROVIDER ?? "claude-local";
|
|
@@ -32,10 +32,9 @@ const evidence = { authoritative: provider === "claude-ci", provider, smoke,
|
|
|
32
32
|
status: "failed", cases: {}, errors: [], startedAt: new Date().toISOString() };
|
|
33
33
|
try {
|
|
34
34
|
const client = JSON.parse(await readFile(new URL("../package.json", import.meta.resolve("@modelcontextprotocol/client")), "utf8"));
|
|
35
|
-
|
|
35
|
+
const clientVersion = mcpClientVersion(client);
|
|
36
36
|
const cliName = provider.startsWith("codex-") ? "codexCli" : "claudeCli";
|
|
37
|
-
evidence.dependencies = { mcpClient:
|
|
38
|
-
if (provider === "claude-ci" && evidence.dependencies.claudeCli !== "2.1.248") throw new Error("Unexpected Claude CLI version");
|
|
37
|
+
evidence.dependencies = { mcpClient: clientVersion, [cliName]: smoke ? "simulated" : await modelVersion(provider) };
|
|
39
38
|
let interrupted = false;
|
|
40
39
|
for (const file of files) {
|
|
41
40
|
const displayFile = relative(process.cwd(), file);
|
package/semantic/evidence.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { resourceHelperOperation } from "./resources.mjs";
|
|
2
3
|
import {
|
|
3
4
|
canonicalToolNames,
|
|
4
5
|
normalizeToolSelectionContract,
|
|
@@ -21,6 +22,7 @@ const sha256 = (value) => createHash("sha256").update(JSON.stringify(value)).dig
|
|
|
21
22
|
export function validRecord(record, evidence) {
|
|
22
23
|
const { authoritative, smoke } = evidence;
|
|
23
24
|
const isHash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
25
|
+
if (record.nativeResources !== undefined && typeof record.nativeResources !== "boolean") return false;
|
|
24
26
|
if (record.status !== "passed" || record.authoritative !== authoritative || record.smoke !== smoke
|
|
25
27
|
|| record.provider !== evidence.provider || record.configuredModel !== evidence.configuredModel
|
|
26
28
|
|| record.modelEvidence !== evidence.modelEvidence
|
|
@@ -56,13 +58,57 @@ export function validRecord(record, evidence) {
|
|
|
56
58
|
&& validNegativeFeedbackAssertion(turn)
|
|
57
59
|
&& validFeedbackDisclosure(turn, record.judgeVerdicts, trial.trial, isHash)
|
|
58
60
|
&& turn.toolCalls.every((call) => Object.hasOwn(call, "result") && typeof call.isError === "boolean")
|
|
59
|
-
&&
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
&& validPathEvidence(turn, record, trial, isHash)));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validPathEvidence(turn, record, trial, isHash) {
|
|
65
|
+
const operations = turn.resourceOperations ?? [];
|
|
66
|
+
if (!Array.isArray(operations)) return false;
|
|
67
|
+
if (record.nativeResources !== true) {
|
|
68
|
+
if (operations.length) return false;
|
|
69
|
+
} else {
|
|
70
|
+
const catalogue = trial.resourceCatalogue;
|
|
71
|
+
if (!record.provider.startsWith("codex-") || !catalogue
|
|
72
|
+
|| !Array.isArray(catalogue.resources) || !Array.isArray(catalogue.resourceTemplates)
|
|
73
|
+
|| catalogue.resources.length > 4096 || catalogue.resourceTemplates.length > 4096
|
|
74
|
+
|| !catalogue.resources.every(entry => entry && typeof entry.uri === "string" && entry.uri.trim())
|
|
75
|
+
|| !catalogue.resourceTemplates.every(entry => entry && typeof entry.uriTemplate === "string" && entry.uriTemplate.trim())
|
|
76
|
+
|| Buffer.byteLength(JSON.stringify(catalogue)) > 2 * 1_048_576
|
|
77
|
+
|| !isHash(turn.resourceCatalogueSha256) || turn.resourceCatalogueSha256 !== sha256(catalogue)
|
|
78
|
+
|| turn.answerProviderResourceOperationCount !== operations.length
|
|
79
|
+
|| turn.answerProviderTurnCount !== turn.toolCallCount + operations.length + 1
|
|
80
|
+
|| !isHash(turn.materialSha256) || turn.materialSha256 !== sha256(turn.pathEvidence)) return false;
|
|
81
|
+
const helpers = { "resources/read": "read_mcp_resource", "resources/list": "list_mcp_resources",
|
|
82
|
+
"resources/templates/list": "list_mcp_resource_templates" };
|
|
83
|
+
try {
|
|
84
|
+
for (const operation of operations) {
|
|
85
|
+
if (!operation || operation.source !== "provider-native-helper"
|
|
86
|
+
|| Object.keys(operation).some(key => !["method", "target", "arguments", "result", "source"].includes(key))) return false;
|
|
87
|
+
const checked = resourceHelperOperation({ server: "emseepea_eval", status: "completed",
|
|
88
|
+
tool: helpers[operation.method], arguments: operation.arguments,
|
|
89
|
+
result: { content: [{ type: "text", text: JSON.stringify(operation.result) }], structured_content: null } }, catalogue);
|
|
90
|
+
if (!checked || checked.method !== operation.method || checked.target !== operation.target) return false;
|
|
91
|
+
}
|
|
92
|
+
} catch { return false; }
|
|
93
|
+
}
|
|
94
|
+
if (!Array.isArray(turn.pathEvidence) || turn.pathEvidence.length !== turn.toolCallCount + operations.length) return false;
|
|
95
|
+
let toolIndex = 0, resourceIndex = 0;
|
|
96
|
+
for (const path of turn.pathEvidence) {
|
|
97
|
+
if (!path || typeof path !== "object" || Array.isArray(path)) return false;
|
|
98
|
+
if (path.method === "tools/call") {
|
|
99
|
+
const call = turn.toolCalls[toolIndex++];
|
|
100
|
+
if (!call || path.target !== call.name
|
|
101
|
+
|| path.requestSha256 !== sha256({ method: path.method, name: call.name, arguments: call.arguments })
|
|
102
|
+
|| path.responseSha256 !== sha256(call.result)) return false;
|
|
103
|
+
} else {
|
|
104
|
+
const operation = operations[resourceIndex++];
|
|
105
|
+
if (!operation || path.source !== "provider-native-helper" || path.method !== operation.method
|
|
106
|
+
|| path.target !== operation.target || path.requestSha256 !== sha256(operation.arguments)
|
|
107
|
+
|| path.responseSha256 !== sha256(operation.result)) return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return toolIndex === turn.toolCallCount && resourceIndex === operations.length;
|
|
111
|
+
|
|
66
112
|
}
|
|
67
113
|
|
|
68
114
|
function validToolAssertions(turn, isHash) {
|
package/semantic/material.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
|
|
4
|
+
import { collectResourceCatalogue } from "./resources.mjs";
|
|
5
|
+
|
|
4
6
|
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
5
7
|
|
|
6
8
|
const sha256 = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
@@ -115,6 +117,7 @@ export async function listMcpTools(url, testCase, signal) {
|
|
|
115
117
|
signal?.addEventListener("abort", abort, { once: true });
|
|
116
118
|
});
|
|
117
119
|
try {
|
|
120
|
+
if (testCase.nativeResources && !client.getServerCapabilities()?.tools) return [];
|
|
118
121
|
const response = await Promise.race([client.listTools(), cancelled]);
|
|
119
122
|
const tools = response.tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }));
|
|
120
123
|
tools.sort((left, right) => left.name.localeCompare(right.name));
|
|
@@ -128,6 +131,15 @@ export async function listMcpTools(url, testCase, signal) {
|
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
133
|
|
|
134
|
+
export async function listMcpResourceCatalogue(url, testCase, signal) {
|
|
135
|
+
signal?.throwIfAborted();
|
|
136
|
+
const client = await openClient(url, testCase);
|
|
137
|
+
const abort = () => { void client.close().catch(() => {}); };
|
|
138
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
139
|
+
try { return await collectResourceCatalogue(client, signal); }
|
|
140
|
+
finally { signal?.removeEventListener("abort", abort); await client.close(); }
|
|
141
|
+
}
|
|
142
|
+
|
|
131
143
|
function requestFor(operation) {
|
|
132
144
|
if (operation.method === "tools/call") {
|
|
133
145
|
return { method: operation.method, name: operation.name, arguments: operation.arguments ?? {} };
|
package/semantic/provider.mjs
CHANGED
|
@@ -2,8 +2,16 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { assertResourceHelperCollisions, resourceHelperOperation } from "./resources.mjs";
|
|
5
6
|
import { scriptedElicitations } from "./elicitation.mjs";
|
|
6
7
|
|
|
8
|
+
export function mcpClientVersion(client) {
|
|
9
|
+
if (client?.name !== "@modelcontextprotocol/client" || typeof client.version !== "string" || !client.version.trim()) {
|
|
10
|
+
throw new Error("Invalid MCP client provenance");
|
|
11
|
+
}
|
|
12
|
+
return client.version;
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
const claudeModel = "claude-sonnet-4-6";
|
|
8
16
|
const mcpServerName = "emseepea_eval";
|
|
9
17
|
const codexEmptyDiscoveryTools = new Map([
|
|
@@ -241,9 +249,9 @@ export function modelInvocation(provider, prompt, directory) {
|
|
|
241
249
|
};
|
|
242
250
|
}
|
|
243
251
|
|
|
244
|
-
export function conversationInvocation(provider, directory, url, tools, authToken, context) {
|
|
252
|
+
export function conversationInvocation(provider, directory, url, tools, authToken, context, resourceCatalogue) {
|
|
245
253
|
if (provider.startsWith("codex-")) {
|
|
246
|
-
return codexInvocation(provider, undefined, directory, { url, tools, authToken, context });
|
|
254
|
+
return codexInvocation(provider, undefined, directory, { url, tools, authToken, context, resourceCatalogue });
|
|
247
255
|
}
|
|
248
256
|
const nativeTools = tools.map(({ name }) => nativeToolName(name));
|
|
249
257
|
const config = nativeTools.length ? {
|
|
@@ -281,9 +289,9 @@ export function conversationInvocation(provider, directory, url, tools, authToke
|
|
|
281
289
|
};
|
|
282
290
|
}
|
|
283
291
|
|
|
284
|
-
export function startModelConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets = []) {
|
|
292
|
+
export function startModelConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets = [], resourceCatalogue) {
|
|
285
293
|
if (provider.startsWith("codex-")) {
|
|
286
|
-
return startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets);
|
|
294
|
+
return startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets, resourceCatalogue);
|
|
287
295
|
}
|
|
288
296
|
signal?.throwIfAborted();
|
|
289
297
|
const invocation = conversationInvocation(provider, directory, url, tools, authToken, context);
|
|
@@ -418,7 +426,7 @@ export async function runModel(provider, prompt, directory, signal) {
|
|
|
418
426
|
}
|
|
419
427
|
|
|
420
428
|
export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, processExitCode = 0,
|
|
421
|
-
selectedModel = "test-model", secrets = [], providerFailureCategory) {
|
|
429
|
+
selectedModel = "test-model", secrets = [], providerFailureCategory, resourceCatalogue) {
|
|
422
430
|
let events;
|
|
423
431
|
try {
|
|
424
432
|
events = typeof stdout === "string"
|
|
@@ -457,17 +465,23 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
|
|
|
457
465
|
|| events.filter(({ type }) => type === "turn.completed").length !== 1) {
|
|
458
466
|
throw new Error("Model command returned an incomplete turn");
|
|
459
467
|
}
|
|
468
|
+
if (resourceCatalogue) assertResourceHelperCollisions(advertisedTools);
|
|
460
469
|
const advertised = new Set(advertisedTools.map(({ name }) => name));
|
|
461
470
|
const completed = events.filter(({ type }) => type === "item.completed").map(({ item }) => item);
|
|
462
471
|
const forbidden = completed.filter((item) => !["agent_message", "reasoning", "mcp_tool_call"].includes(item?.type));
|
|
463
472
|
if (forbidden.length) throw forbiddenItemError(forbidden);
|
|
464
473
|
const toolItems = completed.filter(({ type }) => type === "mcp_tool_call");
|
|
465
|
-
const
|
|
474
|
+
const resourceItems = resourceCatalogue ? toolItems.flatMap((item) => {
|
|
475
|
+
const operation = resourceHelperOperation(item, resourceCatalogue);
|
|
476
|
+
return operation ? [{ item, operation }] : [];
|
|
477
|
+
}) : [];
|
|
478
|
+
const resourceItemSet = new Set(resourceItems.map(({ item }) => item));
|
|
479
|
+
const discoveryItems = toolItems.filter((item) => !resourceItemSet.has(item)).filter(({ server, tool }) =>
|
|
466
480
|
server === "codex" && codexEmptyDiscoveryTools.has(tool));
|
|
467
481
|
if (discoveryItems.some((item) => !emptyCodexDiscovery(item))) {
|
|
468
482
|
throw forbiddenItemError(discoveryItems.filter((item) => !emptyCodexDiscovery(item)));
|
|
469
483
|
}
|
|
470
|
-
const applicationToolItems = toolItems.filter((item) => !discoveryItems.includes(item));
|
|
484
|
+
const applicationToolItems = toolItems.filter((item) => !discoveryItems.includes(item) && !resourceItemSet.has(item));
|
|
471
485
|
const calls = applicationToolItems.map((item) => {
|
|
472
486
|
if (item.server !== mcpServerName || !advertised.has(item.tool)
|
|
473
487
|
|| !item.arguments || typeof item.arguments !== "object" || Array.isArray(item.arguments)) {
|
|
@@ -487,17 +501,24 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
|
|
|
487
501
|
answer,
|
|
488
502
|
calls,
|
|
489
503
|
toolResults,
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
504
|
+
resourceOperations: resourceItems.map(({ operation }) => ({ ...operation, source: "provider-native-helper" })),
|
|
505
|
+
pathEvidence: toolItems.flatMap((item) => {
|
|
506
|
+
const native = resourceItems.find((entry) => entry.item === item)?.operation;
|
|
507
|
+
if (native) return [{ method: native.method, target: native.target, source: "provider-native-helper",
|
|
508
|
+
requestSha256: hash(native.arguments), responseSha256: hash(native.result) }];
|
|
509
|
+
const index = applicationToolItems.indexOf(item);
|
|
510
|
+
if (index < 0) return [];
|
|
511
|
+
const call = calls[index];
|
|
512
|
+
return [{ method: "tools/call", target: call.name,
|
|
513
|
+
requestSha256: hash({ method: "tools/call", name: call.name, arguments: call.arguments }),
|
|
514
|
+
responseSha256: hash(toolResults[index].content) }];
|
|
515
|
+
}),
|
|
496
516
|
models: [selectedModel],
|
|
497
517
|
modelEvidence: "configured",
|
|
498
518
|
turnCount: 1,
|
|
499
|
-
providerTurnCount: calls.length + 1,
|
|
519
|
+
providerTurnCount: calls.length + resourceItems.length + 1,
|
|
500
520
|
providerToolCount: calls.length,
|
|
521
|
+
providerResourceOperationCount: resourceItems.length,
|
|
501
522
|
threadId,
|
|
502
523
|
};
|
|
503
524
|
}
|
|
@@ -521,14 +542,14 @@ function emptyCodexDiscovery(item) {
|
|
|
521
542
|
}
|
|
522
543
|
}
|
|
523
544
|
|
|
524
|
-
function startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets) {
|
|
545
|
+
function startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets, resourceCatalogue) {
|
|
525
546
|
let threadId;
|
|
526
547
|
let closed = false;
|
|
527
548
|
return Object.freeze({
|
|
528
549
|
async send(prompt, options) {
|
|
529
550
|
if (closed) throw new Error("Model conversation is closed");
|
|
530
551
|
if (options?.elicitations?.length) throw new Error("Codex provider does not support scripted elicitation");
|
|
531
|
-
const invocation = conversationInvocation(provider, directory, url, tools, authToken, context);
|
|
552
|
+
const invocation = conversationInvocation(provider, directory, url, tools, authToken, context, resourceCatalogue);
|
|
532
553
|
const args = threadId
|
|
533
554
|
? ["exec", "resume", threadId,
|
|
534
555
|
...withoutOption(withoutOption(invocation.args.slice(1), "--cd"), "--sandbox")]
|
|
@@ -543,7 +564,7 @@ function startCodexConversation(provider, directory, url, tools, authToken, cont
|
|
|
543
564
|
if (execution.errorCode) throw new Error("Model conversation could not start");
|
|
544
565
|
const turn = parseCodexEvents(execution.stdout, tools, threadId, execution.code,
|
|
545
566
|
providerModel(provider), [...invocation.providerSecrets, authToken, ...serverSecrets],
|
|
546
|
-
execution.providerFailureCategory);
|
|
567
|
+
execution.providerFailureCategory, resourceCatalogue);
|
|
547
568
|
threadId = turn.threadId;
|
|
548
569
|
return { ...turn, elicitations: [] };
|
|
549
570
|
},
|
|
@@ -590,9 +611,9 @@ function codexInvocation(provider, prompt, directory, conversation) {
|
|
|
590
611
|
"-c", "features.unified_exec=false",
|
|
591
612
|
];
|
|
592
613
|
if (conversation?.context) args.push("-c", `developer_instructions=${JSON.stringify(conversation.context)}`);
|
|
593
|
-
if (conversation
|
|
614
|
+
if (conversation && (conversation.tools.length || conversation.resourceCatalogue)) {
|
|
594
615
|
args.push("-c", `mcp_servers.${mcpServerName}.url=${JSON.stringify(conversation.url)}`);
|
|
595
|
-
args.push("-c", `mcp_servers.${mcpServerName}.enabled_tools=${JSON.stringify(conversation.tools.map(({ name }) => name))}`);
|
|
616
|
+
if (conversation.tools.length) args.push("-c", `mcp_servers.${mcpServerName}.enabled_tools=${JSON.stringify(conversation.tools.map(({ name }) => name))}`);
|
|
596
617
|
args.push("-c", `mcp_servers.${mcpServerName}.default_tools_approval_mode="approve"`);
|
|
597
618
|
for (const { name } of conversation.tools) {
|
|
598
619
|
args.push("-c", `mcp_servers.${mcpServerName}.tools.${JSON.stringify(name)}.approval_mode="approve"`);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ReadResourceResultSchema } from "@modelcontextprotocol/core";
|
|
2
|
+
import { ResourceTemplate } from "@modelcontextprotocol/server";
|
|
3
|
+
|
|
4
|
+
export const nativeResourceHelpers = new Set([
|
|
5
|
+
"list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource",
|
|
6
|
+
]);
|
|
7
|
+
const server = "emseepea_eval";
|
|
8
|
+
const limit = 1_048_576;
|
|
9
|
+
|
|
10
|
+
// The inventory is obtained with the same authentication as the conversation.
|
|
11
|
+
// It bounds provider-reported helpers; it is not independent HTTP-hop evidence.
|
|
12
|
+
export async function collectResourceCatalogue(client, signal) {
|
|
13
|
+
signal?.throwIfAborted();
|
|
14
|
+
if (!client.getServerCapabilities()?.resources) return { resources: [], resourceTemplates: [] };
|
|
15
|
+
const collect = async (method, key) => {
|
|
16
|
+
const entries = [];
|
|
17
|
+
const cursors = new Set();
|
|
18
|
+
let cursor;
|
|
19
|
+
let bytes = 0;
|
|
20
|
+
for (let page = 0; page < 32; page += 1) {
|
|
21
|
+
signal?.throwIfAborted();
|
|
22
|
+
const response = await client[method](cursor === undefined ? {} : { cursor }, { signal });
|
|
23
|
+
bytes += Buffer.byteLength(JSON.stringify(response));
|
|
24
|
+
if (bytes > limit || !Array.isArray(response[key])) throw new Error("Resource catalogue exceeds its bounds");
|
|
25
|
+
entries.push(...response[key]);
|
|
26
|
+
if (entries.length > 4096) throw new Error("Resource catalogue exceeds its bounds");
|
|
27
|
+
if (response.nextCursor === undefined) return entries;
|
|
28
|
+
if (typeof response.nextCursor !== "string" || !response.nextCursor || cursors.has(response.nextCursor)) {
|
|
29
|
+
throw new Error("Resource catalogue returned an invalid cursor");
|
|
30
|
+
}
|
|
31
|
+
cursors.add(response.nextCursor);
|
|
32
|
+
cursor = response.nextCursor;
|
|
33
|
+
}
|
|
34
|
+
throw new Error("Resource catalogue exceeds its page bound");
|
|
35
|
+
};
|
|
36
|
+
const resources = await collect("listResources", "resources");
|
|
37
|
+
const resourceTemplates = await collect("listResourceTemplates", "resourceTemplates");
|
|
38
|
+
if (resources.some((entry) => typeof entry.uri !== "string")
|
|
39
|
+
|| resourceTemplates.some((entry) => typeof entry.uriTemplate !== "string")) {
|
|
40
|
+
throw new Error("Resource catalogue contains invalid entries");
|
|
41
|
+
}
|
|
42
|
+
for (const entry of resourceTemplates) new ResourceTemplate(entry.uriTemplate, { list: undefined });
|
|
43
|
+
return { resources, resourceTemplates };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function assertResourceHelperCollisions(tools) {
|
|
47
|
+
if (tools.some(({ name }) => nativeResourceHelpers.has(name))) {
|
|
48
|
+
throw new Error("Native resource helpers collide with an advertised tool");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const object = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
53
|
+
const keys = (value, allowed) => Object.keys(value).every((key) => allowed.includes(key));
|
|
54
|
+
|
|
55
|
+
export function resourceHelperOperation(item, catalogue) {
|
|
56
|
+
if (!catalogue || !nativeResourceHelpers.has(item.tool)) return undefined;
|
|
57
|
+
if (![server, "codex"].includes(item.server) || item.status !== "completed"
|
|
58
|
+
|| !object(item.arguments) || !keys(item.arguments, ["server", "uri"])) return undefined;
|
|
59
|
+
const reading = item.tool === "read_mcp_resource";
|
|
60
|
+
if (reading && (item.arguments.server !== server || typeof item.arguments.uri !== "string")) return undefined;
|
|
61
|
+
if (!reading && (item.arguments.uri !== undefined
|
|
62
|
+
|| (item.arguments.server !== undefined && item.arguments.server !== server))) return undefined;
|
|
63
|
+
if (!object(item.result) || !keys(item.result, ["content", "structured_content"])
|
|
64
|
+
|| item.result.structured_content !== null || !Array.isArray(item.result.content)
|
|
65
|
+
|| item.result.content.length !== 1) return undefined;
|
|
66
|
+
const [content] = item.result.content;
|
|
67
|
+
if (!object(content) || content.type !== "text" || typeof content.text !== "string"
|
|
68
|
+
|| !keys(content, ["type", "text"]) || Buffer.byteLength(content.text) > limit) return undefined;
|
|
69
|
+
let result;
|
|
70
|
+
try { result = JSON.parse(content.text); } catch { return undefined; }
|
|
71
|
+
if (!object(result)) return undefined;
|
|
72
|
+
if (reading) {
|
|
73
|
+
const uri = item.arguments.uri;
|
|
74
|
+
if (!keys(result, ["server", "uri", "contents"]) || result.server !== server || result.uri !== uri
|
|
75
|
+
|| !Array.isArray(result.contents) || !knownUri(uri, catalogue)
|
|
76
|
+
|| !ReadResourceResultSchema.safeParse({ contents: result.contents }).success
|
|
77
|
+
|| result.contents.some((entry) => !object(entry)
|
|
78
|
+
|| (typeof entry.text === "string") === (typeof entry.blob === "string"))) return undefined;
|
|
79
|
+
// Aggregate reads may return distinct content URIs. They are content from
|
|
80
|
+
// this registered request, not new catalogue entries or follow-up authority.
|
|
81
|
+
return { method: "resources/read", target: uri, arguments: item.arguments, result };
|
|
82
|
+
}
|
|
83
|
+
const key = item.tool === "list_mcp_resources" ? "resources" : "resourceTemplates";
|
|
84
|
+
const field = key === "resources" ? "uri" : "uriTemplate";
|
|
85
|
+
if (!keys(result, ["server", key]) || (result.server !== undefined && result.server !== server)
|
|
86
|
+
|| !Array.isArray(result[key])) return undefined;
|
|
87
|
+
if (item.server === "codex" && item.arguments.server === undefined
|
|
88
|
+
&& result.server === undefined && result[key].length === 0) return undefined;
|
|
89
|
+
if (result[key].some((entry) => !object(entry) || entry.server !== server
|
|
90
|
+
|| !catalogue[key].some((known) => known[field] === entry[field]))) return undefined;
|
|
91
|
+
return { method: key === "resources" ? "resources/list" : "resources/templates/list",
|
|
92
|
+
target: server, arguments: item.arguments, result };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function knownUri(uri, catalogue) {
|
|
96
|
+
return catalogue.resources.some((entry) => entry.uri === uri)
|
|
97
|
+
|| catalogue.resourceTemplates.some((entry) => new ResourceTemplate(entry.uriTemplate, { list: undefined }).uriTemplate.match(uri) !== null);
|
|
98
|
+
}
|
package/semantic/test.d.mts
CHANGED
|
@@ -35,11 +35,22 @@ export interface ConversationOptions {
|
|
|
35
35
|
context?: string;
|
|
36
36
|
/** Server environment shared by all trials, or isolated values selected by trial number. */
|
|
37
37
|
environment?: Record<string, string> | ((trial: number) => Record<string, string>);
|
|
38
|
+
/** Opt in to registered native resource-helper evidence. Currently Codex only. */
|
|
39
|
+
nativeResources?: boolean;
|
|
38
40
|
authToken?: string;
|
|
39
41
|
authTokenEnvironment?: string;
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
export interface ResourceOperation {
|
|
45
|
+
readonly method: "resources/list" | "resources/templates/list" | "resources/read";
|
|
46
|
+
readonly target: string;
|
|
47
|
+
readonly source: "provider-native-helper";
|
|
48
|
+
readonly arguments: Readonly<Record<string, unknown>>;
|
|
49
|
+
readonly result: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
export interface ConversationTurn {
|
|
53
|
+
readonly resourceOperations: readonly (readonly ResourceOperation[])[];
|
|
43
54
|
readonly responses: readonly string[];
|
|
44
55
|
readonly toolCalls: readonly (readonly ToolCall[])[];
|
|
45
56
|
readonly toolOutcomes: readonly (readonly ToolOutcome[])[];
|
package/semantic/test.mjs
CHANGED
|
@@ -6,11 +6,13 @@ import { dirname, join, resolve } from "node:path";
|
|
|
6
6
|
import { environmentForTrial, validateConversationOptions } from "./case.mjs";
|
|
7
7
|
import {
|
|
8
8
|
listMcpTools,
|
|
9
|
+
listMcpResourceCatalogue,
|
|
9
10
|
semanticAuthToken,
|
|
10
11
|
startSemanticServer,
|
|
11
12
|
stopSemanticServer,
|
|
12
13
|
} from "./material.mjs";
|
|
13
14
|
import { parseJudgeVerdict, providerModel, providerSettings, runModel, startModelConversation } from "./provider.mjs";
|
|
15
|
+
import { assertResourceHelperCollisions } from "./resources.mjs";
|
|
14
16
|
import {
|
|
15
17
|
canonicalToolNames,
|
|
16
18
|
normalizeToolSelectionContract,
|
|
@@ -46,6 +48,9 @@ export async function createConversation(testContext, options) {
|
|
|
46
48
|
|
|
47
49
|
const provider = process.env.EMSEEPEA_EVAL_PROVIDER ?? "claude-local";
|
|
48
50
|
if (!["claude-local", "claude-ci", "codex-local", "codex-ci"].includes(provider)) throw new Error("Unsupported model provider");
|
|
51
|
+
if (specification.nativeResources && !provider.startsWith("codex-")) {
|
|
52
|
+
throw new Error("nativeResources requires the Codex provider");
|
|
53
|
+
}
|
|
49
54
|
const smoke = process.env.EMSEEPEA_EVAL_SMOKE === "1";
|
|
50
55
|
if (smoke && provider === "claude-ci") throw new Error("Smoke tests cannot qualify a release");
|
|
51
56
|
const file = process.env.EMSEEPEA_TEST_FILE;
|
|
@@ -57,6 +62,7 @@ export async function createConversation(testContext, options) {
|
|
|
57
62
|
name,
|
|
58
63
|
file,
|
|
59
64
|
mode: "conversation",
|
|
65
|
+
...(specification.nativeResources ? { nativeResources: true } : {}),
|
|
60
66
|
authoritative: provider === "claude-ci",
|
|
61
67
|
smoke,
|
|
62
68
|
provider,
|
|
@@ -86,12 +92,15 @@ export async function createConversation(testContext, options) {
|
|
|
86
92
|
}, testContext.signal);
|
|
87
93
|
try {
|
|
88
94
|
const tools = await listMcpTools(running.url, specification, testContext.signal);
|
|
89
|
-
|
|
95
|
+
if (specification.nativeResources) assertResourceHelperCollisions(tools);
|
|
96
|
+
const resourceCatalogue = specification.nativeResources
|
|
97
|
+
? await listMcpResourceCatalogue(running.url, specification, testContext.signal) : undefined;
|
|
98
|
+
const record = { trial, turns: [], ...(resourceCatalogue ? { resourceCatalogue } : {}) };
|
|
90
99
|
const directory = await mkdtemp(join(tmpdir(), "emseepea-conversation-"));
|
|
91
100
|
const serverSecrets = Object.entries(environment ?? {})
|
|
92
101
|
.filter(([key]) => /token|password|secret|credential|api.?key/i.test(key))
|
|
93
102
|
.map(([, value]) => value);
|
|
94
|
-
state.trials.push({ running, tools, record, directory, history: [], model: undefined, serverSecrets, conversation: 1 });
|
|
103
|
+
state.trials.push({ running, tools, resourceCatalogue, record, directory, history: [], model: undefined, serverSecrets, conversation: 1 });
|
|
95
104
|
evidence.answerTrials.push(record);
|
|
96
105
|
} catch (error) {
|
|
97
106
|
await stopSemanticServer(running.child);
|
|
@@ -118,6 +127,7 @@ export async function createConversation(testContext, options) {
|
|
|
118
127
|
specification.context,
|
|
119
128
|
testContext.signal,
|
|
120
129
|
trial.serverSecrets,
|
|
130
|
+
trial.resourceCatalogue,
|
|
121
131
|
);
|
|
122
132
|
const answer = await trial.model.send(prompt, options);
|
|
123
133
|
const calls = answer.calls;
|
|
@@ -129,6 +139,7 @@ export async function createConversation(testContext, options) {
|
|
|
129
139
|
trial.history.push({
|
|
130
140
|
user: prompt,
|
|
131
141
|
toolCalls: recordedToolCalls,
|
|
142
|
+
resourceOperations: answer.resourceOperations ?? [],
|
|
132
143
|
assistant: answer.answer,
|
|
133
144
|
});
|
|
134
145
|
const record = {
|
|
@@ -139,6 +150,9 @@ export async function createConversation(testContext, options) {
|
|
|
139
150
|
response: answer.answer,
|
|
140
151
|
elicitations: answer.elicitations,
|
|
141
152
|
toolCalls: recordedToolCalls,
|
|
153
|
+
resourceOperations: answer.resourceOperations ?? [],
|
|
154
|
+
resourceCatalogueSha256: trial.resourceCatalogue ? hash(JSON.stringify(trial.resourceCatalogue)) : undefined,
|
|
155
|
+
...(trial.resourceCatalogue ? { answerProviderResourceOperationCount: answer.providerResourceOperationCount } : {}),
|
|
142
156
|
toolOutcomesSha256: hash(JSON.stringify(recordedToolCalls)),
|
|
143
157
|
promptSha256: hash(prompt),
|
|
144
158
|
answerSha256: hash(answer.answer),
|
|
@@ -195,6 +209,7 @@ export async function createConversation(testContext, options) {
|
|
|
195
209
|
toolOutcomes: Object.freeze(trials.map(({ record }) => immutableSnapshot(record.toolCalls.map(
|
|
196
210
|
({ name, arguments: args, result, isError }) => ({ name, arguments: args, result, isError }),
|
|
197
211
|
)))),
|
|
212
|
+
resourceOperations: Object.freeze(trials.map(({ record }) => immutableSnapshot(record.resourceOperations))),
|
|
198
213
|
elicitations: Object.freeze(trials.map(({ record }) => Object.freeze(record.elicitations))),
|
|
199
214
|
[privateTurn]: trials,
|
|
200
215
|
});
|