@emseepea/testing 0.22.0 → 0.23.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 +34 -1
- package/package.json +2 -2
- package/semantic/evidence.mjs +53 -7
- package/semantic/provider.mjs +1 -0
- package/semantic/selection.mjs +113 -0
- package/semantic/test.d.mts +27 -0
- package/semantic/test.mjs +95 -10
package/README.md
CHANGED
|
@@ -29,6 +29,39 @@ sequence in the listed order and count. The evidence retains all alternatives
|
|
|
29
29
|
and the matched sequence; arguments, meaning, confirmation and feedback still
|
|
30
30
|
need their own assertions.
|
|
31
31
|
|
|
32
|
+
Use `assertToolSelection` when valid tool order or recovery paths can vary, but
|
|
33
|
+
the permitted tools and call counts are bounded. Its tool-selection contract
|
|
34
|
+
lists required, allowed, and explicitly forbidden tools. Any advertised tool
|
|
35
|
+
that is not required or allowed is rejected.
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
assertToolSelection(turn, {
|
|
39
|
+
required: [
|
|
40
|
+
{ name: "list-candidates", minCalls: 1, maxCalls: 1 },
|
|
41
|
+
{ name: "commit-selection", minCalls: 1, maxCalls: 1 },
|
|
42
|
+
],
|
|
43
|
+
allowed: [{ name: "lookup-candidate", maxCalls: 2 }],
|
|
44
|
+
forbidden: [],
|
|
45
|
+
totalCalls: { minCalls: 2, maxCalls: 4 },
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The finite maximums are checked after the turn. They do not stop tool calls or
|
|
50
|
+
truncate evidence.
|
|
51
|
+
|
|
52
|
+
A passing selection assertion proves only that every
|
|
53
|
+
recorded call satisfied the declared tool and count rules. It does not prove
|
|
54
|
+
argument correctness, persisted effects, response meaning, or recovery from an
|
|
55
|
+
error. Keep an exact assertion when order, count, and arguments are
|
|
56
|
+
deterministic, and add the separate assertions your application outcome needs.
|
|
57
|
+
|
|
58
|
+
Each turn also exposes `toolOutcomes`, aligned by trial and call order with
|
|
59
|
+
`toolCalls`. Each outcome is a read-only snapshot of what the model saw. It
|
|
60
|
+
contains only `name`, `arguments`, the model-visible `result`, and `isError`.
|
|
61
|
+
Use it to check an expected error and the later calls that followed the error.
|
|
62
|
+
A later non-error result does not by itself prove recovery or a persisted
|
|
63
|
+
effect.
|
|
64
|
+
|
|
32
65
|
For a successful turn that advertises feedback, use
|
|
33
66
|
`assertToolCallsWithOptionalFeedback`. It requires the exact ordered primary
|
|
34
67
|
calls and arguments, then allows no feedback call or one trailing
|
|
@@ -60,7 +93,7 @@ the same conversation.
|
|
|
60
93
|
Native conversations have no framework tool-call or native provider-turn count
|
|
61
94
|
limit. Calls must still use advertised MCP tools. The per-send timeout,
|
|
62
95
|
cancellation, output checks, model checks, and isolated test environment remain
|
|
63
|
-
effective.
|
|
96
|
+
effective. Application assertions decide which calls and results are
|
|
64
97
|
acceptable. Tool-free judge invocations retain their existing limits.
|
|
65
98
|
|
|
66
99
|
Call `await conversation.fresh()` to discard provider transcript history while
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emseepea/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"description": "MCP integration and semantic testing helpers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"README.md"
|
|
44
44
|
],
|
|
45
45
|
"scripts": {
|
|
46
|
-
"build": "tsc -p tsconfig.json",
|
|
46
|
+
"build": "tsc -p tsconfig.json && tsc -p tsconfig.type-tests.json",
|
|
47
47
|
"prepack": "npm run build",
|
|
48
48
|
"test": "npm run build && npm run test:built",
|
|
49
49
|
"test:built": "node --test test/*.test.mjs",
|
package/semantic/evidence.mjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
canonicalToolNames,
|
|
4
|
+
normalizeToolSelectionContract,
|
|
5
|
+
toolSelectionContractSatisfied,
|
|
6
|
+
} from "./selection.mjs";
|
|
2
7
|
|
|
3
8
|
const negativeFeedbackObservations = new Set([
|
|
4
9
|
"error",
|
|
@@ -11,6 +16,8 @@ const negativeFeedbackObservations = new Set([
|
|
|
11
16
|
"capability_mismatch",
|
|
12
17
|
]);
|
|
13
18
|
|
|
19
|
+
const sha256 = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
20
|
+
|
|
14
21
|
export function validRecord(record, evidence) {
|
|
15
22
|
const { authoritative, smoke } = evidence;
|
|
16
23
|
const isHash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
@@ -39,6 +46,10 @@ export function validRecord(record, evidence) {
|
|
|
39
46
|
&& isHash(turn.promptSha256) && isHash(turn.answerSha256)
|
|
40
47
|
&& isHash(turn.advertisedToolsSha256) && isHash(turn.selectedCallsSha256)
|
|
41
48
|
&& Array.isArray(turn.toolCalls) && turn.toolCalls.length === turn.toolCallCount
|
|
49
|
+
&& turn.selectedCallsSha256 === sha256(turn.toolCalls.map(({ name, arguments: args }) => ({
|
|
50
|
+
name,
|
|
51
|
+
arguments: args,
|
|
52
|
+
})))
|
|
42
53
|
&& JSON.stringify(turn.selectedTools)
|
|
43
54
|
=== JSON.stringify(turn.toolCalls.map(({ name }) => name))
|
|
44
55
|
&& validToolAssertions(turn, isHash)
|
|
@@ -46,9 +57,12 @@ export function validRecord(record, evidence) {
|
|
|
46
57
|
&& validFeedbackDisclosure(turn, record.judgeVerdicts, trial.trial, isHash)
|
|
47
58
|
&& turn.toolCalls.every((call) => Object.hasOwn(call, "result") && typeof call.isError === "boolean")
|
|
48
59
|
&& Array.isArray(turn.pathEvidence) && turn.pathEvidence.length === turn.toolCallCount
|
|
49
|
-
&& turn.pathEvidence.every(({ method, target, requestSha256, responseSha256 }) =>
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
})));
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
function validToolAssertions(turn, isHash) {
|
|
@@ -74,6 +88,30 @@ function validToolAssertions(turn, isHash) {
|
|
|
74
88
|
&& (turn.toolCalls.length === 0
|
|
75
89
|
|| (turn.toolCalls.length === 1 && turn.toolCalls[0].name === turn.expectedOptionalTool));
|
|
76
90
|
}
|
|
91
|
+
if (turn.expectedToolSelection !== undefined) {
|
|
92
|
+
const advertised = turn.advertisedToolNames;
|
|
93
|
+
if (!Array.isArray(advertised) || advertised.length !== turn.advertisedToolCount
|
|
94
|
+
|| advertised.some((name) => typeof name !== "string" || !name.trim())
|
|
95
|
+
|| JSON.stringify(advertised) !== JSON.stringify(canonicalToolNames(new Set(advertised)))
|
|
96
|
+
|| !isHash(turn.advertisedToolNamesSha256)
|
|
97
|
+
|| turn.advertisedToolNamesSha256 !== createHash("sha256")
|
|
98
|
+
.update(JSON.stringify(advertised)).digest("hex")
|
|
99
|
+
|| !isHash(turn.expectedToolSelectionSha256)
|
|
100
|
+
|| !isHash(turn.toolOutcomesSha256)
|
|
101
|
+
|| turn.toolOutcomesSha256 !== createHash("sha256")
|
|
102
|
+
.update(JSON.stringify(turn.toolCalls)).digest("hex")) return false;
|
|
103
|
+
let normalized;
|
|
104
|
+
try {
|
|
105
|
+
normalized = normalizeToolSelectionContract(turn.expectedToolSelection, advertised);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (JSON.stringify(normalized) !== JSON.stringify(turn.expectedToolSelection)
|
|
110
|
+
|| turn.expectedToolSelectionSha256 !== createHash("sha256")
|
|
111
|
+
.update(JSON.stringify(normalized)).digest("hex")) return false;
|
|
112
|
+
return toolSelectionContractSatisfied(normalized, turn.toolCalls)
|
|
113
|
+
&& validOutcomeAssertions(turn, "expectedOutcomeAssertionsSha256");
|
|
114
|
+
}
|
|
77
115
|
if (JSON.stringify(turn.selectedTools) !== JSON.stringify(turn.expectedTools)) return false;
|
|
78
116
|
if (turn.expectedToolAlternatives !== undefined) {
|
|
79
117
|
const alternatives = turn.expectedToolAlternatives;
|
|
@@ -88,15 +126,23 @@ function validToolAssertions(turn, isHash) {
|
|
|
88
126
|
return JSON.stringify(turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))
|
|
89
127
|
=== JSON.stringify(turn.expectedCalls);
|
|
90
128
|
}
|
|
91
|
-
|
|
129
|
+
return validOutcomeAssertions(turn, "expectedSelectionSha256", true, isHash);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validOutcomeAssertions(turn, digestField, includeTools = false, isHash = (value) => (
|
|
133
|
+
typeof value === "string" && /^[a-f0-9]{64}$/.test(value)
|
|
134
|
+
)) {
|
|
135
|
+
const hasAssertions = turn.expectedArguments !== undefined || turn.expectedFeedback !== undefined;
|
|
136
|
+
if (!includeTools && !hasAssertions) return turn[digestField] === undefined;
|
|
137
|
+
if (!isHash(turn[digestField])) return false;
|
|
92
138
|
const expectedHash = createHash("sha256").update(JSON.stringify({
|
|
93
|
-
tools: turn.expectedTools,
|
|
139
|
+
...(includeTools ? { tools: turn.expectedTools } : {}),
|
|
94
140
|
arguments: turn.expectedArguments,
|
|
95
141
|
feedback: turn.expectedFeedback,
|
|
96
|
-
...(turn.expectedToolAlternatives === undefined
|
|
142
|
+
...(!includeTools || turn.expectedToolAlternatives === undefined
|
|
97
143
|
? {} : { alternatives: turn.expectedToolAlternatives }),
|
|
98
144
|
})).digest("hex");
|
|
99
|
-
if (expectedHash !== turn
|
|
145
|
+
if (expectedHash !== turn[digestField]) return false;
|
|
100
146
|
for (const [name, expected] of Object.entries(turn.expectedArguments ?? {})) {
|
|
101
147
|
const matches = turn.toolCalls.filter((call) => call.name === name);
|
|
102
148
|
if (matches.length !== 1 || JSON.stringify(matches[0].arguments) !== JSON.stringify(expected)) return false;
|
package/semantic/provider.mjs
CHANGED
|
@@ -529,6 +529,7 @@ function codexInvocation(provider, prompt, directory, conversation) {
|
|
|
529
529
|
"-c", 'shell_environment_policy.inherit="none"',
|
|
530
530
|
"-c", "allow_login_shell=false",
|
|
531
531
|
"-c", "features.multi_agent=false",
|
|
532
|
+
"-c", "features.shell_tool=false",
|
|
532
533
|
"-c", "features.unified_exec=false",
|
|
533
534
|
];
|
|
534
535
|
if (conversation?.context) args.push("-c", `developer_instructions=${JSON.stringify(conversation.context)}`);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const contractKeys = ["allowed", "forbidden", "required", "totalCalls"];
|
|
2
|
+
const contractKeysWithoutForbidden = ["allowed", "required", "totalCalls"];
|
|
3
|
+
const requiredKeys = ["maxCalls", "minCalls", "name"];
|
|
4
|
+
const allowedKeys = ["maxCalls", "name"];
|
|
5
|
+
const totalKeys = ["maxCalls", "minCalls"];
|
|
6
|
+
|
|
7
|
+
function exactKeys(value, keys) {
|
|
8
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
9
|
+
&& JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...keys].sort());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function name(value) {
|
|
13
|
+
return typeof value === "string" && value.trim() === value && value.length > 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function count(value, minimum) {
|
|
17
|
+
return Number.isSafeInteger(value) && value >= minimum;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function freeze(value) {
|
|
21
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
22
|
+
for (const nested of Object.values(value)) freeze(nested);
|
|
23
|
+
Object.freeze(value);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function compareToolNames(left, right) {
|
|
29
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function canonicalToolNames(names) {
|
|
33
|
+
return [...names].sort(compareToolNames);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeToolSelectionContract(contract, advertisedToolNames) {
|
|
37
|
+
if (!(exactKeys(contract, contractKeys) || exactKeys(contract, contractKeysWithoutForbidden))
|
|
38
|
+
|| !Array.isArray(contract.required) || !Array.isArray(contract.allowed)
|
|
39
|
+
|| (contract.forbidden !== undefined && !Array.isArray(contract.forbidden))
|
|
40
|
+
|| !exactKeys(contract.totalCalls, totalKeys)) {
|
|
41
|
+
throw new TypeError("Tool-selection contract has an invalid shape");
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(advertisedToolNames) || advertisedToolNames.some((toolName) => !name(toolName))
|
|
44
|
+
|| new Set(advertisedToolNames).size !== advertisedToolNames.length) {
|
|
45
|
+
throw new TypeError("Advertised tool names must be unique non-empty strings");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const required = contract.required.map((entry) => {
|
|
49
|
+
if (!exactKeys(entry, requiredKeys) || !name(entry.name)
|
|
50
|
+
|| !count(entry.minCalls, 1) || !count(entry.maxCalls, entry.minCalls)) {
|
|
51
|
+
throw new TypeError("Required tools need a name and valid minimum and maximum call counts");
|
|
52
|
+
}
|
|
53
|
+
return { name: entry.name, minCalls: entry.minCalls, maxCalls: entry.maxCalls };
|
|
54
|
+
}).sort((left, right) => compareToolNames(left.name, right.name));
|
|
55
|
+
const allowed = contract.allowed.map((entry) => {
|
|
56
|
+
if (!exactKeys(entry, allowedKeys) || !name(entry.name) || !count(entry.maxCalls, 1)) {
|
|
57
|
+
throw new TypeError("Allowed tools need a name and a positive maximum call count");
|
|
58
|
+
}
|
|
59
|
+
return { name: entry.name, maxCalls: entry.maxCalls };
|
|
60
|
+
}).sort((left, right) => compareToolNames(left.name, right.name));
|
|
61
|
+
const forbidden = canonicalToolNames(contract.forbidden ?? []);
|
|
62
|
+
if (forbidden.some((toolName) => !name(toolName))) {
|
|
63
|
+
throw new TypeError("Forbidden tools must be non-empty strings");
|
|
64
|
+
}
|
|
65
|
+
const named = [...required.map(({ name: toolName }) => toolName),
|
|
66
|
+
...allowed.map(({ name: toolName }) => toolName), ...forbidden];
|
|
67
|
+
if (new Set(named).size !== named.length) {
|
|
68
|
+
throw new TypeError("Required, allowed, and forbidden tools must be disjoint and unique");
|
|
69
|
+
}
|
|
70
|
+
const advertised = new Set(advertisedToolNames);
|
|
71
|
+
if (named.some((toolName) => !advertised.has(toolName))) {
|
|
72
|
+
throw new TypeError("Every tool-selection contract name must be advertised");
|
|
73
|
+
}
|
|
74
|
+
if (required.length + allowed.length === 0) {
|
|
75
|
+
throw new TypeError("Tool-selection contract must permit at least one tool");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { minCalls, maxCalls } = contract.totalCalls;
|
|
79
|
+
if (!count(minCalls, 0) || !count(maxCalls, minCalls)) {
|
|
80
|
+
throw new TypeError("Total call counts must be finite safe integers in ascending order");
|
|
81
|
+
}
|
|
82
|
+
const requiredMinimum = required.reduce((total, entry) => total + entry.minCalls, 0);
|
|
83
|
+
const permittedMaximum = [...required, ...allowed].reduce((total, entry) => total + entry.maxCalls, 0);
|
|
84
|
+
if (!Number.isSafeInteger(requiredMinimum) || !Number.isSafeInteger(permittedMaximum)
|
|
85
|
+
|| minCalls < requiredMinimum || maxCalls > permittedMaximum) {
|
|
86
|
+
throw new TypeError("Total call counts must fit the declared per-tool cardinalities");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return freeze({
|
|
90
|
+
required,
|
|
91
|
+
allowed,
|
|
92
|
+
forbidden,
|
|
93
|
+
totalCalls: { minCalls, maxCalls },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function toolSelectionContractSatisfied(contract, calls) {
|
|
98
|
+
if (!Array.isArray(calls) || calls.some((call) => !call || !name(call.name))) return false;
|
|
99
|
+
if (calls.length < contract.totalCalls.minCalls || calls.length > contract.totalCalls.maxCalls) return false;
|
|
100
|
+
const rules = new Map([
|
|
101
|
+
...contract.required.map((entry) => [entry.name, entry]),
|
|
102
|
+
...contract.allowed.map((entry) => [entry.name, { ...entry, minCalls: 0 }]),
|
|
103
|
+
]);
|
|
104
|
+
const counts = new Map();
|
|
105
|
+
for (const { name: toolName } of calls) {
|
|
106
|
+
if (!rules.has(toolName)) return false;
|
|
107
|
+
counts.set(toolName, (counts.get(toolName) ?? 0) + 1);
|
|
108
|
+
}
|
|
109
|
+
return [...rules].every(([toolName, rule]) => {
|
|
110
|
+
const actual = counts.get(toolName) ?? 0;
|
|
111
|
+
return actual >= rule.minCalls && actual <= rule.maxCalls;
|
|
112
|
+
});
|
|
113
|
+
}
|
package/semantic/test.d.mts
CHANGED
|
@@ -5,6 +5,30 @@ export interface ToolCall {
|
|
|
5
5
|
arguments: Record<string, unknown>;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
export interface ToolOutcome {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly arguments: Readonly<Record<string, unknown>>;
|
|
11
|
+
readonly result: unknown;
|
|
12
|
+
readonly isError: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ToolSelectionContract {
|
|
16
|
+
readonly required: readonly {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly minCalls: number;
|
|
19
|
+
readonly maxCalls: number;
|
|
20
|
+
}[];
|
|
21
|
+
readonly allowed: readonly {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly maxCalls: number;
|
|
24
|
+
}[];
|
|
25
|
+
readonly forbidden?: readonly string[];
|
|
26
|
+
readonly totalCalls: {
|
|
27
|
+
readonly minCalls: number;
|
|
28
|
+
readonly maxCalls: number;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
8
32
|
export interface ConversationOptions {
|
|
9
33
|
server: URL;
|
|
10
34
|
/** Real application context supplied in production. Never use this as test guidance. */
|
|
@@ -18,6 +42,7 @@ export interface ConversationOptions {
|
|
|
18
42
|
export interface ConversationTurn {
|
|
19
43
|
readonly responses: readonly string[];
|
|
20
44
|
readonly toolCalls: readonly (readonly ToolCall[])[];
|
|
45
|
+
readonly toolOutcomes: readonly (readonly ToolOutcome[])[];
|
|
21
46
|
readonly elicitations: readonly (readonly ElicitationEvidence[])[];
|
|
22
47
|
}
|
|
23
48
|
|
|
@@ -61,6 +86,8 @@ export function assertToolNames(
|
|
|
61
86
|
turn: ConversationTurn,
|
|
62
87
|
expected: readonly string[] | { readonly oneOf: readonly (readonly string[])[] },
|
|
63
88
|
): void;
|
|
89
|
+
/** Requires completed calls to satisfy an independently authored closed-world selection contract. */
|
|
90
|
+
export function assertToolSelection(turn: ConversationTurn, contract: ToolSelectionContract): void;
|
|
64
91
|
export function assertToolArguments(
|
|
65
92
|
turn: ConversationTurn,
|
|
66
93
|
name: string,
|
package/semantic/test.mjs
CHANGED
|
@@ -11,11 +11,28 @@ import {
|
|
|
11
11
|
stopSemanticServer,
|
|
12
12
|
} from "./material.mjs";
|
|
13
13
|
import { parseJudgeVerdict, providerModel, providerSettings, runModel, startModelConversation } from "./provider.mjs";
|
|
14
|
+
import {
|
|
15
|
+
canonicalToolNames,
|
|
16
|
+
normalizeToolSelectionContract,
|
|
17
|
+
toolSelectionContractSatisfied,
|
|
18
|
+
} from "./selection.mjs";
|
|
14
19
|
|
|
15
20
|
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
16
21
|
const names = new Set();
|
|
17
22
|
const privateTurn = Symbol("emseepea-semantic-turn");
|
|
18
23
|
|
|
24
|
+
function immutableSnapshot(value) {
|
|
25
|
+
const snapshot = structuredClone(value);
|
|
26
|
+
const freeze = (entry) => {
|
|
27
|
+
if (entry && typeof entry === "object" && !Object.isFrozen(entry)) {
|
|
28
|
+
for (const nested of Object.values(entry)) freeze(nested);
|
|
29
|
+
Object.freeze(entry);
|
|
30
|
+
}
|
|
31
|
+
return entry;
|
|
32
|
+
};
|
|
33
|
+
return freeze(snapshot);
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
export async function createConversation(testContext, options) {
|
|
20
37
|
const specification = validateConversationOptions(options);
|
|
21
38
|
if (!testContext || typeof testContext.name !== "string" || typeof testContext.after !== "function") {
|
|
@@ -104,13 +121,14 @@ export async function createConversation(testContext, options) {
|
|
|
104
121
|
);
|
|
105
122
|
const answer = await trial.model.send(prompt, options);
|
|
106
123
|
const calls = answer.calls;
|
|
124
|
+
const recordedToolCalls = calls.map((call, index) => ({
|
|
125
|
+
...call,
|
|
126
|
+
result: answer.toolResults[index].content,
|
|
127
|
+
isError: answer.toolResults[index].isError,
|
|
128
|
+
}));
|
|
107
129
|
trial.history.push({
|
|
108
130
|
user: prompt,
|
|
109
|
-
toolCalls:
|
|
110
|
-
...call,
|
|
111
|
-
result: answer.toolResults[index].content,
|
|
112
|
-
isError: answer.toolResults[index].isError,
|
|
113
|
-
})),
|
|
131
|
+
toolCalls: recordedToolCalls,
|
|
114
132
|
assistant: answer.answer,
|
|
115
133
|
});
|
|
116
134
|
const record = {
|
|
@@ -120,11 +138,8 @@ export async function createConversation(testContext, options) {
|
|
|
120
138
|
prompt,
|
|
121
139
|
response: answer.answer,
|
|
122
140
|
elicitations: answer.elicitations,
|
|
123
|
-
toolCalls:
|
|
124
|
-
|
|
125
|
-
result: answer.toolResults[index].content,
|
|
126
|
-
isError: answer.toolResults[index].isError,
|
|
127
|
-
})),
|
|
141
|
+
toolCalls: recordedToolCalls,
|
|
142
|
+
toolOutcomesSha256: hash(JSON.stringify(recordedToolCalls)),
|
|
128
143
|
promptSha256: hash(prompt),
|
|
129
144
|
answerSha256: hash(answer.answer),
|
|
130
145
|
answerModels: answer.models,
|
|
@@ -148,6 +163,7 @@ export async function createConversation(testContext, options) {
|
|
|
148
163
|
calls,
|
|
149
164
|
prompt,
|
|
150
165
|
record,
|
|
166
|
+
tools: trial.tools,
|
|
151
167
|
state,
|
|
152
168
|
evidence,
|
|
153
169
|
provider,
|
|
@@ -176,6 +192,9 @@ export async function createConversation(testContext, options) {
|
|
|
176
192
|
return Object.freeze({
|
|
177
193
|
responses: Object.freeze(trials.map(({ answer }) => answer)),
|
|
178
194
|
toolCalls: Object.freeze(trials.map(({ calls }) => Object.freeze(calls))),
|
|
195
|
+
toolOutcomes: Object.freeze(trials.map(({ record }) => immutableSnapshot(record.toolCalls.map(
|
|
196
|
+
({ name, arguments: args, result, isError }) => ({ name, arguments: args, result, isError }),
|
|
197
|
+
)))),
|
|
179
198
|
elicitations: Object.freeze(trials.map(({ record }) => Object.freeze(record.elicitations))),
|
|
180
199
|
[privateTurn]: trials,
|
|
181
200
|
});
|
|
@@ -194,6 +213,7 @@ export async function createConversation(testContext, options) {
|
|
|
194
213
|
|
|
195
214
|
export function assertToolCalls(turn, expected) {
|
|
196
215
|
const trials = turnTrials(turn);
|
|
216
|
+
ensureNoDeclarativeSelection(trials);
|
|
197
217
|
validateExpectedCalls(expected);
|
|
198
218
|
for (const trial of trials) {
|
|
199
219
|
trial.record.expectedTools = expected.map(({ name }) => name);
|
|
@@ -210,6 +230,7 @@ export function assertToolCalls(turn, expected) {
|
|
|
210
230
|
|
|
211
231
|
export async function assertToolCallsWithOptionalFeedback(turn, expected) {
|
|
212
232
|
const trials = turnTrials(turn);
|
|
233
|
+
ensureNoDeclarativeSelection(trials);
|
|
213
234
|
validateExpectedCalls(expected);
|
|
214
235
|
const judgments = [];
|
|
215
236
|
for (let trialIndex = 0; trialIndex < trials.length; trialIndex += 1) {
|
|
@@ -266,6 +287,7 @@ export function assertNoToolCalls(turn) {
|
|
|
266
287
|
|
|
267
288
|
export function assertOptionalToolCall(turn, name) {
|
|
268
289
|
const trials = turnTrials(turn);
|
|
290
|
+
ensureNoDeclarativeSelection(trials);
|
|
269
291
|
if (typeof name !== "string" || !name.trim()) {
|
|
270
292
|
throw new Error("Optional tool-call expectation needs a tool name");
|
|
271
293
|
}
|
|
@@ -281,6 +303,7 @@ export function assertOptionalToolCall(turn, name) {
|
|
|
281
303
|
|
|
282
304
|
export function assertToolNames(turn, expected) {
|
|
283
305
|
const trials = turnTrials(turn);
|
|
306
|
+
ensureNoDeclarativeSelection(trials);
|
|
284
307
|
const validNames = (names) => Array.isArray(names)
|
|
285
308
|
&& names.every((name) => typeof name === "string" && name.trim());
|
|
286
309
|
const alternatives = Array.isArray(expected) ? undefined : expected?.oneOf;
|
|
@@ -309,6 +332,40 @@ export function assertToolNames(turn, expected) {
|
|
|
309
332
|
}
|
|
310
333
|
}
|
|
311
334
|
|
|
335
|
+
export function assertToolSelection(turn, contract) {
|
|
336
|
+
const trials = turnTrials(turn);
|
|
337
|
+
ensureNoExactSelection(trials);
|
|
338
|
+
let candidates;
|
|
339
|
+
try {
|
|
340
|
+
candidates = trials.map((trial) => {
|
|
341
|
+
const advertisedToolNames = canonicalToolNames(trial.tools.map(({ name }) => name));
|
|
342
|
+
return { advertisedToolNames, normalized: normalizeToolSelectionContract(contract, advertisedToolNames) };
|
|
343
|
+
});
|
|
344
|
+
if (candidates.some(({ normalized }) => JSON.stringify(normalized)
|
|
345
|
+
!== JSON.stringify(candidates[0].normalized))) {
|
|
346
|
+
throw new Error("Tool-selection contract must normalize identically for every trial");
|
|
347
|
+
}
|
|
348
|
+
} catch (error) {
|
|
349
|
+
failAssertion(trials, "tool-selection contract assertion");
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
const normalized = candidates[0].normalized;
|
|
353
|
+
trials.forEach((trial, index) => {
|
|
354
|
+
const { advertisedToolNames } = candidates[index];
|
|
355
|
+
trial.record.expectedToolSelection = structuredClone(normalized);
|
|
356
|
+
trial.record.expectedToolSelectionSha256 = hash(JSON.stringify(normalized));
|
|
357
|
+
trial.record.advertisedToolNames = advertisedToolNames;
|
|
358
|
+
trial.record.advertisedToolNamesSha256 = hash(JSON.stringify(advertisedToolNames));
|
|
359
|
+
if (trial.record.expectedArguments !== undefined || trial.record.expectedFeedback !== undefined) {
|
|
360
|
+
recordFlexibleExpectation(trial.record);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
if (!trials.every((trial) => toolSelectionContractSatisfied(normalized, trial.calls))) {
|
|
364
|
+
failAssertion(trials, "tool-selection contract assertion");
|
|
365
|
+
throw new Error("Tool calls did not satisfy the declared selection contract");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
312
369
|
export function assertToolArguments(turn, name, expected) {
|
|
313
370
|
const trials = turnTrials(turn);
|
|
314
371
|
if (typeof name !== "string" || !name.trim() || !expected || typeof expected !== "object"
|
|
@@ -536,6 +593,7 @@ async function closeConversation(state, evidence, output) {
|
|
|
536
593
|
const complete = !state.failed && state.meaningAssertions > 0 && evidence.answerTrials.length === 3
|
|
537
594
|
&& evidence.answerTrials.every(({ turns }) => turns.length > 0
|
|
538
595
|
&& turns.every((turn) => Array.isArray(turn.expectedTools)
|
|
596
|
+
|| turn.expectedToolSelection !== undefined
|
|
539
597
|
|| turn.expectedOptionalFeedback === true
|
|
540
598
|
|| typeof turn.expectedOptionalTool === "string"));
|
|
541
599
|
if (complete) {
|
|
@@ -582,7 +640,34 @@ function failAssertion(trials, phase) {
|
|
|
582
640
|
trials[0].evidence.failedPhase = phase;
|
|
583
641
|
}
|
|
584
642
|
|
|
643
|
+
function ensureNoDeclarativeSelection(trials) {
|
|
644
|
+
if (trials.some(({ record }) => record.expectedToolSelection !== undefined)) {
|
|
645
|
+
failAssertion(trials, "tool-selection contract assertion");
|
|
646
|
+
throw new Error("Cannot combine exact and declarative tool-selection assertions");
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function ensureNoExactSelection(trials) {
|
|
651
|
+
const hasExactSelection = ({ record }) => record.expectedCalls !== undefined
|
|
652
|
+
|| record.expectedTools !== undefined
|
|
653
|
+
|| record.expectedOptionalTool !== undefined
|
|
654
|
+
|| record.expectedOptionalFeedback !== undefined
|
|
655
|
+
|| record.expectedToolAlternatives !== undefined;
|
|
656
|
+
if (trials.some(hasExactSelection)) {
|
|
657
|
+
failAssertion(trials, "tool-selection contract assertion");
|
|
658
|
+
throw new Error("Cannot combine exact and declarative tool-selection assertions");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
585
662
|
function recordFlexibleExpectation(record) {
|
|
663
|
+
if (record.expectedToolSelection !== undefined) {
|
|
664
|
+
delete record.expectedSelectionSha256;
|
|
665
|
+
record.expectedOutcomeAssertionsSha256 = hash(JSON.stringify({
|
|
666
|
+
arguments: record.expectedArguments,
|
|
667
|
+
feedback: record.expectedFeedback,
|
|
668
|
+
}));
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
586
671
|
record.expectedSelectionSha256 = hash(JSON.stringify({
|
|
587
672
|
tools: record.expectedTools,
|
|
588
673
|
arguments: record.expectedArguments,
|