Assistant: drop mid-run MESSAGES_SNAPSHOT instead of reconciling it
All checks were successful
Deploy to VPS / test (push) Successful in 1m32s
Deploy to VPS / deploy (push) Successful in 20s

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 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 18:56:37 -06:00
parent 965e30a2d2
commit 8f51e33fe0

View File

@@ -250,31 +250,25 @@ class StripModelArtifactsMiddleware extends (Middleware as any) {
} }
} }
// ── ReconcileSnapshotMiddleware ────────────────────────────────────────────── // ── DropRunMessagesSnapshotMiddleware ────────────────────────────────────────
// MAF 1.10 reuses the streamed id for PLAIN-text snapshots, but still mints a // MAF's run-end MESSAGES_SNAPSHOT re-mints ids for post-tool-call text (their
// fresh UUID for post-tool-call assistant text — deliberately, per their // #3619) and, with thread persistence on, rebuilds history under STORE ids.
// issue #3619 ("separate message"). ag-ui's snapshot merge appends unknown // ag-ui's apply treats the snapshot as authoritative: client messages absent
// ids, so tool+text turns still produce a duplicate assistant bubble without // from it are dropped, matching ids are replaced. Because streamed ids and
// this reconciliation (re-verified live on MAF 1.10 / CK 1.62, 2026-07-04). // snapshot ids never agree for tool+text turns, every mid-session snapshot
// Drops only the orphan text-only assistant message with no streamed // either duplicates the current answer (fresh-id append) or wipes earlier
// counterpart. Caveat: interfered with a2ui retry runs (duplicated the user // ones (store-id replace with empty content) — both observed live. During a
// message) — a2ui is disabled while that pipeline matures; revisit both // normal run the client already built the exact turn from streamed events,
// together. // so the snapshot adds nothing: swallow it. Hydration replays (empty
// // input.messages) pass through untouched — there the snapshot IS the
// Thread persistence (2026-07-04) adds two exemptions: a hydration replay // conversation, and the client converges to store ids on every reload.
// (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.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> { run(input: any, next: any): Observable<any> {
const streamedTextIds = new Set<string>(); const isHydration =
// eslint-disable-next-line @typescript-eslint/no-explicit-any !Array.isArray(input?.messages) || input.messages.length === 0;
const inputMessages: any[] = Array.isArray(input?.messages) ? input.messages : [];
const isHydration = inputMessages.length === 0;
const clientKnownIds = new Set<string>(inputMessages.map((m) => String(m?.id ?? "")));
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => { return new Observable<any>((observer) => {
@@ -283,38 +277,9 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => { next: (eventWithState: any) => {
const event = eventWithState.event; const event = eventWithState.event;
const type: string = event?.type ?? ""; if (event?.type === EventType.MESSAGES_SNAPSHOT && !isHydration) {
return; // client state from streaming is already correct
if (type === EventType.TEXT_MESSAGE_START && event?.messageId) {
streamedTextIds.add(String(event.messageId));
} }
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); observer.next(event);
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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") { if (process.env.CK_MIDDLEWARES !== "off") {
agent.use(new SuppressRenderToolTextMiddleware() as any); agent.use(new SuppressRenderToolTextMiddleware() as any);
agent.use(new StripModelArtifactsMiddleware() 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); agent.use(new DeduplicateToolCallMiddleware() as any);
} }
if (process.env.CK_DEBUG_EVENTS === "1" || process.env.CK_MIDDLEWARES === "off") { if (process.env.CK_DEBUG_EVENTS === "1" || process.env.CK_MIDDLEWARES === "off") {