mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 09:11:11 +00:00
test: harden chat transcript validation (#107893)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
537
ui/src/pages/chat/chat-pane-history.test.ts
Normal file
537
ui/src/pages/chat/chat-pane-history.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user