@wix/pathgrade 1.0.14 → 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 (55) hide show
  1. package/README.md +28 -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/scripted-mcp-mock-host.d.ts +39 -0
  24. package/dist/providers/scripted-mcp-mock-host.js +368 -0
  25. package/dist/reporters/cli.js +4 -3
  26. package/dist/reporters/github-comment.js +1 -1
  27. package/dist/reporters/report-summary.js +1 -0
  28. package/dist/reporting/core.js +19 -5
  29. package/dist/reporting/types.d.ts +2 -1
  30. package/dist/runners/model-builders.js +1 -1
  31. package/dist/runners/model-validation.js +4 -1
  32. package/dist/runners/model.d.ts +1 -1
  33. package/dist/runners/report-projection.js +2 -2
  34. package/dist/runners/vitest-adapter.js +1 -1
  35. package/dist/sdk/agent.js +64 -19
  36. package/dist/sdk/diagnostics.d.ts +1 -0
  37. package/dist/sdk/diagnostics.js +6 -3
  38. package/dist/sdk/index.d.ts +4 -3
  39. package/dist/sdk/index.js +1 -1
  40. package/dist/sdk/lifecycle.js +3 -3
  41. package/dist/sdk/managed-session.d.ts +3 -0
  42. package/dist/sdk/managed-session.js +71 -27
  43. package/dist/sdk/mcp-event-input.d.ts +3 -0
  44. package/dist/sdk/mcp-event-input.js +8 -0
  45. package/dist/sdk/mcp-evidence.d.ts +39 -0
  46. package/dist/sdk/mcp-evidence.js +71 -12
  47. package/dist/sdk/mcp-mock-approvals.d.ts +40 -0
  48. package/dist/sdk/mcp-mock-approvals.js +235 -0
  49. package/dist/sdk/scripted-mcp-events.d.ts +24 -0
  50. package/dist/sdk/scripted-mcp-events.js +30 -0
  51. package/dist/sdk/types.d.ts +6 -2
  52. package/dist/tool-events.d.ts +16 -1
  53. package/dist/types.d.ts +3 -1
  54. package/dist/viewer.html +4 -4
  55. package/package.json +3 -2
package/README.md CHANGED
@@ -334,6 +334,7 @@ const mock = mockMcpServer({
334
334
  tools: [{
335
335
  name: 'get_weather',
336
336
  description: 'Get weather for a city',
337
+ annotations: { readOnlyHint: true },
337
338
  when: 'weather',
338
339
  response: { temp: 72, unit: 'F' },
339
340
  }],
@@ -342,6 +343,33 @@ const mock = mockMcpServer({
342
343
  const agent = await createAgent({ agent: 'claude', mcpMock: mock });
343
344
  ```
344
345
 
346
+ Read-only metadata is explicit: add `annotations.readOnlyHint: true` only when
347
+ the operation being modeled is read-only. Unannotated and explicitly mutating
348
+ mock tools retain the runtime's normal approval behavior.
349
+
350
+ To evaluate mutation approval behavior with Claude or Codex app-server, pass a
351
+ non-empty ordered `mcpMockApprovalRules` array. The first matching rule wins:
352
+
353
+ ```ts
354
+ const agent = await createAgent({
355
+ agent: 'codex',
356
+ transport: 'app-server',
357
+ mcpMock: mock,
358
+ mcpMockApprovalRules: [
359
+ { serverName: 'sessions', toolName: 'remove_session',
360
+ argumentsContaining: { mode: 'delete' }, decision: 'deny' },
361
+ { serverName: 'sessions', toolName: 'remove_session',
362
+ argumentsContaining: { mode: 'archive' }, decision: 'approve' },
363
+ ],
364
+ });
365
+ ```
366
+
367
+ Use `getMcpApproval()` for the simulated-user decision and
368
+ `wasMcpToolInvoked()` for receipt-backed execution (`true`, `false`, or
369
+ `undefined`). An `mcp_tool_call` event by itself proves only that an attempt was
370
+ observed. Mock responses are fixed fixtures; this mode does not model stateful
371
+ mutation or make later reads observe earlier writes.
372
+
345
373
  ## CLI
346
374
 
347
375
  ```bash
@@ -58,7 +58,7 @@ function runCase(input) {
58
58
  const evaluations = input.evaluations.map((entry, index) => ({
59
59
  id: `${attemptId}:evaluation-${index + 1}`,
60
60
  attemptId,
61
- score: entry.score,
61
+ ...(entry.score !== undefined ? { score: entry.score } : {}),
62
62
  ...(entry.trial ? { trial: entry.trial } : {}),
63
63
  ...(entry.diagnostics ? { diagnostics: entry.diagnostics } : {}),
64
64
  ...(entry.resultKind ? { resultKind: entry.resultKind } : {}),
@@ -67,7 +67,7 @@ function toReportEvaluations(evaluations) {
67
67
  if (evaluations.length === 0)
68
68
  return undefined;
69
69
  return evaluations.map(entry => ({
70
- score: entry.score,
70
+ ...(entry.score !== undefined ? { score: entry.score } : {}),
71
71
  trial: entry.trial,
72
72
  diagnostics: entry.diagnostics,
73
73
  resultKind: entry.resultKind,
@@ -22,6 +22,7 @@ const SDK_ERROR_SUBTYPES = [
22
22
  'error_max_structured_output_retries',
23
23
  ];
24
24
  import { TOOL_NAME_MAP, buildSummary, enrichSkillEvents } from '../../tool-events.js';
25
+ import { attachOriginalMcpInput } from '../../sdk/mcp-event-input.js';
25
26
  import { parseClaudeSdkMcpToolName } from './mcp-tool-name.js';
26
27
  import { applyObservedToolResult, extractObservedToolResults, } from './tool-results.js';
27
28
  export function projectSdkMessages(input) {
@@ -202,7 +203,7 @@ function buildToolEvent(block, turnNumber, answerStore, mcpServerNames, deniedMc
202
203
  tool: mcpTool.tool,
203
204
  status: 'incomplete',
204
205
  };
205
- return {
206
+ return attachOriginalMcpInput({
206
207
  action: 'mcp_tool_call',
207
208
  provider: 'claude',
208
209
  providerToolName: normalizedProviderToolName,
@@ -214,7 +215,7 @@ function buildToolEvent(block, turnNumber, answerStore, mcpServerNames, deniedMc
214
215
  summary: `MCP tool ${normalizedProviderToolName}`,
215
216
  confidence: 'high',
216
217
  rawSnippet: JSON.stringify(block).slice(0, 200),
217
- };
218
+ }, rawInput ?? {});
218
219
  }
219
220
  const action = TOOL_NAME_MAP[providerToolName] ?? 'unknown';
220
221
  const args = action === 'ask_user'
@@ -3,6 +3,8 @@ import type { AskBus } from '../../sdk/ask-bus/types.js';
3
3
  import { type McpSafetyOptions } from '../../sdk/mcp-safety.js';
4
4
  import type { AskUserAnswerStore } from './ask-user-answer-store.js';
5
5
  import type { ClaudeDeniedMcpEventStore } from './denied-mcp-event-store.js';
6
+ import type { ScriptedMcpMockHost } from '../../providers/scripted-mcp-mock-host.js';
7
+ import type { ToolEvent } from '../../tool-events.js';
6
8
  export interface ClaudeToolPermissionBridgeDeps {
7
9
  askBus: AskBus;
8
10
  getTurnNumber: () => number;
@@ -10,6 +12,8 @@ export interface ClaudeToolPermissionBridgeDeps {
10
12
  mcpServerNames: readonly string[];
11
13
  mcpSafety?: McpSafetyOptions;
12
14
  deniedMcpEvents?: ClaudeDeniedMcpEventStore;
15
+ scriptedMcpHost?: ScriptedMcpMockHost;
16
+ recordScriptedEvent?: (event: ToolEvent) => void;
13
17
  }
14
18
  export type ClaudeToolPermissionBridge = CanUseTool & {
15
19
  lastError(): Error | null;
@@ -1,12 +1,15 @@
1
1
  import { decideMcpToolCall, redactMcpSecrets, } from '../../sdk/mcp-safety.js';
2
2
  import { createAskUserBridge, } from './ask-user-bridge.js';
3
3
  import { parseClaudeSdkMcpToolName } from './mcp-tool-name.js';
4
+ import { canonicalizeJson } from '../../core/canonical-json.js';
5
+ import { buildScriptedMcpApprovalEvent, buildScriptedMcpDeniedCallEvent, } from '../../sdk/scripted-mcp-events.js';
4
6
  export function createClaudeToolPermissionBridge(deps) {
5
7
  const askUserBridge = createAskUserBridge({
6
8
  askBus: deps.askBus,
7
9
  getTurnNumber: deps.getTurnNumber,
8
10
  answerStore: deps.answerStore,
9
11
  });
12
+ const scriptedMemo = new Map();
10
13
  const canUseTool = async (toolName, input, options) => {
11
14
  if (toolName === 'AskUserQuestion') {
12
15
  return askUserBridge(toolName, input, options);
@@ -16,6 +19,56 @@ export function createClaudeToolPermissionBridge(deps) {
16
19
  if (parsed.kind === 'non_mcp') {
17
20
  return allow(input);
18
21
  }
22
+ if (runMode === 'mock' && deps.scriptedMcpHost && parsed.kind === 'mcp') {
23
+ const args = asRecord(input);
24
+ const host = deps.scriptedMcpHost;
25
+ if (host.isReadOnly(parsed.server, parsed.tool))
26
+ return allow(input);
27
+ const memoKey = `${deps.getTurnNumber()}\0${options.toolUseID}`;
28
+ let fingerprint;
29
+ try {
30
+ fingerprint = canonicalizeJson({ toolName, input: args });
31
+ }
32
+ catch {
33
+ host.failProtocol('invalid Claude MCP approval authority fields');
34
+ recordClaudeProtocolFailure(deps, options.toolUseID, parsed.server, parsed.tool, args, 'malformed_request');
35
+ return { behavior: 'deny', message: 'The MCP approval request failed validation.' };
36
+ }
37
+ const prior = scriptedMemo.get(memoKey);
38
+ if (prior) {
39
+ if (prior.fingerprint === fingerprint)
40
+ return prior.result;
41
+ host.failProtocol('conflicting duplicate Claude MCP approval callback');
42
+ recordClaudeProtocolFailure(deps, options.toolUseID, parsed.server, parsed.tool, args);
43
+ return { behavior: 'deny', message: 'The MCP approval request failed validation.' };
44
+ }
45
+ const result = Promise.resolve().then(() => {
46
+ const decision = host.decide(parsed.server, parsed.tool, args);
47
+ deps.recordScriptedEvent?.(buildScriptedMcpApprovalEvent({
48
+ provider: 'claude', toolUseId: options.toolUseID, turnNumber: deps.getTurnNumber(),
49
+ serverName: parsed.server, toolName: parsed.tool, decision,
50
+ }));
51
+ if (decision.decision === 'approve')
52
+ return allow(input);
53
+ const outcome = decision.outcome === 'matched' ? 'user_denied' : 'protocol_error';
54
+ deps.deniedMcpEvents?.record(options.toolUseID, buildScriptedMcpDeniedCallEvent({
55
+ provider: 'claude', toolUseId: options.toolUseID, turnNumber: deps.getTurnNumber(),
56
+ serverName: parsed.server, toolName: parsed.tool, args, outcome,
57
+ }));
58
+ return { behavior: 'deny', message: decision.outcome === 'matched'
59
+ ? 'The user denied this MCP tool call.'
60
+ : 'The MCP approval request failed validation.' };
61
+ });
62
+ scriptedMemo.set(memoKey, { fingerprint, result });
63
+ return result;
64
+ }
65
+ if (runMode === 'mock' && deps.scriptedMcpHost && parsed.kind === 'unrecognized') {
66
+ deps.scriptedMcpHost.failProtocol(`unrecognized Claude SDK MCP tool name: ${toolName}`);
67
+ return {
68
+ behavior: 'deny',
69
+ message: `unrecognized Claude SDK MCP tool name: ${toolName}`,
70
+ };
71
+ }
19
72
  if (runMode === 'mock') {
20
73
  return allow(input);
21
74
  }
@@ -44,9 +97,23 @@ export function createClaudeToolPermissionBridge(deps) {
44
97
  };
45
98
  const bridge = canUseTool;
46
99
  bridge.lastError = () => askUserBridge.lastError();
47
- bridge.clearLastError = () => askUserBridge.clearLastError();
100
+ bridge.clearLastError = () => {
101
+ askUserBridge.clearLastError();
102
+ scriptedMemo.clear();
103
+ };
48
104
  return bridge;
49
105
  }
106
+ function recordClaudeProtocolFailure(deps, toolUseId, serverName, toolName, args, reason = 'conflicting_duplicate') {
107
+ deps.recordScriptedEvent?.(buildScriptedMcpApprovalEvent({
108
+ provider: 'claude', toolUseId, turnNumber: deps.getTurnNumber(), serverName, toolName,
109
+ decision: { decision: 'deny', outcome: 'protocol_error', reason },
110
+ status: 'error',
111
+ }));
112
+ deps.deniedMcpEvents?.record(toolUseId, buildScriptedMcpDeniedCallEvent({
113
+ provider: 'claude', toolUseId, turnNumber: deps.getTurnNumber(),
114
+ serverName, toolName, args, outcome: 'protocol_error',
115
+ }));
116
+ }
50
117
  function allow(input) {
51
118
  return { behavior: 'allow', updatedInput: asRecord(input) };
52
119
  }
@@ -43,6 +43,8 @@ export interface ClaudeAgentDeps {
43
43
  now?: () => Date;
44
44
  /** Monotonic clock seam for deterministic tool duration tests. */
45
45
  monotonicNow?: () => number;
46
+ /** Grace period before force-closing an unresponsive SDK query. */
47
+ queryAbortGraceMs?: number;
46
48
  }
47
49
  export interface ClaudeAgentOptions {
48
50
  /**
@@ -47,6 +47,19 @@ function createLinkedAbortController(signal) {
47
47
  function getTurnAbortSignal(sessionOptions) {
48
48
  return sessionOptions?.getAbortSignal?.() ?? sessionOptions?.abortSignal;
49
49
  }
50
+ async function settlesWithin(promise, timeoutMs) {
51
+ let timer;
52
+ try {
53
+ return await Promise.race([
54
+ promise.then(() => true),
55
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }),
56
+ ]);
57
+ }
58
+ finally {
59
+ if (timer)
60
+ clearTimeout(timer);
61
+ }
62
+ }
50
63
  export class ClaudeAgent extends BaseAgent {
51
64
  deps;
52
65
  opts;
@@ -65,6 +78,7 @@ export class ClaudeAgent extends BaseAgent {
65
78
  const platform = this.deps.platform ?? process.platform;
66
79
  const hostEnv = this.deps.hostEnv ?? process.env;
67
80
  const envExecutable = this.deps.envExecutable ?? process.env.PATHGRADE_CLAUDE_CODE_EXECUTABLE;
81
+ const queryAbortGraceMs = Math.max(0, this.deps.queryAbortGraceMs ?? 250);
68
82
  const runtimeEnv = getRuntimeEnv(runtime);
69
83
  const sensitiveValues = collectSensitiveEnvValues(runtimeEnv);
70
84
  const sandboxedSpawn = createSandboxedClaudeSpawn({
@@ -86,7 +100,8 @@ export class ClaudeAgent extends BaseAgent {
86
100
  mcpSafety: sessionOptions?.mcpSafety,
87
101
  });
88
102
  await assertStdioMcpServersStartForClaudeSdk(mcpMountOptions);
89
- const mcpServers = await mountMcpForClaudeSdk(mcpMountOptions);
103
+ const mcpServers = sessionOptions?.scriptedMcpHost?.claudeServers
104
+ ?? await mountMcpForClaudeSdk(mcpMountOptions);
90
105
  let priorSessionId;
91
106
  let turnNumber = 0;
92
107
  // The live ask-user bridge resolves AskUserQuestion through the bus
@@ -97,12 +112,19 @@ export class ClaudeAgent extends BaseAgent {
97
112
  // collisions.
98
113
  let answerStore = createAskUserAnswerStore();
99
114
  let deniedMcpEvents = createClaudeDeniedMcpEventStore();
115
+ let scriptedEvents = [];
116
+ let activeQuery;
117
+ let activeController;
118
+ let quiescence = Promise.resolve();
119
+ let resolveQuiescence;
100
120
  const bridge = createClaudeToolPermissionBridge({
101
121
  askBus,
102
122
  getTurnNumber: () => turnNumber,
103
123
  answerStore: { record: (id, e) => answerStore.record(id, e), get: (id) => answerStore.get(id) },
104
124
  mcpServerNames: mcpServers ? Object.keys(mcpServers) : [],
105
125
  mcpSafety: sessionOptions?.mcpSafety,
126
+ scriptedMcpHost: sessionOptions?.scriptedMcpHost,
127
+ recordScriptedEvent: (event) => scriptedEvents.push(event),
106
128
  deniedMcpEvents: {
107
129
  record: (id, event) => deniedMcpEvents.record(id, event),
108
130
  get: (id) => deniedMcpEvents.get(id),
@@ -114,9 +136,11 @@ export class ClaudeAgent extends BaseAgent {
114
136
  turnNumber += 1;
115
137
  answerStore = createAskUserAnswerStore();
116
138
  deniedMcpEvents = createClaudeDeniedMcpEventStore();
139
+ scriptedEvents = [];
117
140
  // Clear any ask-bus rejection captured on a prior turn so a
118
141
  // stale error never causes a spurious result on this turn.
119
142
  bridge.clearLastError();
143
+ activeController = createLinkedAbortController(getTurnAbortSignal(sessionOptions));
120
144
  const sdkOptions = buildClaudeSdkOptions({
121
145
  workspacePath,
122
146
  spawnClaudeCodeProcess: sandboxedSpawn,
@@ -126,21 +150,31 @@ export class ClaudeAgent extends BaseAgent {
126
150
  claudeCodeExecutable,
127
151
  resume: priorSessionId,
128
152
  mcpServers,
129
- abortController: createLinkedAbortController(getTurnAbortSignal(sessionOptions)),
153
+ abortController: activeController,
130
154
  });
131
155
  const messages = [];
132
156
  const messageTimings = [];
133
157
  const now = this.deps.now ?? (() => new Date());
134
158
  const monotonicNow = this.deps.monotonicNow ?? (() => performance.now());
135
159
  const stream = queryFn({ prompt: message, options: sdkOptions });
136
- for await (const msg of stream) {
137
- messages.push(msg);
138
- messageTimings.push(hasToolLifecycleBoundary(msg)
139
- ? {
140
- receivedAt: now().toISOString(),
141
- receivedMonotonicMs: monotonicNow(),
142
- }
143
- : undefined);
160
+ activeQuery = stream;
161
+ quiescence = new Promise((resolve) => { resolveQuiescence = resolve; });
162
+ try {
163
+ for await (const msg of stream) {
164
+ messages.push(msg);
165
+ messageTimings.push(hasToolLifecycleBoundary(msg)
166
+ ? {
167
+ receivedAt: now().toISOString(),
168
+ receivedMonotonicMs: monotonicNow(),
169
+ }
170
+ : undefined);
171
+ }
172
+ }
173
+ finally {
174
+ activeQuery = undefined;
175
+ activeController = undefined;
176
+ resolveQuiescence?.();
177
+ resolveQuiescence = undefined;
144
178
  }
145
179
  // The legacy NDJSON parser only synthesized the slash-command
146
180
  // `use_skill` event from the *opening* user message. The Claude
@@ -161,6 +195,7 @@ export class ClaudeAgent extends BaseAgent {
161
195
  deniedMcpEvents,
162
196
  sensitiveValues,
163
197
  });
198
+ projected.result.toolEvents = [...scriptedEvents, ...projected.result.toolEvents];
164
199
  // Capture the SDK-reported session id BEFORE checking for a bus
165
200
  // rejection so the next turn's `Options.resume` points at this
166
201
  // turn's session even when the turn ended in an ask-bus error.
@@ -191,6 +226,16 @@ export class ClaudeAgent extends BaseAgent {
191
226
  return {
192
227
  start: ({ message }) => runTurn(message),
193
228
  reply: ({ message }) => runTurn(message),
229
+ async dispose() {
230
+ const query = activeQuery;
231
+ activeController?.abort();
232
+ if (!query)
233
+ return;
234
+ if (await settlesWithin(quiescence, queryAbortGraceMs))
235
+ return;
236
+ query.close();
237
+ await settlesWithin(quiescence, queryAbortGraceMs);
238
+ },
194
239
  };
195
240
  }
196
241
  }