@salesforce/sfdx-agent-sdk 0.63.0 → 0.65.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,15 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-sdk` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.65.0] - 2026-09-02
7
+
8
+ _No changes — released alongside dependent packages._
9
+
10
+ ## [0.64.0] - 2026-09-02
11
+
12
+ ### Features
13
+ - **agent-sdk,harness-mastra,harness-claude,harness-openai**: reject concurrent turns with a typed SESSION_BUSY error @W-24044572@ ([#783](https://github.com/forcedotcom/agentic-dx/pull/783))
14
+
6
15
  ## [0.63.0] - 2026-09-02
7
16
 
8
17
  _No changes — released alongside dependent packages._
package/README.md CHANGED
@@ -774,21 +774,22 @@ totals, subscribe to `chat-stream-completed` telemetry instead.
774
774
  The SDK throws `AgentSDKError` for predictable not-found and compatibility conditions. Each error has a `type` property
775
775
  from `AgentSDKErrorType`:
776
776
 
777
- | Type | Thrown By |
778
- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
779
- | `AGENT_NOT_FOUND` | `AgentManager.getAgent()`, `AgentManager.destroyAgent()`, `AgentManager.recoverAgent()` (id neither live nor a restore-failure) |
780
- | `CHAT_SESSION_NOT_FOUND` | `Agent.getChatSession()`, `Agent.destroyChatSession()`, `Agent.cloneChatSession()`, `Agent.compactChatSession()` |
781
- | `COMPACTION_FAILED` | `Agent.compactChatSession()` when the harness's underlying summarization call rejects. The original error is attached as `cause`; the source session is left intact. |
782
- | `DISPOSED` | `Agent` and `ChatSession` methods called after the owner has been destroyed |
783
- | `INCOMPATIBLE_HARNESS` | `createAgentManager()` when the factory advertises an unsupported `protocolVersion`, or the constructed harness reports a `protocolVersion` that differs from the factory's |
784
- | `INVALID_MESSAGE_CONTENT` | `ChatSession.chat()` / harness `stream()` when a message part is not valid as input (a `tool-call`/`tool-result` part, or non-base64-string file data); also `ChatSession.setSessionContext()` / harness `setSessionContext()` when the object exceeds a harness's size / nesting bounds (Mastra: 256 KiB serialized, depth 200). `getSessionContext()` never throws on a corrupt stored slot — it soft-skips to `{}` and logs. |
785
- | `MCP_SERVER_DISABLED` | `Agent.reconnectMcpServer()` when the named server is configured with `enabled: false` |
786
- | `MCP_SERVER_NOT_FOUND` | `Agent.reconnectMcpServer()` when the server name is not in the agent's `mcpServers` config |
787
- | `MODEL_NOT_SUPPORTED_BY_HARNESS` | `AgentManager.createAgent()` / `Agent.updateAgentConfig()` (G8 pre-flight) when the resolved `ModelConnectivityInfo.providerHint` isn't in the harness's `supportedProviderHints`. Surfaces before any harness work runs (no MCP discovery, no subprocess spawn, no language-model construction) so the consumer can branch cleanly on `err.type` and recover without resource cleanup. |
788
- | `MULTIMODAL_NOT_SUPPORTED` | `ChatSession.chat()` / harness `stream()` when a file fails pre-stream capability validation (unsupported format, too large, or too many files) |
789
- | `NOT_SUPPORTED` | `ApiKeyConnectivityResolver.resolve()` when the consumer-supplied `getApiKey` returns an empty / nullish value. Surfaces locally so the consumer sees "the resolver returned an empty key" rather than chasing a 401 through provider logs after `Authorization: Bearer ` (no key) lands on the wire. |
790
- | `THREAD_NOT_FOUND` | `ChatSession.setSessionContext()` / harness `setSessionContext()` when seeding context on a thread that was never created (create-first contract). A `ChatSession` is only reachable via `createChatSession()` `createThread`, so this indicates a seed-before-create misuse; the harness maps its runtime "thread not found" to this typed error. |
791
- | `TOOL_CALL_NOT_FOUND` | `ChatSession.approveToolCall()` / `declineToolCall()` / `submitToolResult()` when the supplied `toolCallId` doesn't match any pending tool-call request on the current session. Typically indicates a wrong-id, wrong-session, or already-settled call. Both production harnesses (Mastra, Claude) throw this from their per-turn approval coordinator. |
777
+ | Type | Thrown By |
778
+ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
779
+ | `AGENT_NOT_FOUND` | `AgentManager.getAgent()`, `AgentManager.destroyAgent()`, `AgentManager.recoverAgent()` (id neither live nor a restore-failure) |
780
+ | `CHAT_SESSION_NOT_FOUND` | `Agent.getChatSession()`, `Agent.destroyChatSession()`, `Agent.cloneChatSession()`, `Agent.compactChatSession()` |
781
+ | `COMPACTION_FAILED` | `Agent.compactChatSession()` when the harness's underlying summarization call rejects. The original error is attached as `cause`; the source session is left intact. |
782
+ | `DISPOSED` | `Agent` and `ChatSession` methods called after the owner has been destroyed |
783
+ | `INCOMPATIBLE_HARNESS` | `createAgentManager()` when the factory advertises an unsupported `protocolVersion`, or the constructed harness reports a `protocolVersion` that differs from the factory's |
784
+ | `INVALID_MESSAGE_CONTENT` | `ChatSession.chat()` / harness `stream()` when a message part is not valid as input (a `tool-call`/`tool-result` part, or non-base64-string file data); also `ChatSession.setSessionContext()` / harness `setSessionContext()` when the object exceeds a harness's size / nesting bounds (Mastra: 256 KiB serialized, depth 200). `getSessionContext()` never throws on a corrupt stored slot — it soft-skips to `{}` and logs. |
785
+ | `MCP_SERVER_DISABLED` | `Agent.reconnectMcpServer()` when the named server is configured with `enabled: false` |
786
+ | `MCP_SERVER_NOT_FOUND` | `Agent.reconnectMcpServer()` when the server name is not in the agent's `mcpServers` config |
787
+ | `MODEL_NOT_SUPPORTED_BY_HARNESS` | `AgentManager.createAgent()` / `Agent.updateAgentConfig()` (G8 pre-flight) when the resolved `ModelConnectivityInfo.providerHint` isn't in the harness's `supportedProviderHints`. Surfaces before any harness work runs (no MCP discovery, no subprocess spawn, no language-model construction) so the consumer can branch cleanly on `err.type` and recover without resource cleanup. |
788
+ | `MULTIMODAL_NOT_SUPPORTED` | `ChatSession.chat()` / harness `stream()` when a file fails pre-stream capability validation (unsupported format, too large, or too many files) |
789
+ | `NOT_SUPPORTED` | `ApiKeyConnectivityResolver.resolve()` when the consumer-supplied `getApiKey` returns an empty / nullish value. Surfaces locally so the consumer sees "the resolver returned an empty key" rather than chasing a 401 through provider logs after `Authorization: Bearer ` (no key) lands on the wire. |
790
+ | `SESSION_BUSY` | `ChatSession.chat()` (and, at the harness layer, `stream()`) when a turn is already in flight on that session / `(agentId, threadId)`. A session runs **one turn at a time**. `submitToolResult()` / `approveToolCall()` / `declineToolCall()` are control messages on the in-flight turn — never rejected as busy. Two origins with different observable side effects: **SDK-detected** (the session's own in-flight flag is set) is thrown synchronously without notifying subscribers, same posture as `DISPOSED`; **harness-detected** (a `stream()` reaches the harness while its per-turn coordinator is still active) travels the pre-stream-failure path below — subscribers see `ErrorEvent` + `FinishEvent(error)` and a `chat-stream-error` telemetry event before the promise rejects. This replaces the previous silent-dispose-and-replace, so racing two turns against one `(agentId, threadId)` rejects rather than losing the first turn. Abandoning a turn's stream clears the SDK flag but does **not** settle the underlying harness turn, so an immediate re-`chat()` may still surface a harness-origin `SESSION_BUSY` until that turn settles — **abort** (via the turn's `abortSignal`) to release deterministically. |
791
+ | `THREAD_NOT_FOUND` | `ChatSession.setSessionContext()` / harness `setSessionContext()` when seeding context on a thread that was never created (create-first contract). A `ChatSession` is only reachable via `createChatSession()` → `createThread`, so this indicates a seed-before-create misuse; the harness maps its runtime "thread not found" to this typed error. |
792
+ | `TOOL_CALL_NOT_FOUND` | `ChatSession.approveToolCall()` / `declineToolCall()` / `submitToolResult()` when the supplied `toolCallId` doesn't match any pending tool-call request on the current session. Typically indicates a wrong-id, wrong-session, or already-settled call. Both production harnesses (Mastra, Claude) throw this from their per-turn approval coordinator. |
792
793
 
793
794
  ```typescript
794
795
  import { AgentSDKError, AgentSDKErrorType } from '@salesforce/sfdx-agent-sdk';
@@ -816,6 +817,14 @@ receive a terminal event for every turn:
816
817
  already emitted, appends a synthetic `FinishEvent(finishReason: 'error')` at the end. The originating `chat()` promise
817
818
  resolves normally; iterate the stream to observe the failure.
818
819
  - **Calling a disposed session** throws `AgentSDKError('DISPOSED')` synchronously without notifying subscribers.
820
+ - **Calling `chat()` while a turn is in flight** throws `AgentSDKError('SESSION_BUSY')`. One turn runs at a time per
821
+ session; settle calls (`submitToolResult()` / `approveToolCall()` / `declineToolCall()`) act on the in-flight turn and
822
+ are never rejected as busy. When the session detects the concurrent call itself it throws synchronously without
823
+ notifying subscribers (like `DISPOSED`); when the rejection originates in the harness (a `stream()` racing an active
824
+ coordinator) it follows the pre-stream-failure path above (subscribers see `ErrorEvent` + `FinishEvent`,
825
+ `chat-stream-error` fires). Abandoning a turn's `eventStream` clears the SDK-level busy state but leaves the
826
+ underlying harness turn running until it settles, so **abort** (rather than merely abandon) to admit the next `chat()`
827
+ deterministically.
819
828
 
820
829
  ### MCP Server Configuration
821
830
 
@@ -70,6 +70,24 @@ export type ChatSessionParentBuses = {
70
70
  * `FinishEvent(finishReason: 'error')` at the end. Subscribers see the same sequence.
71
71
  * - **Calling a disposed session** throws `AgentSDKError('DISPOSED')` synchronously without
72
72
  * notifying subscribers — this is a programmer error, not an operational one.
73
+ * - **Calling `chat()` while a turn is already in flight** throws `AgentSDKError('SESSION_BUSY')`. A
74
+ * session runs one turn at a time. `submitToolResult()` / `approveToolCall()` / `declineToolCall()`
75
+ * are control messages on the in-flight turn — NOT new turns — so they are never rejected as busy.
76
+ * `SESSION_BUSY` has **two origins with different observable side effects**:
77
+ * - **SDK-detected** (the session's own in-flight flag is still set): thrown synchronously by
78
+ * `chat()` before `chat-stream-started` or any subscriber notification — a programmer error, same
79
+ * posture as `DISPOSED`. The session's flag clears when the turn's `chat()`-returned `eventStream`
80
+ * reaches its terminal `finish`, **or** when the consumer abandons that stream (early `break` /
81
+ * `.return()`).
82
+ * - **Harness-detected** (a `stream()` reaches the harness while its per-turn coordinator for the
83
+ * `(agentId, threadId)` is still active): travels the normal **pre-stream-failure** path above —
84
+ * subscribers receive `ErrorEvent` + `FinishEvent(finishReason: 'error')` and a `chat-stream-error`
85
+ * telemetry event fires before the promise rejects. This is what a consumer sees if it *abandons*
86
+ * a turn's stream without aborting it and then immediately calls `chat()` again: the SDK flag has
87
+ * cleared, but the underlying harness turn keeps running until it settles, so the harness rejects
88
+ * the new turn until then. **To release a turn deterministically, abort it (via the turn's
89
+ * `abortSignal`) rather than merely abandoning its stream** — abort settles the coordinator, so the
90
+ * next `chat()` is admitted cleanly.
73
91
  */
74
92
  export interface ChatSession {
75
93
  /** Returns the unique session/thread identifier. */
@@ -322,6 +340,18 @@ export declare class DefaultChatSession implements ChatSession {
322
340
  */
323
341
  private latestUsage;
324
342
  private disposed;
343
+ /**
344
+ * True while a turn started by {@link chat} is in flight — from the `chat()`
345
+ * call until its returned `eventStream` reaches its terminal `finish` (or is
346
+ * abandoned). A second `chat()` while this is set throws
347
+ * `AgentSDKError('SESSION_BUSY')` synchronously (see {@link assertNotBusy}),
348
+ * giving the SDK its own "one turn at a time per session" enforcement rather
349
+ * than relying solely on a caller / service to serialize turns. Settle calls
350
+ * (`submitToolResult` / `approveToolCall` / `declineToolCall`) are control
351
+ * messages on the in-flight turn, not new turns, so they do NOT consult or
352
+ * mutate this flag. See the interface-level "Failure handling" notes.
353
+ */
354
+ private turnActive;
325
355
  /**
326
356
  * @param harness - The agent harness managing thread and message lifecycle.
327
357
  * @param agentId - ID of the agent this session belongs to.
@@ -564,4 +594,14 @@ export declare class DefaultChatSession implements ChatSession {
564
594
  */
565
595
  private notifySettleRejection;
566
596
  private assertNotDisposed;
597
+ /**
598
+ * Rejects a second `chat()` while a turn started by an earlier `chat()` is
599
+ * still in flight. This gives the SDK its own enforcement of the "one turn at
600
+ * a time per session" invariant, rather than relying solely on a caller (or
601
+ * the service's process-local status registry) to serialize turns — closing
602
+ * the "second chat() silently disposes the first" class of lost-turn bug at
603
+ * the harness layer. Like {@link assertNotDisposed}, it throws synchronously
604
+ * without notifying subscribers (a programmer error, not an operational one).
605
+ */
606
+ private assertNotBusy;
567
607
  }
@@ -73,6 +73,18 @@ export class DefaultChatSession {
73
73
  */
74
74
  latestUsage = {};
75
75
  disposed = false;
76
+ /**
77
+ * True while a turn started by {@link chat} is in flight — from the `chat()`
78
+ * call until its returned `eventStream` reaches its terminal `finish` (or is
79
+ * abandoned). A second `chat()` while this is set throws
80
+ * `AgentSDKError('SESSION_BUSY')` synchronously (see {@link assertNotBusy}),
81
+ * giving the SDK its own "one turn at a time per session" enforcement rather
82
+ * than relying solely on a caller / service to serialize turns. Settle calls
83
+ * (`submitToolResult` / `approveToolCall` / `declineToolCall`) are control
84
+ * messages on the in-flight turn, not new turns, so they do NOT consult or
85
+ * mutate this flag. See the interface-level "Failure handling" notes.
86
+ */
87
+ turnActive = false;
76
88
  /**
77
89
  * @param harness - The agent harness managing thread and message lifecycle.
78
90
  * @param agentId - ID of the agent this session belongs to.
@@ -112,6 +124,13 @@ export class DefaultChatSession {
112
124
  */
113
125
  async chat(message, options) {
114
126
  this.assertNotDisposed();
127
+ // Reject a concurrent turn before any observable side effect (no
128
+ // chat-stream-started, no subscriber notification) — SESSION_BUSY is a
129
+ // programmer error, same posture as DISPOSED. Set the in-flight flag
130
+ // synchronously (before the `await`) so a second `chat()` racing this one
131
+ // cannot slip past the guard.
132
+ this.assertNotBusy();
133
+ this.turnActive = true;
115
134
  const startedAt = this.emitChatStreamStarted('chat');
116
135
  try {
117
136
  const result = await this.harness.stream(this.agentId, this.threadId, message, options);
@@ -121,6 +140,9 @@ export class DefaultChatSession {
121
140
  };
122
141
  }
123
142
  catch (err) {
143
+ // Pre-stream failure ends the turn before a stream exists — clear the
144
+ // flag here since `wrapEventStream` (which normally clears it) never runs.
145
+ this.turnActive = false;
124
146
  this.notifyPreStreamError(err, startedAt);
125
147
  throw err;
126
148
  }
@@ -226,6 +248,23 @@ export class DefaultChatSession {
226
248
  yield errorEvent;
227
249
  lastError = error;
228
250
  }
251
+ finally {
252
+ // The SDK-level turn is no longer in flight: the stream drained to its
253
+ // terminal event, threw (caught above), or the consumer abandoned it via
254
+ // `.return()` / an early `break` (the generator suspends on the
255
+ // `yield event` inside this `try`, so `finally` runs on that path too).
256
+ // Clearing here — not only at the `finish` event — is what lets the SDK
257
+ // busy-guard (see `assertNotBusy`) admit the next `chat()` after ANY of
258
+ // those exits, so the SDK flag never wedges the session.
259
+ //
260
+ // NOTE: on an ABANDON-without-abort this clears the SDK flag while the
261
+ // underlying harness turn keeps running (the harness coordinator settles
262
+ // on its own terminal / a teardown, not on consumer abandonment), so the
263
+ // next `chat()` can still surface a harness-origin `SESSION_BUSY` until
264
+ // that turn settles. Aborting (not merely abandoning) releases both
265
+ // layers deterministically. See the interface-level "Failure handling".
266
+ this.turnActive = false;
267
+ }
229
268
  if (!sawFinish && lastError !== undefined) {
230
269
  const finishEvent = { type: 'finish', finishReason: 'error' };
231
270
  this.chatEventBus.emit(finishEvent);
@@ -721,5 +760,20 @@ export class DefaultChatSession {
721
760
  throw new AgentSDKError('ChatSession has been disposed.', AgentSDKErrorType.DISPOSED);
722
761
  }
723
762
  }
763
+ /**
764
+ * Rejects a second `chat()` while a turn started by an earlier `chat()` is
765
+ * still in flight. This gives the SDK its own enforcement of the "one turn at
766
+ * a time per session" invariant, rather than relying solely on a caller (or
767
+ * the service's process-local status registry) to serialize turns — closing
768
+ * the "second chat() silently disposes the first" class of lost-turn bug at
769
+ * the harness layer. Like {@link assertNotDisposed}, it throws synchronously
770
+ * without notifying subscribers (a programmer error, not an operational one).
771
+ */
772
+ assertNotBusy() {
773
+ if (this.turnActive) {
774
+ throw new AgentSDKError('A chat turn is already in progress for this session. Await (or abandon) the current ' +
775
+ "turn's event stream before starting another.", AgentSDKErrorType.SESSION_BUSY);
776
+ }
777
+ }
724
778
  }
725
779
  //# sourceMappingURL=chat-session.js.map
package/dist/errors.d.ts CHANGED
@@ -11,6 +11,7 @@ export declare const AgentSDKErrorType: {
11
11
  readonly MODEL_NOT_SUPPORTED_BY_HARNESS: 'MODEL_NOT_SUPPORTED_BY_HARNESS';
12
12
  readonly MULTIMODAL_NOT_SUPPORTED: 'MULTIMODAL_NOT_SUPPORTED';
13
13
  readonly NOT_SUPPORTED: 'NOT_SUPPORTED';
14
+ readonly SESSION_BUSY: 'SESSION_BUSY';
14
15
  readonly THREAD_NOT_FOUND: 'THREAD_NOT_FOUND';
15
16
  readonly TOOL_CALL_NOT_FOUND: 'TOOL_CALL_NOT_FOUND';
16
17
  };
package/dist/errors.js CHANGED
@@ -15,6 +15,7 @@ export const AgentSDKErrorType = {
15
15
  MODEL_NOT_SUPPORTED_BY_HARNESS: 'MODEL_NOT_SUPPORTED_BY_HARNESS',
16
16
  MULTIMODAL_NOT_SUPPORTED: 'MULTIMODAL_NOT_SUPPORTED',
17
17
  NOT_SUPPORTED: 'NOT_SUPPORTED',
18
+ SESSION_BUSY: 'SESSION_BUSY',
18
19
  THREAD_NOT_FOUND: 'THREAD_NOT_FOUND',
19
20
  TOOL_CALL_NOT_FOUND: 'TOOL_CALL_NOT_FOUND',
20
21
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-sdk",
3
- "version": "0.63.0",
3
+ "version": "0.65.0",
4
4
  "description": "Harness-agnostic agentic infrastructure for Salesforce developer experience tooling",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -47,9 +47,9 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@eslint/js": "^10.0.1",
50
- "@salesforce/sfdx-agent-harness-claude": "0.59.0",
51
- "@salesforce/sfdx-agent-harness-mastra": "0.62.0",
52
- "@salesforce/sfdx-agent-harness-openai": "0.28.0",
50
+ "@salesforce/sfdx-agent-harness-claude": "0.61.0",
51
+ "@salesforce/sfdx-agent-harness-mastra": "0.64.0",
52
+ "@salesforce/sfdx-agent-harness-openai": "0.30.0",
53
53
  "@types/node": "^22.20.1",
54
54
  "@vitest/coverage-istanbul": "^4.1.10",
55
55
  "@vitest/eslint-plugin": "^1.6.27",