@salesforce/sfdx-agent-sdk 0.30.0 → 0.32.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,22 @@
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.32.0] - 2026-07-08
7
+
8
+ ### Fixes
9
+ - **agent-sdk,harness-mastra,harness-claude**: canonical role:tool placement for completed tool calls @W-23351939 ([#649](https://github.com/forcedotcom/agentic-dx/pull/649))
10
+
11
+ ## [0.31.0] - 2026-07-07
12
+
13
+ ### Features
14
+ - **agent-sdk**: registry-driven model-connectivity smoke @W-23292476@ ([#641](https://github.com/forcedotcom/agentic-dx/pull/641))
15
+
16
+ ### Fixes
17
+ - **harness-mastra**: getMessages() round-trips the tool-call part @W-23351939 ([#648](https://github.com/forcedotcom/agentic-dx/pull/648))
18
+
19
+ ### Chores
20
+ - **deps-dev**: bump the dev-dependencies group with 3 updates ([#642](https://github.com/forcedotcom/agentic-dx/pull/642))
21
+
6
22
  ## [0.30.0] - 2026-07-03
7
23
 
8
24
  _No changes — released alongside dependent packages._
package/README.md CHANGED
@@ -191,20 +191,20 @@ iterating the same `eventStream` until it sees a terminal `finish` event.
191
191
 
192
192
  Discriminated union (`event.type`) of streaming events:
193
193
 
194
- | Type | Key Fields | Description |
195
- | ----------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
196
- | `start` | — | Stream has begun. |
197
- | `text-delta` | `text` | Incremental response text. |
198
- | `reasoning-delta` | `text` | Chain-of-thought fragment. |
199
- | `tool-call` | `toolCallId`, `toolName`, `args`, `annotations?`, `serverName?`, `bareToolName?` | Tool invocation. `annotations` is the MCP-spec hints (`readOnlyHint`, `destructiveHint`, …) when the source declared them; `serverName` is set when the tool came from an MCP server. `toolName` is the harness-namespaced display name; `bareToolName` is the un-namespaced leaf paired with `serverName` (set for MCP tools only) — use the `(serverName, bareToolName)` pair, never string-split `toolName`, for cross-harness identity. |
200
- | `tool-call-delta` | `toolCallId`, `toolName?`, `argsTextDelta` | Incremental fragment of a tool call's args JSON, emitted while the model composes the call. Concatenate successive deltas for the same `toolCallId` to build the args text; the parsed result matches the terminal `tool-call.args`. Useful for live-typing tool inputs UI; consumers that don't need streaming-args can ignore this event and continue reading the parsed `args` on the terminal `tool-call`. `toolName` is optional (Claude's signal does not carry it on the wire). |
201
- | `tool-approval-request` | `toolCall: ToolCallInfo`, `annotations?`, `serverName?`, `bareToolName?` | Engine requests approval before executing a tool. Same `annotations` / `serverName` / `bareToolName` semantics as `tool-call`. `bareToolName` is the field the SDK reads to build the `remember` policy matcher (see [Tool Approval](#tool-approval)). |
194
+ | Type | Key Fields | Description |
195
+ | ----------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
196
+ | `start` | — | Stream has begun. |
197
+ | `text-delta` | `text` | Incremental response text. |
198
+ | `reasoning-delta` | `text` | Chain-of-thought fragment. |
199
+ | `tool-call` | `toolCallId`, `toolName`, `args`, `annotations?`, `serverName?`, `bareToolName?` | Tool invocation. `annotations` is the MCP-spec hints (`readOnlyHint`, `destructiveHint`, …) when the source declared them; `serverName` is set when the tool came from an MCP server. `toolName` is the harness-namespaced display name; `bareToolName` is the un-namespaced leaf paired with `serverName` (set for MCP tools only) — use the `(serverName, bareToolName)` pair, never string-split `toolName`, for cross-harness identity. |
200
+ | `tool-call-delta` | `toolCallId`, `toolName?`, `argsTextDelta` | Incremental fragment of a tool call's args JSON, emitted while the model composes the call. Concatenate successive deltas for the same `toolCallId` to build the args text; the parsed result matches the terminal `tool-call.args`. Useful for live-typing tool inputs UI; consumers that don't need streaming-args can ignore this event and continue reading the parsed `args` on the terminal `tool-call`. `toolName` is optional (Claude's signal does not carry it on the wire). |
201
+ | `tool-approval-request` | `toolCall: ToolCallInfo`, `annotations?`, `serverName?`, `bareToolName?` | Engine requests approval before executing a tool. Same `annotations` / `serverName` / `bareToolName` semantics as `tool-call`. `bareToolName` is the field the SDK reads to build the `remember` policy matcher (see [Tool Approval](#tool-approval)). |
202
202
  | `tool-result` | `toolCallId`, `toolName`, `result`, `isError?`, `error?`, `annotations?`, `serverName?`, `bareToolName?` | Tool execution completed. `error` is present when `isError` is true (best-effort: harnesses may synthesize an `Error` from a string payload, so `error.stack` is not guaranteed to point at the tool's throw site; the field may be absent on empty error payloads). Same `annotations` / `serverName` / `bareToolName` semantics as `tool-call`. |
203
- | `tool-progress` | `toolCallId`, `toolName`, `output?`, `parentToolCallId?` | Incremental progress signal from a long-running tool call. Distinct from `tool-result`: zero or more `tool-progress` events may be emitted before exactly one terminal `tool-result`. `output` and `parentToolCallId` are best-effort enrichment that depends on the tool — the event itself is the load-bearing "tool is still working" signal; consumers SHOULD NOT branch on which optional fields are present. Useful for "tool is working" UI on long-running tools (build, test, deploy, large search, sub-agent tasks). |
204
- | `step-start` | `stepIndex` | New LLM invocation step began. |
205
- | `step-finish` | `stepIndex`, `finishReason`, `usage?` | Step completed with per-step token usage. |
206
- | `error` | `error`, `code?` | Mid-stream error (yielded, not thrown). |
207
- | `finish` | `finishReason`, `usage?` | Stream completed with aggregate token usage. |
203
+ | `tool-progress` | `toolCallId`, `toolName`, `output?`, `parentToolCallId?` | Incremental progress signal from a long-running tool call. Distinct from `tool-result`: zero or more `tool-progress` events may be emitted before exactly one terminal `tool-result`. `output` and `parentToolCallId` are best-effort enrichment that depends on the tool — the event itself is the load-bearing "tool is still working" signal; consumers SHOULD NOT branch on which optional fields are present. Useful for "tool is working" UI on long-running tools (build, test, deploy, large search, sub-agent tasks). |
204
+ | `step-start` | `stepIndex` | New LLM invocation step began. |
205
+ | `step-finish` | `stepIndex`, `finishReason`, `usage?` | Step completed with per-step token usage. |
206
+ | `error` | `error`, `code?` | Mid-stream error (yielded, not thrown). |
207
+ | `finish` | `finishReason`, `usage?` | Stream completed with aggregate token usage. |
208
208
 
209
209
  > **Diagnostic logging.** The `ChatEvent` union is the harness-agnostic public stream — it never carries
210
210
  > harness-internal chunk shapes. When a harness encounters a chunk type its adapter does not recognize (typically after
@@ -1389,18 +1389,20 @@ This package publishes two ESM entry points:
1389
1389
  > see the subpath. Modern bundlers (Vite, esbuild, Webpack 5+, tsup, Rollup with `@rollup/plugin-node-resolve` v15+)
1390
1390
  > resolve it natively. This is a harness-author concern only; consumer applications never touch the subpath.
1391
1391
 
1392
- | Export | Surface | Role |
1393
- | ----------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1394
- | `HarnessFactory<H>` | Type only on bare; value+type on `/harness` | Construct a harness of type `H` bound to a storage root. Declares `harnessId` and `protocolVersion`. Default `H = AgentHarness`. |
1395
- | `AgentHarness` | Type only on bare; type on `/harness` | Runtime contract: agent / thread / stream / tool / message lifecycle. Declares its own `harnessId` and `protocolVersion`. |
1396
- | `SUPPORTED_PROTOCOL_VERSIONS` | `/harness` only | Readonly list of harness protocol versions this SDK accepts. `createAgentManager` checks both the factory and the constructed harness. |
1397
- | `HarnessBusOwner` | `/harness` only | Composition helper owning telemetry + log buses with `dispose()` semantics. Reuse it instead of reimplementing bus plumbing. |
1398
- | `lowerStreamInput` | `/harness` only | Validates a `MessagePart[]` and lowers each input part to your runtime's content-block shape. Use it in `stream()` so multimodal caps and `MULTIMODAL_NOT_SUPPORTED` / `INVALID_MESSAGE_CONTENT` semantics match every other harness. |
1399
- | `GenSink<T>` | `/harness` only | Buffered async-generator wrapper for routing `ChatEvent`s to a consumer's `ChatStreamResult.eventStream`. Single-iteration: calling `generator()` twice throws — sinks have one waiter slot and one buffer, two iterators race on both. |
1400
- | `mcpServerConfigEqual` | Bare specifier and `/harness` | Structural deep-equality predicate over `MCPServerConfig`. Use inside `updateAgent` to decide which servers to preserve vs. cycle. Treats `enabled: undefined` and `enabled: true` as equal; compares URLs via `String(url)` (so `URL` instances and strings round-trip); `headers` and `env` are key-order-insensitive; `reconnectionOptions` compares field-wise. |
1401
- | `AlwaysActiveEntry` | `/harness` only | Entry shape consumed by per-harness `toolSearch.alwaysActive` extension fields. Three matching patterns: `{ serverName }` (server-wide), `{ serverName, toolName }` (precise), `{ toolName }` (cross-source). At least one of `serverName` / `toolName` must be present. |
1402
- | `matchesAlwaysActive` | `/harness` only | Predicate `(entries, serverName, toolName) → boolean` consulted per-tool when stamping always-load metadata or partitioning a tool-search pool. Use this instead of pattern-matching entries by hand so harness behavior stays uniform. |
1403
- | `validateAlwaysActiveEntry` | `/harness` only | Throws on a malformed entry (`{}`, both fields empty). Call once per entry at the harness boundary so a typo fails loud at config time rather than silently dropping the entry on every `stream()`. |
1392
+ | Export | Surface | Role |
1393
+ | ---------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1394
+ | `HarnessFactory<H>` | Type only on bare; value+type on `/harness` | Construct a harness of type `H` bound to a storage root. Declares `harnessId` and `protocolVersion`. Default `H = AgentHarness`. |
1395
+ | `AgentHarness` | Type only on bare; type on `/harness` | Runtime contract: agent / thread / stream / tool / message lifecycle. Declares its own `harnessId` and `protocolVersion`. |
1396
+ | `SUPPORTED_PROTOCOL_VERSIONS` | `/harness` only | Readonly list of harness protocol versions this SDK accepts. `createAgentManager` checks both the factory and the constructed harness. |
1397
+ | `HarnessBusOwner` | `/harness` only | Composition helper owning telemetry + log buses with `dispose()` semantics. Reuse it instead of reimplementing bus plumbing. |
1398
+ | `lowerStreamInput` | `/harness` only | Validates a `MessagePart[]` and lowers each input part to your runtime's content-block shape. Use it in `stream()` so multimodal caps and `MULTIMODAL_NOT_SUPPORTED` / `INVALID_MESSAGE_CONTENT` semantics match every other harness. |
1399
+ | `GenSink<T>` | `/harness` only | Buffered async-generator wrapper for routing `ChatEvent`s to a consumer's `ChatStreamResult.eventStream`. Single-iteration: calling `generator()` twice throws — sinks have one waiter slot and one buffer, two iterators race on both. |
1400
+ | `mcpServerConfigEqual` | Bare specifier and `/harness` | Structural deep-equality predicate over `MCPServerConfig`. Use inside `updateAgent` to decide which servers to preserve vs. cycle. Treats `enabled: undefined` and `enabled: true` as equal; compares URLs via `String(url)` (so `URL` instances and strings round-trip); `headers` and `env` are key-order-insensitive; `reconnectionOptions` compares field-wise. |
1401
+ | `AlwaysActiveEntry` | `/harness` only | Entry shape consumed by per-harness `toolSearch.alwaysActive` extension fields. Three matching patterns: `{ serverName }` (server-wide), `{ serverName, toolName }` (precise), `{ toolName }` (cross-source). At least one of `serverName` / `toolName` must be present. |
1402
+ | `matchesAlwaysActive` | `/harness` only | Predicate `(entries, serverName, toolName) → boolean` consulted per-tool when stamping always-load metadata or partitioning a tool-search pool. Use this instead of pattern-matching entries by hand so harness behavior stays uniform. |
1403
+ | `validateAlwaysActiveEntry` | `/harness` only | Throws on a malformed entry (`{}`, both fields empty). Call once per entry at the harness boundary so a typo fails loud at config time rather than silently dropping the entry on every `stream()`. |
1404
+ | `splitToolResultsIntoToolMessages` | `/harness` only | Read-side normalizer `(Message[]) → Message[]`. Hoists every completed tool call's `tool-result` part onto its own `role: 'tool'` message (leaving the `tool-call` on the assistant message), so `getMessages()` returns the canonical cross-harness layout. Idempotent; backfills a blank result `toolName` from the matching call; preserves `isError` and (in-memory) `error`. Call it at the end of `getMessages()`. (Whether `error` survives to the returned history is a harness-persistence concern — see `AgentHarness.getMessages`; `isError` always survives.) |
1405
+ | `mergeToolResultsIntoAssistant` | `/harness` only | Write-side inverse `(Message[]) → Message[]`. Folds each `role: 'tool'` message's result back adjacent to its `tool-call` in the preceding assistant message, so a runtime that stores a completed call as one merged object round-trips losslessly. Call it at the start of `addContext()` before persisting. |
1404
1406
 
1405
1407
  Minimal skeleton:
1406
1408
 
@@ -1,7 +1,7 @@
1
1
  import { Model } from './models/index.js';
2
2
  import { type JSONWebToken, type OrgConnection, type OrgConnectionFactory } from '@salesforce/agentic-common';
3
3
  import type { AgentConfig } from './harness/harness-config.js';
4
- import type { ModelConnectivityInfo } from './types/model-connectivity-info.js';
4
+ import type { ModelConnectivityInfo, ProviderHint } from './types/model-connectivity-info.js';
5
5
  /**
6
6
  * The result of resolving an agent's connectivity.
7
7
  *
@@ -82,6 +82,27 @@ export declare class DefaultAgentConnectivityResolver implements AgentConnectivi
82
82
  */
83
83
  resolve(projectRoot: string, config: AgentConfig): Promise<ResolvedConnectivity>;
84
84
  }
85
+ /**
86
+ * Maps a Salesforce gateway-routed {@link Model} to the wire shape the harness will
87
+ * speak. Anthropic models go to the `/invoke-with-response-stream` (Bedrock-Anthropic)
88
+ * pass-through endpoint; OpenAI models go to the `/responses` (OpenAI Responses)
89
+ * pass-through endpoint. Throws for any model whose family cannot be inferred from
90
+ * its `name` — a programming error worth surfacing eagerly rather than silently
91
+ * defaulting.
92
+ *
93
+ * **Scope:** This helper is for the Salesforce gateway path only — both
94
+ * `llmgateway__BedrockAnthropic*` and `llmgateway__Anthropic*` prefixes go to the
95
+ * gateway's Bedrock pass-through, so both collapse to `'bedrock-anthropic'`. A
96
+ * consumer wanting direct-Anthropic auth (`providerHint: 'anthropic'`) drives that
97
+ * path through {@link ApiKeyConnectivityResolver} with an explicit `providerHint`
98
+ * argument, not through this resolver.
99
+ *
100
+ * Exported (module-path only, not on the public `index.ts` surface) so e2e test
101
+ * resolvers reuse the same prefix→hint mapping rather than re-deriving it — a
102
+ * divergent hand-rolled ternary in `test/e2e/e2e-setup.ts` misrouted the
103
+ * `sfdc_ai__Default*` geo ids to `bedrock-anthropic` (W-23292476).
104
+ */
105
+ export declare function pickProviderHintForGatewayModel(model: Model): ProviderHint;
85
106
  /**
86
107
  * Resolves an `AgentConfig.modelId` value (which may be a {@link ModelName} enum value, a
87
108
  * pre-built {@link Model} instance, or `undefined`) to a concrete {@link Model}.
@@ -69,8 +69,13 @@ export class DefaultAgentConnectivityResolver {
69
69
  * consumer wanting direct-Anthropic auth (`providerHint: 'anthropic'`) drives that
70
70
  * path through {@link ApiKeyConnectivityResolver} with an explicit `providerHint`
71
71
  * argument, not through this resolver.
72
+ *
73
+ * Exported (module-path only, not on the public `index.ts` surface) so e2e test
74
+ * resolvers reuse the same prefix→hint mapping rather than re-deriving it — a
75
+ * divergent hand-rolled ternary in `test/e2e/e2e-setup.ts` misrouted the
76
+ * `sfdc_ai__Default*` geo ids to `bedrock-anthropic` (W-23292476).
72
77
  */
73
- function pickProviderHintForGatewayModel(model) {
78
+ export function pickProviderHintForGatewayModel(model) {
74
79
  const name = model.name;
75
80
  if (name.startsWith('llmgateway__BedrockAnthropic') || name.startsWith('llmgateway__Anthropic')) {
76
81
  return 'bedrock-anthropic';
@@ -406,6 +406,38 @@ export interface AgentHarness {
406
406
  * MUST populate `Message.createdAt` on every returned message. MUST return
407
407
  * messages sorted ascending by `createdAt`.
408
408
  *
409
+ * **A completed tool call MUST produce exactly two messages, in a canonical
410
+ * layout (#647).** For every completed tool invocation the returned history
411
+ * MUST contain:
412
+ *
413
+ * 1. an `assistant` message carrying the `tool-call` {@link MessagePart}
414
+ * (the model's `toolName` + `args`), immediately followed by
415
+ * 2. a separate `role: 'tool'` message carrying the paired `tool-result`
416
+ * part (the outcome). The two share a `toolCallId`.
417
+ *
418
+ * The `tool-result` part appears **only** on the `role: 'tool'` message —
419
+ * never also on the `assistant` message or a `user` message. The
420
+ * `tool-result` part's `isError` flag MUST survive to history, since
421
+ * consumers render failed tool calls differently. The `error` `Error` object
422
+ * is best-effort: it is preserved in-memory but does NOT survive a harness
423
+ * persistence round-trip (neither harness persists it), so consumers keying
424
+ * off a failed result MUST branch on `isError`, not on the presence of
425
+ * `error`.
426
+ *
427
+ * This is the shape the SDK's `MessageRole` documents as canonical (`'tool'`
428
+ * = "Results from tool executions"), aligned with AI SDK `ModelMessage` and
429
+ * the AG-UI `ToolMessage` model, so a downstream translator is a pure
430
+ * per-message 1:1 map with no cross-message state or per-harness placement
431
+ * knowledge. **Placement, not just presence, is the contract** — collapsing a
432
+ * call to only its `tool-result`, or leaving the result on the assistant /
433
+ * user message, makes history harness-dependent and breaks any consumer that
434
+ * renders, replays, or audits tool activity from history (e.g. an AG-UI
435
+ * `MESSAGES_SNAPSHOT`). Both production harnesses converge on this layout via
436
+ * the shared `splitToolResultsIntoToolMessages` normalizer
437
+ * (`@salesforce/sfdx-agent-sdk/harness`); the inverse
438
+ * `mergeToolResultsIntoAssistant` keeps the `getMessages()` → `addContext()`
439
+ * round-trip lossless.
440
+ *
409
441
  * @param agentId - ID of the agent.
410
442
  * @param threadId - ID of the conversation thread.
411
443
  * @returns All messages in chronological order (ascending by `createdAt`).
@@ -50,3 +50,4 @@ export { HarnessBusOwner } from './harness-bus-owner.js';
50
50
  export { lowerStreamInput, type InputMessagePart } from './stream-input.js';
51
51
  export { GenSink } from './gen-sink.js';
52
52
  export { matchesAlwaysActive, validateAlwaysActiveEntry, type AlwaysActiveEntry } from './always-active.js';
53
+ export { splitToolResultsIntoToolMessages, mergeToolResultsIntoAssistant } from './tool-message-normalizer.js';
@@ -9,4 +9,5 @@ export { HarnessBusOwner } from './harness-bus-owner.js';
9
9
  export { lowerStreamInput } from './stream-input.js';
10
10
  export { GenSink } from './gen-sink.js';
11
11
  export { matchesAlwaysActive, validateAlwaysActiveEntry } from './always-active.js';
12
+ export { splitToolResultsIntoToolMessages, mergeToolResultsIntoAssistant } from './tool-message-normalizer.js';
12
13
  //# sourceMappingURL=public.js.map
@@ -0,0 +1,44 @@
1
+ import type { Message } from '../types/messages.js';
2
+ /**
3
+ * READ side. Hoists every `tool-result` part that rides on a non-`tool` message
4
+ * (assistant on Mastra, user on Claude) onto its own trailing `role: 'tool'`
5
+ * message, producing the canonical layout described above.
6
+ *
7
+ * Behavior:
8
+ * - **Idempotent.** A `tool-result` already on a `role: 'tool'` message is left
9
+ * untouched, so applying this over already-canonical history (e.g. Claude's
10
+ * `addContext` mirror, or a second pass) is a no-op.
11
+ * - **Order-preserving.** Each hoisted `tool-result` becomes a `role: 'tool'`
12
+ * message inserted immediately after the message it was hoisted from, so a
13
+ * result stays adjacent to its originating `tool-call`.
14
+ * - **toolName backfill.** When a hoisted `tool-result`'s `toolName` is blank
15
+ * (Claude persists `tool_result` blocks without a name), it is backfilled from
16
+ * the matching `tool-call` seen earlier in the same input.
17
+ * - **isError / error survive.** Both flow through to the hoisted part verbatim.
18
+ * - **Empty-message drop.** A source message left with no parts after hoisting
19
+ * (e.g. an assistant message whose only content was a `tool-result`) is
20
+ * dropped rather than emitted as an empty message.
21
+ * - **createdAt.** Each emitted `role: 'tool'` message inherits its source
22
+ * message's `createdAt`, so the harness's ascending-by-`createdAt` sort keeps
23
+ * the tool message at or after its originating message. String-content
24
+ * messages (no parts) pass through untouched.
25
+ */
26
+ export declare function splitToolResultsIntoToolMessages(messages: Message[]): Message[];
27
+ /**
28
+ * WRITE side (inverse of {@link splitToolResultsIntoToolMessages}). Folds each
29
+ * `role: 'tool'` message's `tool-result` part back into the content array of the
30
+ * assistant message that carries the matching `tool-call`, inserting it
31
+ * immediately after that `tool-call` part.
32
+ *
33
+ * The adjacency is load-bearing: Mastra's `mapMessageToMastra` coalesces an
34
+ * adjacent `tool-call` + `tool-result` pair (same `toolCallId`) back into one
35
+ * `tool-invocation(result)`, so folding the result in beside its call keeps the
36
+ * `getMessages()` → `addContext()` round-trip lossless and never double-stores
37
+ * the call.
38
+ *
39
+ * A `role: 'tool'` message whose result has no matching `tool-call` in a prior
40
+ * assistant message is preserved as-is (defensive — a caller-constructed history
41
+ * the SDK didn't produce). History with no `role: 'tool'` messages is returned
42
+ * unchanged.
43
+ */
44
+ export declare function mergeToolResultsIntoAssistant(messages: Message[]): Message[];
@@ -0,0 +1,175 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ /**
6
+ * Canonical cross-harness layout for a completed tool call (#647).
7
+ *
8
+ * A completed tool call MUST surface as TWO messages in `getMessages()`
9
+ * history: an `assistant` message carrying the `tool-call` part (the model's
10
+ * `toolName` + `args`), immediately followed by a separate `role: 'tool'`
11
+ * message carrying the paired `tool-result` part (same `toolCallId`). The
12
+ * `tool-result` appears ONLY on the `role: 'tool'` message — never also on the
13
+ * assistant or a user message.
14
+ *
15
+ * This matches the shape the SDK's `Message` / `MessageRole` types document as
16
+ * canonical ("Aligned with AI SDK `ModelMessage` roles"; `'tool'` = "Results
17
+ * from tool executions") and the AG-UI `ToolMessage` model, so a downstream
18
+ * translator is a pure per-message 1:1 map with no cross-message state.
19
+ *
20
+ * The two production harnesses natively store completed calls differently —
21
+ * Mastra merges call + result into one object on the assistant message; Claude
22
+ * puts the result on the following `user` message (Anthropic wire convention).
23
+ * Both call {@link splitToolResultsIntoToolMessages} at the end of
24
+ * `getMessages()` so consumers see one shape regardless of harness. The write
25
+ * path ({@link mergeToolResultsIntoAssistant}) is the exact inverse, keeping
26
+ * `getMessages()` → `addContext()` round-trips lossless.
27
+ *
28
+ * Both helpers live on `@salesforce/sfdx-agent-sdk/harness` so the two harnesses
29
+ * cannot drift on the normalization — the same precedent as `mcpServerConfigEqual`,
30
+ * `lowerStreamInput`, and `backfillCreatedAt`.
31
+ */
32
+ function isToolCallPart(part) {
33
+ return part.type === 'tool-call';
34
+ }
35
+ function isToolResultPart(part) {
36
+ return part.type === 'tool-result';
37
+ }
38
+ /**
39
+ * READ side. Hoists every `tool-result` part that rides on a non-`tool` message
40
+ * (assistant on Mastra, user on Claude) onto its own trailing `role: 'tool'`
41
+ * message, producing the canonical layout described above.
42
+ *
43
+ * Behavior:
44
+ * - **Idempotent.** A `tool-result` already on a `role: 'tool'` message is left
45
+ * untouched, so applying this over already-canonical history (e.g. Claude's
46
+ * `addContext` mirror, or a second pass) is a no-op.
47
+ * - **Order-preserving.** Each hoisted `tool-result` becomes a `role: 'tool'`
48
+ * message inserted immediately after the message it was hoisted from, so a
49
+ * result stays adjacent to its originating `tool-call`.
50
+ * - **toolName backfill.** When a hoisted `tool-result`'s `toolName` is blank
51
+ * (Claude persists `tool_result` blocks without a name), it is backfilled from
52
+ * the matching `tool-call` seen earlier in the same input.
53
+ * - **isError / error survive.** Both flow through to the hoisted part verbatim.
54
+ * - **Empty-message drop.** A source message left with no parts after hoisting
55
+ * (e.g. an assistant message whose only content was a `tool-result`) is
56
+ * dropped rather than emitted as an empty message.
57
+ * - **createdAt.** Each emitted `role: 'tool'` message inherits its source
58
+ * message's `createdAt`, so the harness's ascending-by-`createdAt` sort keeps
59
+ * the tool message at or after its originating message. String-content
60
+ * messages (no parts) pass through untouched.
61
+ */
62
+ export function splitToolResultsIntoToolMessages(messages) {
63
+ // toolCallId → toolName, harvested from tool-call parts as we walk forward,
64
+ // so a later tool-result with a blank name can be backfilled.
65
+ const toolNameByCallId = new Map();
66
+ for (const msg of messages) {
67
+ if (typeof msg.content === 'string')
68
+ continue;
69
+ for (const part of msg.content) {
70
+ if (isToolCallPart(part) && part.toolName)
71
+ toolNameByCallId.set(part.toolCallId, part.toolName);
72
+ }
73
+ }
74
+ const out = [];
75
+ for (const msg of messages) {
76
+ // String content or a message that is already a tool message passes
77
+ // through unchanged (idempotency + nothing to hoist).
78
+ if (typeof msg.content === 'string' || msg.role === 'tool') {
79
+ out.push(msg);
80
+ continue;
81
+ }
82
+ const kept = [];
83
+ const hoisted = [];
84
+ for (const part of msg.content) {
85
+ if (isToolResultPart(part)) {
86
+ const toolName = part.toolName || toolNameByCallId.get(part.toolCallId) || '';
87
+ hoisted.push({ ...part, toolName });
88
+ }
89
+ else {
90
+ kept.push(part);
91
+ }
92
+ }
93
+ if (hoisted.length === 0) {
94
+ out.push(msg);
95
+ continue;
96
+ }
97
+ // Emit the source message first (minus its tool-results) only if it
98
+ // still carries content; a result-only message is dropped.
99
+ if (kept.length > 0) {
100
+ out.push({ ...msg, content: kept });
101
+ }
102
+ // One role:'tool' message per hoisted result — keeps the downstream
103
+ // AG-UI map a clean 1:1 (one tool-result part → one ToolMessage).
104
+ for (const result of hoisted) {
105
+ out.push({
106
+ id: `${msg.id}:tool:${result.toolCallId}`,
107
+ role: 'tool',
108
+ content: [result],
109
+ createdAt: msg.createdAt,
110
+ });
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ /**
116
+ * WRITE side (inverse of {@link splitToolResultsIntoToolMessages}). Folds each
117
+ * `role: 'tool'` message's `tool-result` part back into the content array of the
118
+ * assistant message that carries the matching `tool-call`, inserting it
119
+ * immediately after that `tool-call` part.
120
+ *
121
+ * The adjacency is load-bearing: Mastra's `mapMessageToMastra` coalesces an
122
+ * adjacent `tool-call` + `tool-result` pair (same `toolCallId`) back into one
123
+ * `tool-invocation(result)`, so folding the result in beside its call keeps the
124
+ * `getMessages()` → `addContext()` round-trip lossless and never double-stores
125
+ * the call.
126
+ *
127
+ * A `role: 'tool'` message whose result has no matching `tool-call` in a prior
128
+ * assistant message is preserved as-is (defensive — a caller-constructed history
129
+ * the SDK didn't produce). History with no `role: 'tool'` messages is returned
130
+ * unchanged.
131
+ */
132
+ export function mergeToolResultsIntoAssistant(messages) {
133
+ const out = [];
134
+ for (const msg of messages) {
135
+ if (msg.role !== 'tool' || typeof msg.content === 'string') {
136
+ out.push(msg);
137
+ continue;
138
+ }
139
+ const results = msg.content.filter(isToolResultPart);
140
+ const nonResults = msg.content.filter((p) => !isToolResultPart(p));
141
+ const unmerged = [];
142
+ for (const result of results) {
143
+ if (!foldResultIntoAssistant(out, result))
144
+ unmerged.push(result);
145
+ }
146
+ // Anything we couldn't place (no matching prior tool-call) or any
147
+ // non-result parts on the tool message stay on a preserved tool message
148
+ // so we never silently drop content.
149
+ const leftover = [...nonResults, ...unmerged];
150
+ if (leftover.length > 0) {
151
+ out.push({ ...msg, content: leftover });
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+ /**
157
+ * Inserts `result` immediately after the matching `tool-call` in the most recent
158
+ * assistant message in `out` that carries it. Returns `true` when placed.
159
+ */
160
+ function foldResultIntoAssistant(out, result) {
161
+ for (let i = out.length - 1; i >= 0; i--) {
162
+ const candidate = out[i];
163
+ if (candidate.role !== 'assistant' || typeof candidate.content === 'string')
164
+ continue;
165
+ const callIndex = candidate.content.findIndex((p) => isToolCallPart(p) && p.toolCallId === result.toolCallId);
166
+ if (callIndex === -1)
167
+ continue;
168
+ const merged = [...candidate.content];
169
+ merged.splice(callIndex + 1, 0, result);
170
+ out[i] = { ...candidate, content: merged };
171
+ return true;
172
+ }
173
+ return false;
174
+ }
175
+ //# sourceMappingURL=tool-message-normalizer.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-sdk",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Harness-agnostic agentic infrastructure for Salesforce developer experience tooling",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,6 +29,8 @@
29
29
  "test:e2e": "tsc --build ./test/tsconfig.json && vitest run -c ./test/e2e/vitest.config.ts",
30
30
  "test:e2e:local": "node scripts/local-e2e.mjs",
31
31
  "test:e2e:local:cleanup": "node scripts/local-e2e-cleanup.mjs",
32
+ "test:smoke": "tsc --build ./test/tsconfig.json && vitest run -c ./test/smoke/vitest.config.ts",
33
+ "test:smoke:local": "node scripts/local-smoke.mjs",
32
34
  "verify": "tsx scripts/verify.ts"
33
35
  },
34
36
  "license": "SEE LICENSE IN LICENSE.txt",
@@ -44,8 +46,8 @@
44
46
  },
45
47
  "devDependencies": {
46
48
  "@eslint/js": "^10.0.1",
47
- "@salesforce/sfdx-agent-harness-claude": "0.26.0",
48
- "@salesforce/sfdx-agent-harness-mastra": "0.29.0",
49
+ "@salesforce/sfdx-agent-harness-claude": "0.28.0",
50
+ "@salesforce/sfdx-agent-harness-mastra": "0.31.0",
49
51
  "@types/node": "^22.20.0",
50
52
  "@vitest/coverage-istanbul": "^4.1.8",
51
53
  "@vitest/eslint-plugin": "^1.6.20",
@@ -56,11 +58,11 @@
56
58
  "eslint-plugin-n": "^18.2.1",
57
59
  "globals": "^17.6.0",
58
60
  "lint-staged": "^17.0.7",
59
- "prettier": "^3.9.0",
61
+ "prettier": "^3.9.4",
60
62
  "rimraf": "^6.1.3",
61
- "tsx": "^4.22.4",
63
+ "tsx": "^4.23.0",
62
64
  "typescript": "^6.0.3",
63
- "typescript-eslint": "^8.61.0",
65
+ "typescript-eslint": "^8.62.1",
64
66
  "vitest": "^4.1.8"
65
67
  },
66
68
  "engines": {