Skip to content

bug: synthetic compaction messages are persisted as real rows, duplicating the summary in every later read #1984

Description

@nechmads

Summary

After a Think session compacts, the synthetic compaction_<recordId> message that getHistory() substitutes for the compacted range gets persisted as a real assistant_messages row on the next turn. From then on getHistory() returns that id twice — once as the synthetic substitution, once as the stored row — permanently, for every read of that session.

Consequences:

  • the compaction summary is sent to the model twice on every subsequent turn (wasted context, duplicated content);
  • clients receive two messages with the same id, which breaks any keyed rendering (React logs Encountered two children with the same key, row:compaction_<uuid>);
  • it compounds — each further compaction creates another synthetic message that gets filed the same way;
  • the filed row becomes a parent link in the message chain, so it cannot simply be deleted without re-parenting its children.

Versions

agents@0.19.0, @cloudflare/think@0.15.0, ai@6.0.210. Think subclass (not AIChatAgent), browser client via useAgentChat from agents/chat/react, WebSocket transport, getInitialMessages: null, resume: true.

Root cause

  1. compact() writes an assistant_compactions row and leaves the underlying messages in place. getHistory() then substitutes a synthetic message for the compacted range — applyCompactions, agents/dist/experimental/memory/session/index.js:1061-1087:

    result.push({
      id: `${COMPACTION_PREFIX}${comp.id}`,
      role: "assistant",
      parts: [{ type: "text", text: comp.summary }],
      createdAt: new Date()
    });

    This message is computed on read and has no row of its own.

  2. It is broadcast to clients like any other message, so it lives in useChat state, and the transport posts the full transcript back on the next turn — agents/dist/chat/react.js:202-206:

    const bodyPayload = JSON.stringify({ messages: options.messages, trigger: options.trigger, ...extraBody });
  3. Think persists every incoming message — @cloudflare/think/dist/think.js:6264-6275:

    const reconciled = reconcileMessages(incomingMessages, serverMessages, sanitizeMessage);
    for (const msg of reconciled) await this._persistIncomingMessage(msg, serverMessages);

    reconcileMessages passes the synthetic message through unchanged (it exact-matches an id present in serverMessages).

  4. _upsertMessageInHistory (think.js:1401-1405) decides insert-vs-update by looking the id up in storage:

    if (await this.session.getMessage(safe.id)) await this.session.updateMessage(safe);
    else await this.session.appendMessage(safe, parentId);

    getMessage reads only assistant_messages (session/index.js:791-796). A synthetic compaction id has no row there, so the else branch runs and appendMessage inserts it as a real row.

Reproduction

Clean thread, Think subclass with the standard useAgentChat browser client. I inspected the Durable Object's SQLite directly at each step (.wrangler/state/v3/do/...).

Step assistant_messages rows with compaction_% id assistant_compactions
Fresh thread, a few short exchanges 10 0 0
One large tool result (~670 KB) → auto-compaction fires 12 0 1
One ordinary user message 15 1 3

That single ordinary message is what files the row. The resulting chain:

c4af8cb8-…                   | assistant | dsDPImLyNTLghz
compaction_bb61da07-3990-4ce | assistant | c4af8cb8-9505-    ← filed as a real row
OLWluQByjgtm24n5             | user      | compaction_bb6    ← now a parent link

Reproduced independently on two separate threads.

Suggested fix

Skip synthetic compaction messages at intake. The repo already ships exactly the predicate needed — isCompactionMessage (agents/dist/compaction-helpers-*.js:58-60, exported from agents/experimental/memory/utils):

function isCompactionMessage(msg) {
  return msg.id.startsWith(COMPACTION_PREFIX);
}

It is currently called in only one place (filtering prior compaction messages out of a range being re-compacted) and not on the persistence path. Guarding _persistIncomingMessage / _upsertMessageInHistory with it would prevent new occurrences.

Two related points worth considering:

  • Existing sessions need repair. Any session that has compacted and taken one more turn already carries the row, and because it is a parent link, a cleanup must re-parent its children rather than delete outright.
  • A defensive guard in appendMessage (reject ids using the reserved compaction_ prefix) would make the invariant hard to violate from any path, not just this one.

Workaround

Filtering the synthetic messages out of the outgoing transcript client-side stops new rows from being created:

useAgentChat({
  agent,
  prepareSendMessagesRequest: ({ messages }) => ({
    body: { messages: messages.filter((m) => !isCompactionMessage(m)) }
  })
});

This works because prepareSendMessagesRequest's body is Object.assigned into extraBody, which the payload spreads after messages:. Verified: with two unfiled synthetic messages pending, one ordinary turn produced zero new rows, and normal message persistence was unaffected.

Related

#1966 raises the server→client direction of the same boundary (keeping server-only context out of client projections). This issue is the reverse: client→server intake persisting a message the server itself generated on read.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions