Skip to content

AIChatAgent: assistant messages duplicated in SQLite on every saveMessages() round-trip #957

@alexander-zuev

Description

@alexander-zuev

Issue

Every assistant message gets stored twice in the DO's SQLite — once with the server-generated assistant_* ID, once with the client's nanoid ID.

After 3 user messages, SQLite has 8 rows (should be 6):

rowid  id                                 role       text
-----  ---------------------------------  ---------  --------------------------------------------------
1      MOhQOEEhu3YnXgsx                   user
2      assistant_1771621652018_po6druuea   assistant  Hello! How can I help you today?
3      RJHGHXxn1iP0pOi7                   assistant  Hello! How can I help you today?          ← DUP
4      kwSt140SvyNPigp5                   user
5      assistant_1771621789109_s9rq2j0va   assistant  Sure! Everything seems to be working fine...
6      q01cA3vA2iT0g8yT                   assistant  Sure! Everything seems to be working fine... ← DUP
7      mFvYne67dIJ4YJjx                   user
8      assistant_1771621793779_ka5a8yav8   assistant  Yes, I'm doing great...

Rows 2+3 and 5+6 have identical content, different IDs. Row 8 not yet duplicated (no subsequent `saveMessages()` round-trip).

Root cause

The server generates `assistant_*` IDs for assistant messages. The client (`useAgentChat`) independently generates nanoid IDs for the same messages during SSE streaming. When the client calls `saveMessages()`, both IDs arrive as separate rows.

The existing dedup mechanisms (`_mergeIncomingWithServerState` and `_resolveMessageForToolMerge`) only reconcile by `toolCallId` — plain text assistant messages have no `toolCallId`, so they pass through unmatched and get `INSERT`'d as duplicates.

Proposed fix

Option A: Client uses server-generated assistant_* ID from the SSE stream instead of generating its own nanoid.

Option B: Client stops sending assistant messages back via saveMessages() entirely. The server already has them — it generated them. Only new user messages need to round-trip.

Either fix eliminates the need for content-based reconciliation.

Reproduction

Minimal repro (code based on Build a Chat Agent):

https://github.com/alexander-zuev/ai-chat-message-duplication-repro

git clone https://github.com/alexander-zuev/ai-chat-message-duplication-repro
cd ai-chat-message-duplication-repro
npm install
npx wrangler dev
# Open http://localhost:8787, send 3+ messages, then:
sqlite3 .wrangler/state/v3/do/chat-agent-ChatAgent/*.sqlite \
  "SELECT rowid, id, json_extract(message, '$.role') AS role, \
   substr(json_extract(message, '$.parts[1].text'), 1, 60) AS text \
   FROM cf_ai_chat_agent_messages ORDER BY rowid;"

Workaround

Override `persistMessages()` to match client nanoid IDs to server `assistant_*` IDs by content:

override async persistMessages(messages: UIMessage[], excludeBroadcastIds?: string[]) {
  const serverContentToId = new Map<string, string>();
  for (const msg of this.messages) {
    if (msg.role === 'assistant' && msg.id.startsWith('assistant_')) {
      serverContentToId.set(JSON.stringify(msg.parts), msg.id);
    }
  }

  const reconciled = messages.map((msg) => {
    if (msg.role !== 'assistant' || msg.id.startsWith('assistant_')) return msg;
    const serverId = serverContentToId.get(JSON.stringify(msg.parts));
    return serverId ? { ...msg, id: serverId } : msg;
  });

  return super.persistMessages(reconciled, excludeBroadcastIds);
}

Versions

  • `@cloudflare/ai-chat`: 0.1.3
  • `agents`: 0.5.1
  • `ai`: 6.0.96
  • `wrangler`: 4.67.0

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions