@salesforce/sfdx-agent-harness-mastra 0.19.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,11 @@
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
+
6
11
  ## [0.19.0] - 2026-06-12
7
12
 
8
13
  ### Features
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). */
@@ -181,6 +184,15 @@ export declare class MastraApprovalCoordinator {
181
184
  * parker invocation for the same id.
182
185
  */
183
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;
184
196
  /**
185
197
  * Callbacks invoked exactly once during teardown (`abort` or `dispose`,
186
198
  * whichever fires first, or natural pump exit).
@@ -238,13 +250,39 @@ export declare class MastraApprovalCoordinator {
238
250
  /**
239
251
  * Single teardown path for abort / dispose / approval-timeout / pump-
240
252
  * error. Drains timers, rejects pending consumer-tool parkers, marks
241
- * every emitted approval as coordinator-settled, releases Mastra's
242
- * suspended run, pushes the synthetic terminal pair on the sink, and
243
- * 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".
244
282
  *
245
- * The `reason` parameter only changes the sink's terminal shape:
246
- * `'dispose'` pushes error-only (matches Claude's dispose contract);
247
- * 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.
248
286
  */
249
287
  private teardown;
250
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). */
@@ -117,6 +120,15 @@ export class MastraApprovalCoordinator {
117
120
  * parker invocation for the same id.
118
121
  */
119
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();
120
132
  /**
121
133
  * Callbacks invoked exactly once during teardown (`abort` or `dispose`,
122
134
  * whichever fires first, or natural pump exit).
@@ -229,18 +241,52 @@ export class MastraApprovalCoordinator {
229
241
  /**
230
242
  * Single teardown path for abort / dispose / approval-timeout / pump-
231
243
  * error. Drains timers, rejects pending consumer-tool parkers, marks
232
- * every emitted approval as coordinator-settled, releases Mastra's
233
- * suspended run, pushes the synthetic terminal pair on the sink, and
234
- * 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:
251
+ *
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.
235
263
  *
236
- * The `reason` parameter only changes the sink's terminal shape:
237
- * `'dispose'` pushes error-only (matches Claude's dispose contract);
238
- * everything else pushes error+finish.
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.
239
277
  */
240
278
  teardown(reason, error, code) {
241
279
  this.cancelAllTimers();
242
280
  for (const id of this.emittedApprovals)
243
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);
244
290
  // Reject every parked consumer-tool call so the bridge handler
245
291
  // unblocks with an error rather than waiting forever.
246
292
  for (const entry of this.pendingByToolCallId.values())
@@ -264,18 +310,9 @@ export class MastraApprovalCoordinator {
264
310
  const resolve = this.continuationResolver;
265
311
  this.continuationResolver = undefined;
266
312
  resolve?.('done');
267
- if (reason === 'dispose') {
268
- // Match Claude's dispose shape: error event only, no `finish`.
269
- // Disposal is a consumer-triggered teardown, not a normal turn
270
- // end, so a `finish` event would be misleading.
271
- this.initial.push({ type: 'error', error, code });
272
- this.initial.end();
273
- }
274
- else {
275
- this.initial.push({ type: 'error', error, code });
276
- this.initial.push({ type: 'finish', finishReason: 'error' });
277
- this.initial.end();
278
- }
313
+ this.initial.push({ type: 'error', error, code });
314
+ this.initial.push({ type: 'finish', finishReason: 'error' });
315
+ this.initial.end();
279
316
  this.pendingSuspendIds.clear();
280
317
  this.runTeardownCallbacks();
281
318
  }
@@ -287,14 +324,21 @@ export class MastraApprovalCoordinator {
287
324
  this.recordDecision('decline', toolCallId, { kind: 'decline' });
288
325
  }
289
326
  recordDecision(method, toolCallId, decision) {
290
- if (this.settledByCoordinator.has(toolCallId)) {
291
- throw new Error(`MastraApprovalCoordinator.${method}(): tool call "${toolCallId}" was already auto-resolved by the coordinator`);
292
- }
293
- if (this.consumerSettled.has(toolCallId)) {
294
- 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;
295
335
  }
296
336
  if (!this.emittedApprovals.has(toolCallId)) {
297
- 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);
298
342
  }
299
343
  this.consumerSettled.add(toolCallId);
300
344
  this.cancelTimer(toolCallId);
@@ -344,8 +388,18 @@ export class MastraApprovalCoordinator {
344
388
  * resolves (probe-validated, see #529 step 2c).
345
389
  */
346
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
+ }
347
400
  const entry = this.pendingByToolCallId.get(toolCallId);
348
401
  if (entry) {
402
+ this.consumerToolSettled.add(toolCallId);
349
403
  this.pendingByToolCallId.delete(toolCallId);
350
404
  entry.resolve({ result, isError });
351
405
  return;
@@ -355,10 +409,14 @@ export class MastraApprovalCoordinator {
355
409
  // We allow this whenever the coordinator has seen a `tool-call`
356
410
  // event under that id (reachable in `toolCallArgs`); else throw.
357
411
  if (this.toolCallArgs.has(toolCallId)) {
412
+ this.consumerToolSettled.add(toolCallId);
358
413
  this.preSubmittedByToolCallId.set(toolCallId, { result, isError });
359
414
  return;
360
415
  }
361
- 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);
362
420
  }
363
421
  // ───────────────────────── pump loop ────────────────────────────────────
364
422
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.19.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.20.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.20.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",