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:
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user