From 8f51e33fe06065fae21ebbc0cd716877143f9ecc Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sat, 4 Jul 2026 18:56:37 -0600 Subject: [PATCH] Assistant: drop mid-run MESSAGES_SNAPSHOT instead of reconciling it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod bug: earlier assistant replies vanished as new turns completed and only came back on refresh. Debug capture showed why: with persistence on, the run-end snapshot rebuilds history under STORE ids while the client holds it under STREAMED ids (MAF re-mints post-tool-call text ids, their #3619). ag-ui apply treats the snapshot as authoritative — matching ids get replaced (streamed text wiped by the store's empty-content toolCall entry) and the store's text copies were being filtered out by ReconcileSnapshotMiddleware as presumed duplicates, so prior answers had no surviving representation. Reconciling id-mismatched snapshots is unwinnable; skip them instead. During a normal run the client already built the turn from streamed events and the snapshot adds nothing. Hydration replays (empty input.messages) still pass through — there the snapshot IS the conversation, and every reload converges the client to store ids. ReconcileSnapshotMiddleware is replaced by the much simpler DropRunMessagesSnapshotMiddleware. Verified in dev: multi-turn history persists across runs, one card that survives follow-ups, reload replays everything, no duplicates. Co-Authored-By: Claude Fable 5 --- frontend/server.ts | 71 ++++++++++++---------------------------------- 1 file changed, 18 insertions(+), 53 deletions(-) diff --git a/frontend/server.ts b/frontend/server.ts index 90d1925..39df9b2 100644 --- a/frontend/server.ts +++ b/frontend/server.ts @@ -250,31 +250,25 @@ class StripModelArtifactsMiddleware extends (Middleware as any) { } } -// ── ReconcileSnapshotMiddleware ────────────────────────────────────────────── -// MAF 1.10 reuses the streamed id for PLAIN-text snapshots, but still mints a -// fresh UUID for post-tool-call assistant text — deliberately, per their -// issue #3619 ("separate message"). ag-ui's snapshot merge appends unknown -// ids, so tool+text turns still produce a duplicate assistant bubble without -// this reconciliation (re-verified live on MAF 1.10 / CK 1.62, 2026-07-04). -// Drops only the orphan text-only assistant message with no streamed -// counterpart. Caveat: interfered with a2ui retry runs (duplicated the user -// message) — a2ui is disabled while that pipeline matures; revisit both -// together. -// -// Thread persistence (2026-07-04) adds two exemptions: a hydration replay -// (empty input.messages) is passed through untouched — nothing in it was -// streamed this run — and messages whose id the client already sent in -// input.messages are history, not duplicates. +// ── DropRunMessagesSnapshotMiddleware ──────────────────────────────────────── +// MAF's run-end MESSAGES_SNAPSHOT re-mints ids for post-tool-call text (their +// #3619) and, with thread persistence on, rebuilds history under STORE ids. +// ag-ui's apply treats the snapshot as authoritative: client messages absent +// from it are dropped, matching ids are replaced. Because streamed ids and +// snapshot ids never agree for tool+text turns, every mid-session snapshot +// either duplicates the current answer (fresh-id append) or wipes earlier +// ones (store-id replace with empty content) — both observed live. During a +// normal run the client already built the exact turn from streamed events, +// so the snapshot adds nothing: swallow it. Hydration replays (empty +// input.messages) pass through untouched — there the snapshot IS the +// conversation, and the client converges to store ids on every reload. // eslint-disable-next-line @typescript-eslint/no-explicit-any -class ReconcileSnapshotMiddleware extends (Middleware as any) { +class DropRunMessagesSnapshotMiddleware extends (Middleware as any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any run(input: any, next: any): Observable { - const streamedTextIds = new Set(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const inputMessages: any[] = Array.isArray(input?.messages) ? input.messages : []; - const isHydration = inputMessages.length === 0; - const clientKnownIds = new Set(inputMessages.map((m) => String(m?.id ?? ""))); + const isHydration = + !Array.isArray(input?.messages) || input.messages.length === 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any return new Observable((observer) => { @@ -283,38 +277,9 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any next: (eventWithState: any) => { const event = eventWithState.event; - const type: string = event?.type ?? ""; - - if (type === EventType.TEXT_MESSAGE_START && event?.messageId) { - streamedTextIds.add(String(event.messageId)); + if (event?.type === EventType.MESSAGES_SNAPSHOT && !isHydration) { + return; // client state from streaming is already correct } - - if (type === EventType.MESSAGES_SNAPSHOT && !isHydration) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const msgs: any[] = Array.isArray(event?.messages) ? event.messages : []; - const filtered = msgs.filter((m) => { - if (m?.role !== "assistant") return true; - const id = String(m?.id ?? ""); - const hasText = typeof m?.content === "string" && m.content.length > 0; - const hasToolCalls = - (Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) || - (Array.isArray(m?.tool_calls) && m.tool_calls.length > 0); - if ( - hasText && - !hasToolCalls && - !streamedTextIds.has(id) && - !clientKnownIds.has(id) - ) { - return false; // drop orphan text-only assistant duplicate - } - return true; - }); - if (filtered.length !== msgs.length) { - observer.next({ ...event, messages: filtered }); - return; - } - } - observer.next(event); }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -433,7 +398,7 @@ app.all("/api/copilotkit/*", async (c) => { if (process.env.CK_MIDDLEWARES !== "off") { agent.use(new SuppressRenderToolTextMiddleware() as any); agent.use(new StripModelArtifactsMiddleware() as any); - agent.use(new ReconcileSnapshotMiddleware() as any); + agent.use(new DropRunMessagesSnapshotMiddleware() as any); agent.use(new DeduplicateToolCallMiddleware() as any); } if (process.env.CK_DEBUG_EVENTS === "1" || process.env.CK_MIDDLEWARES === "off") {