@emseepea/testing 0.24.1 → 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 +4 -2
- package/semantic/case.mjs +3 -0
- package/semantic/evidence.mjs +53 -7
- package/semantic/material.mjs +12 -0
- package/semantic/provider.mjs +33 -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": {
|
|
@@ -58,7 +58,9 @@
|
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@emseepea/server": "0.23.1",
|
|
61
|
-
"@modelcontextprotocol/client": "2.2.0"
|
|
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/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,6 +2,7 @@ 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
|
|
|
7
8
|
export function mcpClientVersion(client) {
|
|
@@ -248,9 +249,9 @@ export function modelInvocation(provider, prompt, directory) {
|
|
|
248
249
|
};
|
|
249
250
|
}
|
|
250
251
|
|
|
251
|
-
export function conversationInvocation(provider, directory, url, tools, authToken, context) {
|
|
252
|
+
export function conversationInvocation(provider, directory, url, tools, authToken, context, resourceCatalogue) {
|
|
252
253
|
if (provider.startsWith("codex-")) {
|
|
253
|
-
return codexInvocation(provider, undefined, directory, { url, tools, authToken, context });
|
|
254
|
+
return codexInvocation(provider, undefined, directory, { url, tools, authToken, context, resourceCatalogue });
|
|
254
255
|
}
|
|
255
256
|
const nativeTools = tools.map(({ name }) => nativeToolName(name));
|
|
256
257
|
const config = nativeTools.length ? {
|
|
@@ -288,9 +289,9 @@ export function conversationInvocation(provider, directory, url, tools, authToke
|
|
|
288
289
|
};
|
|
289
290
|
}
|
|
290
291
|
|
|
291
|
-
export function startModelConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets = []) {
|
|
292
|
+
export function startModelConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets = [], resourceCatalogue) {
|
|
292
293
|
if (provider.startsWith("codex-")) {
|
|
293
|
-
return startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets);
|
|
294
|
+
return startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets, resourceCatalogue);
|
|
294
295
|
}
|
|
295
296
|
signal?.throwIfAborted();
|
|
296
297
|
const invocation = conversationInvocation(provider, directory, url, tools, authToken, context);
|
|
@@ -425,7 +426,7 @@ export async function runModel(provider, prompt, directory, signal) {
|
|
|
425
426
|
}
|
|
426
427
|
|
|
427
428
|
export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, processExitCode = 0,
|
|
428
|
-
selectedModel = "test-model", secrets = [], providerFailureCategory) {
|
|
429
|
+
selectedModel = "test-model", secrets = [], providerFailureCategory, resourceCatalogue) {
|
|
429
430
|
let events;
|
|
430
431
|
try {
|
|
431
432
|
events = typeof stdout === "string"
|
|
@@ -464,17 +465,23 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
|
|
|
464
465
|
|| events.filter(({ type }) => type === "turn.completed").length !== 1) {
|
|
465
466
|
throw new Error("Model command returned an incomplete turn");
|
|
466
467
|
}
|
|
468
|
+
if (resourceCatalogue) assertResourceHelperCollisions(advertisedTools);
|
|
467
469
|
const advertised = new Set(advertisedTools.map(({ name }) => name));
|
|
468
470
|
const completed = events.filter(({ type }) => type === "item.completed").map(({ item }) => item);
|
|
469
471
|
const forbidden = completed.filter((item) => !["agent_message", "reasoning", "mcp_tool_call"].includes(item?.type));
|
|
470
472
|
if (forbidden.length) throw forbiddenItemError(forbidden);
|
|
471
473
|
const toolItems = completed.filter(({ type }) => type === "mcp_tool_call");
|
|
472
|
-
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 }) =>
|
|
473
480
|
server === "codex" && codexEmptyDiscoveryTools.has(tool));
|
|
474
481
|
if (discoveryItems.some((item) => !emptyCodexDiscovery(item))) {
|
|
475
482
|
throw forbiddenItemError(discoveryItems.filter((item) => !emptyCodexDiscovery(item)));
|
|
476
483
|
}
|
|
477
|
-
const applicationToolItems = toolItems.filter((item) => !discoveryItems.includes(item));
|
|
484
|
+
const applicationToolItems = toolItems.filter((item) => !discoveryItems.includes(item) && !resourceItemSet.has(item));
|
|
478
485
|
const calls = applicationToolItems.map((item) => {
|
|
479
486
|
if (item.server !== mcpServerName || !advertised.has(item.tool)
|
|
480
487
|
|| !item.arguments || typeof item.arguments !== "object" || Array.isArray(item.arguments)) {
|
|
@@ -494,17 +501,24 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
|
|
|
494
501
|
answer,
|
|
495
502
|
calls,
|
|
496
503
|
toolResults,
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
+
}),
|
|
503
516
|
models: [selectedModel],
|
|
504
517
|
modelEvidence: "configured",
|
|
505
518
|
turnCount: 1,
|
|
506
|
-
providerTurnCount: calls.length + 1,
|
|
519
|
+
providerTurnCount: calls.length + resourceItems.length + 1,
|
|
507
520
|
providerToolCount: calls.length,
|
|
521
|
+
providerResourceOperationCount: resourceItems.length,
|
|
508
522
|
threadId,
|
|
509
523
|
};
|
|
510
524
|
}
|
|
@@ -528,14 +542,14 @@ function emptyCodexDiscovery(item) {
|
|
|
528
542
|
}
|
|
529
543
|
}
|
|
530
544
|
|
|
531
|
-
function startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets) {
|
|
545
|
+
function startCodexConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets, resourceCatalogue) {
|
|
532
546
|
let threadId;
|
|
533
547
|
let closed = false;
|
|
534
548
|
return Object.freeze({
|
|
535
549
|
async send(prompt, options) {
|
|
536
550
|
if (closed) throw new Error("Model conversation is closed");
|
|
537
551
|
if (options?.elicitations?.length) throw new Error("Codex provider does not support scripted elicitation");
|
|
538
|
-
const invocation = conversationInvocation(provider, directory, url, tools, authToken, context);
|
|
552
|
+
const invocation = conversationInvocation(provider, directory, url, tools, authToken, context, resourceCatalogue);
|
|
539
553
|
const args = threadId
|
|
540
554
|
? ["exec", "resume", threadId,
|
|
541
555
|
...withoutOption(withoutOption(invocation.args.slice(1), "--cd"), "--sandbox")]
|
|
@@ -550,7 +564,7 @@ function startCodexConversation(provider, directory, url, tools, authToken, cont
|
|
|
550
564
|
if (execution.errorCode) throw new Error("Model conversation could not start");
|
|
551
565
|
const turn = parseCodexEvents(execution.stdout, tools, threadId, execution.code,
|
|
552
566
|
providerModel(provider), [...invocation.providerSecrets, authToken, ...serverSecrets],
|
|
553
|
-
execution.providerFailureCategory);
|
|
567
|
+
execution.providerFailureCategory, resourceCatalogue);
|
|
554
568
|
threadId = turn.threadId;
|
|
555
569
|
return { ...turn, elicitations: [] };
|
|
556
570
|
},
|
|
@@ -597,9 +611,9 @@ function codexInvocation(provider, prompt, directory, conversation) {
|
|
|
597
611
|
"-c", "features.unified_exec=false",
|
|
598
612
|
];
|
|
599
613
|
if (conversation?.context) args.push("-c", `developer_instructions=${JSON.stringify(conversation.context)}`);
|
|
600
|
-
if (conversation
|
|
614
|
+
if (conversation && (conversation.tools.length || conversation.resourceCatalogue)) {
|
|
601
615
|
args.push("-c", `mcp_servers.${mcpServerName}.url=${JSON.stringify(conversation.url)}`);
|
|
602
|
-
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))}`);
|
|
603
617
|
args.push("-c", `mcp_servers.${mcpServerName}.default_tools_approval_mode="approve"`);
|
|
604
618
|
for (const { name } of conversation.tools) {
|
|
605
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
|
});
|