@emseepea/testing 0.6.1 → 0.9.4

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
@@ -14,6 +14,33 @@ Use `createConversation` inside an ordinary `node:test` test. Send one or more
14
14
  user prompts, then assert exact tool calls and response meaning with the exported
15
15
  semantic assertions.
16
16
 
17
+ Use `assertToolNames` when one tool has intentionally free-form arguments. Pair
18
+ it with `assertToolArguments` for the stable call and `assertFeedback` for the
19
+ feedback observation and important detail. Keep `assertToolCalls` when every
20
+ complete argument should match exactly and no feedback tool is advertised.
21
+
22
+ For a successful turn that advertises feedback, use
23
+ `assertToolCallsWithOptionalFeedback`. It requires the exact ordered primary
24
+ calls and arguments, then allows no feedback call or one trailing
25
+ `submit-feedback` call. When feedback is present, it also requires a successful
26
+ tool result and semantically checks that the response openly states the
27
+ specific observation. Await this assertion.
28
+
29
+ Pair it with `assertNoNegativeFeedback` so legitimate positive feedback does
30
+ not make the primary behavior fail. Disclosure costs three judge calls for each
31
+ feedback-bearing trial, with no extra judge calls when feedback is absent and a
32
+ maximum of nine across the three trials.
33
+
34
+ Use `assertOptionalToolCall(turn, "submit-feedback")` only for a deliberately
35
+ unsuccessful journey where feedback is valid but not required. It accepts no
36
+ call or one feedback call and rejects duplicate feedback or any other tool.
37
+
38
+ For a successful application journey that advertises `submit-feedback`, call
39
+ `assertNoNegativeFeedback(...turns)` once after its normal assertions. It fails
40
+ if the AI records an error, friction, annoyance, unnecessary difficulty,
41
+ confusion, repetition, an unexpected bad result, or a capability mismatch. The
42
+ evidence records both the expectation and any offending call.
43
+
17
44
  The runner sends each prompt unchanged through one provider-native MCP
18
45
  conversation. It does not add tool-selection instructions, a JSON call plan,
19
46
  advertised-tool text, an answer wrapper, or prepared MCP material. Exact tool
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emseepea/testing",
3
- "version": "0.6.1",
3
+ "version": "0.9.4",
4
4
  "description": "MCP integration and semantic testing helpers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,7 +41,7 @@
41
41
  "test:built": "node --test test/*.test.mjs"
42
42
  },
43
43
  "dependencies": {
44
- "@emseepea/server": "0.4.0",
44
+ "@emseepea/server": "0.7.0",
45
45
  "@modelcontextprotocol/client": "2.0.0"
46
46
  },
47
47
  "engines": {
package/semantic/cli.mjs CHANGED
@@ -1,11 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
+ import { createHash } from "node:crypto";
3
4
  import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
5
6
  import { dirname, join, relative, resolve } from "node:path";
6
7
  import { discoverTests } from "./discover.mjs";
7
8
  import { modelVersion } from "./provider.mjs";
8
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
+
9
21
  const paths = [];
10
22
  let provider = process.env.EMSEEPEA_EVAL_PROVIDER ?? "claude-local";
11
23
  let output = "artifacts/llm-eval/evidence.json";
@@ -105,7 +117,7 @@ function validRecord(record, authoritative, smoke) {
105
117
  if (record.status !== "passed" || record.authoritative !== authoritative || record.smoke !== smoke
106
118
  || record.mode !== "conversation" || record.answerTrials?.length !== 3
107
119
  || !Number.isInteger(record.judgeVerdicts?.length) || record.judgeVerdicts.length < 9
108
- || record.judgeVerdicts.length % 9 !== 0
120
+ || record.judgeVerdicts.length % 3 !== 0
109
121
  || !record.judgeVerdicts.every((judgment) => isHash(judgment.expectationSha256)
110
122
  && isHash(judgment.requestSha256) && isHash(judgment.responseSha256)
111
123
  && typeof judgment.expectedMeaning === "string" && judgment.expectedMeaning.length > 0
@@ -123,14 +135,100 @@ function validRecord(record, authoritative, smoke) {
123
135
  && typeof turn.response === "string"
124
136
  && isHash(turn.promptSha256) && isHash(turn.answerSha256)
125
137
  && isHash(turn.advertisedToolsSha256) && isHash(turn.selectedCallsSha256)
126
- && isHash(turn.expectedCallsSha256)
127
138
  && Array.isArray(turn.toolCalls)
128
- && JSON.stringify(turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))
129
- === JSON.stringify(turn.expectedCalls)
130
- && turn.toolCalls.every((call) => Object.hasOwn(call, "result"))
131
- && JSON.stringify(turn.selectedTools) === JSON.stringify(turn.expectedTools)
139
+ && JSON.stringify(turn.selectedTools)
140
+ === JSON.stringify(turn.toolCalls.map(({ name }) => name))
141
+ && validToolAssertions(turn, isHash)
142
+ && validNegativeFeedbackAssertion(turn)
143
+ && validFeedbackDisclosure(turn, record.judgeVerdicts, trial.trial, isHash)
144
+ && turn.toolCalls.every((call) => Object.hasOwn(call, "result") && typeof call.isError === "boolean")
132
145
  && Array.isArray(turn.pathEvidence) && turn.pathEvidence.length === turn.toolCallCount
133
146
  && turn.pathEvidence.every(({ method, target, requestSha256, responseSha256 }) =>
134
147
  method === "tools/call" && turn.selectedTools.includes(target)
135
148
  && isHash(requestSha256) && isHash(responseSha256))));
136
149
  }
150
+
151
+ function validToolAssertions(turn, isHash) {
152
+ if (turn.expectedOptionalFeedback === true) {
153
+ if (!Array.isArray(turn.expectedCalls)) return false;
154
+ const expectedHash = createHash("sha256").update(JSON.stringify({
155
+ calls: turn.expectedCalls,
156
+ optionalFeedback: true,
157
+ })).digest("hex");
158
+ const calls = turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args }));
159
+ const primaryCalls = calls.slice(0, turn.expectedCalls?.length);
160
+ const trailingCalls = calls.slice(turn.expectedCalls?.length);
161
+ return expectedHash === turn.expectedSelectionSha256
162
+ && JSON.stringify(primaryCalls) === JSON.stringify(turn.expectedCalls)
163
+ && (trailingCalls.length === 0
164
+ || (trailingCalls.length === 1 && trailingCalls[0].name === "submit-feedback"));
165
+ }
166
+ if (typeof turn.expectedOptionalTool === "string" && turn.expectedOptionalTool.trim()) {
167
+ const expectedHash = createHash("sha256").update(JSON.stringify({
168
+ optionalTool: turn.expectedOptionalTool,
169
+ })).digest("hex");
170
+ return expectedHash === turn.expectedSelectionSha256
171
+ && (turn.toolCalls.length === 0
172
+ || (turn.toolCalls.length === 1 && turn.toolCalls[0].name === turn.expectedOptionalTool));
173
+ }
174
+ if (JSON.stringify(turn.selectedTools) !== JSON.stringify(turn.expectedTools)) return false;
175
+ if (isHash(turn.expectedCallsSha256)) {
176
+ return JSON.stringify(turn.toolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))
177
+ === JSON.stringify(turn.expectedCalls);
178
+ }
179
+ if (!isHash(turn.expectedSelectionSha256)) return false;
180
+ const expectedHash = createHash("sha256").update(JSON.stringify({
181
+ tools: turn.expectedTools,
182
+ arguments: turn.expectedArguments,
183
+ feedback: turn.expectedFeedback,
184
+ })).digest("hex");
185
+ if (expectedHash !== turn.expectedSelectionSha256) return false;
186
+ for (const [name, expected] of Object.entries(turn.expectedArguments ?? {})) {
187
+ const matches = turn.toolCalls.filter((call) => call.name === name);
188
+ if (matches.length !== 1 || JSON.stringify(matches[0].arguments) !== JSON.stringify(expected)) return false;
189
+ }
190
+ if (turn.expectedFeedback) {
191
+ const calls = turn.toolCalls.filter((call) => call.name === "submit-feedback");
192
+ const detail = calls[0]?.arguments?.detail;
193
+ if (calls.length !== 1 || !turn.expectedFeedback.observation.includes(calls[0].arguments?.observation)
194
+ || typeof detail !== "string" || turn.expectedFeedback.detailIncludes.some(
195
+ (value) => !detail.toLowerCase().includes(value.toLowerCase()),
196
+ )) return false;
197
+ }
198
+ return true;
199
+ }
200
+
201
+ function validNegativeFeedbackAssertion(turn) {
202
+ if (turn.expectedNegativeFeedback !== false) return true;
203
+ const actual = turn.toolCalls.filter((call) => call.name === "submit-feedback"
204
+ && negativeFeedbackObservations.has(call.arguments?.observation));
205
+ return actual.length === 0 && JSON.stringify(actual) === JSON.stringify(turn.negativeFeedbackCalls);
206
+ }
207
+
208
+ function validFeedbackDisclosure(turn, judgments, trial, isHash) {
209
+ if (turn.expectedOptionalFeedback !== true) return turn.feedbackDisclosure === undefined;
210
+ const feedback = turn.toolCalls[turn.expectedCalls.length];
211
+ if (!feedback) return turn.feedbackDisclosure === undefined;
212
+ if (feedback.isError || typeof feedback.arguments?.observation !== "string"
213
+ || !feedback.arguments.observation.trim() || typeof feedback.arguments.detail !== "string"
214
+ || !feedback.arguments.detail.trim()) return false;
215
+ const expectedMeaning = feedbackDisclosureExpectation(feedback);
216
+ const expectationSha256 = createHash("sha256").update(expectedMeaning).digest("hex");
217
+ if (!isHash(turn.feedbackDisclosure?.expectationSha256)
218
+ || JSON.stringify(turn.feedbackDisclosure) !== JSON.stringify({
219
+ observation: feedback.arguments.observation,
220
+ detail: feedback.arguments.detail,
221
+ expectationSha256,
222
+ })) return false;
223
+ const matching = judgments.filter((judgment) => judgment.trial === trial
224
+ && judgment.turn === turn.turn && judgment.expectationSha256 === expectationSha256);
225
+ return matching.length === 3 && matching.every((judgment) =>
226
+ judgment.expectedMeaning === expectedMeaning && judgment.verdict?.pass === true);
227
+ }
228
+
229
+ function feedbackDisclosureExpectation(call) {
230
+ return "The final assistant response makes it clear that feedback was submitted, recorded, or noted, "
231
+ + "and communicates the substance "
232
+ + `of this specific ${call.arguments.observation} observation: ${call.arguments.detail} `
233
+ + "A concise, faithful summary is enough. It need not repeat every example, field, or phrase.";
234
+ }
@@ -84,7 +84,11 @@ export function parseNativeClaudeEvents(stdout, advertisedTools, requireInit = f
84
84
  }
85
85
  return { name: publicName, arguments: input };
86
86
  });
87
- if (toolUses.length > 3) throw new Error("Model command used more than three tools");
87
+ if (toolUses.length > 3) {
88
+ throw Object.assign(new Error("Model command used more than three tools"), {
89
+ attemptedToolCalls: calls,
90
+ });
91
+ }
88
92
  if (init) {
89
93
  const available = [...(init.tools ?? [])].sort();
90
94
  const expected = [...advertised.keys()].sort();
@@ -134,7 +138,10 @@ export function parseNativeClaudeEvents(stdout, advertisedTools, requireInit = f
134
138
  return {
135
139
  answer: result.result,
136
140
  calls,
137
- toolResults: toolUses.map(({ id }) => toolResults.get(id).content),
141
+ toolResults: toolUses.map(({ id }) => {
142
+ const toolResult = toolResults.get(id);
143
+ return { content: toolResult.content, isError: toolResult.is_error === true };
144
+ }),
138
145
  pathEvidence,
139
146
  models: Object.keys(result.modelUsage),
140
147
  turnCount: 1,
@@ -31,7 +31,26 @@ export function createConversation(
31
31
  ): Promise<SemanticConversation>;
32
32
 
33
33
  export function assertToolCalls(turn: ConversationTurn, expected: readonly ToolCall[]): void;
34
+ /** Requires the exact primary calls and allows one trailing submit-feedback call. */
35
+ export function assertToolCallsWithOptionalFeedback(
36
+ turn: ConversationTurn,
37
+ expected: readonly ToolCall[],
38
+ ): Promise<void>;
34
39
  export function assertNoToolCalls(turn: ConversationTurn): void;
40
+ /** Allows no tool call or exactly one call to the named tool in each trial. */
41
+ export function assertOptionalToolCall(turn: ConversationTurn, name: string): void;
42
+ export function assertToolNames(turn: ConversationTurn, expected: readonly string[]): void;
43
+ export function assertToolArguments(
44
+ turn: ConversationTurn,
45
+ name: string,
46
+ expected: Record<string, unknown>,
47
+ ): void;
48
+ export function assertFeedback(
49
+ turn: ConversationTurn,
50
+ expectation: { observation: string | readonly string[]; detailIncludes: readonly string[] },
51
+ ): void;
52
+ /** Asserts that successful turns did not submit an error, friction, or other negative observation. */
53
+ export function assertNoNegativeFeedback(...turns: readonly ConversationTurn[]): void;
35
54
  export function assertResponseContains(turn: ConversationTurn, expected: string | readonly string[]): void;
36
55
  export function assertResponseMeaning(
37
56
  turn: ConversationTurn,
package/semantic/test.mjs CHANGED
@@ -101,7 +101,15 @@ export async function createConversation(testContext, options) {
101
101
  );
102
102
  const answer = await trial.model.send(prompt);
103
103
  const calls = answer.calls;
104
- trial.history.push({ user: prompt, assistant: answer.answer });
104
+ trial.history.push({
105
+ user: prompt,
106
+ toolCalls: calls.map((call, index) => ({
107
+ ...call,
108
+ result: answer.toolResults[index].content,
109
+ isError: answer.toolResults[index].isError,
110
+ })),
111
+ assistant: answer.answer,
112
+ });
105
113
  const record = {
106
114
  turn: trial.record.turns.length + 1,
107
115
  interactionMode: "native-mcp",
@@ -109,7 +117,8 @@ export async function createConversation(testContext, options) {
109
117
  response: answer.answer,
110
118
  toolCalls: calls.map((call, index) => ({
111
119
  ...call,
112
- result: answer.toolResults[index],
120
+ result: answer.toolResults[index].content,
121
+ isError: answer.toolResults[index].isError,
113
122
  })),
114
123
  promptSha256: hash(prompt),
115
124
  answerSha256: hash(answer.answer),
@@ -141,7 +150,12 @@ export async function createConversation(testContext, options) {
141
150
  });
142
151
  }
143
152
  } catch (error) {
144
- if (activeTrial) activeTrial.record.error = safeModelFailure(error);
153
+ if (activeTrial) {
154
+ activeTrial.record.error = safeModelFailure(error);
155
+ if (Array.isArray(error?.attemptedToolCalls)) {
156
+ activeTrial.record.attemptedToolCalls = error.attemptedToolCalls;
157
+ }
158
+ }
145
159
  state.failed = true;
146
160
  evidence.failedPhase = "conversation turn";
147
161
  throw new Error(`Semantic test failed during conversation turn: ${name}`);
@@ -157,11 +171,7 @@ export async function createConversation(testContext, options) {
157
171
 
158
172
  export function assertToolCalls(turn, expected) {
159
173
  const trials = turnTrials(turn);
160
- if (!Array.isArray(expected) || expected.some((call) => !call || typeof call.name !== "string"
161
- || !call.name.trim() || !call.arguments || typeof call.arguments !== "object"
162
- || Array.isArray(call.arguments))) {
163
- throw new Error("Expected tool calls must have names and object arguments");
164
- }
174
+ validateExpectedCalls(expected);
165
175
  for (const trial of trials) {
166
176
  trial.record.expectedTools = expected.map(({ name }) => name);
167
177
  trial.record.expectedCalls = expected;
@@ -175,10 +185,161 @@ export function assertToolCalls(turn, expected) {
175
185
  }
176
186
  }
177
187
 
188
+ export async function assertToolCallsWithOptionalFeedback(turn, expected) {
189
+ const trials = turnTrials(turn);
190
+ validateExpectedCalls(expected);
191
+ for (const trial of trials) {
192
+ trial.record.expectedTools = expected.map(({ name }) => name);
193
+ trial.record.expectedCalls = expected;
194
+ trial.record.expectedOptionalFeedback = true;
195
+ trial.record.expectedSelectionSha256 = hash(JSON.stringify({ calls: expected, optionalFeedback: true }));
196
+ const primaryCalls = trial.calls.slice(0, expected.length);
197
+ const trailingCalls = trial.calls.slice(expected.length);
198
+ try {
199
+ assert.deepStrictEqual(primaryCalls, expected);
200
+ assert.ok(trailingCalls.length <= 1);
201
+ if (trailingCalls.length === 1) assert.equal(trailingCalls[0].name, "submit-feedback");
202
+ } catch {
203
+ failAssertion(trials, "tool-call assertion");
204
+ throw new Error("Tool calls did not match the expected calls with at most one trailing feedback call");
205
+ }
206
+ }
207
+ let failed = false;
208
+ for (let trialIndex = 0; trialIndex < trials.length; trialIndex += 1) {
209
+ const trial = trials[trialIndex];
210
+ const feedback = trial.calls[expected.length];
211
+ if (!feedback) continue;
212
+ const recordedCall = trial.record.toolCalls[expected.length];
213
+ if (typeof feedback.arguments.observation !== "string" || !feedback.arguments.observation.trim()
214
+ || typeof feedback.arguments.detail !== "string" || !feedback.arguments.detail.trim()) {
215
+ failed = true;
216
+ continue;
217
+ }
218
+ const expectation = feedbackDisclosureExpectation(feedback);
219
+ trial.record.feedbackDisclosure = {
220
+ observation: feedback.arguments.observation,
221
+ detail: feedback.arguments.detail,
222
+ expectationSha256: hash(expectation),
223
+ };
224
+ const disclosureFailed = await judgeTrialMeaning(trial, trialIndex, expectation);
225
+ if (recordedCall.isError || disclosureFailed) failed = true;
226
+ }
227
+ if (failed) {
228
+ failAssertion(trials, "feedback-disclosure assertion");
229
+ throw new Error("Optional feedback failed or was not openly described in the response");
230
+ }
231
+ }
232
+
178
233
  export function assertNoToolCalls(turn) {
179
234
  assertToolCalls(turn, []);
180
235
  }
181
236
 
237
+ export function assertOptionalToolCall(turn, name) {
238
+ const trials = turnTrials(turn);
239
+ if (typeof name !== "string" || !name.trim()) {
240
+ throw new Error("Optional tool-call expectation needs a tool name");
241
+ }
242
+ for (const trial of trials) {
243
+ trial.record.expectedOptionalTool = name;
244
+ trial.record.expectedSelectionSha256 = hash(JSON.stringify({ optionalTool: name }));
245
+ if (trial.calls.length > 1 || (trial.calls.length === 1 && trial.calls[0].name !== name)) {
246
+ failAssertion([trial], "optional tool-call assertion");
247
+ throw new Error(`Expected no tool call or one ${name} call`);
248
+ }
249
+ }
250
+ }
251
+
252
+ export function assertToolNames(turn, expected) {
253
+ const trials = turnTrials(turn);
254
+ if (!Array.isArray(expected) || expected.some((name) => typeof name !== "string" || !name.trim())) {
255
+ throw new Error("Expected tool names must be a string array");
256
+ }
257
+ for (const trial of trials) {
258
+ trial.record.expectedTools = expected;
259
+ recordFlexibleExpectation(trial.record);
260
+ }
261
+ try {
262
+ for (const trial of trials) assert.deepStrictEqual(trial.calls.map(({ name }) => name), expected);
263
+ } catch {
264
+ failAssertion(trials, "tool-name assertion");
265
+ throw new Error("Tool names did not match the expected order and count");
266
+ }
267
+ }
268
+
269
+ export function assertToolArguments(turn, name, expected) {
270
+ const trials = turnTrials(turn);
271
+ if (typeof name !== "string" || !name.trim() || !expected || typeof expected !== "object"
272
+ || Array.isArray(expected)) {
273
+ throw new Error("Tool argument expectation needs a name and object arguments");
274
+ }
275
+ for (const trial of trials) {
276
+ const matches = trial.calls.filter((call) => call.name === name);
277
+ trial.record.expectedArguments ??= {};
278
+ trial.record.expectedArguments[name] = expected;
279
+ recordFlexibleExpectation(trial.record);
280
+ try {
281
+ assert.equal(matches.length, 1);
282
+ assert.deepStrictEqual(matches[0].arguments, expected);
283
+ } catch {
284
+ failAssertion([trial], "tool-argument assertion");
285
+ throw new Error(`Arguments for ${name} did not match exactly`);
286
+ }
287
+ }
288
+ }
289
+
290
+ export function assertFeedback(turn, expectation) {
291
+ const trials = turnTrials(turn);
292
+ const observations = typeof expectation?.observation === "string"
293
+ ? [expectation.observation]
294
+ : expectation?.observation;
295
+ const detailIncludes = expectation?.detailIncludes;
296
+ if (!Array.isArray(observations) || observations.length === 0
297
+ || !Array.isArray(detailIncludes) || detailIncludes.length === 0
298
+ || [...observations, ...detailIncludes].some((value) => typeof value !== "string" || !value.trim())) {
299
+ throw new Error("Feedback expectation needs observation and detailIncludes strings");
300
+ }
301
+ for (const trial of trials) {
302
+ trial.record.expectedFeedback = { observation: observations, detailIncludes };
303
+ recordFlexibleExpectation(trial.record);
304
+ const calls = trial.calls.filter(({ name }) => name === "submit-feedback");
305
+ const detail = calls[0]?.arguments?.detail;
306
+ if (calls.length !== 1
307
+ || !observations.includes(calls[0].arguments?.observation)
308
+ || typeof detail !== "string"
309
+ || detailIncludes.some((value) => !detail.toLowerCase().includes(value.toLowerCase()))) {
310
+ failAssertion([trial], "feedback assertion");
311
+ throw new Error("Feedback did not match the expected observation and useful detail");
312
+ }
313
+ }
314
+ }
315
+
316
+ const negativeFeedbackObservations = new Set([
317
+ "error",
318
+ "friction",
319
+ "annoyance",
320
+ "unnecessary_difficulty",
321
+ "confusion",
322
+ "repetition",
323
+ "unexpected_bad_result",
324
+ "capability_mismatch",
325
+ ]);
326
+
327
+ export function assertNoNegativeFeedback(...turns) {
328
+ if (turns.length === 0) throw new Error("No-negative-feedback assertion needs at least one turn");
329
+ const trials = turns.flatMap(turnTrials);
330
+ for (const trial of trials) {
331
+ const offendingCalls = trial.calls.filter((call) =>
332
+ call.name === "submit-feedback"
333
+ && negativeFeedbackObservations.has(call.arguments?.observation));
334
+ trial.record.expectedNegativeFeedback = false;
335
+ trial.record.negativeFeedbackCalls = offendingCalls;
336
+ if (offendingCalls.length > 0) {
337
+ failAssertion([trial], "negative-feedback assertion");
338
+ throw new Error("A successful example interaction recorded negative feedback");
339
+ }
340
+ }
341
+ }
342
+
182
343
  export function assertResponseContains(turn, expected) {
183
344
  const trials = turnTrials(turn);
184
345
  const values = typeof expected === "string" ? [expected] : expected;
@@ -211,41 +372,7 @@ export async function assertResponseMeaning(turn, expectation) {
211
372
  for (let trialIndex = 0; trialIndex < trials.length; trialIndex += 1) {
212
373
  const trial = trials[trialIndex];
213
374
  trial.record.expectedMeaning = expectation.expected;
214
- for (let judgment = 1; judgment <= 3; judgment += 1) {
215
- const request = judgePrompt(trial.history, expectation.expected);
216
- const record = {
217
- trial: trialIndex + 1,
218
- turn: trial.record.turn,
219
- judgment,
220
- expectedMeaning: expectation.expected,
221
- expectationSha256: hash(expectation.expected),
222
- requestSha256: hash(request),
223
- };
224
- try {
225
- const response = await isolatedModel(
226
- trial.provider,
227
- request,
228
- "emseepea-judge-",
229
- trial.signal,
230
- );
231
- Object.assign(record, {
232
- models: response.models,
233
- turnCount: response.turnCount,
234
- providerTurnCount: response.providerTurnCount,
235
- providerToolCount: response.providerToolCount,
236
- responseSha256: hash(response.answer),
237
- });
238
- const verdict = parseJudgeVerdict(response.answer.trim());
239
- record.verdict = verdict;
240
- if (!verdict.pass) failed = true;
241
- } catch (error) {
242
- record.error = error instanceof SyntaxError || error.message === "Judge returned an invalid verdict"
243
- ? "invalid judge verdict"
244
- : safeModelFailure(error);
245
- failed = true;
246
- }
247
- trial.evidence.judgeVerdicts.push(record);
248
- }
375
+ if (await judgeTrialMeaning(trial, trialIndex, expectation.expected)) failed = true;
249
376
  trial.record.meaningAssertionCount += 1;
250
377
  }
251
378
  trials[0].state.meaningAssertions += 1;
@@ -255,6 +382,41 @@ export async function assertResponseMeaning(turn, expectation) {
255
382
  }
256
383
  }
257
384
 
385
+ async function judgeTrialMeaning(trial, trialIndex, expected) {
386
+ let failed = false;
387
+ for (let judgment = 1; judgment <= 3; judgment += 1) {
388
+ const request = judgePrompt(trial.history, expected);
389
+ const record = {
390
+ trial: trialIndex + 1,
391
+ turn: trial.record.turn,
392
+ judgment,
393
+ expectedMeaning: expected,
394
+ expectationSha256: hash(expected),
395
+ requestSha256: hash(request),
396
+ };
397
+ try {
398
+ const response = await isolatedModel(trial.provider, request, "emseepea-judge-", trial.signal);
399
+ Object.assign(record, {
400
+ models: response.models,
401
+ turnCount: response.turnCount,
402
+ providerTurnCount: response.providerTurnCount,
403
+ providerToolCount: response.providerToolCount,
404
+ responseSha256: hash(response.answer),
405
+ });
406
+ const verdict = parseJudgeVerdict(response.answer.trim());
407
+ record.verdict = verdict;
408
+ if (!verdict.pass) failed = true;
409
+ } catch (error) {
410
+ record.error = error instanceof SyntaxError || error.message === "Judge returned an invalid verdict"
411
+ ? "invalid judge verdict"
412
+ : safeModelFailure(error);
413
+ failed = true;
414
+ }
415
+ trial.evidence.judgeVerdicts.push(record);
416
+ }
417
+ return failed;
418
+ }
419
+
258
420
  function safeModelFailure(error) {
259
421
  const message = error instanceof Error ? error.message : "";
260
422
  const safeMessages = new Set([
@@ -313,7 +475,9 @@ async function closeConversation(state, evidence, output) {
313
475
  }));
314
476
  const complete = !state.failed && state.meaningAssertions > 0 && evidence.answerTrials.length === 3
315
477
  && evidence.answerTrials.every(({ turns }) => turns.length > 0
316
- && turns.every((turn) => Array.isArray(turn.expectedTools)));
478
+ && turns.every((turn) => Array.isArray(turn.expectedTools)
479
+ || turn.expectedOptionalFeedback === true
480
+ || typeof turn.expectedOptionalTool === "string"));
317
481
  if (complete) {
318
482
  evidence.status = "passed";
319
483
  } else if (!evidence.failedPhase) {
@@ -322,7 +486,7 @@ async function closeConversation(state, evidence, output) {
322
486
  await mkdir(dirname(output), { recursive: true });
323
487
  await writeFile(output, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600 });
324
488
  if (!complete && !state.failed) {
325
- throw new Error("Semantic conversation needs exact tool-call assertions for every turn and a meaning assertion");
489
+ throw new Error("Semantic conversation needs tool-selection assertions for every turn and a meaning assertion");
326
490
  }
327
491
  }
328
492
 
@@ -338,11 +502,34 @@ function turnTrials(turn) {
338
502
  return trials;
339
503
  }
340
504
 
505
+ function validateExpectedCalls(expected) {
506
+ if (!Array.isArray(expected) || expected.some((call) => !call || typeof call.name !== "string"
507
+ || !call.name.trim() || !call.arguments || typeof call.arguments !== "object"
508
+ || Array.isArray(call.arguments))) {
509
+ throw new Error("Expected tool calls must have names and object arguments");
510
+ }
511
+ }
512
+
513
+ function feedbackDisclosureExpectation(call) {
514
+ return "The final assistant response makes it clear that feedback was submitted, recorded, or noted, "
515
+ + "and communicates the substance "
516
+ + `of this specific ${call.arguments.observation} observation: ${call.arguments.detail} `
517
+ + "A concise, faithful summary is enough. It need not repeat every example, field, or phrase.";
518
+ }
519
+
341
520
  function failAssertion(trials, phase) {
342
521
  for (const trial of trials) trial.state.failed = true;
343
522
  trials[0].evidence.failedPhase = phase;
344
523
  }
345
524
 
525
+ function recordFlexibleExpectation(record) {
526
+ record.expectedSelectionSha256 = hash(JSON.stringify({
527
+ tools: record.expectedTools,
528
+ arguments: record.expectedArguments,
529
+ feedback: record.expectedFeedback,
530
+ }));
531
+ }
532
+
346
533
  function judgePrompt(history, expected) {
347
534
  return [
348
535
  "Judge whether the final assistant response communicates the complete expected meaning in this conversation.",