From 9279e5ea19ea679fa5713c9be44bbd45ae128602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 30 Aug 2026 16:03:27 +0000 Subject: [PATCH] fix(web): reconcile inactive session transcript windows (#3159) --- ...-session-transcript-reconciliation.spec.ts | 203 ++++++++++++++++++ .../message-window-reconciliation.test.ts | 108 ++++++++++ .../session/message-window-reconciliation.ts | 84 ++++++++ .../session/use-session-messages.test.ts | 124 +++++++++++ .../domains/session/use-session-messages.ts | 38 ++-- .../plan.md | 165 ++++++++++++++ ...k-01-reconcile-latest-transcript-window.md | 104 +++++++++ .../task-prompt-transcript-visibility.md | 15 +- .../task-prompt-transcript-visibility.md | 59 ++++- 9 files changed, 869 insertions(+), 31 deletions(-) create mode 100644 apps/web/e2e/tests/chat/inactive-session-transcript-reconciliation.spec.ts create mode 100644 apps/web/hooks/domains/session/message-window-reconciliation.test.ts create mode 100644 apps/web/hooks/domains/session/message-window-reconciliation.ts create mode 100644 docs/plans/inactive-session-transcript-reconciliation/plan.md create mode 100644 docs/plans/inactive-session-transcript-reconciliation/task-01-reconcile-latest-transcript-window.md diff --git a/apps/web/e2e/tests/chat/inactive-session-transcript-reconciliation.spec.ts b/apps/web/e2e/tests/chat/inactive-session-transcript-reconciliation.spec.ts new file mode 100644 index 0000000000..5a49a4489e --- /dev/null +++ b/apps/web/e2e/tests/chat/inactive-session-transcript-reconciliation.spec.ts @@ -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 }; + setMessages: (sessionId: string, messages: Message[]) => void; + }; + }; +}; + +async function snapshotCachedMessages(page: Page, sessionId: string): Promise { + 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 { + 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); + }); +}); diff --git a/apps/web/hooks/domains/session/message-window-reconciliation.test.ts b/apps/web/hooks/domains/session/message-window-reconciliation.test.ts new file mode 100644 index 0000000000..19138eff6c --- /dev/null +++ b/apps/web/hooks/domains/session/message-window-reconciliation.test.ts @@ -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" }); + }); +}); diff --git a/apps/web/hooks/domains/session/message-window-reconciliation.ts b/apps/web/hooks/domains/session/message-window-reconciliation.ts new file mode 100644 index 0000000000..eb6b7b0673 --- /dev/null +++ b/apps/web/hooks/domains/session/message-window-reconciliation.ts @@ -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 }; +} diff --git a/apps/web/hooks/domains/session/use-session-messages.test.ts b/apps/web/hooks/domains/session/use-session-messages.test.ts index 59174778d9..86d9a38411 100644 --- a/apps/web/hooks/domains/session/use-session-messages.test.ts +++ b/apps/web/hooks/domains/session/use-session-messages.test.ts @@ -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() { } 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(); + 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(); 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(); + 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(); + }); +}); diff --git a/apps/web/hooks/domains/session/use-session-messages.ts b/apps/web/hooks/domains/session/use-session-messages.ts index c1b9c4d977..d270d68dfd 100644 --- a/apps/web/hooks/domains/session/use-session-messages.ts +++ b/apps/web/hooks/domains/session/use-session-messages.ts @@ -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; promise: Promise; + cachedAtRequest: Message[]; }; const EMPTY_MESSAGES: Message[] = []; @@ -174,9 +176,10 @@ function requestSessionMessages( client: NonNullable>, sessionId: string, readiness: Promise, -): Promise { + 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("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. diff --git a/docs/plans/inactive-session-transcript-reconciliation/plan.md b/docs/plans/inactive-session-transcript-reconciliation/plan.md new file mode 100644 index 0000000000..cedae5199c --- /dev/null +++ b/docs/plans/inactive-session-transcript-reconciliation/plan.md @@ -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. diff --git a/docs/plans/inactive-session-transcript-reconciliation/task-01-reconcile-latest-transcript-window.md b/docs/plans/inactive-session-transcript-reconciliation/task-01-reconcile-latest-transcript-window.md new file mode 100644 index 0000000000..bd8d6b63bc --- /dev/null +++ b/docs/plans/inactive-session-transcript-reconciliation/task-01-reconcile-latest-transcript-window.md @@ -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` diff --git a/docs/specs/ui/requirements/task-prompt-transcript-visibility.md b/docs/specs/ui/requirements/task-prompt-transcript-visibility.md index 55fc5ffd0b..2bce5ad937 100644 --- a/docs/specs/ui/requirements/task-prompt-transcript-visibility.md +++ b/docs/specs/ui/requirements/task-prompt-transcript-visibility.md @@ -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) diff --git a/docs/specs/ui/system-design/task-prompt-transcript-visibility.md b/docs/specs/ui/system-design/task-prompt-transcript-visibility.md index b846496814..d9f540e7b4 100644 --- a/docs/specs/ui/system-design/task-prompt-transcript-visibility.md +++ b/docs/specs/ui/system-design/task-prompt-transcript-visibility.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.