@salesforce/sfdx-agent-sdk 0.15.0 → 0.17.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.
@@ -3,6 +3,7 @@ import { type AgentHarness, type ConfigOf } from './harness/agent-harness.js';
3
3
  import type { HarnessFactory } from './harness/harness-factory.js';
4
4
  import { type AgentConfig } from './harness/harness-config.js';
5
5
  import { type Agent } from './agent.js';
6
+ import type { HooksForAgent } from './types/redaction.js';
6
7
  import type { TelemetryEventCallback } from './types/telemetry-events.js';
7
8
  import { type AgentConnectivityResolver } from './agent-connectivity-resolver.js';
8
9
  /**
@@ -124,6 +125,7 @@ export declare class DefaultAgentManager<H extends AgentHarness = AgentHarness>
124
125
  private readonly harness;
125
126
  private readonly agentIdGenerator;
126
127
  private readonly agentConnectivityResolver;
128
+ private readonly hooksForAgent;
127
129
  private readonly clock;
128
130
  private readonly identityStore;
129
131
  private readonly agents;
@@ -144,7 +146,7 @@ export declare class DefaultAgentManager<H extends AgentHarness = AgentHarness>
144
146
  * is private, so this is the only way to obtain an instance, but
145
147
  * consumers should always go through {@link createAgentManager}.
146
148
  */
147
- static __build<H extends AgentHarness>(harness: H, agentConnectivityResolver: AgentConnectivityResolver, storageRootFolder: string, agentIdGenerator: UniqueIDGenerator, clock: Clock, logBus: LogBus): Promise<DefaultAgentManager<H>>;
149
+ static __build<H extends AgentHarness>(harness: H, agentConnectivityResolver: AgentConnectivityResolver, hooksForAgent: HooksForAgent | undefined, storageRootFolder: string, agentIdGenerator: UniqueIDGenerator, clock: Clock, logBus: LogBus): Promise<DefaultAgentManager<H>>;
148
150
  private init;
149
151
  shutdown(): Promise<void>;
150
152
  createAgent(projectRoot: string, config?: ConfigOf<H> & {
@@ -189,14 +191,25 @@ export declare class DefaultAgentManager<H extends AgentHarness = AgentHarness>
189
191
  * function returns; failures are queryable via
190
192
  * {@link AgentManager.getRestoreFailures}.
191
193
  *
192
- * The optional `connectivityResolver` overrides the default
193
- * `DefaultAgentConnectivityResolver` used by e2e tests and custom-auth
194
- * deployments where the SDK should not run sf-CLI-based org resolution.
195
- * Production callers leave it unset.
194
+ * The optional third-positional `options` bag carries the SDK's per-manager
195
+ * opt-ins. Production callers typically leave it unset:
196
+ *
197
+ * - `connectivityResolver` overrides the default
198
+ * `DefaultAgentConnectivityResolver`; used by e2e tests and custom-auth
199
+ * deployments where the SDK should not run sf-CLI-based org resolution.
200
+ * - `hooksForAgent` — sync callback resolving a per-agent
201
+ * {@link AgentHooks} bag (today carries `onToolResult`); invoked once per
202
+ * `createAgent`, boot-time restore, and `Agent.updateAgentConfig`. The
203
+ * resolved bag threads through to `AgentHarness.createAgent`'s
204
+ * `options.hooks` and reaches the harness's native seam (Claude
205
+ * `PostToolUse`, Mastra `processInputStep`).
196
206
  *
197
207
  * @throws {AgentSDKError} `INCOMPATIBLE_HARNESS` when either the factory or
198
208
  * the constructed harness reports a `protocolVersion` outside
199
209
  * {@link SUPPORTED_PROTOCOL_VERSIONS}, or when the harness's reported
200
210
  * version disagrees with the factory's.
201
211
  */
202
- export declare function createAgentManager<H extends AgentHarness = AgentHarness>(storageRootFolder: string, harnessFactory: HarnessFactory<H>, connectivityResolver?: AgentConnectivityResolver): Promise<AgentManager<H>>;
212
+ export declare function createAgentManager<H extends AgentHarness = AgentHarness>(storageRootFolder: string, harnessFactory: HarnessFactory<H>, options?: {
213
+ connectivityResolver?: AgentConnectivityResolver;
214
+ hooksForAgent?: HooksForAgent;
215
+ }): Promise<AgentManager<H>>;
@@ -25,6 +25,7 @@ export class DefaultAgentManager {
25
25
  harness;
26
26
  agentIdGenerator;
27
27
  agentConnectivityResolver;
28
+ hooksForAgent;
28
29
  clock;
29
30
  identityStore;
30
31
  agents = new Map();
@@ -34,9 +35,10 @@ export class DefaultAgentManager {
34
35
  router;
35
36
  unroutedUnsubs;
36
37
  disposed = false;
37
- constructor(harness, agentConnectivityResolver, identityStore, agentIdGenerator, clock, logBus) {
38
+ constructor(harness, agentConnectivityResolver, hooksForAgent, identityStore, agentIdGenerator, clock, logBus) {
38
39
  this.harness = harness;
39
40
  this.agentConnectivityResolver = agentConnectivityResolver;
41
+ this.hooksForAgent = hooksForAgent;
40
42
  this.identityStore = identityStore;
41
43
  this.agentIdGenerator = agentIdGenerator;
42
44
  this.clock = clock;
@@ -57,9 +59,9 @@ export class DefaultAgentManager {
57
59
  * is private, so this is the only way to obtain an instance, but
58
60
  * consumers should always go through {@link createAgentManager}.
59
61
  */
60
- static async __build(harness, agentConnectivityResolver, storageRootFolder, agentIdGenerator, clock, logBus) {
62
+ static async __build(harness, agentConnectivityResolver, hooksForAgent, storageRootFolder, agentIdGenerator, clock, logBus) {
61
63
  const identityStore = new AgentIdentityStore(storageRootFolder, harness.harnessId, logBus);
62
- const manager = new DefaultAgentManager(harness, agentConnectivityResolver, identityStore, agentIdGenerator, clock, logBus);
64
+ const manager = new DefaultAgentManager(harness, agentConnectivityResolver, hooksForAgent, identityStore, agentIdGenerator, clock, logBus);
63
65
  await manager.init();
64
66
  return manager;
65
67
  }
@@ -155,9 +157,10 @@ export class DefaultAgentManager {
155
157
  throw new Error(`projectRoot is not a directory: "${projectRoot}"`);
156
158
  }
157
159
  const runtime = await this.agentConnectivityResolver.resolve(projectRoot, config);
158
- await this.harness.createAgent(agentId, projectRoot, runtime.llmGatewayClient, toHarnessConfig(config, runtime.orgJwt), options.abortSignal !== undefined ? { abortSignal: options.abortSignal } : undefined);
160
+ const hooks = this.hooksForAgent?.(agentId, config) ?? {};
161
+ await this.harness.createAgent(agentId, projectRoot, runtime.llmGatewayClient, toHarnessConfig(config, runtime.orgJwt), { ...(options.abortSignal !== undefined ? { abortSignal: options.abortSignal } : {}), hooks });
159
162
  const agentSlice = this.router.registerAgent(agentId);
160
- const agent = new DefaultAgent(this.harness, agentId, projectRoot, config, runtime.llmGatewayClient, runtime.orgConnection, runtime.orgJwt, this.agentConnectivityResolver, this.identityStore, this.router, agentSlice, { telemetry: this.telemetryBus, log: this.logBus }, this.clock, this.agentIdGenerator);
163
+ const agent = new DefaultAgent(this.harness, agentId, projectRoot, config, runtime.llmGatewayClient, runtime.orgConnection, runtime.orgJwt, this.agentConnectivityResolver, this.hooksForAgent, this.identityStore, this.router, agentSlice, { telemetry: this.telemetryBus, log: this.logBus }, this.clock, this.agentIdGenerator);
161
164
  this.agents.set(agentId, agent);
162
165
  this.telemetryBus.emit({
163
166
  type: 'agent-created',
@@ -262,17 +265,25 @@ export class DefaultAgentManager {
262
265
  * function returns; failures are queryable via
263
266
  * {@link AgentManager.getRestoreFailures}.
264
267
  *
265
- * The optional `connectivityResolver` overrides the default
266
- * `DefaultAgentConnectivityResolver` used by e2e tests and custom-auth
267
- * deployments where the SDK should not run sf-CLI-based org resolution.
268
- * Production callers leave it unset.
268
+ * The optional third-positional `options` bag carries the SDK's per-manager
269
+ * opt-ins. Production callers typically leave it unset:
270
+ *
271
+ * - `connectivityResolver` overrides the default
272
+ * `DefaultAgentConnectivityResolver`; used by e2e tests and custom-auth
273
+ * deployments where the SDK should not run sf-CLI-based org resolution.
274
+ * - `hooksForAgent` — sync callback resolving a per-agent
275
+ * {@link AgentHooks} bag (today carries `onToolResult`); invoked once per
276
+ * `createAgent`, boot-time restore, and `Agent.updateAgentConfig`. The
277
+ * resolved bag threads through to `AgentHarness.createAgent`'s
278
+ * `options.hooks` and reaches the harness's native seam (Claude
279
+ * `PostToolUse`, Mastra `processInputStep`).
269
280
  *
270
281
  * @throws {AgentSDKError} `INCOMPATIBLE_HARNESS` when either the factory or
271
282
  * the constructed harness reports a `protocolVersion` outside
272
283
  * {@link SUPPORTED_PROTOCOL_VERSIONS}, or when the harness's reported
273
284
  * version disagrees with the factory's.
274
285
  */
275
- export async function createAgentManager(storageRootFolder, harnessFactory, connectivityResolver) {
286
+ export async function createAgentManager(storageRootFolder, harnessFactory, options) {
276
287
  let stats;
277
288
  try {
278
289
  stats = await stat(storageRootFolder);
@@ -306,8 +317,8 @@ export async function createAgentManager(storageRootFolder, harnessFactory, conn
306
317
  `advertised version ${factoryVersion} (SDK supports: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}). ` +
307
318
  `Update the SDK or harness package.`, AgentSDKErrorType.INCOMPATIBLE_HARNESS);
308
319
  }
309
- const agentConnectivityResolver = connectivityResolver ?? new DefaultAgentConnectivityResolver();
310
- return DefaultAgentManager.__build(harness, agentConnectivityResolver, storageRootFolder, new UUIDGenerator(), new RealClock(), new LogBus());
320
+ const agentConnectivityResolver = options?.connectivityResolver ?? new DefaultAgentConnectivityResolver();
321
+ return DefaultAgentManager.__build(harness, agentConnectivityResolver, options?.hooksForAgent, storageRootFolder, new UUIDGenerator(), new RealClock(), new LogBus());
311
322
  }
312
323
  function isSupportedProtocolVersion(version) {
313
324
  return (typeof version === 'number' &&
package/dist/agent.d.ts CHANGED
@@ -7,6 +7,7 @@ import { type JSONWebToken, type LLMGatewayClient } from '@salesforce/llm-gatewa
7
7
  import { type AgentConnectivityResolver } from './agent-connectivity-resolver.js';
8
8
  import type { AgentIdentityStore } from './internal/agent-identity-store.js';
9
9
  import type { TelemetryRouter, TelemetrySlice } from './internal/telemetry-router.js';
10
+ import type { HooksForAgent } from './types/redaction.js';
10
11
  import type { TelemetryBus, TelemetryEventCallback } from './types/telemetry-events.js';
11
12
  /**
12
13
  * Parent bus pair wired at construction time so an agent's events bubble upward into the manager's buses.
@@ -44,8 +45,13 @@ export interface Agent {
44
45
  /**
45
46
  * Request a reconnect of one MCP server on this agent without recycling
46
47
  * any other server, custom tool, instruction, or skill. Useful for
47
- * recovering a single failed MCP server without paying the full
48
- * `updateAgentConfig` destroy/recreate cost.
48
+ * recovering a single failed MCP server after a transport-level error
49
+ * (e.g. JWT-rotation timing on stdio servers, transient EOF on remote
50
+ * transports). For the diff-driven case — `Agent.updateAgentConfig`
51
+ * applying a new `MCPConfiguration` — the harness already preserves any
52
+ * server whose config is structurally unchanged and cycles only the
53
+ * changed/added/removed servers; an explicit `reconnectMcpServer` call
54
+ * is **not** required there.
49
55
  *
50
56
  * Throws if `serverName` is not configured on this agent or if the named
51
57
  * server is disabled (`enabled: false`).
@@ -132,6 +138,7 @@ export declare class DefaultAgent implements Agent {
132
138
  private orgConnection;
133
139
  private orgJwt;
134
140
  private readonly agentConnectivityResolver;
141
+ private readonly hooksForAgent;
135
142
  private readonly identityStore;
136
143
  private readonly sessions;
137
144
  private readonly sessionSliceUnregisters;
@@ -152,13 +159,17 @@ export declare class DefaultAgent implements Agent {
152
159
  * @param orgConnection - Authenticated org connection carrying identity and env inference.
153
160
  * @param orgJwt - Self-refreshing JWT for the resolved org (used for MCP auth injection).
154
161
  * @param agentConnectivityResolver - Used to re-resolve org connectivity when the org or model changes.
162
+ * @param hooksForAgent - Per-agent hooks resolver supplied by the SDK consumer at `createAgentManager` time. The
163
+ * agent re-invokes it on every `updateAgentConfig` (with `nextConfig`, and again with `previousConfig` on the
164
+ * rollback path) so the bag the harness sees always reflects the current persisted config. `undefined` when
165
+ * the consumer didn't pass a `hooksForAgent`.
155
166
  * @param identityStore - SDK-owned persistence for the `{ agentId, projectRoot, AgentConfig }` triple. The agent
156
167
  * calls `write()` on a successful `updateAgentConfig` so disk state and in-memory state stay in lockstep.
157
168
  * @param router - Telemetry router used to obtain session slices when sessions are created.
158
169
  * @param inbound - Router slice delivering harness events routed to this agent (non-session-scoped).
159
170
  * @param parent - Manager's bus pair; this agent forwards its events upward into them.
160
171
  */
161
- constructor(harness: AgentHarness, agentId: string, projectRoot: string, config: AgentConfig, llmGatewayClient: LLMGatewayClient, orgConnection: OrgConnection, orgJwt: JSONWebToken, agentConnectivityResolver: AgentConnectivityResolver, identityStore: AgentIdentityStore, router: TelemetryRouter, inbound: TelemetrySlice, parent: AgentParentBuses, clock?: Clock, idGenerator?: UniqueIDGenerator);
172
+ constructor(harness: AgentHarness, agentId: string, projectRoot: string, config: AgentConfig, llmGatewayClient: LLMGatewayClient, orgConnection: OrgConnection, orgJwt: JSONWebToken, agentConnectivityResolver: AgentConnectivityResolver, hooksForAgent: HooksForAgent | undefined, identityStore: AgentIdentityStore, router: TelemetryRouter, inbound: TelemetrySlice, parent: AgentParentBuses, clock?: Clock, idGenerator?: UniqueIDGenerator);
162
173
  /**
163
174
  * @requirements
164
175
  * - MUST return the agent's ID.
@@ -178,11 +189,17 @@ export declare class DefaultAgent implements Agent {
178
189
  * @requirements
179
190
  * - MUST merge the provided `config` with the internal `config` object.
180
191
  * - MUST guarantee that the `agentId` remains unchanged during the merge.
181
- * - MUST destroy the existing agent in the harness by delegating to `this.harness.destroyAgent(this.getId())`.
182
- * - MUST recreate the agent in the harness with the newly merged configuration by delegating to `this.harness.createAgent(...)`.
183
- * - MUST persist the merged config via `this.identityStore.write(...)` after the harness recreate succeeds and
184
- * before the in-memory swaps, so a write failure rolls back through the same catch path as a recreate failure.
185
- * - MUST preserve the previous in-memory config state if recreation or persistence fails.
192
+ * - MUST apply the merged config to the harness via `this.harness.updateAgent(...)` — a single primitive
193
+ * that preserves any MCP client whose `MCPServerConfig` is structurally equal to the currently-applied one.
194
+ * The destroy+recreate shape this method used pre-#541 closed every MCP client on a model-only or
195
+ * instructions-only or org-connect-only change; the new shape preserves them and only cycles servers
196
+ * that actually changed.
197
+ * - MUST persist the merged config via `this.identityStore.write(...)` after `harness.updateAgent` succeeds
198
+ * and before the in-memory swaps, so a write failure rolls back through the same catch path as an
199
+ * `updateAgent` failure.
200
+ * - MUST preserve the previous in-memory config state if `updateAgent` or persistence fails. Rollback
201
+ * uses the same `harness.updateAgent` primitive against the previous config — the harness re-diffs
202
+ * against its current (possibly partially-updated) state and reverts only the actual deltas.
186
203
  */
187
204
  updateAgentConfig(config?: AgentConfig, options?: {
188
205
  abortSignal?: AbortSignal;
package/dist/agent.js CHANGED
@@ -21,6 +21,7 @@ export class DefaultAgent {
21
21
  orgConnection;
22
22
  orgJwt;
23
23
  agentConnectivityResolver;
24
+ hooksForAgent;
24
25
  identityStore;
25
26
  sessions = new Map();
26
27
  sessionSliceUnregisters = new Map();
@@ -41,13 +42,17 @@ export class DefaultAgent {
41
42
  * @param orgConnection - Authenticated org connection carrying identity and env inference.
42
43
  * @param orgJwt - Self-refreshing JWT for the resolved org (used for MCP auth injection).
43
44
  * @param agentConnectivityResolver - Used to re-resolve org connectivity when the org or model changes.
45
+ * @param hooksForAgent - Per-agent hooks resolver supplied by the SDK consumer at `createAgentManager` time. The
46
+ * agent re-invokes it on every `updateAgentConfig` (with `nextConfig`, and again with `previousConfig` on the
47
+ * rollback path) so the bag the harness sees always reflects the current persisted config. `undefined` when
48
+ * the consumer didn't pass a `hooksForAgent`.
44
49
  * @param identityStore - SDK-owned persistence for the `{ agentId, projectRoot, AgentConfig }` triple. The agent
45
50
  * calls `write()` on a successful `updateAgentConfig` so disk state and in-memory state stay in lockstep.
46
51
  * @param router - Telemetry router used to obtain session slices when sessions are created.
47
52
  * @param inbound - Router slice delivering harness events routed to this agent (non-session-scoped).
48
53
  * @param parent - Manager's bus pair; this agent forwards its events upward into them.
49
54
  */
50
- constructor(harness, agentId, projectRoot, config, llmGatewayClient, orgConnection, orgJwt, agentConnectivityResolver, identityStore, router, inbound, parent, clock = new RealClock(), idGenerator = new UUIDGenerator()) {
55
+ constructor(harness, agentId, projectRoot, config, llmGatewayClient, orgConnection, orgJwt, agentConnectivityResolver, hooksForAgent, identityStore, router, inbound, parent, clock = new RealClock(), idGenerator = new UUIDGenerator()) {
51
56
  this.harness = harness;
52
57
  this.agentId = agentId;
53
58
  this.projectRoot = projectRoot;
@@ -56,6 +61,7 @@ export class DefaultAgent {
56
61
  this.orgConnection = orgConnection;
57
62
  this.orgJwt = orgJwt;
58
63
  this.agentConnectivityResolver = agentConnectivityResolver;
64
+ this.hooksForAgent = hooksForAgent;
59
65
  this.identityStore = identityStore;
60
66
  this.router = router;
61
67
  this.clock = clock;
@@ -100,11 +106,17 @@ export class DefaultAgent {
100
106
  * @requirements
101
107
  * - MUST merge the provided `config` with the internal `config` object.
102
108
  * - MUST guarantee that the `agentId` remains unchanged during the merge.
103
- * - MUST destroy the existing agent in the harness by delegating to `this.harness.destroyAgent(this.getId())`.
104
- * - MUST recreate the agent in the harness with the newly merged configuration by delegating to `this.harness.createAgent(...)`.
105
- * - MUST persist the merged config via `this.identityStore.write(...)` after the harness recreate succeeds and
106
- * before the in-memory swaps, so a write failure rolls back through the same catch path as a recreate failure.
107
- * - MUST preserve the previous in-memory config state if recreation or persistence fails.
109
+ * - MUST apply the merged config to the harness via `this.harness.updateAgent(...)` — a single primitive
110
+ * that preserves any MCP client whose `MCPServerConfig` is structurally equal to the currently-applied one.
111
+ * The destroy+recreate shape this method used pre-#541 closed every MCP client on a model-only or
112
+ * instructions-only or org-connect-only change; the new shape preserves them and only cycles servers
113
+ * that actually changed.
114
+ * - MUST persist the merged config via `this.identityStore.write(...)` after `harness.updateAgent` succeeds
115
+ * and before the in-memory swaps, so a write failure rolls back through the same catch path as an
116
+ * `updateAgent` failure.
117
+ * - MUST preserve the previous in-memory config state if `updateAgent` or persistence fails. Rollback
118
+ * uses the same `harness.updateAgent` primitive against the previous config — the harness re-diffs
119
+ * against its current (possibly partially-updated) state and reverts only the actual deltas.
108
120
  */
109
121
  async updateAgentConfig(config = {}, options) {
110
122
  this.assertNotDisposed();
@@ -129,13 +141,14 @@ export class DefaultAgent {
129
141
  // (If modelId is omitted, the resolver pinned the default at creation time.)
130
142
  nextClient.setModel(nextModel);
131
143
  }
132
- await this.harness.destroyAgent(this.agentId);
133
- let nextConfigRegistered = false;
134
144
  try {
135
- await this.harness.createAgent(this.agentId, this.projectRoot, nextClient, toHarnessConfig(nextConfig, nextOrgJwt), options);
136
- nextConfigRegistered = true;
145
+ const nextHooks = this.hooksForAgent?.(this.agentId, nextConfig) ?? {};
146
+ await this.harness.updateAgent(this.agentId, nextClient, toHarnessConfig(nextConfig, nextOrgJwt), {
147
+ ...(options?.abortSignal !== undefined ? { abortSignal: options.abortSignal } : {}),
148
+ hooks: nextHooks,
149
+ });
137
150
  // Persist before the in-memory swaps so a write failure flows through the same
138
- // catch block as a recreate failure: the rollback restores the harness with
151
+ // catch block as an updateAgent failure: the rollback re-runs updateAgent against
139
152
  // previousConfig and disk state remains the pre-update record.
140
153
  await this.identityStore.write(this.agentId, this.projectRoot, nextConfig);
141
154
  this.config = nextConfig;
@@ -158,15 +171,11 @@ export class DefaultAgent {
158
171
  if (nextClient === previousClient) {
159
172
  previousClient.setModel(previousModel);
160
173
  }
161
- // Clear nextConfig registration only when the harness recreate
162
- // actually succeeded (identityStore.write-failure path) — the
163
- // harness throws on unknown id, so calling destroyAgent on the
164
- // harness-recreate-failure path would short-circuit the rollback
165
- // createAgent below.
166
- if (nextConfigRegistered) {
167
- await this.harness.destroyAgent(this.agentId);
168
- }
169
- await this.harness.createAgent(this.agentId, this.projectRoot, previousClient, toHarnessConfig(previousConfig, previousOrgJwt));
174
+ // Re-apply the previous config through the same primitive. The harness re-diffs
175
+ // against its current state — if updateAgent partially applied (e.g. some MCP
176
+ // servers were already cycled), reverting via updateAgent restores them too.
177
+ const previousHooks = this.hooksForAgent?.(this.agentId, previousConfig) ?? {};
178
+ await this.harness.updateAgent(this.agentId, previousClient, toHarnessConfig(previousConfig, previousOrgJwt), { hooks: previousHooks });
170
179
  }
171
180
  catch {
172
181
  // Ignore restoration errors; rethrow the original failure.
@@ -62,11 +62,17 @@ export interface ChatSession {
62
62
  *
63
63
  * "Client-side tool" means a tool you declared in {@link AgentConfig.tools}
64
64
  * without an `execute` function — the SDK registers its name + schema with the
65
- * model but does not run it. When the model calls one, the chat eventStream
66
- * emits a `tool-call` event and ends with `finishReason: 'tool-calls'`. Your
67
- * application runs the tool however it likes (HTTP call, DB query, UI prompt,
68
- * etc.) and calls this method with the result; the agent loop resumes and
69
- * produces its next turn on the returned `ChatStreamResult.eventStream`.
65
+ * model but does not run it. When the model calls one, the chat `eventStream`
66
+ * emits a `tool-call` event. Your application runs the tool however it likes
67
+ * (HTTP call, DB query, UI prompt, etc.) and calls this method with the result;
68
+ * the agent loop resumes and the post-resume events (`tool-result`, model
69
+ * follow-up text, terminal `finish`) arrive on the **same** `eventStream`
70
+ * the original {@link chat} call returned. The consumer keeps iterating it.
71
+ *
72
+ * Returns `Promise<void>` once the harness has accepted the result. The
73
+ * promise rejects on pre-stream failure (the `chat()`-returned subscribers
74
+ * still observe `ErrorEvent` + `FinishEvent` before the rejection so the
75
+ * subscribe-side contract holds).
70
76
  *
71
77
  * Use this method ONLY for client-side tools. Tools provided via
72
78
  * {@link AgentConfig.mcpServers} are executed by the harness — their results
@@ -74,23 +80,22 @@ export interface ChatSession {
74
80
  * Human-in-the-loop approval of harness-executed tools uses
75
81
  * {@link approveToolCall} / {@link declineToolCall}, not this method.
76
82
  *
77
- * On pre-stream failure, subscribers are notified with `ErrorEvent` + `FinishEvent` before
78
- * the returned promise rejects. See the interface-level "Failure handling" notes for details.
79
- *
80
83
  * @param toolResult - The completed tool execution result. `toolCallId` and
81
84
  * `toolName` MUST match the values from the originating `tool-call` event.
82
85
  */
83
- submitToolResult(toolResult: ToolResultInfo): Promise<ChatStreamResult>;
86
+ submitToolResult(toolResult: ToolResultInfo): Promise<void>;
84
87
  /**
85
88
  * Approve a pending tool call, allowing the harness to execute it.
86
89
  * Called after receiving a `tool-approval-request` event from the stream.
87
90
  *
88
- * Returns a `ChatStreamResult` containing the continuation stream the harness
89
- * executes the approved tool, generates the model's follow-up response, and
90
- * streams both the text and events back to the caller.
91
+ * Returns `Promise<void>` once the harness has accepted the approval. The
92
+ * harness then executes the tool and emits the resulting events
93
+ * (`tool-result`, model follow-up text, terminal `finish`) on the **same**
94
+ * `eventStream` the original {@link chat} call returned. The consumer keeps
95
+ * iterating it.
91
96
  *
92
- * On pre-stream failure, subscribers are notified with `ErrorEvent` + `FinishEvent` before
93
- * the returned promise rejects. See the interface-level "Failure handling" notes for details.
97
+ * The promise rejects on pre-stream failure; subscribers still observe
98
+ * `ErrorEvent` + `FinishEvent` on the chat stream before the rejection.
94
99
  *
95
100
  * @param toolCallId - ID of the pending tool call to approve.
96
101
  * @param options - Optional approval metadata.
@@ -101,21 +106,20 @@ export interface ChatSession {
101
106
  */
102
107
  approveToolCall(toolCallId: string, options?: {
103
108
  remember?: boolean;
104
- }): Promise<ChatStreamResult>;
109
+ }): Promise<void>;
105
110
  /**
106
111
  * Decline a pending tool call. The stream resumes with the model
107
- * acknowledging the decline and potentially suggesting alternatives.
112
+ * acknowledging the decline and potentially suggesting alternatives
113
+ * those events arrive on the **same** `eventStream` the original
114
+ * {@link chat} call returned.
108
115
  *
109
- * Returns a `ChatStreamResult` containing the continuation stream the harness
110
- * cancels the pending tool call, generates the model's acknowledgement response,
111
- * and streams both the text and events back to the caller.
112
- *
113
- * On pre-stream failure, subscribers are notified with `ErrorEvent` + `FinishEvent` before
114
- * the returned promise rejects. See the interface-level "Failure handling" notes for details.
116
+ * Returns `Promise<void>` once the harness has accepted the decline. The
117
+ * promise rejects on pre-stream failure; subscribers still observe
118
+ * `ErrorEvent` + `FinishEvent` on the chat stream before the rejection.
115
119
  *
116
120
  * @param toolCallId - ID of the pending tool call to decline.
117
121
  */
118
- declineToolCall(toolCallId: string): Promise<ChatStreamResult>;
122
+ declineToolCall(toolCallId: string): Promise<void>;
119
123
  /**
120
124
  * Retrieve message history for this session.
121
125
  *
@@ -246,7 +250,7 @@ export declare class DefaultChatSession implements ChatSession {
246
250
  * - MUST notify listeners with `ErrorEvent` + `FinishEvent` and re-throw if the harness throws
247
251
  * before returning a stream result.
248
252
  */
249
- submitToolResult(toolResult: ToolResultInfo): Promise<ChatStreamResult>;
253
+ submitToolResult(toolResult: ToolResultInfo): Promise<void>;
250
254
  /**
251
255
  * @requirements
252
256
  * - MUST yield each event from the provided `stream`.
@@ -290,7 +294,7 @@ export declare class DefaultChatSession implements ChatSession {
290
294
  */
291
295
  approveToolCall(toolCallId: string, _options?: {
292
296
  remember?: boolean;
293
- }): Promise<ChatStreamResult>;
297
+ }): Promise<void>;
294
298
  /**
295
299
  * @requirements
296
300
  * - MUST delegate to `this.harness.declineToolCall()`, passing `this.agentId`, `this.threadId`, and `toolCallId`.
@@ -302,7 +306,7 @@ export declare class DefaultChatSession implements ChatSession {
302
306
  * - MUST notify listeners with `ErrorEvent` + `FinishEvent` and re-throw if the harness throws
303
307
  * before returning a stream result.
304
308
  */
305
- declineToolCall(toolCallId: string): Promise<ChatStreamResult>;
309
+ declineToolCall(toolCallId: string): Promise<void>;
306
310
  /**
307
311
  * @requirements
308
312
  * - MUST delegate to `this.harness.getMessages()`, passing `this.agentId` and `this.threadId`.
@@ -395,5 +399,18 @@ export declare class DefaultChatSession implements ChatSession {
395
399
  * measures real elapsed time even for pre-stream rejections.
396
400
  */
397
401
  private notifyPreStreamError;
402
+ /**
403
+ * issue #529 contract change: a settle call (`approveToolCall` /
404
+ * `declineToolCall` / `submitToolResult`) rejected. The settle's
405
+ * Promise is the consumer's primary failure surface, but subscribers
406
+ * registered via {@link ChatSession.subscribe} also expect to observe
407
+ * `error + finish` events so a UI bound to the chat stream can
408
+ * render the failure. Emit those without firing
409
+ * `chat-stream-error` telemetry — chat-stream-* telemetry is owned
410
+ * by the chat() lifecycle, not by settle calls (issue #529: one
411
+ * chat-stream-started/completed/error pair per turn, not per
412
+ * settle).
413
+ */
414
+ private notifySettleRejection;
398
415
  private assertNotDisposed;
399
416
  }
@@ -109,16 +109,16 @@ export class DefaultChatSession {
109
109
  */
110
110
  async submitToolResult(toolResult) {
111
111
  this.assertNotDisposed();
112
- const startedAt = this.emitChatStreamStarted('submit-tool-result');
112
+ // issue #529 contract change: settle calls are control messages on the
113
+ // existing chat() turn's stream — they don't open a new stream and
114
+ // they don't emit chat-stream-started/completed. The post-resume
115
+ // events flow through the harness's existing turn sink, which the
116
+ // consumer's chat()-returned eventStream is already iterating.
113
117
  try {
114
- const result = await this.harness.submitToolResult(this.agentId, this.threadId, toolResult);
115
- return {
116
- textStream: result.textStream,
117
- eventStream: this.wrapEventStream(result.eventStream, startedAt),
118
- };
118
+ await this.harness.submitToolResult(this.agentId, this.threadId, toolResult);
119
119
  }
120
120
  catch (err) {
121
- this.notifyPreStreamError(err, startedAt);
121
+ this.notifySettleRejection(err);
122
122
  throw err;
123
123
  }
124
124
  }
@@ -246,19 +246,17 @@ export class DefaultChatSession {
246
246
  */
247
247
  async approveToolCall(toolCallId, _options) {
248
248
  this.assertNotDisposed();
249
- const startedAt = this.emitChatStreamStarted('approve-tool-call');
249
+ // issue #529 contract change: see `submitToolResult` for the rationale.
250
+ // Settle is a control message on the existing turn; events flow on
251
+ // the chat()-returned stream.
250
252
  try {
251
- const result = await this.harness.approveToolCall(this.agentId, this.threadId, toolCallId);
252
- this.emitToolApprovalResolved(toolCallId, true);
253
- return {
254
- textStream: result.textStream,
255
- eventStream: this.wrapEventStream(result.eventStream, startedAt),
256
- };
253
+ await this.harness.approveToolCall(this.agentId, this.threadId, toolCallId);
257
254
  }
258
255
  catch (err) {
259
- this.notifyPreStreamError(err, startedAt);
256
+ this.notifySettleRejection(err);
260
257
  throw err;
261
258
  }
259
+ this.emitToolApprovalResolved(toolCallId, true);
262
260
  }
263
261
  /**
264
262
  * @requirements
@@ -273,19 +271,15 @@ export class DefaultChatSession {
273
271
  */
274
272
  async declineToolCall(toolCallId) {
275
273
  this.assertNotDisposed();
276
- const startedAt = this.emitChatStreamStarted('decline-tool-call');
274
+ // issue #529 contract change: see `submitToolResult` for the rationale.
277
275
  try {
278
- const result = await this.harness.declineToolCall(this.agentId, this.threadId, toolCallId);
279
- this.emitToolApprovalResolved(toolCallId, false);
280
- return {
281
- textStream: result.textStream,
282
- eventStream: this.wrapEventStream(result.eventStream, startedAt),
283
- };
276
+ await this.harness.declineToolCall(this.agentId, this.threadId, toolCallId);
284
277
  }
285
278
  catch (err) {
286
- this.notifyPreStreamError(err, startedAt);
279
+ this.notifySettleRejection(err);
287
280
  throw err;
288
281
  }
282
+ this.emitToolApprovalResolved(toolCallId, false);
289
283
  }
290
284
  /**
291
285
  * @requirements
@@ -527,6 +521,23 @@ export class DefaultChatSession {
527
521
  error,
528
522
  });
529
523
  }
524
+ /**
525
+ * issue #529 contract change: a settle call (`approveToolCall` /
526
+ * `declineToolCall` / `submitToolResult`) rejected. The settle's
527
+ * Promise is the consumer's primary failure surface, but subscribers
528
+ * registered via {@link ChatSession.subscribe} also expect to observe
529
+ * `error + finish` events so a UI bound to the chat stream can
530
+ * render the failure. Emit those without firing
531
+ * `chat-stream-error` telemetry — chat-stream-* telemetry is owned
532
+ * by the chat() lifecycle, not by settle calls (issue #529: one
533
+ * chat-stream-started/completed/error pair per turn, not per
534
+ * settle).
535
+ */
536
+ notifySettleRejection(err) {
537
+ const error = err instanceof Error ? err : new Error(String(err));
538
+ this.chatEventBus.emit({ type: 'error', error });
539
+ this.chatEventBus.emit({ type: 'finish', finishReason: 'error' });
540
+ }
530
541
  assertNotDisposed() {
531
542
  if (this.disposed) {
532
543
  throw new AgentSDKError('ChatSession has been disposed.', AgentSDKErrorType.DISPOSED);