@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 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
- Any non-empty, unrecognised, account-app, plugin, or other foreign call is
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.24.1",
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`);
@@ -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
- && Array.isArray(turn.pathEvidence) && turn.pathEvidence.length === turn.toolCallCount
60
- && turn.pathEvidence.every(({ method, target, requestSha256, responseSha256 }, index) => {
61
- const call = turn.toolCalls[index];
62
- return method === "tools/call" && target === call.name
63
- && requestSha256 === sha256({ method, name: call.name, arguments: call.arguments })
64
- && responseSha256 === sha256(call.result);
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) {
@@ -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 ?? {} };
@@ -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 discoveryItems = toolItems.filter(({ server, tool }) =>
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
- pathEvidence: calls.map((call, index) => ({
498
- method: "tools/call",
499
- target: call.name,
500
- requestSha256: hash({ method: "tools/call", name: call.name, arguments: call.arguments }),
501
- responseSha256: hash(toolResults[index].content),
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?.tools.length) {
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
+ }
@@ -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
- const record = { trial, turns: [] };
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
  });