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
-
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.
-
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 });
-
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).
-
_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.
Summary
After a Think session compacts, the synthetic
compaction_<recordId>message thatgetHistory()substitutes for the compacted range gets persisted as a realassistant_messagesrow on the next turn. From then ongetHistory()returns that id twice — once as the synthetic substitution, once as the stored row — permanently, for every read of that session.Consequences:
id, which breaks any keyed rendering (React logsEncountered two children with the same key, row:compaction_<uuid>);Versions
agents@0.19.0,@cloudflare/think@0.15.0,ai@6.0.210. Think subclass (notAIChatAgent), browser client viauseAgentChatfromagents/chat/react, WebSocket transport,getInitialMessages: null,resume: true.Root cause
compact()writes anassistant_compactionsrow 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:This message is computed on read and has no row of its own.
It is broadcast to clients like any other message, so it lives in
useChatstate, and the transport posts the full transcript back on the next turn —agents/dist/chat/react.js:202-206:Think persists every incoming message —
@cloudflare/think/dist/think.js:6264-6275:reconcileMessagespasses the synthetic message through unchanged (it exact-matches an id present inserverMessages)._upsertMessageInHistory(think.js:1401-1405) decides insert-vs-update by looking the id up in storage:getMessagereads onlyassistant_messages(session/index.js:791-796). A synthetic compaction id has no row there, so theelsebranch runs andappendMessageinserts it as a real row.Reproduction
Clean thread,
Thinksubclass with the standarduseAgentChatbrowser client. I inspected the Durable Object's SQLite directly at each step (.wrangler/state/v3/do/...).assistant_messagescompaction_%idassistant_compactionsThat single ordinary message is what files the row. The resulting chain:
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 fromagents/experimental/memory/utils):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/_upsertMessageInHistorywith it would prevent new occurrences.Two related points worth considering:
appendMessage(reject ids using the reservedcompaction_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:
This works because
prepareSendMessagesRequest'sbodyisObject.assigned intoextraBody, which the payload spreads aftermessages:. 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.