test: harden chat transcript validation (#107893)

This commit is contained in:
Shakker
2026-07-15 02:07:33 +01:00
committed by Shakker
parent a2f6d96bfb
commit fcbc7900bb
8 changed files with 587 additions and 473 deletions

View File

@@ -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<HTMLElement>("[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<void>((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",

View File

@@ -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;
});
}

View File

@@ -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<string, unknown> | null;
handleTranscriptScroll: (event: Event) => void;
historyAutoLoadBlocked: boolean;
historyObserverArmed: boolean;
syncHistoryObserver: () => void;
prependUniqueNativeMessages: (messages: unknown[], current: unknown[]) => unknown[];
prependUniqueCatalogMessages: (messages: unknown[]) => unknown[];
loadOlderMessages: () => Promise<void>;
hasOlderMessages: () => boolean;
loadingOlder: boolean;
olderOffsetsSeen: Set<number>;
};
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<string, unknown>)["__openclaw"] as
| Record<string, unknown>
| 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);
});
});

View File

@@ -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<string, unknown>)["__openclaw"] as
| Record<string, unknown>
| 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<TaskSuggestionsAcceptResult>();

View File

@@ -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,

View File

@@ -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",
};

View File

@@ -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,

View File

@@ -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
);
}
}
}