Skip to content

Fix/1984 synthetic compaction - #2036

Merged
threepointone merged 7 commits into
cloudflare:mainfrom
justanotherbyte:fix/1984-synthetic-compaction
Sep 29, 2026
Merged

threepointone merged 7 commits into
cloudflare:mainfrom
justanotherbyte:fix/1984-synthetic-compaction

Conversation

@justanotherbyte

@justanotherbyte justanotherbyte commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1984

Original fix by @justanotherbyte. Maintainer update: the files this PR originally edited (experimental/memory/session/{session,providers/agent,providers/postgres}.ts) were removed from main in #2196, so the same fix has been rebuilt on the new packages/agents/src/sessions/ code. Main has been merged in; no rebase and no force-push.

What was wrong

After a session compacts, history reads substitute a synthetic compaction_<recordId> message for the compacted range. That message is computed on read and has no row of its own. The browser transport posts the whole transcript back on the next turn, and the overlay's createdAt is regenerated on every read, so after any cache rebuild the echoed copy no longer matches the server's copy byte for byte. It then went through upsertMessage into appendMessage and was stored as a real row. From then on every read returned the summary twice: the model saw it twice, clients got duplicate React keys, and each further compaction added another copy. The stray row also became the parent of the next message.

How it's fixed

  • Write guard (sessions/handle.ts): appendMessage, upsertMessage, updateMessage and the internal sync upsert drop any message whose id starts with compaction_. Nothing is written and no change event is sent. It does not console.warn, because the echo happens normally after every compaction. importMessage stays verbatim on purpose: a copied transcript can have later rows parented to a stray row, and dropping it would cut them off.
  • Read filter (sessions/core.ts): repairs sessions already affected, including stray rows copied forward by the legacy assistant_* migration, on read and with no migration. A stored compaction_<id> row is hidden only when <id> names one of this session's own compaction records, meaning it duplicates an overlay the session already renders. Path reads (getHistory, history() in both directions, getRecentHistory, getHistoryRowStats, the auto-compaction token estimate) walk through hidden rows without returning them, so a hidden row's child takes its place. getBranches lists a hidden row's children under its parent in seq order, getLatestLeaf never returns a hidden row, and search skips hidden rows. The record lookup runs only for ids with the prefix, so healthy sessions read no extra rows.
  • Imported summaries stay visible. A compaction_ row with no matching record, such as a summary brought in by the documented history() → importMessage() cross-object move, is the only copy of that context and is shown as ordinary content.
  • Path cap: the 10,000-row walk cap counts stored rows, hidden rows included. getRecentHistory().truncated is judged from the unfiltered walk, so a hidden row inside a capped path can't hide the truncation.
  • This composes with the reconciliation changes from fix(chat): reconcile reused tool-call IDs one-to-one #2040: an echoed overlay still reconciles to its own compaction_ id, and the guard sits below reconciliation in Sessions, so it is dropped whichever path it takes. Hosts other than Think get the same protection.

Review feedback (@cjol)

  • Changeset: added (.changeset/compaction-overlay-echo.md, patch for agents and @cloudflare/think).
  • No warning on the happy path: the guard is silent.
  • Postgres provider test: no longer applies, because the Postgres provider does not exist on main any more (removed in feat(agents): move sessions into a Lifecycle capability #2196).
  • Child of a stray row is re-parented correctly: tested for a stray row stored in the current schema and for one lifted from the legacy tables. The child appears in place in every read, in both directions, in getBranches of the stray row's parent, and as the latest leaf. A branch from the child also walks through the hidden row.

Tests

  • packages/agents/src/tests/sessions/capability.test.ts: write-guard test (every write aperture, no events, the next message attaches to the real leaf) and a test for a stray row plus its re-parented child.
  • packages/agents/src/tests/sessions/migration.test.ts: a legacy stray row plus child is hidden after migration, re-parented, and excluded from search.
  • packages/think/src/tests/think-session.test.ts: an overlay echoed at Think's intake is not stored (the contributor's original test).
  • capability.test.ts (review follow-ups): a history() → importMessage() round trip keeps the summary; a hidden row's children are listed among their siblings in seq order; a 10,000+ row path holding a hidden row reports truncated: true.
  • Each new test fails without the fix and passes with it.

Known limits

  • Stray rows already stored stay in storage. They are hidden on read, not deleted.
  • The write guard still refuses every compaction_ id on appendMessage/upsertMessage/updateMessage, so an imported summary can't be edited through those calls afterwards.
  • getMessage(id) still returns a stray row if asked for it directly by id.
  • The overlay's createdAt is still regenerated on each read. That's harmless now, but a stable timestamp would let Think skip the echo before reaching Sessions. Possible follow-up.

@changeset-bot

changeset-bot Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3703678

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

This PR includes changesets to release 3 packages
Name Type
agents Patch
@cloudflare/think Patch
@cloudflare/agent-think 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

…loudflare#1984)

getHistory() synthesizes a compaction_<id> overlay on read; when a client echoed it back it was filed as a real row, duplicating the summary in every later read. Session now refuses to persist reserved compaction_ ids, and applyCompactions filters such rows so existing sessions self-heal on read.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread .changeset/reconnect-optimistic-send.md Outdated
Comment on lines +1 to +5
---
"agents": patch
---

Fix `useAgentChat` dropping an in-flight optimistic send on reconnect. When a message was sent while the socket was down, PartySocket buffers the frame for delivery, but the idle-connect transcript the server replays on reconnect (`cf_agent_chat_messages`) doesn't include it yet — so the whole-array `setMessages` replace erased the just-sent message from the UI until the turn completed and the server rebroadcast. The reconnect replay now preserves a trailing local-only `user` message that was buffered while disconnected, while still letting the server snapshot win for a delivered send it deliberately rolls back (e.g. `messageConcurrency: "drop"`) and for a regenerate's assistant replacement. Also fixes `@cloudflare/ai-chat` and `@cloudflare/think`, which re-export the hook unchanged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Compaction duplication fix ships without a release note, so users never get it

The bug fix that stops compaction summaries being stored and duplicated is added to a published package without an accompanying changeset entry (only .changeset/reconnect-optimistic-send.md exists, which describes an unrelated reconnect fix), so the fix is not released or documented.
Impact: The fix may not be published in a version bump and won't appear in the changelog users read.

Repository rule: changesets required for package bug fixes

AGENTS.md ("Contributing → Changesets") states: "Changes to packages/ that affect the public API or fix bugs need a changeset". Commit ffaafdc4 changes packages/agents/src/experimental/memory/session/session.ts:624-635, packages/agents/src/experimental/memory/session/providers/agent.ts:535-541 and packages/agents/src/experimental/memory/session/providers/postgres.ts:269-272 — a user-visible bug fix (#1984) — but adds no .changeset/*.md. The only changeset in the PR (.changeset/reconnect-optimistic-send.md:1-5) documents the useAgentChat reconnect fix from the other commit.

Prompt for agents
The PR contains two independent fixes but only one changeset. Add a second changeset markdown file under .changeset/ (patch bump for the "agents" package) describing the #1984 fix: Session no longer persists messages using the reserved compaction_ id prefix, and both the SQLite (AgentSessionProvider) and Postgres session providers filter compaction-prefixed rows out of the read projection so previously corrupted sessions self-heal on read.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Did you remove the changeset? It’s important to include one otherwise the change won’t be released when we merge.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

oh wait I now see that was an unrelated changeset. Can you add a new one for this change @justanotherbyte ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Added .changeset/compaction-overlay-echo.md (patch for agents and @cloudflare/think) describing the #1984 fix.

@justanotherbyte
justanotherbyte force-pushed the fix/1984-synthetic-compaction branch from ffaafdc to 33b7b89 Compare August 4, 2026 15:45
@pkg-pr-new

pkg-pr-new Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2036

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2036

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2036

hono-agents

npm i https://pkg.pr.new/hono-agents@2036

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2036

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2036

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2036

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2036

commit: 3703678

cjol
cjol previously requested changes Aug 6, 2026

@cjol cjol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall shape is good, thank you! Definitely need a changeset, a few nits otherwise in comments.

Comment on lines +630 to +635
if (isCompactionMessage(message)) {
console.warn(
`[Session] Refusing to persist a message with the reserved compaction id "${message.id}" — synthetic compaction overlays are computed on read and are never stored (#1984).`
);
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this necessary? Receiving a compaction message from the browser is expected, so we’re logging a warning under normal, happy-path conditions (potentially even on every single turn after a compaction?)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Removed: the guard now lives in sessions/handle.ts (this file was deleted on main in #2196) and drops echoed compaction_ messages silently, with no warning.

expect(compactions).toHaveLength(1);
});

it("does not persist synthetic compaction overlays echoed back by a client (#1984)", async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For completeness, it might be good to add a test for the postgres provider as well

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The Postgres provider no longer exists on main (removed in #2196), so there is nothing to test there; the fix now lives in the single sessions/ store.

// Simulate an already-corrupted session: a synthetic overlay was filed as
// a real row on a prior turn (before the intake guard existed), parented
// into the live chain via the raw insert seam.
await agent.rawInsertChildForTest("m5", overlay!.id);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For completeness, I might test that a child of the overlay message is restored and re-parented correctly

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Added: capability.test.ts and migration.test.ts now check that a stray overlay row's child shows up in its place in history (both directions), in getBranches of the parent, and as the latest leaf.

@threepointone

Copy link
Copy Markdown
Contributor

I checked this against current main. The bug is still there:

  1. sessions/overlays.ts overlayMessage gives the compaction_<id> message a new createdAt on every read.
  2. So after any cache rebuild (a wake, for example), the client's echoed overlay no longer matches the server's copy byte for byte in _persistIncomingMessage.
  3. It then goes through upsertMessage, then appendMessage, and is inserted as a real row. Nothing on the write path rejects the compaction_ prefix.

This needs a full rewrite rather than a rebase. All three files it edits (experimental/memory/session/{session,providers/agent,providers/postgres}.ts) were removed in #2196, and the Postgres provider no longer exists. On main, the fix would be:

  • A write guard in SessionHandle.appendMessage / upsertMessage, or earlier in Think's intake, that drops an echoed overlay silently. Per @cjol's earlier review, this should not console.warn, because the echo is normal after every compaction.
  • A read-side filter in SessionCore. The legacy migration in core.ts copies old rows forward, so sessions that already have stray overlay rows still need cleaning.
  • A test for @cjol's other request: a child of a stray overlay row is re-parented correctly.
  • A changeset.

threepointone and others added 3 commits September 29, 2026 17:12
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	packages/agents/src/experimental/memory/session/providers/agent.ts
#	packages/agents/src/experimental/memory/session/providers/postgres.ts
#	packages/agents/src/experimental/memory/session/session.ts
#	packages/agents/src/tests/experimental/memory/session/provider.test.ts
#	packages/agents/src/tests/experimental/memory/session/session.test.ts
Co-authored-by: Cursor <cursoragent@cursor.com>
devin-ai-integration[bot]

This comment was marked as resolved.

@agent-think

agent-think Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

🟡 agents import sizes: 1 entry point grew

Entry point Exports Largest gzip change Size now
🟡 agents/sessions 1 resized +400 B (+3.94%) 10.3 KiB
Changed exports (1)
Import Gzip change Size now
🟡 agents/sessions#Sessions +400 B (+3.94%) 10.3 KiB
How this works

Each runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement.

Compared 2afe0e0b → 37036784 · workflow run · reported by agent-think[bot]

…'s overlays

Address review: imported history() summaries stay visible (no matching
compaction record), getBranches keeps seq order when flattening a hidden
row's children, and getRecentHistory judges the depth cap from the
unfiltered walk.

Co-authored-by: Cursor <cursoragent@cursor.com>
devin-ai-integration[bot]

This comment was marked as resolved.

…ed rows

Address review: #pathIds bound an unqualified id to the compactions
table, so newest-first replay kept stored overlay echoes; tokenEstimate
recorded the visible row count as the memo depth, so appends past the
walk cap kept counting the dropped oldest row.

Co-authored-by: Cursor <cursoragent@cursor.com>
@threepointone
threepointone dismissed cjol’s stale review September 29, 2026 17:35

Addressed in the rework: the Postgres provider was removed from main (so the dialect concern no longer applies), and the fix now filters on read + guards writes, with the test cases requested. Dismissing so this can land; happy to follow up on anything further.

@threepointone
threepointone merged commit 040db5c into cloudflare:main Sep 29, 2026
16 checks passed
@github-actions github-actions Bot mentioned this pull request Oct 2, 2026
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: synthetic compaction messages are persisted as real rows, duplicating the summary in every later read

3 participants