fix(app): keep submitted prompts in timeline order (#2259)

Canonical provider message IDs and optimistic client IDs occupy different namespaces. Preserve both identities for deterministic reconciliation while retaining a dated content fallback for older daemon timelines.
This commit is contained in:
Mohamed Boudra
2026-07-20 17:29:52 +02:00
committed by GitHub
parent 07d988488e
commit e1bda8e498
22 changed files with 240 additions and 42 deletions
@@ -538,7 +538,7 @@ describe("processTimelineResponse", () => {
expect(result.error).toBe(null);
});
it("reconciles an optimistic user message during an after-page response", () => {
it("reconciles a legacy canonical user message by content during an after-page response", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
@@ -559,7 +559,7 @@ describe("processTimelineResponse", () => {
item: {
type: "user_message",
text: "sent while catching up",
messageId: "optimistic-after",
messageId: "canonical-after",
},
},
],
@@ -568,7 +568,42 @@ describe("processTimelineResponse", () => {
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toHaveLength(1);
expect(userMessages[0]?.id).toBe("optimistic-after");
expect(userMessages[0]?.id).toBe("canonical-after");
expect(userMessages[0]?.optimistic).toBeUndefined();
});
it("reconciles an optimistic user message by client message id", () => {
const optimistic = makeOptimisticUserMessage("local presentation", "client-message");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [optimistic],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [
{
...makeTimelineEntry(2, "provider presentation", "user_message"),
item: {
type: "user_message",
text: "provider presentation",
messageId: "provider-message",
clientMessageId: "client-message",
},
},
],
},
});
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toEqual([
expect.objectContaining({
id: "provider-message",
clientMessageId: "client-message",
text: "local presentation",
}),
]);
expect(userMessages[0]?.optimistic).toBeUndefined();
});
@@ -1178,7 +1213,7 @@ describe("processTimelineResponse", () => {
]);
});
it("does not match equal prompt text when canonical message ids differ", () => {
it("does not match equal prompt text when canonical client message ids differ", () => {
const prompt = makeOptimisticUserMessage("continue", "local-prompt");
const result = processTimelineResponse({
@@ -1197,6 +1232,7 @@ describe("processTimelineResponse", () => {
type: "user_message",
text: "continue",
messageId: "remote-prompt",
clientMessageId: "remote-client-prompt",
},
},
],
@@ -312,6 +312,7 @@ function mergeCanonicalUserWithLocalPresentation(
return {
kind: "user_message",
id: canonical.id,
...(canonical.clientMessageId ? { clientMessageId: canonical.clientMessageId } : {}),
text: local.text,
timestamp: local.timestamp,
...(local.images && local.images.length > 0 ? { images: local.images } : {}),
@@ -321,6 +322,27 @@ function mergeCanonicalUserWithLocalPresentation(
};
}
interface CanonicalUserMessageIdentity {
messageId?: string;
clientMessageId?: string;
text: string;
}
function matchesOptimisticUserMessageIdentity(
canonical: CanonicalUserMessageIdentity,
optimistic: UserMessageItem,
): boolean {
if (canonical.clientMessageId !== undefined) {
return canonical.clientMessageId === optimistic.id;
}
if (canonical.messageId === optimistic.id) {
return true;
}
// COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once
// the supported daemon floor emits clientMessageId on submitted user messages.
return canonical.text.length > 0 && canonical.text === optimistic.text;
}
function reconcileLocalUserPresentationAfterReplace(params: {
canonicalTail: StreamItem[];
previousTail: StreamItem[];
@@ -352,6 +374,21 @@ function reconcileLocalUserPresentationAfterReplace(params: {
return false;
}
if (local.item.optimistic) {
const canonical = params.canonicalTail[index];
if (canonical?.kind !== "user_message") {
return false;
}
const identityMatches = matchesOptimisticUserMessageIdentity(
{
messageId: canonical.id,
clientMessageId: canonical.clientMessageId,
text: canonical.text,
},
local.item,
);
if (canonical.clientMessageId !== undefined || identityMatches) {
return identityMatches;
}
return ordinal >= local.ordinal;
}
return params.canonicalTail[index]?.id === local.item.id;
@@ -773,10 +810,7 @@ function matchesOptimisticUserMessage(params: {
if (event.type !== "timeline" || event.item.type !== "user_message") {
return false;
}
if (event.item.messageId !== undefined) {
return event.item.messageId === params.optimistic.id;
}
return event.item.text.length > 0 && event.item.text === params.optimistic.text;
return matchesOptimisticUserMessageIdentity(event.item, params.optimistic);
}
function acknowledgeOptimisticUserMessage(params: {
+9 -1
View File
@@ -86,6 +86,7 @@ export type UserMessageImageAttachment = AttachmentMetadata;
export interface UserMessageItem {
kind: "user_message";
id: string;
clientMessageId?: string;
text: string;
timestamp: Date;
optimistic?: true;
@@ -238,6 +239,7 @@ function markThoughtReady(item: ThoughtItem): ThoughtItem {
function buildUserMessageItem(input: {
id: string;
clientMessageId?: string;
text: string;
timestamp: Date;
optimistic?: UserMessageItem | null;
@@ -246,6 +248,7 @@ function buildUserMessageItem(input: {
return {
kind: "user_message",
id: input.id,
...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}),
text: input.optimistic.text,
timestamp: input.optimistic.timestamp,
...(input.optimistic.images && input.optimistic.images.length > 0
@@ -260,6 +263,7 @@ function buildUserMessageItem(input: {
return {
kind: "user_message",
id: input.id,
...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}),
text: input.text,
timestamp: input.timestamp,
};
@@ -351,6 +355,7 @@ function appendUserMessage(
text: string,
timestamp: Date,
messageId?: string,
clientMessageId?: string,
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
if (!hasContent) {
@@ -366,6 +371,7 @@ function appendUserMessage(
const nextItem = buildUserMessageItem({
id: entryId,
clientMessageId,
text: chunk,
timestamp,
optimistic,
@@ -833,7 +839,9 @@ function reduceTimelineEvent(
const item = event.item;
switch (item.type) {
case "user_message":
return finalizeActiveThoughts(appendUserMessage(state, item.text, timestamp, item.messageId));
return finalizeActiveThoughts(
appendUserMessage(state, item.text, timestamp, item.messageId, item.clientMessageId),
);
case "assistant_message":
return finalizeActiveThoughts(
appendAssistantMessage(