@emseepea/testing 0.24.1 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -2
- package/chatgpt/run.mjs +28 -13
- package/chatgpt/session.mjs +83 -0
- package/chatgpt/setup.mjs +1 -1
- 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,
|
|
@@ -647,6 +670,63 @@ The command verifies a
|
|
|
647
670
|
signed-in control, not the account's identity; the human confirms that identity.
|
|
648
671
|
If that control changes, pass an account-specific `--authenticated-selector`.
|
|
649
672
|
|
|
673
|
+
### Use an already-authorized browser
|
|
674
|
+
|
|
675
|
+
If the launched browser repeats a security challenge, stop setup. Complete
|
|
676
|
+
login, MFA, CAPTCHA, and consent yourself in an ordinary browser. The setup
|
|
677
|
+
helper keeps Chromium's sandbox enabled, but cannot guarantee that an
|
|
678
|
+
automation-launched browser will be accepted by the login service.
|
|
679
|
+
|
|
680
|
+
An existing Playwright integration can pass its owner-authorized `Page` directly
|
|
681
|
+
to `runNativeChatGptJourney`. The adapter does not need to launch that browser.
|
|
682
|
+
It cannot attach to an arbitrary ordinary Chrome window without a supported
|
|
683
|
+
browser control interface.
|
|
684
|
+
|
|
685
|
+
Chrome versions with GUI remote debugging expose it at
|
|
686
|
+
`chrome://inspect/#remote-debugging`. The browser owner can select "Allow remote
|
|
687
|
+
debugging for this browser instance" and approve the connection in Chrome.
|
|
688
|
+
This needs no command-line browser launch.
|
|
689
|
+
|
|
690
|
+
The runner must still execute on
|
|
691
|
+
the browser's host; enabling this option does not give a cloud runner access
|
|
692
|
+
to another computer. Use a dedicated test browser and disable debugging when
|
|
693
|
+
finished.
|
|
694
|
+
|
|
695
|
+
For a Chromium session that the adopter has already opened and exposed through
|
|
696
|
+
Chrome DevTools Protocol (CDP), the execution command can attach locally:
|
|
697
|
+
|
|
698
|
+
```sh
|
|
699
|
+
npx emseepea-chatgpt --enable-native-chatgpt --scenario ./native-chatgpt.mjs \
|
|
700
|
+
--connect-cdp 'ws://127.0.0.1:9222/devtools/browser/<browser-id>' \
|
|
701
|
+
--browser-owner '<test-account-owner>'
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
This is an alternative to the setup command, not a way to solve a CAPTCHA.
|
|
705
|
+
The browser owner must confirm the signed-in account and complete connection
|
|
706
|
+
consent before attaching. `--browser-owner` must match the scenario's account
|
|
707
|
+
owner. Use a dedicated synthetic-test browser with one browser context.
|
|
708
|
+
|
|
709
|
+
The command accepts only a browser WebSocket on literal `127.0.0.1`, an explicit
|
|
710
|
+
port from 1024 to 65535, and `/devtools/browser` with an optional browser ID.
|
|
711
|
+
Remote hosts,
|
|
712
|
+
credentials, query strings, redirects, HTTP discovery, and profile options are
|
|
713
|
+
rejected.
|
|
714
|
+
|
|
715
|
+
WebSocket messages and the initial send queue are capped at 16 MiB.
|
|
716
|
+
Keep the debugging interface and address private, bound to loopback, and
|
|
717
|
+
available only while testing; CDP grants browser control and is not an account
|
|
718
|
+
authorization mechanism.
|
|
719
|
+
|
|
720
|
+
Attachment opens a separate test tab in the authorized context. It does not
|
|
721
|
+
read or export cookies or storage state, inspect existing tabs, launch Chrome,
|
|
722
|
+
or alter login behavior or the owner's browser defaults.
|
|
723
|
+
|
|
724
|
+
After the scenario's required cleanup, the runner
|
|
725
|
+
closes its tab and disconnects. The owner browser and its existing tabs remain
|
|
726
|
+
open. Missing browser access or incomplete authentication blocks verification.
|
|
727
|
+
|
|
728
|
+
### Connect and run the owned scenario
|
|
729
|
+
|
|
650
730
|
Connect your authorized HTTPS MCP endpoint through ChatGPT's current plugin
|
|
651
731
|
installation flow. Complete account consent yourself. A local server requires
|
|
652
732
|
a supported HTTPS tunnel before ChatGPT can reach it. See the
|
package/chatgpt/run.mjs
CHANGED
|
@@ -4,12 +4,15 @@ import { readFile } from "node:fs/promises";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { attachOwnedBrowser, validateBrowserAttachment } from "./session.mjs";
|
|
7
8
|
|
|
8
9
|
const help = `Usage: emseepea-chatgpt --enable-native-chatgpt --scenario <owned-module.mjs>
|
|
9
10
|
[--profile <dedicated-directory>] [--output <private-evidence.json>]
|
|
11
|
+
Or attach: --connect-cdp <loopback-browser-WebSocket> --browser-owner <owner>
|
|
10
12
|
|
|
11
13
|
The module exports authorization and createJourney({ page, runId }). It owns
|
|
12
|
-
its assertions, server observations, and cleanup. Complete human setup first
|
|
14
|
+
its assertions, server observations, and cleanup. Complete human setup first,
|
|
15
|
+
or explicitly confirm ownership of an already-authorized local CDP browser.
|
|
13
16
|
This command never logs in, grants consent, or exports authentication state.
|
|
14
17
|
`;
|
|
15
18
|
|
|
@@ -21,37 +24,49 @@ async function main() {
|
|
|
21
24
|
for (let index = 0; index < args.length; index += 1) {
|
|
22
25
|
const arg = args[index];
|
|
23
26
|
if (arg === "--enable-native-chatgpt") continue;
|
|
24
|
-
if (!["--scenario", "--profile", "--output"].includes(arg) || values.has(arg) || !args[index + 1] || args[index + 1].startsWith("--")) throw new Error("invalid-run-options");
|
|
27
|
+
if (!["--scenario", "--profile", "--output", "--connect-cdp", "--browser-owner"].includes(arg) || values.has(arg) || !args[index + 1] || args[index + 1].startsWith("--")) throw new Error("invalid-run-options");
|
|
25
28
|
values.set(arg, args[++index]);
|
|
26
29
|
}
|
|
27
30
|
if (!values.get("--scenario")) throw new Error("owned-scenario-required");
|
|
28
31
|
const scenario = await import(pathToFileURL(resolve(values.get("--scenario"))).href);
|
|
29
32
|
if (typeof scenario.createJourney !== "function" || scenario.authorization?.approved !== true || typeof scenario.authorization.accountOwner !== "string") throw new Error("owned-scenario-required");
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const attached = values.has("--connect-cdp");
|
|
34
|
+
if ((attached && values.has("--profile")) || (!attached && values.has("--browser-owner"))) throw new Error("invalid-run-options");
|
|
35
|
+
if (attached) validateBrowserAttachment(values.get("--connect-cdp"), values.get("--browser-owner"), scenario.authorization.accountOwner);
|
|
36
|
+
let profile;
|
|
37
|
+
let record;
|
|
38
|
+
if (!attached) {
|
|
39
|
+
profile = resolve(values.get("--profile") ?? join(homedir(), ".emseepea", "chatgpt-browser"));
|
|
40
|
+
record = await readFile(join(profile, "emseepea-profile.json"), "utf8").then(JSON.parse).catch(() => undefined);
|
|
41
|
+
const ownerSha256 = createHash("sha256").update(scenario.authorization.accountOwner).digest("hex");
|
|
42
|
+
if (record?.schemaVersion !== 1 || record?.humanConfirmed !== true || record?.ownerSha256 !== ownerSha256 || !["chromium", "chrome", "msedge"].includes(record?.channel)) throw new Error("human-setup-required");
|
|
43
|
+
}
|
|
34
44
|
let chromium;
|
|
35
45
|
try { ({ chromium } = await import("playwright")); }
|
|
36
46
|
catch { throw new Error("optional-playwright-dependency-required"); }
|
|
37
47
|
const { runNativeChatGptJourney } = await import("../dist/chatgpt.js");
|
|
38
|
-
let
|
|
39
|
-
|
|
40
|
-
|
|
48
|
+
let session;
|
|
49
|
+
if (attached) session = await attachOwnedBrowser({ chromium, endpoint: values.get("--connect-cdp"), browserOwner: values.get("--browser-owner"), accountOwner: scenario.authorization.accountOwner });
|
|
50
|
+
else {
|
|
51
|
+
let context;
|
|
52
|
+
try { context = await chromium.launchPersistentContext(profile, { headless: false, chromiumSandbox: true, ...(record.channel === "chromium" ? {} : { channel: record.channel }) }); }
|
|
53
|
+
catch { throw new Error("headed-browser-unavailable"); }
|
|
54
|
+
session = { page: context.pages()[0] ?? await context.newPage(), close: () => context.close() };
|
|
55
|
+
}
|
|
41
56
|
try {
|
|
42
|
-
const page =
|
|
43
|
-
await page.goto("https://chatgpt.com/", { waitUntil: "domcontentloaded" });
|
|
57
|
+
const page = session.page;
|
|
58
|
+
if (!attached) await page.goto("https://chatgpt.com/", { waitUntil: "domcontentloaded" });
|
|
44
59
|
const runId = randomUUID();
|
|
45
60
|
const declared = await scenario.createJourney({ page, runId });
|
|
46
61
|
const evidence = await runNativeChatGptJourney({ ...declared, enabled: true, page, runId, authorization: scenario.authorization },
|
|
47
62
|
resolve(values.get("--output") ?? "artifacts/native-chatgpt/evidence.json"));
|
|
48
63
|
console.log(`Native ChatGPT journey ${evidence.status}. Evidence written privately.`);
|
|
49
64
|
if (evidence.status !== "passed") process.exitCode = evidence.status === "failed" ? 1 : 2;
|
|
50
|
-
} finally { await
|
|
65
|
+
} finally { await session.close(); }
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
main().catch((error) => {
|
|
54
|
-
const categories = new Set(["explicit-opt-in-required", "invalid-run-options", "owned-scenario-required", "human-setup-required", "optional-playwright-dependency-required", "headed-browser-unavailable"]);
|
|
69
|
+
const categories = new Set(["explicit-opt-in-required", "invalid-run-options", "owned-scenario-required", "human-setup-required", "optional-playwright-dependency-required", "headed-browser-unavailable", "browser-owner-confirmation-required", "loopback-browser-endpoint-required", "owned-browser-attachment-unavailable"]);
|
|
55
70
|
console.error(`Native ChatGPT journey blocked: ${categories.has(error.message) ? error.message : "journey-unavailable"}`);
|
|
56
71
|
process.exitCode = 2;
|
|
57
72
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The owner exposes this local browser interface; this module never launches,
|
|
2
|
+
// authenticates, exports storage, or bypasses a browser challenge.
|
|
3
|
+
export function validateBrowserAttachment(endpoint, browserOwner, accountOwner) {
|
|
4
|
+
if (typeof accountOwner !== "string" || !accountOwner.trim() || browserOwner !== accountOwner) throw new Error("browser-owner-confirmation-required");
|
|
5
|
+
if (typeof endpoint !== "string" || endpoint.length > 256 || !/^ws:\/\/127\.0\.0\.1:[0-9]+\/devtools\/browser(?:\/[A-Za-z0-9-]+)?$/.test(endpoint)) throw new Error("loopback-browser-endpoint-required");
|
|
6
|
+
let url;
|
|
7
|
+
try { url = new URL(endpoint); }
|
|
8
|
+
catch { throw new Error("loopback-browser-endpoint-required"); }
|
|
9
|
+
if (Number(url.port) < 1024 || Number(url.port) > 65535 || !url.port || url.pathname.length > 150) throw new Error("loopback-browser-endpoint-required");
|
|
10
|
+
return url.href;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function attachOwnedBrowser({ chromium, endpoint, browserOwner, accountOwner }) {
|
|
14
|
+
const address = validateBrowserAttachment(endpoint, browserOwner, accountOwner);
|
|
15
|
+
const transport = browserTransport(address);
|
|
16
|
+
let browser;
|
|
17
|
+
let page;
|
|
18
|
+
try {
|
|
19
|
+
// Supported Playwright transport seam: Node's WebSocket rejects redirects,
|
|
20
|
+
// while Playwright's URL connector follows them. Keep browser defaults.
|
|
21
|
+
transport.open();
|
|
22
|
+
browser = await chromium.connectOverCDP(transport, { timeout: 30_000, noDefaults: true });
|
|
23
|
+
const contexts = browser.contexts();
|
|
24
|
+
if (contexts.length !== 1) throw new Error("owned-browser-context-required");
|
|
25
|
+
// A new tab shares the owner's authorized session without selecting,
|
|
26
|
+
// navigating, closing, or retaining any pre-existing private tab.
|
|
27
|
+
page = await contexts[0].newPage();
|
|
28
|
+
await page.goto("https://chatgpt.com/", { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
29
|
+
return { page, async close() {
|
|
30
|
+
try { await page.close({ runBeforeUnload: false }); }
|
|
31
|
+
finally { await browser.close(); } // CDP-connected close disconnects, not browser termination.
|
|
32
|
+
} };
|
|
33
|
+
} catch {
|
|
34
|
+
try { await page?.close({ runBeforeUnload: false }); } catch { /* redacted */ }
|
|
35
|
+
try { await browser?.close(); } catch { /* redacted */ }
|
|
36
|
+
transport.close();
|
|
37
|
+
throw new Error("owned-browser-attachment-unavailable");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Only JSON framing for Playwright's public ConnectOverCDPTransport interface.
|
|
42
|
+
// Playwright owns all CDP/browser semantics. No messages are retained or logged.
|
|
43
|
+
function browserTransport(address) {
|
|
44
|
+
let socket;
|
|
45
|
+
let ended = false;
|
|
46
|
+
const pending = [];
|
|
47
|
+
let pendingBytes = 0;
|
|
48
|
+
const finish = () => {
|
|
49
|
+
if (ended) return;
|
|
50
|
+
ended = true;
|
|
51
|
+
pending.length = 0;
|
|
52
|
+
pendingBytes = 0;
|
|
53
|
+
transport.onclose?.("browser-connection-closed");
|
|
54
|
+
};
|
|
55
|
+
const transport = {
|
|
56
|
+
open() {
|
|
57
|
+
socket = new WebSocket(address);
|
|
58
|
+
socket.onopen = () => {
|
|
59
|
+
try { for (const message of pending) socket.send(message); pending.length = 0; pendingBytes = 0; }
|
|
60
|
+
catch { transport.close(); finish(); }
|
|
61
|
+
};
|
|
62
|
+
socket.onmessage = (event) => {
|
|
63
|
+
try {
|
|
64
|
+
if (typeof event.data !== "string" || Buffer.byteLength(event.data) > 16 * 1024 * 1024) throw new Error("bounded-message-required");
|
|
65
|
+
transport.onmessage?.(JSON.parse(event.data));
|
|
66
|
+
} catch { transport.close(); finish(); }
|
|
67
|
+
};
|
|
68
|
+
socket.onerror = finish;
|
|
69
|
+
socket.onclose = finish;
|
|
70
|
+
},
|
|
71
|
+
send(message) {
|
|
72
|
+
const encoded = JSON.stringify(message);
|
|
73
|
+
if (ended || Buffer.byteLength(encoded) > 16 * 1024 * 1024) throw new Error("bounded-message-required");
|
|
74
|
+
if (socket.readyState === WebSocket.CONNECTING) {
|
|
75
|
+
if (pending.length >= 64 || pendingBytes + Buffer.byteLength(encoded) > 16 * 1024 * 1024) throw new Error("bounded-message-required");
|
|
76
|
+
pending.push(encoded);
|
|
77
|
+
pendingBytes += Buffer.byteLength(encoded);
|
|
78
|
+
} else socket.send(encoded);
|
|
79
|
+
},
|
|
80
|
+
close() { socket?.close(); },
|
|
81
|
+
};
|
|
82
|
+
return transport;
|
|
83
|
+
}
|
package/chatgpt/setup.mjs
CHANGED
|
@@ -49,7 +49,7 @@ async function main() {
|
|
|
49
49
|
}
|
|
50
50
|
await chmod(profile, 0o700);
|
|
51
51
|
let context;
|
|
52
|
-
try { context = await chromium.launchPersistentContext(profile, { headless: false, ...(channel === "chromium" ? {} : { channel }) }); }
|
|
52
|
+
try { context = await chromium.launchPersistentContext(profile, { headless: false, chromiumSandbox: true, ...(channel === "chromium" ? {} : { channel }) }); }
|
|
53
53
|
catch { throw new Error("headed-browser-unavailable"); }
|
|
54
54
|
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
55
55
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emseepea/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.1",
|
|
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
|
});
|