@salesforce/sfdx-agent-harness-mastra 0.53.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,11 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-harness-mastra` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.54.0] - 2026-08-25
7
+
8
+ ### Features
9
+ - **harness-mastra**: store session context in a harness-owned per-thread store @W-23632686@ ([#765](https://github.com/forcedotcom/agentic-dx/pull/765))
10
+
6
11
  ## [0.53.0] - 2026-08-25
7
12
 
8
13
  ### Chores
@@ -12,6 +12,19 @@ import type { LanguageModelV3 } from '@ai-sdk/provider';
12
12
  import { Clock, type LogRecord, type UniqueIDGenerator, type Unsubscribe } from '@salesforce/agentic-common';
13
13
  import { type ToolsInput } from './utils.js';
14
14
  import { Workspace } from '@mastra/core/workspace';
15
+ /**
16
+ * Render a session-context object into the system text injected via `stream()`'s
17
+ * `system:` option — Mastra folds it into a `"user-provided"` system block,
18
+ * disjoint from `instructions` and the model's Working Memory `"memory"` block.
19
+ * Canonicalizes through the store's `sortJsonValue` so equal contexts render
20
+ * identically; `{}` renders to `''` so `stream()` omits the option entirely.
21
+ *
22
+ * The canonical JSON is emitted with `<`/`>` unicode-escaped (`\u003c` /
23
+ * `\u003e`). This lands in a system message, so a consumer value can't forge the
24
+ * `</session_context>` boundary — escaping the rendered form only, never the
25
+ * stored object's round-trip fidelity.
26
+ */
27
+ export declare function renderSessionContextSystemText(context: SessionContext): string;
15
28
  /**
16
29
  * Per-server MCP state. Each enabled server gets its own MCPClient and
17
30
  * independent discovery promise so readiness is tracked individually.
@@ -250,21 +263,12 @@ export declare class MastraHarness implements MastraAgentHarness {
250
263
  private readonly mastra;
251
264
  private readonly buses;
252
265
  /**
253
- * Harness-owned disk persistence for the per-thread session-context channel.
254
- * A dedicated JSON sidecar disjoint from the LibSQL-backed message store —
255
- * a `setSessionContext` object MUST NOT appear in `getMessages()` output.
256
- * Rooted at the same `storageRootFolder` the constructor receives.
257
- *
258
- * INTERIM: the session-context spike prescribes Mastra persist this over
259
- * Working Memory (`memory.updateWorkingMemory` / `getWorkingMemory`, JSON
260
- * mode — its native, already-enabled channel), not a sidecar. The sidecar
261
- * ships here so the shared `runSessionContextConformance` persistence gate is
262
- * green on all three harnesses in this milestone; W-23632686 replaces this
263
- * store with Working Memory (Claude/OpenAI keep their sidecars). The
264
- * consumer-visible contract — whole-object set, `{}`-on-miss, absent from
265
- * `getMessages` — is identical across both, so the swap is internal.
266
+ * Harness-owned store backing `setSessionContext` / `getSessionContext` —
267
+ * disjoint from Mastra's Working Memory (`this.memory`), so it shares no
268
+ * value with the model's native `updateWorkingMemory` writes and the two
269
+ * can't race. See the session-context Critical Invariant in ARCHITECTURE.md.
266
270
  */
267
- private readonly sessionContext;
271
+ private readonly sessionContextStore;
268
272
  /**
269
273
  * Mastra-specific extension surface, typed off `MastraAgentHarness`. The
270
274
  * SDK never reads this; consumers reach through `manager.extensions.mastra`.
@@ -395,6 +399,9 @@ export declare class MastraHarness implements MastraAgentHarness {
395
399
  * @requirements
396
400
  * - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
397
401
  * - MUST clone the source thread's messages into a new thread.
402
+ * - MUST copy the source thread's stored session context onto the clone —
403
+ * Mastra's native clone copies the thread row but not the harness-owned
404
+ * store, so without this the clone would silently start `{}`.
398
405
  */
399
406
  cloneThread(agentId: string, sourceThreadId: string): Promise<string>;
400
407
  /**
@@ -552,20 +559,52 @@ export declare class MastraHarness implements MastraAgentHarness {
552
559
  /** @deprecated Renamed to {@link addMessages}; this delegates verbatim. */
553
560
  addContext(agentId: string, threadId: string, messages: Message[]): Promise<void>;
554
561
  /**
555
- * Replace this thread's session-context object in full. Whole-object set
556
- * (not a merge); persisted verbatim to the harness-owned sidecar, disjoint
557
- * from the message transcript — the set object never appears in
558
- * `getMessages()`. Throws `AGENT_NOT_FOUND` for an unknown agent before any
559
- * store access.
562
+ * Replace this thread's consumer session-context object in full (whole-object
563
+ * set, not a merge), persisted to the harness-owned store — disjoint from
564
+ * Mastra's Working Memory, so it never races the model's native
565
+ * `updateWorkingMemory` writes and never surfaces in `getMessages()`.
566
+ * `getAgentStateOrThrow` first (`AGENT_NOT_FOUND`); then create-first via
567
+ * {@link assertThreadExists} (the store itself has no thread concept), which
568
+ * throws `THREAD_NOT_FOUND` for an uncreated thread. Size/depth guards live in
569
+ * `SessionContextStore.write`.
560
570
  */
561
571
  setSessionContext(agentId: string, threadId: string, content: SessionContext): Promise<void>;
562
572
  /**
563
- * This thread's persisted session-context object, or `{}` when nothing has
564
- * been set (or the sidecar is unparseable). Never `null`/`undefined`, never
565
- * throws on a miss. Throws `AGENT_NOT_FOUND` for an unknown agent before any
566
- * store access.
573
+ * This thread's consumer session-context object read back from the
574
+ * harness-owned session context store, or `{}` when nothing has been set.
575
+ * Never `null`/`undefined`, never throws on a miss (corrupt files are
576
+ * soft-skipped to `{}` and logged inside the store). `getAgentStateOrThrow`
577
+ * runs first so an unregistered agent surfaces `AGENT_NOT_FOUND` before any
578
+ * store touch.
567
579
  */
568
580
  getSessionContext(agentId: string, threadId: string): Promise<SessionContext>;
581
+ /**
582
+ * Create-first guard for `setSessionContext`: throws typed `THREAD_NOT_FOUND`
583
+ * when `threadId` was never created *for this agent* (Mastra's `Memory` is the
584
+ * thread registry). We must enforce the `resourceId` scope ourselves — Mastra's
585
+ * `getThreadById({ threadId, resourceId })` wrapper (`@mastra/core` 1.55) drops
586
+ * the `resourceId` arg, so on a shared `Memory` agent A's thread would satisfy
587
+ * a raw check for agent B. The returned `StorageThreadType` carries
588
+ * `resourceId`, so we assert it matches (consistent with `getThreadIds`'
589
+ * scoping). Only reachable when a caller reuses an id across agents; the SDK's
590
+ * `ChatSession` path always mints UUIDs.
591
+ */
592
+ private assertThreadExists;
593
+ /**
594
+ * The system text this thread's stored session context would inject on the
595
+ * next turn — renders via the SAME {@link renderSessionContextSystemText}
596
+ * `stream()` calls, so this seam observes exactly what a turn would inject.
597
+ * Not on the `AgentHarness` contract: its only caller is this harness's
598
+ * conformance seam (`test/conformance` supplies `renderSystemContext`).
599
+ */
600
+ renderSessionContextSystemContext(agentId: string, threadId: string): Promise<string>;
601
+ /**
602
+ * Copy a thread's stored session context onto another thread — used by
603
+ * `compactThread` (onto the compacted thread before the source retires) and
604
+ * `cloneThread` (Mastra's native clone doesn't know about this store). Skips
605
+ * the write when the source is `{}`, since a missing file already reads `{}`.
606
+ */
607
+ private carrySessionContextForward;
569
608
  /**
570
609
  * Issue a single non-agentic model call (`maxSteps: 1`) for one-shot
571
610
  * summarization. Used internally by `compactThread()`; not part of the
@@ -15,10 +15,10 @@ import { AgentSDKError, AgentSDKErrorType, DEFAULT_MAX_STEPS, McpServerStatus, r
15
15
  import { HarnessBusOwner, lowerStreamInput, mcpServerConfigEqual, mergeToolResultsIntoAssistant, splitToolResultsIntoToolMessages, } from '@salesforce/sfdx-agent-sdk/harness';
16
16
  import { MastraApprovalCoordinator } from './mastra-approval-coordinator.js';
17
17
  import { MASTRA_BUILT_IN_TOOL_POLICIES } from './mastra-built-in-policies.js';
18
- import { SessionContextStore } from './session-context-store.js';
19
18
  import { SKILL_SEARCH_META_TOOL_NAMES, TOOL_SEARCH_META_TOOL_NAMES } from './skill-bridge.js';
20
19
  import { mapMastraToMessage, mapMessageToMastra, partToTranscriptText } from './mastra-message-mapper.js';
21
20
  import { isStandardSchemaWithJSON, standardSchemaToJSONSchema } from '@mastra/schema-compat';
21
+ import { SessionContextStore, sortJsonValue } from './session-context-store.js';
22
22
  import { mapToolDefinitionsToMastraTools } from './mastra-tool-mapper.js';
23
23
  import { hasEnabledServers, mapToMastraMCPServers } from './mcp-config-mapper.js';
24
24
  import { buildMcpErrorDetail, sanitizeMcpErrorMessage, synthesizeHttpTransportError } from './mcp-error-classifier.js';
@@ -64,6 +64,26 @@ function stripMastraWorkspacePrefix(name) {
64
64
  function isDisposedError(err) {
65
65
  return err instanceof AgentSDKError && err.type === AgentSDKErrorType.DISPOSED;
66
66
  }
67
+ /** Opening/closing tags wrapping the rendered session-context block so the model can recognize it as standing context, distinct from its own Working Memory. */
68
+ const SESSION_CONTEXT_TAG = 'session_context';
69
+ /**
70
+ * Render a session-context object into the system text injected via `stream()`'s
71
+ * `system:` option — Mastra folds it into a `"user-provided"` system block,
72
+ * disjoint from `instructions` and the model's Working Memory `"memory"` block.
73
+ * Canonicalizes through the store's `sortJsonValue` so equal contexts render
74
+ * identically; `{}` renders to `''` so `stream()` omits the option entirely.
75
+ *
76
+ * The canonical JSON is emitted with `<`/`>` unicode-escaped (`\u003c` /
77
+ * `\u003e`). This lands in a system message, so a consumer value can't forge the
78
+ * `</session_context>` boundary — escaping the rendered form only, never the
79
+ * stored object's round-trip fidelity.
80
+ */
81
+ export function renderSessionContextSystemText(context) {
82
+ if (Object.keys(context).length === 0)
83
+ return '';
84
+ const canonical = JSON.stringify(sortJsonValue(context, 0)).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
85
+ return `<${SESSION_CONTEXT_TAG}>\n${canonical}\n</${SESSION_CONTEXT_TAG}>`;
86
+ }
67
87
  /**
68
88
  * Mastra-backed implementation of {@link AgentHarness}.
69
89
  *
@@ -368,21 +388,12 @@ export class MastraHarness {
368
388
  mastra;
369
389
  buses = new HarnessBusOwner();
370
390
  /**
371
- * Harness-owned disk persistence for the per-thread session-context channel.
372
- * A dedicated JSON sidecar disjoint from the LibSQL-backed message store —
373
- * a `setSessionContext` object MUST NOT appear in `getMessages()` output.
374
- * Rooted at the same `storageRootFolder` the constructor receives.
375
- *
376
- * INTERIM: the session-context spike prescribes Mastra persist this over
377
- * Working Memory (`memory.updateWorkingMemory` / `getWorkingMemory`, JSON
378
- * mode — its native, already-enabled channel), not a sidecar. The sidecar
379
- * ships here so the shared `runSessionContextConformance` persistence gate is
380
- * green on all three harnesses in this milestone; W-23632686 replaces this
381
- * store with Working Memory (Claude/OpenAI keep their sidecars). The
382
- * consumer-visible contract — whole-object set, `{}`-on-miss, absent from
383
- * `getMessages` — is identical across both, so the swap is internal.
391
+ * Harness-owned store backing `setSessionContext` / `getSessionContext` —
392
+ * disjoint from Mastra's Working Memory (`this.memory`), so it shares no
393
+ * value with the model's native `updateWorkingMemory` writes and the two
394
+ * can't race. See the session-context Critical Invariant in ARCHITECTURE.md.
384
395
  */
385
- sessionContext;
396
+ sessionContextStore;
386
397
  /**
387
398
  * Mastra-specific extension surface, typed off `MastraAgentHarness`. The
388
399
  * SDK never reads this; consumers reach through `manager.extensions.mastra`.
@@ -435,7 +446,16 @@ export class MastraHarness {
435
446
  semanticRecall: false,
436
447
  workingMemory: {
437
448
  enabled: true,
449
+ // Thread scope keeps each conversation's Working Memory
450
+ // isolated (Mastra's default is resource scope, which would
451
+ // share one slot across every thread on the agent).
438
452
  scope: 'thread',
453
+ // `agentManaged: true` (Mastra's default) keeps the native
454
+ // `updateWorkingMemory` tool — the model's own scratchpad, and
455
+ // this slot alone. Consumer session context lives in the
456
+ // separate `this.sessionContextStore`, so the harness has
457
+ // nothing to partition out of here.
458
+ agentManaged: true,
439
459
  },
440
460
  },
441
461
  });
@@ -443,7 +463,7 @@ export class MastraHarness {
443
463
  storage: this.storage,
444
464
  memory: { shared: this.memory },
445
465
  });
446
- this.sessionContext = new SessionContextStore(this.storageRootFolder, this.buses.getLogBus());
466
+ this.sessionContextStore = new SessionContextStore(this.storageRootFolder, this.buses.getLogBus());
447
467
  }
448
468
  /**
449
469
  * @requirements
@@ -739,10 +759,9 @@ export class MastraHarness {
739
759
  }));
740
760
  state.skillSearchProcessor?.dispose();
741
761
  await state.mastraWorkspace?.destroy();
742
- // Remove every session-context sidecar for this agent so teardown leaves
743
- // no orphaned context behind (per-thread destroyThread isn't called for
744
- // each thread on agent destruction).
745
- await this.sessionContext.deleteAgent(agentId);
762
+ // The store is separate from Mastra's thread rows, so it needs its own
763
+ // teardown — remove every thread's stored context for this agent.
764
+ await this.sessionContextStore.deleteAgent(agentId);
746
765
  this.mastra.removeAgent(agentId);
747
766
  this.modelConnectivityByAgent.delete(agentId);
748
767
  return this.activeAgents.delete(agentId);
@@ -1213,10 +1232,9 @@ export class MastraHarness {
1213
1232
  }
1214
1233
  const memory = await this.getMemoryOrThrow(agentId);
1215
1234
  await memory.deleteThread(threadId);
1216
- // Drop the session-context sidecar too, so a destroyed thread leaves no
1217
- // orphaned context that a same-id recreate would resurrect. Runs on the
1218
- // store's own serialize queue; ENOENT-tolerant for a never-set thread.
1219
- await this.sessionContext.delete(agentId, threadId);
1235
+ // The store is separate from Mastra's thread row, so delete it too or a
1236
+ // same-id recreate would resurrect the destroyed thread's context.
1237
+ await this.sessionContextStore.delete(agentId, threadId);
1220
1238
  }
1221
1239
  /**
1222
1240
  * @requirements
@@ -1235,10 +1253,23 @@ export class MastraHarness {
1235
1253
  * @requirements
1236
1254
  * - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
1237
1255
  * - MUST clone the source thread's messages into a new thread.
1256
+ * - MUST copy the source thread's stored session context onto the clone —
1257
+ * Mastra's native clone copies the thread row but not the harness-owned
1258
+ * store, so without this the clone would silently start `{}`.
1238
1259
  */
1239
1260
  async cloneThread(agentId, sourceThreadId) {
1240
1261
  const memory = await this.getMemoryOrThrow(agentId);
1241
1262
  const { thread } = await memory.cloneThread({ sourceThreadId });
1263
+ // Native clone already persisted the new thread; if the store copy then
1264
+ // fails (a genuine FS error), delete the orphan before rethrowing so a
1265
+ // failed clone leaves no thread the caller never got an id for.
1266
+ try {
1267
+ await this.carrySessionContextForward(agentId, sourceThreadId, thread.id);
1268
+ }
1269
+ catch (err) {
1270
+ await this.destroyThreadBestEffort(agentId, thread.id);
1271
+ throw err;
1272
+ }
1242
1273
  return thread.id;
1243
1274
  }
1244
1275
  /**
@@ -1268,6 +1299,21 @@ export class MastraHarness {
1268
1299
  const messages = await this.getMessages(agentId, threadId);
1269
1300
  if (messages.length === 0) {
1270
1301
  const newThreadId = await this.createThread(agentId);
1302
+ // An empty transcript can still carry session context (it's set
1303
+ // independently of messages), so carry it forward here too — before
1304
+ // the source is retired, and fail-closed (source left intact on a
1305
+ // copy failure) to match the main path's COMPACTION_FAILED contract.
1306
+ try {
1307
+ await this.carrySessionContextForward(agentId, threadId, newThreadId);
1308
+ }
1309
+ catch (err) {
1310
+ // The new thread was already created; a carry-forward failure
1311
+ // orphans it (never returned, never retired). Delete it before
1312
+ // surfacing COMPACTION_FAILED so a failed compaction leaves no
1313
+ // trace beyond the still-intact source thread.
1314
+ await this.destroyThreadBestEffort(agentId, newThreadId);
1315
+ throw new AgentSDKError(`Failed to compact thread "${threadId}" for agent "${agentId}": ${getErrorMessage(err)}`, AgentSDKErrorType.COMPACTION_FAILED, { cause: err });
1316
+ }
1271
1317
  // Empty source still gets compacted away — leaving it attached
1272
1318
  // would be a leak. Mirrors the non-empty path's destroyThread
1273
1319
  // call below; both paths exit with the source detached.
@@ -1280,6 +1326,9 @@ export class MastraHarness {
1280
1326
  return `${m.role}: ${text}`;
1281
1327
  })
1282
1328
  .join('\n');
1329
+ // `undefined` until `createThread` succeeds, so the catch can tell a
1330
+ // pre-create failure (nothing to clean up) from a post-create failure
1331
+ // (the new thread is orphaned and must be deleted).
1283
1332
  let newThreadId;
1284
1333
  try {
1285
1334
  const summaryText = transcript.length <= MAX_TRANSCRIPT_CHARS
@@ -1302,8 +1351,19 @@ export class MastraHarness {
1302
1351
  createdAt: assistantTs,
1303
1352
  },
1304
1353
  ]);
1354
+ // Carry the source thread's session context onto the compacted thread
1355
+ // before the source is retired. Inside the try so a copy failure is
1356
+ // fail-closed (surfaces as COMPACTION_FAILED, source left intact).
1357
+ await this.carrySessionContextForward(agentId, threadId, newThreadId);
1305
1358
  }
1306
1359
  catch (err) {
1360
+ // If the failure came after `createThread`, the new thread is
1361
+ // orphaned (never returned to the caller, source not yet retired).
1362
+ // Delete it so a failed compaction leaves no trace beyond the
1363
+ // still-intact source thread.
1364
+ if (newThreadId !== undefined) {
1365
+ await this.destroyThreadBestEffort(agentId, newThreadId);
1366
+ }
1307
1367
  throw new AgentSDKError(`Failed to compact thread "${threadId}" for agent "${agentId}": ${getErrorMessage(err)}`, AgentSDKErrorType.COMPACTION_FAILED, { cause: err });
1308
1368
  }
1309
1369
  // Source-thread cleanup is best-effort. The user's data is already
@@ -1520,6 +1580,12 @@ export class MastraHarness {
1520
1580
  systemPrompt: agentState.composedInstructions,
1521
1581
  });
1522
1582
  }
1583
+ // Deliver the consumer session context for this turn via the `system:`
1584
+ // option below (see `renderSessionContextSystemText`). `read` softens
1585
+ // never-set/corrupt to `{}` but rejects a genuine FS fault (EACCES/EIO),
1586
+ // failing the turn rather than silently dropping context for it.
1587
+ const sessionContext = await this.sessionContextStore.read(agentId, threadId);
1588
+ const systemContext = renderSessionContextSystemText(sessionContext);
1523
1589
  const output = await agentState.agent.stream(streamInput, {
1524
1590
  memory: {
1525
1591
  thread: threadId,
@@ -1528,6 +1594,7 @@ export class MastraHarness {
1528
1594
  requestContext: this.buildRequestContext(agentId, threadId),
1529
1595
  maxSteps: options?.maxSteps ?? DEFAULT_MAX_STEPS,
1530
1596
  abortSignal: options?.abortSignal,
1597
+ ...(systemContext.length > 0 ? { system: systemContext } : {}),
1531
1598
  // Pass `requireToolApproval: true` to Mastra iff the gate is active
1532
1599
  // so Mastra suspends every non-consumer tool with a
1533
1600
  // `tool-call-approval` chunk and the coordinator can consult the
@@ -1731,27 +1798,78 @@ export class MastraHarness {
1731
1798
  await this.addMessages(agentId, threadId, messages);
1732
1799
  }
1733
1800
  /**
1734
- * Replace this thread's session-context object in full. Whole-object set
1735
- * (not a merge); persisted verbatim to the harness-owned sidecar, disjoint
1736
- * from the message transcript — the set object never appears in
1737
- * `getMessages()`. Throws `AGENT_NOT_FOUND` for an unknown agent before any
1738
- * store access.
1801
+ * Replace this thread's consumer session-context object in full (whole-object
1802
+ * set, not a merge), persisted to the harness-owned store — disjoint from
1803
+ * Mastra's Working Memory, so it never races the model's native
1804
+ * `updateWorkingMemory` writes and never surfaces in `getMessages()`.
1805
+ * `getAgentStateOrThrow` first (`AGENT_NOT_FOUND`); then create-first via
1806
+ * {@link assertThreadExists} (the store itself has no thread concept), which
1807
+ * throws `THREAD_NOT_FOUND` for an uncreated thread. Size/depth guards live in
1808
+ * `SessionContextStore.write`.
1739
1809
  */
1740
1810
  async setSessionContext(agentId, threadId, content) {
1741
1811
  this.getAgentStateOrThrow(agentId);
1742
- await this.sessionContext.write(agentId, threadId, content);
1812
+ // Run the create-first check as a write precondition INSIDE the store's
1813
+ // serialize queue so check + write are one critical section against a
1814
+ // concurrent `destroyThread` → `store.delete`; a bare check-then-write
1815
+ // would let the delete land between them and recreate an orphan file.
1816
+ await this.sessionContextStore.write(agentId, threadId, content, () => this.assertThreadExists(agentId, threadId));
1743
1817
  }
1744
1818
  /**
1745
- * This thread's persisted session-context object, or `{}` when nothing has
1746
- * been set (or the sidecar is unparseable). Never `null`/`undefined`, never
1747
- * throws on a miss. Throws `AGENT_NOT_FOUND` for an unknown agent before any
1748
- * store access.
1819
+ * This thread's consumer session-context object read back from the
1820
+ * harness-owned session context store, or `{}` when nothing has been set.
1821
+ * Never `null`/`undefined`, never throws on a miss (corrupt files are
1822
+ * soft-skipped to `{}` and logged inside the store). `getAgentStateOrThrow`
1823
+ * runs first so an unregistered agent surfaces `AGENT_NOT_FOUND` before any
1824
+ * store touch.
1749
1825
  */
1750
1826
  async getSessionContext(agentId, threadId) {
1751
1827
  this.getAgentStateOrThrow(agentId);
1752
- return this.sessionContext.read(agentId, threadId);
1828
+ return this.sessionContextStore.read(agentId, threadId);
1829
+ }
1830
+ /**
1831
+ * Create-first guard for `setSessionContext`: throws typed `THREAD_NOT_FOUND`
1832
+ * when `threadId` was never created *for this agent* (Mastra's `Memory` is the
1833
+ * thread registry). We must enforce the `resourceId` scope ourselves — Mastra's
1834
+ * `getThreadById({ threadId, resourceId })` wrapper (`@mastra/core` 1.55) drops
1835
+ * the `resourceId` arg, so on a shared `Memory` agent A's thread would satisfy
1836
+ * a raw check for agent B. The returned `StorageThreadType` carries
1837
+ * `resourceId`, so we assert it matches (consistent with `getThreadIds`'
1838
+ * scoping). Only reachable when a caller reuses an id across agents; the SDK's
1839
+ * `ChatSession` path always mints UUIDs.
1840
+ */
1841
+ async assertThreadExists(agentId, threadId) {
1842
+ const memory = await this.getMemoryOrThrow(agentId);
1843
+ const thread = await memory.getThreadById({ threadId, resourceId: this.agentResourceId(agentId) });
1844
+ if (thread === null || thread.resourceId !== this.agentResourceId(agentId)) {
1845
+ throw new AgentSDKError(`Cannot set session context on thread "${threadId}": the thread was never created for this agent (create-first contract).`, AgentSDKErrorType.THREAD_NOT_FOUND);
1846
+ }
1753
1847
  }
1754
1848
  // ── Private Helpers ──────────────────────────────────────────────
1849
+ /**
1850
+ * The system text this thread's stored session context would inject on the
1851
+ * next turn — renders via the SAME {@link renderSessionContextSystemText}
1852
+ * `stream()` calls, so this seam observes exactly what a turn would inject.
1853
+ * Not on the `AgentHarness` contract: its only caller is this harness's
1854
+ * conformance seam (`test/conformance` supplies `renderSystemContext`).
1855
+ */
1856
+ async renderSessionContextSystemContext(agentId, threadId) {
1857
+ this.getAgentStateOrThrow(agentId);
1858
+ const context = await this.sessionContextStore.read(agentId, threadId);
1859
+ return renderSessionContextSystemText(context);
1860
+ }
1861
+ /**
1862
+ * Copy a thread's stored session context onto another thread — used by
1863
+ * `compactThread` (onto the compacted thread before the source retires) and
1864
+ * `cloneThread` (Mastra's native clone doesn't know about this store). Skips
1865
+ * the write when the source is `{}`, since a missing file already reads `{}`.
1866
+ */
1867
+ async carrySessionContextForward(agentId, sourceThreadId, targetThreadId) {
1868
+ const content = await this.sessionContextStore.read(agentId, sourceThreadId);
1869
+ if (Object.keys(content).length === 0)
1870
+ return;
1871
+ await this.sessionContextStore.write(agentId, targetThreadId, content);
1872
+ }
1755
1873
  /**
1756
1874
  * Issue a single non-agentic model call (`maxSteps: 1`) for one-shot
1757
1875
  * summarization. Used internally by `compactThread()`; not part of the
@@ -36,8 +36,10 @@ export declare function mapMastraToMessage(msg: MastraDBMessage, logBus?: LogBus
36
36
  export declare function mapMastraPartToMessagePart(part: MastraMessagePart, logBus?: LogBus): MessagePart[];
37
37
  /**
38
38
  * Converts an SDK {@link Message} into the Mastra persisted message format.
39
- * The `role` field is normalised: `'tool'` role (which has no Mastra equivalent)
40
- * is coerced to `'assistant'`.
39
+ * A bare `role: 'tool'` message is PRESERVED as `'tool'` (Mastra's
40
+ * `MastraDBMessage` role union includes `'tool'`), so a hand-authored tool
41
+ * message written via `addContext` reads back as `'tool'` rather than being
42
+ * rewritten to `'assistant'` — the cross-harness bare-`tool` fidelity contract.
41
43
  */
42
44
  export declare function mapMessageToMastra(msg: Message, threadId: string, resourceId: string): MastraDBMessage;
43
45
  /**
@@ -226,8 +226,10 @@ export function mapMastraPartToMessagePart(part, logBus) {
226
226
  }
227
227
  /**
228
228
  * Converts an SDK {@link Message} into the Mastra persisted message format.
229
- * The `role` field is normalised: `'tool'` role (which has no Mastra equivalent)
230
- * is coerced to `'assistant'`.
229
+ * A bare `role: 'tool'` message is PRESERVED as `'tool'` (Mastra's
230
+ * `MastraDBMessage` role union includes `'tool'`), so a hand-authored tool
231
+ * message written via `addContext` reads back as `'tool'` rather than being
232
+ * rewritten to `'assistant'` — the cross-harness bare-`tool` fidelity contract.
231
233
  */
232
234
  export function mapMessageToMastra(msg, threadId, resourceId) {
233
235
  const parts = [];
@@ -254,7 +256,6 @@ export function mapMessageToMastra(msg, threadId, resourceId) {
254
256
  parts.push(mapMessagePartToMastra(part));
255
257
  }
256
258
  }
257
- const role = msg.role === 'tool' ? 'assistant' : msg.role;
258
259
  if (!msg.createdAt) {
259
260
  // Internal contract: callers (the harness's `addContext` and
260
261
  // `compactThread`) backfill `createdAt` before reaching this
@@ -267,7 +268,13 @@ export function mapMessageToMastra(msg, threadId, resourceId) {
267
268
  }
268
269
  return {
269
270
  id: msg.id,
270
- role,
271
+ // Preserve `'tool'` rather than coercing it to `'assistant'`. Mastra's
272
+ // `MastraDBMessage.role` static type is `'user'|'assistant'|'system'|'signal'`
273
+ // (it omits `'tool'`), but LibSQL persists the role verbatim and the read
274
+ // path (`mapMastraToMessage`) maps `'tool'` straight back, so a bare tool
275
+ // message round-trips as `'tool'`. The narrow cast records that the runtime
276
+ // role space is wider than this Mastra version's write-side type.
277
+ role: msg.role,
271
278
  createdAt: msg.createdAt,
272
279
  threadId,
273
280
  resourceId,
@@ -1,39 +1,24 @@
1
1
  import { type LogBus } from '@salesforce/agentic-common';
2
- import type { SessionContext } from '@salesforce/sfdx-agent-sdk';
2
+ import { type JsonValue, type SessionContext } from '@salesforce/sfdx-agent-sdk';
3
3
  /**
4
- * Harness-owned disk persistence for per-thread session context. One JSON file
5
- * per `(agentId, threadId)` under `${storageRootFolder}/session-context/`:
6
- *
7
- * ${storageRootFolder}/session-context/
8
- * <agentId>/
9
- * <threadId>.json ← a single {@link SessionContext} object
10
- *
11
- * A dedicated sidecar channel, disjoint from the message transcript (the
12
- * LibSQL-backed `Memory` store) — the session-context contract requires this
13
- * object NOT appear in `getMessages` output. The store persists the object
14
- * verbatim (the full `JsonValue` union) so a nested / array / null-bearing
15
- * context round-trips unchanged.
16
- *
17
- * **Whole-file rewrite via `.tmp` + `rename`.** Each `write` is a whole-object
18
- * replacement (`setSessionContext` is a whole-object set, not a merge) written
19
- * to a `.tmp` sibling then `rename`d over the target — atomic on POSIX; the
20
- * per-`(agentId, threadId)` in-flight queue handles the Windows
21
- * concurrent-rename EPERM and serializes concurrent read/write cycles on the
22
- * same thread. Mirrors the SDK `AgentIdentityStore` atomic-write shape.
23
- *
24
- * **`{}`-on-miss, never null, never throw on a miss.** `read` returns `{}` when
25
- * nothing was ever set (`ENOENT`) — an empty and a never-set context are
26
- * behaviorally identical, so callers need no null-check. A corrupt file is
27
- * logged (`LogBus.warn`) and treated as empty rather than failing every read
28
- * (the `AgentIdentityStore` soft-skip posture); the corrupt file stays on disk
29
- * and the next `write` overwrites it cleanly.
30
- *
31
- * A deliberate behavioral copy of the OpenAI harness's `SessionContextStore`.
32
- * Each harness keeps its own copy rather than importing a sibling's — harness
33
- * independence is enforced by no harness `package.json` declaring another
34
- * harness as a dependency (the same reason each harness carries its own
35
- * `mcp-error-classifier`). Drift is guarded by the shared
36
- * `runSessionContextConformance` table.
4
+ * Recursively key-sort a JSON value (objects only; arrays keep order) so
5
+ * structurally-equal contexts serialize byte-identically. Rejects objects
6
+ * deeper than {@link MAX_SESSION_CONTEXT_DEPTH} with a typed error instead of a
7
+ * raw stack-overflow `RangeError`. Exported for reuse by the per-turn `system:`
8
+ * renderer in `mastra-harness.ts`.
9
+ */
10
+ export declare function sortJsonValue(value: JsonValue, depth: number): JsonValue;
11
+ /**
12
+ * Harness-owned disk persistence for per-thread session context — one JSON file
13
+ * per `(agentId, threadId)` under `${storageRootFolder}/session-context/`,
14
+ * physically disjoint from Mastra's Working Memory and absent from `getMessages`.
15
+ * As the sole writer of this channel, it shares no value with the model's native
16
+ * `updateWorkingMemory` writes, so the two never race. Whole-object `write` via
17
+ * `.tmp` + `rename` (atomic on POSIX); a per-`(agentId, threadId)` in-flight
18
+ * queue serializes read/write cycles and covers Windows rename EPERM. `read`
19
+ * returns `{}` on miss or corruption (logged), never null, never throws on a
20
+ * miss. A behavioral copy of the Claude / OpenAI harness stores (harnesses can't
21
+ * depend on each other); drift-guarded by `runSessionContextConformance`.
37
22
  */
38
23
  export declare class SessionContextStore {
39
24
  private readonly storageRootFolder;
@@ -47,27 +32,19 @@ export declare class SessionContextStore {
47
32
  */
48
33
  read(agentId: string, threadId: string): Promise<SessionContext>;
49
34
  /**
50
- * Replace this thread's session-context object in full. Persisted verbatim
51
- * so the whole `JsonValue` union survives a round-trip.
52
- */
53
- write(agentId: string, threadId: string, content: SessionContext): Promise<void>;
54
- /**
55
- * Delete this thread's persisted session-context file. Called from thread
56
- * (and agent) teardown so a destroyed thread leaves no orphaned sidecar that
57
- * a same-id recreate would resurrect. ENOENT-tolerant — deleting a thread
58
- * whose context was never set is a no-op, not an error. Runs on the same
59
- * per-thread serialize queue as read/write so it can't race a concurrent
60
- * write.
35
+ * Replace this thread's session-context object in full. The optional
36
+ * `precondition` runs inside the serialize queue just before the write, so a
37
+ * caller's create-first check and the write form one critical section against
38
+ * a concurrent `delete`; a throwing precondition writes nothing.
61
39
  */
40
+ write(agentId: string, threadId: string, content: SessionContext, precondition?: () => Promise<void>): Promise<void>;
41
+ /** Delete this thread's file (ENOENT-tolerant), on the serialize queue so it can't race a write. */
62
42
  delete(agentId: string, threadId: string): Promise<void>;
63
43
  /**
64
- * Delete every persisted session-context file for an agent (its whole
65
- * `session-context/<agentId>/` directory). Called from agent teardown so a
66
- * destroyed agent leaves no orphaned sidecars behind — covers all threads in
67
- * one op without enumerating them. ENOENT-tolerant (an agent that never set
68
- * any context has no directory). Not routed through the per-thread serialize
69
- * queue: agent destruction is terminal, so there is no legitimate concurrent
70
- * per-thread write to order against.
44
+ * Delete an agent's whole `session-context/<agentId>/` directory (ENOENT-tolerant).
45
+ * Drains in-flight per-thread ops first — the per-thread queue can't cover an
46
+ * agent-wide op, so without the drain a queued write could `rename` after the
47
+ * `rm` and leave an orphan dir a recreate would resurrect.
71
48
  */
72
49
  deleteAgent(agentId: string): Promise<void>;
73
50
  private readContext;
@@ -76,12 +53,8 @@ export declare class SessionContextStore {
76
53
  private dirFor;
77
54
  private fileFor;
78
55
  /**
79
- * Per-`(agentId, threadId)` in-flight queue. Ported from the OpenAI
80
- * harness's `SessionContextStore.serialize` — serializes read-mutate-write
81
- * cycles on the same thread so a concurrent `setSessionContext` and
82
- * `getSessionContext` never interleave a read against a half-written file.
83
- * Keyed by the SAME injective encoding as {@link fileFor}, so two ids that
84
- * only differ in punctuation get distinct queues (not one shared queue).
56
+ * Per-`(agentId, threadId)` in-flight queue so read/write cycles on one thread
57
+ * never interleave. Keyed by the same injective encoding as {@link fileFor}.
85
58
  */
86
59
  private serialize;
87
60
  }
@@ -5,42 +5,64 @@
5
5
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { getErrorMessage } from '@salesforce/agentic-common';
8
+ import { AgentSDKError, AgentSDKErrorType } from '@salesforce/sfdx-agent-sdk';
8
9
  const SESSION_CONTEXT_SUBDIR = 'session-context';
9
10
  const FILE_EXT = '.json';
10
11
  /**
11
- * Harness-owned disk persistence for per-thread session context. One JSON file
12
- * per `(agentId, threadId)` under `${storageRootFolder}/session-context/`:
13
- *
14
- * ${storageRootFolder}/session-context/
15
- * <agentId>/
16
- * <threadId>.json ← a single {@link SessionContext} object
17
- *
18
- * A dedicated sidecar channel, disjoint from the message transcript (the
19
- * LibSQL-backed `Memory` store) — the session-context contract requires this
20
- * object NOT appear in `getMessages` output. The store persists the object
21
- * verbatim (the full `JsonValue` union) so a nested / array / null-bearing
22
- * context round-trips unchanged.
23
- *
24
- * **Whole-file rewrite via `.tmp` + `rename`.** Each `write` is a whole-object
25
- * replacement (`setSessionContext` is a whole-object set, not a merge) written
26
- * to a `.tmp` sibling then `rename`d over the target — atomic on POSIX; the
27
- * per-`(agentId, threadId)` in-flight queue handles the Windows
28
- * concurrent-rename EPERM and serializes concurrent read/write cycles on the
29
- * same thread. Mirrors the SDK `AgentIdentityStore` atomic-write shape.
30
- *
31
- * **`{}`-on-miss, never null, never throw on a miss.** `read` returns `{}` when
32
- * nothing was ever set (`ENOENT`) — an empty and a never-set context are
33
- * behaviorally identical, so callers need no null-check. A corrupt file is
34
- * logged (`LogBus.warn`) and treated as empty rather than failing every read
35
- * (the `AgentIdentityStore` soft-skip posture); the corrupt file stays on disk
36
- * and the next `write` overwrites it cleanly.
37
- *
38
- * A deliberate behavioral copy of the OpenAI harness's `SessionContextStore`.
39
- * Each harness keeps its own copy rather than importing a sibling's — harness
40
- * independence is enforced by no harness `package.json` declaring another
41
- * harness as a dependency (the same reason each harness carries its own
42
- * `mcp-error-classifier`). Drift is guarded by the shared
43
- * `runSessionContextConformance` table.
12
+ * Max serialized size (UTF-8 bytes) of a stored context. `write` throws above
13
+ * this rather than letting an oversized object ride into every turn's system
14
+ * context. Generous — context is for identifiers/flags, not payloads.
15
+ */
16
+ const MAX_SESSION_CONTEXT_BYTES = 256 * 1024;
17
+ /** Max object nesting depth `sortJsonValue` will recurse before rejecting. */
18
+ const MAX_SESSION_CONTEXT_DEPTH = 200;
19
+ /**
20
+ * Recursively key-sort a JSON value (objects only; arrays keep order) so
21
+ * structurally-equal contexts serialize byte-identically. Rejects objects
22
+ * deeper than {@link MAX_SESSION_CONTEXT_DEPTH} with a typed error instead of a
23
+ * raw stack-overflow `RangeError`. Exported for reuse by the per-turn `system:`
24
+ * renderer in `mastra-harness.ts`.
25
+ */
26
+ export function sortJsonValue(value, depth) {
27
+ if (depth > MAX_SESSION_CONTEXT_DEPTH) {
28
+ throw new AgentSDKError(`Session context exceeds the maximum nesting depth of ${MAX_SESSION_CONTEXT_DEPTH}.`, AgentSDKErrorType.INVALID_MESSAGE_CONTENT);
29
+ }
30
+ if (Array.isArray(value))
31
+ return value.map((v) => sortJsonValue(v, depth + 1));
32
+ if (value === null || typeof value !== 'object')
33
+ return value;
34
+ // Null-prototype, not `{}`: assigning a `"__proto__"` key (legal in JSON)
35
+ // on a plain object hits the prototype setter and silently drops the key.
36
+ const sorted = Object.create(null);
37
+ for (const key of Object.keys(value).sort()) {
38
+ sorted[key] = sortJsonValue(value[key], depth + 1);
39
+ }
40
+ return sorted;
41
+ }
42
+ /** Canonical (recursively key-sorted) JSON so equal contexts render identically. */
43
+ function canonicalizeSessionContext(value) {
44
+ return JSON.stringify(sortJsonValue(value, 0));
45
+ }
46
+ /** Canonicalize + enforce the size cap; throws `INVALID_MESSAGE_CONTENT` over the limit. */
47
+ function serializeSessionContext(value) {
48
+ const serialized = canonicalizeSessionContext(value);
49
+ const bytes = Buffer.byteLength(serialized, 'utf8');
50
+ if (bytes > MAX_SESSION_CONTEXT_BYTES) {
51
+ throw new AgentSDKError(`Session context is ${bytes} bytes, above the ${MAX_SESSION_CONTEXT_BYTES}-byte limit.`, AgentSDKErrorType.INVALID_MESSAGE_CONTENT);
52
+ }
53
+ return serialized;
54
+ }
55
+ /**
56
+ * Harness-owned disk persistence for per-thread session context — one JSON file
57
+ * per `(agentId, threadId)` under `${storageRootFolder}/session-context/`,
58
+ * physically disjoint from Mastra's Working Memory and absent from `getMessages`.
59
+ * As the sole writer of this channel, it shares no value with the model's native
60
+ * `updateWorkingMemory` writes, so the two never race. Whole-object `write` via
61
+ * `.tmp` + `rename` (atomic on POSIX); a per-`(agentId, threadId)` in-flight
62
+ * queue serializes read/write cycles and covers Windows rename EPERM. `read`
63
+ * returns `{}` on miss or corruption (logged), never null, never throws on a
64
+ * miss. A behavioral copy of the Claude / OpenAI harness stores (harnesses can't
65
+ * depend on each other); drift-guarded by `runSessionContextConformance`.
44
66
  */
45
67
  export class SessionContextStore {
46
68
  storageRootFolder;
@@ -59,33 +81,34 @@ export class SessionContextStore {
59
81
  return this.serialize(agentId, threadId, () => this.readContext(agentId, threadId));
60
82
  }
61
83
  /**
62
- * Replace this thread's session-context object in full. Persisted verbatim
63
- * so the whole `JsonValue` union survives a round-trip.
84
+ * Replace this thread's session-context object in full. The optional
85
+ * `precondition` runs inside the serialize queue just before the write, so a
86
+ * caller's create-first check and the write form one critical section against
87
+ * a concurrent `delete`; a throwing precondition writes nothing.
64
88
  */
65
- async write(agentId, threadId, content) {
66
- await this.serialize(agentId, threadId, () => this.writeContext(agentId, threadId, content));
89
+ async write(agentId, threadId, content, precondition) {
90
+ await this.serialize(agentId, threadId, async () => {
91
+ if (precondition)
92
+ await precondition();
93
+ await this.writeContext(agentId, threadId, content);
94
+ });
67
95
  }
68
- /**
69
- * Delete this thread's persisted session-context file. Called from thread
70
- * (and agent) teardown so a destroyed thread leaves no orphaned sidecar that
71
- * a same-id recreate would resurrect. ENOENT-tolerant — deleting a thread
72
- * whose context was never set is a no-op, not an error. Runs on the same
73
- * per-thread serialize queue as read/write so it can't race a concurrent
74
- * write.
75
- */
96
+ /** Delete this thread's file (ENOENT-tolerant), on the serialize queue so it can't race a write. */
76
97
  async delete(agentId, threadId) {
77
98
  await this.serialize(agentId, threadId, () => this.deleteContext(agentId, threadId));
78
99
  }
79
100
  /**
80
- * Delete every persisted session-context file for an agent (its whole
81
- * `session-context/<agentId>/` directory). Called from agent teardown so a
82
- * destroyed agent leaves no orphaned sidecars behind — covers all threads in
83
- * one op without enumerating them. ENOENT-tolerant (an agent that never set
84
- * any context has no directory). Not routed through the per-thread serialize
85
- * queue: agent destruction is terminal, so there is no legitimate concurrent
86
- * per-thread write to order against.
101
+ * Delete an agent's whole `session-context/<agentId>/` directory (ENOENT-tolerant).
102
+ * Drains in-flight per-thread ops first — the per-thread queue can't cover an
103
+ * agent-wide op, so without the drain a queued write could `rename` after the
104
+ * `rm` and leave an orphan dir a recreate would resurrect.
87
105
  */
88
106
  async deleteAgent(agentId) {
107
+ const prefix = `${encodeSegment(agentId)} `;
108
+ const inFlight = [...this.inflight.entries()]
109
+ .filter(([k]) => k.startsWith(prefix))
110
+ .map(([, p]) => p.catch(() => undefined));
111
+ await Promise.all(inFlight);
89
112
  await rm(this.dirFor(agentId), { recursive: true, force: true });
90
113
  }
91
114
  // ── Internals (called only inside `serialize`) ───────────────────────────
@@ -111,10 +134,7 @@ export class SessionContextStore {
111
134
  });
112
135
  return {};
113
136
  }
114
- // A bare array / scalar / null parsed cleanly but is not the on-disk
115
- // SessionContext object shape — as good as corrupt, so soft-skip to `{}`
116
- // and log it (like the parse-failure branch) rather than discard the file
117
- // silently.
137
+ // Parsed cleanly but not an object shape — treat as corrupt: soft-skip + log.
118
138
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
119
139
  this.logBus?.warn('skipping non-object Mastra session-context file', { file });
120
140
  return {};
@@ -122,10 +142,13 @@ export class SessionContextStore {
122
142
  return parsed;
123
143
  }
124
144
  async writeContext(agentId, threadId, content) {
145
+ // Serialize (canonicalize + size-guard) before touching disk, so a bad
146
+ // object leaves no partial write behind.
147
+ const serialized = serializeSessionContext(content);
125
148
  const file = this.fileFor(agentId, threadId);
126
149
  await mkdir(dirname(file), { recursive: true });
127
150
  const tmp = `${file}.tmp`;
128
- await writeFile(tmp, JSON.stringify(content), 'utf8');
151
+ await writeFile(tmp, serialized, 'utf8');
129
152
  await rename(tmp, file);
130
153
  }
131
154
  async deleteContext(agentId, threadId) {
@@ -140,12 +163,8 @@ export class SessionContextStore {
140
163
  return join(this.dirFor(agentId), `${encodeSegment(threadId)}${FILE_EXT}`);
141
164
  }
142
165
  /**
143
- * Per-`(agentId, threadId)` in-flight queue. Ported from the OpenAI
144
- * harness's `SessionContextStore.serialize` — serializes read-mutate-write
145
- * cycles on the same thread so a concurrent `setSessionContext` and
146
- * `getSessionContext` never interleave a read against a half-written file.
147
- * Keyed by the SAME injective encoding as {@link fileFor}, so two ids that
148
- * only differ in punctuation get distinct queues (not one shared queue).
166
+ * Per-`(agentId, threadId)` in-flight queue so read/write cycles on one thread
167
+ * never interleave. Keyed by the same injective encoding as {@link fileFor}.
149
168
  */
150
169
  serialize(agentId, threadId, fn) {
151
170
  const k = `${encodeSegment(agentId)} ${encodeSegment(threadId)}`;
@@ -160,22 +179,12 @@ export class SessionContextStore {
160
179
  }
161
180
  }
162
181
  /**
163
- * Encode an agent/thread id into a single safe path segment via base64url.
164
- *
165
- * Injective over well-formed strings — distinct ids map to distinct segments —
166
- * for two reasons a lossy `[^A-Za-z0-9._-] → _` collapse got wrong: (1) it
167
- * aliased distinct threads (`a/b` and `a?b` both became `a_b`), so one chat
168
- * could read/overwrite another's session context and share its serialize-queue
169
- * key; (2) it passed `.` / `..` through untouched, so a threadId of `..` escaped
170
- * the `session-context/` dir. base64url's alphabet is `[A-Za-z0-9_-]` — no `.`
171
- * and no `/` — so an encoded segment can never be `..` or contain a separator.
172
- *
173
- * **Caveat — not injective over lone (unpaired) UTF-16 surrogates.** The `utf8`
174
- * transcode replaces an unpaired surrogate with U+FFFD before encoding, so e.g.
175
- * `"\uD800"` and `"\uD801"` collide. A non-issue in practice (agent/thread ids
176
- * are UUIDs / gateway-issued ids, always well-formed); closing it would mean an
177
- * encoding change that re-orphans every existing sidecar. Documented, not
178
- * silently absolute. Byte-identical to the Claude / OpenAI harness copies.
182
+ * Encode an agent/thread id into one safe path segment via base64url. Injective
183
+ * (distinct ids → distinct segments) and traversal-safe: the `[A-Za-z0-9_-]`
184
+ * alphabet has no `.` or `/`, so a segment can never be `..` or contain a
185
+ * separator, and punctuation-only-distinct ids (`a/b` vs `a?b`) never alias.
186
+ * Caveat: lone UTF-16 surrogates transcode to U+FFFD and collide — a non-issue
187
+ * for UUID / gateway-issued ids.
179
188
  */
180
189
  function encodeSegment(s) {
181
190
  return Buffer.from(s, 'utf8').toString('base64url');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -42,12 +42,12 @@
42
42
  "openai": "^7.0.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@salesforce/sfdx-agent-sdk": "0.54.0"
45
+ "@salesforce/sfdx-agent-sdk": "0.55.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@eslint/js": "^10.0.1",
49
49
  "@salesforce/harness-conformance": "0.0.1",
50
- "@salesforce/sfdx-agent-sdk": "0.54.0",
50
+ "@salesforce/sfdx-agent-sdk": "0.55.0",
51
51
  "@types/node": "^22.20.1",
52
52
  "@vitest/coverage-istanbul": "^4.1.10",
53
53
  "@vitest/eslint-plugin": "^1.6.27",