From 130dec624d5b542303bcae69ff05af54e940a408 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Nov 2026 09:53:55 +0100 Subject: [PATCH] test(app): pin the held running turn across a freshness check A chat that left mid-turn holds its running footer until its messages are current. With the freshness check, "current" is that commit with no rows, so the tests and the timeline-sync doc now cover the check in flight, a current answer, and a behind answer that holds until the fetched rows land. --- docs/timeline-sync.md | 29 ++-- packages/app/src/timeline/replica.test.ts | 161 +++++++++++++++++++++- 2 files changed, 175 insertions(+), 15 deletions(-) diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 82d45f0242..0a0a7187c4 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -125,19 +125,22 @@ current. The timeline owner publishes freshness; the view renders it without a t separate resume workflow. The turn footer in a chat follows its messages, not the agent status. Turn activity comes from the -agent directory and messages from the chat's own catch-up, and either can land first. So the timeline -owner keeps an unsettled turn per chat: the turn its messages were captured during. It is set when a -catch-up becomes owed while the turn is open, or when cached messages are painted that were saved -while one was, and it is saved with those messages in the replica. It carries the turn's start time. -While it is set and the agent reads idle, the chat keeps showing that running turn, with its loader -and elapsed timer unchanged, and there is no separate catching-up footer. The commit that brings the -chat current clears it, in the same store write as the rows, so the final messages and the completed -footer land together. A failed catch-up clears it too, and the footer takes the true status. Losing -the connection is not a failed catch-up: none can run or fail until the host returns, so the running -footer stays beside "Reconnecting to host" and the catch-up on reconnect resolves it. Releasing it -offline would put a completed footer under messages known to be behind. Surfaces outside the timeline (the sidebar, tabs, the composer's stop and send controls) read the true status -and never see this state. Live delivery needs no hold because `applyStreamEvent` commits queued rows -before it applies turn liveness. +agent directory and messages from the chat's own catch-up, and either can land first. So the +timeline owner keeps an unsettled turn per chat: the turn its messages were captured during. It is +set when a check or catch-up becomes owed while the turn is open, or when cached messages are +painted that were saved while one was, and it is saved with those messages in the replica. It +carries the turn's start time. While it is set and the agent reads idle, the chat keeps showing that +running turn, with its loader and elapsed timer unchanged, and there is no separate catching-up +footer. The commit that brings the chat current clears it, in the same store write as the rows, so +the final messages and the completed footer land together. A `current` answer is that commit with no +rows. A check in flight keeps the hold, and `behind` keeps it until the fetched rows land. A failed +catch-up clears it too, and the footer takes the true status. Losing the connection is not a failed +catch-up: none can run or fail until the host returns, so the running footer stays beside +"Reconnecting to host" and the check on reconnect resolves it. Releasing it offline would put a +completed footer under messages known to be behind. Surfaces outside the timeline (the sidebar, +tabs, the composer's stop and send controls) read the true status and never see this state. Live +delivery needs no hold because `applyStreamEvent` commits queued rows before it applies turn +liveness. The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index d73206cd8a..13046701ee 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -63,6 +63,7 @@ function createOwner( ready: Promise.resolve(), release: async () => undefined, }), + checksFreshness = false, ): ViewedTimelineOwner { const replica = createTimelineReplica({ serverId: SERVER_ID, @@ -76,9 +77,16 @@ function createOwner( drainQueuedAgentMessage: () => undefined, applyTurnLiveness, ports: { - canCheckFreshness: () => false, + canCheckFreshness: () => checksFreshness, observe, - readCursor: () => undefined, + readCursor: () => { + if (!checksFreshness) return undefined; + const session = useSessionStore.getState().sessions[SERVER_ID]; + const timeline = selectAgentTimelineState(session, AGENT_ID); + return timeline.status === "synced" && timeline.range + ? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq } + : undefined; + }, fetchPage, fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), reportError: () => undefined, @@ -895,6 +903,155 @@ describe("the turn footer changes together with its messages", () => { owner.dispose(); }); + type Answer = "current" | "behind"; + + // A chat whose subscribes wait for the test to answer them, and whose fetches are recorded. + function createCheckedChat(cachedReply: string | null) { + useSessionStore.getState().initializeSession(SERVER_ID, null); + useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, { + items: cachedReply ? [item("reply-1", cachedReply, 4)] : [], + head: [], + range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 }, + older: "none", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); + const unanswered: ((answer: Answer, beforeAnswer?: () => void) => void)[] = []; + let fetches = 0; + let finishFetch = () => {}; + const owner = createOwner( + { readTimeline: async () => undefined, commitTimeline: () => undefined }, + undefined, + () => { + fetches += 1; + return new Promise((resolve) => { + finishFetch = () => resolve({ hasNewer: false, endCursor: null }); + }); + }, + () => ({ + // The host runtime keeps the answer's agent rows in this same continuation. + ready: new Promise((resolve) => { + unanswered.push((answer, beforeAnswer) => { + beforeAnswer?.(); + resolve({ timelines: { [AGENT_ID]: answer } }); + }); + }), + release: async () => undefined, + }), + true, + ); + const answer = async (value: Answer, beforeAnswer?: () => void) => { + await expect.poll(() => unanswered.length).toBeGreaterThan(0); + unanswered.shift()?.(value, beforeAnswer); + }; + const open = async () => { + owner.replaceVisibleAgentIds("test", [AGENT_ID]); + owner.setConnected(true); + await answer("current"); + await expect.poll(() => owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready"); + }; + return { owner, answer, open, fetchCount: () => fetches, finishFetch: () => finishFetch() }; + } + + function leaveAndReturn(owner: ViewedTimelineOwner, way: "reconnect" | "foreground"): void { + if (way === "reconnect") { + owner.setConnected(false); + owner.setConnected(true); + return; + } + owner.setActive(false); + owner.setActive(true); + } + + it.each([ + ["reconnect", "reattaching"], + ["foreground", "checking"], + ] as const)( + "freshness check on %s: the held turn stays through the check and current settles it", + async (way, status) => { + const chat = createCheckedChat("Done."); + agentIsRunning(); + await chat.open(); + + leaveAndReturn(chat.owner, way); + idleSnapshotArrives(); + expect(chat.owner.getAgentTimelineStatus(AGENT_ID)).toBe(status); + expect(footer()).toBe("running"); + + const recorder = recordCommits(); + await chat.answer("current"); + await expect.poll(() => chat.owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready"); + recorder.stop(); + + expect(footer()).toBe("settled"); + expect(replies()).toEqual(["Done."]); + expect(chat.fetchCount()).toBe(0); + expect(recorder.commits.map((commit) => commit.footer).slice(0, 2)).toEqual([ + "running", + "settled", + ]); + chat.owner.dispose(); + }, + ); + + it("freshness check: an idle row carried by the current answer settles the footer in the same step", async () => { + const chat = createCheckedChat("Done."); + agentIsRunning(); + await chat.open(); + leaveAndReturn(chat.owner, "reconnect"); + + let footerAfterRow: Footer | null = null; + await chat.answer("current", () => { + idleSnapshotArrives(); + footerAfterRow = footer(); + }); + await expect.poll(() => chat.owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready"); + + expect(footerAfterRow).toBe("running"); + expect(footer()).toBe("settled"); + chat.owner.dispose(); + }); + + it("freshness check: an agent still running keeps its footer after current", async () => { + const chat = createCheckedChat("Half"); + agentIsRunning(); + await chat.open(); + leaveAndReturn(chat.owner, "reconnect"); + + await chat.answer("current"); + await expect.poll(() => chat.owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready"); + + expect(footer()).toBe("running"); + idleSnapshotArrives(); + expect(footer()).toBe("settled"); + chat.owner.dispose(); + }); + + it("freshness check: behind holds the turn until the fetched rows land with the completed footer", async () => { + const chat = createCheckedChat("Half"); + agentIsRunning(); + await chat.open(); + leaveAndReturn(chat.owner, "reconnect"); + idleSnapshotArrives(); + + await chat.answer("behind"); + await expect.poll(() => chat.fetchCount()).toBe(1); + expect(footer()).toBe("running"); + + const recorder = recordCommits(); + chat.owner.applyTimelineResponse(catchUpPage({ replyThroughSeq: 8, reply: "Done." })); + recorder.stop(); + + expect(footer()).toBe("settled"); + expect(replies()).toEqual(["HalfDone."]); + expect(recorder.commits.at(-2)).toMatchObject({ footer: "running", replies: ["Half"] }); + expect(recorder.commits.at(-1)).toMatchObject({ footer: "settled", replies: ["HalfDone."] }); + chat.finishFetch(); + await expect.poll(() => chat.owner.getAgentTimelineStatus(AGENT_ID)).toBe("ready"); + chat.owner.dispose(); + }); + it("catch-up failure gives the footer the true status", async () => { let failNext = false; const owner = createChat("Half", async () => {