cloudflare / cloudflare/agents
bug: synthetic compaction messages are persisted as real rows, duplicating the summary in every later read
- Dominant language
- TypeScript
- Stars
- 5.6k
- Forks
- 711
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 53
Description
## Summary
After a Think session compacts, the synthetic `compaction_` 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_`);
- 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`:
```js
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`:
```js
const bodyPayload = JSON.stringify({ messages: options.messages, trigger: options.trigger, ...extraBody });
```
3. Think persists **every** incoming message — `@cloudflare/think/dist/think.js:6264-6275`:
```js
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:
```js
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`):
```js
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:
```ts
useAgentChat({
agent,
prepareSendMessagesRequest: ({ messages }) => ({
body: { messages: messages.filter((m) => !isCompactionMessage(m)) }
})
});
```
This works because `prepareSendMessagesRequest`'s `body` is `Object.assign`ed 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.
Contributor guide
Assessment
This issue has not been assessed yet.