mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 09:54:40 +00:00
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:
+16
-13
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user