@emseepea/testing 0.5.3 → 0.9.3
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 +49 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -6
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/semantic/cli.mjs +104 -6
- package/semantic/provider.mjs +19 -9
- package/semantic/test.d.mts +19 -0
- package/semantic/test.mjs +232 -45
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
|
|
@@ -30,6 +57,28 @@ never production. Resources and prompts need deterministic protocol tests;
|
|
|
30
57
|
this library does not pretend that manually injecting their content proves a
|
|
31
58
|
native user journey.
|
|
32
59
|
|
|
60
|
+
## Test Extension Composition
|
|
61
|
+
|
|
62
|
+
Use `startEmseepea` in ordinary tests when you have an app factory and want to
|
|
63
|
+
exercise it in the same process. `insecureTestAuthentication` supplies a small
|
|
64
|
+
test-only verifier for protected-access examples:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import { insecureTestAuthentication, startEmseepea } from "@emseepea/testing";
|
|
68
|
+
|
|
69
|
+
const running = await startEmseepea(t, await createApp({
|
|
70
|
+
access: { access: "protected", requiredScopes: ["peas:read"] },
|
|
71
|
+
authentication: insecureTestAuthentication(["peas:read"]),
|
|
72
|
+
observability: [{ id: "test", emit: (event) => events.push(event) }],
|
|
73
|
+
}), { token: "test-token" });
|
|
74
|
+
|
|
75
|
+
const client = await running.connect();
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Never deploy `insecureTestAuthentication`. It accepts any supplied bearer token
|
|
79
|
+
and exists only to keep ordinary tests focused on composition and authorization
|
|
80
|
+
flow. Test a real verifier separately against its provider contract.
|
|
81
|
+
|
|
33
82
|
When a conversation writes to external state, pass an `environment` function
|
|
34
83
|
that returns a separate test database connection for each trial. The trial
|
|
35
84
|
number selects infrastructure only. It is never sent to the model or MCP
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Client } from "@modelcontextprotocol/client";
|
|
2
|
+
import { serveEmseepea, type AuthenticationOptions } from "@emseepea/server";
|
|
2
3
|
export interface TestCleanup {
|
|
3
4
|
after(cleanup: () => Promise<void>): void;
|
|
4
5
|
}
|
|
@@ -16,5 +17,8 @@ export interface RunningMcpServer {
|
|
|
16
17
|
}>;
|
|
17
18
|
url: URL;
|
|
18
19
|
}
|
|
20
|
+
/** Test-only verifier. Never use it in a deployed server. */
|
|
21
|
+
export declare function insecureTestAuthentication(permissions: readonly string[], discovery?: "public" | "protected"): AuthenticationOptions;
|
|
22
|
+
export declare function startEmseepea(test: TestCleanup, app: Parameters<typeof serveEmseepea>[0], options?: Pick<StartMcpServerOptions, "clientName" | "token">): Promise<RunningMcpServer>;
|
|
19
23
|
export declare function startMcpServer(test: TestCleanup, serverUrl: URL, options?: StartMcpServerOptions): Promise<RunningMcpServer>;
|
|
20
24
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAiC,MAAM,8BAA8B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAiC,MAAM,8BAA8B,CAAC;AACrF,OAAO,EAAE,aAAa,EAAE,KAAK,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAE7E,MAAM,WAAW,WAAW;IAC1B,KAAK,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CAC3C;AAED,MAAM,WAAW,qBAAqB;IACpC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAChC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACzC,MAAM,IAAI,QAAQ,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvD,GAAG,EAAE,GAAG,CAAC;CACV;AAED,6DAA6D;AAC7D,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,SAAS,MAAM,EAAE,EAC9B,SAAS,GAAE,QAAQ,GAAG,WAAyB,GAC9C,qBAAqB,CAyBvB;AAED,wBAAsB,aAAa,CACjC,IAAI,EAAE,WAAW,EACjB,GAAG,EAAE,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC,CAAC,CAAC,EACxC,OAAO,GAAE,IAAI,CAAC,qBAAqB,EAAE,YAAY,GAAG,OAAO,CAAM,GAChE,OAAO,CAAC,gBAAgB,CAAC,CAY3B;AAED,wBAAsB,cAAc,CAClC,IAAI,EAAE,WAAW,EACjB,SAAS,EAAE,GAAG,EACd,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,gBAAgB,CAAC,CAsD3B"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
4
|
+
import { serveEmseepea } from "@emseepea/server";
|
|
5
|
+
/** Test-only verifier. Never use it in a deployed server. */
|
|
6
|
+
export function insecureTestAuthentication(permissions, discovery = "protected") {
|
|
7
|
+
const resourceServerUrl = new URL("https://test.example/mcp");
|
|
8
|
+
return {
|
|
9
|
+
discovery,
|
|
10
|
+
verifier: {
|
|
11
|
+
async verifyAccessToken(token) {
|
|
12
|
+
return {
|
|
13
|
+
token,
|
|
14
|
+
clientId: "emseepea-test-client",
|
|
15
|
+
scopes: [...permissions],
|
|
16
|
+
expiresAt: Math.floor(Date.now() / 1_000) + 60,
|
|
17
|
+
resource: resourceServerUrl,
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
metadata: {
|
|
22
|
+
resourceServerUrl,
|
|
23
|
+
oauthMetadata: {
|
|
24
|
+
issuer: "https://auth.test.example",
|
|
25
|
+
authorization_endpoint: "https://auth.test.example/authorize",
|
|
26
|
+
token_endpoint: "https://auth.test.example/token",
|
|
27
|
+
response_types_supported: ["code"],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export async function startEmseepea(test, app, options = {}) {
|
|
33
|
+
const running = await serveEmseepea(app, { port: 0 });
|
|
34
|
+
const clients = [];
|
|
35
|
+
test.after(async () => {
|
|
36
|
+
await Promise.allSettled(clients.map((client) => client.close()));
|
|
37
|
+
await running.close();
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
...running,
|
|
41
|
+
output: () => Object.freeze({ stdout: "", stderr: "" }),
|
|
42
|
+
connect: (token = options.token) => connect(running.url, clients, options.clientName, token),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
4
45
|
export async function startMcpServer(test, serverUrl, options = {}) {
|
|
5
46
|
const child = spawn(process.execPath, [fileURLToPath(serverUrl)], {
|
|
6
47
|
env: {
|
|
@@ -50,14 +91,15 @@ export async function startMcpServer(test, serverUrl, options = {}) {
|
|
|
50
91
|
return {
|
|
51
92
|
url,
|
|
52
93
|
output: () => Object.freeze({ stdout: output, stderr: errors }),
|
|
53
|
-
|
|
54
|
-
const client = new Client({ name: options.clientName ?? "emseepea-test", version: "0.0.0" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
|
|
55
|
-
await client.connect(new StreamableHTTPClientTransport(url, token ? { authProvider: { token: async () => token } } : undefined));
|
|
56
|
-
clients.push(client);
|
|
57
|
-
return client;
|
|
58
|
-
},
|
|
94
|
+
connect: (token = options.token) => connect(url, clients, options.clientName, token),
|
|
59
95
|
};
|
|
60
96
|
}
|
|
97
|
+
async function connect(url, clients, clientName = "emseepea-test", token) {
|
|
98
|
+
const client = new Client({ name: clientName, version: "0.0.0" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
|
|
99
|
+
await client.connect(new StreamableHTTPClientTransport(url, token ? { authProvider: { token: async () => token } } : undefined));
|
|
100
|
+
clients.push(client);
|
|
101
|
+
return client;
|
|
102
|
+
}
|
|
61
103
|
async function stopProcess(child) {
|
|
62
104
|
if (child.exitCode !== null)
|
|
63
105
|
return;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,MAAM,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@emseepea/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "MCP integration and semantic testing helpers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"test:built": "node --test test/*.test.mjs"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
+
"@emseepea/server": "0.6.1",
|
|
44
45
|
"@modelcontextprotocol/client": "2.0.0"
|
|
45
46
|
},
|
|
46
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 %
|
|
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.
|
|
129
|
-
=== JSON.stringify(turn.
|
|
130
|
-
&& turn
|
|
131
|
-
&&
|
|
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
|
+
}
|
package/semantic/provider.mjs
CHANGED
|
@@ -84,14 +84,21 @@ export function parseNativeClaudeEvents(stdout, advertisedTools, requireInit = f
|
|
|
84
84
|
}
|
|
85
85
|
return { name: publicName, arguments: input };
|
|
86
86
|
});
|
|
87
|
-
if (toolUses.length > 3)
|
|
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();
|
|
95
|
+
const expectedServer = advertised.size > 0
|
|
96
|
+
? init.mcp_servers?.length === 1
|
|
97
|
+
&& init.mcp_servers[0]?.name === mcpServerName
|
|
98
|
+
&& init.mcp_servers[0]?.status === "connected"
|
|
99
|
+
: init.mcp_servers?.length === 0;
|
|
91
100
|
if (JSON.stringify(available) !== JSON.stringify(expected)
|
|
92
|
-
||
|
|
93
|
-
|| init.mcp_servers[0]?.name !== mcpServerName
|
|
94
|
-
|| init.mcp_servers[0]?.status !== "connected") {
|
|
101
|
+
|| !expectedServer) {
|
|
95
102
|
throw new Error("Model command did not expose exactly the target MCP tools");
|
|
96
103
|
}
|
|
97
104
|
}
|
|
@@ -131,7 +138,10 @@ export function parseNativeClaudeEvents(stdout, advertisedTools, requireInit = f
|
|
|
131
138
|
return {
|
|
132
139
|
answer: result.result,
|
|
133
140
|
calls,
|
|
134
|
-
toolResults: toolUses.map(({ id }) =>
|
|
141
|
+
toolResults: toolUses.map(({ id }) => {
|
|
142
|
+
const toolResult = toolResults.get(id);
|
|
143
|
+
return { content: toolResult.content, isError: toolResult.is_error === true };
|
|
144
|
+
}),
|
|
135
145
|
pathEvidence,
|
|
136
146
|
models: Object.keys(result.modelUsage),
|
|
137
147
|
turnCount: 1,
|
|
@@ -189,7 +199,7 @@ export function modelInvocation(provider, prompt, directory) {
|
|
|
189
199
|
|
|
190
200
|
export function conversationInvocation(provider, directory, url, tools, authToken, context) {
|
|
191
201
|
const nativeTools = tools.map(({ name }) => nativeToolName(name));
|
|
192
|
-
const config = {
|
|
202
|
+
const config = nativeTools.length ? {
|
|
193
203
|
mcpServers: {
|
|
194
204
|
[mcpServerName]: {
|
|
195
205
|
type: "http",
|
|
@@ -197,7 +207,7 @@ export function conversationInvocation(provider, directory, url, tools, authToke
|
|
|
197
207
|
...(authToken ? { headers: { Authorization: "Bearer ${EMSEEPEA_SEMANTIC_MCP_TOKEN}" } } : {}),
|
|
198
208
|
},
|
|
199
209
|
},
|
|
200
|
-
};
|
|
210
|
+
} : undefined;
|
|
201
211
|
const base = modelInvocation(provider, "", directory);
|
|
202
212
|
return {
|
|
203
213
|
...base,
|
|
@@ -210,7 +220,7 @@ export function conversationInvocation(provider, directory, url, tools, authToke
|
|
|
210
220
|
"--effort", "low",
|
|
211
221
|
"--max-turns", "4",
|
|
212
222
|
"--strict-mcp-config",
|
|
213
|
-
"--mcp-config", JSON.stringify(config),
|
|
223
|
+
...(config ? ["--mcp-config", JSON.stringify(config)] : []),
|
|
214
224
|
"--disable-slash-commands",
|
|
215
225
|
"--no-session-persistence",
|
|
216
226
|
"--permission-mode", "dontAsk",
|
|
@@ -221,7 +231,7 @@ export function conversationInvocation(provider, directory, url, tools, authToke
|
|
|
221
231
|
"--no-chrome",
|
|
222
232
|
"--prompt-suggestions", "false",
|
|
223
233
|
],
|
|
224
|
-
env: { ...base.env, ...(authToken ? { EMSEEPEA_SEMANTIC_MCP_TOKEN: authToken } : {}) },
|
|
234
|
+
env: { ...base.env, ...(config && authToken ? { EMSEEPEA_SEMANTIC_MCP_TOKEN: authToken } : {}) },
|
|
225
235
|
};
|
|
226
236
|
}
|
|
227
237
|
|
package/semantic/test.d.mts
CHANGED
|
@@ -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({
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.",
|