fix(web): reconcile inactive session transcript windows (#3159)
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import { test, expect, type Page, type SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { waitForStableActiveSession } from "../../helpers/session-store";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { scrollToOldestLoadedEdge } from "./message-pagination-helpers";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
|
||||
const INITIAL_RECEIVER_MARKER = "INACTIVE-RECEIVER-INITIAL-8D4K";
|
||||
const PEER_PROMPT_MARKER = "INACTIVE-RECEIVER-PEER-PROMPT-2J7M";
|
||||
const RECENT_RECEIVER_MARKER = "INACTIVE-RECEIVER-RECENT-5Q9V";
|
||||
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
|
||||
|
||||
type E2EMessageStoreWindow = Window & {
|
||||
__KANDEV_E2E_STORE__?: {
|
||||
getState: () => {
|
||||
messages: { bySession: Record<string, Message[]> };
|
||||
setMessages: (sessionId: string, messages: Message[]) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
async function snapshotCachedMessages(page: Page, sessionId: string): Promise<Message[]> {
|
||||
return page.evaluate((sid) => {
|
||||
const store = (window as E2EMessageStoreWindow).__KANDEV_E2E_STORE__;
|
||||
if (!store) throw new Error("E2E store bridge is unavailable");
|
||||
return store.getState().messages.bySession[sid] ?? [];
|
||||
}, sessionId);
|
||||
}
|
||||
|
||||
async function restoreCachedMessages(
|
||||
page: Page,
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ sid, cached }) => {
|
||||
const store = (window as E2EMessageStoreWindow).__KANDEV_E2E_STORE__;
|
||||
if (!store) throw new Error("E2E store bridge is unavailable");
|
||||
store.getState().setMessages(sid, cached);
|
||||
},
|
||||
{ sid: sessionId, cached: messages },
|
||||
);
|
||||
}
|
||||
|
||||
async function createTaskWithTwoSessions(
|
||||
testPage: Page,
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
): Promise<{ taskId: string; senderId: string; receiverId: string }> {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Inactive sibling transcript",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return DONE_STATES.includes(sessions[0]?.state ?? "");
|
||||
},
|
||||
{ timeout: 30_000, message: "Waiting for the first session to finish" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await session.openNewSessionDialog();
|
||||
await session.newSessionPromptInput().fill("/e2e:simple-message");
|
||||
await session.newSessionStartButton().click();
|
||||
await expect(session.newSessionDialog()).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return sessions.filter((candidate) => DONE_STATES.includes(candidate.state)).length;
|
||||
},
|
||||
{ timeout: 60_000, message: "Waiting for both sessions to finish" },
|
||||
)
|
||||
.toBe(2);
|
||||
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
const sorted = sessions.sort(
|
||||
(left, right) => new Date(left.started_at).getTime() - new Date(right.started_at).getTime(),
|
||||
);
|
||||
return { taskId: task.id, senderId: sorted[0].id, receiverId: sorted[1].id };
|
||||
}
|
||||
|
||||
test.describe("inactive session transcript reconciliation", () => {
|
||||
// @covers AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10
|
||||
test("reaches an attributed sibling prompt after the receiver cache becomes disjoint", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const { taskId, senderId, receiverId } = await createTaskWithTwoSessions(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
);
|
||||
|
||||
await apiClient.seedSessionMessage(receiverId, {
|
||||
type: "message",
|
||||
content: INITIAL_RECEIVER_MARKER,
|
||||
authorType: "user",
|
||||
});
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.sessionTabBySessionId(receiverId)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(session.sessionTabBySessionId(senderId)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await session.sessionTabBySessionId(receiverId).click();
|
||||
await waitForStableActiveSession(testPage, receiverId);
|
||||
await expect(
|
||||
session.activeChat().getByText(INITIAL_RECEIVER_MARKER, { exact: true }),
|
||||
).toBeVisible();
|
||||
const staleReceiverCache = await snapshotCachedMessages(testPage, receiverId);
|
||||
|
||||
await session.sessionTabBySessionId(senderId).click();
|
||||
await waitForStableActiveSession(testPage, senderId);
|
||||
|
||||
await apiClient.seedSessionMessage(receiverId, {
|
||||
type: "message",
|
||||
content: PEER_PROMPT_MARKER,
|
||||
authorType: "user",
|
||||
metadata: {
|
||||
sender_task_id: taskId,
|
||||
sender_task_title: "Inactive sibling transcript",
|
||||
sender_session_id: senderId,
|
||||
},
|
||||
});
|
||||
await apiClient.seedToolCallMessages(receiverId, 105);
|
||||
await apiClient.seedSessionMessage(receiverId, {
|
||||
type: "message",
|
||||
content: RECENT_RECEIVER_MARKER,
|
||||
authorType: "agent",
|
||||
});
|
||||
await testPage.waitForFunction(
|
||||
({ sid, marker }) => {
|
||||
const store = (window as E2EMessageStoreWindow).__KANDEV_E2E_STORE__;
|
||||
return store
|
||||
?.getState()
|
||||
.messages.bySession[sid]?.some((message) => message.content === marker);
|
||||
},
|
||||
{ sid: receiverId, marker: RECENT_RECEIVER_MARKER },
|
||||
{
|
||||
message:
|
||||
"RECENT_RECEIVER_MARKER should arrive in the inactive receiver cache via live delivery",
|
||||
},
|
||||
);
|
||||
await restoreCachedMessages(testPage, receiverId, staleReceiverCache);
|
||||
await testPage.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await testPage.waitForFunction(
|
||||
({ sid, marker }) => {
|
||||
const store = (window as E2EMessageStoreWindow).__KANDEV_E2E_STORE__;
|
||||
return store
|
||||
?.getState()
|
||||
.messages.bySession[sid]?.some((message) => message.content === marker);
|
||||
},
|
||||
{ sid: receiverId, marker: RECENT_RECEIVER_MARKER },
|
||||
{
|
||||
message: "Foreground recovery should reconcile the receiver's latest message window",
|
||||
},
|
||||
);
|
||||
|
||||
const persisted = await apiClient.listSessionMessages(receiverId);
|
||||
expect(
|
||||
persisted.messages.slice(-100).some((message) => message.content === PEER_PROMPT_MARKER),
|
||||
).toBe(false);
|
||||
|
||||
await session.sessionTabBySessionId(receiverId).click();
|
||||
await waitForStableActiveSession(testPage, receiverId);
|
||||
await session.waitForLoad();
|
||||
const chat = session.activeChat();
|
||||
const list = chat.locator(".chat-message-list");
|
||||
await expect(chat.getByText(RECENT_RECEIVER_MARKER, { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await scrollToOldestLoadedEdge(list, RECENT_RECEIVER_MARKER);
|
||||
|
||||
const peerRow = chat.locator("[id^='msg-']").filter({ hasText: PEER_PROMPT_MARKER });
|
||||
await expect(peerRow).toHaveCount(1);
|
||||
await expect(peerRow.getByText(PEER_PROMPT_MARKER, { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(peerRow.getByTestId("sender-task-badge")).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import { sessionId, taskId } from "@/lib/types/ids";
|
||||
import { reconcileLatestMessageWindow } from "./message-window-reconciliation";
|
||||
|
||||
function message(id: string, minute: number, fraction = ""): Message {
|
||||
return {
|
||||
id,
|
||||
task_id: taskId("task-1"),
|
||||
session_id: sessionId("session-1"),
|
||||
author_type: "agent",
|
||||
content: id,
|
||||
type: "message",
|
||||
created_at: `2026-08-30T09:${String(minute).padStart(2, "0")}:00${fraction}Z`,
|
||||
} as Message;
|
||||
}
|
||||
|
||||
describe("reconcileLatestMessageWindow", () => {
|
||||
// @covers AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10
|
||||
it("drops a disjoint stale prefix and retains a row received during the request", () => {
|
||||
const stale = [message("stale-1", 0), message("stale-2", 1)];
|
||||
const fetched = [message("fetched-3", 3), message("fetched-4", 4)];
|
||||
const live = message("live-5", 5);
|
||||
|
||||
const result = reconcileLatestMessageWindow({
|
||||
cachedAtRequest: stale,
|
||||
cachedAtResponse: [...stale, live],
|
||||
fetched,
|
||||
});
|
||||
|
||||
expect(result.messages.map(({ id }) => id)).toEqual(["fetched-3", "fetched-4", "live-5"]);
|
||||
expect(result.oldestCursor).toBe("fetched-3");
|
||||
});
|
||||
|
||||
it("keeps cached older pages when the fetched suffix overlaps them", () => {
|
||||
const cached = [message("cached-1", 1), message("shared-3", 3)];
|
||||
const fetched = [message("shared-3", 3), message("fetched-4", 4)];
|
||||
const live = message("live-6", 6);
|
||||
|
||||
const result = reconcileLatestMessageWindow({
|
||||
cachedAtRequest: cached,
|
||||
cachedAtResponse: [...cached, live],
|
||||
fetched,
|
||||
});
|
||||
|
||||
expect(result.messages.map(({ id }) => id)).toEqual([
|
||||
"cached-1",
|
||||
"shared-3",
|
||||
"fetched-4",
|
||||
"live-6",
|
||||
]);
|
||||
expect(result.oldestCursor).toBe("cached-1");
|
||||
});
|
||||
|
||||
it("leaves an older live row for the next page when the fetched window is disjoint", () => {
|
||||
const stale = [message("stale-1", 0), message("stale-2", 1)];
|
||||
const fetched = [message("fetched-3", 3), message("fetched-4", 4)];
|
||||
const olderLive = message("live-old-2", 2);
|
||||
const newerLive = message("live-5", 5);
|
||||
|
||||
const result = reconcileLatestMessageWindow({
|
||||
cachedAtRequest: stale,
|
||||
cachedAtResponse: [...stale, olderLive, newerLive],
|
||||
fetched,
|
||||
});
|
||||
|
||||
expect(result.messages.map(({ id }) => id)).toEqual(["fetched-3", "fetched-4", "live-5"]);
|
||||
expect(result.oldestCursor).toBe("fetched-3");
|
||||
});
|
||||
|
||||
it("sorts timestamps using sub-millisecond precision before the id tie-breaker", () => {
|
||||
const earlier = message("z-earlier", 0, ".000001");
|
||||
const later = message("a-later", 0, ".000002");
|
||||
|
||||
const result = reconcileLatestMessageWindow({
|
||||
cachedAtRequest: [],
|
||||
cachedAtResponse: [],
|
||||
fetched: [later, earlier],
|
||||
});
|
||||
|
||||
expect(result.messages.map(({ id }) => id)).toEqual(["z-earlier", "a-later"]);
|
||||
});
|
||||
|
||||
it("uses the id tie-breaker after truncating timestamps to backend microseconds", () => {
|
||||
const first = message("z-tie", 0, ".0000011");
|
||||
const second = message("a-tie", 0, ".0000012");
|
||||
|
||||
const result = reconcileLatestMessageWindow({
|
||||
cachedAtRequest: [],
|
||||
cachedAtResponse: [],
|
||||
fetched: [first, second],
|
||||
});
|
||||
|
||||
expect(result.messages.map(({ id }) => id)).toEqual(["a-tie", "z-tie"]);
|
||||
});
|
||||
|
||||
it("keeps the current cache when an empty response has no replacement boundary", () => {
|
||||
const cached = [message("cached-1", 1)];
|
||||
|
||||
expect(
|
||||
reconcileLatestMessageWindow({
|
||||
cachedAtRequest: cached,
|
||||
cachedAtResponse: cached,
|
||||
fetched: [],
|
||||
}),
|
||||
).toEqual({ messages: cached, oldestCursor: "cached-1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Message } from "@/lib/types/http";
|
||||
|
||||
export type LatestMessageWindow = {
|
||||
messages: Message[];
|
||||
oldestCursor: string | null;
|
||||
};
|
||||
|
||||
const MICROSECONDS_PER_MILLISECOND = BigInt(1_000);
|
||||
|
||||
/** Parse the same normalized-microsecond precision used by message pagination. */
|
||||
function timestampMicros(createdAt: string): bigint | null {
|
||||
const match = createdAt.match(
|
||||
/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/,
|
||||
);
|
||||
if (!match) {
|
||||
const parsed = Date.parse(createdAt);
|
||||
return Number.isNaN(parsed) ? null : BigInt(parsed) * MICROSECONDS_PER_MILLISECOND;
|
||||
}
|
||||
|
||||
const milliseconds = Date.parse(`${match[1]}${match[3]}`);
|
||||
if (Number.isNaN(milliseconds)) return null;
|
||||
const fraction = (match[2] ?? "").padEnd(6, "0").slice(0, 6);
|
||||
return BigInt(milliseconds) * MICROSECONDS_PER_MILLISECOND + BigInt(fraction);
|
||||
}
|
||||
|
||||
function compareMessages(a: Message, b: Message): number {
|
||||
const aTimestamp = timestampMicros(a.created_at);
|
||||
const bTimestamp = timestampMicros(b.created_at);
|
||||
if (aTimestamp !== null && bTimestamp !== null) {
|
||||
if (aTimestamp !== bTimestamp) return aTimestamp < bTimestamp ? -1 : 1;
|
||||
} else if (a.created_at !== b.created_at) {
|
||||
return a.created_at.localeCompare(b.created_at);
|
||||
}
|
||||
if (a.id < b.id) return -1;
|
||||
if (a.id > b.id) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function joinMessages(base: Message[], extras: Message[]): Message[] {
|
||||
if (extras.length === 0) return base;
|
||||
const baseIds = new Set(base.map((message) => message.id));
|
||||
return [...base, ...extras.filter((message) => !baseIds.has(message.id))].sort(compareMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a bounded newest-page response with the session cache while
|
||||
* preserving one contiguous pagination interval.
|
||||
*/
|
||||
export function reconcileLatestMessageWindow(params: {
|
||||
cachedAtRequest: Message[];
|
||||
cachedAtResponse: Message[];
|
||||
fetched: Message[];
|
||||
}): LatestMessageWindow {
|
||||
const { cachedAtRequest, cachedAtResponse, fetched } = params;
|
||||
if (fetched.length === 0) {
|
||||
return {
|
||||
messages: cachedAtResponse,
|
||||
oldestCursor: cachedAtResponse[0]?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const orderedFetched = [...fetched].sort(compareMessages);
|
||||
const fetchedBoundary = orderedFetched[0];
|
||||
const fetchedIds = new Set(orderedFetched.map((message) => message.id));
|
||||
const overlapsCachedWindow = cachedAtRequest.some((message) => fetchedIds.has(message.id));
|
||||
|
||||
if (overlapsCachedWindow) {
|
||||
const messages = joinMessages(orderedFetched, cachedAtResponse);
|
||||
return { messages, oldestCursor: messages[0]?.id ?? null };
|
||||
}
|
||||
|
||||
const cachedAtRequestIds = new Set(cachedAtRequest.map((message) => message.id));
|
||||
// A live row older than the fetched boundary belongs to the next page. If
|
||||
// retained here, the row would sit before `oldestCursor` and leave a gap in
|
||||
// the single contiguous interval represented by this window.
|
||||
const liveAdditions = cachedAtResponse.filter(
|
||||
(message) =>
|
||||
!cachedAtRequestIds.has(message.id) &&
|
||||
!fetchedIds.has(message.id) &&
|
||||
compareMessages(message, fetchedBoundary) >= 0,
|
||||
);
|
||||
const messages = joinMessages(orderedFetched, liveAdditions);
|
||||
return { messages, oldestCursor: fetchedBoundary.id };
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import { taskId, sessionId } from "@/lib/types/ids";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.mergeMessages.mockReset();
|
||||
mockListSessionTurns.mockResolvedValue({ turns: [], total: 0 });
|
||||
mockWebSocketClient.request.mockResolvedValue({ messages: [], has_more: false });
|
||||
mockWebSocketClient.subscribeSession.mockReturnValue(vi.fn());
|
||||
@@ -287,6 +288,64 @@ function deferred<T>() {
|
||||
}
|
||||
|
||||
describe("session subscription hydration ordering", () => {
|
||||
// @covers AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10
|
||||
it("replaces a disjoint stale cache while preserving rows received during the fetch", async () => {
|
||||
const readiness = deferred<void>();
|
||||
const response = deferred<{ messages: Message[]; has_more: boolean }>();
|
||||
mockWebSocketClient.getSessionSubscriptionReadiness.mockReturnValue(readiness.promise);
|
||||
mockWebSocketClient.subscribeSessionWithReady.mockReturnValue({
|
||||
ready: readiness.promise,
|
||||
unsubscribe: vi.fn(),
|
||||
});
|
||||
mockWebSocketClient.request.mockReturnValue(response.promise);
|
||||
|
||||
const staleOldest = makeMessage({
|
||||
id: "stale-1",
|
||||
created_at: "2026-08-30T09:00:00Z",
|
||||
});
|
||||
const staleNewest = makeMessage({
|
||||
id: "stale-2",
|
||||
created_at: "2026-08-30T09:01:00Z",
|
||||
});
|
||||
mockState.messages.bySession["sess-1"] = [staleOldest, staleNewest];
|
||||
|
||||
const { unmount } = renderHook(() => useSessionMessages("sess-1"));
|
||||
|
||||
await act(async () => {
|
||||
readiness.resolve();
|
||||
await readiness.promise;
|
||||
});
|
||||
|
||||
const liveDuringFetch = makeMessage({
|
||||
id: "live-5",
|
||||
author_type: "agent",
|
||||
created_at: "2026-08-30T09:05:00Z",
|
||||
});
|
||||
mockState.messages.bySession["sess-1"] = [staleOldest, staleNewest, liveDuringFetch];
|
||||
|
||||
const fetchedNewest = makeMessage({
|
||||
id: "fetched-4",
|
||||
author_type: "agent",
|
||||
created_at: "2026-08-30T09:04:00Z",
|
||||
});
|
||||
const fetchedOldest = makeMessage({
|
||||
id: "fetched-3",
|
||||
author_type: "agent",
|
||||
created_at: "2026-08-30T09:03:00Z",
|
||||
});
|
||||
await act(async () => {
|
||||
response.resolve({ messages: [fetchedNewest, fetchedOldest], has_more: true });
|
||||
await response.promise;
|
||||
});
|
||||
|
||||
expect(mockState.mergeMessages).toHaveBeenCalledWith(
|
||||
"sess-1",
|
||||
[fetchedOldest, fetchedNewest, liveDuringFetch],
|
||||
{ hasMore: true, oldestCursor: "fetched-3" },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("does not request messages before subscription acknowledgement", async () => {
|
||||
const readiness = deferred<void>();
|
||||
mockWebSocketClient.getSessionSubscriptionReadiness.mockReturnValue(readiness.promise);
|
||||
@@ -431,3 +490,68 @@ describe("turn loading for sessions without hydrated turns", () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deduplicated message request baselines", () => {
|
||||
it("uses the first caller's cache baseline for a concurrent fetch", async () => {
|
||||
const readiness = deferred<void>();
|
||||
const response = deferred<{ messages: Message[]; has_more: boolean }>();
|
||||
mockWebSocketClient.getSessionSubscriptionReadiness.mockReturnValue(readiness.promise);
|
||||
mockWebSocketClient.subscribeSessionWithReady.mockReturnValue({
|
||||
ready: readiness.promise,
|
||||
unsubscribe: vi.fn(),
|
||||
});
|
||||
mockWebSocketClient.request.mockReturnValue(response.promise);
|
||||
|
||||
const staleOldest = makeMessage({
|
||||
id: "stale-1",
|
||||
created_at: "2026-08-30T09:00:00Z",
|
||||
});
|
||||
const staleNewest = makeMessage({
|
||||
id: "stale-2",
|
||||
created_at: "2026-08-30T09:01:00Z",
|
||||
});
|
||||
const liveDuringFetch = makeMessage({
|
||||
id: "live-5",
|
||||
author_type: "agent",
|
||||
created_at: "2026-08-30T09:05:00Z",
|
||||
});
|
||||
mockState.messages.bySession["sess-1"] = [staleOldest, staleNewest];
|
||||
mockState.mergeMessages.mockImplementation((_sessionId, messages) => {
|
||||
mockState.messages.bySession["sess-1"] = messages;
|
||||
});
|
||||
|
||||
const first = renderHook(() => useSessionMessages("sess-1"));
|
||||
await act(async () => {
|
||||
readiness.resolve();
|
||||
await readiness.promise;
|
||||
});
|
||||
expect(mockWebSocketClient.request).toHaveBeenCalledTimes(1);
|
||||
|
||||
mockState.messages.bySession["sess-1"] = [staleOldest, staleNewest, liveDuringFetch];
|
||||
const second = renderHook(() => useSessionMessages("sess-1"));
|
||||
|
||||
const fetchedOldest = makeMessage({
|
||||
id: "fetched-3",
|
||||
author_type: "agent",
|
||||
created_at: "2026-08-30T09:03:00Z",
|
||||
});
|
||||
const fetchedNewest = makeMessage({
|
||||
id: "fetched-4",
|
||||
author_type: "agent",
|
||||
created_at: "2026-08-30T09:04:00Z",
|
||||
});
|
||||
await act(async () => {
|
||||
response.resolve({ messages: [fetchedNewest, fetchedOldest], has_more: true });
|
||||
await response.promise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockState.mergeMessages).toHaveBeenLastCalledWith(
|
||||
"sess-1",
|
||||
[fetchedOldest, fetchedNewest, liveDuringFetch],
|
||||
{ hasMore: true, oldestCursor: "fetched-3" },
|
||||
);
|
||||
first.unmount();
|
||||
second.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useUnknownSessionSubscriptionRetryEffect,
|
||||
} from "./use-session-subscription-retry";
|
||||
import { doFetchMessages } from "./use-session-message-fetch";
|
||||
import { reconcileLatestMessageWindow } from "./message-window-reconciliation";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export { shouldRetryUnknownSessionSubscription } from "./use-session-subscription-retry";
|
||||
@@ -138,6 +139,7 @@ type MessageListResponse = { messages: Message[]; has_more?: boolean; cursor?: s
|
||||
type InFlightMessageRequest = {
|
||||
readiness: Promise<void>;
|
||||
promise: Promise<MessageListResponse>;
|
||||
cachedAtRequest: Message[];
|
||||
};
|
||||
|
||||
const EMPTY_MESSAGES: Message[] = [];
|
||||
@@ -174,9 +176,10 @@ function requestSessionMessages(
|
||||
client: NonNullable<ReturnType<typeof getWebSocketClient>>,
|
||||
sessionId: string,
|
||||
readiness: Promise<void>,
|
||||
): Promise<MessageListResponse> {
|
||||
cachedAtRequest: Message[],
|
||||
): InFlightMessageRequest {
|
||||
const existing = inFlightMessageRequests.get(sessionId);
|
||||
if (existing?.readiness === readiness) return existing.promise;
|
||||
if (existing?.readiness === readiness) return existing;
|
||||
|
||||
const requestParams = {
|
||||
session_id: sessionId,
|
||||
@@ -184,7 +187,7 @@ function requestSessionMessages(
|
||||
sort: "desc" as const,
|
||||
};
|
||||
const promise = client.request<MessageListResponse>("message.list", requestParams, 10000);
|
||||
const entry = { readiness, promise };
|
||||
const entry = { readiness, promise, cachedAtRequest: [...cachedAtRequest] };
|
||||
inFlightMessageRequests.set(sessionId, entry);
|
||||
void promise.then(
|
||||
() => {
|
||||
@@ -202,7 +205,7 @@ function requestSessionMessages(
|
||||
}, 0);
|
||||
},
|
||||
);
|
||||
return promise;
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Fetch latest messages via WS and merge with any that arrived via live notifications. */
|
||||
@@ -227,24 +230,21 @@ async function fetchAndStoreMessages(
|
||||
// after subscription acknowledgement so the REST snapshot cannot race the
|
||||
// initial WebSocket subscription registration.
|
||||
void ensureSessionTurnsLoaded(sessionId, store, { readiness });
|
||||
const cachedAtRequest = store.getState().messages.bySession[sessionId] ?? [];
|
||||
const seq = nextFetchSeq();
|
||||
const response = await requestSessionMessages(client, sessionId, readiness);
|
||||
// Keep the snapshot on the deduplicated request. Concurrent callers may
|
||||
// observe different cache contents, but reconciliation must use the
|
||||
// baseline captured by the caller that actually issued the network request.
|
||||
const request = requestSessionMessages(client, sessionId, readiness, cachedAtRequest);
|
||||
const response = await request.promise;
|
||||
if (isActive && !isActive()) return [];
|
||||
const fetched = [...(response.messages ?? [])].reverse();
|
||||
logFetchSummary(sessionId, fetched, response, INITIAL_FETCH_LIMIT);
|
||||
// Merge: keep WS-delivered messages that aren't in the fetch response.
|
||||
// This prevents a slow fetch (sent before messages existed) from wiping
|
||||
// messages that arrived via real-time notifications while the fetch was
|
||||
// in flight.
|
||||
const existing = store.getState().messages.bySession[sessionId] ?? [];
|
||||
const fetchedIds = new Set(fetched.map((m) => m.id));
|
||||
const extras = existing.filter((m) => !fetchedIds.has(m.id));
|
||||
const merged =
|
||||
extras.length > 0
|
||||
? [...fetched, ...extras].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||
)
|
||||
: fetched;
|
||||
const { messages: merged, oldestCursor } = reconcileLatestMessageWindow({
|
||||
cachedAtRequest: request.cachedAtRequest,
|
||||
cachedAtResponse: store.getState().messages.bySession[sessionId] ?? [],
|
||||
fetched,
|
||||
});
|
||||
|
||||
// Stale-fetch guard: if a newer fetch for this session already merged while
|
||||
// this one was in flight, skip the merge so the older snapshot can't drop
|
||||
@@ -259,7 +259,7 @@ async function fetchAndStoreMessages(
|
||||
// refetch doesn't re-render the whole chat (see reconcileMessages).
|
||||
store.getState().mergeMessages(sessionId, merged, {
|
||||
hasMore: response.has_more ?? false,
|
||||
oldestCursor: merged[0]?.id ?? null,
|
||||
oldestCursor,
|
||||
});
|
||||
// The store now holds the identity-reconciled array; callers only read length
|
||||
// and message content from the return, so `merged` is equivalent.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
created: 2026-08-30
|
||||
status: complete
|
||||
requirements:
|
||||
- REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/task-prompt-transcript-visibility.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Reconcile Inactive Session Transcript Windows
|
||||
|
||||
## Overview
|
||||
|
||||
Revisiting a previously opened agent session will restore one contiguous newest
|
||||
message window. Older persisted rows will remain reachable through the existing
|
||||
upward pagination path.
|
||||
|
||||
This fixes the observed case where one sibling agent sent a durable prompt to
|
||||
another session, the receiver processed it while inactive, and the prompt later
|
||||
could not be reached in the UI.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Reconcile a bounded latest-message response with cached session rows as one
|
||||
contiguous window.
|
||||
- Preserve cached older pages only when they overlap the fetched suffix.
|
||||
- Preserve live rows that arrive while the latest fetch is in flight.
|
||||
- Derive `oldestCursor` from the true contiguous boundary.
|
||||
- Add a pure frontend regression test for overlapping, disjoint, and in-flight
|
||||
message cases.
|
||||
- Add desktop browser evidence for a same-task sibling session that receives an
|
||||
attributed prompt while inactive and accumulates more than 100 later rows.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Message persistence, MCP delivery, sender attribution, or backend pagination
|
||||
queries.
|
||||
- Global or task-wide delivery of session-detail WebSocket traffic.
|
||||
- Subscribing to inactive sibling sessions.
|
||||
- Eagerly loading a complete transcript when a task or session opens.
|
||||
- Changes to transcript layout, grouping, navigation, copy, or touch behavior.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
The receiver prompt was persisted and dispatched to the intended Luna session.
|
||||
The receiver was inactive, so the scoped `BroadcastToSession` correctly had no
|
||||
browser recipient. Luna still received the prompt at the agent runtime and
|
||||
acted on it.
|
||||
|
||||
When the browser revisited Luna 34 minutes later, 156 rows had been persisted
|
||||
after the prompt. The bounded latest fetch returned 100 rows whose oldest row
|
||||
was newer than the prompt. `fetchAndStoreMessages` retained the much older
|
||||
cached rows even though the two sets did not overlap, sorted both sets, and set
|
||||
`oldestCursor` from the oldest cached row. The local array therefore contained
|
||||
an unloaded middle gap while pagination started before that gap. The durable
|
||||
peer prompt became unreachable from the transcript.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Contiguous-window reconciliation
|
||||
|
||||
- Extract a pure latest-window reconciliation helper beside
|
||||
`use-session-messages.ts`.
|
||||
- Capture the cached message IDs before issuing `message.list`.
|
||||
- After the response, detect whether the fetched suffix overlaps the current
|
||||
cached window.
|
||||
- For an overlapping window, deduplicate and sort the complete joined interval.
|
||||
- For a disjoint window, use the fetched suffix as the new base and retain only
|
||||
rows that were not present before the request and arrived through live
|
||||
delivery while it was in flight.
|
||||
- Return both the reconciled messages and the correct oldest cursor. A disjoint
|
||||
prefix must not influence that cursor.
|
||||
- Keep the existing monotonic fetch guard and Zustand identity reconciliation.
|
||||
|
||||
### Browser regression
|
||||
|
||||
- Launch one task with two normal agent sessions and open the receiver first so
|
||||
its initial rows enter the browser cache.
|
||||
- Switch to the sibling so the receiver becomes inactive.
|
||||
- Persist an attributed user prompt on the receiver, followed by enough agent
|
||||
or tool rows to push that prompt outside the newest 100-row fetch.
|
||||
- Restore the receiver's earlier cache snapshot after test-only live broadcasts
|
||||
settle, then trigger the normal foreground-recovery fetch.
|
||||
- Reopen the receiver, navigate upward, and assert the peer prompt and sender
|
||||
badge become visible exactly once.
|
||||
|
||||
## Test strategy
|
||||
|
||||
| Acceptance criterion | Test evidence |
|
||||
| --- | --- |
|
||||
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.4` | The browser flow reaches the attributed prompt through the existing upward pagination action. |
|
||||
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.6` | Collapsed activity pagination continues until the peer prompt becomes a standalone visible entry. |
|
||||
| `AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10` | Pure reconciliation tests and the same-task sibling browser scenario prove that a stale cache cannot skip the persisted middle range. |
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
- Desktop outcome: switching back to a receiver session exposes one contiguous
|
||||
transcript window and makes the peer prompt reachable.
|
||||
- Mobile outcome: the same shared session-message hook and native transcript
|
||||
preserve the identical durable-history result.
|
||||
- Nearest mobile exemplar: `apps/web/components/task/task-layout.tsx` and the
|
||||
existing mobile message-pagination scenarios.
|
||||
- This fix changes state normalization only. It adds no mobile interaction,
|
||||
layout, navigation, scroll-owner, or touch-target behavior.
|
||||
- Focused helper coverage plus the desktop end-to-end regression owns the new
|
||||
behavior. Existing `mobile-message-pagination.spec.ts` remains the responsive
|
||||
proof for the shared cursor and upward-pagination path; no new
|
||||
viewport-specific scenario is required.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Reconcile the latest transcript window](task-01-reconcile-latest-transcript-window.md)
|
||||
|
||||
## Dependency order
|
||||
|
||||
Task 01 is one vertical repair. Its unit regression must fail before the helper
|
||||
is implemented, and its browser regression must fail before the production
|
||||
reconciliation is changed.
|
||||
|
||||
## Verification strategy
|
||||
|
||||
Run the focused unit test first, then the production-build Chromium scenario.
|
||||
Finish with frontend type, lint, and localization ratchet checks. The backend is
|
||||
unchanged. Use a subshell for each command so the documented paths are safe to
|
||||
copy as one shell block.
|
||||
|
||||
## Results
|
||||
|
||||
- Added a pure reconciliation helper that keeps overlapping history, replaces
|
||||
disjoint stale prefixes, and retains rows received during an in-flight fetch.
|
||||
- Wired the helper's contiguous boundary into the existing latest-message
|
||||
fetch without changing backend, WebSocket, or store contracts.
|
||||
- Added unit coverage for all reconciliation branches and a two-session browser
|
||||
regression that reproduces the previously unreachable attributed prompt.
|
||||
- The PR fixup stores the cache baseline on the deduplicated request, compares
|
||||
RFC3339 timestamps at the backend's normalized-microsecond precision, and
|
||||
excludes out-of-order older live rows from a disjoint window until pagination
|
||||
reaches them.
|
||||
- Verified the focused unit suite (27 tests), desktop Chromium regression,
|
||||
existing mobile pagination suite (5 tests), frontend typecheck and lint,
|
||||
i18n ratchet, and specification lint.
|
||||
|
||||
## Risks
|
||||
|
||||
- Retaining every cached extra recreates the gap; discarding every extra can
|
||||
erase a live row that arrived while the fetch was pending.
|
||||
- An overlapping cache can contain legitimately paginated older rows and must
|
||||
keep them.
|
||||
- The cursor must follow the reconciled interval boundary, not simply the
|
||||
first row after sorting unrelated sets.
|
||||
- The test must seed deterministic message order and confirm that the peer
|
||||
prompt is outside the newest response page.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- Broadcasting session-detail messages globally or subscribing to every
|
||||
sibling session conflicts with the bounded session-stream architecture.
|
||||
- Automatically loading every missing page on revisit increases transcript
|
||||
traffic and conflicts with the bounded-opening contract.
|
||||
- Keeping a disjoint prefix and introducing multiple cursors would add a new
|
||||
store model and pagination protocol for a repair that one contiguous window
|
||||
can solve.
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
---
|
||||
created: 2026-08-30
|
||||
status: complete
|
||||
requirements:
|
||||
- REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001
|
||||
acceptance_criteria:
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.4
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.6
|
||||
- AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/task-prompt-transcript-visibility.md
|
||||
depends_on: []
|
||||
---
|
||||
|
||||
# Task 01: Reconcile the Latest Transcript Window
|
||||
|
||||
## Outcome
|
||||
|
||||
A revisited session stores one contiguous newest message interval, uses its
|
||||
true oldest cursor, and can paginate to an attributed peer prompt that was
|
||||
persisted while the session was inactive.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add a pure latest-window reconciliation helper for session messages.
|
||||
- Track which cached IDs existed when a latest fetch began.
|
||||
- Join overlapping cached and fetched windows without duplicates.
|
||||
- Replace a disjoint cached prefix while retaining live rows received during
|
||||
the fetch.
|
||||
- Apply the helper and its cursor to `fetchAndStoreMessages`.
|
||||
- Add focused unit tests before the implementation.
|
||||
- Add a deterministic desktop Playwright regression for same-task sibling
|
||||
session history.
|
||||
|
||||
## Exclusions
|
||||
|
||||
- Backend, database, MCP, and WebSocket routing changes.
|
||||
- New API fields, pagination modes, or eager gap filling.
|
||||
- Responsive layout or interaction changes.
|
||||
- New user-facing text or localization keys.
|
||||
|
||||
## Implementation acceptance conditions
|
||||
|
||||
1. A disjoint stale cache and newest response produce only the contiguous
|
||||
fetched suffix plus rows proven to have arrived during the request, with
|
||||
`oldestCursor` equal to the fetched suffix boundary.
|
||||
2. An overlapping response preserves cached older pages, deduplicates shared
|
||||
rows, retains live additions, and keeps one chronological interval.
|
||||
3. In a browser flow with two sessions on one task, a receiver cached before it
|
||||
becomes inactive can later paginate to exactly one attributed sibling prompt
|
||||
after more than 100 newer rows have been persisted.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add helper tests for an overlapping window, a disjoint stale window, and a
|
||||
live row that appears after the fetch begins. Run them and record the RED
|
||||
failure.
|
||||
2. Add the same-task sibling Playwright scenario and record that the prompt is
|
||||
unreachable with the stale cursor.
|
||||
3. Implement the smallest pure reconciliation helper and wire it into the
|
||||
latest-fetch path.
|
||||
4. Run the focused unit and browser tests to GREEN, then refactor without
|
||||
widening the store or backend contracts.
|
||||
|
||||
## Likely files
|
||||
|
||||
- `apps/web/hooks/domains/session/use-session-messages.ts`
|
||||
- `apps/web/hooks/domains/session/message-window-reconciliation.ts`
|
||||
- `apps/web/hooks/domains/session/message-window-reconciliation.test.ts`
|
||||
- `apps/web/e2e/tests/chat/inactive-session-transcript-reconciliation.spec.ts`
|
||||
- `apps/web/e2e/helpers/api-client.ts` only if the existing message seed helpers
|
||||
cannot express the deterministic same-task scenario.
|
||||
|
||||
## Exact verification commands
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm --filter @kandev/web exec vitest run hooks/domains/session/message-window-reconciliation.test.ts)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/chat/inactive-session-transcript-reconciliation.spec.ts)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm run lint)
|
||||
(cd apps/web && pnpm run i18n:ratchet)
|
||||
```
|
||||
|
||||
## Results
|
||||
|
||||
- RED unit evidence: the pre-fix hook retained `stale-1` and `stale-2` around a
|
||||
disjoint newest response and derived `oldestCursor` from `stale-1`.
|
||||
- RED browser evidence: the newest receiver message loaded, but upward
|
||||
pagination could not reach the persisted attributed peer prompt.
|
||||
- GREEN implementation: `reconcileLatestMessageWindow` joins overlapping
|
||||
windows, replaces disjoint prefixes, preserves in-flight live additions, and
|
||||
returns the cursor for the actual contiguous boundary.
|
||||
- PR fixup: the deduplicated request owns the first cache baseline, timestamp
|
||||
ordering preserves the backend's normalized-microsecond precision, and older
|
||||
out-of-order live rows remain for the next page instead of preceding the
|
||||
cursor boundary.
|
||||
- GREEN verification:
|
||||
- `pnpm --filter @kandev/web exec vitest run hooks/domains/session/message-window-reconciliation.test.ts hooks/domains/session/use-session-messages.test.ts` (27 passed)
|
||||
- `pnpm e2e:run --project chromium tests/chat/inactive-session-transcript-reconciliation.spec.ts` (1 passed)
|
||||
- `pnpm e2e:run --project mobile-chrome tests/chat/mobile-message-pagination.spec.ts` (5 passed)
|
||||
- `pnpm run typecheck`
|
||||
- `pnpm run lint`
|
||||
- `pnpm run i18n:ratchet`
|
||||
- `python3 scripts/lint-spec-files.py --all`
|
||||
@@ -28,6 +28,7 @@ The transcript can lose the original task prompt after a reload. Long activity g
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.7:** Stop automatic loading in these cases:
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.8:** Older content moves the load boundary above the preload region.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.9:** When the first user prompt of a session is loaded, the transcript shall not show an older-page control.
|
||||
- **AC-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001.10:** When a previously opened session is revisited after more than one message page was persisted while it was inactive, the transcript shall reconcile to a contiguous newest window and upward pagination shall reach every persisted user prompt without gaps or duplicate rows.
|
||||
|
||||
## Migrated source detail
|
||||
|
||||
@@ -63,6 +64,8 @@ prompt. This requirement includes sessions with more than 100 tool events.
|
||||
response.
|
||||
- Show the first prompt at the start after all older history is loaded.
|
||||
- Apply the same transcript rule on desktop and mobile.
|
||||
- Reconcile a revisited session to one contiguous newest window before older
|
||||
pagination resumes. Stale cached rows must not create an unreachable gap.
|
||||
|
||||
## Scenarios
|
||||
|
||||
@@ -104,6 +107,15 @@ prompt. This requirement includes sessions with more than 100 tool events.
|
||||
- **THEN** the first prompt is visible at the start of the transcript.
|
||||
- **THEN** no older-page control remains.
|
||||
|
||||
### Revisit after inactive-session activity
|
||||
|
||||
- **GIVEN** a session was opened and its older rows remain cached.
|
||||
- **GIVEN** more than one newest-page window is persisted while that session is
|
||||
inactive.
|
||||
- **WHEN** the user revisits the session and navigates upward.
|
||||
- **THEN** the transcript exposes every persisted user prompt in chronological
|
||||
order without a skipped middle range or duplicate row.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Backfill or repair missing message records in the database.
|
||||
@@ -114,4 +126,5 @@ prompt. This requirement includes sessions with more than 100 tool events.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
See [the implementation plan](../../../plans/hide-redundant-older-messages-control/plan.md).
|
||||
- [Hide redundant older-message controls](../../../plans/hide-redundant-older-messages-control/plan.md)
|
||||
- [Reconcile inactive-session transcript windows](../../../plans/inactive-session-transcript-reconciliation/plan.md)
|
||||
|
||||
@@ -20,13 +20,16 @@ The design changes no backend query, message order, persistence rule, or API fie
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001` | [Opening boundary](#opening-boundary), [Pagination boundary](#pagination-boundary), [Failure and compatibility](#failure-and-compatibility) |
|
||||
| `REQ-UI-TASK-PROMPT-TRANSCRIPT-VISIBILITY-001` | [Opening boundary](#opening-boundary), [Latest-window reconciliation](#latest-window-reconciliation), [Pagination boundary](#pagination-boundary), [Failure and compatibility](#failure-and-compatibility) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
- `useLazyLoadMessages` owns the visible pagination boundary for transcript consumers.
|
||||
- The session message fetch owns one bounded initial request for the newest message window.
|
||||
- `messages.metaBySession[sessionId].hasMore` keeps the raw backend `has_more` value.
|
||||
- `messages.metaBySession[sessionId].oldestCursor` names the oldest row in the
|
||||
contiguous loaded window, never an older row separated from the newest
|
||||
snapshot by an unloaded gap.
|
||||
- `messages.bySession[sessionId]` supplies the loaded messages and their prompt ordinals.
|
||||
- The native transcript and Prompt History panel consume the shared hook result.
|
||||
- The native transcript owns the upward-navigation intent and the visible top-boundary key.
|
||||
@@ -45,6 +48,27 @@ The session message fetch requests only the newest bounded window when a task op
|
||||
|
||||
If the newest window has no user message, the transcript shows the task-description fallback. Upward navigation then loads older pages through visible pagination.
|
||||
|
||||
## Latest-window reconciliation
|
||||
|
||||
The frontend represents each session transcript as one contiguous loaded
|
||||
window. A latest-message fetch reconciles its bounded newest suffix with the
|
||||
rows already cached for that session:
|
||||
|
||||
- If the fetched suffix overlaps the cached window, deduplicate and retain the
|
||||
complete joined window. The oldest cursor remains the oldest row in that
|
||||
joined window.
|
||||
- If the fetched suffix does not overlap the cached window, the older cached
|
||||
rows cannot be joined under one pagination cursor. Replace that disjoint
|
||||
cache with the fetched suffix. Retain only live rows that arrived after the
|
||||
fetch started and therefore follow the response snapshot.
|
||||
- Set the oldest cursor from the actual contiguous boundary. A disjoint cached
|
||||
row must never become the cursor for the newest fetched suffix.
|
||||
|
||||
The dropped browser cache is not data loss. Persisted messages remain
|
||||
authoritative and are loaded again through normal upward pagination. This rule
|
||||
also preserves the bounded opening behavior: revisiting a session does not
|
||||
subscribe to inactive siblings or eagerly load its complete transcript.
|
||||
|
||||
## Pagination boundary
|
||||
|
||||
The frontend derives a visible `hasMore` value from two inputs:
|
||||
@@ -79,21 +103,28 @@ Scroll restoration remains part of the same cycle. The transcript restores one s
|
||||
## Control flow
|
||||
|
||||
1. The initial message request stores one newest suffix and raw pagination metadata.
|
||||
2. The shared hook reports visible older history while prompt `#1` is absent.
|
||||
3. The user reaches the oldest loaded point.
|
||||
4. An older-page request prepends messages through the existing coordinator.
|
||||
5. The native transcript preserves a stable message-row position across the complete request.
|
||||
6. The transcript compares the committed visible top boundary with the request baseline.
|
||||
7. If the boundary changed, the current load cycle stops.
|
||||
8. If only a collapsed activity group changed, the same cycle can request another page.
|
||||
9. If the page contains prompt `#1`, the shared hook changes its visible value to false.
|
||||
10. The transcript removes its sentinel, loading state, and older-page control.
|
||||
11. Explicit reverse search uses raw pagination when it must backfill a backend search hit.
|
||||
2. The fetch reconciles that suffix with any cached rows as one contiguous
|
||||
window and records its true oldest cursor.
|
||||
3. The shared hook reports visible older history while prompt `#1` is absent.
|
||||
4. The user reaches the oldest loaded point.
|
||||
5. An older-page request prepends messages through the existing coordinator.
|
||||
6. The native transcript preserves a stable message-row position across the complete request.
|
||||
7. The transcript compares the committed visible top boundary with the request baseline.
|
||||
8. If the boundary changed, the current load cycle stops.
|
||||
9. If only a collapsed activity group changed, the same cycle can request another page.
|
||||
10. If the page contains prompt `#1`, the shared hook changes its visible value to false.
|
||||
11. The transcript removes its sentinel, loading state, and older-page control.
|
||||
12. Explicit reverse search uses raw pagination when it must backfill a backend search hit.
|
||||
|
||||
## Failure and compatibility
|
||||
|
||||
If a request fails before prompt `#1` is known, the explicit older-page control remains available. A zero-result response keeps the existing retry behavior.
|
||||
|
||||
If a latest fetch is disjoint from a stale cache, the UI temporarily stops
|
||||
rendering the stale prefix rather than presenting a false contiguous window.
|
||||
The older-page control remains available from the fetched suffix and reloads
|
||||
that durable history from the correct cursor.
|
||||
|
||||
An initial tool-only window completes without an older-page request. The task-description fallback remains visible until upward pagination loads a stored user prompt.
|
||||
|
||||
Older payloads can omit `prompt_index`. In this case, the shared hook uses raw `has_more` exhaustion as the compatibility fallback.
|
||||
@@ -121,6 +152,12 @@ The existing full-height mobile task layout remains the nearest mobile exemplar.
|
||||
- Desktop and mobile browser tests seed hidden pre-prompt rows and prove that no older-page control remains.
|
||||
- Desktop and mobile browser tests prove that task opening makes no request with an older-page cursor.
|
||||
- A message-fetch test proves that a tool-only initial window completes without automatic backfill.
|
||||
- Latest-window reconciliation tests cover overlapping windows, a disjoint
|
||||
stale cache, and live rows received while the fetch is in flight.
|
||||
- A same-task sibling-session browser test caches an older receiver window,
|
||||
persists a peer prompt plus more than one page while the receiver is
|
||||
inactive, revisits it, and reaches the attributed prompt through upward
|
||||
pagination.
|
||||
- Separate desktop and mobile browser tests preserve the prepend anchor while loading older pages.
|
||||
- Desktop and mobile browser tests prove that one upward action loads only one page when that page adds visible entries.
|
||||
- A collapsed-activity browser scenario proves that the same load cycle continues until a new visible top entry appears.
|
||||
|
||||
Reference in New Issue
Block a user