mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
* feat: avatar upload UI, self-service profiles, server-side avatar proxy * refactor(ui): make resolveAvatar synchronous so avatar updates render immediately resolveAvatar no longer does async work (gravatar moved server-side), so the until()-deferred render left the identity chip img stale after updateSelfUser. Rendering synchronously reuses the img element and reflects avatarUrl changes on the next render. * fix(gateway): bound avatar Gravatar lookup to one timeout budget Linked-email Gravatar lookups ran sequentially, each with its own 5s timeout, so an upstream outage stalled the held connection for 5s x linked-email-count. Resolve them concurrently and pick the first hit in email order, keeping primary-email precedence while capping the request to a single timeout budget. * fix(gateway): cap per-profile Gravatar fan-out to bound sockets and memory A profile with many linked emails would fan out one concurrent fetch per email with no bound, each able to buffer up to MAX_GRAVATAR_BYTES before the cache budget applies. Cap the lookups to the first 8 primary-ordered emails so a single avatar request holds a bounded number of sockets and transient bytes. * test: reconcile avatar tests with server-side gravatar model after merge main landed a client-side Gravatar path (browser computes the gravatar.com URL); this branch resolves avatars through the same-origin gateway route, which is the only approach that works under the Control UI CSP (img-src 'self'). Update the identity-section, app-sidebar footer chip, and profile-page e2e expectations to assert the canonical /api/users/<id>/avatar route (gateway serves the Gravatar fallback behind it) instead of a CSP-blocked direct gravatar.com image. * test(gateway): fix fetchImpl mock param type for check-test-types The Gravatar mock typed its param as (url: string), narrower than the fetchImpl signature (URL | RequestInfo); function-param contravariance made it unassignable under tsgo:test. Widen to the fetch input type and extract the URL via a small helper that avoids no-base-to-string on a Request. * fix(gateway): short-circuit Gravatar lookups and version presence avatar URL Two autoreview findings on the avatar path: - Privacy: the concurrent Promise.all fan-out queried every linked email's hash against Gravatar even when the primary already had one, exposing secondary work/personal addresses. Resolve sequentially with short-circuit so a later hash is disclosed only after the earlier email is a definite miss, under one shared request deadline that still bounds total latency. - Staleness: presence published an unversioned /api/users/<id>/avatar, so a reconnecting viewer's <img> kept the stale cached image after an upload. Carry the profile revision as ?v=<updatedAt> so a changed avatar refetches.
3667 lines
120 KiB
TypeScript
3667 lines
120 KiB
TypeScript
/* @vitest-environment jsdom */
|
||
|
||
import { html, render } from "lit";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import * as markdown from "../../../components/markdown.ts";
|
||
import type { MessageGroup } from "../../../lib/chat/chat-types.ts";
|
||
import { setUiTimeFormatPreference } from "../../../lib/format.ts";
|
||
import { setAvatarGatewayOrigin } from "../../../lib/identity-avatar.ts";
|
||
import * as localStorageModule from "../../../local-storage.ts";
|
||
import * as chatAvatar from "../chat-avatar.ts";
|
||
import { renderMessageGroup, renderStreamGroup } from "./chat-message.ts";
|
||
|
||
const localStorageValues = new Map<string, string>();
|
||
const markdownRenderMock = vi.fn(
|
||
(value: string, _options?: { codeBlockChrome?: "copy" | "none"; fileLinks?: boolean }) => value,
|
||
);
|
||
const streamingMarkdownRenderMock = vi.fn(
|
||
(value: string, _options?: { codeBlockChrome?: "copy" | "none"; fileLinks?: boolean }) =>
|
||
`<div class="streaming-markdown">${value}</div>`,
|
||
);
|
||
|
||
function getSafeLocalStorageMock(): Storage {
|
||
return {
|
||
get length() {
|
||
return localStorageValues.size;
|
||
},
|
||
clear: () => localStorageValues.clear(),
|
||
getItem: (key: string) => localStorageValues.get(key) ?? null,
|
||
key: (index: number) => [...localStorageValues.keys()][index] ?? null,
|
||
removeItem: (key: string) => localStorageValues.delete(key),
|
||
setItem: (key: string, value: string) => localStorageValues.set(key, value),
|
||
};
|
||
}
|
||
|
||
function renderChatAvatarMock(
|
||
...[role]: Parameters<typeof chatAvatar.renderChatAvatar>
|
||
): ReturnType<typeof chatAvatar.renderChatAvatar> {
|
||
return html`<div class="chat-avatar ${role}"></div>`;
|
||
}
|
||
|
||
function requireFirstMockArg(
|
||
mock: ReturnType<typeof vi.fn>,
|
||
label: string,
|
||
): Record<string, unknown> {
|
||
const [call] = mock.mock.calls;
|
||
if (!call) {
|
||
throw new Error(`expected ${label} call`);
|
||
}
|
||
const [arg] = call;
|
||
if (!arg || typeof arg !== "object" || Array.isArray(arg)) {
|
||
throw new Error(`expected ${label} payload`);
|
||
}
|
||
return arg;
|
||
}
|
||
|
||
function selectText(element: Element) {
|
||
const range = document.createRange();
|
||
range.selectNodeContents(element);
|
||
const selection = window.getSelection();
|
||
selection?.removeAllRanges();
|
||
selection?.addRange(range);
|
||
}
|
||
|
||
function pointerClick(element: Element) {
|
||
element.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.spyOn(localStorageModule, "getSafeLocalStorage").mockImplementation(getSafeLocalStorageMock);
|
||
vi.spyOn(markdown, "toSanitizedMarkdownHtml").mockImplementation(markdownRenderMock);
|
||
vi.spyOn(markdown, "toStreamingMarkdownHtml").mockImplementation(streamingMarkdownRenderMock);
|
||
vi.spyOn(chatAvatar, "renderChatAvatar").mockImplementation(renderChatAvatarMock);
|
||
});
|
||
|
||
type RenderMessageGroupOptions = Parameters<typeof renderMessageGroup>[1];
|
||
|
||
function expectElement<T extends Element>(
|
||
container: Element,
|
||
selector: string,
|
||
constructor: new () => T,
|
||
): T {
|
||
const element = container.querySelector<T>(selector);
|
||
expect(element).toBeInstanceOf(constructor);
|
||
if (!(element instanceof constructor)) {
|
||
throw new Error(`Expected ${selector} to match ${constructor.name}`);
|
||
}
|
||
return element;
|
||
}
|
||
|
||
function requireFetchCallForUrl(fetchMock: ReturnType<typeof vi.fn>, expectedUrl: string) {
|
||
const call = fetchMock.mock.calls.find(([url]) => url === expectedUrl) as
|
||
| [string, RequestInit?]
|
||
| undefined;
|
||
if (!call) {
|
||
throw new Error(`Expected fetch call for ${expectedUrl}`);
|
||
}
|
||
return call;
|
||
}
|
||
|
||
function expectSameOriginGet(init: RequestInit | undefined) {
|
||
expect(init?.credentials).toBe("same-origin");
|
||
expect(init?.method).toBe("GET");
|
||
}
|
||
|
||
function rejectWhenAborted<T>(signal: AbortSignal, rejection: () => Error): Promise<T> {
|
||
return new Promise<T>((_resolve, reject) => {
|
||
signal.addEventListener("abort", () => reject(rejection()), { once: true });
|
||
});
|
||
}
|
||
|
||
function renderAssistantMessage(
|
||
container: HTMLElement,
|
||
message: unknown,
|
||
opts: Partial<RenderMessageGroupOptions> = {},
|
||
) {
|
||
renderGroupedMessage(container, message, "assistant", opts);
|
||
}
|
||
|
||
function renderAssistantMessages(
|
||
container: HTMLElement,
|
||
messages: unknown[],
|
||
opts: Partial<RenderMessageGroupOptions> = {},
|
||
) {
|
||
const timestamp =
|
||
typeof messages[0] === "object" &&
|
||
messages[0] !== null &&
|
||
typeof (messages[0] as { timestamp?: unknown }).timestamp === "number"
|
||
? (messages[0] as { timestamp: number }).timestamp
|
||
: Date.now();
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "assistant-group",
|
||
role: "assistant",
|
||
messages: messages.map((message, index) => ({
|
||
key: `assistant-message-${index}`,
|
||
message,
|
||
})),
|
||
timestamp,
|
||
isStreaming: false,
|
||
};
|
||
render(
|
||
renderMessageGroup(group, {
|
||
showReasoning: true,
|
||
showToolCalls: true,
|
||
assistantName: "OpenClaw",
|
||
assistantAvatar: null,
|
||
...opts,
|
||
}),
|
||
container,
|
||
);
|
||
}
|
||
|
||
function renderAssistantMessageEntries(
|
||
container: HTMLElement,
|
||
entries: MessageGroup["messages"],
|
||
opts: Partial<RenderMessageGroupOptions> = {},
|
||
) {
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "assistant-group",
|
||
role: "assistant",
|
||
messages: entries,
|
||
timestamp: Date.now(),
|
||
isStreaming: false,
|
||
};
|
||
render(
|
||
renderMessageGroup(group, {
|
||
showReasoning: true,
|
||
showToolCalls: true,
|
||
assistantName: "OpenClaw",
|
||
assistantAvatar: null,
|
||
...opts,
|
||
}),
|
||
container,
|
||
);
|
||
}
|
||
|
||
function renderGroupedMessage(
|
||
container: HTMLElement,
|
||
message: unknown,
|
||
role: string,
|
||
opts: Partial<RenderMessageGroupOptions> = {},
|
||
) {
|
||
const timestamp =
|
||
typeof message === "object" &&
|
||
message !== null &&
|
||
typeof (message as { timestamp?: unknown }).timestamp === "number"
|
||
? (message as { timestamp: number }).timestamp
|
||
: Date.now();
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: `${role}-group`,
|
||
role,
|
||
messages: [{ key: `${role}-message`, message }],
|
||
timestamp,
|
||
isStreaming: false,
|
||
};
|
||
render(
|
||
renderMessageGroup(group, {
|
||
showReasoning: true,
|
||
showToolCalls: true,
|
||
assistantName: "OpenClaw",
|
||
assistantAvatar: null,
|
||
...opts,
|
||
}),
|
||
container,
|
||
);
|
||
}
|
||
|
||
function createMessageGroup(message: unknown, role: string): MessageGroup {
|
||
const timestamp =
|
||
typeof message === "object" &&
|
||
message !== null &&
|
||
typeof (message as { timestamp?: unknown }).timestamp === "number"
|
||
? (message as { timestamp: number }).timestamp
|
||
: Date.now();
|
||
return {
|
||
kind: "group",
|
||
key: `${role}:${timestamp}`,
|
||
role,
|
||
messages: [{ key: `${role}:${timestamp}:message`, message }],
|
||
timestamp,
|
||
isStreaming: false,
|
||
};
|
||
}
|
||
|
||
function createAssistantCanvasBlock(params: {
|
||
suffix: string;
|
||
title?: string;
|
||
url?: string;
|
||
preferredHeight?: number;
|
||
presentationTarget?: "assistant_message" | "tool_card";
|
||
}) {
|
||
const viewId = `cv_inline_${params.suffix}`;
|
||
const url = params.url ?? `/__openclaw__/canvas/documents/${viewId}/index.html`;
|
||
const title = params.title ?? "Inline demo";
|
||
const preferredHeight = params.preferredHeight ?? 360;
|
||
return {
|
||
type: "canvas",
|
||
preview: {
|
||
kind: "canvas",
|
||
surface: "assistant_message",
|
||
render: "url",
|
||
viewId,
|
||
title,
|
||
url,
|
||
preferredHeight,
|
||
},
|
||
rawText: JSON.stringify({
|
||
kind: "canvas",
|
||
view: {
|
||
backend: "canvas",
|
||
id: viewId,
|
||
url,
|
||
title,
|
||
preferred_height: preferredHeight,
|
||
},
|
||
presentation: {
|
||
target: params.presentationTarget ?? "assistant_message",
|
||
},
|
||
}),
|
||
};
|
||
}
|
||
|
||
function renderMessageGroups(
|
||
container: HTMLElement,
|
||
groups: MessageGroup[],
|
||
opts: Partial<RenderMessageGroupOptions> = {},
|
||
) {
|
||
render(
|
||
html`${groups.map((group) =>
|
||
renderMessageGroup(group, {
|
||
showReasoning: true,
|
||
showToolCalls: true,
|
||
assistantName: "OpenClaw",
|
||
assistantAvatar: null,
|
||
...opts,
|
||
}),
|
||
)}`,
|
||
container,
|
||
);
|
||
}
|
||
|
||
function clearDeleteConfirmSkip() {
|
||
localStorageValues.delete("openclaw:skipDeleteConfirm");
|
||
}
|
||
|
||
function stubAnimationFrameQueue() {
|
||
const callbacks: FrameRequestCallback[] = [];
|
||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||
callbacks.push(callback);
|
||
return callbacks.length;
|
||
});
|
||
return () => {
|
||
const pending = callbacks.splice(0);
|
||
for (const callback of pending) {
|
||
callback(performance.now());
|
||
}
|
||
};
|
||
}
|
||
|
||
function getLastCaptureClickListener(calls: readonly unknown[][]) {
|
||
for (let index = calls.length - 1; index >= 0; index--) {
|
||
const [type, listener, options] = calls[index] ?? [];
|
||
if (type === "click" && options === true && listener) {
|
||
return listener;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function expectLastCaptureClickListener(calls: readonly unknown[][]): unknown {
|
||
const listener = getLastCaptureClickListener(calls);
|
||
expect(typeof listener).toBe("function");
|
||
if (typeof listener !== "function") {
|
||
throw new Error("Expected capture click listener");
|
||
}
|
||
return listener;
|
||
}
|
||
|
||
function countCaptureClickListenerRemovals(calls: readonly unknown[][], listener: unknown) {
|
||
return calls.filter(
|
||
([type, removedListener, options]) =>
|
||
type === "click" && options === true && removedListener === listener,
|
||
).length;
|
||
}
|
||
|
||
function renderDeleteConfirmFixture() {
|
||
const container = document.createElement("div");
|
||
container.dataset.deleteConfirmFixture = "true";
|
||
document.body.appendChild(container);
|
||
const onDelete = vi.fn();
|
||
clearDeleteConfirmSkip();
|
||
renderMessageGroups(
|
||
container,
|
||
[
|
||
createMessageGroup(
|
||
{
|
||
role: "assistant",
|
||
content: "hello from assistant",
|
||
timestamp: 1000,
|
||
},
|
||
"assistant",
|
||
),
|
||
],
|
||
{ onDelete },
|
||
);
|
||
const deleteButton = container.querySelector<HTMLButtonElement>(".chat-group-delete");
|
||
expect(deleteButton).toBeInstanceOf(HTMLButtonElement);
|
||
return { container, deleteButton: deleteButton!, onDelete };
|
||
}
|
||
|
||
function openDeleteConfirm(deleteButton: HTMLButtonElement) {
|
||
deleteButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
}
|
||
|
||
function domRect(params: {
|
||
left?: number;
|
||
top?: number;
|
||
width?: number;
|
||
height?: number;
|
||
}): DOMRect {
|
||
const left = params.left ?? 0;
|
||
const top = params.top ?? 0;
|
||
const width = params.width ?? 0;
|
||
const height = params.height ?? 0;
|
||
const rect = {
|
||
x: left,
|
||
y: top,
|
||
left,
|
||
top,
|
||
width,
|
||
height,
|
||
right: left + width,
|
||
bottom: top + height,
|
||
toJSON: () => rect,
|
||
};
|
||
return rect as DOMRect;
|
||
}
|
||
|
||
function stubDeleteConfirmGeometry(params: {
|
||
trigger: { left: number; top: number; width: number; height: number };
|
||
popover: { width: number; height: number };
|
||
viewport: { left?: number; top?: number; width: number; height: number };
|
||
}) {
|
||
vi.stubGlobal("innerWidth", params.viewport.width);
|
||
vi.stubGlobal("innerHeight", params.viewport.height);
|
||
vi.stubGlobal("visualViewport", {
|
||
height: params.viewport.height,
|
||
offsetLeft: params.viewport.left ?? 0,
|
||
offsetTop: params.viewport.top ?? 0,
|
||
width: params.viewport.width,
|
||
});
|
||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||
function (this: HTMLElement) {
|
||
if (this.classList.contains("chat-group-delete")) {
|
||
return domRect(params.trigger);
|
||
}
|
||
if (this.classList.contains("chat-delete-confirm")) {
|
||
return domRect(params.popover);
|
||
}
|
||
return domRect({});
|
||
},
|
||
);
|
||
}
|
||
|
||
function clickDeleteButtonIconPath(deleteButton: HTMLButtonElement) {
|
||
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||
icon.appendChild(path);
|
||
deleteButton.appendChild(icon);
|
||
path.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
}
|
||
|
||
function setupArmedDeleteConfirm() {
|
||
const flushAnimationFrames = stubAnimationFrameQueue();
|
||
const addListenerSpy = vi.spyOn(document, "addEventListener");
|
||
const removeListenerSpy = vi.spyOn(document, "removeEventListener");
|
||
const fixture = renderDeleteConfirmFixture();
|
||
|
||
openDeleteConfirm(fixture.deleteButton);
|
||
flushAnimationFrames();
|
||
|
||
const outsideClickListener = expectLastCaptureClickListener(addListenerSpy.mock.calls);
|
||
expect(fixture.container.querySelectorAll(".chat-delete-confirm")).toHaveLength(1);
|
||
|
||
return { ...fixture, outsideClickListener, removeListenerSpy };
|
||
}
|
||
|
||
function expectDeleteConfirmDismissed(params: {
|
||
container: HTMLElement;
|
||
outsideClickListener: unknown;
|
||
removeListenerSpy: ReturnType<typeof vi.spyOn>;
|
||
}) {
|
||
expect(params.container.querySelector(".chat-delete-confirm")).toBeNull();
|
||
expect(
|
||
countCaptureClickListenerRemovals(
|
||
params.removeListenerSpy.mock.calls,
|
||
params.outsideClickListener,
|
||
),
|
||
).toBe(1);
|
||
}
|
||
|
||
async function flushAssistantAttachmentAvailabilityChecks() {
|
||
for (let i = 0; i < 6; i++) {
|
||
await Promise.resolve();
|
||
}
|
||
}
|
||
|
||
function mediaTicketPayload(mediaTicket: string, ttlMs = 5 * 60 * 1000) {
|
||
return {
|
||
available: true,
|
||
mediaTicket,
|
||
mediaTicketExpiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
||
};
|
||
}
|
||
|
||
afterEach(() => {
|
||
markdownRenderMock.mockClear();
|
||
document.querySelectorAll("[data-delete-confirm-fixture]").forEach((element) => {
|
||
element.remove();
|
||
});
|
||
clearDeleteConfirmSkip();
|
||
setUiTimeFormatPreference("auto");
|
||
setAvatarGatewayOrigin(null);
|
||
vi.useRealTimers();
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe("grouped chat rendering", () => {
|
||
it("renders a compact count for collapsed duplicate messages", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessageEntries(container, [
|
||
{
|
||
key: "assistant-heartbeat",
|
||
message: {
|
||
role: "assistant",
|
||
content: [{ type: "text", text: "HEARTBEAT_OK" }],
|
||
timestamp: 1,
|
||
},
|
||
duplicateCount: 4,
|
||
},
|
||
]);
|
||
|
||
const badge = container.querySelector(".chat-duplicate-count");
|
||
expect(badge?.textContent?.trim()).toBe("×4");
|
||
expect(badge?.getAttribute("aria-label")).toBe("4 consecutive identical messages collapsed");
|
||
});
|
||
|
||
it("does not render the stale assistant read-aloud footer action", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: "hello from assistant",
|
||
timestamp: 1000,
|
||
});
|
||
|
||
expect(container.querySelector(".chat-tts-btn")).toBeNull();
|
||
expect(container.querySelector('[aria-label="Read aloud"]')).toBeNull();
|
||
});
|
||
|
||
it("renders assistant message actions in the footer row", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: "Short reply",
|
||
timestamp: 1000,
|
||
});
|
||
|
||
const assistantGroup = expectElement(container, ".chat-group.assistant", HTMLElement);
|
||
expect(assistantGroup.querySelector(".chat-bubble-actions")).toBeNull();
|
||
expect(
|
||
assistantGroup.querySelector(".chat-group-footer-actions .chat-copy-btn"),
|
||
).toBeInstanceOf(HTMLElement);
|
||
|
||
renderGroupedMessage(
|
||
container,
|
||
{
|
||
role: "user",
|
||
content: "Short reply",
|
||
timestamp: 1001,
|
||
},
|
||
"user",
|
||
);
|
||
|
||
const userBubble = expectElement(container, ".chat-group.user .chat-bubble", HTMLElement);
|
||
expect(userBubble.classList.contains("has-copy")).toBe(false);
|
||
expect(userBubble.querySelector(".chat-bubble-actions")).toBeNull();
|
||
});
|
||
|
||
it("does not replay an arrival animation when a message row mounts", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: "Stable transcript row",
|
||
timestamp: 1000,
|
||
});
|
||
|
||
const bubble = expectElement(container, ".chat-bubble", HTMLElement);
|
||
expect(bubble.classList.contains("fade-in")).toBe(false);
|
||
expect(expectElement(container, ".chat-group", HTMLElement).dataset.chatRowKey).toBeTruthy();
|
||
});
|
||
|
||
it("uses the displayed answer for assistant message actions", () => {
|
||
const container = document.createElement("div");
|
||
const onOpenSidebar = vi.fn();
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
content: "<think>internal reasoning</think>\nVisible answer",
|
||
timestamp: 1000,
|
||
},
|
||
{
|
||
onOpenSidebar,
|
||
showReasoning: false,
|
||
},
|
||
);
|
||
|
||
container.querySelector<HTMLButtonElement>(".chat-expand-btn")?.click();
|
||
|
||
expect(requireFirstMockArg(onOpenSidebar, "sidebar open")).toMatchObject({
|
||
kind: "markdown",
|
||
content: "Visible answer",
|
||
});
|
||
});
|
||
|
||
it("renders user markdown without code-block copy chrome", () => {
|
||
const container = document.createElement("div");
|
||
const markdownContent = "```bash\npython3 - <<'PY'\nprint('ok')\nPY\n```";
|
||
|
||
renderGroupedMessage(
|
||
container,
|
||
{
|
||
role: "user",
|
||
content: markdownContent,
|
||
timestamp: 1001,
|
||
},
|
||
"user",
|
||
);
|
||
|
||
expect(markdownRenderMock).toHaveBeenCalledWith(markdownContent, {
|
||
assistantTranscriptRoleHeaders: false,
|
||
codeBlockChrome: "none",
|
||
fileLinks: true,
|
||
});
|
||
});
|
||
|
||
it("keeps assistant markdown code-block copy chrome enabled", () => {
|
||
const container = document.createElement("div");
|
||
const markdownContent = "```bash\necho ok\n```";
|
||
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: markdownContent,
|
||
timestamp: 1000,
|
||
});
|
||
|
||
expect(markdownRenderMock).toHaveBeenCalledWith(markdownContent, {
|
||
assistantTranscriptRoleHeaders: true,
|
||
codeBlockChrome: "copy",
|
||
fileLinks: true,
|
||
});
|
||
});
|
||
|
||
it("positions delete confirm by message side", () => {
|
||
const container = document.createElement("div");
|
||
clearDeleteConfirmSkip();
|
||
renderMessageGroups(
|
||
container,
|
||
[
|
||
createMessageGroup(
|
||
{
|
||
role: "user",
|
||
content: "hello from user",
|
||
timestamp: 1000,
|
||
},
|
||
"user",
|
||
),
|
||
createMessageGroup(
|
||
{
|
||
role: "assistant",
|
||
content: "hello from assistant",
|
||
timestamp: 1001,
|
||
},
|
||
"assistant",
|
||
),
|
||
],
|
||
{ onDelete: vi.fn() },
|
||
);
|
||
|
||
const userDeleteButton = container.querySelector<HTMLButtonElement>(
|
||
".chat-group.user .chat-group-delete",
|
||
);
|
||
expect(userDeleteButton).toBeInstanceOf(HTMLButtonElement);
|
||
userDeleteButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
|
||
const userConfirm = container.querySelector<HTMLElement>(
|
||
".chat-group.user .chat-delete-confirm",
|
||
);
|
||
expect(userConfirm).toBeInstanceOf(HTMLElement);
|
||
expect([...userConfirm!.classList]).toEqual([
|
||
"chat-delete-confirm",
|
||
"chat-delete-confirm--left",
|
||
]);
|
||
|
||
const assistantDeleteButton = container.querySelector<HTMLButtonElement>(
|
||
".chat-group.assistant .chat-group-delete",
|
||
);
|
||
expect(assistantDeleteButton).toBeInstanceOf(HTMLButtonElement);
|
||
assistantDeleteButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
|
||
const assistantConfirm = container.querySelector<HTMLElement>(
|
||
".chat-group.assistant .chat-delete-confirm",
|
||
);
|
||
expect(assistantConfirm).toBeInstanceOf(HTMLElement);
|
||
expect([...assistantConfirm!.classList]).toEqual([
|
||
"chat-delete-confirm",
|
||
"chat-delete-confirm--right",
|
||
]);
|
||
});
|
||
|
||
it("renders a confirmed rewind action only for user groups", () => {
|
||
const container = document.createElement("div");
|
||
const onRewind = vi.fn();
|
||
localStorageValues.delete("openclaw:skip-rewind-confirm");
|
||
renderMessageGroups(
|
||
container,
|
||
[
|
||
createMessageGroup({ role: "user", content: "rewind me", timestamp: 1000 }, "user"),
|
||
createMessageGroup({ role: "assistant", content: "answer", timestamp: 1001 }, "assistant"),
|
||
],
|
||
{ onRewind },
|
||
);
|
||
|
||
const rewindButtons = container.querySelectorAll<HTMLButtonElement>(".chat-group-rewind");
|
||
expect(rewindButtons).toHaveLength(1);
|
||
rewindButtons[0]!.click();
|
||
expect(container.querySelector(".chat-delete-confirm__text")?.textContent).toBe(
|
||
"Rewind to before this message?",
|
||
);
|
||
container.querySelector<HTMLButtonElement>(".chat-delete-confirm__yes")!.click();
|
||
expect(onRewind).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("disables rewind while the agent is working", () => {
|
||
const container = document.createElement("div");
|
||
renderMessageGroups(
|
||
container,
|
||
[createMessageGroup({ role: "user", content: "busy", timestamp: 1000 }, "user")],
|
||
{ onRewind: vi.fn(), rewindDisabled: true },
|
||
);
|
||
|
||
const button = container.querySelector<HTMLButtonElement>(".chat-group-rewind");
|
||
const tooltip = button?.closest("openclaw-tooltip");
|
||
expect(button?.disabled).toBe(true);
|
||
expect(tooltip?.content).toBe("Rewind is unavailable while the agent is working");
|
||
});
|
||
|
||
it("places the delete confirm below the trigger near the top viewport edge", () => {
|
||
stubDeleteConfirmGeometry({
|
||
trigger: { left: 20, top: 4, width: 24, height: 24 },
|
||
popover: { width: 200, height: 96 },
|
||
viewport: { width: 320, height: 240 },
|
||
});
|
||
const fixture = renderDeleteConfirmFixture();
|
||
|
||
openDeleteConfirm(fixture.deleteButton);
|
||
|
||
const popover = expectElement(fixture.container, ".chat-delete-confirm", HTMLElement);
|
||
expect(popover.dataset.placement).toBe("below");
|
||
expect(popover.style.top).toBe("34px");
|
||
expect(popover.style.left).toBe("20px");
|
||
});
|
||
|
||
it("places the delete confirm above the trigger near the bottom viewport edge", () => {
|
||
stubDeleteConfirmGeometry({
|
||
trigger: { left: 20, top: 190, width: 24, height: 24 },
|
||
popover: { width: 200, height: 80 },
|
||
viewport: { width: 320, height: 240 },
|
||
});
|
||
const fixture = renderDeleteConfirmFixture();
|
||
|
||
openDeleteConfirm(fixture.deleteButton);
|
||
|
||
const popover = expectElement(fixture.container, ".chat-delete-confirm", HTMLElement);
|
||
expect(popover.dataset.placement).toBe("above");
|
||
expect(popover.style.top).toBe("104px");
|
||
expect(popover.style.left).toBe("20px");
|
||
});
|
||
|
||
it("clamps the delete confirm horizontally inside narrow viewports", () => {
|
||
stubDeleteConfirmGeometry({
|
||
trigger: { left: 260, top: 120, width: 24, height: 24 },
|
||
popover: { width: 200, height: 80 },
|
||
viewport: { width: 320, height: 240 },
|
||
});
|
||
const fixture = renderDeleteConfirmFixture();
|
||
|
||
openDeleteConfirm(fixture.deleteButton);
|
||
|
||
const popover = expectElement(fixture.container, ".chat-delete-confirm", HTMLElement);
|
||
expect(popover.style.left).toBe("112px");
|
||
});
|
||
|
||
it("clamps the delete confirm inside shifted visual viewports", () => {
|
||
stubDeleteConfirmGeometry({
|
||
trigger: { left: 620, top: 540, width: 24, height: 24 },
|
||
popover: { width: 200, height: 80 },
|
||
viewport: { left: 320, top: 300, width: 320, height: 240 },
|
||
});
|
||
const fixture = renderDeleteConfirmFixture();
|
||
|
||
openDeleteConfirm(fixture.deleteButton);
|
||
|
||
const popover = expectElement(fixture.container, ".chat-delete-confirm", HTMLElement);
|
||
expect(popover.dataset.placement).toBe("above");
|
||
expect(popover.style.left).toBe("432px");
|
||
expect(popover.style.top).toBe("452px");
|
||
});
|
||
|
||
it("removes the delete confirm outside-click listener when Cancel dismisses it", () => {
|
||
const fixture = setupArmedDeleteConfirm();
|
||
const cancel = fixture.container.querySelector<HTMLButtonElement>(
|
||
".chat-delete-confirm__cancel",
|
||
);
|
||
|
||
expect(cancel).toBeInstanceOf(HTMLButtonElement);
|
||
cancel!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
|
||
expectDeleteConfirmDismissed(fixture);
|
||
expect(fixture.onDelete).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("removes the delete confirm outside-click listener when Delete dismisses it", () => {
|
||
const fixture = setupArmedDeleteConfirm();
|
||
const confirm = fixture.container.querySelector<HTMLButtonElement>(".chat-delete-confirm__yes");
|
||
|
||
expect(confirm).toBeInstanceOf(HTMLButtonElement);
|
||
confirm!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
|
||
expectDeleteConfirmDismissed(fixture);
|
||
expect(fixture.onDelete).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("removes the delete confirm outside-click listener when an outside click dismisses it", () => {
|
||
const fixture = setupArmedDeleteConfirm();
|
||
|
||
document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
|
||
expectDeleteConfirmDismissed(fixture);
|
||
expect(fixture.onDelete).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("removes the delete confirm outside-click listener when the delete button toggles it", () => {
|
||
const fixture = setupArmedDeleteConfirm();
|
||
|
||
openDeleteConfirm(fixture.deleteButton);
|
||
|
||
expectDeleteConfirmDismissed(fixture);
|
||
expect(fixture.onDelete).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("removes the delete confirm outside-click listener when the delete button icon toggles it", () => {
|
||
const fixture = setupArmedDeleteConfirm();
|
||
|
||
clickDeleteButtonIconPath(fixture.deleteButton);
|
||
|
||
expectDeleteConfirmDismissed(fixture);
|
||
expect(fixture.onDelete).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("does not attach the delete confirm outside-click listener after an immediate toggle", () => {
|
||
const flushAnimationFrames = stubAnimationFrameQueue();
|
||
const addListenerSpy = vi.spyOn(document, "addEventListener");
|
||
const fixture = renderDeleteConfirmFixture();
|
||
|
||
openDeleteConfirm(fixture.deleteButton);
|
||
openDeleteConfirm(fixture.deleteButton);
|
||
flushAnimationFrames();
|
||
|
||
expect(fixture.container.querySelector(".chat-delete-confirm")).toBeNull();
|
||
expect(getLastCaptureClickListener(addListenerSpy.mock.calls)).toBeNull();
|
||
expect(fixture.onDelete).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("renders assistant context usage from input and cache tokens", () => {
|
||
const renderUsage = (usage: Record<string, number>, contextWindow: number) => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
content: "Done",
|
||
usage,
|
||
model: "anthropic/claude-opus-4-7",
|
||
timestamp: 1000,
|
||
},
|
||
{ contextWindow },
|
||
);
|
||
return container;
|
||
};
|
||
|
||
const cached = renderUsage(
|
||
{
|
||
input: 1,
|
||
output: 1200,
|
||
cacheRead: 438_400,
|
||
cacheWrite: 307,
|
||
},
|
||
1_000_000,
|
||
);
|
||
const meta = cached.querySelector<HTMLDetailsElement>("details.msg-meta");
|
||
expect(meta?.open).toBe(false);
|
||
const summary = meta?.querySelector<HTMLElement>(".msg-meta__summary");
|
||
const time = summary?.querySelector<HTMLTimeElement>(".chat-group-timestamp");
|
||
expect(time).not.toBeNull();
|
||
expect(time?.title).toBe("");
|
||
expect(summary?.textContent).not.toContain("Context");
|
||
expect(summary?.getAttribute("aria-label")).toContain("Message context for");
|
||
expect(cached.querySelector(".msg-meta__ctx")?.textContent).toBe("44% ctx");
|
||
expect(
|
||
Array.from(cached.querySelectorAll(".msg-meta__cache")).map((node) => node.textContent),
|
||
).toEqual(["R438.4k", "W307"]);
|
||
|
||
const outputHeavy = renderUsage(
|
||
{
|
||
input: 1_000,
|
||
output: 9_000,
|
||
cacheRead: 0,
|
||
cacheWrite: 0,
|
||
},
|
||
10_000,
|
||
);
|
||
expect(outputHeavy.querySelector(".msg-meta__ctx")?.textContent).toBe("10% ctx");
|
||
});
|
||
|
||
it("previews message context from the timestamp and pins it on click", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
content: "Done",
|
||
usage: { input: 12_000, output: 300 },
|
||
model: "openai/gpt-5.5",
|
||
timestamp: 1000,
|
||
},
|
||
{ contextWindow: 100_000 },
|
||
);
|
||
|
||
const details = container.querySelector<HTMLDetailsElement>("details.msg-meta")!;
|
||
const summary = details.querySelector<HTMLElement>("summary")!;
|
||
const pointerEnter = new Event("pointerenter");
|
||
Object.defineProperty(pointerEnter, "pointerType", { value: "mouse" });
|
||
details.dispatchEvent(pointerEnter);
|
||
expect(details.open).toBe(true);
|
||
|
||
details.dispatchEvent(new Event("pointerleave"));
|
||
expect(details.open).toBe(false);
|
||
|
||
details.dispatchEvent(pointerEnter);
|
||
summary.click();
|
||
details.dispatchEvent(new Event("pointerleave"));
|
||
expect(details.open).toBe(true);
|
||
|
||
summary.click();
|
||
expect(details.open).toBe(false);
|
||
});
|
||
|
||
it("uses the largest single assistant call for grouped context usage", () => {
|
||
const container = document.createElement("div");
|
||
|
||
renderAssistantMessages(
|
||
container,
|
||
[
|
||
{
|
||
role: "assistant",
|
||
content: "Checking",
|
||
usage: { input: 105_944, output: 100 },
|
||
timestamp: 1000,
|
||
},
|
||
{
|
||
role: "assistant",
|
||
content: "Done",
|
||
usage: { input: 108_577, output: 100 },
|
||
timestamp: 1001,
|
||
},
|
||
],
|
||
{ contextWindow: 258_400 },
|
||
);
|
||
|
||
expect(container.querySelector(".msg-meta__ctx")?.textContent).toBe("42% ctx");
|
||
expect(container.querySelector(".msg-meta__tokens")?.textContent).toBe("↑214.5k");
|
||
});
|
||
|
||
it("renders relative labels while preserving absolute message and streaming timestamps", () => {
|
||
vi.useFakeTimers();
|
||
const timestamp = Date.UTC(2026, 3, 24, 18, 30);
|
||
vi.setSystemTime(timestamp + 5 * 60 * 1000);
|
||
const container = document.createElement("div");
|
||
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: "Done",
|
||
timestamp,
|
||
});
|
||
|
||
let time = container.querySelector<HTMLTimeElement>(".chat-group-timestamp");
|
||
expect(time?.dateTime).toBe(new Date(timestamp).toISOString());
|
||
expect(time?.textContent?.trim()).toBe("5m ago");
|
||
|
||
render(
|
||
renderStreamGroup([
|
||
{
|
||
kind: "stream",
|
||
key: `stream:${timestamp}`,
|
||
text: "Working",
|
||
startedAt: timestamp,
|
||
isStreaming: true,
|
||
},
|
||
]),
|
||
container,
|
||
);
|
||
|
||
time = container.querySelector<HTMLTimeElement>(".chat-group-timestamp");
|
||
expect(time?.dateTime).toBe(new Date(timestamp).toISOString());
|
||
expect(time?.textContent?.trim()).toBe("5m ago");
|
||
});
|
||
|
||
it("renders compact dates for old and far-future messages while clamping clock skew", () => {
|
||
vi.useFakeTimers();
|
||
const now = Date.UTC(2026, 5, 24, 18, 30);
|
||
vi.setSystemTime(now);
|
||
const container = document.createElement("div");
|
||
const renderTimestamp = (timestamp: number) => {
|
||
renderAssistantMessage(container, { role: "assistant", content: "Done", timestamp });
|
||
return container.querySelector<HTMLTimeElement>(".chat-group-timestamp")?.textContent?.trim();
|
||
};
|
||
|
||
const oldTimestamp = Date.UTC(2026, 3, 24, 18, 30);
|
||
expect(renderTimestamp(oldTimestamp)).toBe(
|
||
new Date(oldTimestamp).toLocaleDateString([], { month: "short", day: "numeric" }),
|
||
);
|
||
expect(renderTimestamp(now + 30_000)).toBe("just now");
|
||
|
||
const nextYear = Date.UTC(2027, 3, 24, 18, 30);
|
||
expect(renderTimestamp(nextYear)).toBe(
|
||
new Date(nextYear).toLocaleDateString([], {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("uses the earliest segment timestamp for a multi-segment stream footer", () => {
|
||
const container = document.createElement("div");
|
||
|
||
render(
|
||
renderStreamGroup([
|
||
{ kind: "stream", key: "stream-seg:s:0", text: "first", startedAt: 20, isStreaming: false },
|
||
{
|
||
kind: "stream",
|
||
key: "stream-seg:s:1",
|
||
text: "second",
|
||
startedAt: 10,
|
||
isStreaming: false,
|
||
},
|
||
{ kind: "stream", key: "stream:s:live", text: "third", startedAt: 30, isStreaming: true },
|
||
]),
|
||
container,
|
||
);
|
||
|
||
expect(container.querySelectorAll(".chat-group.assistant")).toHaveLength(1);
|
||
expect(container.querySelectorAll(".chat-bubble")).toHaveLength(3);
|
||
expect(container.querySelector<HTMLTimeElement>(".chat-group-timestamp")?.dateTime).toBe(
|
||
new Date(10).toISOString(),
|
||
);
|
||
});
|
||
|
||
it.each([
|
||
{ preference: "12" as const, expected: /AM|PM/i },
|
||
{ preference: "24" as const, expected: /^(?!.*(?:AM|PM))/i },
|
||
])(
|
||
"honors the $preference-hour clock preference in timestamp tooltips",
|
||
({ preference, expected }) => {
|
||
setUiTimeFormatPreference(preference);
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: "Done",
|
||
timestamp: Date.UTC(2026, 0, 15, 19, 30),
|
||
});
|
||
|
||
expect(container.querySelector("openclaw-tooltip")?.getAttribute("content")).toMatch(
|
||
expected,
|
||
);
|
||
},
|
||
);
|
||
|
||
it("omits streaming bubble class for completed stream segments", () => {
|
||
const container = document.createElement("div");
|
||
|
||
render(
|
||
renderStreamGroup([
|
||
{
|
||
kind: "stream",
|
||
key: "stream:1",
|
||
text: "Completed segment",
|
||
startedAt: 1,
|
||
isStreaming: false,
|
||
},
|
||
]),
|
||
container,
|
||
);
|
||
|
||
const bubble = container.querySelector(".chat-bubble");
|
||
expect(bubble?.classList.contains("streaming")).toBe(false);
|
||
});
|
||
|
||
it("renders streaming text through the streaming markdown renderer", () => {
|
||
const container = document.createElement("div");
|
||
markdownRenderMock.mockClear();
|
||
streamingMarkdownRenderMock.mockClear();
|
||
|
||
render(
|
||
renderStreamGroup([
|
||
{
|
||
kind: "stream",
|
||
key: "stream:1",
|
||
text: "**live**\nreply",
|
||
startedAt: 1,
|
||
isStreaming: true,
|
||
},
|
||
]),
|
||
container,
|
||
);
|
||
|
||
expect(markdownRenderMock).not.toHaveBeenCalled();
|
||
expect(streamingMarkdownRenderMock).toHaveBeenCalledWith("**live**\nreply", {
|
||
assistantTranscriptRoleHeaders: true,
|
||
codeBlockChrome: "copy",
|
||
fileLinks: true,
|
||
});
|
||
const text = container.querySelector(".streaming-markdown");
|
||
expect(text?.textContent).toBe("**live**\nreply");
|
||
});
|
||
|
||
it("renders a reading-indicator-only run without avatar or footer", () => {
|
||
const container = document.createElement("div");
|
||
|
||
render(
|
||
renderStreamGroup([{ kind: "reading-indicator", key: "reading", startedAt: 1_000 }]),
|
||
container,
|
||
);
|
||
|
||
const group = container.querySelector(".chat-group.assistant");
|
||
expect(group).not.toBeNull();
|
||
expect(group?.classList.contains("chat-group--working")).toBe(true);
|
||
// Working runs are pure claw: the avatar only arrives with stream text.
|
||
expect(container.querySelectorAll(".chat-avatar.assistant")).toHaveLength(0);
|
||
expect(container.querySelector(".chat-reading-indicator")).not.toBeNull();
|
||
expect(container.querySelector(".chat-working-indicator__elapsed")).not.toBeNull();
|
||
expect(
|
||
container.querySelector(".chat-working-indicator__status > .agent-chat__sr-only")
|
||
?.textContent,
|
||
).toBe("Working…");
|
||
expect(
|
||
container.querySelectorAll(
|
||
".chat-working-indicator__status > span:not(.agent-chat__sr-only)",
|
||
),
|
||
).toHaveLength(0);
|
||
expect(container.querySelector(".chat-group-footer")).toBeNull();
|
||
});
|
||
|
||
it("renders the active plan card inside the working stream group", () => {
|
||
const container = document.createElement("div");
|
||
|
||
render(
|
||
renderStreamGroup(
|
||
[
|
||
{ kind: "reading-indicator", key: "reading", startedAt: 1_000 },
|
||
{ kind: "plan", key: "plan:main:active" },
|
||
],
|
||
{
|
||
planActive: true,
|
||
planStatus: {
|
||
explanation: "Keep the change focused",
|
||
steps: [
|
||
{ step: "Inspect", status: "completed" },
|
||
{ step: "Implement", status: "in_progress" },
|
||
],
|
||
},
|
||
},
|
||
),
|
||
container,
|
||
);
|
||
|
||
expect(container.querySelector(".chat-group--working .plan-checklist--card")).not.toBeNull();
|
||
expect(container.querySelectorAll(".plan-checklist__step")).toHaveLength(2);
|
||
expect(container.querySelectorAll(".chat-avatar.assistant")).toHaveLength(0);
|
||
});
|
||
|
||
it("keeps the avatar once a stream part joins the reading indicator", () => {
|
||
const container = document.createElement("div");
|
||
|
||
render(
|
||
renderStreamGroup([
|
||
{ kind: "stream", key: "stream:s:live", text: "reply", startedAt: 10, isStreaming: true },
|
||
{ kind: "reading-indicator", key: "reading", startedAt: 10 },
|
||
]),
|
||
container,
|
||
);
|
||
|
||
const group = container.querySelector(".chat-group.assistant");
|
||
expect(group?.classList.contains("chat-group--working")).toBe(false);
|
||
expect(container.querySelectorAll(".chat-avatar.assistant")).toHaveLength(1);
|
||
expect(container.querySelector(".chat-reading-indicator")).not.toBeNull();
|
||
});
|
||
|
||
it("seeds a stable punch stance per reading-indicator key", () => {
|
||
const stanceFor = (key: string) => {
|
||
const container = document.createElement("div");
|
||
render(renderStreamGroup([{ kind: "reading-indicator", key, startedAt: 1 }]), container);
|
||
const bubble = container.querySelector(".chat-reading-indicator");
|
||
return [...(bubble?.classList ?? [])].filter((cls) =>
|
||
cls.startsWith("chat-reading-indicator--"),
|
||
);
|
||
};
|
||
|
||
const first = stanceFor("stream:agent:main:pending");
|
||
// Stable across re-renders: same key always fights the same style.
|
||
expect(stanceFor("stream:agent:main:pending")).toEqual(first);
|
||
// At most one stance modifier; orthodox is the unmarked default.
|
||
expect(first.length).toBeLessThanOrEqual(1);
|
||
for (const cls of first) {
|
||
expect([
|
||
"chat-reading-indicator--southpaw",
|
||
"chat-reading-indicator--flurry",
|
||
"chat-reading-indicator--haymaker",
|
||
]).toContain(cls);
|
||
}
|
||
});
|
||
|
||
it("keeps the synthetic progress word screen-reader-only across runs", () => {
|
||
const statusFor = (startedAt: number) => {
|
||
const container = document.createElement("div");
|
||
render(
|
||
renderStreamGroup([{ kind: "reading-indicator", key: "reading", startedAt }]),
|
||
container,
|
||
);
|
||
const status = container.querySelector(".chat-working-indicator__status");
|
||
return {
|
||
hidden: status?.querySelector(".agent-chat__sr-only")?.textContent,
|
||
visibleLabels: status?.querySelectorAll("span:not(.agent-chat__sr-only)").length,
|
||
};
|
||
};
|
||
|
||
expect(statusFor(1_000)).toEqual({ hidden: "Working…", visibleLabels: 0 });
|
||
expect(statusFor(1_500)).toEqual({ hidden: "Working…", visibleLabels: 0 });
|
||
expect(statusFor(8_000)).toEqual({ hidden: "Working…", visibleLabels: 0 });
|
||
});
|
||
|
||
it("renders configured local user names", () => {
|
||
const renderUser = (opts: Partial<RenderMessageGroupOptions>) => {
|
||
const container = document.createElement("div");
|
||
renderGroupedMessage(
|
||
container,
|
||
{
|
||
role: "user",
|
||
content: "hello",
|
||
timestamp: 1000,
|
||
},
|
||
"user",
|
||
opts,
|
||
);
|
||
return container;
|
||
};
|
||
|
||
const named = renderUser({ userName: "Buns" });
|
||
const sender = named.querySelector<HTMLElement>(".chat-group.user .chat-sender-name");
|
||
expect(sender?.textContent).toBe("Buns");
|
||
|
||
const avatar = named.querySelector<HTMLElement>(".chat-avatar.user");
|
||
expect(avatar?.tagName).toBe("DIV");
|
||
});
|
||
|
||
it("renders a durable sender label and avatar chip in user message metadata", async () => {
|
||
const container = document.createElement("div");
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "attributed-user-group",
|
||
role: "user",
|
||
senderLabel: "alice",
|
||
sender: { id: "profile-1", name: "Alice Example" },
|
||
messages: [
|
||
{
|
||
key: "attributed-user-message",
|
||
message: { role: "user", content: "hello", timestamp: 1000 },
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
isStreaming: false,
|
||
};
|
||
|
||
render(
|
||
renderMessageGroup(group, {
|
||
showReasoning: true,
|
||
showToolCalls: true,
|
||
assistantName: "OpenClaw",
|
||
assistantAvatar: null,
|
||
userName: "Local User",
|
||
}),
|
||
container,
|
||
);
|
||
|
||
expect(
|
||
container.querySelector<HTMLElement>(".chat-group.user .chat-sender-name")?.textContent,
|
||
).toBe("alice");
|
||
await vi.waitFor(() => {
|
||
expect(
|
||
container.querySelector<HTMLElement>(".chat-author-avatar__initials")?.textContent?.trim(),
|
||
).toBe("AE");
|
||
});
|
||
});
|
||
|
||
it("renders an author avatar for a user group with sender identity", async () => {
|
||
const container = document.createElement("div");
|
||
render(
|
||
renderMessageGroup(
|
||
{
|
||
kind: "group",
|
||
key: "attributed-user",
|
||
role: "user",
|
||
senderLabel: "Alice Example",
|
||
sender: { id: "profile_123", name: "Alice Example" },
|
||
messages: [
|
||
{
|
||
key: "attributed-message",
|
||
message: { role: "user", content: "hello", timestamp: 1000 },
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
isStreaming: false,
|
||
},
|
||
{
|
||
showReasoning: true,
|
||
showToolCalls: true,
|
||
assistantName: "OpenClaw",
|
||
},
|
||
),
|
||
container,
|
||
);
|
||
|
||
await vi.waitFor(() => {
|
||
expect(container.querySelector(".chat-author-avatar__initials")?.textContent?.trim()).toBe(
|
||
"AE",
|
||
);
|
||
});
|
||
expect(container.querySelector(".chat-author-avatar")?.getAttribute("title")).toBe(
|
||
"Alice Example",
|
||
);
|
||
});
|
||
|
||
it("falls back to initials when a user avatar image fails", async () => {
|
||
const container = document.createElement("div");
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "gravatar-user",
|
||
role: "user",
|
||
senderLabel: "alice",
|
||
// profileAvatarUrl exercises the img tier; bare emails render initials
|
||
// only (no third-party avatar fetch without a gateway proxy base).
|
||
sender: { id: "alice@example.com", profileAvatarUrl: "/api/users/alice/avatar" },
|
||
messages: [
|
||
{
|
||
key: "gravatar-message",
|
||
message: { role: "user", content: "hello", timestamp: 1000 },
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
isStreaming: false,
|
||
};
|
||
render(
|
||
renderMessageGroup(group, {
|
||
showReasoning: true,
|
||
showToolCalls: true,
|
||
assistantName: "OpenClaw",
|
||
}),
|
||
container,
|
||
);
|
||
|
||
const image = await vi.waitFor(() => {
|
||
const result = container.querySelector<HTMLImageElement>(".chat-author-avatar__image");
|
||
expect(result).not.toBeNull();
|
||
expect(result?.getAttribute("src")).toBe("/api/users/alice/avatar");
|
||
return result!;
|
||
});
|
||
image.dispatchEvent(new Event("error"));
|
||
expect(container.querySelector(".chat-author-avatar")?.classList.contains("is-fallback")).toBe(
|
||
true,
|
||
);
|
||
expect(container.querySelector(".chat-author-avatar__fallback")?.textContent?.trim()).toBe("A");
|
||
});
|
||
|
||
it("does not render an author avatar for a user group without sender identity", () => {
|
||
const container = document.createElement("div");
|
||
renderGroupedMessage(container, { role: "user", content: "hello", timestamp: 1000 }, "user");
|
||
expect(container.querySelector(".chat-author-avatar")).toBeNull();
|
||
});
|
||
|
||
it("never renders a user author avatar on assistant output", () => {
|
||
const container = document.createElement("div");
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "assistant-with-sender",
|
||
role: "assistant",
|
||
senderLabel: "Forwarded Agent",
|
||
sender: { id: "agent@example.com", name: "Forwarded Agent" },
|
||
messages: [
|
||
{
|
||
key: "assistant-message",
|
||
message: { role: "assistant", content: "hello", timestamp: 1000 },
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
isStreaming: false,
|
||
};
|
||
render(
|
||
renderMessageGroup(group, {
|
||
showReasoning: true,
|
||
showToolCalls: true,
|
||
assistantName: "OpenClaw",
|
||
}),
|
||
container,
|
||
);
|
||
expect(container.querySelector(".chat-author-avatar")).toBeNull();
|
||
});
|
||
|
||
it("uses assistant senderLabel for forwarded assistant-side groups", () => {
|
||
const container = document.createElement("div");
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "forwarded-group",
|
||
role: "assistant",
|
||
senderLabel: "Forwarded from main",
|
||
messages: [
|
||
{
|
||
key: "forwarded-message",
|
||
message: { role: "assistant", content: "forwarded report", timestamp: 1000 },
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
isStreaming: false,
|
||
};
|
||
|
||
render(
|
||
renderMessageGroup(group, {
|
||
showReasoning: true,
|
||
showToolCalls: true,
|
||
assistantName: "OpenClaw",
|
||
assistantAvatar: null,
|
||
}),
|
||
container,
|
||
);
|
||
|
||
const sender = container.querySelector<HTMLElement>(".chat-group.assistant .chat-sender-name");
|
||
expect(sender?.textContent).toBe("Forwarded from main");
|
||
});
|
||
|
||
it("collapses consecutive tool results into an activity group", () => {
|
||
const container = document.createElement("div");
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "tool-group",
|
||
role: "tool",
|
||
messages: [
|
||
{
|
||
key: "tool-message-1",
|
||
message: {
|
||
role: "toolResult",
|
||
toolCallId: "call-1",
|
||
toolName: "read_file",
|
||
content: "File one",
|
||
timestamp: 1000,
|
||
},
|
||
},
|
||
{
|
||
key: "tool-message-2",
|
||
message: {
|
||
role: "toolResult",
|
||
toolCallId: "call-2",
|
||
toolName: "run_command",
|
||
content: "Command output",
|
||
timestamp: 1001,
|
||
},
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
isStreaming: false,
|
||
};
|
||
|
||
renderMessageGroups(container, [group], {
|
||
isToolMessageExpanded: (id) => (id === "activity:tool-group" ? false : undefined),
|
||
});
|
||
|
||
const activity = expectElement(container, ".chat-activity-group__summary", HTMLButtonElement);
|
||
// Aggregate summary from summarizeToolGroup replaces the old "Activity: N tools" label.
|
||
expect(activity.textContent).toContain("Ran a command, read a file");
|
||
expect(activity.querySelector(".chat-activity-group__preview")).toBeNull();
|
||
expect(activity.textContent).not.toContain("read_file");
|
||
expect(activity.textContent).not.toContain("run_command");
|
||
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
|
||
});
|
||
|
||
it("collapses paired parallel tool cards from one message into an activity group", () => {
|
||
const container = document.createElement("div");
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "parallel-tool-group",
|
||
role: "tool",
|
||
messages: [
|
||
{
|
||
key: "parallel-tool-message",
|
||
message: {
|
||
role: "assistant",
|
||
content: [
|
||
{
|
||
type: "toolCall",
|
||
id: "call-a",
|
||
name: "read",
|
||
arguments: { path: "/repo/a.ts" },
|
||
},
|
||
{
|
||
type: "toolCall",
|
||
id: "call-b",
|
||
name: "read",
|
||
arguments: { path: "/repo/b.ts" },
|
||
},
|
||
{
|
||
type: "tool_result",
|
||
id: "call-a",
|
||
name: "read",
|
||
text: "File A",
|
||
},
|
||
{
|
||
type: "tool_result",
|
||
id: "call-b",
|
||
name: "read",
|
||
text: "File B",
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
},
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
isStreaming: false,
|
||
};
|
||
|
||
renderMessageGroups(container, [group], {
|
||
isToolMessageExpanded: (id) => (id === "activity:parallel-tool-group" ? false : undefined),
|
||
});
|
||
|
||
const activity = expectElement(container, ".chat-activity-group__summary", HTMLButtonElement);
|
||
expect(activity.textContent).toContain("Read 2 files");
|
||
expect(
|
||
expectElement(activity, ".chat-activity-group__label", HTMLElement).getAttribute("title"),
|
||
).toBe("Read 2 files");
|
||
expect(container.querySelectorAll(".chat-activity-group")).toHaveLength(1);
|
||
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
|
||
});
|
||
|
||
it("uses the running mutation verb in an active group summary", () => {
|
||
const container = document.createElement("div");
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "running-tool-group",
|
||
role: "tool",
|
||
messages: [
|
||
{
|
||
key: "finished-read",
|
||
message: {
|
||
role: "toolResult",
|
||
toolCallId: "call-read",
|
||
toolName: "read",
|
||
content: "done",
|
||
},
|
||
},
|
||
{
|
||
key: "running-edit",
|
||
message: {
|
||
role: "assistant",
|
||
__openclawToolStreamLive: true,
|
||
__openclawToolStreamResultReceived: false,
|
||
content: [
|
||
{
|
||
type: "tool_use",
|
||
id: "call-edit",
|
||
name: "edit",
|
||
input: { path: "/repo/src/a.ts", oldText: "old", newText: "new" },
|
||
},
|
||
],
|
||
},
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
isStreaming: true,
|
||
};
|
||
|
||
renderMessageGroups(container, [group], { runActive: true });
|
||
|
||
expect(container.querySelector(".chat-activity-group__label")?.textContent).toBe(
|
||
"Editing a.ts…",
|
||
);
|
||
});
|
||
|
||
it("passes the effective default-expanded activity state to the toggle handler", () => {
|
||
const container = document.createElement("div");
|
||
document.body.append(container);
|
||
const onToggleToolMessageExpanded = vi.fn();
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "tool-group",
|
||
role: "tool",
|
||
messages: [
|
||
{
|
||
key: "tool-message-1",
|
||
message: {
|
||
role: "toolResult",
|
||
toolCallId: "call-1",
|
||
toolName: "read_file",
|
||
isError: true,
|
||
content: JSON.stringify({ error: "Read failed" }),
|
||
timestamp: 1000,
|
||
},
|
||
},
|
||
{
|
||
key: "tool-message-2",
|
||
message: {
|
||
role: "toolResult",
|
||
toolCallId: "call-2",
|
||
toolName: "run_command",
|
||
content: "Command output",
|
||
timestamp: 1001,
|
||
},
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
isStreaming: false,
|
||
};
|
||
|
||
renderMessageGroups(container, [group], { onToggleToolMessageExpanded });
|
||
|
||
expect(container.querySelector(".chat-activity-group.is-open")).toBeInstanceOf(HTMLElement);
|
||
const activitySummary = expectElement(
|
||
container,
|
||
".chat-activity-group__summary",
|
||
HTMLButtonElement,
|
||
);
|
||
expect(activitySummary.classList.contains("chat-activity-group__summary--error")).toBe(true);
|
||
expect(activitySummary.getAttribute("aria-label")).toBe("Activity: 2 tools, includes errors.");
|
||
expect(activitySummary.querySelector(".chat-activity-group__badge")).toBeNull();
|
||
const errorSummary = expectElement(
|
||
container,
|
||
".chat-tool-msg-summary--error",
|
||
HTMLButtonElement,
|
||
);
|
||
expect(errorSummary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe(
|
||
"Tool error",
|
||
);
|
||
expect(errorSummary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
|
||
selectText(expectElement(activitySummary, ".chat-activity-group__label", HTMLElement));
|
||
pointerClick(activitySummary);
|
||
expect(onToggleToolMessageExpanded).not.toHaveBeenCalled();
|
||
|
||
window.getSelection()?.removeAllRanges();
|
||
activitySummary.click();
|
||
|
||
expect(onToggleToolMessageExpanded).toHaveBeenCalledWith("activity:tool-group", true);
|
||
container.remove();
|
||
});
|
||
|
||
it("keeps recovered grouped activity collapsed while retaining its failure summary", () => {
|
||
const container = document.createElement("div");
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "tool-group",
|
||
role: "tool",
|
||
turnSucceeded: true,
|
||
messages: [
|
||
{
|
||
key: "tool-message-1",
|
||
message: {
|
||
role: "toolResult",
|
||
toolCallId: "call-1",
|
||
toolName: "web_search",
|
||
isError: true,
|
||
content: JSON.stringify({ error: "No matches" }),
|
||
timestamp: 1000,
|
||
},
|
||
},
|
||
{
|
||
key: "tool-message-2",
|
||
message: {
|
||
role: "toolResult",
|
||
toolCallId: "call-2",
|
||
toolName: "read_file",
|
||
content: "Fallback context",
|
||
timestamp: 1001,
|
||
},
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
isStreaming: false,
|
||
};
|
||
|
||
renderMessageGroups(container, [group]);
|
||
|
||
expect(container.querySelector(".chat-activity-group.is-open")).toBeNull();
|
||
expect(container.querySelector(".chat-activity-group__summary--error")).toBeNull();
|
||
expect(container.querySelector(".chat-activity-group__label")?.textContent).toContain(
|
||
"1 failed",
|
||
);
|
||
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
|
||
});
|
||
|
||
it("keeps recovered coalesced tool failures neutral in the activity list", () => {
|
||
const container = document.createElement("div");
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "recovered-tool-group",
|
||
role: "tool",
|
||
turnSucceeded: true,
|
||
messages: [
|
||
{
|
||
key: "recovered-tool-message",
|
||
message: {
|
||
role: "assistant",
|
||
isError: true,
|
||
content: [
|
||
{
|
||
type: "tool_use",
|
||
id: "call-recovered",
|
||
name: "bash",
|
||
input: { command: "run fallback" },
|
||
},
|
||
{
|
||
type: "tool_result",
|
||
id: "call-recovered",
|
||
name: "bash",
|
||
text: "Primary path failed",
|
||
isError: true,
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
},
|
||
},
|
||
{
|
||
key: "recovered-followup",
|
||
message: {
|
||
role: "toolResult",
|
||
toolCallId: "call-followup",
|
||
toolName: "read_file",
|
||
content: "Fallback context",
|
||
timestamp: 1001,
|
||
},
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
isStreaming: false,
|
||
};
|
||
|
||
renderMessageGroups(container, [group], {
|
||
isToolMessageExpanded: (id) => id === "activity:recovered-tool-group",
|
||
});
|
||
|
||
const summaries = container.querySelectorAll(".chat-tool-msg-summary");
|
||
expect(summaries).toHaveLength(2);
|
||
expect(container.querySelector(".chat-activity-group__summary--error")).toBeNull();
|
||
expect(container.querySelector(".chat-activity-group__label")?.textContent).toContain(
|
||
"1 failed",
|
||
);
|
||
expect(container.querySelectorAll(".chat-tool-msg-summary--error")).toHaveLength(1);
|
||
expect(container.querySelector(".chat-tool-row__badge")?.textContent).toBe("failed");
|
||
// Command calls render a `$ command` row instead of the tool-name label.
|
||
expect(summaries[0]?.querySelector(".chat-tool-row__cmd")?.textContent).toBe("run fallback");
|
||
});
|
||
|
||
it("hides grouped tool activity when tool calls are disabled", () => {
|
||
const container = document.createElement("div");
|
||
const group: MessageGroup = {
|
||
kind: "group",
|
||
key: "tool-group",
|
||
role: "tool",
|
||
messages: [
|
||
{
|
||
key: "tool-message-1",
|
||
message: {
|
||
role: "toolResult",
|
||
toolCallId: "call-1",
|
||
toolName: "read_file",
|
||
content: "File one",
|
||
timestamp: 1000,
|
||
},
|
||
},
|
||
{
|
||
key: "tool-message-2",
|
||
message: {
|
||
role: "toolResult",
|
||
toolCallId: "call-2",
|
||
toolName: "run_command",
|
||
content: "Command output",
|
||
timestamp: 1001,
|
||
},
|
||
},
|
||
],
|
||
timestamp: 1000,
|
||
isStreaming: false,
|
||
};
|
||
|
||
renderMessageGroups(container, [group], { showToolCalls: false });
|
||
|
||
expect(container.querySelector(".chat-activity-group")).toBeNull();
|
||
});
|
||
|
||
it("keeps inline tool cards collapsed by default and renders expanded state", () => {
|
||
const container = document.createElement("div");
|
||
const message = {
|
||
id: "assistant-1",
|
||
role: "assistant",
|
||
toolCallId: "call-1",
|
||
content: [
|
||
{
|
||
type: "toolcall",
|
||
id: "call-1",
|
||
name: "browser.open",
|
||
arguments: { url: "https://example.com" },
|
||
},
|
||
{
|
||
type: "toolresult",
|
||
id: "call-1",
|
||
name: "browser.open",
|
||
text: "Opened page",
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
};
|
||
renderAssistantMessage(container, message, {
|
||
isToolExpanded: () => false,
|
||
});
|
||
|
||
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
|
||
|
||
renderAssistantMessage(container, message, {
|
||
isToolExpanded: () => true,
|
||
});
|
||
|
||
// Simple object args render as key-value rows; only the output keeps a block.
|
||
const kvRow = container.querySelector(".chat-tool-kv__row");
|
||
expect(kvRow?.querySelector(".chat-tool-kv__key")?.textContent).toBe("url:");
|
||
expect(kvRow?.querySelector(".chat-tool-kv__value")?.textContent).toBe("https://example.com");
|
||
const blocks = Array.from(container.querySelectorAll(".chat-tool-card__block"));
|
||
expect(
|
||
blocks.map((block) => block.querySelector(".chat-tool-card__block-label")?.textContent),
|
||
).toEqual(["Tool output"]);
|
||
expect(blocks[0]?.querySelector("code")?.textContent).toBe("Opened page");
|
||
});
|
||
|
||
it("renders expanded standalone tool-call rows", () => {
|
||
const container = document.createElement("div");
|
||
const message = {
|
||
id: "assistant-4b",
|
||
role: "assistant",
|
||
toolCallId: "call-4b",
|
||
content: [
|
||
{
|
||
type: "toolcall",
|
||
id: "call-4b",
|
||
name: "sessions_spawn",
|
||
arguments: { mode: "session", thread: true },
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
};
|
||
renderAssistantMessage(container, message, {
|
||
isToolExpanded: () => false,
|
||
});
|
||
|
||
expectElement(container, ".chat-bubble--tool-shell", HTMLElement);
|
||
const summary = container.querySelector<HTMLElement>(".chat-tool-msg-summary");
|
||
expect(summary?.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Sub-agent");
|
||
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
|
||
|
||
renderAssistantMessage(container, message, {
|
||
isToolExpanded: () => true,
|
||
});
|
||
|
||
// Simple object args render as key-value rows instead of a raw JSON block.
|
||
expect(container.querySelector(".chat-tool-card__block")).toBeNull();
|
||
const kvRows = Array.from(container.querySelectorAll(".chat-tool-kv__row"));
|
||
expect(
|
||
kvRows.map((row) => [
|
||
row.querySelector(".chat-tool-kv__key")?.textContent,
|
||
row.querySelector(".chat-tool-kv__value")?.textContent,
|
||
]),
|
||
).toEqual([
|
||
["mode:", "session"],
|
||
["thread:", "true"],
|
||
]);
|
||
});
|
||
|
||
it("renders assistant tool content as a flat concise tool row without a top-level call id", () => {
|
||
const container = document.createElement("div");
|
||
const message = {
|
||
id: "assistant-tool-content",
|
||
role: "assistant",
|
||
content: [
|
||
{
|
||
type: "tool_use",
|
||
id: "call-content-only",
|
||
name: "bash",
|
||
input: { command: "bash" },
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
};
|
||
|
||
renderAssistantMessage(container, message, {
|
||
isToolMessageExpanded: () => false,
|
||
});
|
||
|
||
expectElement(container, ".chat-bubble--tool-shell", HTMLElement);
|
||
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||
// Command calls render a `$ command` row instead of the tool-name label.
|
||
expect(summary.querySelector(".chat-tool-row__cmd")?.textContent).toBe("bash");
|
||
expect(summary.querySelector(".chat-tool-msg-summary__names")).toBeNull();
|
||
});
|
||
|
||
it("keeps top-level tool-name results collapsed", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
toolName: "bash",
|
||
content: "A long tool result that should stay behind the disclosure.",
|
||
timestamp: Date.now(),
|
||
},
|
||
{ isToolMessageExpanded: () => false },
|
||
);
|
||
|
||
expectElement(container, ".chat-bubble--tool-shell", HTMLElement);
|
||
expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
|
||
expect(container.querySelector(".chat-text")).toBeNull();
|
||
});
|
||
|
||
it("omits normalized duplicate names from standalone tool results", () => {
|
||
const container = document.createElement("div");
|
||
const message = {
|
||
role: "toolResult",
|
||
toolCallId: "call-heartbeat",
|
||
toolName: "heartbeat_respond",
|
||
content: [
|
||
{
|
||
type: "tool_result",
|
||
name: "heartbeat_respond",
|
||
text: "Acknowledged",
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
};
|
||
|
||
renderAssistantMessage(container, message, {
|
||
isToolMessageExpanded: () => false,
|
||
});
|
||
|
||
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe(
|
||
"Heartbeat Respond",
|
||
);
|
||
expect(summary.querySelector(".chat-tool-msg-summary__names")).toBeNull();
|
||
});
|
||
|
||
it("cleans collapsed tool connector copy while preserving expanded raw input", () => {
|
||
const container = document.createElement("div");
|
||
const message = {
|
||
id: "assistant-string-tool",
|
||
role: "assistant",
|
||
toolCallId: "call-string-tool",
|
||
content: [
|
||
{
|
||
type: "toolcall",
|
||
id: "call-string-tool",
|
||
name: "presentation_create",
|
||
arguments: "with Example Deck",
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
};
|
||
renderAssistantMessage(container, message, {
|
||
isToolExpanded: () => false,
|
||
});
|
||
|
||
// The cleaned string-arg preview is now the primary collapsed label.
|
||
expect(container.querySelector(".chat-tool-msg-summary__label")?.textContent?.trim()).toBe(
|
||
"Example Deck",
|
||
);
|
||
expect(container.querySelector(".chat-tool-msg-summary__names")).toBeNull();
|
||
expect(container.querySelector(".chat-tool-msg-summary")?.textContent).not.toContain(
|
||
"with Example Deck",
|
||
);
|
||
|
||
renderAssistantMessage(container, message, {
|
||
isToolExpanded: () => true,
|
||
});
|
||
|
||
expect(container.querySelector(".chat-tool-msg-body")?.textContent).not.toContain(
|
||
"presentation_create",
|
||
);
|
||
expect(container.querySelector(".chat-tool-card__block code")?.textContent).toBe(
|
||
"with Example Deck",
|
||
);
|
||
});
|
||
|
||
it("renders expanded tool output rows and their json content", () => {
|
||
const container = document.createElement("div");
|
||
renderMessageGroups(
|
||
container,
|
||
[
|
||
createMessageGroup(
|
||
{
|
||
id: "assistant-5",
|
||
role: "assistant",
|
||
toolCallId: "call-5",
|
||
content: [
|
||
{
|
||
type: "toolcall",
|
||
id: "call-5",
|
||
name: "sessions_spawn",
|
||
arguments: { mode: "session", thread: true },
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
"assistant",
|
||
),
|
||
createMessageGroup(
|
||
{
|
||
id: "tool-5",
|
||
role: "tool",
|
||
toolCallId: "call-5",
|
||
toolName: "sessions_spawn",
|
||
content: JSON.stringify(
|
||
{
|
||
status: "error",
|
||
error: "Session mode is unavailable for this target.",
|
||
childSessionKey: "agent:test:subagent:abc123",
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
timestamp: Date.now() + 1,
|
||
},
|
||
"tool",
|
||
),
|
||
],
|
||
{
|
||
isToolExpanded: () => true,
|
||
isToolMessageExpanded: () => true,
|
||
},
|
||
);
|
||
|
||
// The call's simple args render as key-value rows; the error keeps a block.
|
||
const kvRows = Array.from(container.querySelectorAll(".chat-tool-kv__row"));
|
||
expect(
|
||
kvRows.map((row) => [
|
||
row.querySelector(".chat-tool-kv__key")?.textContent,
|
||
row.querySelector(".chat-tool-kv__value")?.textContent,
|
||
]),
|
||
).toEqual([
|
||
["mode:", "session"],
|
||
["thread:", "true"],
|
||
]);
|
||
const blocks = Array.from(container.querySelectorAll(".chat-tool-card__block"));
|
||
expect(
|
||
blocks.map((block) => block.querySelector(".chat-tool-card__block-label")?.textContent),
|
||
).toEqual(["Tool error"]);
|
||
expect(JSON.parse(blocks[0]?.querySelector("code")?.textContent ?? "{}")).toEqual({
|
||
status: "error",
|
||
error: "Session mode is unavailable for this target.",
|
||
childSessionKey: "agent:test:subagent:abc123",
|
||
});
|
||
});
|
||
|
||
it("respects explicit success on collapsed standalone tool-result summaries", () => {
|
||
const container = document.createElement("div");
|
||
renderMessageGroups(
|
||
container,
|
||
[
|
||
createMessageGroup(
|
||
{
|
||
id: "tool-error-collapsed",
|
||
role: "toolResult",
|
||
toolCallId: "call-error-collapsed",
|
||
toolName: "web_search",
|
||
isError: false,
|
||
content: JSON.stringify({
|
||
error: "missing_brave_api_key",
|
||
message: "BRAVE_API_KEY is not configured",
|
||
}),
|
||
timestamp: Date.now(),
|
||
},
|
||
"tool",
|
||
),
|
||
],
|
||
{
|
||
isToolMessageExpanded: () => false,
|
||
},
|
||
);
|
||
|
||
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(false);
|
||
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool output");
|
||
expect(summary.querySelector(".chat-tool-msg-summary__names")?.textContent).toBe("web_search");
|
||
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
|
||
expect(container.querySelector(".chat-tool-msg-body")).toBeNull();
|
||
});
|
||
|
||
it("respects explicit success on MCP-style standalone tool-result summaries", () => {
|
||
const container = document.createElement("div");
|
||
renderMessageGroups(
|
||
container,
|
||
[
|
||
createMessageGroup(
|
||
{
|
||
id: "tool-error-collapsed-mcp",
|
||
role: "toolResult",
|
||
toolCallId: "call-error-collapsed-mcp",
|
||
toolName: "memory_forget",
|
||
isError: false,
|
||
content: JSON.stringify({
|
||
isError: true,
|
||
content: [{ type: "text", text: "Tool error: boom" }],
|
||
}),
|
||
timestamp: Date.now(),
|
||
},
|
||
"tool",
|
||
),
|
||
],
|
||
{
|
||
isToolMessageExpanded: () => false,
|
||
},
|
||
);
|
||
|
||
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(false);
|
||
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool output");
|
||
expect(summary.querySelector(".chat-tool-msg-summary__names")?.textContent).toBe(
|
||
"memory_forget",
|
||
);
|
||
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
|
||
});
|
||
|
||
it("marks status-only standalone tool-result summaries as errors", () => {
|
||
const container = document.createElement("div");
|
||
document.body.append(container);
|
||
const onToggleToolMessageExpanded = vi.fn();
|
||
const groups = [
|
||
createMessageGroup(
|
||
{
|
||
id: "tool-status-error",
|
||
role: "toolResult",
|
||
toolCallId: "call-status-error",
|
||
toolName: "sessions_spawn",
|
||
content: JSON.stringify({ status: "error" }, null, 2),
|
||
timestamp: Date.now(),
|
||
},
|
||
"tool",
|
||
),
|
||
];
|
||
|
||
renderMessageGroups(container, groups, {
|
||
isToolMessageExpanded: () => false,
|
||
onToggleToolMessageExpanded,
|
||
});
|
||
|
||
let summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(true);
|
||
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool error");
|
||
expect(summary.querySelector(".chat-tool-msg-summary__names")?.textContent).toBe("Sub-agent");
|
||
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
|
||
selectText(expectElement(summary, ".chat-tool-msg-summary__label", HTMLElement));
|
||
pointerClick(summary);
|
||
expect(onToggleToolMessageExpanded).not.toHaveBeenCalled();
|
||
|
||
window.getSelection()?.removeAllRanges();
|
||
summary.click();
|
||
expect(onToggleToolMessageExpanded).toHaveBeenCalledOnce();
|
||
|
||
renderMessageGroups(container, groups, {
|
||
isToolMessageExpanded: () => true,
|
||
});
|
||
|
||
summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(true);
|
||
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool error");
|
||
expect(
|
||
JSON.parse(container.querySelector(".chat-json-content code")?.textContent ?? "{}"),
|
||
).toEqual({ status: "error" });
|
||
container.remove();
|
||
});
|
||
|
||
it("keeps succeeded standalone tool-result summaries collapsed without error styling", () => {
|
||
const container = document.createElement("div");
|
||
const groups = [
|
||
{
|
||
...createMessageGroup(
|
||
{
|
||
id: "tool-status-error",
|
||
role: "toolResult",
|
||
toolCallId: "call-status-error",
|
||
toolName: "sessions_spawn",
|
||
content: JSON.stringify({ status: "error" }, null, 2),
|
||
timestamp: Date.now(),
|
||
},
|
||
"tool",
|
||
),
|
||
turnSucceeded: true,
|
||
},
|
||
];
|
||
|
||
renderMessageGroups(container, groups, {
|
||
isToolMessageExpanded: () => false,
|
||
});
|
||
|
||
const summary = expectElement(container, ".chat-tool-msg-summary", HTMLButtonElement);
|
||
expect(summary.classList.contains("chat-tool-msg-summary--error")).toBe(false);
|
||
expect(summary.querySelector(".chat-tool-msg-summary__label")?.textContent).toBe("Tool output");
|
||
expect(summary.querySelector(".chat-tool-msg-summary__error-badge")).toBeNull();
|
||
});
|
||
|
||
it("collapses an inline tool call while keeping matching tool output visible", () => {
|
||
const container = document.createElement("div");
|
||
const groups = [
|
||
createMessageGroup(
|
||
{
|
||
id: "assistant-tool-messages",
|
||
role: "assistant",
|
||
toolCallId: "call-tool-messages",
|
||
content: [
|
||
{
|
||
type: "toolcall",
|
||
id: "call-tool-messages",
|
||
name: "sessions_spawn",
|
||
arguments: { mode: "session", thread: true },
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
"assistant",
|
||
),
|
||
createMessageGroup(
|
||
{
|
||
id: "tool-tool-messages",
|
||
role: "tool",
|
||
toolCallId: "call-tool-messages",
|
||
toolName: "sessions_spawn",
|
||
content: JSON.stringify({ status: "error" }, null, 2),
|
||
timestamp: Date.now() + 1,
|
||
},
|
||
"tool",
|
||
),
|
||
];
|
||
renderMessageGroups(container, groups, {
|
||
isToolExpanded: () => true,
|
||
isToolMessageExpanded: () => true,
|
||
});
|
||
|
||
// The call's simple args render as key-value rows while expanded.
|
||
const kvRows = Array.from(container.querySelectorAll(".chat-tool-kv__row"));
|
||
expect(
|
||
kvRows.map((row) => [
|
||
row.querySelector(".chat-tool-kv__key")?.textContent,
|
||
row.querySelector(".chat-tool-kv__value")?.textContent,
|
||
]),
|
||
).toEqual([
|
||
["mode:", "session"],
|
||
["thread:", "true"],
|
||
]);
|
||
expect(
|
||
JSON.parse(container.querySelector(".chat-json-content code")?.textContent ?? "{}"),
|
||
).toEqual({
|
||
status: "error",
|
||
});
|
||
|
||
// Collapsing the call card must not hide the matching tool output message.
|
||
renderMessageGroups(container, groups, {
|
||
isToolExpanded: () => false,
|
||
isToolMessageExpanded: () => true,
|
||
});
|
||
|
||
expect(container.querySelector(".chat-tool-kv")).toBeNull();
|
||
expect(
|
||
JSON.parse(container.querySelector(".chat-json-content code")?.textContent ?? "{}"),
|
||
).toEqual({
|
||
status: "error",
|
||
});
|
||
});
|
||
|
||
it("renders assistant MEDIA attachments, voice-note badge, and reply pill", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-media-inline",
|
||
role: "assistant",
|
||
content:
|
||
"[[reply_to_current]]Here is the image.\nMEDIA:https://example.com/photo.png\nMEDIA:https://example.com/voice.ogg\n[[audio_as_voice]]",
|
||
timestamp: Date.now(),
|
||
},
|
||
{ showToolCalls: false },
|
||
);
|
||
|
||
expect(container.querySelector(".chat-reply-pill__label")?.textContent?.trim()).toBe(
|
||
"Replying to current message",
|
||
);
|
||
expect(container.querySelector(".chat-text")?.textContent?.trim()).toBe("Here is the image.");
|
||
expect(expectElement(container, ".chat-message-image", HTMLImageElement).src).toBe(
|
||
"https://example.com/photo.png",
|
||
);
|
||
expect(expectElement(container, "audio", HTMLAudioElement).src).toBe(
|
||
"https://example.com/voice.ogg",
|
||
);
|
||
expect(container.querySelector(".chat-assistant-attachment-badge")?.textContent?.trim()).toBe(
|
||
"Voice note",
|
||
);
|
||
});
|
||
|
||
it("notifies when assistant audio and video attachment metadata loads", () => {
|
||
const container = document.createElement("div");
|
||
const onAssistantAttachmentLoaded = vi.fn();
|
||
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-media-layout",
|
||
role: "assistant",
|
||
content:
|
||
"Audio and video\nMEDIA:https://example.com/voice.ogg\nMEDIA:https://example.com/clip.mp4",
|
||
timestamp: Date.now(),
|
||
},
|
||
{ showToolCalls: false, onAssistantAttachmentLoaded },
|
||
);
|
||
|
||
expectElement(container, "audio", HTMLAudioElement).dispatchEvent(
|
||
new Event("loadedmetadata", { bubbles: true }),
|
||
);
|
||
expectElement(container, "video", HTMLVideoElement).dispatchEvent(
|
||
new Event("loadedmetadata", { bubbles: true }),
|
||
);
|
||
|
||
expect(onAssistantAttachmentLoaded).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it("renders verified local assistant attachments through the authenticated media route", async () => {
|
||
const source = `/tmp/openclaw/${crypto.randomUUID()} test image.png`;
|
||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||
if (url.includes("meta=1")) {
|
||
const headers = init?.headers as Headers;
|
||
expect(headers.get("Authorization")).toBe("Bearer session-token");
|
||
return { ok: true, json: async () => mediaTicketPayload("ticket-local") };
|
||
}
|
||
throw new Error(`Unexpected fetch: ${url}`);
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
const container = document.createElement("div");
|
||
const renderMessage = () =>
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-local-media-inline",
|
||
role: "assistant",
|
||
content: `Local image\nMEDIA:${source}`,
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
showToolCalls: false,
|
||
basePath: "/openclaw",
|
||
assistantAttachmentAuthToken: "session-token",
|
||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||
onRequestUpdate: renderMessage,
|
||
},
|
||
);
|
||
|
||
renderMessage();
|
||
expect(container.querySelector(".chat-assistant-attachment-badge")?.textContent?.trim()).toBe(
|
||
"Checking...",
|
||
);
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
|
||
const expectedMetaUrl = `/openclaw/__openclaw__/assistant-media?source=${encodeURIComponent(source).replaceAll("%20", "+")}&meta=1`;
|
||
const [, fetchInit] = requireFetchCallForUrl(fetchMock, expectedMetaUrl);
|
||
expectSameOriginGet(fetchInit);
|
||
expect(
|
||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||
).toBe(expectedMetaUrl.replace("&meta=1", "&mediaTicket=ticket-local"));
|
||
});
|
||
|
||
it("stops checking when local assistant attachment metadata fetch stalls", async () => {
|
||
vi.useFakeTimers();
|
||
const source = `/tmp/openclaw/${crypto.randomUUID()}-stalled.txt`;
|
||
const fetchMock = vi.fn(
|
||
(_url: string, init?: RequestInit) =>
|
||
new Promise<Response>((_resolve, reject) => {
|
||
const signal = init?.signal;
|
||
signal?.addEventListener(
|
||
"abort",
|
||
() =>
|
||
reject(
|
||
signal.reason instanceof Error
|
||
? signal.reason
|
||
: new DOMException("aborted", "AbortError"),
|
||
),
|
||
{ once: true },
|
||
);
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
const container = document.createElement("div");
|
||
const rerender = () =>
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-local-media-stalled-metadata",
|
||
role: "assistant",
|
||
content: `Local document\nMEDIA:${source}`,
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
showToolCalls: false,
|
||
basePath: "/openclaw",
|
||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||
onRequestUpdate: rerender,
|
||
},
|
||
);
|
||
|
||
rerender();
|
||
expect(container.querySelector(".chat-assistant-attachment-badge")?.textContent?.trim()).toBe(
|
||
"Checking...",
|
||
);
|
||
|
||
const expectedMetaUrl = `/openclaw/__openclaw__/assistant-media?source=${encodeURIComponent(source)}&meta=1`;
|
||
const [, fetchInit] = requireFetchCallForUrl(fetchMock, expectedMetaUrl);
|
||
await vi.advanceTimersByTimeAsync(30_001);
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
|
||
expect(fetchInit?.signal).toBeInstanceOf(AbortSignal);
|
||
expect(fetchInit?.signal?.aborted).toBe(true);
|
||
expect(container.querySelector(".chat-assistant-attachment-badge")?.textContent?.trim()).toBe(
|
||
"Unavailable",
|
||
);
|
||
});
|
||
|
||
it("refreshes local assistant media tickets before expiry without another render", async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date("2026-04-30T00:00:00Z"));
|
||
const source = `/tmp/openclaw/${crypto.randomUUID()}-refresh.png`;
|
||
const fetchMock = vi
|
||
.fn<
|
||
(url: string, init?: RequestInit) => Promise<{ ok: true; json: () => Promise<unknown> }>
|
||
>()
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => mediaTicketPayload("ticket-old", 31_000),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => mediaTicketPayload("ticket-new"),
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
const container = document.createElement("div");
|
||
const rerender = () =>
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-local-media-ticket-refresh",
|
||
role: "assistant",
|
||
content: `Local image\nMEDIA:${source}`,
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
showToolCalls: false,
|
||
basePath: "/openclaw",
|
||
assistantAttachmentAuthToken: "test-auth-token",
|
||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||
onRequestUpdate: rerender,
|
||
},
|
||
);
|
||
|
||
rerender();
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(
|
||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||
).toContain("mediaTicket=ticket-old");
|
||
|
||
await vi.advanceTimersByTimeAsync(1_001);
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(
|
||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||
).toContain("mediaTicket=ticket-new");
|
||
});
|
||
|
||
it("rechecks local assistant media when its auth token changes", async () => {
|
||
const source = `/tmp/openclaw/${crypto.randomUUID()}-auth.png`;
|
||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||
if (!url.includes("meta=1")) {
|
||
throw new Error(`Unexpected fetch: ${url}`);
|
||
}
|
||
const usesUpdatedFixture =
|
||
new Headers(init?.headers).get("Authorization") ===
|
||
["Bearer", "test-token-placeholder"].join(" ");
|
||
return {
|
||
ok: true,
|
||
json: async () =>
|
||
usesUpdatedFixture ? mediaTicketPayload("ticket-fresh") : { available: false },
|
||
};
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
const container = document.createElement("div");
|
||
|
||
const renderWithToken = (token: string | null) =>
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-local-media-auth-refresh",
|
||
role: "assistant",
|
||
content: `Local image\nMEDIA:${source}`,
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
showToolCalls: false,
|
||
basePath: "/openclaw",
|
||
assistantAttachmentAuthToken: token,
|
||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||
onRequestUpdate: () => renderWithToken(token),
|
||
},
|
||
);
|
||
|
||
renderWithToken(null);
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
expect(container.querySelector(".chat-assistant-attachment-badge")?.textContent?.trim()).toBe(
|
||
"Unavailable",
|
||
);
|
||
|
||
renderWithToken("test-token-placeholder");
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(
|
||
expectElement(container, ".chat-message-image", HTMLImageElement).getAttribute("src"),
|
||
).toContain("mediaTicket=ticket-fresh");
|
||
});
|
||
|
||
it("retries unavailable local assistant media after the retry window", async () => {
|
||
vi.useFakeTimers();
|
||
const source = `/tmp/openclaw/${crypto.randomUUID()}-retry.png`;
|
||
const fetchMock = vi
|
||
.fn<(url: string) => Promise<{ ok: true; json: () => Promise<unknown> }>>()
|
||
.mockResolvedValueOnce({ ok: true, json: async () => ({ available: false }) })
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => mediaTicketPayload("ticket-retry"),
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
const container = document.createElement("div");
|
||
const rerender = () =>
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-local-media-retry-after-unavailable",
|
||
role: "assistant",
|
||
content: `Local image\nMEDIA:${source}`,
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
showToolCalls: false,
|
||
basePath: "/openclaw",
|
||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||
onRequestUpdate: rerender,
|
||
},
|
||
);
|
||
|
||
rerender();
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(container.querySelector(".chat-assistant-attachment-badge")?.textContent?.trim()).toBe(
|
||
"Unavailable",
|
||
);
|
||
|
||
await vi.advanceTimersByTimeAsync(5_001);
|
||
rerender();
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(
|
||
expectElement(container, ".chat-message-image", HTMLImageElement).getAttribute("src"),
|
||
).toContain("mediaTicket=ticket-retry");
|
||
});
|
||
|
||
it("preserves same-origin assistant attachments without local preview rewriting", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-same-origin-media-inline",
|
||
role: "assistant",
|
||
content:
|
||
"Inline\nMEDIA:/media/inbound/test-image.png\nMEDIA:/__openclaw__/media/test-doc.pdf",
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
showToolCalls: false,
|
||
basePath: "/openclaw",
|
||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||
},
|
||
);
|
||
|
||
expect(
|
||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||
).toBe("/media/inbound/test-image.png");
|
||
expect(
|
||
container
|
||
.querySelector<HTMLAnchorElement>(".chat-assistant-attachment-card__link")
|
||
?.getAttribute("href"),
|
||
).toBe("/__openclaw__/media/test-doc.pdf");
|
||
expect(container.querySelector(".chat-assistant-attachment-card--blocked")).toBeNull();
|
||
});
|
||
|
||
it("renders local files outside preview roots as unavailable", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-blocked-local-media",
|
||
role: "assistant",
|
||
content: "Blocked\nMEDIA:/Users/test/Documents/private.pdf\nDone",
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
showToolCalls: false,
|
||
basePath: "/openclaw",
|
||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||
},
|
||
);
|
||
|
||
expect(container.querySelector(".chat-assistant-attachment-card__link")).toBeNull();
|
||
const blocked = container.querySelector(".chat-assistant-attachment-card--blocked");
|
||
expect(blocked?.querySelector(".chat-assistant-attachment-card__title")?.textContent).toBe(
|
||
"private.pdf",
|
||
);
|
||
expect(
|
||
blocked?.querySelector(".chat-assistant-attachment-card__reason")?.textContent?.trim(),
|
||
).toBe("Outside allowed folders");
|
||
expect(container.querySelector(".chat-text")?.textContent?.trim()).toBe("Blocked\nDone");
|
||
});
|
||
|
||
it("renders transcript video URLs with encoded extensions", () => {
|
||
const container = document.createElement("div");
|
||
const mediaUrl = "https://cdn.example/clip%2Emp4?download=1";
|
||
|
||
renderGroupedMessage(
|
||
container,
|
||
{
|
||
id: "user-encoded-video",
|
||
role: "user",
|
||
content: "",
|
||
MediaPath: mediaUrl,
|
||
timestamp: Date.now(),
|
||
},
|
||
"user",
|
||
{ showToolCalls: false },
|
||
);
|
||
|
||
expect(expectElement(container, "video", HTMLVideoElement).src).toBe(mediaUrl);
|
||
});
|
||
|
||
it("renders transcript image variants and structured image blocks", async () => {
|
||
const firstSource = `/tmp/openclaw/${crypto.randomUUID()}-first.png`;
|
||
const secondSource = `/tmp/openclaw/${crypto.randomUUID()}-second.jpg`;
|
||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||
const mediaUrl = new URL(url, "http://control.test");
|
||
expect(mediaUrl.pathname).toBe("/openclaw/__openclaw__/assistant-media");
|
||
expect(mediaUrl.searchParams.get("meta")).toBe("1");
|
||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer test-auth-token");
|
||
return { ok: true, json: async () => mediaTicketPayload("ticket-transcript") };
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
|
||
const container = document.createElement("div");
|
||
const renderUserMedia = (message: unknown) => {
|
||
const rerender = () =>
|
||
renderGroupedMessage(container, message, "user", {
|
||
showToolCalls: false,
|
||
basePath: "/openclaw",
|
||
assistantAttachmentAuthToken: "test-auth-token",
|
||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||
onRequestUpdate: rerender,
|
||
});
|
||
rerender();
|
||
};
|
||
|
||
renderUserMedia({
|
||
id: "user-history-image-octet-stream",
|
||
role: "user",
|
||
content: "",
|
||
MediaPath: firstSource,
|
||
MediaType: "application/octet-stream",
|
||
timestamp: Date.now(),
|
||
});
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
expect(
|
||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||
).toContain(`source=${encodeURIComponent(firstSource)}`);
|
||
|
||
renderUserMedia({
|
||
id: "user-history-images",
|
||
role: "user",
|
||
content: "",
|
||
MediaPaths: [firstSource, secondSource],
|
||
MediaTypes: ["image/png", "application/octet-stream"],
|
||
timestamp: Date.now(),
|
||
});
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
expect(
|
||
[...container.querySelectorAll<HTMLImageElement>(".chat-message-image")].map((image) =>
|
||
image.getAttribute("src"),
|
||
),
|
||
).toEqual([
|
||
expect.stringContaining(`source=${encodeURIComponent(firstSource)}`),
|
||
expect.stringContaining(`source=${encodeURIComponent(secondSource)}`),
|
||
]);
|
||
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: [{ type: "input_image", image_url: "data:image/png;base64,cG5n" }],
|
||
timestamp: Date.now(),
|
||
});
|
||
expect(
|
||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||
).toBe("data:image/png;base64,cG5n");
|
||
});
|
||
|
||
it("renders canonical inbound transcript images through the authenticated media route", async () => {
|
||
const source = `media://inbound/${crypto.randomUUID()}.png`;
|
||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||
const mediaUrl = new URL(url, "http://control.test");
|
||
expect(mediaUrl.searchParams.get("source")).toBe(source);
|
||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer test-auth-token");
|
||
return { ok: true, json: async () => mediaTicketPayload("ticket-inbound") };
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
|
||
const container = document.createElement("div");
|
||
const rerender = () =>
|
||
renderGroupedMessage(
|
||
container,
|
||
{
|
||
id: "user-inbound-media-ref",
|
||
role: "user",
|
||
content: "",
|
||
MediaPath: source,
|
||
MediaType: "image/png",
|
||
timestamp: Date.now(),
|
||
},
|
||
"user",
|
||
{
|
||
showToolCalls: false,
|
||
basePath: "/openclaw",
|
||
assistantAttachmentAuthToken: "test-auth-token",
|
||
localMediaPreviewRoots: [],
|
||
onRequestUpdate: rerender,
|
||
},
|
||
);
|
||
|
||
rerender();
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(
|
||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||
).toBe(
|
||
`/openclaw/__openclaw__/assistant-media?source=${encodeURIComponent(source)}&mediaTicket=ticket-inbound`,
|
||
);
|
||
});
|
||
|
||
it("expires pairing QR images and requests a refresh at the expiry boundary", async () => {
|
||
vi.useFakeTimers();
|
||
vi.setSystemTime(new Date("2026-06-30T05:45:00Z"));
|
||
const container = document.createElement("div");
|
||
const onRequestUpdate = vi.fn();
|
||
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
content: [
|
||
{
|
||
type: "openclaw_pairing_qr",
|
||
image_url: "data:image/png;base64,cXJwbmc=",
|
||
alt: "OpenClaw pairing QR code",
|
||
expiresAtMs: Date.now() + 1_000,
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
{ showToolCalls: false, onRequestUpdate },
|
||
);
|
||
|
||
const image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||
expect(image?.getAttribute("src")).toBe("data:image/png;base64,cXJwbmc=");
|
||
expect(image?.getAttribute("alt")).toBe("OpenClaw pairing QR code");
|
||
await vi.advanceTimersByTimeAsync(999);
|
||
expect(onRequestUpdate).not.toHaveBeenCalled();
|
||
await vi.advanceTimersByTimeAsync(1);
|
||
expect(onRequestUpdate).toHaveBeenCalledTimes(1);
|
||
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: [
|
||
{
|
||
type: "openclaw_pairing_qr",
|
||
image_url: "data:image/png;base64,ZXhwaXJlZA==",
|
||
alt: "OpenClaw pairing QR code",
|
||
expiresAtMs: Date.now() - 1,
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
});
|
||
expect(container.querySelector(".chat-message-image")).toBeNull();
|
||
expect(container.textContent).toContain("Pairing QR expired");
|
||
});
|
||
|
||
it.each([
|
||
"media://outbound/photo.png",
|
||
"media://inbound/",
|
||
"media://inbound/nested%2Fphoto.png",
|
||
"media://inbound/%00.png",
|
||
"media://inbound/nested/../photo.png",
|
||
"media://inbound/%2e%2e/photo.png",
|
||
"media://inbound/..",
|
||
"media://inbound/photo.png?raw=1",
|
||
"media://inbound/photo.png#preview",
|
||
])("does not proxy non-canonical inbound media ref %s", (source) => {
|
||
const fetchMock = vi.fn();
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
|
||
const container = document.createElement("div");
|
||
renderGroupedMessage(
|
||
container,
|
||
{
|
||
id: "user-invalid-inbound-media-ref",
|
||
role: "user",
|
||
content: "",
|
||
MediaPath: source,
|
||
MediaType: "image/png",
|
||
timestamp: Date.now(),
|
||
},
|
||
"user",
|
||
{
|
||
showToolCalls: false,
|
||
assistantAttachmentAuthToken: "session-token",
|
||
localMediaPreviewRoots: [],
|
||
},
|
||
);
|
||
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it("fetches managed outgoing chat images with auth and requester scope", async () => {
|
||
const managedChatImageUrl = `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`;
|
||
const objectUrl = "blob:managed-image";
|
||
const NativeUrl = URL;
|
||
vi.stubGlobal(
|
||
"URL",
|
||
class extends NativeUrl {
|
||
static override createObjectURL = vi.fn(() => objectUrl);
|
||
static override revokeObjectURL = vi.fn();
|
||
},
|
||
);
|
||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||
const headers = new Headers(init?.headers);
|
||
expect(headers.get("Authorization")).toBe("Bearer test-auth-token");
|
||
expect(headers.get("x-openclaw-requester-session-key")).toBe("agent:main:main");
|
||
return { ok: true, blob: async () => new Blob(["png"], { type: "image/png" }) };
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
content: [
|
||
{
|
||
type: "image",
|
||
url: managedChatImageUrl,
|
||
alt: "Generated image 1",
|
||
width: 1,
|
||
height: 1,
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
showToolCalls: false,
|
||
assistantAttachmentAuthToken: "test-auth-token",
|
||
},
|
||
);
|
||
|
||
await vi.waitFor(() => {
|
||
const image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||
expect(image?.getAttribute("src")).toBe(objectUrl);
|
||
expect(image?.getAttribute("alt")).toBe("Generated image 1");
|
||
});
|
||
const [, fetchInit] = requireFetchCallForUrl(fetchMock, managedChatImageUrl);
|
||
expectSameOriginGet(fetchInit);
|
||
});
|
||
|
||
it("aborts a stalled managed outgoing image fetch after the deadline", async () => {
|
||
vi.useFakeTimers();
|
||
const managedChatImageUrl = `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`;
|
||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||
const signal = init?.signal;
|
||
if (!signal) {
|
||
throw new Error("missing managed image signal");
|
||
}
|
||
return await rejectWhenAborted<Response>(signal, () => {
|
||
const reason = signal.reason;
|
||
return reason instanceof Error ? reason : new Error("managed image fetch aborted");
|
||
});
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
content: [
|
||
{
|
||
type: "image",
|
||
url: managedChatImageUrl,
|
||
alt: "Generated image timeout",
|
||
width: 1,
|
||
height: 1,
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
showToolCalls: false,
|
||
assistantAttachmentAuthToken: "test-auth-token",
|
||
},
|
||
);
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
const [, fetchInit] = requireFetchCallForUrl(fetchMock, managedChatImageUrl);
|
||
expect(fetchInit?.signal?.aborted).toBe(false);
|
||
expectSameOriginGet(fetchInit);
|
||
|
||
await vi.advanceTimersByTimeAsync(29_999);
|
||
expect(fetchInit?.signal?.aborted).toBe(false);
|
||
await vi.advanceTimersByTimeAsync(1);
|
||
expect(fetchInit?.signal?.aborted).toBe(true);
|
||
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
expect(container.querySelector(".chat-message-image")).toBeNull();
|
||
expect(vi.getTimerCount()).toBe(0);
|
||
});
|
||
|
||
it("falls back when a managed outgoing image body stalls after headers", async () => {
|
||
vi.useFakeTimers();
|
||
const managedChatImageUrl = `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`;
|
||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||
const signal = init?.signal;
|
||
if (!signal) {
|
||
throw new Error("missing managed image signal");
|
||
}
|
||
return {
|
||
ok: true,
|
||
blob: async () =>
|
||
await rejectWhenAborted<Blob>(
|
||
signal,
|
||
() => new DOMException("The operation was aborted.", "AbortError"),
|
||
),
|
||
};
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: [
|
||
{
|
||
type: "image",
|
||
url: managedChatImageUrl,
|
||
alt: "Generated image body stall",
|
||
width: 1,
|
||
height: 1,
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
const [, fetchInit] = requireFetchCallForUrl(fetchMock, managedChatImageUrl);
|
||
expect(fetchInit?.signal?.aborted).toBe(false);
|
||
expectSameOriginGet(fetchInit);
|
||
|
||
await vi.advanceTimersByTimeAsync(30_000);
|
||
expect(fetchInit?.signal?.aborted).toBe(true);
|
||
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
expect(container.querySelector(".chat-message-image")).toBeNull();
|
||
expect(vi.getTimerCount()).toBe(0);
|
||
});
|
||
|
||
it("treats a failed managed outgoing image fetch as a missing preview", async () => {
|
||
const managedChatImageUrl = `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`;
|
||
const fetchMock = vi.fn(async () => {
|
||
throw new Error("gateway unavailable");
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: [
|
||
{
|
||
type: "image",
|
||
url: managedChatImageUrl,
|
||
alt: "Unavailable generated image",
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
expect(container.querySelector(".chat-message-image")).toBeNull();
|
||
});
|
||
|
||
it("bounds managed outgoing image blob URLs with least-recently-used eviction", async () => {
|
||
const imageUrls = Array.from(
|
||
{ length: 65 },
|
||
() => `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`,
|
||
);
|
||
let objectUrlIndex = 0;
|
||
const createObjectURL = vi.fn(() => `blob:managed-image-${objectUrlIndex++}`);
|
||
const revokeObjectURL = vi.fn();
|
||
const NativeUrl = URL;
|
||
vi.stubGlobal(
|
||
"URL",
|
||
class extends NativeUrl {
|
||
static override createObjectURL = createObjectURL;
|
||
static override revokeObjectURL = revokeObjectURL;
|
||
},
|
||
);
|
||
const fetchMock = vi.fn(async () => ({
|
||
ok: true,
|
||
blob: async () => new Blob(["png"], { type: "image/png" }),
|
||
}));
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: imageUrls.slice(0, 64).map((url, index) => ({
|
||
type: "image",
|
||
url,
|
||
alt: `Generated image ${index + 1}`,
|
||
})),
|
||
timestamp: Date.now(),
|
||
});
|
||
await vi.waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(64));
|
||
|
||
const recentContainer = document.createElement("div");
|
||
renderAssistantMessage(recentContainer, {
|
||
role: "assistant",
|
||
content: [{ type: "image", url: imageUrls[0], alt: "Recently viewed image" }],
|
||
timestamp: Date.now(),
|
||
});
|
||
expect(createObjectURL).toHaveBeenCalledTimes(64);
|
||
|
||
const overflowContainer = document.createElement("div");
|
||
renderAssistantMessage(overflowContainer, {
|
||
role: "assistant",
|
||
content: [{ type: "image", url: imageUrls[64], alt: "Newest image" }],
|
||
timestamp: Date.now(),
|
||
});
|
||
await vi.waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(imageUrls.length));
|
||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:managed-image-1");
|
||
expect(revokeObjectURL).not.toHaveBeenCalledWith("blob:managed-image-0");
|
||
|
||
const replace = vi.fn();
|
||
const close = vi.fn();
|
||
const openedWindow = {
|
||
opener: window,
|
||
location: { replace },
|
||
close,
|
||
};
|
||
const openSpy = vi.spyOn(window, "open").mockReturnValue(openedWindow as unknown as Window);
|
||
const evictedImage = container.querySelector<HTMLImageElement>('img[alt="Generated image 2"]');
|
||
expect(evictedImage).toBeInstanceOf(HTMLImageElement);
|
||
evictedImage!.click();
|
||
|
||
expect(openSpy).toHaveBeenCalledWith("about:blank", "_blank");
|
||
expect(openedWindow.opener).toBeNull();
|
||
await vi.waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(imageUrls.length + 1));
|
||
expect(replace).toHaveBeenCalledWith("blob:managed-image-65");
|
||
expect(close).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("bounds managed outgoing image miss retention", async () => {
|
||
const imageUrls = Array.from(
|
||
{ length: 65 },
|
||
() => `/api/chat/media/outgoing/agent%3Amain%3Amain/${crypto.randomUUID()}/full`,
|
||
);
|
||
const fetchMock = vi.fn(async () => ({ ok: false }));
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
const container = document.createElement("div");
|
||
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: imageUrls.map((url, index) => ({
|
||
type: "image",
|
||
url,
|
||
alt: `Missing image ${index + 1}`,
|
||
})),
|
||
timestamp: Date.now(),
|
||
});
|
||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(imageUrls.length));
|
||
await flushAssistantAttachmentAvailabilityChecks();
|
||
|
||
const retryContainer = document.createElement("div");
|
||
renderAssistantMessage(retryContainer, {
|
||
role: "assistant",
|
||
content: [{ type: "image", url: imageUrls[0], alt: "Oldest missing image" }],
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(imageUrls.length + 1));
|
||
});
|
||
|
||
it("does not send auth to cross-origin managed-image-looking URLs", () => {
|
||
const fetchMock = vi.fn(async () => {
|
||
throw new Error("cross-origin image URL should not be fetched with Control UI auth");
|
||
});
|
||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
content: [
|
||
{
|
||
type: "image",
|
||
url: "https://evil.example/api/chat/media/outgoing/agent%3Amain%3Amain/00000000-0000-4000-8000-000000000000/full",
|
||
alt: "Untrusted image",
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
showToolCalls: false,
|
||
assistantAttachmentAuthToken: "session-token",
|
||
},
|
||
);
|
||
|
||
const image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||
expect(image?.getAttribute("src")).toBe(
|
||
"https://evil.example/api/chat/media/outgoing/agent%3Amain%3Amain/00000000-0000-4000-8000-000000000000/full",
|
||
);
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("renders direct tool-result image data inline", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
content: [
|
||
{
|
||
type: "image",
|
||
data: "cG5n",
|
||
mimeType: "image/png",
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
{ showToolCalls: false },
|
||
);
|
||
expect(
|
||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||
).toBe("data:image/png;base64,cG5n");
|
||
});
|
||
|
||
it("passes through pre-encoded data: URLs in direct tool-result image blocks", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
content: [
|
||
{
|
||
type: "image",
|
||
data: "data:image/png;base64,cG5n",
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
{ showToolCalls: false },
|
||
);
|
||
expect(
|
||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||
).toBe("data:image/png;base64,cG5n");
|
||
});
|
||
|
||
it("renders canvas-only [embed] shortcodes inside the assistant bubble", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-canvas-only",
|
||
role: "assistant",
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: '[embed ref="cv_tictactoe" title="Tic-Tac-Toe" /]',
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
{ showToolCalls: false },
|
||
);
|
||
|
||
expectElement(container, ".chat-bubble", HTMLElement);
|
||
const iframe = expectElement(container, ".chat-tool-card__preview-frame", HTMLIFrameElement);
|
||
expect(iframe.getAttribute("title")).toBe("Tic-Tac-Toe");
|
||
expect(container.querySelector(".chat-tool-card__preview-label")?.textContent?.trim()).toBe(
|
||
"Tic-Tac-Toe",
|
||
);
|
||
});
|
||
|
||
it("opens only safe assistant image URLs in a hardened new tab", () => {
|
||
const container = document.createElement("div");
|
||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||
const renderAssistantImage = (url: string) =>
|
||
renderAssistantMessage(container, {
|
||
role: "assistant",
|
||
content: [{ type: "image_url", image_url: { url } }],
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
try {
|
||
renderAssistantImage("https://example.com/cat.png");
|
||
let image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||
expect(image).toBeInstanceOf(HTMLImageElement);
|
||
image!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
|
||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||
expect(openSpy).toHaveBeenCalledWith(
|
||
"https://example.com/cat.png",
|
||
"_blank",
|
||
"noopener,noreferrer",
|
||
);
|
||
|
||
openSpy.mockClear();
|
||
renderAssistantImage("javascript:alert(1)");
|
||
image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||
expect(image).toBeInstanceOf(HTMLImageElement);
|
||
image!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
expect(openSpy).not.toHaveBeenCalled();
|
||
|
||
renderAssistantImage("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' />");
|
||
image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||
expect(image).toBeInstanceOf(HTMLImageElement);
|
||
image!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
expect(openSpy).not.toHaveBeenCalled();
|
||
} finally {
|
||
openSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("routes inline canvas blocks through the scoped canvas host when available", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-scoped-canvas",
|
||
role: "assistant",
|
||
content: [
|
||
{ type: "text", text: "Rendered inline." },
|
||
{
|
||
type: "canvas",
|
||
preview: {
|
||
kind: "canvas",
|
||
surface: "assistant_message",
|
||
render: "url",
|
||
viewId: "cv_inline_scoped",
|
||
title: "Scoped preview",
|
||
url: "/__openclaw__/canvas/documents/cv_inline_scoped/index.html",
|
||
preferredHeight: 320,
|
||
},
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
canvasPluginSurfaceUrl: "http://127.0.0.1:19003/__openclaw__/cap/cap_123",
|
||
},
|
||
);
|
||
|
||
const iframe = container.querySelector(".chat-tool-card__preview-frame");
|
||
expect(iframe?.getAttribute("src")).toBe(
|
||
"http://127.0.0.1:19003/__openclaw__/cap/cap_123/__openclaw__/canvas/documents/cv_inline_scoped/index.html",
|
||
);
|
||
});
|
||
|
||
it("renders server-history canvas blocks for the live toolResult sequence after history reload", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-final-live-shape",
|
||
role: "assistant",
|
||
content: [
|
||
{ type: "thinking", thinking: "", thinkingSignature: "sig-2" },
|
||
{ type: "text", text: "This item is ready." },
|
||
{
|
||
type: "canvas",
|
||
preview: {
|
||
kind: "canvas",
|
||
surface: "assistant_message",
|
||
render: "url",
|
||
viewId: "cv_canvas_live_history",
|
||
title: "Live history preview",
|
||
url: "/__openclaw__/canvas/documents/cv_canvas_live_history/index.html",
|
||
preferredHeight: 420,
|
||
},
|
||
rawText: JSON.stringify({
|
||
kind: "canvas",
|
||
view: {
|
||
backend: "canvas",
|
||
id: "cv_canvas_live_history",
|
||
url: "/__openclaw__/canvas/documents/cv_canvas_live_history/index.html",
|
||
},
|
||
presentation: {
|
||
target: "assistant_message",
|
||
},
|
||
}),
|
||
},
|
||
],
|
||
timestamp: Date.now() + 2,
|
||
},
|
||
{ showToolCalls: true },
|
||
);
|
||
|
||
const allPreviews = container.querySelectorAll(".chat-tool-card__preview-frame");
|
||
expect(allPreviews).toHaveLength(1);
|
||
const bubble = expectElement(container, ".chat-group.assistant .chat-bubble", HTMLElement);
|
||
const iframe = expectElement(bubble, ".chat-tool-card__preview-frame", HTMLIFrameElement);
|
||
expect(iframe.getAttribute("src")).toBe(
|
||
"/__openclaw__/canvas/documents/cv_canvas_live_history/index.html",
|
||
);
|
||
expect(bubble.querySelector(".chat-text")?.textContent?.trim()).toBe("This item is ready.");
|
||
expect(bubble.querySelector(".chat-tool-card__preview-label")?.textContent?.trim()).toBe(
|
||
"Live history preview",
|
||
);
|
||
});
|
||
|
||
it("keeps lifted assistant canvas previews beside flat tool rows", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
id: "assistant-tool-canvas",
|
||
role: "assistant",
|
||
toolName: "bash",
|
||
content: [
|
||
{
|
||
type: "tool_use",
|
||
id: "call-tool-canvas",
|
||
name: "bash",
|
||
input: { command: "render preview" },
|
||
},
|
||
createAssistantCanvasBlock({ suffix: "tool_canvas" }),
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
{ showToolCalls: true, isToolMessageExpanded: () => true },
|
||
);
|
||
|
||
expectElement(container, ".chat-bubble--tool-shell", HTMLElement);
|
||
const iframe = expectElement(container, ".chat-tool-card__preview-frame", HTMLIFrameElement);
|
||
expect(iframe.getAttribute("src")).toBe(
|
||
"/__openclaw__/canvas/documents/cv_inline_tool_canvas/index.html",
|
||
);
|
||
expect(container.querySelector(".chat-tool-msg-summary")).not.toBeNull();
|
||
});
|
||
|
||
it("keeps assistant message actions outside the bubble", () => {
|
||
const container = document.createElement("div");
|
||
renderAssistantMessage(container, {
|
||
id: "assistant-action-space",
|
||
role: "assistant",
|
||
content: "Copyable assistant text.",
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
const bubble = container.querySelector(".chat-group.assistant .chat-bubble");
|
||
expect(bubble?.classList.contains("chat-bubble--has-actions")).toBe(false);
|
||
expect(bubble?.querySelector(".chat-bubble-actions")).toBeNull();
|
||
expect(
|
||
container.querySelector(".chat-group.assistant .chat-group-footer-actions .chat-copy-btn"),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it("renders hidden assistant_message canvas results with the configured sandbox", () => {
|
||
const container = document.createElement("div");
|
||
const renderCanvas = (params: { embedSandboxMode?: "trusted"; suffix: string }) =>
|
||
renderMessageGroups(
|
||
container,
|
||
[
|
||
createMessageGroup(
|
||
{
|
||
id: `assistant-canvas-inline-${params.suffix}`,
|
||
role: "assistant",
|
||
content: [
|
||
{ type: "text", text: "Inline canvas result." },
|
||
createAssistantCanvasBlock({ suffix: params.suffix }),
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
"assistant",
|
||
),
|
||
],
|
||
{
|
||
embedSandboxMode: params.embedSandboxMode ?? "scripts",
|
||
},
|
||
);
|
||
|
||
renderCanvas({ suffix: "default" });
|
||
|
||
let iframe = expectElement(container, ".chat-tool-card__preview-frame", HTMLIFrameElement);
|
||
expect(iframe.getAttribute("sandbox")).toBe("allow-scripts");
|
||
expect(iframe.getAttribute("src")).toBe(
|
||
"/__openclaw__/canvas/documents/cv_inline_default/index.html",
|
||
);
|
||
expect(container.querySelector(".chat-text")?.textContent?.trim()).toBe(
|
||
"Inline canvas result.",
|
||
);
|
||
expect(container.querySelector(".chat-tool-card__preview-label")?.textContent?.trim()).toBe(
|
||
"Inline demo",
|
||
);
|
||
expect(container.querySelector(".chat-tool-card__raw-toggle")?.textContent?.trim()).toBe(
|
||
"Raw details",
|
||
);
|
||
|
||
renderCanvas({ embedSandboxMode: "trusted", suffix: "trusted" });
|
||
iframe = expectElement(container, ".chat-tool-card__preview-frame", HTMLIFrameElement);
|
||
expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin");
|
||
});
|
||
|
||
it("recreates canvas preview iframes when the sandbox policy changes", () => {
|
||
const container = document.createElement("div");
|
||
const renderCanvas = (embedSandboxMode: "strict" | "scripts") =>
|
||
renderMessageGroups(
|
||
container,
|
||
[
|
||
createMessageGroup(
|
||
{
|
||
id: "assistant-canvas-inline-sandbox-change",
|
||
role: "assistant",
|
||
content: [
|
||
{ type: "text", text: "Inline canvas result." },
|
||
createAssistantCanvasBlock({ suffix: "sandbox-change" }),
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
"assistant",
|
||
),
|
||
],
|
||
{ embedSandboxMode },
|
||
);
|
||
|
||
renderCanvas("strict");
|
||
const strictIframe = expectElement(
|
||
container,
|
||
".chat-tool-card__preview-frame",
|
||
HTMLIFrameElement,
|
||
);
|
||
expect(strictIframe.getAttribute("sandbox")).toBe("");
|
||
|
||
renderCanvas("scripts");
|
||
const scriptsIframe = expectElement(
|
||
container,
|
||
".chat-tool-card__preview-frame",
|
||
HTMLIFrameElement,
|
||
);
|
||
expect(scriptsIframe).not.toBe(strictIframe);
|
||
expect(scriptsIframe.getAttribute("sandbox")).toBe("allow-scripts");
|
||
});
|
||
|
||
it("renders assistant_message canvas results in the assistant bubble even when tool rows are visible", () => {
|
||
const container = document.createElement("div");
|
||
renderMessageGroups(
|
||
container,
|
||
[
|
||
createMessageGroup(
|
||
{
|
||
id: "assistant-canvas-inline-visible",
|
||
role: "assistant",
|
||
content: [
|
||
{ type: "text", text: "Inline canvas result." },
|
||
createAssistantCanvasBlock({ suffix: "visible" }),
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
"assistant",
|
||
),
|
||
createMessageGroup(
|
||
{
|
||
id: "tool-artifact-inline-visible",
|
||
role: "tool",
|
||
toolCallId: "call-artifact-inline-visible",
|
||
toolName: "canvas_render",
|
||
content: JSON.stringify({
|
||
kind: "canvas",
|
||
view: {
|
||
backend: "canvas",
|
||
id: "cv_inline_visible",
|
||
url: "/__openclaw__/canvas/documents/cv_inline_visible/index.html",
|
||
title: "Inline demo",
|
||
preferred_height: 360,
|
||
},
|
||
presentation: {
|
||
target: "assistant_message",
|
||
},
|
||
}),
|
||
timestamp: Date.now() + 1,
|
||
},
|
||
"tool",
|
||
),
|
||
],
|
||
{
|
||
isToolMessageExpanded: () => true,
|
||
},
|
||
);
|
||
|
||
const allPreviews = container.querySelectorAll(".chat-tool-card__preview-frame");
|
||
expect(allPreviews).toHaveLength(1);
|
||
const bubble = expectElement(container, ".chat-group.assistant .chat-bubble", HTMLElement);
|
||
const iframe = expectElement(bubble, ".chat-tool-card__preview-frame", HTMLIFrameElement);
|
||
expect(iframe.getAttribute("src")).toBe(
|
||
"/__openclaw__/canvas/documents/cv_inline_visible/index.html",
|
||
);
|
||
expect(bubble.querySelector(".chat-text")?.textContent?.trim()).toBe("Inline canvas result.");
|
||
expect(bubble.querySelector(".chat-tool-card__preview-label")?.textContent?.trim()).toBe(
|
||
"Inline demo",
|
||
);
|
||
expect(
|
||
container.querySelector(".chat-group.tool .chat-tool-msg-summary__label")?.textContent,
|
||
).toBe("Tool output");
|
||
expect(
|
||
container.querySelector(".chat-group.tool .chat-tool-msg-summary__names")?.textContent,
|
||
).toBe("canvas_render");
|
||
});
|
||
|
||
it("opens generic tool details instead of a canvas preview from tool rows", () => {
|
||
const container = document.createElement("div");
|
||
const onOpenSidebar = vi.fn();
|
||
renderMessageGroups(
|
||
container,
|
||
[
|
||
createMessageGroup(
|
||
{
|
||
id: "assistant-canvas-sidebar",
|
||
role: "assistant",
|
||
content: [{ type: "text", text: "Sidebar canvas result." }],
|
||
timestamp: Date.now(),
|
||
},
|
||
"assistant",
|
||
),
|
||
createMessageGroup(
|
||
{
|
||
id: "tool-artifact-sidebar",
|
||
role: "tool",
|
||
toolCallId: "call-artifact-sidebar",
|
||
toolName: "canvas_render",
|
||
content: JSON.stringify({
|
||
kind: "canvas",
|
||
view: {
|
||
backend: "canvas",
|
||
id: "cv_sidebar",
|
||
url: "https://example.com/canvas",
|
||
title: "Sidebar demo",
|
||
preferred_height: 420,
|
||
},
|
||
presentation: {
|
||
target: "tool_card",
|
||
},
|
||
}),
|
||
timestamp: Date.now() + 1,
|
||
},
|
||
"tool",
|
||
),
|
||
],
|
||
{
|
||
isToolExpanded: () => true,
|
||
isToolMessageExpanded: () => true,
|
||
onOpenSidebar,
|
||
},
|
||
);
|
||
|
||
const sidebarButton = container.querySelector<HTMLButtonElement>(".chat-tool-card__action-btn");
|
||
expect(sidebarButton).toBeInstanceOf(HTMLButtonElement);
|
||
sidebarButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
|
||
expect(container.querySelector(".chat-tool-card__preview-frame")).toBeNull();
|
||
expect(onOpenSidebar).toHaveBeenCalledTimes(1);
|
||
expect(requireFirstMockArg(onOpenSidebar, "sidebar open").kind).toBe("markdown");
|
||
});
|
||
|
||
it("adds a full-message request when opening a truncated assistant message", () => {
|
||
const container = document.createElement("div");
|
||
const onOpenSidebar = vi.fn();
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
content: [{ type: "text", text: "abcde\n...(truncated)..." }],
|
||
__openclaw: { id: "msg-truncated-1", seq: 1 },
|
||
},
|
||
{
|
||
sessionKey: "global",
|
||
agentId: "work",
|
||
onOpenSidebar,
|
||
},
|
||
);
|
||
|
||
const expandButton = container.querySelector<HTMLButtonElement>(".chat-expand-btn");
|
||
expect(expandButton).toBeInstanceOf(HTMLButtonElement);
|
||
expandButton!.click();
|
||
|
||
const sidebar = requireFirstMockArg(onOpenSidebar, "sidebar open");
|
||
expect(sidebar.kind).toBe("markdown");
|
||
expect(sidebar.fullMessageRequest).toEqual({
|
||
sessionKey: "global",
|
||
agentId: "work",
|
||
messageId: "msg-truncated-1",
|
||
kind: "assistant_message",
|
||
});
|
||
});
|
||
|
||
it("does not add a full-message request for non-truncated assistant messages", () => {
|
||
const container = document.createElement("div");
|
||
const onOpenSidebar = vi.fn();
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
content: [{ type: "text", text: "full visible message" }],
|
||
__openclaw: { id: "msg-visible-1", seq: 1 },
|
||
},
|
||
{
|
||
sessionKey: "global",
|
||
agentId: "work",
|
||
onOpenSidebar,
|
||
},
|
||
);
|
||
|
||
const expandButton = container.querySelector<HTMLButtonElement>(".chat-expand-btn");
|
||
expect(expandButton).toBeInstanceOf(HTMLButtonElement);
|
||
expandButton!.click();
|
||
|
||
const sidebar = requireFirstMockArg(onOpenSidebar, "sidebar open");
|
||
expect(sidebar.kind).toBe("markdown");
|
||
expect(sidebar.fullMessageRequest).toBeUndefined();
|
||
});
|
||
|
||
it("does not add a full-message request for mirrored message-tool replies", () => {
|
||
const container = document.createElement("div");
|
||
const onOpenSidebar = vi.fn();
|
||
renderAssistantMessage(
|
||
container,
|
||
{
|
||
role: "assistant",
|
||
content: [{ type: "text", text: "mirrored text\n...(truncated)..." }],
|
||
openclawMessageToolMirror: { toolName: "message", toolCallId: "call-1" },
|
||
__openclaw: { id: "msg-tool-result", seq: 2, truncated: true },
|
||
},
|
||
{
|
||
sessionKey: "global",
|
||
agentId: "work",
|
||
onOpenSidebar,
|
||
},
|
||
);
|
||
|
||
const expandButton = container.querySelector<HTMLButtonElement>(".chat-expand-btn");
|
||
expect(expandButton).toBeInstanceOf(HTMLButtonElement);
|
||
expandButton!.click();
|
||
|
||
const sidebar = requireFirstMockArg(onOpenSidebar, "sidebar open");
|
||
expect(sidebar.kind).toBe("markdown");
|
||
expect(sidebar.fullMessageRequest).toBeUndefined();
|
||
});
|
||
});
|
||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|