Skip to content

fix(core): prevent duplicate step persistence messages in memory#1123

Merged
omeraplak merged 3 commits intomainfrom
fix/1121-duplicate-memory-message
Mar 3, 2026
Merged

fix(core): prevent duplicate step persistence messages in memory#1123
omeraplak merged 3 commits intomainfrom
fix/1121-duplicate-memory-message

Conversation

@omeraplak
Copy link
Copy Markdown
Member

@omeraplak omeraplak commented Mar 3, 2026

PR Checklist

Please check if your PR fulfills the following requirements:

Bugs / Features

What is the current behavior?

When step-level conversation persistence (conversationPersistence.mode = "step") flushes around tool results, the same assistant response can be persisted multiple times with different message_id values.

This leads to duplicate assistant messages in memory and can trigger downstream provider errors such as duplicate OpenAI reasoning item IDs.

Related issue: #1121

What is the new behavior?

  • Reuse a stable assistant response message ID across step checkpoints.
  • Deduplicate identical step response payloads before they are added to the conversation buffer.
  • Reset the step-response dedupe state on retry/reset paths.
  • Add a regression test that verifies checkpoint flushes reuse the same assistant message ID.

fixes #1121

Notes for reviewers

Validation run locally:

  • cd packages/core && pnpm exec vitest src/agent/step-persistence.spec.ts src/agent/memory-persistence.integration.spec.ts src/agent/conversation-buffer.spec.ts

Changeset added:

  • .changeset/khaki-rivers-fly.md

Summary by cubic

Prevents duplicate assistant messages during step-level persistence by canonicalizing the assistant message ID and deduping repeated responses across flushes. Fixes #1121 and avoids downstream provider errors from duplicate reasoning item IDs.

  • Bug Fixes
    • Canonicalize assistant message IDs across all step checkpoints by overriding per-response IDs with a stable ID.
    • Fingerprint step responses and skip duplicates across intermediate flushes; clear dedupe state on retry/reset.
    • Add regression tests verifying ID reuse through tool-call/result checkpoints.

Written for commit 01bae0f. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Prevents duplicate storage of assistant responses during step checkpoints by ensuring a stable message identity across intermediate flushes and filtering duplicate step response payloads.
  • Tests

    • Added coverage verifying assistant message IDs are reused across intermediate flushes.
  • Refactor

    • Internal memory-persistence integration simplified for more reliable message saving.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 3, 2026

🦋 Changeset detected

Latest commit: 01bae0f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@voltagent/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@joggrbot

This comment has been minimized.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 3, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 00da88f and 01bae0f.

📒 Files selected for processing (2)
  • packages/core/src/agent/agent.ts
  • packages/core/src/agent/step-persistence.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/agent/agent.ts

📝 Walkthrough

Walkthrough

Adds per-operation fingerprinting and stable assistant response message IDs to deduplicate assistant/tool messages across step checkpoints, preventing duplicate persistence during intermediate flushes and ensuring checkpoint flushes update the same memory message. (50 words)

Changes

Cohort / File(s) Summary
Changeset Documentation
\.changeset/khaki-rivers-fly.md
Patch changelog bump documenting the fix for duplicate assistant-message persistence when conversationPersistence.mode = "step".
Step response deduplication
packages/core/src/agent/agent.ts
Adds per-operation fingerprint storage key, getStepResponseFingerprints, normalizeStepResponseMessages, and getOrCreateStepResponseMessageId; integrates normalization into step handling and streaming flows; resets fingerprints on attempt reset to avoid duplicate assistant messages across flushes.
Step persistence tests
packages/core/src/agent/step-persistence.spec.ts
Introduces a logger factory, new test verifying assistant message ID reuse across step checkpoints with intermediate flushes, and updated test helpers/mocks for buffer and persistence wiring.
Memory persist queue typing
packages/core/src/agent/memory-persist-queue.ts
Adds MemoryPersistQueueMemoryManager = Pick<MemoryManager, "saveMessage"> and narrows MemoryPersistQueue constructor to depend on that subset type.

Sequence Diagram(s)

sequenceDiagram
    participant Step as Step Handler
    participant Agent as Agent
    participant Buffer as ConversationBuffer / MemoryPersistQueue
    participant Memory as MemoryManager / DB

    Note over Step,Agent: Step produces response messages (streaming or final)
    Step->>Agent: emit responseMessages
    Agent->>Agent: normalizeStepResponseMessages (fingerprint dedupe, ensure stable messageId)
    Agent->>Buffer: buffer.save(normalizedMessages) (uses stable messageId)
    Buffer->>Memory: saveMessage/upsert (persists single message per stable id)
    Memory-->>Buffer: ack
    Buffer-->>Agent: persistence complete
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐇 I hopped through logs with tiny feet,

Fingerprints found each echoed beat.
One steady id, no duplicates stay,
Checkpoints now tidy, snuggled away.
🥕✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(core): prevent duplicate step persistence messages in memory' clearly and specifically describes the main bug fix of preventing duplicate assistant message persistence during step-level checkpoints.
Description check ✅ Passed The PR description is comprehensive and follows the template, including current/new behavior, linked issues, tests, changesets, and reviewer notes with local validation commands.
Linked Issues check ✅ Passed The PR successfully addresses issue #1121 by implementing stable assistant message IDs across step checkpoints and deduplicating responses, which directly prevents the duplicate message storage bug reported.
Out of Scope Changes check ✅ Passed All changes are focused on fixing the duplicate step persistence issue: message ID stabilization, deduplication logic, test coverage, and fingerprint tracking are all directly related to issue #1121.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/1121-duplicate-memory-message

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Mar 3, 2026

Deploying voltagent with  Cloudflare Pages  Cloudflare Pages

Latest commit: 01bae0f
Status: ✅  Deploy successful!
Preview URL: https://457b874d.voltagent.pages.dev
Branch Preview URL: https://fix-1121-duplicate-memory-me.voltagent.pages.dev

View logs

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/core/src/agent/step-persistence.spec.ts">

<violation number="1" location="packages/core/src/agent/step-persistence.spec.ts:267">
P2: The test can pass even when assistant message IDs are missing (`undefined`), so it does not fully verify stable ID reuse.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/core/src/agent/step-persistence.spec.ts (1)

145-148: Extract the new test harness setup to reuse the existing createHarness() helper or define type-safe mock interfaces.

The new test case (lines 133–161) duplicates harness initialization with as any casts on lines 145, 147, and 159. Either reuse the existing createHarness() helper or define narrow local test interfaces (e.g., Pick<MemoryManager, 'saveMessage'>) instead of casting to any to maintain type safety per coding guidelines.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/agent/step-persistence.spec.ts` around lines 145 - 148, The
test duplicates a harness setup and uses unsafe as any casts for memoryManager
and oc.logger when instantiating MemoryPersistQueue; replace this by reusing the
existing createHarness() helper to produce a properly typed memoryManager and
logger, or create narrow mock types (e.g., const memoryManager:
Pick<MemoryManager, 'saveMessage' | 'loadMessages'> = { saveMessage: jest.fn(),
loadMessages: jest.fn() } and a typed logger) and pass those into new
MemoryPersistQueue so you can remove all as any casts and keep the test
type-safe; update references to MemoryPersistQueue, createHarness,
memoryManager, and logger accordingly.
packages/core/src/agent/agent.ts (1)

3667-3671: Use stable ID for assistant message deduplication to handle provider ID variance.

At line 3669, assistant message fingerprints include provider-emitted IDs. The Vercel AI SDK does not guarantee assistant message IDs remain stable across intermediate steps—they can change per step during streaming. If the same assistant content arrives with different provider IDs across steps, it won't dedupe.

Apply the suggested adjustment to use fallbackAssistantMessageId for assistant message fingerprints, ensuring deduplication works correctly regardless of upstream ID changes:

Suggested adjustment
       const fingerprint = safeStringify({
         role: normalizedMessage.role,
-        id: (normalizedMessage as { id?: unknown }).id ?? null,
+        id:
+          normalizedMessage.role === "assistant"
+            ? fallbackAssistantMessageId
+            : ((normalizedMessage as { id?: unknown }).id ?? null),
         content: normalizedMessage.content,
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/agent/agent.ts` around lines 3667 - 3671, The fingerprint
currently includes provider-emitted IDs from normalizedMessage.id which can
change during streaming; update the fingerprint logic in the agent message
dedupe to use fallbackAssistantMessageId when normalizedMessage.role ===
'assistant' (i.e., set id = (normalizedMessage.role === 'assistant' ?
(normalizedMessage as any).fallbackAssistantMessageId : (normalizedMessage as {
id?: unknown }).id) ?? null) so assistant messages use the stable
fallbackAssistantMessageId for deduplication while other roles keep their id or
null; update references in the fingerprint creation (function/variable names:
fingerprint, normalizedMessage, fallbackAssistantMessageId) accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/core/src/agent/agent.ts`:
- Around line 3667-3671: The fingerprint currently includes provider-emitted IDs
from normalizedMessage.id which can change during streaming; update the
fingerprint logic in the agent message dedupe to use fallbackAssistantMessageId
when normalizedMessage.role === 'assistant' (i.e., set id =
(normalizedMessage.role === 'assistant' ? (normalizedMessage as
any).fallbackAssistantMessageId : (normalizedMessage as { id?: unknown }).id) ??
null) so assistant messages use the stable fallbackAssistantMessageId for
deduplication while other roles keep their id or null; update references in the
fingerprint creation (function/variable names: fingerprint, normalizedMessage,
fallbackAssistantMessageId) accordingly.

In `@packages/core/src/agent/step-persistence.spec.ts`:
- Around line 145-148: The test duplicates a harness setup and uses unsafe as
any casts for memoryManager and oc.logger when instantiating MemoryPersistQueue;
replace this by reusing the existing createHarness() helper to produce a
properly typed memoryManager and logger, or create narrow mock types (e.g.,
const memoryManager: Pick<MemoryManager, 'saveMessage' | 'loadMessages'> = {
saveMessage: jest.fn(), loadMessages: jest.fn() } and a typed logger) and pass
those into new MemoryPersistQueue so you can remove all as any casts and keep
the test type-safe; update references to MemoryPersistQueue, createHarness,
memoryManager, and logger accordingly.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7f228b9 and 13f4f40.

📒 Files selected for processing (3)
  • .changeset/khaki-rivers-fly.md
  • packages/core/src/agent/agent.ts
  • packages/core/src/agent/step-persistence.spec.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/core/src/agent/step-persistence.spec.ts (1)

173-175: Avoid any in the new step-handler test signature.

event: any drops compile-time guarantees in the new regression path; prefer a typed fixture/interface (or StepResult<ToolSet>-compatible shape).

As per coding guidelines, "**/*.ts: Maintain type safety in TypeScript-first codebase".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/agent/step-persistence.spec.ts` around lines 173 - 175, The
test uses an untyped handler signature: replace the `any` types in the handler
declaration created from (agent as any).createStepHandler(oc, undefined) so the
test asserts compile-time safety; change the handler variable signature to the
concrete event/result type expected by createStepHandler (for example a
StepResult<ToolSet>-compatible interface or the specific StepEvent/StepResult
type used across the agent), update the cast from (agent as any) to a narrower
type if needed, and ensure the parameter signature for handler is strongly typed
instead of `event: any` so the test keeps TypeScript guarantees.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/agent/agent.ts`:
- Around line 3661-3665: The code only normalizes assistant IDs when
rawMessageId is missing, which allows provider-assigned assistant IDs to vary;
change the logic in the normalizedMessage assignment so that whenever
responseMessage.role === "assistant" you always return ({ ...responseMessage,
id: fallbackAssistantMessageId } as ModelMessage) regardless of rawMessageId,
otherwise keep responseMessage as-is; update the expression that constructs
normalizedMessage (referencing normalizedMessage, responseMessage, rawMessageId,
and fallbackAssistantMessageId) to enforce this unconditional override for
assistant messages.

In `@packages/core/src/agent/step-persistence.spec.ts`:
- Around line 146-287: Update the test "reuses assistant message id across step
checkpoints after intermediate flushes" to simulate the regression by supplying
explicit, different assistant message IDs across the three handler invocations
(e.g., "m1", "m2", "m3") in the response payloads so the code exercises the path
where the same payload is persisted with changing message_id values;
specifically, modify the three handler calls' response.messages[0] objects to
include an id field with unique values, keep the saveMessage mock and the
persistedAssistantIds extraction/assertions unchanged, and ensure the final
assertions still verify that persisted assistant IDs are all strings and
deduplicate to a single canonical id as before.

---

Nitpick comments:
In `@packages/core/src/agent/step-persistence.spec.ts`:
- Around line 173-175: The test uses an untyped handler signature: replace the
`any` types in the handler declaration created from (agent as
any).createStepHandler(oc, undefined) so the test asserts compile-time safety;
change the handler variable signature to the concrete event/result type expected
by createStepHandler (for example a StepResult<ToolSet>-compatible interface or
the specific StepEvent/StepResult type used across the agent), update the cast
from (agent as any) to a narrower type if needed, and ensure the parameter
signature for handler is strongly typed instead of `event: any` so the test
keeps TypeScript guarantees.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 13f4f40 and 00da88f.

📒 Files selected for processing (3)
  • packages/core/src/agent/agent.ts
  • packages/core/src/agent/memory-persist-queue.ts
  • packages/core/src/agent/step-persistence.spec.ts

@omeraplak omeraplak merged commit 527f2cf into main Mar 3, 2026
23 checks passed
@omeraplak omeraplak deleted the fix/1121-duplicate-memory-message branch March 3, 2026 19:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]The message was stored multiple times.

1 participant