From a863c993569d805fa0877aa7e62a3f99664f33e5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 29 May 2026 23:01:00 -0700 Subject: [PATCH] fix(copilot): make user-message persistence idempotent on message_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit persistUserMessage appended to copilot_chats.messages unconditionally (messages || [userMsg]::jsonb) with no check that the id already exists. A repeated POST for the same userMessageId — network retry, double submit, React double-effect, or a bypassed/expired send lock (Redis-down no-op) — therefore tacked on a duplicate array entry. The assistant append in terminal-state.ts is already guarded; this brings the user append to parity. (Evidence: 100% of duplicated message_ids in prod are role=user.) Guard the append with a containment check so it only concatenates when the array doesn't already hold a message with this id: CASE WHEN messages @> [{id}] THEN messages ELSE messages || [userMsg] END The client mints a fresh id per distinct send (verified across all hook revisions), so a repeated id is always the same message — skipping is safe and never drops a distinct message. conversationId/updatedAt stay unconditional so the stream marker still advances on a retry. The dual-write to copilot_messages is already idempotent (ON CONFLICT). Verified on Postgres: same id -> stays 1 element; different id -> 2. Co-Authored-By: Claude Opus 4.8 --- apps/sim/lib/copilot/chat/post.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 9c1d5950d0..62a5746aed 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -326,10 +326,19 @@ async function persistUserMessage(params: { contexts, }) + // Append the user message idempotently: only concatenate when the array + // doesn't already contain a message with this id. A repeated id here is + // always a retry/double-submit of the same message (the client mints a + // fresh id per distinct send), so skipping the append avoids duplicate + // entries while keeping the stream marker / updatedAt current. const [updated] = await db .update(copilotChats) .set({ - messages: sql`${copilotChats.messages} || ${JSON.stringify([userMsg])}::jsonb`, + messages: sql`CASE + WHEN ${copilotChats.messages} @> ${JSON.stringify([{ id: userMessageId }])}::jsonb + THEN ${copilotChats.messages} + ELSE ${copilotChats.messages} || ${JSON.stringify([userMsg])}::jsonb + END`, conversationId: userMessageId, updatedAt: new Date(), })