From fcbc7900bbd9b154d096810195fd11f367e0f0b3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 15 Jul 2026 02:07:33 +0100 Subject: [PATCH] test: harden chat transcript validation (#107893) --- ui/src/e2e/chat-flow.e2e.test.ts | 22 +- ui/src/e2e/claude-sessions.e2e.test.ts | 10 +- ui/src/pages/chat/chat-pane-history.test.ts | 537 ++++++++++++++++++++ ui/src/pages/chat/chat-pane.test.ts | 434 ---------------- ui/src/pages/chat/chat-thread.test.ts | 7 +- ui/src/pages/chat/chat-view.test.ts | 30 +- ui/src/pages/chat/components/chat-thread.ts | 2 +- ui/src/pages/chat/session-message-cache.ts | 18 +- 8 files changed, 587 insertions(+), 473 deletions(-) create mode 100644 ui/src/pages/chat/chat-pane-history.test.ts diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts index 3aea621ba7a9..b0b658469d71 100644 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -288,9 +288,9 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { await page.locator("openclaw-chat-pane").evaluate((pane) => { ( globalThis as typeof globalThis & { - __classicChatPane?: Element; + classicChatPane?: Element; } - ).__classicChatPane = pane; + ).classicChatPane = pane; }); const startupRequestsBeforeSplit = (await gateway.getRequests("chat.startup")).length; await gateway.deferNext("chat.startup"); @@ -313,9 +313,9 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { (pane) => ( globalThis as typeof globalThis & { - __classicChatPane?: Element; + classicChatPane?: Element; } - ).__classicChatPane === pane, + ).classicChatPane === pane, ), ) .toBe(true); @@ -2426,9 +2426,9 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { const samples: FrameSample[] = []; ( globalThis as typeof globalThis & { - __chatSessionReturnSamples: FrameSample[]; + chatSessionReturnSamples: FrameSample[]; } - ).__chatSessionReturnSamples = samples; + ).chatSessionReturnSamples = samples; const deadline = performance.now() + 750; const sample = () => { const pane = document.querySelector("openclaw-chat-pane") as @@ -2438,7 +2438,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { | null; const rows = Array.from(document.querySelectorAll("[data-chat-row-key]")); samples.push({ - hiddenNotice: document.body.textContent?.includes("Showing last") === true, + hiddenNotice: document.body.textContent?.includes("Showing last") ?? false, loading: document.querySelector(".chat-history-loading") !== null, messageCount: pane?.state?.chatMessages?.length ?? 0, minOpacity: rows.reduce( @@ -2456,12 +2456,14 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { await sessionB.click(); await page.getByText(/^recent retained message 140\n/).waitFor({ timeout: 10_000 }); - await new Promise((resolve) => setTimeout(resolve, 800)); + await new Promise((resolve) => { + setTimeout(resolve, 800); + }); const samples = await page.evaluate( () => ( globalThis as typeof globalThis & { - __chatSessionReturnSamples: Array<{ + chatSessionReturnSamples: Array<{ hiddenNotice: boolean; loading: boolean; messageCount: number; @@ -2469,7 +2471,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { sessionKey: string; }>; } - ).__chatSessionReturnSamples, + ).chatSessionReturnSamples, ); const returnedSamples = samples.filter( (sample) => sample.sessionKey === "agent:main:session-b", diff --git a/ui/src/e2e/claude-sessions.e2e.test.ts b/ui/src/e2e/claude-sessions.e2e.test.ts index ad1cb621331c..3a62aaa78f24 100644 --- a/ui/src/e2e/claude-sessions.e2e.test.ts +++ b/ui/src/e2e/claude-sessions.e2e.test.ts @@ -54,7 +54,7 @@ type VirtualRowPrependSample = { async function startVirtualRowPrependProbe(thread: Locator, anchor: VisibleVirtualRow) { await thread.evaluate((element, expected) => { const target = globalThis as typeof globalThis & { - __chatPrependProbe?: { + chatPrependProbe?: { observer: MutationObserver; samples: VirtualRowPrependSample[]; }; @@ -92,24 +92,24 @@ async function startVirtualRowPrependProbe(thread: Locator, anchor: VisibleVirtu childList: true, subtree: true, }); - target.__chatPrependProbe = { observer, samples }; + target.chatPrependProbe = { observer, samples }; }, anchor); } async function finishVirtualRowPrependProbe(thread: Locator) { return thread.evaluate(() => { const target = globalThis as typeof globalThis & { - __chatPrependProbe?: { + chatPrependProbe?: { observer: MutationObserver; samples: VirtualRowPrependSample[]; }; }; - const probe = target.__chatPrependProbe; + const probe = target.chatPrependProbe; if (!probe) { throw new Error("expected an active virtual row prepend probe"); } probe.observer.disconnect(); - delete target.__chatPrependProbe; + delete target.chatPrependProbe; return probe.samples; }); } diff --git a/ui/src/pages/chat/chat-pane-history.test.ts b/ui/src/pages/chat/chat-pane-history.test.ts new file mode 100644 index 000000000000..ab08394c09dd --- /dev/null +++ b/ui/src/pages/chat/chat-pane-history.test.ts @@ -0,0 +1,537 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; +import type { SessionCatalogTranscriptItem } from "../../../../packages/gateway-protocol/src/index.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ApplicationContext } from "../../app/context.ts"; +import type { SessionCapability } from "../../lib/sessions/index.ts"; +import "./chat-pane.ts"; +import { loadChatHistory } from "./chat-history.ts"; +import type { ChatPageHost } from "./chat-state.ts"; + +type TestChatPane = HTMLElement & { + catalogMessages: unknown[]; + context: ApplicationContext; + state: ChatPageHost; + connectedClient: GatewayBrowserClient | null; + connectionGeneration: number; + catalogItemMessage: (item: SessionCatalogTranscriptItem) => Record | null; + handleTranscriptScroll: (event: Event) => void; + historyAutoLoadBlocked: boolean; + historyObserverArmed: boolean; + syncHistoryObserver: () => void; + prependUniqueNativeMessages: (messages: unknown[], current: unknown[]) => unknown[]; + prependUniqueCatalogMessages: (messages: unknown[]) => unknown[]; + loadOlderMessages: () => Promise; + hasOlderMessages: () => boolean; + loadingOlder: boolean; + olderOffsetsSeen: Set; +}; + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function createSessionContext( + client: GatewayBrowserClient, + sessions: SessionCapability, +): ApplicationContext { + return { + gateway: { + snapshot: { + client, + connected: true, + hello: { features: { methods: ["taskSuggestions.list"] } }, + }, + }, + agents: { state: { agentsList: null } }, + sessions, + } as unknown as ApplicationContext; +} + +function createTestChatPane(params: { client: GatewayBrowserClient; sessions: SessionCapability }) { + const pane = document.createElement("openclaw-chat-pane") as unknown as TestChatPane; + Object.defineProperty(pane, "isConnected", { + configurable: true, + value: true, + }); + const requestUpdate = vi.fn(); + const state = { + agentsList: null, + assistantAgentId: null, + chatError: null, + chatHistoryPagination: { hasMore: false }, + chatLoading: false, + chatMessages: [], + chatQueue: [], + chatRunId: null, + chatSending: false, + chatStream: null, + client: params.client, + connected: true, + connectionEpoch: 4, + hello: null, + lastError: null, + requestUpdate, + sessionKey: "agent:main:current", + sessions: params.sessions, + sessionsError: null, + sessionsLoading: false, + sidebarContent: null, + sidebarOpen: false, + chatScrollGeneration: 0, + chatScrollCommitCleanup: null, + handleChatScroll: vi.fn(), + renderLifecycle: { afterCommit: () => () => {}, invalidate: () => {} }, + } as unknown as ChatPageHost; + pane.context = createSessionContext(params.client, params.sessions); + pane.state = state; + pane.connectedClient = params.client; + pane.connectionGeneration = 4; + return { pane, state }; +} + +function nativeHistoryMessage(seq: number, text = `message ${seq}`) { + return { + role: seq % 2 === 0 ? "assistant" : "user", + content: [{ type: "text", text }], + __openclaw: { seq }, + }; +} + +function nativeHistorySeq(message: unknown): number | undefined { + const metadata = (message as Record)["__openclaw"] as + | Record + | undefined; + return typeof metadata?.seq === "number" ? metadata.seq : undefined; +} + +describe("chat pane native history pagination", () => { + it("does not request older rows from a complete imported snapshot", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.chatHistoryPagination = { + hasMore: false, + totalMessages: 107, + completeSnapshot: true, + }; + + expect(pane.hasOlderMessages()).toBe(false); + }); + + it("auto-loads a visible sentinel when the initial tail is not scrollable", async () => { + const request = vi.fn(async () => ({ + messages: [nativeHistoryMessage(1), nativeHistoryMessage(2)], + hasMore: false, + totalMessages: 4, + })); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)]; + state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; + const thread = document.createElement("div"); + thread.className = "chat-thread"; + Object.defineProperty(thread, "scrollHeight", { value: 100 }); + Object.defineProperty(thread, "clientHeight", { value: 200 }); + const sentinel = document.createElement("div"); + sentinel.className = "chat-history-sentinel"; + thread.append(sentinel); + pane.append(thread); + const observe = vi.fn(); + class FakeIntersectionObserver { + constructor(private readonly callback: IntersectionObserverCallback) {} + disconnect() {} + observe(target: Element) { + observe(target); + this.callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + } + } + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + try { + pane.syncHistoryObserver(); + await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); + expect(observe).toHaveBeenCalledWith(sentinel); + await vi.waitFor(() => + expect(state.chatMessages.map(nativeHistorySeq)).toEqual([1, 2, 3, 4]), + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("does not consume bootstrap history while disconnected", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.connected = false; + state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; + const construct = vi.fn(); + class FakeIntersectionObserver { + constructor() { + construct(); + } + disconnect() {} + observe() {} + } + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + + pane.syncHistoryObserver(); + + expect(construct).not.toHaveBeenCalled(); + expect(pane.historyAutoLoadBlocked).toBe(false); + }); + + it("stops non-scrollable bootstrap after one older page", async () => { + const request = vi.fn(async () => ({ + messages: [nativeHistoryMessage(3), nativeHistoryMessage(4)], + hasMore: true, + nextOffset: 4, + totalMessages: 6, + })); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.chatMessages = [nativeHistoryMessage(5), nativeHistoryMessage(6)]; + state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 6 }; + const thread = document.createElement("div"); + thread.className = "chat-thread"; + Object.defineProperty(thread, "scrollHeight", { value: 100 }); + Object.defineProperty(thread, "clientHeight", { value: 200 }); + const sentinel = document.createElement("div"); + sentinel.className = "chat-history-sentinel"; + thread.append(sentinel); + pane.append(thread); + class FakeIntersectionObserver { + constructor(private readonly callback: IntersectionObserverCallback) {} + disconnect() {} + observe() { + this.callback( + [{ isIntersecting: true } as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + } + } + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + try { + pane.syncHistoryObserver(); + await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); + await vi.waitFor(() => + expect(state.chatMessages.map(nativeHistorySeq)).toEqual([3, 4, 5, 6]), + ); + + pane.syncHistoryObserver(); + + expect(request).toHaveBeenCalledOnce(); + expect(pane.historyAutoLoadBlocked).toBe(true); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("reuses an unchanged armed history observer across pane updates", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; + pane.historyObserverArmed = true; + const thread = document.createElement("div"); + thread.className = "chat-thread"; + Object.defineProperty(thread, "scrollHeight", { value: 400 }); + Object.defineProperty(thread, "clientHeight", { value: 200 }); + const sentinel = document.createElement("div"); + sentinel.className = "chat-history-sentinel"; + thread.append(sentinel); + pane.append(thread); + const observe = vi.fn(); + const disconnect = vi.fn(); + const construct = vi.fn(); + class FakeIntersectionObserver { + constructor() { + construct(); + } + disconnect() { + disconnect(); + } + observe(target: Element) { + observe(target); + } + } + vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + try { + pane.syncHistoryObserver(); + pane.syncHistoryObserver(); + + expect(construct).toHaveBeenCalledOnce(); + expect(observe).toHaveBeenCalledOnce(); + expect(observe).toHaveBeenCalledWith(sentinel); + expect(disconnect).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("keeps multiple projected messages from the same transcript sequence", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); + const projected = [ + nativeHistoryMessage(1, "tool call"), + nativeHistoryMessage(1, "visible tool reply"), + ]; + + expect(pane.prependUniqueNativeMessages(projected, [nativeHistoryMessage(2)])).toEqual([ + ...projected, + nativeHistoryMessage(2), + ]); + expect(pane.prependUniqueNativeMessages(projected, projected)).toEqual(projected); + expect( + pane.prependUniqueNativeMessages(projected, [projected[1], nativeHistoryMessage(2)]), + ).toEqual([projected[0], projected[1], nativeHistoryMessage(2)]); + }); + + it("deduplicates projected catalog transcript records by catalog message id", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); + const current = pane.catalogItemMessage({ + id: "catalog-item-1", + type: "userMessage", + text: "newer projection", + }); + const overlapping = pane.catalogItemMessage({ + id: "catalog-item-1", + type: "userMessage", + text: "older projection", + }); + if (!current || !overlapping) { + throw new Error("expected catalog transcript projections"); + } + pane.catalogMessages = [current]; + + expect(pane.prependUniqueCatalogMessages([overlapping])).toEqual([current]); + }); + + it("prepends a strictly older page and exhausts", async () => { + const request = vi.fn(async () => ({ + messages: [nativeHistoryMessage(1), nativeHistoryMessage(2)], + hasMore: false, + totalMessages: 4, + })); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)]; + state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; + await pane.loadOlderMessages(); + + expect(request).toHaveBeenCalledWith("chat.history", { + sessionKey: state.sessionKey, + limit: 100, + offset: 2, + }); + expect(state.chatMessages.map(nativeHistorySeq)).toEqual([1, 2, 3, 4]); + expect(state.chatHistoryPagination).toEqual({ hasMore: false, totalMessages: 4 }); + expect(state.lastError).toBeNull(); + expect(pane.hasOlderMessages()).toBe(false); + + await pane.loadOlderMessages(); + expect(request).toHaveBeenCalledOnce(); + }); + + it("allows only one native older-page request in flight", async () => { + const deferred = createDeferred<{ + messages: unknown[]; + hasMore: boolean; + totalMessages: number; + }>(); + const request = vi.fn(() => deferred.promise); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)]; + state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; + + const first = pane.loadOlderMessages(); + const second = pane.loadOlderMessages(); + expect(pane.loadingOlder).toBe(true); + expect(state.requestUpdate).toHaveBeenCalled(); + expect(request).toHaveBeenCalledOnce(); + + deferred.resolve({ messages: [], hasMore: false, totalMessages: 4 }); + await Promise.all([first, second]); + expect(pane.loadingOlder).toBe(false); + }); + + it("refreshes the tail instead of mixing an older page from a replacement session", async () => { + const request = vi + .fn() + .mockResolvedValueOnce({ + messages: [nativeHistoryMessage(1), nativeHistoryMessage(2)], + hasMore: false, + totalMessages: 2, + sessionInfo: { sessionId: "session-new" }, + }) + .mockResolvedValueOnce({ + messages: [nativeHistoryMessage(7), nativeHistoryMessage(8)], + hasMore: false, + totalMessages: 2, + sessionInfo: { sessionId: "session-new" }, + }); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.currentSessionId = "session-old"; + state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)]; + state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; + + await pane.loadOlderMessages(); + + expect(request).toHaveBeenNthCalledWith(1, "chat.history", { + sessionKey: state.sessionKey, + limit: 100, + offset: 2, + }); + expect(request).toHaveBeenNthCalledWith( + 2, + "chat.history", + expect.objectContaining({ sessionKey: state.sessionKey, limit: 100 }), + ); + expect(state.currentSessionId).toBe("session-new"); + expect(state.chatMessages.map(nativeHistorySeq)).toEqual([7, 8]); + }); + + it("revalidates the tail without discarding loaded depth for the same backing session", async () => { + const request = vi.fn(async () => ({ + messages: [nativeHistoryMessage(3), nativeHistoryMessage(4)], + hasMore: true, + nextOffset: 2, + totalMessages: 4, + sessionInfo: { sessionId: "session-current" }, + })); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.currentSessionId = "session-current"; + state.chatMessages = [ + nativeHistoryMessage(1), + nativeHistoryMessage(2), + nativeHistoryMessage(3), + nativeHistoryMessage(4), + ]; + state.chatHistoryPagination = { hasMore: false, totalMessages: 4 }; + pane.olderOffsetsSeen.add(2); + pane.olderOffsetsSeen.add(4); + + await loadChatHistory(state); + + expect(state.chatMessages.map(nativeHistorySeq)).toEqual([1, 2, 3, 4]); + expect(state.chatHistoryPagination).toEqual({ + hasMore: false, + totalMessages: 4, + }); + expect(pane.hasOlderMessages()).toBe(false); + expect(pane.olderOffsetsSeen).toEqual(new Set()); + }); + + it("keeps projected siblings while replacing the overlapping tail", async () => { + const firstProjection = nativeHistoryMessage(3, "first projection"); + const secondProjection = nativeHistoryMessage(3, "second projection"); + const request = vi.fn(async () => ({ + messages: [firstProjection, secondProjection, nativeHistoryMessage(4)], + hasMore: true, + nextOffset: 2, + totalMessages: 4, + sessionInfo: { sessionId: "session-current" }, + })); + const client = { request } as unknown as GatewayBrowserClient; + const { state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.currentSessionId = "session-current"; + state.chatMessages = [ + nativeHistoryMessage(1), + nativeHistoryMessage(2), + nativeHistoryMessage(3, "stale projection"), + nativeHistoryMessage(4, "stale latest"), + ]; + state.chatHistoryPagination = { hasMore: false, totalMessages: 4 }; + + await loadChatHistory(state); + + expect(state.chatMessages).toEqual([ + nativeHistoryMessage(1), + nativeHistoryMessage(2), + firstProjection, + secondProjection, + nativeHistoryMessage(4), + ]); + }); + + it("replaces the tail when the refreshed raw range does not overlap loaded history", async () => { + const request = vi.fn(async () => ({ + messages: [nativeHistoryMessage(7), nativeHistoryMessage(8)], + hasMore: true, + nextOffset: 2, + totalMessages: 8, + sessionInfo: { sessionId: "session-current" }, + })); + const client = { request } as unknown as GatewayBrowserClient; + const { state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.currentSessionId = "session-current"; + state.chatMessages = [ + nativeHistoryMessage(1), + nativeHistoryMessage(2), + nativeHistoryMessage(3), + nativeHistoryMessage(4), + ]; + state.chatHistoryPagination = { hasMore: false, totalMessages: 4 }; + + await loadChatHistory(state); + + expect(state.chatMessages.map(nativeHistorySeq)).toEqual([7, 8]); + expect(state.chatHistoryPagination).toEqual({ + hasMore: true, + nextOffset: 2, + totalMessages: 8, + }); + }); + + it("preserves loaded visible rows when an adjacent refreshed page projects empty", async () => { + const request = vi.fn(async () => ({ + messages: [], + hasMore: true, + nextOffset: 2, + totalMessages: 6, + sessionInfo: { sessionId: "session-current" }, + })); + const client = { request } as unknown as GatewayBrowserClient; + const { state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + state.currentSessionId = "session-current"; + state.chatMessages = [ + nativeHistoryMessage(1), + nativeHistoryMessage(2), + nativeHistoryMessage(3), + nativeHistoryMessage(4), + ]; + state.chatHistoryPagination = { hasMore: false, totalMessages: 4 }; + + await loadChatHistory(state); + + expect(state.chatMessages.map(nativeHistorySeq)).toEqual([1, 2, 3, 4]); + expect(state.chatHistoryPagination).toEqual({ + hasMore: false, + totalMessages: 6, + }); + }); + + it("preserves the older-page cursor when a tail refresh fails", async () => { + const client = { + request: vi.fn(async () => { + throw new Error("gateway unavailable"); + }), + } as unknown as GatewayBrowserClient; + const { state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + const pagination = { hasMore: true as const, nextOffset: 2, totalMessages: 4 }; + state.chatHistoryPagination = pagination; + + await loadChatHistory(state); + + expect(state.chatHistoryPagination).toBe(pagination); + }); +}); diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 244e0018dcb0..a8ddb79eb56b 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -17,7 +17,6 @@ import type { ApplicationContext } from "../../app/context.ts"; import { buildCatalogSessionKey, type CatalogSessionKey } from "../../lib/sessions/catalog-key.ts"; import type { SessionCapability } from "../../lib/sessions/index.ts"; import "./chat-pane.ts"; -import { loadChatHistory } from "./chat-history.ts"; import type { ChatPageHost } from "./chat-state.ts"; import { createBackgroundTasksProps } from "./components/chat-background-tasks.ts"; import { createSessionWorkspaceProps } from "./components/chat-session-workspace.ts"; @@ -193,13 +192,6 @@ function nativeHistoryMessage(seq: number, text = `message ${seq}`) { }; } -function nativeHistorySeq(message: unknown): number | undefined { - const metadata = (message as Record)["__openclaw"] as - | Record - | undefined; - return typeof metadata?.seq === "number" ? metadata.seq : undefined; -} - describe("chat pane initialization", () => { it("sets the pane route before attaching outbox projection", () => { const pane = document.createElement("openclaw-chat-pane") as unknown as TestChatPane; @@ -770,432 +762,6 @@ describe("chat pane catalog session lifecycle", () => { }); }); -describe("chat pane native history pagination", () => { - it("does not request older rows from a complete imported snapshot", () => { - const client = { request: vi.fn() } as unknown as GatewayBrowserClient; - const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.chatHistoryPagination = { - hasMore: false, - totalMessages: 107, - completeSnapshot: true, - }; - - expect(pane.hasOlderMessages()).toBe(false); - }); - - it("auto-loads a visible sentinel when the initial tail is not scrollable", async () => { - const request = vi.fn(async () => ({ - messages: [nativeHistoryMessage(1), nativeHistoryMessage(2)], - hasMore: false, - totalMessages: 4, - })); - const client = { request } as unknown as GatewayBrowserClient; - const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)]; - state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; - const thread = document.createElement("div"); - thread.className = "chat-thread"; - Object.defineProperty(thread, "scrollHeight", { value: 100 }); - Object.defineProperty(thread, "clientHeight", { value: 200 }); - const sentinel = document.createElement("div"); - sentinel.className = "chat-history-sentinel"; - thread.append(sentinel); - pane.append(thread); - const observe = vi.fn(); - class FakeIntersectionObserver { - constructor(private readonly callback: IntersectionObserverCallback) {} - disconnect() {} - observe(target: Element) { - observe(target); - this.callback( - [{ isIntersecting: true } as IntersectionObserverEntry], - this as unknown as IntersectionObserver, - ); - } - } - vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); - try { - pane.syncHistoryObserver(); - await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); - expect(observe).toHaveBeenCalledWith(sentinel); - await vi.waitFor(() => - expect(state.chatMessages.map(nativeHistorySeq)).toEqual([1, 2, 3, 4]), - ); - } finally { - vi.unstubAllGlobals(); - } - }); - - it("does not consume bootstrap history while disconnected", () => { - const client = { request: vi.fn() } as unknown as GatewayBrowserClient; - const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.connected = false; - state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; - const construct = vi.fn(); - class FakeIntersectionObserver { - constructor() { - construct(); - } - disconnect() {} - observe() {} - } - vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); - - pane.syncHistoryObserver(); - - expect(construct).not.toHaveBeenCalled(); - expect(pane.historyAutoLoadBlocked).toBe(false); - }); - - it("stops non-scrollable bootstrap after one older page", async () => { - const request = vi.fn(async () => ({ - messages: [nativeHistoryMessage(3), nativeHistoryMessage(4)], - hasMore: true, - nextOffset: 4, - totalMessages: 6, - })); - const client = { request } as unknown as GatewayBrowserClient; - const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.chatMessages = [nativeHistoryMessage(5), nativeHistoryMessage(6)]; - state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 6 }; - const thread = document.createElement("div"); - thread.className = "chat-thread"; - Object.defineProperty(thread, "scrollHeight", { value: 100 }); - Object.defineProperty(thread, "clientHeight", { value: 200 }); - const sentinel = document.createElement("div"); - sentinel.className = "chat-history-sentinel"; - thread.append(sentinel); - pane.append(thread); - class FakeIntersectionObserver { - constructor(private readonly callback: IntersectionObserverCallback) {} - disconnect() {} - observe() { - this.callback( - [{ isIntersecting: true } as IntersectionObserverEntry], - this as unknown as IntersectionObserver, - ); - } - } - vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); - try { - pane.syncHistoryObserver(); - await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); - await vi.waitFor(() => - expect(state.chatMessages.map(nativeHistorySeq)).toEqual([3, 4, 5, 6]), - ); - - pane.syncHistoryObserver(); - - expect(request).toHaveBeenCalledOnce(); - expect(pane.historyAutoLoadBlocked).toBe(true); - } finally { - vi.unstubAllGlobals(); - } - }); - - it("reuses an unchanged armed history observer across pane updates", () => { - const client = { request: vi.fn() } as unknown as GatewayBrowserClient; - const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; - pane.historyObserverArmed = true; - const thread = document.createElement("div"); - thread.className = "chat-thread"; - Object.defineProperty(thread, "scrollHeight", { value: 400 }); - Object.defineProperty(thread, "clientHeight", { value: 200 }); - const sentinel = document.createElement("div"); - sentinel.className = "chat-history-sentinel"; - thread.append(sentinel); - pane.append(thread); - const observe = vi.fn(); - const disconnect = vi.fn(); - const construct = vi.fn(); - class FakeIntersectionObserver { - constructor() { - construct(); - } - disconnect() { - disconnect(); - } - observe(target: Element) { - observe(target); - } - } - vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); - try { - pane.syncHistoryObserver(); - pane.syncHistoryObserver(); - - expect(construct).toHaveBeenCalledOnce(); - expect(observe).toHaveBeenCalledOnce(); - expect(observe).toHaveBeenCalledWith(sentinel); - expect(disconnect).not.toHaveBeenCalled(); - } finally { - vi.unstubAllGlobals(); - } - }); - - it("keeps multiple projected messages from the same transcript sequence", () => { - const client = { request: vi.fn() } as unknown as GatewayBrowserClient; - const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); - const projected = [ - nativeHistoryMessage(1, "tool call"), - nativeHistoryMessage(1, "visible tool reply"), - ]; - - expect(pane.prependUniqueNativeMessages(projected, [nativeHistoryMessage(2)])).toEqual([ - ...projected, - nativeHistoryMessage(2), - ]); - expect(pane.prependUniqueNativeMessages(projected, projected)).toEqual(projected); - expect( - pane.prependUniqueNativeMessages(projected, [projected[1], nativeHistoryMessage(2)]), - ).toEqual([projected[0], projected[1], nativeHistoryMessage(2)]); - }); - - it("deduplicates projected catalog transcript records by catalog message id", () => { - const client = { request: vi.fn() } as unknown as GatewayBrowserClient; - const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); - const current = pane.catalogItemMessage({ - id: "catalog-item-1", - type: "userMessage", - text: "newer projection", - }); - const overlapping = pane.catalogItemMessage({ - id: "catalog-item-1", - type: "userMessage", - text: "older projection", - }); - if (!current || !overlapping) { - throw new Error("expected catalog transcript projections"); - } - pane.catalogMessages = [current]; - - expect(pane.prependUniqueCatalogMessages([overlapping])).toEqual([current]); - }); - - it("prepends a strictly older page and exhausts", async () => { - const request = vi.fn(async () => ({ - messages: [nativeHistoryMessage(1), nativeHistoryMessage(2)], - hasMore: false, - totalMessages: 4, - })); - const client = { request } as unknown as GatewayBrowserClient; - const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)]; - state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; - await pane.loadOlderMessages(); - - expect(request).toHaveBeenCalledWith("chat.history", { - sessionKey: state.sessionKey, - limit: 100, - offset: 2, - }); - expect(state.chatMessages.map(nativeHistorySeq)).toEqual([1, 2, 3, 4]); - expect(state.chatHistoryPagination).toEqual({ hasMore: false, totalMessages: 4 }); - expect(state.lastError).toBeNull(); - expect(pane.hasOlderMessages()).toBe(false); - - await pane.loadOlderMessages(); - expect(request).toHaveBeenCalledOnce(); - }); - - it("allows only one native older-page request in flight", async () => { - const deferred = createDeferred<{ - messages: unknown[]; - hasMore: boolean; - totalMessages: number; - }>(); - const request = vi.fn(() => deferred.promise); - const client = { request } as unknown as GatewayBrowserClient; - const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)]; - state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; - - const first = pane.loadOlderMessages(); - const second = pane.loadOlderMessages(); - expect(pane.loadingOlder).toBe(true); - expect(state.requestUpdate).toHaveBeenCalled(); - expect(request).toHaveBeenCalledOnce(); - - deferred.resolve({ messages: [], hasMore: false, totalMessages: 4 }); - await Promise.all([first, second]); - expect(pane.loadingOlder).toBe(false); - }); - - it("refreshes the tail instead of mixing an older page from a replacement session", async () => { - const request = vi - .fn() - .mockResolvedValueOnce({ - messages: [nativeHistoryMessage(1), nativeHistoryMessage(2)], - hasMore: false, - totalMessages: 2, - sessionInfo: { sessionId: "session-new" }, - }) - .mockResolvedValueOnce({ - messages: [nativeHistoryMessage(7), nativeHistoryMessage(8)], - hasMore: false, - totalMessages: 2, - sessionInfo: { sessionId: "session-new" }, - }); - const client = { request } as unknown as GatewayBrowserClient; - const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.currentSessionId = "session-old"; - state.chatMessages = [nativeHistoryMessage(3), nativeHistoryMessage(4)]; - state.chatHistoryPagination = { hasMore: true, nextOffset: 2, totalMessages: 4 }; - - await pane.loadOlderMessages(); - - expect(request).toHaveBeenNthCalledWith(1, "chat.history", { - sessionKey: state.sessionKey, - limit: 100, - offset: 2, - }); - expect(request).toHaveBeenNthCalledWith( - 2, - "chat.history", - expect.objectContaining({ sessionKey: state.sessionKey, limit: 100 }), - ); - expect(state.currentSessionId).toBe("session-new"); - expect(state.chatMessages.map(nativeHistorySeq)).toEqual([7, 8]); - }); - - it("revalidates the tail without discarding loaded depth for the same backing session", async () => { - const request = vi.fn(async () => ({ - messages: [nativeHistoryMessage(3), nativeHistoryMessage(4)], - hasMore: true, - nextOffset: 2, - totalMessages: 4, - sessionInfo: { sessionId: "session-current" }, - })); - const client = { request } as unknown as GatewayBrowserClient; - const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.currentSessionId = "session-current"; - state.chatMessages = [ - nativeHistoryMessage(1), - nativeHistoryMessage(2), - nativeHistoryMessage(3), - nativeHistoryMessage(4), - ]; - state.chatHistoryPagination = { hasMore: false, totalMessages: 4 }; - pane.olderOffsetsSeen.add(2); - pane.olderOffsetsSeen.add(4); - - await loadChatHistory(state); - - expect(state.chatMessages.map(nativeHistorySeq)).toEqual([1, 2, 3, 4]); - expect(state.chatHistoryPagination).toEqual({ - hasMore: false, - totalMessages: 4, - }); - expect(pane.hasOlderMessages()).toBe(false); - expect(pane.olderOffsetsSeen).toEqual(new Set()); - }); - - it("keeps projected siblings while replacing the overlapping tail", async () => { - const firstProjection = nativeHistoryMessage(3, "first projection"); - const secondProjection = nativeHistoryMessage(3, "second projection"); - const request = vi.fn(async () => ({ - messages: [firstProjection, secondProjection, nativeHistoryMessage(4)], - hasMore: true, - nextOffset: 2, - totalMessages: 4, - sessionInfo: { sessionId: "session-current" }, - })); - const client = { request } as unknown as GatewayBrowserClient; - const { state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.currentSessionId = "session-current"; - state.chatMessages = [ - nativeHistoryMessage(1), - nativeHistoryMessage(2), - nativeHistoryMessage(3, "stale projection"), - nativeHistoryMessage(4, "stale latest"), - ]; - state.chatHistoryPagination = { hasMore: false, totalMessages: 4 }; - - await loadChatHistory(state); - - expect(state.chatMessages).toEqual([ - nativeHistoryMessage(1), - nativeHistoryMessage(2), - firstProjection, - secondProjection, - nativeHistoryMessage(4), - ]); - }); - - it("replaces the tail when the refreshed raw range does not overlap loaded history", async () => { - const request = vi.fn(async () => ({ - messages: [nativeHistoryMessage(7), nativeHistoryMessage(8)], - hasMore: true, - nextOffset: 2, - totalMessages: 8, - sessionInfo: { sessionId: "session-current" }, - })); - const client = { request } as unknown as GatewayBrowserClient; - const { state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.currentSessionId = "session-current"; - state.chatMessages = [ - nativeHistoryMessage(1), - nativeHistoryMessage(2), - nativeHistoryMessage(3), - nativeHistoryMessage(4), - ]; - state.chatHistoryPagination = { hasMore: false, totalMessages: 4 }; - - await loadChatHistory(state); - - expect(state.chatMessages.map(nativeHistorySeq)).toEqual([7, 8]); - expect(state.chatHistoryPagination).toEqual({ - hasMore: true, - nextOffset: 2, - totalMessages: 8, - }); - }); - - it("preserves loaded visible rows when an adjacent refreshed page projects empty", async () => { - const request = vi.fn(async () => ({ - messages: [], - hasMore: true, - nextOffset: 2, - totalMessages: 6, - sessionInfo: { sessionId: "session-current" }, - })); - const client = { request } as unknown as GatewayBrowserClient; - const { state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - state.currentSessionId = "session-current"; - state.chatMessages = [ - nativeHistoryMessage(1), - nativeHistoryMessage(2), - nativeHistoryMessage(3), - nativeHistoryMessage(4), - ]; - state.chatHistoryPagination = { hasMore: false, totalMessages: 4 }; - - await loadChatHistory(state); - - expect(state.chatMessages.map(nativeHistorySeq)).toEqual([1, 2, 3, 4]); - expect(state.chatHistoryPagination).toEqual({ - hasMore: false, - totalMessages: 6, - }); - }); - - it("preserves the older-page cursor when a tail refresh fails", async () => { - const client = { - request: vi.fn(async () => { - throw new Error("gateway unavailable"); - }), - } as unknown as GatewayBrowserClient; - const { state } = createTestChatPane({ client, sessions: {} as SessionCapability }); - const pagination = { hasMore: true as const, nextOffset: 2, totalMessages: 4 }; - state.chatHistoryPagination = pagination; - - await loadChatHistory(state); - - expect(state.chatHistoryPagination).toBe(pagination); - }); -}); - describe("chat pane task suggestion lifecycle", () => { it("keeps accept ownership when the resolved event arrives before the response", async () => { const accepted = createDeferred(); diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 3d47c7037a85..8b24c65774af 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -465,7 +465,12 @@ describe("buildCachedChatItems row identity", () => { content: "Earlier projection from the same record", timestamp: 2, }, - ...siblings.map((message) => ({ ...message, __openclaw: { ...message.__openclaw } })), + ...siblings.map((message) => ({ + __openclaw: { seq: message["__openclaw"].seq }, + role: message.role, + content: message.content, + timestamp: message.timestamp, + })), ], }), 1, diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index c7c0956d336c..db6bea9f0641 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -103,20 +103,20 @@ const buildChatItemsMock = vi.hoisted(() => (message) => typeof message === "object" && message !== null && - (message as { __testVirtualRow?: unknown }).__testVirtualRow === true, + (message as { testVirtualRow?: unknown }).testVirtualRow === true, ); if (virtualRows) { items.push( ...props.messages.map((message, index) => { const testMessage = message as { - __testVirtualKey?: string; - __testVirtualRole?: string; + testVirtualKey?: string; + testVirtualRole?: string; }; - const key = testMessage.__testVirtualKey ?? String(index); + const key = testMessage.testVirtualKey ?? String(index); return { kind: "group", key: `group:${key}`, - role: testMessage.__testVirtualRole ?? (index % 2 === 0 ? "user" : "assistant"), + role: testMessage.testVirtualRole ?? (index % 2 === 0 ? "user" : "assistant"), messages: [{ key: `message:${key}`, message }], timestamp: index + 1, isStreaming: false, @@ -1037,7 +1037,7 @@ describe("chat transcript rendering", () => { it("mounts a bounded end-anchored range for long transcripts", () => { const messages = Array.from({ length: 500 }, (_, index) => ({ - __testVirtualRow: true, + testVirtualRow: true, content: `message ${index}`, })); @@ -1070,9 +1070,9 @@ describe("chat transcript rendering", () => { } satisfies ReactiveControllerHost); const container = document.createElement("div"); const message = (key: string, role: "user" | "assistant", content: string) => ({ - __testVirtualRow: true, - __testVirtualKey: key, - __testVirtualRole: role, + testVirtualRow: true, + testVirtualKey: key, + testVirtualRole: role, content, }); const renderMessages = (messages: unknown[]) => @@ -1113,15 +1113,15 @@ describe("chat transcript rendering", () => { } satisfies ReactiveControllerHost); const container = document.createElement("div"); const existing = { - __testVirtualRow: true, - __testVirtualKey: "assistant-1", - __testVirtualRole: "assistant", + testVirtualRow: true, + testVirtualKey: "assistant-1", + testVirtualRole: "assistant", content: "Existing answer", }; const appended = { - __testVirtualRow: true, - __testVirtualKey: "assistant-2", - __testVirtualRole: "assistant", + testVirtualRow: true, + testVirtualKey: "assistant-2", + testVirtualRole: "assistant", content: "New answer", }; diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index 92a9f6062549..52e731b8cc6a 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -229,7 +229,7 @@ class ChatSessionVirtualizerHost implements ReactiveControllerHost { ) { return indexes; } - return [...indexes, focused].sort((left, right) => left - right); + return [...indexes, focused].toSorted((left, right) => left - right); }, scrollEndThreshold: CHAT_TRANSCRIPT_END_THRESHOLD_PX, overscan: CHAT_TRANSCRIPT_OVERSCAN, diff --git a/ui/src/pages/chat/session-message-cache.ts b/ui/src/pages/chat/session-message-cache.ts index 7d68c1169067..1fc1aed50215 100644 --- a/ui/src/pages/chat/session-message-cache.ts +++ b/ui/src/pages/chat/session-message-cache.ts @@ -170,7 +170,7 @@ export function cacheChatSessionSnapshot( if ( snapshot.messages.length === 0 && snapshot.sessionId === null && - snapshot.pagination.hasMore === false && + !snapshot.pagination.hasMore && (snapshot.pagination.totalMessages ?? 0) === 0 && snapshot.pagination.completeSnapshot !== true ) { @@ -312,14 +312,18 @@ function boundChatSessionSnapshot(snapshot: ChatSessionSnapshot): CachedChatSess return null; } const boundarySeq = readTranscriptSequence(snapshot.messages[start]); - do { + retainedMessageWeight -= messageWeights[start] ?? 0; + start += 1; + if (boundarySeq === null) { + continue; + } + while (start < snapshot.messages.length) { + if (readTranscriptSequence(snapshot.messages[start]) !== boundarySeq) { + break; + } retainedMessageWeight -= messageWeights[start] ?? 0; start += 1; - } while ( - boundarySeq !== null && - start < snapshot.messages.length && - readTranscriptSequence(snapshot.messages[start]) === boundarySeq - ); + } } }