@wix/pathgrade 1.0.13 → 1.0.15
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 +30 -0
- package/dist/adapters/jest/results.js +1 -1
- package/dist/adapters/node-test/index.js +1 -1
- package/dist/agents/claude/sdk-message-projector.js +3 -2
- package/dist/agents/claude/tool-permission-bridge.d.ts +4 -0
- package/dist/agents/claude/tool-permission-bridge.js +68 -1
- package/dist/agents/claude.d.ts +2 -0
- package/dist/agents/claude.js +55 -10
- package/dist/agents/codex-app-server/agent.js +94 -76
- package/dist/agents/codex-app-server/mcp-approval-correlator.d.ts +55 -0
- package/dist/agents/codex-app-server/mcp-approval-correlator.js +299 -0
- package/dist/core/canonical-json.d.ts +2 -0
- package/dist/core/canonical-json.js +51 -0
- package/dist/core/generated-mcp-protocol.d.ts +19 -0
- package/dist/core/generated-mcp-protocol.js +26 -0
- package/dist/core/mcp-mock.d.ts +1 -1
- package/dist/core/mcp-mock.js +24 -0
- package/dist/core/mcp-mock.types.d.ts +6 -0
- package/dist/core/mcp-schema-profile.d.ts +10 -0
- package/dist/core/mcp-schema-profile.js +91 -0
- package/dist/mcp-mock-server.js +29 -13
- package/dist/providers/mcp-config.js +4 -2
- package/dist/providers/sandbox.js +6 -0
- package/dist/providers/scripted-mcp-mock-host.d.ts +39 -0
- package/dist/providers/scripted-mcp-mock-host.js +368 -0
- package/dist/reporters/cli.js +4 -3
- package/dist/reporters/github-comment.js +1 -1
- package/dist/reporters/report-summary.js +1 -0
- package/dist/reporting/core.js +19 -5
- package/dist/reporting/types.d.ts +2 -1
- package/dist/runners/model-builders.js +1 -1
- package/dist/runners/model-validation.js +4 -1
- package/dist/runners/model.d.ts +1 -1
- package/dist/runners/report-projection.js +2 -2
- package/dist/runners/vitest-adapter.js +1 -1
- package/dist/sdk/agent.js +64 -19
- package/dist/sdk/diagnostics.d.ts +1 -0
- package/dist/sdk/diagnostics.js +6 -3
- package/dist/sdk/index.d.ts +4 -3
- package/dist/sdk/index.js +1 -1
- package/dist/sdk/lifecycle.js +3 -3
- package/dist/sdk/managed-session.d.ts +3 -0
- package/dist/sdk/managed-session.js +71 -26
- package/dist/sdk/mcp-event-input.d.ts +3 -0
- package/dist/sdk/mcp-event-input.js +8 -0
- package/dist/sdk/mcp-evidence.d.ts +39 -0
- package/dist/sdk/mcp-evidence.js +71 -12
- package/dist/sdk/mcp-mock-approvals.d.ts +40 -0
- package/dist/sdk/mcp-mock-approvals.js +235 -0
- package/dist/sdk/scripted-mcp-events.d.ts +24 -0
- package/dist/sdk/scripted-mcp-events.js +30 -0
- package/dist/sdk/types.d.ts +6 -2
- package/dist/tool-events.d.ts +16 -1
- package/dist/types.d.ts +3 -1
- package/dist/viewer.html +4 -4
- package/package.json +3 -2
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ToolEvent } from '../tool-events.js';
|
|
2
|
+
import { type CompiledMcpMockSession } from '../sdk/mcp-mock-approvals.js';
|
|
3
|
+
export interface ScriptedMcpDecision {
|
|
4
|
+
decision: 'approve' | 'deny';
|
|
5
|
+
outcome: 'matched' | 'unmatched' | 'protocol_error';
|
|
6
|
+
reason: 'rule_match' | 'no_matching_rule' | 'protocol_error';
|
|
7
|
+
ruleIndex?: number;
|
|
8
|
+
ruleLabel?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ScriptedMcpMockHost {
|
|
11
|
+
readonly claudeServers: Record<string, {
|
|
12
|
+
type: 'http';
|
|
13
|
+
url: string;
|
|
14
|
+
headers: Record<string, string>;
|
|
15
|
+
}>;
|
|
16
|
+
readonly codexConfig: {
|
|
17
|
+
mcp_servers: Record<string, {
|
|
18
|
+
url: string;
|
|
19
|
+
http_headers: Record<string, string>;
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
beginTurn(turnNumber: number): void;
|
|
23
|
+
decide(serverName: string, toolName: string, args: Record<string, unknown>): ScriptedMcpDecision;
|
|
24
|
+
isReadOnly(serverName: string, toolName: string): boolean;
|
|
25
|
+
failProtocol(message: string): void;
|
|
26
|
+
settleEvents(events: ToolEvent[]): {
|
|
27
|
+
events: ToolEvent[];
|
|
28
|
+
error?: Error;
|
|
29
|
+
};
|
|
30
|
+
dispose(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
type Observer = (identity: {
|
|
33
|
+
serverName: string;
|
|
34
|
+
toolName: string;
|
|
35
|
+
}) => void;
|
|
36
|
+
/** Non-public acceptance seam. It observes only authenticated manifest-member calls. */
|
|
37
|
+
export declare function __setScriptedMcpMockObserverForTesting(observer: Observer | null): void;
|
|
38
|
+
export declare function startScriptedMcpMockHost(plan: CompiledMcpMockSession): Promise<ScriptedMcpMockHost>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { Worker } from 'node:worker_threads';
|
|
4
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import { compileScriptedMcpSchemaForProvider } from '../core/mcp-schema-profile.js';
|
|
8
|
+
import { MCP_ANNOTATION_PROTOCOL_FLOOR } from '../core/generated-mcp-protocol.js';
|
|
9
|
+
import { getOriginalMcpInput } from '../sdk/mcp-event-input.js';
|
|
10
|
+
import { createMcpArgumentDigest, createMcpReceiptKey, matchesMcpApprovalArguments, } from '../sdk/mcp-mock-approvals.js';
|
|
11
|
+
let testObserver = null;
|
|
12
|
+
/** Non-public acceptance seam. It observes only authenticated manifest-member calls. */
|
|
13
|
+
export function __setScriptedMcpMockObserverForTesting(observer) {
|
|
14
|
+
testObserver = observer;
|
|
15
|
+
}
|
|
16
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
17
|
+
const MATCH_TIMEOUT_MS = 250;
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
20
|
+
return false;
|
|
21
|
+
const prototype = Object.getPrototypeOf(value);
|
|
22
|
+
return prototype === Object.prototype || prototype === null;
|
|
23
|
+
}
|
|
24
|
+
function asJsonObject(value) {
|
|
25
|
+
return isRecord(value) ? value : undefined;
|
|
26
|
+
}
|
|
27
|
+
function formatResponse(value) {
|
|
28
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
29
|
+
}
|
|
30
|
+
class MatcherWorker {
|
|
31
|
+
worker;
|
|
32
|
+
nextId = 1;
|
|
33
|
+
pending = new Map();
|
|
34
|
+
constructor() {
|
|
35
|
+
this.worker = new Worker(`
|
|
36
|
+
const { parentPort } = require('node:worker_threads');
|
|
37
|
+
parentPort.on('message', ({ id, cases, input }) => {
|
|
38
|
+
try {
|
|
39
|
+
let fallback;
|
|
40
|
+
for (const item of cases) {
|
|
41
|
+
if (item.when) {
|
|
42
|
+
if (new RegExp(item.when, 'i').test(input)) return parentPort.postMessage({ id, response: item.response });
|
|
43
|
+
} else if (fallback === undefined) fallback = item.response;
|
|
44
|
+
}
|
|
45
|
+
parentPort.postMessage({ id, response: fallback, missing: fallback === undefined });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
parentPort.postMessage({ id, error: error instanceof Error ? error.message : String(error) });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
`, { eval: true });
|
|
51
|
+
this.worker.on('message', (message) => {
|
|
52
|
+
const pending = this.pending.get(message.id);
|
|
53
|
+
if (!pending)
|
|
54
|
+
return;
|
|
55
|
+
this.pending.delete(message.id);
|
|
56
|
+
clearTimeout(pending.timer);
|
|
57
|
+
if (message.error)
|
|
58
|
+
pending.reject(new Error(`scripted MCP matcher failed: ${message.error}`));
|
|
59
|
+
else
|
|
60
|
+
pending.resolve(message.missing ? undefined : message.response);
|
|
61
|
+
});
|
|
62
|
+
this.worker.on('error', (error) => this.failAll(error instanceof Error ? error : new Error(String(error))));
|
|
63
|
+
this.worker.on('exit', (code) => {
|
|
64
|
+
if (code !== 0)
|
|
65
|
+
this.failAll(new Error(`scripted MCP matcher exited with code ${code}`));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
match(cases, input) {
|
|
69
|
+
const id = this.nextId++;
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
this.pending.delete(id);
|
|
73
|
+
reject(new Error('scripted MCP matcher deadline exceeded'));
|
|
74
|
+
void this.worker.terminate();
|
|
75
|
+
}, MATCH_TIMEOUT_MS);
|
|
76
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
77
|
+
this.worker.postMessage({ id, cases, input });
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async dispose() {
|
|
81
|
+
this.failAll(new Error('scripted MCP matcher disposed'));
|
|
82
|
+
await this.worker.terminate();
|
|
83
|
+
}
|
|
84
|
+
failAll(error) {
|
|
85
|
+
for (const pending of this.pending.values()) {
|
|
86
|
+
clearTimeout(pending.timer);
|
|
87
|
+
pending.reject(error);
|
|
88
|
+
}
|
|
89
|
+
this.pending.clear();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function startScriptedMcpMockHost(plan) {
|
|
93
|
+
const token = randomBytes(32).toString('base64url');
|
|
94
|
+
const receiptKey = createMcpReceiptKey();
|
|
95
|
+
const matcher = new MatcherWorker();
|
|
96
|
+
const routeToServer = new Map();
|
|
97
|
+
for (const serverName of plan.servers.keys())
|
|
98
|
+
routeToServer.set(`/${randomBytes(18).toString('base64url')}`, serverName);
|
|
99
|
+
const activeTransports = new Set();
|
|
100
|
+
let sequence = 0;
|
|
101
|
+
let currentTurn = 0;
|
|
102
|
+
let fatal;
|
|
103
|
+
let authorizations = [];
|
|
104
|
+
let receipts = [];
|
|
105
|
+
let closing = false;
|
|
106
|
+
const latch = (error) => { fatal ??= error; };
|
|
107
|
+
const httpServer = createServer(async (req, res) => {
|
|
108
|
+
try {
|
|
109
|
+
if (closing)
|
|
110
|
+
return sendHttp(res, 503, 'closing');
|
|
111
|
+
if (req.headers.host !== '127.0.0.1' && !req.headers.host?.startsWith('127.0.0.1:')) {
|
|
112
|
+
return sendHttp(res, 400, 'invalid host');
|
|
113
|
+
}
|
|
114
|
+
const authorization = req.headers.authorization;
|
|
115
|
+
if (Array.isArray(authorization) || authorization !== `Bearer ${token}`) {
|
|
116
|
+
return sendHttp(res, 401, 'unauthorized');
|
|
117
|
+
}
|
|
118
|
+
if (fatal)
|
|
119
|
+
return sendHttp(res, 500, 'scripted MCP session failed');
|
|
120
|
+
if (req.method !== 'POST')
|
|
121
|
+
return sendHttp(res, 405, 'method not allowed');
|
|
122
|
+
const serverName = routeToServer.get(req.url ?? '');
|
|
123
|
+
const compiled = serverName ? plan.servers.get(serverName) : undefined;
|
|
124
|
+
if (!compiled)
|
|
125
|
+
return sendHttp(res, 404, 'not found');
|
|
126
|
+
const body = await readJsonBody(req);
|
|
127
|
+
if (body === undefined)
|
|
128
|
+
return sendHttp(res, 400, 'invalid JSON');
|
|
129
|
+
const requestedVersion = isRecord(body) && body.method === 'initialize'
|
|
130
|
+
? isRecord(body.params) ? body.params.protocolVersion : undefined
|
|
131
|
+
: undefined;
|
|
132
|
+
if (typeof requestedVersion === 'string' && requestedVersion < MCP_ANNOTATION_PROTOCOL_FLOOR) {
|
|
133
|
+
return sendJsonRpcError(res, isRecord(body) ? body.id : null, -32602, 'MCP protocol predates Tool Annotations');
|
|
134
|
+
}
|
|
135
|
+
const server = new Server({ name: compiled.name, version: '1.0.0' }, { capabilities: { tools: {} }, ...(compiled.instructions ? { instructions: compiled.instructions } : {}) });
|
|
136
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
137
|
+
tools: [...compiled.tools.values()].map((tool) => ({
|
|
138
|
+
name: tool.name,
|
|
139
|
+
description: tool.description ?? `Mock tool: ${tool.name}`,
|
|
140
|
+
inputSchema: compileScriptedMcpSchemaForProvider(tool.inputSchema),
|
|
141
|
+
...(tool.readOnlyHint !== undefined ? { annotations: { readOnlyHint: tool.readOnlyHint } } : {}),
|
|
142
|
+
})),
|
|
143
|
+
}));
|
|
144
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
145
|
+
const tool = compiled.tools.get(request.params.name);
|
|
146
|
+
if (!tool)
|
|
147
|
+
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
148
|
+
const args = asJsonObject(request.params.arguments ?? {});
|
|
149
|
+
if (!args)
|
|
150
|
+
throw new Error('Tool arguments must be a JSON object');
|
|
151
|
+
if (currentTurn === 0) {
|
|
152
|
+
latch(new Error(`MCP call arrived outside an active turn for ${compiled.name}.${tool.name}`));
|
|
153
|
+
throw new Error('MCP call arrived outside an active turn');
|
|
154
|
+
}
|
|
155
|
+
const digest = createMcpArgumentDigest(receiptKey, args);
|
|
156
|
+
const receipt = { serverName: compiled.name, toolName: tool.name, digest, sequence: ++sequence };
|
|
157
|
+
if (tool.readOnlyHint !== true) {
|
|
158
|
+
const authorization = authorizations.find((entry) => !entry.consumed
|
|
159
|
+
&& entry.serverName === compiled.name && entry.toolName === tool.name && entry.digest === digest);
|
|
160
|
+
if (!authorization || authorization.sequence >= receipt.sequence) {
|
|
161
|
+
latch(new Error(`MCP receipt arrived without earlier authorization for ${compiled.name}.${tool.name}`));
|
|
162
|
+
throw new Error('MCP call was not authorized');
|
|
163
|
+
}
|
|
164
|
+
authorization.consumed = true;
|
|
165
|
+
}
|
|
166
|
+
receipts.push(receipt);
|
|
167
|
+
testObserver?.({ serverName: compiled.name, toolName: tool.name });
|
|
168
|
+
const response = await matcher.match(tool.cases, JSON.stringify(args));
|
|
169
|
+
if (response === undefined)
|
|
170
|
+
throw new Error(`Unknown tool: ${tool.name}`);
|
|
171
|
+
return { content: [{ type: 'text', text: formatResponse(response) }] };
|
|
172
|
+
});
|
|
173
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
174
|
+
activeTransports.add(transport);
|
|
175
|
+
transport.onclose = () => activeTransports.delete(transport);
|
|
176
|
+
await server.connect(transport);
|
|
177
|
+
await transport.handleRequest(req, res, body);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
latch(error instanceof Error ? error : new Error(String(error)));
|
|
181
|
+
if (!res.headersSent)
|
|
182
|
+
sendHttp(res, 500, 'scripted MCP host failure');
|
|
183
|
+
else
|
|
184
|
+
res.end();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
await new Promise((resolve, reject) => {
|
|
188
|
+
httpServer.once('error', reject);
|
|
189
|
+
httpServer.listen(0, '127.0.0.1', () => resolve());
|
|
190
|
+
});
|
|
191
|
+
const address = httpServer.address();
|
|
192
|
+
if (!address || typeof address === 'string' || address.address !== '127.0.0.1') {
|
|
193
|
+
await closeHttpServer(httpServer);
|
|
194
|
+
throw new Error('scripted MCP host did not bind IPv4 loopback');
|
|
195
|
+
}
|
|
196
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
197
|
+
const claudeServers = {};
|
|
198
|
+
const codexServers = {};
|
|
199
|
+
for (const [route, serverName] of routeToServer) {
|
|
200
|
+
const url = `http://127.0.0.1:${address.port}${route}`;
|
|
201
|
+
claudeServers[serverName] = { type: 'http', url, headers };
|
|
202
|
+
codexServers[serverName] = { url, http_headers: headers };
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
claudeServers,
|
|
206
|
+
codexConfig: { mcp_servers: codexServers },
|
|
207
|
+
beginTurn(turnNumber) {
|
|
208
|
+
if (closing)
|
|
209
|
+
throw new Error('scripted MCP host is closing');
|
|
210
|
+
if (fatal)
|
|
211
|
+
throw fatal;
|
|
212
|
+
currentTurn = turnNumber;
|
|
213
|
+
authorizations = [];
|
|
214
|
+
receipts = [];
|
|
215
|
+
},
|
|
216
|
+
decide(serverName, toolName, rawArgs) {
|
|
217
|
+
if (fatal)
|
|
218
|
+
return { decision: 'deny', outcome: 'protocol_error', reason: 'protocol_error' };
|
|
219
|
+
const args = asJsonObject(rawArgs);
|
|
220
|
+
const tool = plan.servers.get(serverName)?.tools.get(toolName);
|
|
221
|
+
if (!args || !tool || tool.readOnlyHint === true || currentTurn === 0) {
|
|
222
|
+
latch(new Error('invalid scripted MCP approval request'));
|
|
223
|
+
return { decision: 'deny', outcome: 'protocol_error', reason: 'protocol_error' };
|
|
224
|
+
}
|
|
225
|
+
const ruleIndex = plan.rules.findIndex((rule) => rule.serverName === serverName
|
|
226
|
+
&& rule.toolName === toolName && matchesMcpApprovalArguments(args, rule.argumentsContaining));
|
|
227
|
+
if (ruleIndex < 0) {
|
|
228
|
+
latch(new Error(`no scripted MCP approval rule matched ${serverName}.${toolName}`));
|
|
229
|
+
return { decision: 'deny', outcome: 'unmatched', reason: 'no_matching_rule' };
|
|
230
|
+
}
|
|
231
|
+
const rule = plan.rules[ruleIndex];
|
|
232
|
+
if (rule.decision === 'approve') {
|
|
233
|
+
authorizations.push({
|
|
234
|
+
serverName,
|
|
235
|
+
toolName,
|
|
236
|
+
digest: createMcpArgumentDigest(receiptKey, args),
|
|
237
|
+
sequence: ++sequence,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
decision: rule.decision,
|
|
242
|
+
outcome: 'matched',
|
|
243
|
+
reason: 'rule_match',
|
|
244
|
+
ruleIndex,
|
|
245
|
+
...(rule.label ? { ruleLabel: rule.label } : {}),
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
isReadOnly(serverName, toolName) {
|
|
249
|
+
return plan.servers.get(serverName)?.tools.get(toolName)?.readOnlyHint === true;
|
|
250
|
+
},
|
|
251
|
+
failProtocol(message) {
|
|
252
|
+
latch(new Error(message.slice(0, 500)));
|
|
253
|
+
},
|
|
254
|
+
settleEvents(events) {
|
|
255
|
+
const projected = projectReceipts(events, receiptKey, receipts, latch);
|
|
256
|
+
for (const authorization of authorizations) {
|
|
257
|
+
if (!authorization.consumed)
|
|
258
|
+
latch(new Error(`approved MCP call was not invoked: ${authorization.serverName}.${authorization.toolName}`));
|
|
259
|
+
}
|
|
260
|
+
if (receipts.some((receipt) => !receipt.consumed))
|
|
261
|
+
latch(new Error('surplus authenticated MCP receipt'));
|
|
262
|
+
const error = fatal;
|
|
263
|
+
currentTurn = 0;
|
|
264
|
+
authorizations = [];
|
|
265
|
+
receipts = [];
|
|
266
|
+
return { events: projected, ...(error ? { error } : {}) };
|
|
267
|
+
},
|
|
268
|
+
async dispose() {
|
|
269
|
+
if (closing)
|
|
270
|
+
return;
|
|
271
|
+
closing = true;
|
|
272
|
+
await Promise.all([...activeTransports].map((transport) => transport.close().catch(() => undefined)));
|
|
273
|
+
await matcher.dispose().catch(() => undefined);
|
|
274
|
+
await closeHttpServer(httpServer);
|
|
275
|
+
receiptKey.fill(0);
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function projectReceipts(events, key, receipts, latch) {
|
|
280
|
+
const cohorts = new Map();
|
|
281
|
+
for (const [index, event] of events.entries()) {
|
|
282
|
+
if (event.action !== 'mcp_tool_call')
|
|
283
|
+
continue;
|
|
284
|
+
const identity = eventIdentity(event);
|
|
285
|
+
const json = eventInput(event);
|
|
286
|
+
if (!identity || !json)
|
|
287
|
+
continue;
|
|
288
|
+
const digest = createMcpArgumentDigest(key, json);
|
|
289
|
+
const cohortKey = `${identity.serverName}\0${identity.toolName}\0${digest}`;
|
|
290
|
+
const cohort = cohorts.get(cohortKey) ?? { eventIndexes: [], receipts: [], identity };
|
|
291
|
+
cohort.eventIndexes.push(index);
|
|
292
|
+
cohorts.set(cohortKey, cohort);
|
|
293
|
+
}
|
|
294
|
+
for (const receipt of receipts) {
|
|
295
|
+
const cohort = cohorts.get(`${receipt.serverName}\0${receipt.toolName}\0${receipt.digest}`);
|
|
296
|
+
if (cohort)
|
|
297
|
+
cohort.receipts.push(receipt);
|
|
298
|
+
}
|
|
299
|
+
const projected = [...events];
|
|
300
|
+
for (const cohort of cohorts.values()) {
|
|
301
|
+
const canonicalEvents = cohort.eventIndexes.filter((index) => events[index].mcp?.invocation !== 'not_invoked');
|
|
302
|
+
const deniedEvents = cohort.eventIndexes.filter((index) => events[index].mcp?.invocation === 'not_invoked');
|
|
303
|
+
if (deniedEvents.length > 0 && cohort.receipts.length > 0) {
|
|
304
|
+
latch(new Error(`denied MCP call produced a receipt for ${cohort.identity.serverName}.${cohort.identity.toolName}`));
|
|
305
|
+
}
|
|
306
|
+
if (canonicalEvents.length === 0)
|
|
307
|
+
continue;
|
|
308
|
+
if (canonicalEvents.length !== cohort.receipts.length) {
|
|
309
|
+
latch(new Error(`MCP lifecycle/receipt count mismatch for ${cohort.identity.serverName}.${cohort.identity.toolName}`));
|
|
310
|
+
for (const index of canonicalEvents)
|
|
311
|
+
projected[index] = withInvocation(events[index], cohort.identity, 'unknown');
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
for (const [offset, index] of canonicalEvents.entries()) {
|
|
315
|
+
cohort.receipts[offset].consumed = true;
|
|
316
|
+
projected[index] = withInvocation(events[index], cohort.identity, 'confirmed');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return projected;
|
|
320
|
+
}
|
|
321
|
+
function eventIdentity(event) {
|
|
322
|
+
const args = event.arguments ?? {};
|
|
323
|
+
return typeof args.server === 'string' && typeof args.tool === 'string'
|
|
324
|
+
? { serverName: args.server, toolName: args.tool } : undefined;
|
|
325
|
+
}
|
|
326
|
+
function eventInput(event) {
|
|
327
|
+
const original = getOriginalMcpInput(event);
|
|
328
|
+
if (original)
|
|
329
|
+
return asJsonObject(original);
|
|
330
|
+
const args = event.arguments ?? {};
|
|
331
|
+
return asJsonObject(Object.fromEntries(Object.entries(args).filter(([name]) => !['server', 'tool', 'status'].includes(name))));
|
|
332
|
+
}
|
|
333
|
+
function withInvocation(event, identity, invocation) {
|
|
334
|
+
const status = String(event.arguments?.status ?? 'unknown').toLowerCase();
|
|
335
|
+
return invocation === 'confirmed'
|
|
336
|
+
? { ...event, mcp: { ...identity, invocation, outcome: status.includes('fail') || status.includes('error') ? 'tool_error' : 'completed' } }
|
|
337
|
+
: { ...event, mcp: { ...identity, invocation, outcome: 'unknown' } };
|
|
338
|
+
}
|
|
339
|
+
async function readJsonBody(req) {
|
|
340
|
+
const chunks = [];
|
|
341
|
+
let size = 0;
|
|
342
|
+
for await (const chunk of req) {
|
|
343
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
344
|
+
size += buffer.length;
|
|
345
|
+
if (size > MAX_BODY_BYTES)
|
|
346
|
+
throw new Error('scripted MCP request body too large');
|
|
347
|
+
chunks.push(buffer);
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function sendHttp(res, status, message) {
|
|
357
|
+
res.statusCode = status;
|
|
358
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
359
|
+
res.end(message);
|
|
360
|
+
}
|
|
361
|
+
function sendJsonRpcError(res, id, code, message) {
|
|
362
|
+
res.statusCode = 200;
|
|
363
|
+
res.setHeader('content-type', 'application/json');
|
|
364
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: id ?? null, error: { code, message } }));
|
|
365
|
+
}
|
|
366
|
+
function closeHttpServer(server) {
|
|
367
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
368
|
+
}
|
package/dist/reporters/cli.js
CHANGED
|
@@ -46,9 +46,10 @@ export async function runCliPreview(resultsDir, opts) {
|
|
|
46
46
|
console.log();
|
|
47
47
|
// ── Trials
|
|
48
48
|
for (const trial of trials) {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
49
|
+
const evaluated = trial.reward !== undefined;
|
|
50
|
+
const tp = evaluated && trial.reward >= 0.5;
|
|
51
|
+
const trialStatus = !evaluated ? fmt.dim('N/A') : tp ? fmt.pass('PASS') : fmt.fail('FAIL');
|
|
52
|
+
const reward = fmt.bold(evaluated ? trial.reward.toFixed(2) : 'n/a');
|
|
52
53
|
const dur = `${((trial.duration_ms || 0) / 1000).toFixed(1)}s`;
|
|
53
54
|
const cmds = `${trial.n_commands || 0} cmds`;
|
|
54
55
|
const scorers = (trial.scorer_results || []).map((g) => {
|
|
@@ -243,7 +243,7 @@ function formatGroupDetails(group) {
|
|
|
243
243
|
for (const trial of group.trials) {
|
|
244
244
|
out.push(`#### ${trial.name ?? `trial ${trial.trial_id}`}`);
|
|
245
245
|
const reason = trial.diagnostics?.completionReason ?? '—';
|
|
246
|
-
out.push(`reward: **${trial.reward.toFixed(2)}** | duration: ${durationSeconds(trial.duration_ms)} | completion: \`${reason}\``);
|
|
246
|
+
out.push(`reward: **${trial.reward === undefined ? 'n/a' : trial.reward.toFixed(2)}** | duration: ${durationSeconds(trial.duration_ms)} | completion: \`${reason}\``);
|
|
247
247
|
if (trial.scorer_results.length > 0) {
|
|
248
248
|
out.push('');
|
|
249
249
|
out.push('| Scorer | Score | Weight | Details |');
|
|
@@ -14,6 +14,7 @@ export function printReportSummary(groups, opts = {}) {
|
|
|
14
14
|
for (const diagnostic of group.diagnostics) {
|
|
15
15
|
const shouldPrintFull = opts.forceVerbose
|
|
16
16
|
|| diagnostic.state !== 'passed'
|
|
17
|
+
|| diagnostic.resultKind === 'synthetic_no_evaluation'
|
|
17
18
|
|| diagnostic.report.completionReason === 'timeout'
|
|
18
19
|
|| diagnostic.report.completionReason === 'agent_crashed';
|
|
19
20
|
console.log(` ${fmt.bold(diagnostic.caseName)}`);
|
package/dist/reporting/core.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildDiagnosticsReport } from '../sdk/diagnostics.js';
|
|
1
2
|
import { extractSkillsFromLog } from '../tool-events.js';
|
|
2
3
|
export function buildPathgradeReport(input) {
|
|
3
4
|
const warnings = [];
|
|
@@ -37,7 +38,8 @@ export function buildPathgradeReport(input) {
|
|
|
37
38
|
}
|
|
38
39
|
const scores = reportableGroups.flatMap(group => group.cases
|
|
39
40
|
.filter(testCase => testCase.resultKind !== 'synthetic_no_evaluation')
|
|
40
|
-
.map(testCase => testCase.score)
|
|
41
|
+
.map(testCase => testCase.score)
|
|
42
|
+
.filter((score) => score !== undefined));
|
|
41
43
|
const overallPassRate = average(scores);
|
|
42
44
|
const status = input.threshold != null
|
|
43
45
|
? (overallPassRate >= input.threshold ? 'pass' : 'fail')
|
|
@@ -75,7 +77,15 @@ function toBuiltCase(testCase) {
|
|
|
75
77
|
// rule among real evaluations without letting a later synthetic result from
|
|
76
78
|
// an unevaluated sibling agent erase a legitimate score.
|
|
77
79
|
const evaluation = testCase.evaluations.findLast(candidate => candidate.resultKind !== 'synthetic_no_evaluation') ?? testCase.evaluations[testCase.evaluations.length - 1];
|
|
78
|
-
const diagnostics = evaluation.diagnostics
|
|
80
|
+
const diagnostics = evaluation.diagnostics
|
|
81
|
+
?? testCase.diagnostics
|
|
82
|
+
?? (evaluation.resultKind === 'synthetic_no_evaluation'
|
|
83
|
+
? buildDiagnosticsReport({
|
|
84
|
+
score: undefined,
|
|
85
|
+
warnings: ['evaluate() was not called; no evaluation score is available'],
|
|
86
|
+
log: [],
|
|
87
|
+
})
|
|
88
|
+
: undefined);
|
|
79
89
|
return {
|
|
80
90
|
name: testCase.name,
|
|
81
91
|
state: testCase.state,
|
|
@@ -119,7 +129,7 @@ function fallbackCase(testCase, warnings) {
|
|
|
119
129
|
function normalizeTrial(input) {
|
|
120
130
|
const base = input.trial ?? {
|
|
121
131
|
trial_id: 1,
|
|
122
|
-
reward: input.score,
|
|
132
|
+
...(input.score !== undefined ? { reward: input.score } : {}),
|
|
123
133
|
scorer_results: [],
|
|
124
134
|
duration_ms: input.runnerDurationMs,
|
|
125
135
|
n_commands: 0,
|
|
@@ -128,8 +138,12 @@ function normalizeTrial(input) {
|
|
|
128
138
|
session_log: [],
|
|
129
139
|
};
|
|
130
140
|
const skills = base.skills_used ?? extractSkillsFromLog(base.session_log);
|
|
141
|
+
const { reward: existingReward, ...baseWithoutReward } = base;
|
|
131
142
|
return {
|
|
132
|
-
...
|
|
143
|
+
...baseWithoutReward,
|
|
144
|
+
...(input.resultKind === 'synthetic_no_evaluation'
|
|
145
|
+
? {}
|
|
146
|
+
: { reward: existingReward ?? input.score }),
|
|
133
147
|
name: input.name,
|
|
134
148
|
duration_ms: base.duration_ms || input.runnerDurationMs,
|
|
135
149
|
diagnostics: input.diagnostics ?? base.diagnostics,
|
|
@@ -172,7 +186,7 @@ function buildSummary(groupName, cases, report) {
|
|
|
172
186
|
average_duration_ms: average(cases.map(testCase => testCase.runnerDurationMs)),
|
|
173
187
|
trial_count: cases.length,
|
|
174
188
|
diagnostics: cases.flatMap(testCase => testCase.diagnostics
|
|
175
|
-
? [{ caseName: testCase.name, state: testCase.state, report: testCase.diagnostics }]
|
|
189
|
+
? [{ caseName: testCase.name, state: testCase.state, resultKind: testCase.resultKind, report: testCase.diagnostics }]
|
|
176
190
|
: []),
|
|
177
191
|
};
|
|
178
192
|
}
|
|
@@ -25,7 +25,7 @@ export interface ReportCaseInput {
|
|
|
25
25
|
diagnostics?: DiagnosticsReport;
|
|
26
26
|
}
|
|
27
27
|
export interface ReportEvaluationInput {
|
|
28
|
-
score
|
|
28
|
+
score?: number;
|
|
29
29
|
trial?: TrialResult;
|
|
30
30
|
diagnostics?: DiagnosticsReport;
|
|
31
31
|
resultKind?: EvaluationResultKind;
|
|
@@ -58,5 +58,6 @@ export interface ReportSummaryGroup {
|
|
|
58
58
|
export interface ReportSummaryDiagnostics {
|
|
59
59
|
caseName: string;
|
|
60
60
|
state: ReportCaseState;
|
|
61
|
+
resultKind?: EvaluationResultKind;
|
|
61
62
|
report: DiagnosticsReport;
|
|
62
63
|
}
|
|
@@ -24,7 +24,7 @@ export function buildNormalizedRunSnapshotFromReportGroups(run, groups) {
|
|
|
24
24
|
const evaluations = testCase.evaluations?.map((evaluation, evaluationIndex) => ({
|
|
25
25
|
id: `${attemptId}:evaluation-${evaluationIndex + 1}`,
|
|
26
26
|
attemptId,
|
|
27
|
-
score: evaluation.score,
|
|
27
|
+
...(evaluation.score !== undefined ? { score: evaluation.score } : {}),
|
|
28
28
|
...(evaluation.trial ? { trial: evaluation.trial } : {}),
|
|
29
29
|
...(evaluation.diagnostics ? { diagnostics: evaluation.diagnostics } : {}),
|
|
30
30
|
...(evaluation.resultKind ? { resultKind: evaluation.resultKind } : {}),
|
|
@@ -162,7 +162,10 @@ function validateEvaluation(input) {
|
|
|
162
162
|
if (input.evaluation.attemptId !== input.attemptId) {
|
|
163
163
|
input.errors.push({ path: `${evaluationPath}.attemptId`, message: 'Evaluation attemptId must reference its attempt.' });
|
|
164
164
|
}
|
|
165
|
-
|
|
165
|
+
const scoreMissingForSynthetic = input.evaluation.resultKind === 'synthetic_no_evaluation'
|
|
166
|
+
&& input.evaluation.score === undefined;
|
|
167
|
+
if (!scoreMissingForSynthetic
|
|
168
|
+
&& (!Number.isFinite(input.evaluation.score) || input.evaluation.score < 0 || input.evaluation.score > 1)) {
|
|
166
169
|
input.errors.push({ path: `${evaluationPath}.score`, message: 'Evaluation score must be a finite number in [0, 1].' });
|
|
167
170
|
}
|
|
168
171
|
validateNativeReferences(input.evaluation.nativeReferences, `${evaluationPath}.nativeReferences`, input.errors);
|
package/dist/runners/model.d.ts
CHANGED
|
@@ -37,7 +37,7 @@ function totalDurationMs(runCase) {
|
|
|
37
37
|
function projectedEvaluations(runCase) {
|
|
38
38
|
if (runCase.scoringPolicy.kind === 'non-scoring') {
|
|
39
39
|
return runCase.attempts.flatMap(attempt => (attempt.evaluations ?? []).map(evaluation => ({
|
|
40
|
-
score: evaluation.score,
|
|
40
|
+
...(evaluation.score !== undefined ? { score: evaluation.score } : {}),
|
|
41
41
|
...(evaluation.trial ? { trial: evaluation.trial } : {}),
|
|
42
42
|
...(evaluation.diagnostics ? { diagnostics: evaluation.diagnostics } : {}),
|
|
43
43
|
...(evaluation.resultKind ? { resultKind: evaluation.resultKind } : {}),
|
|
@@ -53,7 +53,7 @@ function projectedEvaluations(runCase) {
|
|
|
53
53
|
if (!evaluation)
|
|
54
54
|
return [];
|
|
55
55
|
return [{
|
|
56
|
-
score: evaluation.score,
|
|
56
|
+
...(evaluation.score !== undefined ? { score: evaluation.score } : {}),
|
|
57
57
|
...(evaluation.trial ? { trial: evaluation.trial } : {}),
|
|
58
58
|
...(evaluation.diagnostics ? { diagnostics: evaluation.diagnostics } : {}),
|
|
59
59
|
...(evaluation.resultKind ? { resultKind: evaluation.resultKind } : {}),
|
|
@@ -86,7 +86,7 @@ function toReportCaseInput(testCase, groupName) {
|
|
|
86
86
|
filePath,
|
|
87
87
|
groupName,
|
|
88
88
|
evaluations: pathgradeMeta?.map(entry => ({
|
|
89
|
-
score: entry.score,
|
|
89
|
+
...(entry.score !== undefined ? { score: entry.score } : {}),
|
|
90
90
|
trial: entry.trial,
|
|
91
91
|
diagnostics: entry.diagnostics,
|
|
92
92
|
resultKind: entry.resultKind,
|