@salesforce/sfdx-agent-harness-mastra 0.18.0 → 0.20.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/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-harness-mastra` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.20.0] - 2026-06-15
7
+
8
+ ### Fixes
9
+ - **harness-claude,harness-mastra,agent-sdk**: idempotent settle + quiet dispose (#589) ([#598](https://github.com/forcedotcom/agentic-dx/pull/598))
10
+
11
+ ## [0.19.0] - 2026-06-12
12
+
13
+ ### Features
14
+ - **agent-sdk,harness-mastra,harness-claude**: add tool-call-delta ChatEvent for streaming tool-call args @W-22965697@ ([#597](https://github.com/forcedotcom/agentic-dx/pull/597))
15
+ - **agent-sdk,harness-mastra,harness-claude**: add tool-progress ChatEvent variant @W-22951127@ ([#594](https://github.com/forcedotcom/agentic-dx/pull/594))
16
+
17
+ ### Fixes
18
+ - **harness-claude**: map SDKResultSuccess.is_error to ChatEvent.error ([#593](https://github.com/forcedotcom/agentic-dx/pull/593))
19
+
6
20
  ## [0.18.0] - 2026-06-11
7
21
 
8
22
  ### Fixes
package/README.md CHANGED
@@ -203,9 +203,12 @@ active `eventStream`:
203
203
  { type: 'finish', finishReason: 'error' }
204
204
  ```
205
205
 
206
- Subsequent `approveToolCall` / `declineToolCall` / `submitToolResult` calls for the timed-out tool throw because the
207
- coordinator has already auto-resolved them. The granularity is per `toolCallId` — each emitted approval arms its own
208
- timer; the first one to fire tears down the whole turn (since Mastra can't continue the suspended run after timeout
206
+ Subsequent `approveToolCall` / `declineToolCall` / `submitToolResult` calls for the timed-out tool are silent no-ops
207
+ (issue #589 idempotent settle) — the coordinator already auto-resolved them, so the consumer's intended outcome is
208
+ already true and there's nothing to fail. Only a `toolCallId` the coordinator never saw at all (typo, wrong session,
209
+ called before `tool-approval-request` ever fired) throws, and that throw is an `AgentSDKError` whose
210
+ `type === AgentSDKErrorType.TOOL_CALL_NOT_FOUND`. The granularity is per `toolCallId` — each emitted approval arms its
211
+ own timer; the first one to fire tears down the whole turn (since Mastra can't continue the suspended run after timeout
209
212
  anyway). To observe the timeout, keep iterating the `eventStream` past the `tool-approval-request` event so the
210
213
  synthetic terminal pair lands in your loop:
211
214
 
@@ -128,8 +128,11 @@ export declare class MastraApprovalCoordinator {
128
128
  private readonly consumerSettled;
129
129
  /**
130
130
  * ToolCallIds the coordinator auto-resolved (timeout / abort / dispose
131
- * / consumer-tool auto-approve). A subsequent settle for these ids
132
- * rejects with "already auto-resolved by the coordinator".
131
+ * / pump-error / consumer-tool auto-approve). A subsequent settle for
132
+ * these ids is a silent no-op via the idempotent-settle contract
133
+ * (issue #589) — the lost race is unactionable from the consumer's
134
+ * side, and the methods are void-typed so there's no return value to
135
+ * communicate the no-op.
133
136
  */
134
137
  private readonly settledByCoordinator;
135
138
  /** Per-toolCallId approval-timeout timers (armed when an approval is emitted). */
@@ -142,6 +145,29 @@ export declare class MastraApprovalCoordinator {
142
145
  * `agent.approveToolCall(...)` / `declineToolCall(...)`.
143
146
  */
144
147
  private currentRunId;
148
+ /**
149
+ * `toolCallId` of the MCP tool call currently in-flight on this turn,
150
+ * or `null` when no MCP tool is awaiting a result. Tracked at
151
+ * `routeEnriched` time: set when a MCP-namespaced `tool-call` arrives,
152
+ * cleared on the matching `tool-result`.
153
+ *
154
+ * Used by {@link emitMcpProgress} to attribute incoming
155
+ * `notifications/progress` (which only carry a `progressToken`) to the
156
+ * right `toolCallId` for the `tool-progress` ChatEvent.
157
+ *
158
+ * Mastra serializes MCP tool execution per workflow step, so at most
159
+ * one MCP tool call is in flight per coordinator at any time —
160
+ * matching the "currentMcpToolCallId" field's scalar shape.
161
+ */
162
+ private currentMcpToolCallId;
163
+ /**
164
+ * Captures the (Mastra-namespaced) tool name for each `toolCallId`
165
+ * routed through `routeEnriched`. Used by {@link emitMcpProgress} to
166
+ * stamp `toolName` on the `tool-progress` ChatEvent so AG-UI Activity
167
+ * translators don't have to consult the originating `tool-call` event
168
+ * to route progress chunks.
169
+ */
170
+ private readonly toolNameForCallId;
145
171
  /**
146
172
  * ToolCallIds Mastra is currently suspended on. Mastra retains a separate
147
173
  * workflow snapshot step per id, so the pump must drive one resume per
@@ -158,6 +184,15 @@ export declare class MastraApprovalCoordinator {
158
184
  * parker invocation for the same id.
159
185
  */
160
186
  private readonly preSubmittedByToolCallId;
187
+ /**
188
+ * ToolCallIds the consumer has settled via {@link submitToolResult},
189
+ * or that the coordinator drained at teardown time. Used as the
190
+ * idempotent-settle ledger for the consumer-tool path (parallel to
191
+ * `consumerSettled` + `settledByCoordinator` on the approve/decline
192
+ * path). A subsequent `submitToolResult` for these ids is a silent
193
+ * no-op rather than throwing `TOOL_CALL_NOT_FOUND` (issue #589).
194
+ */
195
+ private readonly consumerToolSettled;
161
196
  /**
162
197
  * Callbacks invoked exactly once during teardown (`abort` or `dispose`,
163
198
  * whichever fires first, or natural pump exit).
@@ -175,6 +210,33 @@ export declare class MastraApprovalCoordinator {
175
210
  private timedOut;
176
211
  constructor(opts: MastraApprovalCoordinatorOptions);
177
212
  initialSink(): GenSink<ChatEvent>;
213
+ /**
214
+ * Mastra's current run id. The harness's MCP-progress dispatcher uses
215
+ * this to filter incoming `notifications/progress` to the coordinator
216
+ * whose run produced the originating tool call (the upstream server
217
+ * received the progressToken Mastra injected from `context.runId`).
218
+ */
219
+ getCurrentRunId(): string;
220
+ /**
221
+ * Issue #591: dispatch an MCP `notifications/progress` notification
222
+ * received via `mcpClient.progress.onUpdate(...)` to the consumer's
223
+ * eventStream as a `tool-progress` ChatEvent.
224
+ *
225
+ * Caller (the harness MCP-progress listener) is responsible for
226
+ * filtering by `progressToken === coordinator.getCurrentRunId()` so this
227
+ * method only sees notifications belonging to its own turn.
228
+ *
229
+ * Drops the notification (no event surfaced) when no MCP tool call is
230
+ * currently in flight — the only path that produces `currentMcpToolCallId
231
+ * === null` after a turn started is "progress arrived after the matching
232
+ * tool-result." Losing a stragglier is preferable to surfacing a
233
+ * progress event without a meaningful toolCallId.
234
+ */
235
+ emitMcpProgress(params: {
236
+ progress: number;
237
+ total?: number;
238
+ message?: string;
239
+ }): void;
178
240
  onTeardown(callback: () => void): void;
179
241
  private runTeardownCallbacks;
180
242
  /**
@@ -188,13 +250,39 @@ export declare class MastraApprovalCoordinator {
188
250
  /**
189
251
  * Single teardown path for abort / dispose / approval-timeout / pump-
190
252
  * error. Drains timers, rejects pending consumer-tool parkers, marks
191
- * every emitted approval as coordinator-settled, releases Mastra's
192
- * suspended run, pushes the synthetic terminal pair on the sink, and
193
- * fires teardown callbacks.
253
+ * every emitted approval and consumer-tool toolCallId as
254
+ * coordinator-settled, releases Mastra's suspended run, pushes the
255
+ * typed `error` + `finish('error')` pair on the sink, and fires
256
+ * teardown callbacks.
257
+ *
258
+ * Every teardown reason — including `'dispose'` — pushes both events
259
+ * (issue #589). Two reasons:
260
+ *
261
+ * - **Consumers route on `error.code`, not on the *presence* of an
262
+ * error event.** AFV's `chat-stream-service.ts` maps
263
+ * `error.code === AgentSDKErrorType.DISPOSED` → its internal
264
+ * `AGENT_CANCELLED` and reaps the turn as cancelled (rather than
265
+ * completed or failed). Suppressing the dispose error event would
266
+ * delete that signal and a turn that was actually torn down would
267
+ * record as successful in their telemetry pipeline. The original
268
+ * bug (issue #589 failure 2) was *untyped* error noise —
269
+ * `error.code` is already typed as `AgentSDKErrorType.DISPOSED`,
270
+ * which gives consumers a clean discriminator without
271
+ * string-matching.
272
+ *
273
+ * - **The SDK invariant requires every chat turn to end with one
274
+ * terminal `FinishEvent`.** `DefaultChatSession.wrapEventStream`
275
+ * only synthesizes a synthetic `finish` when an error landed on
276
+ * the stream; if dispose ended the sink without an error, the
277
+ * turn would end with neither a natural finish nor a synthetic
278
+ * one — breaking the SDK contract pinned in
279
+ * `packages/sfdx-agent-sdk/ARCHITECTURE.md` →
280
+ * "Critical Invariants" → "Every chat turn ends with exactly one
281
+ * terminal FinishEvent".
194
282
  *
195
- * The `reason` parameter only changes the sink's terminal shape:
196
- * `'dispose'` pushes error-only (matches Claude's dispose contract);
197
- * everything else pushes error+finish.
283
+ * Mirrors the Claude coordinator's `teardown` so the abort / dispose
284
+ * / timeout / pump-error paths can't drift out of step on either
285
+ * harness — issue #589 lands the same shape on both.
198
286
  */
199
287
  private teardown;
200
288
  approve(toolCallId: string): void;
@@ -64,8 +64,11 @@ export class MastraApprovalCoordinator {
64
64
  consumerSettled = new Set();
65
65
  /**
66
66
  * ToolCallIds the coordinator auto-resolved (timeout / abort / dispose
67
- * / consumer-tool auto-approve). A subsequent settle for these ids
68
- * rejects with "already auto-resolved by the coordinator".
67
+ * / pump-error / consumer-tool auto-approve). A subsequent settle for
68
+ * these ids is a silent no-op via the idempotent-settle contract
69
+ * (issue #589) — the lost race is unactionable from the consumer's
70
+ * side, and the methods are void-typed so there's no return value to
71
+ * communicate the no-op.
69
72
  */
70
73
  settledByCoordinator = new Set();
71
74
  /** Per-toolCallId approval-timeout timers (armed when an approval is emitted). */
@@ -78,6 +81,29 @@ export class MastraApprovalCoordinator {
78
81
  * `agent.approveToolCall(...)` / `declineToolCall(...)`.
79
82
  */
80
83
  currentRunId = '';
84
+ /**
85
+ * `toolCallId` of the MCP tool call currently in-flight on this turn,
86
+ * or `null` when no MCP tool is awaiting a result. Tracked at
87
+ * `routeEnriched` time: set when a MCP-namespaced `tool-call` arrives,
88
+ * cleared on the matching `tool-result`.
89
+ *
90
+ * Used by {@link emitMcpProgress} to attribute incoming
91
+ * `notifications/progress` (which only carry a `progressToken`) to the
92
+ * right `toolCallId` for the `tool-progress` ChatEvent.
93
+ *
94
+ * Mastra serializes MCP tool execution per workflow step, so at most
95
+ * one MCP tool call is in flight per coordinator at any time —
96
+ * matching the "currentMcpToolCallId" field's scalar shape.
97
+ */
98
+ currentMcpToolCallId = null;
99
+ /**
100
+ * Captures the (Mastra-namespaced) tool name for each `toolCallId`
101
+ * routed through `routeEnriched`. Used by {@link emitMcpProgress} to
102
+ * stamp `toolName` on the `tool-progress` ChatEvent so AG-UI Activity
103
+ * translators don't have to consult the originating `tool-call` event
104
+ * to route progress chunks.
105
+ */
106
+ toolNameForCallId = new Map();
81
107
  /**
82
108
  * ToolCallIds Mastra is currently suspended on. Mastra retains a separate
83
109
  * workflow snapshot step per id, so the pump must drive one resume per
@@ -94,6 +120,15 @@ export class MastraApprovalCoordinator {
94
120
  * parker invocation for the same id.
95
121
  */
96
122
  preSubmittedByToolCallId = new Map();
123
+ /**
124
+ * ToolCallIds the consumer has settled via {@link submitToolResult},
125
+ * or that the coordinator drained at teardown time. Used as the
126
+ * idempotent-settle ledger for the consumer-tool path (parallel to
127
+ * `consumerSettled` + `settledByCoordinator` on the approve/decline
128
+ * path). A subsequent `submitToolResult` for these ids is a silent
129
+ * no-op rather than throwing `TOOL_CALL_NOT_FOUND` (issue #589).
130
+ */
131
+ consumerToolSettled = new Set();
97
132
  /**
98
133
  * Callbacks invoked exactly once during teardown (`abort` or `dispose`,
99
134
  * whichever fires first, or natural pump exit).
@@ -123,6 +158,43 @@ export class MastraApprovalCoordinator {
123
158
  initialSink() {
124
159
  return this.initial;
125
160
  }
161
+ /**
162
+ * Mastra's current run id. The harness's MCP-progress dispatcher uses
163
+ * this to filter incoming `notifications/progress` to the coordinator
164
+ * whose run produced the originating tool call (the upstream server
165
+ * received the progressToken Mastra injected from `context.runId`).
166
+ */
167
+ getCurrentRunId() {
168
+ return this.currentRunId;
169
+ }
170
+ /**
171
+ * Issue #591: dispatch an MCP `notifications/progress` notification
172
+ * received via `mcpClient.progress.onUpdate(...)` to the consumer's
173
+ * eventStream as a `tool-progress` ChatEvent.
174
+ *
175
+ * Caller (the harness MCP-progress listener) is responsible for
176
+ * filtering by `progressToken === coordinator.getCurrentRunId()` so this
177
+ * method only sees notifications belonging to its own turn.
178
+ *
179
+ * Drops the notification (no event surfaced) when no MCP tool call is
180
+ * currently in flight — the only path that produces `currentMcpToolCallId
181
+ * === null` after a turn started is "progress arrived after the matching
182
+ * tool-result." Losing a stragglier is preferable to surfacing a
183
+ * progress event without a meaningful toolCallId.
184
+ */
185
+ emitMcpProgress(params) {
186
+ if (this.aborted || this.disposed)
187
+ return;
188
+ const toolCallId = this.currentMcpToolCallId;
189
+ if (!toolCallId)
190
+ return;
191
+ this.initial.push({
192
+ type: 'tool-progress',
193
+ toolCallId,
194
+ toolName: this.toolNameForCallId.get(toolCallId) ?? 'unknown',
195
+ output: { ...params },
196
+ });
197
+ }
126
198
  onTeardown(callback) {
127
199
  if (this.aborted || this.disposed) {
128
200
  queueMicrotask(callback);
@@ -169,18 +241,52 @@ export class MastraApprovalCoordinator {
169
241
  /**
170
242
  * Single teardown path for abort / dispose / approval-timeout / pump-
171
243
  * error. Drains timers, rejects pending consumer-tool parkers, marks
172
- * every emitted approval as coordinator-settled, releases Mastra's
173
- * suspended run, pushes the synthetic terminal pair on the sink, and
174
- * fires teardown callbacks.
244
+ * every emitted approval and consumer-tool toolCallId as
245
+ * coordinator-settled, releases Mastra's suspended run, pushes the
246
+ * typed `error` + `finish('error')` pair on the sink, and fires
247
+ * teardown callbacks.
248
+ *
249
+ * Every teardown reason — including `'dispose'` — pushes both events
250
+ * (issue #589). Two reasons:
175
251
  *
176
- * The `reason` parameter only changes the sink's terminal shape:
177
- * `'dispose'` pushes error-only (matches Claude's dispose contract);
178
- * everything else pushes error+finish.
252
+ * - **Consumers route on `error.code`, not on the *presence* of an
253
+ * error event.** AFV's `chat-stream-service.ts` maps
254
+ * `error.code === AgentSDKErrorType.DISPOSED` → its internal
255
+ * `AGENT_CANCELLED` and reaps the turn as cancelled (rather than
256
+ * completed or failed). Suppressing the dispose error event would
257
+ * delete that signal and a turn that was actually torn down would
258
+ * record as successful in their telemetry pipeline. The original
259
+ * bug (issue #589 failure 2) was *untyped* error noise —
260
+ * `error.code` is already typed as `AgentSDKErrorType.DISPOSED`,
261
+ * which gives consumers a clean discriminator without
262
+ * string-matching.
263
+ *
264
+ * - **The SDK invariant requires every chat turn to end with one
265
+ * terminal `FinishEvent`.** `DefaultChatSession.wrapEventStream`
266
+ * only synthesizes a synthetic `finish` when an error landed on
267
+ * the stream; if dispose ended the sink without an error, the
268
+ * turn would end with neither a natural finish nor a synthetic
269
+ * one — breaking the SDK contract pinned in
270
+ * `packages/sfdx-agent-sdk/ARCHITECTURE.md` →
271
+ * "Critical Invariants" → "Every chat turn ends with exactly one
272
+ * terminal FinishEvent".
273
+ *
274
+ * Mirrors the Claude coordinator's `teardown` so the abort / dispose
275
+ * / timeout / pump-error paths can't drift out of step on either
276
+ * harness — issue #589 lands the same shape on both.
179
277
  */
180
278
  teardown(reason, error, code) {
181
279
  this.cancelAllTimers();
182
280
  for (const id of this.emittedApprovals)
183
281
  this.settledByCoordinator.add(id);
282
+ // Issue #589: drain in-flight consumer-tool ids into the
283
+ // idempotent-settle ledger so a late `submitToolResult` after
284
+ // teardown is a silent no-op rather than throwing
285
+ // `TOOL_CALL_NOT_FOUND`. Mirrors the approve/decline drain above.
286
+ for (const id of this.pendingByToolCallId.keys())
287
+ this.consumerToolSettled.add(id);
288
+ for (const id of this.toolCallArgs.keys())
289
+ this.consumerToolSettled.add(id);
184
290
  // Reject every parked consumer-tool call so the bridge handler
185
291
  // unblocks with an error rather than waiting forever.
186
292
  for (const entry of this.pendingByToolCallId.values())
@@ -204,18 +310,9 @@ export class MastraApprovalCoordinator {
204
310
  const resolve = this.continuationResolver;
205
311
  this.continuationResolver = undefined;
206
312
  resolve?.('done');
207
- if (reason === 'dispose') {
208
- // Match Claude's dispose shape: error event only, no `finish`.
209
- // Disposal is a consumer-triggered teardown, not a normal turn
210
- // end, so a `finish` event would be misleading.
211
- this.initial.push({ type: 'error', error, code });
212
- this.initial.end();
213
- }
214
- else {
215
- this.initial.push({ type: 'error', error, code });
216
- this.initial.push({ type: 'finish', finishReason: 'error' });
217
- this.initial.end();
218
- }
313
+ this.initial.push({ type: 'error', error, code });
314
+ this.initial.push({ type: 'finish', finishReason: 'error' });
315
+ this.initial.end();
219
316
  this.pendingSuspendIds.clear();
220
317
  this.runTeardownCallbacks();
221
318
  }
@@ -227,14 +324,21 @@ export class MastraApprovalCoordinator {
227
324
  this.recordDecision('decline', toolCallId, { kind: 'decline' });
228
325
  }
229
326
  recordDecision(method, toolCallId, decision) {
230
- if (this.settledByCoordinator.has(toolCallId)) {
231
- throw new Error(`MastraApprovalCoordinator.${method}(): tool call "${toolCallId}" was already auto-resolved by the coordinator`);
232
- }
233
- if (this.consumerSettled.has(toolCallId)) {
234
- throw new Error(`MastraApprovalCoordinator.${method}(): tool call "${toolCallId}" was already settled`);
327
+ // Issue #589: idempotent settle. A tool call the coordinator
328
+ // already auto-resolved (timeout / abort / dispose / pump-error)
329
+ // or the consumer already settled is a lost-race no-op rather
330
+ // than a throw — settle methods are void-typed, so the consumer
331
+ // never branches on a return value, and the outcome they wanted
332
+ // (id is settled) is already true.
333
+ if (this.settledByCoordinator.has(toolCallId) || this.consumerSettled.has(toolCallId)) {
334
+ return;
235
335
  }
236
336
  if (!this.emittedApprovals.has(toolCallId)) {
237
- throw new Error(`MastraApprovalCoordinator.${method}(): no pending approval for tool call "${toolCallId}"`);
337
+ // Genuine misuse — the coordinator never saw this id (typo,
338
+ // wrong session, or settle called before approval-request /
339
+ // tool-call ever fired). Typed so consumers can branch on
340
+ // err.type instead of string-matching the message.
341
+ throw new AgentSDKError(`MastraApprovalCoordinator.${method}(): no pending approval for tool call "${toolCallId}"`, AgentSDKErrorType.TOOL_CALL_NOT_FOUND);
238
342
  }
239
343
  this.consumerSettled.add(toolCallId);
240
344
  this.cancelTimer(toolCallId);
@@ -284,8 +388,18 @@ export class MastraApprovalCoordinator {
284
388
  * resolves (probe-validated, see #529 step 2c).
285
389
  */
286
390
  submitToolResult(toolCallId, result, isError) {
391
+ // Issue #589: idempotent settle. A submitToolResult for an id the
392
+ // consumer already settled, or that the coordinator drained at
393
+ // teardown time, is a silent no-op — the lost-race shape is
394
+ // unactionable from the consumer's side, and the method is
395
+ // void-typed. Mirrors the approve/decline path's
396
+ // `consumerSettled` + `settledByCoordinator` ledger.
397
+ if (this.consumerToolSettled.has(toolCallId)) {
398
+ return;
399
+ }
287
400
  const entry = this.pendingByToolCallId.get(toolCallId);
288
401
  if (entry) {
402
+ this.consumerToolSettled.add(toolCallId);
289
403
  this.pendingByToolCallId.delete(toolCallId);
290
404
  entry.resolve({ result, isError });
291
405
  return;
@@ -295,10 +409,14 @@ export class MastraApprovalCoordinator {
295
409
  // We allow this whenever the coordinator has seen a `tool-call`
296
410
  // event under that id (reachable in `toolCallArgs`); else throw.
297
411
  if (this.toolCallArgs.has(toolCallId)) {
412
+ this.consumerToolSettled.add(toolCallId);
298
413
  this.preSubmittedByToolCallId.set(toolCallId, { result, isError });
299
414
  return;
300
415
  }
301
- throw new Error(`MastraApprovalCoordinator.submitToolResult(): no pending tool call for id "${toolCallId}"`);
416
+ // Issue #589: typed error — the coordinator never saw this id, so
417
+ // this is genuine misuse rather than the lost-race shape the
418
+ // idempotent-settle path collapses to a no-op.
419
+ throw new AgentSDKError(`MastraApprovalCoordinator.submitToolResult(): no pending tool call for id "${toolCallId}"`, AgentSDKErrorType.TOOL_CALL_NOT_FOUND);
302
420
  }
303
421
  // ───────────────────────── pump loop ────────────────────────────────────
304
422
  /**
@@ -548,6 +666,21 @@ export class MastraApprovalCoordinator {
548
666
  if (this.aborted || this.disposed)
549
667
  return;
550
668
  const enriched = this.maybeEnrich(event);
669
+ // Issue #591: track in-flight MCP tool call so progress notifications
670
+ // arriving via `progress.onUpdate(...)` can be attributed to the
671
+ // right `toolCallId`. Set on enriched (i.e. MCP-namespaced) tool-call;
672
+ // cleared on the matching tool-result. Non-MCP tools don't carry
673
+ // `serverName` (the lookup returns undefined) so this never fires
674
+ // for built-ins, consumer-declared tools, or workspace tools.
675
+ if (enriched.type === 'tool-call' && enriched.serverName !== undefined) {
676
+ this.currentMcpToolCallId = enriched.toolCallId;
677
+ this.toolNameForCallId.set(enriched.toolCallId, enriched.toolName);
678
+ }
679
+ else if (enriched.type === 'tool-result' &&
680
+ enriched.serverName !== undefined &&
681
+ this.currentMcpToolCallId === enriched.toolCallId) {
682
+ this.currentMcpToolCallId = null;
683
+ }
551
684
  this.initial.push(enriched);
552
685
  }
553
686
  maybeEnrich(event) {
@@ -33,6 +33,8 @@ import type { ChatEvent } from '@salesforce/sfdx-agent-sdk';
33
33
  * | `tool-call-approval` | `tool-approval-request` | `{ toolCall: { toolCallId, toolName, args } }` — stream suspends |
34
34
  * | `tool-result` | `tool-result` | `{ toolCallId, toolName, result, isError: false }` |
35
35
  * | `tool-error` | `tool-result` | `{ toolCallId, toolName, result: error, isError: true}`|
36
+ * | `tool-output` | `tool-progress` | `{ toolCallId, toolName, output }` |
37
+ * | `tool-call-delta` | `tool-call-delta` | `{ toolCallId, toolName?, argsTextDelta }` |
36
38
  * | `step-start` | `step-start` | `{ stepIndex }` from step metadata |
37
39
  * | `step-finish` | `step-finish` | `{ stepIndex, finishReason, usage }` from `stepResult` |
38
40
  * | `finish` | `finish` | `{ finishReason, usage }` from `output` |
@@ -44,9 +46,8 @@ import type { ChatEvent } from '@salesforce/sfdx-agent-sdk';
44
46
  * | `reasoning-start`, | | |
45
47
  * | `reasoning-end` | | |
46
48
  * | `source`, `file`, | *(skipped)* | Known but unsupported — future ChatEvent candidates |
47
- * | `tool-call-delta`, | | |
48
49
  * | `tool-call-suspended`, | | |
49
- * | `object`, `tool-output` | | |
50
+ * | `object` | | |
50
51
  * | *(all others)* | *(skipped)* | Logged via `LogBus.debug` with `{ chunkType, rawChunk }` |
51
52
  *
52
53
  * Stateful per stream — a new instance must be created for each `MastraHarness.stream()` call.
@@ -34,6 +34,8 @@
34
34
  * | `tool-call-approval` | `tool-approval-request` | `{ toolCall: { toolCallId, toolName, args } }` — stream suspends |
35
35
  * | `tool-result` | `tool-result` | `{ toolCallId, toolName, result, isError: false }` |
36
36
  * | `tool-error` | `tool-result` | `{ toolCallId, toolName, result: error, isError: true}`|
37
+ * | `tool-output` | `tool-progress` | `{ toolCallId, toolName, output }` |
38
+ * | `tool-call-delta` | `tool-call-delta` | `{ toolCallId, toolName?, argsTextDelta }` |
37
39
  * | `step-start` | `step-start` | `{ stepIndex }` from step metadata |
38
40
  * | `step-finish` | `step-finish` | `{ stepIndex, finishReason, usage }` from `stepResult` |
39
41
  * | `finish` | `finish` | `{ finishReason, usage }` from `output` |
@@ -45,9 +47,8 @@
45
47
  * | `reasoning-start`, | | |
46
48
  * | `reasoning-end` | | |
47
49
  * | `source`, `file`, | *(skipped)* | Known but unsupported — future ChatEvent candidates |
48
- * | `tool-call-delta`, | | |
49
50
  * | `tool-call-suspended`, | | |
50
- * | `object`, `tool-output` | | |
51
+ * | `object` | | |
51
52
  * | *(all others)* | *(skipped)* | Logged via `LogBus.debug` with `{ chunkType, rawChunk }` |
52
53
  *
53
54
  * Stateful per stream — a new instance must be created for each `MastraHarness.stream()` call.
@@ -169,19 +170,35 @@ export class MastraEventAdapter {
169
170
  case 'reasoning-end':
170
171
  this.logBus?.debug('Skipping informational Mastra chunk', { chunkType: chunk.type });
171
172
  return undefined;
173
+ case 'tool-output':
174
+ return {
175
+ type: 'tool-progress',
176
+ toolCallId: chunk.payload.toolCallId,
177
+ toolName: chunk.payload.toolName ?? 'unknown',
178
+ output: chunk.payload.output,
179
+ };
180
+ case 'tool-call-delta':
181
+ return {
182
+ type: 'tool-call-delta',
183
+ toolCallId: chunk.payload.toolCallId,
184
+ // Strict `!== undefined` (not truthy) so an empty-string
185
+ // toolName from upstream — unlikely but possible — is
186
+ // forwarded honestly instead of silently swallowed.
187
+ ...(chunk.payload.toolName !== undefined ? { toolName: chunk.payload.toolName } : {}),
188
+ argsTextDelta: chunk.payload.argsTextDelta,
189
+ };
172
190
  // Known harness chunk types not yet supported in the SDK's ChatEvent
173
191
  // model. Silently dropped to avoid leaking harness internals onto the
174
192
  // public stream. Candidates for future ChatEvent variants when the
175
- // corresponding features (citations, file generation, tool arg
176
- // streaming, tool suspension, structured output) are supported.
193
+ // corresponding features (citations, file generation, tool input
194
+ // lifecycle bookends, tool suspension, structured output) are
195
+ // supported.
177
196
  case 'source':
178
197
  case 'file':
179
- case 'tool-call-delta':
180
198
  case 'tool-call-input-streaming-start':
181
199
  case 'tool-call-input-streaming-end':
182
200
  case 'tool-call-suspended':
183
201
  case 'object':
184
- case 'tool-output':
185
202
  this.logBus?.debug('Skipping unsupported Mastra chunk type', { chunkType: chunk.type });
186
203
  return undefined;
187
204
  default:
@@ -478,6 +478,22 @@ export declare class MastraHarness implements MastraAgentHarness {
478
478
  * after `shutdown()` has disposed the buses.
479
479
  */
480
480
  private prepareMcpStatusChange;
481
+ /**
482
+ * Issue #591: install a `progress.onUpdate(serverName, ...)` listener
483
+ * on the MCP client. Incoming `notifications/progress` are dispatched
484
+ * to the active coordinator on this agent whose `currentRunId` matches
485
+ * the notification's `progressToken` — `enableProgressTracking: true`
486
+ * makes `@mastra/mcp` inject `_meta.progressToken = context.runId` into
487
+ * every tool call, so the token uniquely identifies the originating
488
+ * turn even across concurrent threads on the same agent.
489
+ *
490
+ * `progress.onUpdate` is async (it lazily connects the underlying
491
+ * internal client). We swallow rejections inside a fire-and-forget
492
+ * wrapper rather than blocking `createAgent`/`updateAgent` — discovery
493
+ * and progress tracking are independent: a server that fails to wire
494
+ * progress still works for tool calls, just without progress events.
495
+ */
496
+ private registerMcpProgressListener;
481
497
  /**
482
498
  * Runs MCP discovery for one server: emits the
483
499
  * `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
@@ -473,6 +473,9 @@ export class MastraHarness {
473
473
  };
474
474
  mcpServerStates.set(serverName, entry);
475
475
  entry.ready = this.runMcpDiscovery(agentId, serverName, client, mcpServerStates, 0, options?.abortSignal);
476
+ // Issue #591: bridge MCP `notifications/progress` onto the
477
+ // coordinator's per-turn sink as `tool-progress` ChatEvents.
478
+ this.registerMcpProgressListener(agentId, serverName, client);
476
479
  }
477
480
  }
478
481
  // Issue #529 step 2c: the bridge `execute` handler resolves the
@@ -893,6 +896,8 @@ export class MastraHarness {
893
896
  };
894
897
  state.mcpServers.set(name, entry);
895
898
  entry.ready = this.runMcpDiscovery(agentId, name, client, state.mcpServers, 0, options?.abortSignal);
899
+ // Issue #591: same MCP-progress wiring as createAgent path.
900
+ this.registerMcpProgressListener(agentId, name, client);
896
901
  }
897
902
  }
898
903
  }
@@ -1528,6 +1533,52 @@ export class MastraHarness {
1528
1533
  }
1529
1534
  };
1530
1535
  }
1536
+ /**
1537
+ * Issue #591: install a `progress.onUpdate(serverName, ...)` listener
1538
+ * on the MCP client. Incoming `notifications/progress` are dispatched
1539
+ * to the active coordinator on this agent whose `currentRunId` matches
1540
+ * the notification's `progressToken` — `enableProgressTracking: true`
1541
+ * makes `@mastra/mcp` inject `_meta.progressToken = context.runId` into
1542
+ * every tool call, so the token uniquely identifies the originating
1543
+ * turn even across concurrent threads on the same agent.
1544
+ *
1545
+ * `progress.onUpdate` is async (it lazily connects the underlying
1546
+ * internal client). We swallow rejections inside a fire-and-forget
1547
+ * wrapper rather than blocking `createAgent`/`updateAgent` — discovery
1548
+ * and progress tracking are independent: a server that fails to wire
1549
+ * progress still works for tool calls, just without progress events.
1550
+ */
1551
+ registerMcpProgressListener(agentId, serverName, client) {
1552
+ client.progress
1553
+ .onUpdate(serverName, (params) => {
1554
+ const agentState = this.activeAgents.get(agentId);
1555
+ if (!agentState)
1556
+ return;
1557
+ // Walk per-thread coordinators to find the one whose current
1558
+ // run produced this progressToken. Mastra's
1559
+ // `enableProgressTracking` injects `progressToken =
1560
+ // context.runId`, so `progressToken === coordinator.runId`
1561
+ // is an exact, single-match filter.
1562
+ const targetToken = String(params.progressToken);
1563
+ for (const coordinator of agentState.coordinators.values()) {
1564
+ if (coordinator.getCurrentRunId() === targetToken) {
1565
+ coordinator.emitMcpProgress({
1566
+ progress: params.progress,
1567
+ ...(params.total !== undefined && { total: params.total }),
1568
+ ...(params.message !== undefined && { message: params.message }),
1569
+ });
1570
+ return;
1571
+ }
1572
+ }
1573
+ })
1574
+ .catch((err) => {
1575
+ this.buses.getLogBus()?.warn('Failed to register MCP progress listener', {
1576
+ agentId,
1577
+ serverName,
1578
+ error: getErrorMessage(err),
1579
+ });
1580
+ });
1581
+ }
1531
1582
  /**
1532
1583
  * Runs MCP discovery for one server: emits the
1533
1584
  * `mcp-server-discovery-started` / `…-completed` / `…-failed` telemetry
@@ -56,6 +56,13 @@ export function mapToMastraMCPServers(config, orgJwt) {
56
56
  ...(serverConfig.args !== undefined && { args: serverConfig.args }),
57
57
  ...(serverConfig.env !== undefined && { env: serverConfig.env }),
58
58
  ...(serverConfig.timeout !== undefined && { timeout: serverConfig.timeout }),
59
+ // Issue #591: opt every server in to MCP progress notifications
60
+ // so the upstream server knows it can send `notifications/progress`
61
+ // (the harness bridges those to `tool-progress` ChatEvents via
62
+ // `mcpClient.progress.onUpdate(...)`). Default in `@mastra/mcp` is
63
+ // off, so without this the spec-compliant progressToken is never
64
+ // injected into `_meta` and servers send no progress.
65
+ enableProgressTracking: true,
59
66
  };
60
67
  result[name] = def;
61
68
  }
@@ -77,6 +84,8 @@ export function mapToMastraMCPServers(config, orgJwt) {
77
84
  ...serverConfig.reconnectionOptions,
78
85
  },
79
86
  }),
87
+ // Issue #591: same rationale as stdio.
88
+ enableProgressTracking: true,
80
89
  };
81
90
  result[name] = def;
82
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -38,11 +38,11 @@
38
38
  "@salesforce/llm-gateway-sdk": "0.14.0"
39
39
  },
40
40
  "peerDependencies": {
41
- "@salesforce/sfdx-agent-sdk": "0.19.0"
41
+ "@salesforce/sfdx-agent-sdk": "0.21.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@eslint/js": "^10.0.1",
45
- "@salesforce/sfdx-agent-sdk": "0.19.0",
45
+ "@salesforce/sfdx-agent-sdk": "0.21.0",
46
46
  "@types/node": "^22.19.20",
47
47
  "@vitest/coverage-istanbul": "^4.1.8",
48
48
  "@vitest/eslint-plugin": "^1.6.19",