fix(timeline): restore prompt identity on resume

This commit is contained in:
Mohamed Boudra
2026-08-01 01:33:16 +02:00
parent 2b2012e8e6
commit 8f9dd96355
2 changed files with 104 additions and 6 deletions
@@ -258,9 +258,73 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
// ---------------------------------------------------------------------------
describe("processTimelineResponse", () => {
it("applies a missed same-sequence provider identity revision from an unchanged resume tail", () => {
const canonical = createUserMessage({
id: "canonical-prompt",
clientMessageId: "client-message",
text: "local prompt",
timestamp: new Date(1000),
timelineCursor: { epoch: "epoch-1", seq: 5 },
});
const unrelatedTail = makeAssistantItem("unrelated tail", "unrelated-tail");
const currentTail = [canonical, unrelatedTail];
const currentHead = [makeAssistantItem("live head", "live-head")];
const currentCursor: TimelineCursor = { epoch: "epoch-1", startSeq: 1, endSeq: 8 };
const result = processTimelineResponse({
...baseTimelineInput,
currentTail,
currentHead,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "tail",
window: { minSeq: 1, maxSeq: 8, nextSeq: 9 },
startCursor: { seq: 5 },
endCursor: { seq: 8 },
entries: [
{
...makeTimelineEntry(5, "provider prompt", "user_message"),
item: {
type: "user_message",
text: "provider prompt",
messageId: "provider-message",
clientMessageId: "client-message",
},
},
],
},
});
expect(result.commit).toBe("apply");
expect(result.tail).not.toBe(currentTail);
expect(result.tail[0]).toEqual(
expect.objectContaining({
id: "canonical-prompt",
clientMessageId: "client-message",
messageId: "provider-message",
text: "local prompt",
timestamp: canonical.timestamp,
timelineCursor: { epoch: "epoch-1", seq: 5 },
}),
);
expect(result.tail[1]).toBe(unrelatedTail);
expect(result.head).toBe(currentHead);
expect(result.cursor).toBe(currentCursor);
expect(result.cursorChanged).toBe(false);
expect(result.acknowledgedClientMessageIds).toEqual(["client-message"]);
});
it("discards an unchanged resume tail without replacing timeline state", () => {
const submitted = makeSubmittedUserMessage("local prompt", "client-message");
const currentTail = [submitted, makeAssistantItem("existing tail", "existing-tail")];
const canonical = createUserMessage({
id: "canonical-prompt",
clientMessageId: "client-message",
messageId: "provider-message",
text: "local prompt",
timestamp: new Date(1000),
timelineCursor: { epoch: "epoch-1", seq: 1 },
});
const currentTail = [canonical, makeAssistantItem("existing tail", "existing-tail")];
const currentHead = [makeAssistantItem("existing head", "existing-head")];
const result = processTimelineResponse({
@@ -782,6 +782,20 @@ function deriveCanonicalAcknowledgements(params: {
return [...acknowledged];
}
function shouldDiscardUnchangedTimelineResult(params: {
discardPolicy: boolean;
currentTail: StreamItem[];
currentHead: StreamItem[];
nextTail: StreamItem[];
nextHead: StreamItem[];
}): boolean {
return (
params.discardPolicy &&
params.nextTail === params.currentTail &&
params.nextHead === params.currentHead
);
}
function applyTimelineIncrementalPath(args: {
timelineUnits: TimelineUnit[];
payload: ProcessTimelineResponseInput["payload"];
@@ -964,9 +978,20 @@ export function processTimelineResponse(
const discard = resumeTailPolicy.kind === "discard";
let timelineResult: TimelinePathResult;
if (discard) {
const revision = processAgentStreamEvents({
events: timelineUnits.map((unit) => ({
event: unit.event,
seq: unit.seqEnd,
epoch: payload.epoch,
timestamp: unit.timestamp,
})),
currentTail,
currentHead,
currentCursor,
});
timelineResult = {
tail: currentTail,
head: currentHead,
tail: revision.tail,
head: revision.head,
cursor: currentCursor,
cursorChanged: false,
older: "unchanged",
@@ -1036,9 +1061,18 @@ export function processTimelineResponse(
timelineResponseComplete;
const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null;
const commit = shouldDiscardUnchangedTimelineResult({
discardPolicy: discard,
currentTail,
currentHead,
nextTail,
nextHead,
})
? "discard"
: "apply";
return {
commit: discard ? "discard" : "apply",
commit,
tail: nextTail,
head: nextHead,
cursor: nextCursor,
@@ -1135,7 +1169,7 @@ function applyCanonicalUserMessageRevision(input: {
messageId: event.item.messageId,
text: event.item.text,
timestamp: input.timestamp,
timelineCursor: { epoch, seq },
timelineCursor: existing.timelineCursor,
}),
insert: "none",
presentation: "existing",