@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 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. Secrets placed inside test content are not detected or redacted.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emseepea/testing",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "MCP integration and semantic testing helpers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ }
@@ -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.reject;
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",
@@ -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: environmentForTrial(specification.environment, trial),
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
- state.trials.push({ running, tools, record, directory, history: [], model: undefined });
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());