@emseepea/testing 0.21.7 → 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
@@ -57,6 +90,12 @@ advertised-tool text, an answer wrapper, or prepared MCP material. Exact tool
57
90
  assertions come from the provider's native MCP events. Follow-up messages use
58
91
  the same conversation.
59
92
 
93
+ Native conversations have no framework tool-call or native provider-turn count
94
+ limit. Calls must still use advertised MCP tools. The per-send timeout,
95
+ cancellation, output checks, model checks, and isolated test environment remain
96
+ effective. Application assertions decide which calls and results are
97
+ acceptable. Tool-free judge invocations retain their existing limits.
98
+
60
99
  Call `await conversation.fresh()` to discard provider transcript history while
61
100
  keeping the same running test application and its saved data. The next `send`
62
101
  starts a new provider-native session in each trial.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emseepea/testing",
3
- "version": "0.21.7",
3
+ "version": "0.23.0",
4
4
  "description": "MCP integration and semantic testing helpers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,10 +43,11 @@
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
- "test:built": "node --test test/*.test.mjs"
49
+ "test:built": "node --test test/*.test.mjs",
50
+ "test:llm:built": "emseepea-test eval"
50
51
  },
51
52
  "dependencies": {
52
53
  "@emseepea/server": "0.23.0",
package/semantic/cli.mjs CHANGED
@@ -1,23 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { createHash } from "node:crypto";
4
3
  import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
5
4
  import { tmpdir } from "node:os";
6
5
  import { dirname, join, relative, resolve } from "node:path";
6
+ import { validRecord } from "./evidence.mjs";
7
7
  import { discoverTests } from "./discover.mjs";
8
8
  import { modelVersion, providerModel, providerSettings } from "./provider.mjs";
9
9
 
10
- const negativeFeedbackObservations = new Set([
11
- "error",
12
- "friction",
13
- "annoyance",
14
- "unnecessary_difficulty",
15
- "confusion",
16
- "repetition",
17
- "unexpected_bad_result",
18
- "capability_mismatch",
19
- ]);
20
-
21
10
  const paths = [];
22
11
  let provider = process.env.EMSEEPEA_EVAL_PROVIDER ?? "claude-local";
23
12
  let output = "artifacts/llm-eval/evidence.json";
@@ -101,7 +90,7 @@ try {
101
90
  for (const file of files) {
102
91
  const displayFile = relative(process.cwd(), file);
103
92
  const cases = Object.values(evidence.cases).filter((record) => record.file === displayFile);
104
- if (!cases.length || cases.some((record) => !validRecord(record, evidence.authoritative, smoke))) {
93
+ if (!cases.length || cases.some((record) => !validRecord(record, evidence))) {
105
94
  evidence.errors.push(`Missing or failed qualification: ${displayFile}`);
106
95
  }
107
96
  }
@@ -114,138 +103,3 @@ try {
114
103
  }
115
104
  if (evidence.status !== "passed") process.exitCode = 1;
116
105
  console.log(`Semantic checks ${evidence.status}; evidence: ${output}`);
117
-
118
- function validRecord(record, authoritative, smoke) {
119
- const isHash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
120
- if (record.status !== "passed" || record.authoritative !== authoritative || record.smoke !== smoke
121
- || record.provider !== evidence.provider || record.configuredModel !== evidence.configuredModel
122
- || record.modelEvidence !== evidence.modelEvidence
123
- || JSON.stringify(record.settings) !== JSON.stringify(evidence.settings)
124
- || record.mode !== "conversation" || record.answerTrials?.length !== 3
125
- || !Number.isInteger(record.judgeVerdicts?.length) || record.judgeVerdicts.length < 9
126
- || record.judgeVerdicts.length % 3 !== 0
127
- || !record.judgeVerdicts.every((judgment) => isHash(judgment.expectationSha256)
128
- && isHash(judgment.requestSha256) && isHash(judgment.responseSha256)
129
- && typeof judgment.expectedMeaning === "string" && judgment.expectedMeaning.length > 0
130
- && judgment.verdict?.pass === true && judgment.verdict.score === 1
131
- && typeof judgment.verdict.reason === "string" && judgment.verdict.reason.length > 0)) return false;
132
- return record.answerTrials.every((trial) => Array.isArray(trial.turns) && trial.turns.length > 0
133
- && trial.turns.every((turn) => Number.isInteger(turn.advertisedToolCount)
134
- && turn.advertisedToolCount >= 0
135
- && turn.interactionMode === "native-mcp"
136
- && turn.answerTurnCount === 1
137
- && turn.answerProviderToolCount === turn.toolCallCount
138
- && turn.answerProviderTurnCount === turn.toolCallCount + 1
139
- && Number.isInteger(turn.toolCallCount) && turn.toolCallCount >= 0 && turn.toolCallCount <= 3
140
- && typeof turn.prompt === "string" && turn.prompt.length > 0
141
- && typeof turn.response === "string"
142
- && isHash(turn.promptSha256) && isHash(turn.answerSha256)
143
- && isHash(turn.advertisedToolsSha256) && isHash(turn.selectedCallsSha256)
144
- && Array.isArray(turn.toolCalls)
145
- && JSON.stringify(turn.selectedTools)
146
- === JSON.stringify(turn.toolCalls.map(({ name }) => name))
147
- && validToolAssertions(turn, isHash)
148
- && validNegativeFeedbackAssertion(turn)
149
- && validFeedbackDisclosure(turn, record.judgeVerdicts, trial.trial, isHash)
150
- && turn.toolCalls.every((call) => Object.hasOwn(call, "result") && typeof call.isError === "boolean")
151
- && Array.isArray(turn.pathEvidence) && turn.pathEvidence.length === turn.toolCallCount
152
- && turn.pathEvidence.every(({ method, target, requestSha256, responseSha256 }) =>
153
- method === "tools/call" && turn.selectedTools.includes(target)
154
- && isHash(requestSha256) && isHash(responseSha256))));
155
- }
156
-
157
- function validToolAssertions(turn, isHash) {
158
- if (turn.expectedOptionalFeedback === true) {
159
- if (!Array.isArray(turn.expectedCalls)) return false;
160
- const expectedHash = createHash("sha256").update(JSON.stringify({
161
- calls: turn.expectedCalls,
162
- optionalFeedback: true,
163
- })).digest("hex");
164
- const calls = turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args }));
165
- const primaryCalls = calls.slice(0, turn.expectedCalls?.length);
166
- const trailingCalls = calls.slice(turn.expectedCalls?.length);
167
- return expectedHash === turn.expectedSelectionSha256
168
- && JSON.stringify(primaryCalls) === JSON.stringify(turn.expectedCalls)
169
- && (trailingCalls.length === 0
170
- || (trailingCalls.length === 1 && trailingCalls[0].name === "submit-feedback"));
171
- }
172
- if (typeof turn.expectedOptionalTool === "string" && turn.expectedOptionalTool.trim()) {
173
- const expectedHash = createHash("sha256").update(JSON.stringify({
174
- optionalTool: turn.expectedOptionalTool,
175
- })).digest("hex");
176
- return expectedHash === turn.expectedSelectionSha256
177
- && (turn.toolCalls.length === 0
178
- || (turn.toolCalls.length === 1 && turn.toolCalls[0].name === turn.expectedOptionalTool));
179
- }
180
- if (JSON.stringify(turn.selectedTools) !== JSON.stringify(turn.expectedTools)) return false;
181
- if (turn.expectedToolAlternatives !== undefined) {
182
- const alternatives = turn.expectedToolAlternatives;
183
- if (!Array.isArray(alternatives) || alternatives.length < 1 || alternatives.length > 8
184
- || !alternatives.every((names) => Array.isArray(names)
185
- && names.every((name) => typeof name === "string" && name.trim()))
186
- || !alternatives.some((names) => JSON.stringify(names) === JSON.stringify(turn.expectedTools))) {
187
- return false;
188
- }
189
- }
190
- if (isHash(turn.expectedCallsSha256)) {
191
- return JSON.stringify(turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))
192
- === JSON.stringify(turn.expectedCalls);
193
- }
194
- if (!isHash(turn.expectedSelectionSha256)) return false;
195
- const expectedHash = createHash("sha256").update(JSON.stringify({
196
- tools: turn.expectedTools,
197
- arguments: turn.expectedArguments,
198
- feedback: turn.expectedFeedback,
199
- ...(turn.expectedToolAlternatives === undefined
200
- ? {} : { alternatives: turn.expectedToolAlternatives }),
201
- })).digest("hex");
202
- if (expectedHash !== turn.expectedSelectionSha256) return false;
203
- for (const [name, expected] of Object.entries(turn.expectedArguments ?? {})) {
204
- const matches = turn.toolCalls.filter((call) => call.name === name);
205
- if (matches.length !== 1 || JSON.stringify(matches[0].arguments) !== JSON.stringify(expected)) return false;
206
- }
207
- if (turn.expectedFeedback) {
208
- const calls = turn.toolCalls.filter((call) => call.name === "submit-feedback");
209
- const detail = calls[0]?.arguments?.detail;
210
- if (calls.length !== 1 || !turn.expectedFeedback.observation.includes(calls[0].arguments?.observation)
211
- || typeof detail !== "string" || turn.expectedFeedback.detailIncludes.some(
212
- (value) => !detail.toLowerCase().includes(value.toLowerCase()),
213
- )) return false;
214
- }
215
- return true;
216
- }
217
-
218
- function validNegativeFeedbackAssertion(turn) {
219
- if (turn.expectedNegativeFeedback !== false) return true;
220
- const actual = turn.toolCalls.filter((call) => call.name === "submit-feedback"
221
- && negativeFeedbackObservations.has(call.arguments?.observation));
222
- return actual.length === 0 && JSON.stringify(actual) === JSON.stringify(turn.negativeFeedbackCalls);
223
- }
224
-
225
- function validFeedbackDisclosure(turn, judgments, trial, isHash) {
226
- if (turn.expectedOptionalFeedback !== true) return turn.feedbackDisclosure === undefined;
227
- const feedback = turn.toolCalls[turn.expectedCalls.length];
228
- if (!feedback) return turn.feedbackDisclosure === undefined;
229
- if (feedback.isError || typeof feedback.arguments?.observation !== "string"
230
- || !feedback.arguments.observation.trim() || typeof feedback.arguments.detail !== "string"
231
- || !feedback.arguments.detail.trim()) return false;
232
- const expectedMeaning = feedbackDisclosureExpectation(feedback);
233
- const expectationSha256 = createHash("sha256").update(expectedMeaning).digest("hex");
234
- if (!isHash(turn.feedbackDisclosure?.expectationSha256)
235
- || JSON.stringify(turn.feedbackDisclosure) !== JSON.stringify({
236
- observation: feedback.arguments.observation,
237
- detail: feedback.arguments.detail,
238
- expectationSha256,
239
- })) return false;
240
- const matching = judgments.filter((judgment) => judgment.trial === trial
241
- && judgment.turn === turn.turn && judgment.expectationSha256 === expectationSha256);
242
- return matching.length === 3 && matching.every((judgment) =>
243
- judgment.expectedMeaning === expectedMeaning && judgment.verdict?.pass === true);
244
- }
245
-
246
- function feedbackDisclosureExpectation(call) {
247
- return "The final assistant response makes it clear that feedback was submitted, recorded, or noted, "
248
- + "and communicates the substance "
249
- + `of this specific ${call.arguments.observation} observation: ${call.arguments.detail} `
250
- + "A concise, faithful summary is enough. It need not repeat every example, field, or phrase.";
251
- }
@@ -0,0 +1,194 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ canonicalToolNames,
4
+ normalizeToolSelectionContract,
5
+ toolSelectionContractSatisfied,
6
+ } from "./selection.mjs";
7
+
8
+ const negativeFeedbackObservations = new Set([
9
+ "error",
10
+ "friction",
11
+ "annoyance",
12
+ "unnecessary_difficulty",
13
+ "confusion",
14
+ "repetition",
15
+ "unexpected_bad_result",
16
+ "capability_mismatch",
17
+ ]);
18
+
19
+ const sha256 = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
20
+
21
+ export function validRecord(record, evidence) {
22
+ const { authoritative, smoke } = evidence;
23
+ const isHash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
24
+ if (record.status !== "passed" || record.authoritative !== authoritative || record.smoke !== smoke
25
+ || record.provider !== evidence.provider || record.configuredModel !== evidence.configuredModel
26
+ || record.modelEvidence !== evidence.modelEvidence
27
+ || JSON.stringify(record.settings) !== JSON.stringify(evidence.settings)
28
+ || record.mode !== "conversation" || record.answerTrials?.length !== 3
29
+ || !Number.isInteger(record.judgeVerdicts?.length) || record.judgeVerdicts.length < 9
30
+ || record.judgeVerdicts.length % 3 !== 0
31
+ || !record.judgeVerdicts.every((judgment) => isHash(judgment.expectationSha256)
32
+ && isHash(judgment.requestSha256) && isHash(judgment.responseSha256)
33
+ && typeof judgment.expectedMeaning === "string" && judgment.expectedMeaning.length > 0
34
+ && judgment.verdict?.pass === true && judgment.verdict.score === 1
35
+ && typeof judgment.verdict.reason === "string" && judgment.verdict.reason.length > 0)) return false;
36
+ return record.answerTrials.every((trial) => Array.isArray(trial.turns) && trial.turns.length > 0
37
+ && trial.turns.every((turn) => Number.isInteger(turn.advertisedToolCount)
38
+ && turn.advertisedToolCount >= 0
39
+ && turn.interactionMode === "native-mcp"
40
+ && turn.answerTurnCount === 1
41
+ && turn.answerProviderToolCount === turn.toolCallCount
42
+ && Number.isInteger(turn.answerProviderTurnCount) && turn.answerProviderTurnCount >= 1
43
+ && Number.isInteger(turn.toolCallCount) && turn.toolCallCount >= 0
44
+ && typeof turn.prompt === "string" && turn.prompt.length > 0
45
+ && typeof turn.response === "string"
46
+ && isHash(turn.promptSha256) && isHash(turn.answerSha256)
47
+ && isHash(turn.advertisedToolsSha256) && isHash(turn.selectedCallsSha256)
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
+ })))
53
+ && JSON.stringify(turn.selectedTools)
54
+ === JSON.stringify(turn.toolCalls.map(({ name }) => name))
55
+ && validToolAssertions(turn, isHash)
56
+ && validNegativeFeedbackAssertion(turn)
57
+ && validFeedbackDisclosure(turn, record.judgeVerdicts, trial.trial, isHash)
58
+ && turn.toolCalls.every((call) => Object.hasOwn(call, "result") && typeof call.isError === "boolean")
59
+ && Array.isArray(turn.pathEvidence) && turn.pathEvidence.length === turn.toolCallCount
60
+ && turn.pathEvidence.every(({ method, target, requestSha256, responseSha256 }, index) => {
61
+ const call = turn.toolCalls[index];
62
+ return method === "tools/call" && target === call.name
63
+ && requestSha256 === sha256({ method, name: call.name, arguments: call.arguments })
64
+ && responseSha256 === sha256(call.result);
65
+ })));
66
+ }
67
+
68
+ function validToolAssertions(turn, isHash) {
69
+ if (turn.expectedOptionalFeedback === true) {
70
+ if (!Array.isArray(turn.expectedCalls)) return false;
71
+ const expectedHash = createHash("sha256").update(JSON.stringify({
72
+ calls: turn.expectedCalls,
73
+ optionalFeedback: true,
74
+ })).digest("hex");
75
+ const calls = turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args }));
76
+ const primaryCalls = calls.slice(0, turn.expectedCalls?.length);
77
+ const trailingCalls = calls.slice(turn.expectedCalls?.length);
78
+ return expectedHash === turn.expectedSelectionSha256
79
+ && JSON.stringify(primaryCalls) === JSON.stringify(turn.expectedCalls)
80
+ && (trailingCalls.length === 0
81
+ || (trailingCalls.length === 1 && trailingCalls[0].name === "submit-feedback"));
82
+ }
83
+ if (typeof turn.expectedOptionalTool === "string" && turn.expectedOptionalTool.trim()) {
84
+ const expectedHash = createHash("sha256").update(JSON.stringify({
85
+ optionalTool: turn.expectedOptionalTool,
86
+ })).digest("hex");
87
+ return expectedHash === turn.expectedSelectionSha256
88
+ && (turn.toolCalls.length === 0
89
+ || (turn.toolCalls.length === 1 && turn.toolCalls[0].name === turn.expectedOptionalTool));
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
+ }
115
+ if (JSON.stringify(turn.selectedTools) !== JSON.stringify(turn.expectedTools)) return false;
116
+ if (turn.expectedToolAlternatives !== undefined) {
117
+ const alternatives = turn.expectedToolAlternatives;
118
+ if (!Array.isArray(alternatives) || alternatives.length < 1 || alternatives.length > 8
119
+ || !alternatives.every((names) => Array.isArray(names)
120
+ && names.every((name) => typeof name === "string" && name.trim()))
121
+ || !alternatives.some((names) => JSON.stringify(names) === JSON.stringify(turn.expectedTools))) {
122
+ return false;
123
+ }
124
+ }
125
+ if (isHash(turn.expectedCallsSha256)) {
126
+ return JSON.stringify(turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))
127
+ === JSON.stringify(turn.expectedCalls);
128
+ }
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;
138
+ const expectedHash = createHash("sha256").update(JSON.stringify({
139
+ ...(includeTools ? { tools: turn.expectedTools } : {}),
140
+ arguments: turn.expectedArguments,
141
+ feedback: turn.expectedFeedback,
142
+ ...(!includeTools || turn.expectedToolAlternatives === undefined
143
+ ? {} : { alternatives: turn.expectedToolAlternatives }),
144
+ })).digest("hex");
145
+ if (expectedHash !== turn[digestField]) return false;
146
+ for (const [name, expected] of Object.entries(turn.expectedArguments ?? {})) {
147
+ const matches = turn.toolCalls.filter((call) => call.name === name);
148
+ if (matches.length !== 1 || JSON.stringify(matches[0].arguments) !== JSON.stringify(expected)) return false;
149
+ }
150
+ if (turn.expectedFeedback) {
151
+ const calls = turn.toolCalls.filter((call) => call.name === "submit-feedback");
152
+ const detail = calls[0]?.arguments?.detail;
153
+ if (calls.length !== 1 || !turn.expectedFeedback.observation.includes(calls[0].arguments?.observation)
154
+ || typeof detail !== "string" || turn.expectedFeedback.detailIncludes.some(
155
+ (value) => !detail.toLowerCase().includes(value.toLowerCase()),
156
+ )) return false;
157
+ }
158
+ return true;
159
+ }
160
+
161
+ function validNegativeFeedbackAssertion(turn) {
162
+ if (turn.expectedNegativeFeedback !== false) return true;
163
+ const actual = turn.toolCalls.filter((call) => call.name === "submit-feedback"
164
+ && negativeFeedbackObservations.has(call.arguments?.observation));
165
+ return actual.length === 0 && JSON.stringify(actual) === JSON.stringify(turn.negativeFeedbackCalls);
166
+ }
167
+
168
+ function validFeedbackDisclosure(turn, judgments, trial, isHash) {
169
+ if (turn.expectedOptionalFeedback !== true) return turn.feedbackDisclosure === undefined;
170
+ const feedback = turn.toolCalls[turn.expectedCalls.length];
171
+ if (!feedback) return turn.feedbackDisclosure === undefined;
172
+ if (feedback.isError || typeof feedback.arguments?.observation !== "string"
173
+ || !feedback.arguments.observation.trim() || typeof feedback.arguments.detail !== "string"
174
+ || !feedback.arguments.detail.trim()) return false;
175
+ const expectedMeaning = feedbackDisclosureExpectation(feedback);
176
+ const expectationSha256 = createHash("sha256").update(expectedMeaning).digest("hex");
177
+ if (!isHash(turn.feedbackDisclosure?.expectationSha256)
178
+ || JSON.stringify(turn.feedbackDisclosure) !== JSON.stringify({
179
+ observation: feedback.arguments.observation,
180
+ detail: feedback.arguments.detail,
181
+ expectationSha256,
182
+ })) return false;
183
+ const matching = judgments.filter((judgment) => judgment.trial === trial
184
+ && judgment.turn === turn.turn && judgment.expectationSha256 === expectationSha256);
185
+ return matching.length === 3 && matching.every((judgment) =>
186
+ judgment.expectedMeaning === expectedMeaning && judgment.verdict?.pass === true);
187
+ }
188
+
189
+ function feedbackDisclosureExpectation(call) {
190
+ return "The final assistant response makes it clear that feedback was submitted, recorded, or noted, "
191
+ + "and communicates the substance "
192
+ + `of this specific ${call.arguments.observation} observation: ${call.arguments.detail} `
193
+ + "A concise, faithful summary is enough. It need not repeat every example, field, or phrase.";
194
+ }
@@ -50,7 +50,7 @@ export function providerSettings(provider) {
50
50
  userConfig: "ignored",
51
51
  webSearch: false,
52
52
  }
53
- : { effort: "low", maxTurns: 4, permissionMode: "dontAsk", userSettings: false };
53
+ : { effort: "low", maxTurns: null, judgeMaxTurns: 4, permissionMode: "dontAsk", userSettings: false };
54
54
  }
55
55
 
56
56
  export function parseClaudeEvents(stdout, processExitCode = 0) {
@@ -115,11 +115,6 @@ export function parseNativeClaudeEvents(stdout, advertisedTools, requireInit = f
115
115
  }
116
116
  return { name: publicName, arguments: input };
117
117
  });
118
- if (toolUses.length > 3) {
119
- throw Object.assign(new Error("Model command used more than three tools"), {
120
- attemptedToolCalls: calls,
121
- });
122
- }
123
118
  if (init) {
124
119
  const available = [...(init.tools ?? [])].sort();
125
120
  const expected = [...advertised.keys()].sort();
@@ -156,9 +151,8 @@ export function parseNativeClaudeEvents(stdout, advertisedTools, requireInit = f
156
151
  if ((result.permission_denials?.length ?? 0) > 0) {
157
152
  throw new Error("Model command attempted a forbidden action");
158
153
  }
159
- if (!Number.isInteger(result.num_turns)) throw new Error("Model command returned an invalid turn count");
160
- if (result.num_turns !== toolUses.length + 1) {
161
- throw new Error("Model command used an unexpected number of turns");
154
+ if (!Number.isInteger(result.num_turns) || result.num_turns < 1) {
155
+ throw new Error("Model command returned an invalid turn count");
162
156
  }
163
157
  const usage = result.modelUsage?.[claudeModel];
164
158
  if (usage?.canonicalModel !== claudeModel || usage.provider !== "firstParty") {
@@ -251,7 +245,6 @@ export function conversationInvocation(provider, directory, url, tools, authToke
251
245
  "--verbose",
252
246
  "--model", claudeModel,
253
247
  "--effort", "low",
254
- "--max-turns", "4",
255
248
  "--strict-mcp-config",
256
249
  ...(config ? ["--mcp-config", JSON.stringify(config)] : []),
257
250
  "--disable-slash-commands",
@@ -444,7 +437,6 @@ export function parseCodexEvents(stdout, advertisedTools, expectedThreadId, proc
444
437
  const forbidden = completed.filter((item) => !["agent_message", "reasoning", "mcp_tool_call"].includes(item?.type));
445
438
  if (forbidden.length) throw new Error("Model command used a forbidden tool");
446
439
  const toolItems = completed.filter(({ type }) => type === "mcp_tool_call");
447
- if (toolItems.length > 3) throw new Error("Model command used more than three tools");
448
440
  const calls = toolItems.map((item) => {
449
441
  if (item.server !== mcpServerName || !advertised.has(item.tool)
450
442
  || !item.arguments || typeof item.arguments !== "object" || Array.isArray(item.arguments)) {
@@ -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"
@@ -496,7 +553,6 @@ function safeModelFailure(error) {
496
553
  "Model command did not resume the required session",
497
554
  "Model command reported a failed MCP tool call",
498
555
  "Model command exposed a configured secret",
499
- "Model command used more than three tools",
500
556
  "Model conversation already has a pending turn",
501
557
  "Model conversation could not start",
502
558
  "Model conversation is closed",
@@ -537,6 +593,7 @@ async function closeConversation(state, evidence, output) {
537
593
  const complete = !state.failed && state.meaningAssertions > 0 && evidence.answerTrials.length === 3
538
594
  && evidence.answerTrials.every(({ turns }) => turns.length > 0
539
595
  && turns.every((turn) => Array.isArray(turn.expectedTools)
596
+ || turn.expectedToolSelection !== undefined
540
597
  || turn.expectedOptionalFeedback === true
541
598
  || typeof turn.expectedOptionalTool === "string"));
542
599
  if (complete) {
@@ -583,7 +640,34 @@ function failAssertion(trials, phase) {
583
640
  trials[0].evidence.failedPhase = phase;
584
641
  }
585
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
+
586
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
+ }
587
671
  record.expectedSelectionSha256 = hash(JSON.stringify({
588
672
  tools: record.expectedTools,
589
673
  arguments: record.expectedArguments,