@emseepea/testing 0.17.1 → 0.18.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 +29 -1
- package/package.json +1 -1
- package/semantic/elicitation.mjs +51 -0
- package/semantic/provider.mjs +27 -8
- package/semantic/test.d.mts +16 -1
- package/semantic/test.mjs +18 -4
package/README.md
CHANGED
|
@@ -60,6 +60,33 @@ never production. Resources and prompts need deterministic protocol tests;
|
|
|
60
60
|
this library does not pretend that manually injecting their content proves a
|
|
61
61
|
native user journey.
|
|
62
62
|
|
|
63
|
+
## Scripted Confirmation Input
|
|
64
|
+
|
|
65
|
+
For tools that request form elicitation, supply an explicit simulated human
|
|
66
|
+
response for that turn. The script answers the real native client's request;
|
|
67
|
+
it is not sent to the model and does not replace a tool result.
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
const turn = await conversation.send("Create a contact named Synthetic Customer.", {
|
|
71
|
+
elicitations: [{
|
|
72
|
+
messageIncludes: ["Create", "Synthetic Customer"],
|
|
73
|
+
response: { action: "accept", content: { decision: "create" } },
|
|
74
|
+
}],
|
|
75
|
+
});
|
|
76
|
+
assertNoNegativeFeedback(turn);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Choose fragments that identify the exact proposed effect and recipient where
|
|
80
|
+
applicable. Every fragment must match one actual form request. Unexpected,
|
|
81
|
+
ambiguous, duplicate, and unused scripted responses fail the turn. No request
|
|
82
|
+
is approved by default; URL elicitation is unsupported.
|
|
83
|
+
|
|
84
|
+
Each of the three trials records the form message, schema, and scripted human
|
|
85
|
+
decision in `turn.elicitations` and saved evidence. This proves the tested
|
|
86
|
+
native client exchange with simulated human input, not compatibility with a
|
|
87
|
+
different client or a person's actual consent. Continue to use isolated,
|
|
88
|
+
synthetic servers; never point these tests at production.
|
|
89
|
+
|
|
63
90
|
## Test Extension Composition
|
|
64
91
|
|
|
65
92
|
Use `startEmseepea` in ordinary tests when you have an app factory and want to
|
|
@@ -381,6 +408,7 @@ tool arguments, tool results, assertions, or application context.
|
|
|
381
408
|
|
|
382
409
|
Provider events, MCP addresses, configuration, headers, provider and harness
|
|
383
410
|
credentials, environment values, stderr, and home-directory paths are not
|
|
384
|
-
retained.
|
|
411
|
+
retained. Known configured authentication values are scrubbed from elicitation
|
|
412
|
+
evidence. Arbitrary secrets placed inside test content are not detected.
|
|
385
413
|
Local evidence is written with mode `0600`; repository CI retains it for 14
|
|
386
414
|
days.
|
package/package.json
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Scripted human input through the native client's control channel, never model guidance.
|
|
2
|
+
export function redactElicitationEvidence(value, secrets) {
|
|
3
|
+
let json = JSON.stringify(value);
|
|
4
|
+
for (const secret of secrets.filter((item) => typeof item === "string" && item)) {
|
|
5
|
+
// Escape exactly as JSON does, so embedded quotes and backslashes are scrubbed too.
|
|
6
|
+
const escaped = JSON.stringify(secret).slice(1, -1);
|
|
7
|
+
json = json.split(escaped).join("[REDACTED]");
|
|
8
|
+
}
|
|
9
|
+
return JSON.parse(json);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function scriptedElicitations(options = {}, secrets = []) {
|
|
13
|
+
const expected = options.elicitations ?? [];
|
|
14
|
+
if (!Array.isArray(expected) || expected.length > 8) throw new Error("Invalid scripted elicitations");
|
|
15
|
+
for (const entry of expected) {
|
|
16
|
+
if (!entry || !Array.isArray(entry.messageIncludes) || entry.messageIncludes.length === 0
|
|
17
|
+
|| entry.messageIncludes.length > 8
|
|
18
|
+
|| entry.messageIncludes.some((text) => typeof text !== "string" || !text || text.length > 4096)
|
|
19
|
+
|| !entry.response || !["accept", "decline", "cancel"].includes(entry.response.action)
|
|
20
|
+
|| JSON.stringify(entry.response).length > 16_384) throw new Error("Invalid scripted elicitation");
|
|
21
|
+
}
|
|
22
|
+
const fixtures = structuredClone(expected);
|
|
23
|
+
const used = new Set();
|
|
24
|
+
const evidence = [];
|
|
25
|
+
return {
|
|
26
|
+
evidence,
|
|
27
|
+
respond(request) {
|
|
28
|
+
if (request?.subtype !== "elicitation" || request.mcp_server_name !== "emseepea_eval"
|
|
29
|
+
|| (request.mode !== undefined && request.mode !== "form")
|
|
30
|
+
|| typeof request.message !== "string" || request.message.length > 16_384
|
|
31
|
+
|| !request.requested_schema || typeof request.requested_schema !== "object"
|
|
32
|
+
|| JSON.stringify(request.requested_schema).length > 16_384) {
|
|
33
|
+
throw new Error("Unsupported native elicitation request");
|
|
34
|
+
}
|
|
35
|
+
const item = redactElicitationEvidence({ mode: "form", message: request.message,
|
|
36
|
+
requestedSchema: request.requested_schema }, secrets);
|
|
37
|
+
evidence.push(item);
|
|
38
|
+
const matches = fixtures.map((entry, index) => ({ entry, index }))
|
|
39
|
+
.filter(({ entry }) => entry.messageIncludes.every((text) => request.message.includes(text)));
|
|
40
|
+
if (matches.length !== 1 || used.has(matches[0].index)) throw new Error("Unexpected or duplicate native elicitation");
|
|
41
|
+
const { entry, index } = matches[0];
|
|
42
|
+
used.add(index);
|
|
43
|
+
item.response = redactElicitationEvidence(entry.response, secrets);
|
|
44
|
+
item.inputSource = "scripted-human";
|
|
45
|
+
return entry.response;
|
|
46
|
+
},
|
|
47
|
+
finish() {
|
|
48
|
+
if (used.size !== fixtures.length) throw new Error("Unused scripted elicitation response");
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
package/semantic/provider.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { scriptedElicitations } from "./elicitation.mjs";
|
|
4
5
|
|
|
5
6
|
const model = "claude-sonnet-4-6";
|
|
6
7
|
const mcpServerName = "emseepea_eval";
|
|
@@ -236,7 +237,7 @@ export function conversationInvocation(provider, directory, url, tools, authToke
|
|
|
236
237
|
};
|
|
237
238
|
}
|
|
238
239
|
|
|
239
|
-
export function startModelConversation(provider, directory, url, tools, authToken, context, signal) {
|
|
240
|
+
export function startModelConversation(provider, directory, url, tools, authToken, context, signal, serverSecrets = []) {
|
|
240
241
|
signal?.throwIfAborted();
|
|
241
242
|
const invocation = conversationInvocation(provider, directory, url, tools, authToken, context);
|
|
242
243
|
const child = spawn(invocation.command, invocation.args, {
|
|
@@ -250,12 +251,13 @@ export function startModelConversation(provider, directory, url, tools, authToke
|
|
|
250
251
|
let initialEvents = [];
|
|
251
252
|
let initialized = false;
|
|
252
253
|
let closed = false;
|
|
254
|
+
const controlIds = new Set();
|
|
253
255
|
const fail = (message) => {
|
|
254
256
|
if (!pending) return;
|
|
255
257
|
clearTimeout(pending.timer);
|
|
256
|
-
const reject = pending
|
|
258
|
+
const { reject, script } = pending;
|
|
257
259
|
pending = undefined;
|
|
258
|
-
reject(new Error(message));
|
|
260
|
+
reject(Object.assign(new Error(message), { elicitations: script.evidence }));
|
|
259
261
|
};
|
|
260
262
|
const abort = () => { fail("Model conversation was cancelled"); child.kill("SIGKILL"); };
|
|
261
263
|
signal?.addEventListener("abort", abort, { once: true });
|
|
@@ -277,6 +279,21 @@ export function startModelConversation(provider, directory, url, tools, authToke
|
|
|
277
279
|
child.kill("SIGKILL");
|
|
278
280
|
return;
|
|
279
281
|
}
|
|
282
|
+
if (event.type === "control_request") {
|
|
283
|
+
try {
|
|
284
|
+
if (!pending || typeof event.request_id !== "string" || !event.request_id
|
|
285
|
+
|| controlIds.has(event.request_id)) throw new Error("Unexpected native control request");
|
|
286
|
+
controlIds.add(event.request_id);
|
|
287
|
+
const response = pending.script.respond(event.request);
|
|
288
|
+
child.stdin.write(`${JSON.stringify({ type: "control_response", response: {
|
|
289
|
+
subtype: "success", request_id: event.request_id, response,
|
|
290
|
+
} })}\n`);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
fail(error.message);
|
|
293
|
+
child.kill("SIGKILL");
|
|
294
|
+
}
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
280
297
|
if (!pending) {
|
|
281
298
|
initialEvents.push(event);
|
|
282
299
|
continue;
|
|
@@ -284,13 +301,14 @@ export function startModelConversation(provider, directory, url, tools, authToke
|
|
|
284
301
|
pending.events.push(event);
|
|
285
302
|
if (event.type !== "result") continue;
|
|
286
303
|
clearTimeout(pending.timer);
|
|
287
|
-
const { events, resolve, reject } = pending;
|
|
304
|
+
const { events, resolve, reject, script } = pending;
|
|
288
305
|
pending = undefined;
|
|
289
306
|
try {
|
|
307
|
+
script.finish();
|
|
290
308
|
const turn = parseNativeClaudeEvents(events, tools, !initialized);
|
|
291
309
|
initialized = true;
|
|
292
|
-
resolve(turn);
|
|
293
|
-
} catch (error) { reject(error); }
|
|
310
|
+
resolve({ ...turn, elicitations: script.evidence });
|
|
311
|
+
} catch (error) { reject(Object.assign(error, { elicitations: script.evidence })); }
|
|
294
312
|
}
|
|
295
313
|
});
|
|
296
314
|
child.once("error", () => fail("Model conversation could not start"));
|
|
@@ -299,16 +317,17 @@ export function startModelConversation(provider, directory, url, tools, authToke
|
|
|
299
317
|
if (pending) fail(`Model conversation exited ${String(code)}`);
|
|
300
318
|
});
|
|
301
319
|
return Object.freeze({
|
|
302
|
-
send(prompt) {
|
|
320
|
+
send(prompt, options) {
|
|
303
321
|
if (closed || child.stdin.destroyed) throw new Error("Model conversation is closed");
|
|
304
322
|
if (pending) throw new Error("Model conversation already has a pending turn");
|
|
323
|
+
const script = scriptedElicitations(options, [authToken, invocation.env.CLAUDE_CODE_OAUTH_TOKEN, ...serverSecrets]);
|
|
305
324
|
return new Promise((resolve, reject) => {
|
|
306
325
|
const timer = setTimeout(() => {
|
|
307
326
|
fail("Model command timed out");
|
|
308
327
|
child.kill("SIGKILL");
|
|
309
328
|
}, 180_000);
|
|
310
329
|
timer.unref();
|
|
311
|
-
pending = { events: initialEvents, reject, resolve, timer };
|
|
330
|
+
pending = { events: initialEvents, reject, resolve, timer, script };
|
|
312
331
|
initialEvents = [];
|
|
313
332
|
child.stdin.write(`${JSON.stringify({
|
|
314
333
|
type: "user",
|
package/semantic/test.d.mts
CHANGED
|
@@ -18,11 +18,26 @@ export interface ConversationOptions {
|
|
|
18
18
|
export interface ConversationTurn {
|
|
19
19
|
readonly responses: readonly string[];
|
|
20
20
|
readonly toolCalls: readonly (readonly ToolCall[])[];
|
|
21
|
+
readonly elicitations: readonly (readonly ElicitationEvidence[])[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ScriptedElicitation {
|
|
25
|
+
/** Every fragment must occur in exactly one actual form request. Never sent to the model. */
|
|
26
|
+
messageIncludes: readonly string[];
|
|
27
|
+
response: { action: "accept" | "decline" | "cancel"; content?: Record<string, unknown> };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ElicitationEvidence {
|
|
31
|
+
readonly mode: "form";
|
|
32
|
+
readonly message: string;
|
|
33
|
+
readonly requestedSchema: Record<string, unknown>;
|
|
34
|
+
readonly response?: ScriptedElicitation["response"];
|
|
35
|
+
readonly inputSource?: "scripted-human";
|
|
21
36
|
}
|
|
22
37
|
|
|
23
38
|
export interface SemanticConversation {
|
|
24
39
|
/** Sends this exact user message through the same provider-native MCP conversation. */
|
|
25
|
-
send(prompt: string): Promise<ConversationTurn>;
|
|
40
|
+
send(prompt: string, options?: { elicitations: readonly ScriptedElicitation[] }): Promise<ConversationTurn>;
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
export function createConversation(
|
package/semantic/test.mjs
CHANGED
|
@@ -60,15 +60,19 @@ export async function createConversation(testContext, options) {
|
|
|
60
60
|
|
|
61
61
|
try {
|
|
62
62
|
for (let trial = 1; trial <= 3; trial += 1) {
|
|
63
|
+
const environment = environmentForTrial(specification.environment, trial);
|
|
63
64
|
const running = await startSemanticServer({
|
|
64
65
|
...specification,
|
|
65
|
-
environment
|
|
66
|
+
environment,
|
|
66
67
|
}, testContext.signal);
|
|
67
68
|
try {
|
|
68
69
|
const tools = await listMcpTools(running.url, specification, testContext.signal);
|
|
69
70
|
const record = { trial, turns: [] };
|
|
70
71
|
const directory = await mkdtemp(join(tmpdir(), "emseepea-conversation-"));
|
|
71
|
-
|
|
72
|
+
const serverSecrets = Object.entries(environment ?? {})
|
|
73
|
+
.filter(([key]) => /token|password|secret|credential|api.?key/i.test(key))
|
|
74
|
+
.map(([, value]) => value);
|
|
75
|
+
state.trials.push({ running, tools, record, directory, history: [], model: undefined, serverSecrets });
|
|
72
76
|
evidence.answerTrials.push(record);
|
|
73
77
|
} catch (error) {
|
|
74
78
|
await stopSemanticServer(running.child);
|
|
@@ -82,7 +86,7 @@ export async function createConversation(testContext, options) {
|
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
return Object.freeze({
|
|
85
|
-
async send(prompt) {
|
|
89
|
+
async send(prompt, options) {
|
|
86
90
|
if (typeof prompt !== "string" || !prompt.trim()) throw new Error("send needs a user prompt");
|
|
87
91
|
ensureOpen(state);
|
|
88
92
|
const results = await Promise.allSettled(state.trials.map(async (trial) => {
|
|
@@ -94,8 +98,9 @@ export async function createConversation(testContext, options) {
|
|
|
94
98
|
semanticAuthToken(specification),
|
|
95
99
|
specification.context,
|
|
96
100
|
testContext.signal,
|
|
101
|
+
trial.serverSecrets,
|
|
97
102
|
);
|
|
98
|
-
const answer = await trial.model.send(prompt);
|
|
103
|
+
const answer = await trial.model.send(prompt, options);
|
|
99
104
|
const calls = answer.calls;
|
|
100
105
|
trial.history.push({
|
|
101
106
|
user: prompt,
|
|
@@ -111,6 +116,7 @@ export async function createConversation(testContext, options) {
|
|
|
111
116
|
interactionMode: "native-mcp",
|
|
112
117
|
prompt,
|
|
113
118
|
response: answer.answer,
|
|
119
|
+
elicitations: answer.elicitations,
|
|
114
120
|
toolCalls: calls.map((call, index) => ({
|
|
115
121
|
...call,
|
|
116
122
|
result: answer.toolResults[index].content,
|
|
@@ -153,6 +159,7 @@ export async function createConversation(testContext, options) {
|
|
|
153
159
|
}
|
|
154
160
|
const trial = state.trials[index];
|
|
155
161
|
trial.record.error = safeModelFailure(result.reason);
|
|
162
|
+
if (Array.isArray(result.reason?.elicitations)) trial.record.elicitations = result.reason.elicitations;
|
|
156
163
|
if (Array.isArray(result.reason?.attemptedToolCalls)) {
|
|
157
164
|
trial.record.attemptedToolCalls = result.reason.attemptedToolCalls;
|
|
158
165
|
}
|
|
@@ -165,6 +172,7 @@ export async function createConversation(testContext, options) {
|
|
|
165
172
|
return Object.freeze({
|
|
166
173
|
responses: Object.freeze(trials.map(({ answer }) => answer)),
|
|
167
174
|
toolCalls: Object.freeze(trials.map(({ calls }) => Object.freeze(calls))),
|
|
175
|
+
elicitations: Object.freeze(trials.map(({ record }) => Object.freeze(record.elicitations))),
|
|
168
176
|
[privateTurn]: trials,
|
|
169
177
|
});
|
|
170
178
|
},
|
|
@@ -457,6 +465,12 @@ function safeModelFailure(error) {
|
|
|
457
465
|
"Model conversation could not start",
|
|
458
466
|
"Model conversation is closed",
|
|
459
467
|
"Model conversation was cancelled",
|
|
468
|
+
"Unexpected native control request",
|
|
469
|
+
"Unsupported native elicitation request",
|
|
470
|
+
"Unexpected or duplicate native elicitation",
|
|
471
|
+
"Unused scripted elicitation response",
|
|
472
|
+
"Invalid scripted elicitations",
|
|
473
|
+
"Invalid scripted elicitation",
|
|
460
474
|
]);
|
|
461
475
|
if (safeMessages.has(message) || /^Model (?:command|conversation) exited \d{1,3}$/.test(message)) {
|
|
462
476
|
return message.replace(/^./, (character) => character.toLowerCase());
|