fix(app): prevent catch-up from moving submitted messages

Timeline catch-up treated the first unmatched optimistic prompt as an insertion boundary, which could move later acknowledged prompts behind assistant output. Apply canonical entries directly and preserve submission slots during replacement.
This commit is contained in:
Mohamed Boudra
2026-07-21 20:28:27 +02:00
parent a4d11cda2b
commit d27c7ad590
5 changed files with 235 additions and 203 deletions
@@ -415,6 +415,82 @@ describe("processTimelineResponse", () => {
expect(result.tail).toEqual([optimistic]);
});
it("does not move an unmatched submission during timeline replacement", () => {
const unmatched = makeOptimisticUserMessage("first submission", "client-first");
const acknowledged: StreamItem[] = [
{
kind: "user_message",
id: "provider-second",
clientMessageId: "client-second",
text: "second submission",
timestamp: new Date(2000),
},
{
kind: "user_message",
id: "provider-third",
clientMessageId: "client-third",
text: "third submission",
timestamp: new Date(3000),
},
];
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [unmatched, ...acknowledged],
payload: {
...baseTimelineInput.payload,
reset: true,
startCursor: { seq: 2 },
endCursor: { seq: 4 },
entries: [
{
...makeTimelineEntry(2, "second submission", "user_message"),
item: {
type: "user_message",
text: "second submission",
messageId: "provider-second",
clientMessageId: "client-second",
},
},
{
...makeTimelineEntry(3, "third submission", "user_message"),
item: {
type: "user_message",
text: "third submission",
messageId: "provider-third",
clientMessageId: "client-third",
},
},
{
...makeTimelineEntry(4, "response to all three submissions"),
item: {
type: "assistant_message",
text: "response to all three submissions",
messageId: "assistant-response",
},
},
],
},
});
expect(
result.tail.map((item) => ({
kind: item.kind,
id: item.id,
text: "text" in item ? item.text : undefined,
})),
).toEqual([
{ kind: "user_message", id: "client-first", text: "first submission" },
{ kind: "user_message", id: "provider-second", text: "second submission" },
{ kind: "user_message", id: "provider-third", text: "third submission" },
{
kind: "assistant_message",
id: "assistant-response",
text: "response to all three submissions",
},
]);
});
it("sets cursor to null when reset=true but no cursors in payload", () => {
const existingTail: StreamItem[] = [
{
@@ -923,7 +999,7 @@ describe("processTimelineResponse", () => {
expect(thoughts[0]?.text).toBe("Thinking");
});
it("keeps delayed catch-up history before a newly submitted prompt", () => {
it("does not move a submitted prompt when catch-up history arrives", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
@@ -944,10 +1020,10 @@ describe("processTimelineResponse", () => {
[...result.tail, ...result.head]
.filter((item) => item.kind === "assistant_message" || item.kind === "user_message")
.map((item) => item.text),
).toEqual(["Earlier answer", "Missed answer", "New prompt"]);
).toEqual(["Earlier answer", "New prompt", "Missed answer"]);
});
it("keeps delayed catch-up history between a live head and its unmatched head prompt", () => {
it("does not move an unmatched head prompt when catch-up history arrives", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
@@ -971,12 +1047,12 @@ describe("processTimelineResponse", () => {
expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([
"assistant_message",
"tool_call",
"user_message",
"tool_call",
]);
});
it("keeps delayed catch-up history between a live head and its acknowledged head prompt", () => {
it("acknowledges a head prompt in place while catch-up history arrives", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
@@ -1008,8 +1084,8 @@ describe("processTimelineResponse", () => {
expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([
"assistant_message",
"tool_call",
"user_message",
"tool_call",
]);
expect(
[...result.tail, ...result.head]
@@ -1018,7 +1094,7 @@ describe("processTimelineResponse", () => {
).toEqual([undefined]);
});
it("keeps unrelated delayed history before the prompt and its live response", () => {
it("does not move a prompt around unrelated catch-up history", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
@@ -1037,7 +1113,14 @@ describe("processTimelineResponse", () => {
startCursor: { seq: 3 },
endCursor: { seq: 4 },
entries: [
makeTimelineEntry(3, "Missed answer"),
{
...makeTimelineEntry(3, "Missed answer"),
item: {
type: "assistant_message",
text: "Missed answer",
messageId: "missed-answer",
},
},
{
...makeTimelineEntry(4, "Remote prompt", "user_message"),
item: {
@@ -1054,10 +1137,10 @@ describe("processTimelineResponse", () => {
[...result.tail, ...result.head]
.filter((item) => item.kind === "assistant_message" || item.kind === "user_message")
.map((item) => item.text),
).toEqual(["Earlier answer", "Missed answer", "Remote prompt", "New prompt", "Live response"]);
).toEqual(["Earlier answer", "New prompt", "Live response", "Missed answer", "Remote prompt"]);
});
it("keeps delayed history before a prompt whose live answer has promoted blocks", () => {
it("does not move a prompt or its live answer around catch-up history", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const live = processAgentStreamEvents({
events: [
@@ -1082,7 +1165,16 @@ describe("processTimelineResponse", () => {
epoch: "epoch-1",
startCursor: { seq: 3 },
endCursor: { seq: 3 },
entries: [makeTimelineEntry(3, "Missed answer")],
entries: [
{
...makeTimelineEntry(3, "Missed answer"),
item: {
type: "assistant_message",
text: "Missed answer",
messageId: "missed-answer",
},
},
],
},
});
@@ -1090,10 +1182,10 @@ describe("processTimelineResponse", () => {
[...result.tail, ...result.head]
.filter((item) => item.kind === "assistant_message" || item.kind === "user_message")
.map((item) => item.text),
).toEqual(["Missed answer", "New prompt", "First paragraph.", "Second paragraph"]);
).toEqual(["New prompt", "First paragraph.", "Second paragraph", "Missed answer"]);
});
it("keeps delayed tool history before a prompt whose live answer has promoted blocks", () => {
it("does not move a prompt or its live answer around catch-up tool history", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const live = processAgentStreamEvents({
events: [
@@ -1128,14 +1220,73 @@ describe("processTimelineResponse", () => {
});
expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([
"tool_call",
"user_message",
"assistant_message",
"assistant_message",
"tool_call",
]);
});
it("matches a local optimistic prompt after an unrelated remote user row", () => {
it("never moves submitted messages behind a later assistant response", () => {
const unmatched = makeOptimisticUserMessage("first submission", "client-first");
const acknowledged: StreamItem[] = [
{
kind: "user_message",
id: "provider-second",
clientMessageId: "client-second",
text: "second submission",
timestamp: new Date(2000),
},
{
kind: "user_message",
id: "provider-third",
clientMessageId: "client-third",
text: "third submission",
timestamp: new Date(3000),
},
];
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [unmatched, ...acknowledged],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 3 },
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
startCursor: { seq: 4 },
endCursor: { seq: 4 },
entries: [
{
...makeTimelineEntry(4, "response to all three submissions"),
item: {
type: "assistant_message",
text: "response to all three submissions",
messageId: "assistant-response",
},
},
],
},
});
expect(
[...result.tail, ...result.head].map((item) => ({
kind: item.kind,
id: item.id,
text: "text" in item ? item.text : undefined,
})),
).toEqual([
{ kind: "user_message", id: "client-first", text: "first submission" },
{ kind: "user_message", id: "provider-second", text: "second submission" },
{ kind: "user_message", id: "provider-third", text: "third submission" },
{
kind: "assistant_message",
id: "assistant-response",
text: "response to all three submissions",
},
]);
});
it("acknowledges a local prompt in place when a remote user row also arrives", () => {
const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt");
const result = processTimelineResponse({
@@ -1173,8 +1324,8 @@ describe("processTimelineResponse", () => {
.filter((item) => item.kind === "user_message")
.map((item) => ({ text: item.text, optimistic: item.optimistic })),
).toEqual([
{ text: "Remote prompt", optimistic: undefined },
{ text: "Local prompt", optimistic: undefined },
{ text: "Remote prompt", optimistic: undefined },
]);
});
@@ -1208,8 +1359,8 @@ describe("processTimelineResponse", () => {
.filter((item) => item.kind === "user_message")
.map((item) => ({ text: item.text, optimistic: item.optimistic })),
).toEqual([
{ text: "Remote prompt", optimistic: undefined },
{ text: "Local prompt", optimistic: true },
{ text: "Remote prompt", optimistic: undefined },
]);
});
@@ -1244,8 +1395,8 @@ describe("processTimelineResponse", () => {
.filter((item) => item.kind === "user_message")
.map((item) => ({ id: item.id, optimistic: item.optimistic })),
).toEqual([
{ id: "remote-prompt", optimistic: undefined },
{ id: "local-prompt", optimistic: true },
{ id: "remote-prompt", optimistic: undefined },
]);
});