@emseepea/testing 0.22.0 → 0.23.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
@@ -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. Exact application assertions decide which calls and results are
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.22.0",
3
+ "version": "0.23.0",
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",
@@ -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
- method === "tools/call" && turn.selectedTools.includes(target)
51
- && isHash(requestSha256) && isHash(responseSha256))));
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
- if (!isHash(turn.expectedSelectionSha256)) return false;
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.expectedSelectionSha256) return false;
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;
@@ -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
+ }
@@ -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: calls.map((call, index) => ({
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: calls.map((call, index) => ({
124
- ...call,
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,