fix(web): reconcile inactive session transcript windows (#3159)

This commit is contained in:
Carlos Florêncio
2026-08-30 16:03:27 +00:00
committed by GitHub
parent 56138f4a60
commit 9279e5ea19
9 changed files with 869 additions and 31 deletions
@@ -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.
@@ -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.