From a3071b46a4987349e8a17f5d32f8f54de7f975f2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 26 May 2026 11:37:13 +0700 Subject: [PATCH] Fix optimistic user message reconciliation --- .../timeline/session-stream-reducers.test.ts | 3 ++- .../src/timeline/session-stream-reducers.ts | 17 ++++++++-------- packages/app/src/types/stream.test.ts | 19 ++++++++++++------ packages/app/src/types/stream.ts | 20 +++++++++++++------ 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 496ac6c96..ed7b2c33a 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -326,7 +326,7 @@ describe("processTimelineResponse", () => { ...makeTimelineEntry(1, "Analyze this", "user_message"), item: { type: "user_message", - text: "Analyze this", + text: "server-rendered attachment text", messageId: "canonical-create-user", }, }, @@ -339,6 +339,7 @@ describe("processTimelineResponse", () => { expect(userMessages[0]).toMatchObject({ id: "canonical-create-user", text: "Analyze this", + timestamp: new Date(1000), images: [image], attachments: [attachment], }); diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 0f1c07f38..184bfdde2 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -295,16 +295,15 @@ function mergeCanonicalUserWithOptimistic( canonical: UserMessageItem, optimistic: UserMessageItem, ): UserMessageItem { - const shouldPreserveImages = - (!canonical.images || canonical.images.length === 0) && - Boolean(optimistic.images && optimistic.images.length > 0); - const shouldPreserveAttachments = - (!canonical.attachments || canonical.attachments.length === 0) && - Boolean(optimistic.attachments && optimistic.attachments.length > 0); return { - ...canonical, - ...(shouldPreserveImages ? { images: optimistic.images } : {}), - ...(shouldPreserveAttachments ? { attachments: optimistic.attachments } : {}), + kind: "user_message", + id: canonical.id, + text: optimistic.text, + timestamp: optimistic.timestamp, + ...(optimistic.images && optimistic.images.length > 0 ? { images: optimistic.images } : {}), + ...(optimistic.attachments && optimistic.attachments.length > 0 + ? { attachments: optimistic.attachments } + : {}), }; } diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index abd7b2512..e6fe1ed5e 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -724,6 +724,7 @@ describe("stream reducer canonical tool calls", () => { it("preserves optimistic user message images when authoritative user message arrives", () => { const messageId = "msg-user-images"; + const optimisticTimestamp = new Date("2025-01-01T11:10:00Z"); const optimisticImages = [ { id: "att-optimistic", @@ -738,7 +739,7 @@ describe("stream reducer canonical tool calls", () => { kind: "user_message", id: messageId, text: "Analyze this image", - timestamp: new Date("2025-01-01T11:10:00Z"), + timestamp: optimisticTimestamp, optimistic: true, images: optimisticImages, }, @@ -760,7 +761,8 @@ describe("stream reducer canonical tool calls", () => { assert.ok(message); assert.strictEqual(message.id, messageId); assert.deepStrictEqual(message.images, optimisticImages); - assert.strictEqual(message.timestamp.getTime(), authoritativeTimestamp.getTime()); + assert.strictEqual(message.text, "Analyze this image"); + assert.strictEqual(message.timestamp.getTime(), optimisticTimestamp.getTime()); }); it("keeps canonical assistant/user/assistant order during replay", () => { @@ -991,7 +993,9 @@ describe("turn lifecycle events", () => { const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); assert.strictEqual(userMessage.id, "provider-owned-id"); - assert.strictEqual(userMessage.text, "server-owned rendered text"); + assert.strictEqual(userMessage.text, optimistic.text); + assert.strictEqual(userMessage.timestamp.getTime(), optimistic.timestamp.getTime()); + assert.strictEqual(userMessage.optimistic, undefined); assert.deepStrictEqual(userMessage.images, optimistic.images); assert.deepStrictEqual(userMessage.attachments, optimistic.attachments); }, @@ -1029,6 +1033,7 @@ describe("turn lifecycle events", () => { invariant(userMessage?.kind === "user_message"); assert.strictEqual(userMessage.id, "msg_opencode_provider_owned"); assert.strictEqual(userMessage.text, "typed plain text"); + assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime()); assert.strictEqual(userMessage.optimistic, undefined); }); @@ -1062,7 +1067,7 @@ describe("turn lifecycle events", () => { provider: "claude", item: { type: "user_message", - text: "Analyze this", + text: "server-rendered attachment text", messageId: "provider-owned-canonical", }, }, @@ -1075,6 +1080,8 @@ describe("turn lifecycle events", () => { const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); assert.strictEqual(userMessage.id, "provider-owned-canonical"); + assert.strictEqual(userMessage.text, "Analyze this"); + assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime()); assert.strictEqual(userMessage.optimistic, undefined); assert.deepStrictEqual(userMessage.images, [image]); assert.deepStrictEqual(userMessage.attachments, [attachment]); @@ -1212,8 +1219,8 @@ describe("turn lifecycle events", () => { assert.deepStrictEqual( userMessages.map((item) => [item.id, item.text, item.optimistic]), [ - ["provider-owned-first", "first server text", undefined], - ["provider-owned-second", "second server text", undefined], + ["provider-owned-first", "first typed text", undefined], + ["provider-owned-second", "second typed text", undefined], ], ); }); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index d6c0b36c9..7bb394137 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -201,18 +201,26 @@ function buildUserMessageItem(input: { timestamp: Date; optimistic?: UserMessageItem | null; }): UserMessageItem { - const preservedImages = input.optimistic?.images; - const preservedAttachments = input.optimistic?.attachments; + if (input.optimistic) { + return { + kind: "user_message", + id: input.id, + text: input.optimistic.text, + timestamp: input.optimistic.timestamp, + ...(input.optimistic.images && input.optimistic.images.length > 0 + ? { images: input.optimistic.images } + : {}), + ...(input.optimistic.attachments && input.optimistic.attachments.length > 0 + ? { attachments: input.optimistic.attachments } + : {}), + }; + } return { kind: "user_message", id: input.id, text: input.text, timestamp: input.timestamp, - ...(preservedImages && preservedImages.length > 0 ? { images: preservedImages } : {}), - ...(preservedAttachments && preservedAttachments.length > 0 - ? { attachments: preservedAttachments } - : {}), }; }