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.
This commit is contained in:
Mohamed Boudra
2026-11-09 09:53:55 +01:00
parent 279211e110
commit 130dec624d
2 changed files with 175 additions and 15 deletions
+16 -13
View File
@@ -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
+159 -2
View File
@@ -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 () => {