Fix optimistic user message reconciliation

This commit is contained in:
Mohamed Boudra
2026-05-26 11:37:13 +07:00
parent 483790d8c9
commit a3071b46a4
4 changed files with 37 additions and 22 deletions
@@ -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],
});
@@ -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 }
: {}),
};
}
+13 -6
View File
@@ -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],
],
);
});
+14 -6
View File
@@ -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 }
: {}),
};
}