@salesforce/sfdx-agent-harness-mastra 0.90.0 → 0.91.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.91.0] - 2026-09-24
7
+
8
+ ### Features
9
+ - **agent-sdk,harness-mastra,harness-openai,harness-claude**: stable chatId across compaction — compact in place, never rotate the thread id @W-24153851@ ([#840](https://github.com/forcedotcom/agentic-dx/pull/840))
10
+
6
11
  ## [0.90.0] - 2026-09-23
7
12
 
8
13
  ### Fixes
@@ -464,17 +464,17 @@ export declare class MastraHarness implements MastraAgentHarness {
464
464
  /**
465
465
  * @requirements
466
466
  * - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
467
- * - MUST create a new thread seeded with an LLM-generated summary.
467
+ * - MUST compact the thread IN PLACE, keeping the same `threadId` (a SQLite
468
+ * row PK) so `getId()` never changes across compaction (W-24153851).
468
469
  * - MUST chunk-summarize transcripts that exceed `MAX_TRANSCRIPT_CHARS`
469
470
  * to keep any single model call inside the context window.
470
- * - MUST surface summarization or seeding failures as `AgentSDKError`
471
- * with type `COMPACTION_FAILED` and leave the source thread intact on
472
- * failure.
473
- * - MUST destroy the source thread on success so it no longer appears in
474
- * `getThreadIds()`. Cleanup of the source thread is best-effort: if the
475
- * delete fails the new thread is still returned, since the user's data
476
- * has already been persisted there. An orphaned source thread is
477
- * hygiene, not correctness.
471
+ * - MUST surface summarization or commit failures as `AgentSDKError`
472
+ * with type `COMPACTION_FAILED` and leave the transcript intact on failure.
473
+ * - MUST be fail-closed: summarize FIRST (read-only), then
474
+ * append-the-summary-then-delete-the-originals (append-then-delete-by-
475
+ * captured-id, NOT a re-recall). LibSQL has no atomic multi-row swap, so a
476
+ * crash after the append leaves BOTH the summary and the originals
477
+ * (retryable, larger-but-valid) — never an empty thread.
478
478
  * - MUST seed the summary as a user/assistant turn pair where the user
479
479
  * turn carries the summary content and the assistant turn is a brief
480
480
  * acknowledgment. Storing the summary as an assistant turn (even with
@@ -483,7 +483,7 @@ export declare class MastraHarness implements MastraAgentHarness {
483
483
  * it as fabricated, refusing to use the facts on follow-up turns.
484
484
  * User turns are treated as authoritative input.
485
485
  */
486
- compactThread(agentId: string, threadId: string): Promise<string>;
486
+ compactThread(agentId: string, threadId: string): Promise<void>;
487
487
  /**
488
488
  * Best-effort wrapper around `destroyThread`: swallows the error so the
489
489
  * caller's outer flow isn't aborted by a teardown blip, but logs the
@@ -1429,17 +1429,17 @@ export class MastraHarness {
1429
1429
  /**
1430
1430
  * @requirements
1431
1431
  * - MUST throw an error if the specified `agentId` is not found in `activeAgents`.
1432
- * - MUST create a new thread seeded with an LLM-generated summary.
1432
+ * - MUST compact the thread IN PLACE, keeping the same `threadId` (a SQLite
1433
+ * row PK) so `getId()` never changes across compaction (W-24153851).
1433
1434
  * - MUST chunk-summarize transcripts that exceed `MAX_TRANSCRIPT_CHARS`
1434
1435
  * to keep any single model call inside the context window.
1435
- * - MUST surface summarization or seeding failures as `AgentSDKError`
1436
- * with type `COMPACTION_FAILED` and leave the source thread intact on
1437
- * failure.
1438
- * - MUST destroy the source thread on success so it no longer appears in
1439
- * `getThreadIds()`. Cleanup of the source thread is best-effort: if the
1440
- * delete fails the new thread is still returned, since the user's data
1441
- * has already been persisted there. An orphaned source thread is
1442
- * hygiene, not correctness.
1436
+ * - MUST surface summarization or commit failures as `AgentSDKError`
1437
+ * with type `COMPACTION_FAILED` and leave the transcript intact on failure.
1438
+ * - MUST be fail-closed: summarize FIRST (read-only), then
1439
+ * append-the-summary-then-delete-the-originals (append-then-delete-by-
1440
+ * captured-id, NOT a re-recall). LibSQL has no atomic multi-row swap, so a
1441
+ * crash after the append leaves BOTH the summary and the originals
1442
+ * (retryable, larger-but-valid) — never an empty thread.
1443
1443
  * - MUST seed the summary as a user/assistant turn pair where the user
1444
1444
  * turn carries the summary content and the assistant turn is a brief
1445
1445
  * acknowledgment. Storing the summary as an assistant turn (even with
@@ -1449,49 +1449,54 @@ export class MastraHarness {
1449
1449
  * User turns are treated as authoritative input.
1450
1450
  */
1451
1451
  async compactThread(agentId, threadId) {
1452
- this.getAgentStateOrThrow(agentId);
1453
- const messages = await this.getMessages(agentId, threadId);
1454
- if (messages.length === 0) {
1455
- const newThreadId = await this.createThread(agentId);
1456
- // An empty transcript can still carry session context (it's set
1457
- // independently of messages), so carry it forward here too — before
1458
- // the source is retired, and fail-closed (source left intact on a
1459
- // copy failure) to match the main path's COMPACTION_FAILED contract.
1460
- try {
1461
- await this.carrySessionContextForward(agentId, threadId, newThreadId);
1462
- }
1463
- catch (err) {
1464
- // The new thread was already created; a carry-forward failure
1465
- // orphans it (never returned, never retired). Delete it before
1466
- // surfacing COMPACTION_FAILED so a failed compaction leaves no
1467
- // trace beyond the still-intact source thread.
1468
- await this.destroyThreadBestEffort(agentId, newThreadId);
1469
- throw new AgentSDKError(`Failed to compact thread "${threadId}" for agent "${agentId}": ${getErrorMessage(err)}`, AgentSDKErrorType.COMPACTION_FAILED, { cause: err });
1470
- }
1471
- // Empty source still gets compacted away — leaving it attached
1472
- // would be a leak. Mirrors the non-empty path's destroyThread
1473
- // call below; both paths exit with the source detached.
1474
- await this.destroyThreadBestEffort(agentId, threadId);
1475
- return newThreadId;
1452
+ const agentState = this.getAgentStateOrThrow(agentId);
1453
+ // Defense-in-depth against a direct-SDK caller: refuse to rewrite the
1454
+ // transcript out from under an in-flight turn. The SDK's one-turn guard
1455
+ // (auto path) and the service's 409 on an explicit /compact during a turn
1456
+ // already exclude this upstream. Fail fast rather than dispose a live
1457
+ // coordinator (that surfaces a spurious DISPOSED — W-24044572). Mirrors
1458
+ // stream()'s guard.
1459
+ const priorCoordinator = agentState.coordinators.get(threadId);
1460
+ if (priorCoordinator && !priorCoordinator.isSettled()) {
1461
+ throw new AgentSDKError(`A turn is already in progress for thread "${threadId}" on agent "${agentId}". ` +
1462
+ 'Compaction cannot run while a turn is active; await the in-flight turn first.', AgentSDKErrorType.SESSION_BUSY);
1476
1463
  }
1464
+ const messages = await this.getMessages(agentId, threadId);
1465
+ // Empty source: nothing to compact. In-place keeps the same threadId and
1466
+ // its (physically-disjoint) session-context store, so this is a no-op.
1467
+ if (messages.length === 0)
1468
+ return;
1477
1469
  const transcript = messages
1478
1470
  .map((m) => {
1479
1471
  const text = typeof m.content === 'string' ? m.content : m.content.map((p) => partToTranscriptText(p)).join(' ');
1480
1472
  return `${m.role}: ${text}`;
1481
1473
  })
1482
1474
  .join('\n');
1483
- // `undefined` until `createThread` succeeds, so the catch can tell a
1484
- // pre-create failure (nothing to clean up) from a post-create failure
1485
- // (the new thread is orphaned and must be deleted).
1486
- let newThreadId;
1487
1475
  try {
1476
+ // Capture the ORIGINAL persisted (raw) message ids to delete after
1477
+ // the summary is appended. Raw recall ids — not `getMessages()`'s
1478
+ // post-mapped ids, which split a tool call into two SDK messages —
1479
+ // are what `memory.deleteMessages` keys on. Captured before the
1480
+ // append so the summary pair's freshly-minted ids are never in the
1481
+ // delete set.
1482
+ const memory = await this.getMemoryOrThrow(agentId);
1483
+ const { messages: rawMessages } = await memory.recall({
1484
+ threadId,
1485
+ resourceId: this.agentResourceId(agentId),
1486
+ perPage: false,
1487
+ });
1488
+ const originalIds = rawMessages.map((m) => m.id);
1489
+ // Summarize FIRST (read-only). No mutation has happened yet, so a
1490
+ // summary failure leaves the transcript fully intact.
1488
1491
  const summaryText = transcript.length <= MAX_TRANSCRIPT_CHARS
1489
1492
  ? await this.summarize(agentId, buildSummaryPrompt(transcript))
1490
1493
  : await this.summarizeInChunks(agentId, transcript);
1491
- newThreadId = await this.createThread(agentId);
1494
+ // Append the summary pair to the SAME thread, THEN delete the
1495
+ // originals by captured id. A crash between the two leaves both
1496
+ // (retryable), never an empty thread.
1492
1497
  const userTs = this.clock.now();
1493
1498
  const assistantTs = this.clock.nextAfter(userTs);
1494
- await this.addMessages(agentId, newThreadId, [
1499
+ await this.addMessages(agentId, threadId, [
1495
1500
  {
1496
1501
  id: this.idGenerator.getUniqueId(),
1497
1502
  role: 'user',
@@ -1505,26 +1510,18 @@ export class MastraHarness {
1505
1510
  createdAt: assistantTs,
1506
1511
  },
1507
1512
  ]);
1508
- // Carry the source thread's session context onto the compacted thread
1509
- // before the source is retired. Inside the try so a copy failure is
1510
- // fail-closed (surfaces as COMPACTION_FAILED, source left intact).
1511
- await this.carrySessionContextForward(agentId, threadId, newThreadId);
1513
+ if (originalIds.length > 0) {
1514
+ await memory.deleteMessages(originalIds);
1515
+ }
1512
1516
  }
1513
1517
  catch (err) {
1514
- // If the failure came after `createThread`, the new thread is
1515
- // orphaned (never returned to the caller, source not yet retired).
1516
- // Delete it so a failed compaction leaves no trace beyond the
1517
- // still-intact source thread.
1518
- if (newThreadId !== undefined) {
1519
- await this.destroyThreadBestEffort(agentId, newThreadId);
1520
- }
1521
1518
  throw new AgentSDKError(`Failed to compact thread "${threadId}" for agent "${agentId}": ${getErrorMessage(err)}`, AgentSDKErrorType.COMPACTION_FAILED, { cause: err });
1522
1519
  }
1523
- // Source-thread cleanup is best-effort. The user's data is already
1524
- // safely seeded into newThreadId; a failed delete leaves an orphan
1525
- // but does not invalidate the compaction outcome.
1526
- await this.destroyThreadBestEffort(agentId, threadId);
1527
- return newThreadId;
1520
+ // Reset the per-thread consumer-tool retry counter — the rotation-based
1521
+ // compaction used to get this for free via destroyThread; an in-place
1522
+ // compaction keeps the thread, so reset it explicitly. The transcript the
1523
+ // counts described is gone.
1524
+ this.toolArgsRetryCounts.get(agentId)?.delete(threadId);
1528
1525
  }
1529
1526
  /**
1530
1527
  * Best-effort wrapper around `destroyThread`: swallows the error so the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.90.0",
3
+ "version": "0.91.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.17.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@salesforce/sfdx-agent-sdk": "0.91.0"
45
+ "@salesforce/sfdx-agent-sdk": "0.92.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.91.0",
50
+ "@salesforce/sfdx-agent-sdk": "0.92.0",
51
51
  "@types/node": "^22.20.1",
52
52
  "@vitest/coverage-istanbul": "^5.0.1",
53
53
  "@vitest/eslint-plugin": "^1.6.27",