@salesforce/sfdx-agent-harness-mastra 0.48.0 → 0.49.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.49.0] - 2026-08-17
7
+
8
+ ### Features
9
+ - **agent-sdk,harness-mastra,harness-claude,harness-openai**: session-context contract + conformance gate @W-23632685@ ([#747](https://github.com/forcedotcom/agentic-dx/pull/747))
10
+
6
11
  ## [0.48.0] - 2026-08-14
7
12
 
8
13
  _No changes — released alongside dependent packages._
@@ -1,7 +1,7 @@
1
1
  import { Agent } from '@mastra/core/agent';
2
2
  import { SkillSearchProcessor, ToolSearchProcessor } from '@mastra/core/processors';
3
3
  import { MCPClient } from '@mastra/mcp';
4
- import { McpServerStatus, type AgentHarness, type WithAgentConfig, type ChatStreamResult, type HarnessAgentConfig, type MCPConfiguration, type McpServerErrorDetail, type McpServerInfo, type Message, type MessagePart, type ModelConnectivityInfo, type StreamOptions, type TelemetryEventCallback, type ToolResultInfo, type WireCommunicationEventCallback } from '@salesforce/sfdx-agent-sdk';
4
+ import { McpServerStatus, type AgentHarness, type WithAgentConfig, type ChatStreamResult, type HarnessAgentConfig, type MCPConfiguration, type McpServerErrorDetail, type McpServerInfo, type Message, type MessagePart, type ModelConnectivityInfo, type SessionContext, type StreamOptions, type TelemetryEventCallback, type ToolResultInfo, type WireCommunicationEventCallback } from '@salesforce/sfdx-agent-sdk';
5
5
  import { type AgentHooks } from '@salesforce/sfdx-agent-sdk/harness';
6
6
  import type { MastraLanguageModelBuilderContext } from './index.js';
7
7
  import { MastraApprovalCoordinator } from './mastra-approval-coordinator.js';
@@ -248,6 +248,22 @@ export declare class MastraHarness implements MastraAgentHarness {
248
248
  private readonly memory;
249
249
  private readonly mastra;
250
250
  private readonly buses;
251
+ /**
252
+ * Harness-owned disk persistence for the per-thread session-context channel.
253
+ * A dedicated JSON sidecar disjoint from the LibSQL-backed message store —
254
+ * a `setSessionContext` object MUST NOT appear in `getMessages()` output.
255
+ * Rooted at the same `storageRootFolder` the constructor receives.
256
+ *
257
+ * INTERIM: the session-context spike prescribes Mastra persist this over
258
+ * Working Memory (`memory.updateWorkingMemory` / `getWorkingMemory`, JSON
259
+ * mode — its native, already-enabled channel), not a sidecar. The sidecar
260
+ * ships here so the shared `runSessionContextConformance` persistence gate is
261
+ * green on all three harnesses in this milestone; W-23632686 replaces this
262
+ * store with Working Memory (Claude/OpenAI keep their sidecars). The
263
+ * consumer-visible contract — whole-object set, `{}`-on-miss, absent from
264
+ * `getMessages` — is identical across both, so the swap is internal.
265
+ */
266
+ private readonly sessionContext;
251
267
  /**
252
268
  * Mastra-specific extension surface, typed off `MastraAgentHarness`. The
253
269
  * SDK never reads this; consumers reach through `manager.extensions.mastra`.
@@ -529,7 +545,24 @@ export declare class MastraHarness implements MastraAgentHarness {
529
545
  * - MUST throw an error if the specified `agentId` is not found.
530
546
  * - MUST save context messages without triggering an LLM response.
531
547
  */
548
+ addMessages(agentId: string, threadId: string, messages: Message[]): Promise<void>;
549
+ /** @deprecated Renamed to {@link addMessages}; this delegates verbatim. */
532
550
  addContext(agentId: string, threadId: string, messages: Message[]): Promise<void>;
551
+ /**
552
+ * Replace this thread's session-context object in full. Whole-object set
553
+ * (not a merge); persisted verbatim to the harness-owned sidecar, disjoint
554
+ * from the message transcript — the set object never appears in
555
+ * `getMessages()`. Throws `AGENT_NOT_FOUND` for an unknown agent before any
556
+ * store access.
557
+ */
558
+ setSessionContext(agentId: string, threadId: string, content: SessionContext): Promise<void>;
559
+ /**
560
+ * This thread's persisted session-context object, or `{}` when nothing has
561
+ * been set (or the sidecar is unparseable). Never `null`/`undefined`, never
562
+ * throws on a miss. Throws `AGENT_NOT_FOUND` for an unknown agent before any
563
+ * store access.
564
+ */
565
+ getSessionContext(agentId: string, threadId: string): Promise<SessionContext>;
533
566
  /**
534
567
  * Issue a single non-agentic model call (`maxSteps: 1`) for one-shot
535
568
  * summarization. Used internally by `compactThread()`; not part of the
@@ -15,6 +15,7 @@ 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';
18
19
  import { SKILL_SEARCH_META_TOOL_NAMES, TOOL_SEARCH_META_TOOL_NAMES } from './skill-bridge.js';
19
20
  import { mapMastraToMessage, mapMessageToMastra, partToTranscriptText } from './mastra-message-mapper.js';
20
21
  import { isStandardSchemaWithJSON, standardSchemaToJSONSchema } from '@mastra/schema-compat';
@@ -366,6 +367,22 @@ export class MastraHarness {
366
367
  memory;
367
368
  mastra;
368
369
  buses = new HarnessBusOwner();
370
+ /**
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.
384
+ */
385
+ sessionContext;
369
386
  /**
370
387
  * Mastra-specific extension surface, typed off `MastraAgentHarness`. The
371
388
  * SDK never reads this; consumers reach through `manager.extensions.mastra`.
@@ -426,6 +443,7 @@ export class MastraHarness {
426
443
  storage: this.storage,
427
444
  memory: { shared: this.memory },
428
445
  });
446
+ this.sessionContext = new SessionContextStore(this.storageRootFolder, this.buses.getLogBus());
429
447
  }
430
448
  /**
431
449
  * @requirements
@@ -719,6 +737,10 @@ export class MastraHarness {
719
737
  }));
720
738
  state.skillSearchProcessor?.dispose();
721
739
  await state.mastraWorkspace?.destroy();
740
+ // Remove every session-context sidecar for this agent so teardown leaves
741
+ // no orphaned context behind (per-thread destroyThread isn't called for
742
+ // each thread on agent destruction).
743
+ await this.sessionContext.deleteAgent(agentId);
722
744
  this.mastra.removeAgent(agentId);
723
745
  this.modelConnectivityByAgent.delete(agentId);
724
746
  return this.activeAgents.delete(agentId);
@@ -1172,6 +1194,10 @@ export class MastraHarness {
1172
1194
  }
1173
1195
  const memory = await this.getMemoryOrThrow(agentId);
1174
1196
  await memory.deleteThread(threadId);
1197
+ // Drop the session-context sidecar too, so a destroyed thread leaves no
1198
+ // orphaned context that a same-id recreate would resurrect. Runs on the
1199
+ // store's own serialize queue; ENOENT-tolerant for a never-set thread.
1200
+ await this.sessionContext.delete(agentId, threadId);
1175
1201
  }
1176
1202
  /**
1177
1203
  * @requirements
@@ -1243,7 +1269,7 @@ export class MastraHarness {
1243
1269
  newThreadId = await this.createThread(agentId);
1244
1270
  const userTs = this.clock.now();
1245
1271
  const assistantTs = this.clock.nextAfter(userTs);
1246
- await this.addContext(agentId, newThreadId, [
1272
+ await this.addMessages(agentId, newThreadId, [
1247
1273
  {
1248
1274
  id: this.idGenerator.getUniqueId(),
1249
1275
  role: 'user',
@@ -1598,11 +1624,16 @@ export class MastraHarness {
1598
1624
  perPage: false,
1599
1625
  });
1600
1626
  const logBus = this.buses.getLogBus();
1601
- // Drop Mastra's experimental `'signal'` role (system reminders the agent synthesizes via `createSignal()`,
1602
- // see `@mastra/core/dist/agent/signals.d.ts` — all `@experimental`). The SDK's `MessageRole` exposes only
1603
- // `'system' | 'user' | 'assistant' | 'tool'` and surfacing experimental upstream concepts would leak the
1604
- // harness internals up to consumers. If Mastra promotes signals to stable and we want them, the SDK
1605
- // contract needs to add the role first.
1627
+ // Drop roles with no consumer-visible SDK equivalent before mapping:
1628
+ // - `'signal'` — Mastra's experimental self-synthesized system reminders
1629
+ // (`createSignal()`, see `@mastra/core/dist/agent/signals.d.ts`, all
1630
+ // `@experimental`); surfacing them would leak harness internals up.
1631
+ // - `'system'` — not a member of the SDK's `MessageRole` (`'user' |
1632
+ // 'assistant' | 'tool'`). System state rides `instructions` / session
1633
+ // context, never the transcript, so a persisted `system` row (if one
1634
+ // ever exists) must not surface on `getMessages`.
1635
+ // If Mastra promotes signals to stable and we want them, the SDK contract
1636
+ // needs to add the role first.
1606
1637
  // Per-position fallback for any persisted row whose `createdAt` is
1607
1638
  // null/undefined (legacy rows from before the optional-on-write
1608
1639
  // contract, partial-write recovery, or a libsql schema change).
@@ -1613,7 +1644,9 @@ export class MastraHarness {
1613
1644
  // silently bucket null-timestamp rows at epoch and propagate
1614
1645
  // `undefined` to consumers.
1615
1646
  let lastFallback = this.clock.now();
1616
- const mapped = messages.filter((m) => m.role !== 'signal').map((m) => mapMastraToMessage(m, logBus));
1647
+ const mapped = messages
1648
+ .filter((m) => m.role !== 'signal' && m.role !== 'system')
1649
+ .map((m) => mapMastraToMessage(m, logBus));
1617
1650
  // Hoist every completed tool call's `tool-result` part onto its own
1618
1651
  // `role: 'tool'` message so the consumer-visible layout matches the
1619
1652
  // cross-harness `getMessages` contract (assistant carries the
@@ -1652,7 +1685,7 @@ export class MastraHarness {
1652
1685
  * - MUST throw an error if the specified `agentId` is not found.
1653
1686
  * - MUST save context messages without triggering an LLM response.
1654
1687
  */
1655
- async addContext(agentId, threadId, messages) {
1688
+ async addMessages(agentId, threadId, messages) {
1656
1689
  const memory = await this.getMemoryOrThrow(agentId);
1657
1690
  const resourceId = this.agentResourceId(agentId);
1658
1691
  // Backfill `createdAt` on any caller-supplied message that omits it.
@@ -1674,6 +1707,31 @@ export class MastraHarness {
1674
1707
  const mapped = filled.map((m) => mapMessageToMastra(m, threadId, resourceId));
1675
1708
  await memory.saveMessages({ messages: mapped });
1676
1709
  }
1710
+ /** @deprecated Renamed to {@link addMessages}; this delegates verbatim. */
1711
+ async addContext(agentId, threadId, messages) {
1712
+ await this.addMessages(agentId, threadId, messages);
1713
+ }
1714
+ /**
1715
+ * Replace this thread's session-context object in full. Whole-object set
1716
+ * (not a merge); persisted verbatim to the harness-owned sidecar, disjoint
1717
+ * from the message transcript — the set object never appears in
1718
+ * `getMessages()`. Throws `AGENT_NOT_FOUND` for an unknown agent before any
1719
+ * store access.
1720
+ */
1721
+ async setSessionContext(agentId, threadId, content) {
1722
+ this.getAgentStateOrThrow(agentId);
1723
+ await this.sessionContext.write(agentId, threadId, content);
1724
+ }
1725
+ /**
1726
+ * This thread's persisted session-context object, or `{}` when nothing has
1727
+ * been set (or the sidecar is unparseable). Never `null`/`undefined`, never
1728
+ * throws on a miss. Throws `AGENT_NOT_FOUND` for an unknown agent before any
1729
+ * store access.
1730
+ */
1731
+ async getSessionContext(agentId, threadId) {
1732
+ this.getAgentStateOrThrow(agentId);
1733
+ return this.sessionContext.read(agentId, threadId);
1734
+ }
1677
1735
  // ── Private Helpers ──────────────────────────────────────────────
1678
1736
  /**
1679
1737
  * Issue a single non-agentic model call (`maxSteps: 1`) for one-shot
@@ -4,8 +4,10 @@ import type { LogBus } from '@salesforce/agentic-common';
4
4
  /**
5
5
  * Converts a Mastra persisted message into the SDK's {@link Message} type.
6
6
  *
7
- * Callers must filter out Mastra's experimental `'signal'` role before invoking;
8
- * those messages have no SDK equivalent and must not surface in `Message[]`.
7
+ * Callers must filter out roles with no SDK `Message` equivalent before invoking:
8
+ * Mastra's experimental `'signal'` role and `'system'` (which is not a
9
+ * consumer-visible SDK {@link MessageRole} — system state rides `instructions` /
10
+ * session context, never the transcript). Neither must surface in `Message[]`.
9
11
  *
10
12
  * Parts that have no SDK equivalent (source, file, step-start, and any
11
13
  * unrecognized upstream types including AI SDK v5 `data-*` metadata parts)
@@ -59,8 +59,10 @@ function buildCompletedToolInvocation(toolCallId, toolName, args, result) {
59
59
  /**
60
60
  * Converts a Mastra persisted message into the SDK's {@link Message} type.
61
61
  *
62
- * Callers must filter out Mastra's experimental `'signal'` role before invoking;
63
- * those messages have no SDK equivalent and must not surface in `Message[]`.
62
+ * Callers must filter out roles with no SDK `Message` equivalent before invoking:
63
+ * Mastra's experimental `'signal'` role and `'system'` (which is not a
64
+ * consumer-visible SDK {@link MessageRole} — system state rides `instructions` /
65
+ * session context, never the transcript). Neither must surface in `Message[]`.
64
66
  *
65
67
  * Parts that have no SDK equivalent (source, file, step-start, and any
66
68
  * unrecognized upstream types including AI SDK v5 `data-*` metadata parts)
@@ -69,8 +71,8 @@ function buildCompletedToolInvocation(toolCallId, toolName, args, result) {
69
71
  * message history retrieval.
70
72
  */
71
73
  export function mapMastraToMessage(msg, logBus) {
72
- if (msg.role === 'signal') {
73
- throw new Error("mapMastraToMessage: 'signal' role messages must be filtered out before mapping");
74
+ if (msg.role === 'signal' || msg.role === 'system') {
75
+ throw new Error(`mapMastraToMessage: '${msg.role}' role messages must be filtered out before mapping`);
74
76
  }
75
77
  const parts = [];
76
78
  for (const part of msg.content.parts) {
@@ -0,0 +1,87 @@
1
+ import { type LogBus } from '@salesforce/agentic-common';
2
+ import type { SessionContext } from '@salesforce/sfdx-agent-sdk';
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.
37
+ */
38
+ export declare class SessionContextStore {
39
+ private readonly storageRootFolder;
40
+ private readonly logBus;
41
+ private readonly inflight;
42
+ constructor(storageRootFolder: string, logBus: LogBus | undefined);
43
+ /**
44
+ * This thread's persisted session-context object, or `{}` when nothing has
45
+ * been set (missing file) or the file is unparseable. Never `null`/`undefined`,
46
+ * never throws on a miss.
47
+ */
48
+ read(agentId: string, threadId: string): Promise<SessionContext>;
49
+ /**
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.
61
+ */
62
+ delete(agentId: string, threadId: string): Promise<void>;
63
+ /**
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.
71
+ */
72
+ deleteAgent(agentId: string): Promise<void>;
73
+ private readContext;
74
+ private writeContext;
75
+ private deleteContext;
76
+ private dirFor;
77
+ private fileFor;
78
+ /**
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).
85
+ */
86
+ private serialize;
87
+ }
@@ -0,0 +1,183 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
6
+ import { dirname, join } from 'node:path';
7
+ import { getErrorMessage } from '@salesforce/agentic-common';
8
+ const SESSION_CONTEXT_SUBDIR = 'session-context';
9
+ const FILE_EXT = '.json';
10
+ /**
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.
44
+ */
45
+ export class SessionContextStore {
46
+ storageRootFolder;
47
+ logBus;
48
+ inflight = new Map();
49
+ constructor(storageRootFolder, logBus) {
50
+ this.storageRootFolder = storageRootFolder;
51
+ this.logBus = logBus;
52
+ }
53
+ /**
54
+ * This thread's persisted session-context object, or `{}` when nothing has
55
+ * been set (missing file) or the file is unparseable. Never `null`/`undefined`,
56
+ * never throws on a miss.
57
+ */
58
+ async read(agentId, threadId) {
59
+ return this.serialize(agentId, threadId, () => this.readContext(agentId, threadId));
60
+ }
61
+ /**
62
+ * Replace this thread's session-context object in full. Persisted verbatim
63
+ * so the whole `JsonValue` union survives a round-trip.
64
+ */
65
+ async write(agentId, threadId, content) {
66
+ await this.serialize(agentId, threadId, () => this.writeContext(agentId, threadId, content));
67
+ }
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
+ */
76
+ async delete(agentId, threadId) {
77
+ await this.serialize(agentId, threadId, () => this.deleteContext(agentId, threadId));
78
+ }
79
+ /**
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.
87
+ */
88
+ async deleteAgent(agentId) {
89
+ await rm(this.dirFor(agentId), { recursive: true, force: true });
90
+ }
91
+ // ── Internals (called only inside `serialize`) ───────────────────────────
92
+ async readContext(agentId, threadId) {
93
+ const file = this.fileFor(agentId, threadId);
94
+ let raw;
95
+ try {
96
+ raw = await readFile(file, 'utf8');
97
+ }
98
+ catch (err) {
99
+ if (err.code === 'ENOENT')
100
+ return {};
101
+ throw err;
102
+ }
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(raw);
106
+ }
107
+ catch (err) {
108
+ this.logBus?.warn('skipping unparseable Mastra session-context file', {
109
+ file,
110
+ error: getErrorMessage(err),
111
+ });
112
+ return {};
113
+ }
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.
118
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
119
+ this.logBus?.warn('skipping non-object Mastra session-context file', { file });
120
+ return {};
121
+ }
122
+ return parsed;
123
+ }
124
+ async writeContext(agentId, threadId, content) {
125
+ const file = this.fileFor(agentId, threadId);
126
+ await mkdir(dirname(file), { recursive: true });
127
+ const tmp = `${file}.tmp`;
128
+ await writeFile(tmp, JSON.stringify(content), 'utf8');
129
+ await rename(tmp, file);
130
+ }
131
+ async deleteContext(agentId, threadId) {
132
+ // `force: true` makes a missing file a no-op (no ENOENT throw) — deleting
133
+ // a thread that never had session context set is not an error.
134
+ await rm(this.fileFor(agentId, threadId), { force: true });
135
+ }
136
+ dirFor(agentId) {
137
+ return join(this.storageRootFolder, SESSION_CONTEXT_SUBDIR, encodeSegment(agentId));
138
+ }
139
+ fileFor(agentId, threadId) {
140
+ return join(this.dirFor(agentId), `${encodeSegment(threadId)}${FILE_EXT}`);
141
+ }
142
+ /**
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).
149
+ */
150
+ serialize(agentId, threadId, fn) {
151
+ const k = `${encodeSegment(agentId)} ${encodeSegment(threadId)}`;
152
+ const previous = this.inflight.get(k) ?? Promise.resolve();
153
+ const next = previous.catch(() => undefined).then(fn);
154
+ this.inflight.set(k, next);
155
+ next.finally(() => {
156
+ if (this.inflight.get(k) === next)
157
+ this.inflight.delete(k);
158
+ }).catch(() => undefined);
159
+ return next;
160
+ }
161
+ }
162
+ /**
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.
179
+ */
180
+ function encodeSegment(s) {
181
+ return Buffer.from(s, 'utf8').toString('base64url');
182
+ }
183
+ //# sourceMappingURL=session-context-store.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.48.0",
3
+ "version": "0.49.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.49.0"
45
+ "@salesforce/sfdx-agent-sdk": "0.50.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.49.0",
50
+ "@salesforce/sfdx-agent-sdk": "0.50.0",
51
51
  "@types/node": "^22.20.1",
52
52
  "@vitest/coverage-istanbul": "^4.1.10",
53
53
  "@vitest/eslint-plugin": "^1.6.26",