@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.
Files changed (56) hide show
  1. package/README.md +30 -0
  2. package/dist/adapters/jest/results.js +1 -1
  3. package/dist/adapters/node-test/index.js +1 -1
  4. package/dist/agents/claude/sdk-message-projector.js +3 -2
  5. package/dist/agents/claude/tool-permission-bridge.d.ts +4 -0
  6. package/dist/agents/claude/tool-permission-bridge.js +68 -1
  7. package/dist/agents/claude.d.ts +2 -0
  8. package/dist/agents/claude.js +55 -10
  9. package/dist/agents/codex-app-server/agent.js +94 -76
  10. package/dist/agents/codex-app-server/mcp-approval-correlator.d.ts +55 -0
  11. package/dist/agents/codex-app-server/mcp-approval-correlator.js +299 -0
  12. package/dist/core/canonical-json.d.ts +2 -0
  13. package/dist/core/canonical-json.js +51 -0
  14. package/dist/core/generated-mcp-protocol.d.ts +19 -0
  15. package/dist/core/generated-mcp-protocol.js +26 -0
  16. package/dist/core/mcp-mock.d.ts +1 -1
  17. package/dist/core/mcp-mock.js +24 -0
  18. package/dist/core/mcp-mock.types.d.ts +6 -0
  19. package/dist/core/mcp-schema-profile.d.ts +10 -0
  20. package/dist/core/mcp-schema-profile.js +91 -0
  21. package/dist/mcp-mock-server.js +29 -13
  22. package/dist/providers/mcp-config.js +4 -2
  23. package/dist/providers/sandbox.js +6 -0
  24. package/dist/providers/scripted-mcp-mock-host.d.ts +39 -0
  25. package/dist/providers/scripted-mcp-mock-host.js +368 -0
  26. package/dist/reporters/cli.js +4 -3
  27. package/dist/reporters/github-comment.js +1 -1
  28. package/dist/reporters/report-summary.js +1 -0
  29. package/dist/reporting/core.js +19 -5
  30. package/dist/reporting/types.d.ts +2 -1
  31. package/dist/runners/model-builders.js +1 -1
  32. package/dist/runners/model-validation.js +4 -1
  33. package/dist/runners/model.d.ts +1 -1
  34. package/dist/runners/report-projection.js +2 -2
  35. package/dist/runners/vitest-adapter.js +1 -1
  36. package/dist/sdk/agent.js +64 -19
  37. package/dist/sdk/diagnostics.d.ts +1 -0
  38. package/dist/sdk/diagnostics.js +6 -3
  39. package/dist/sdk/index.d.ts +4 -3
  40. package/dist/sdk/index.js +1 -1
  41. package/dist/sdk/lifecycle.js +3 -3
  42. package/dist/sdk/managed-session.d.ts +3 -0
  43. package/dist/sdk/managed-session.js +71 -26
  44. package/dist/sdk/mcp-event-input.d.ts +3 -0
  45. package/dist/sdk/mcp-event-input.js +8 -0
  46. package/dist/sdk/mcp-evidence.d.ts +39 -0
  47. package/dist/sdk/mcp-evidence.js +71 -12
  48. package/dist/sdk/mcp-mock-approvals.d.ts +40 -0
  49. package/dist/sdk/mcp-mock-approvals.js +235 -0
  50. package/dist/sdk/scripted-mcp-events.d.ts +24 -0
  51. package/dist/sdk/scripted-mcp-events.js +30 -0
  52. package/dist/sdk/types.d.ts +6 -2
  53. package/dist/tool-events.d.ts +16 -1
  54. package/dist/types.d.ts +3 -1
  55. package/dist/viewer.html +4 -4
  56. 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
+ }
@@ -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 tp = trial.reward >= 0.5;
50
- const trialStatus = tp ? fmt.pass('PASS') : fmt.fail('FAIL');
51
- const reward = fmt.bold(trial.reward.toFixed(2));
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)}`);
@@ -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 ?? testCase.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
- ...base,
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: number;
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
- if (!Number.isFinite(input.evaluation.score) || input.evaluation.score < 0 || input.evaluation.score > 1) {
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);
@@ -79,7 +79,7 @@ export interface AttemptRecord {
79
79
  export interface EvaluationRecord {
80
80
  id: string;
81
81
  attemptId: string;
82
- score: number;
82
+ score?: number;
83
83
  name?: string;
84
84
  diagnostics?: DiagnosticsReport;
85
85
  trial?: TrialResult;
@@ -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,