mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
test(ui): consolidate chat jsdom suites
This commit is contained in:
@@ -1,101 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderChatAvatar } from "./chat-avatar.ts";
|
||||
|
||||
vi.mock("../views/agents-utils.ts", () => {
|
||||
const isRenderableControlUiAvatarUrl = (value: string) =>
|
||||
/^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//"));
|
||||
|
||||
return {
|
||||
assistantAvatarFallbackUrl: () => "/openclaw-molty.png",
|
||||
isRenderableControlUiAvatarUrl,
|
||||
resolveAssistantTextAvatar: (value: string | null | undefined) => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || trimmed === "A") {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
trimmed.length > 8 ||
|
||||
/\s/.test(trimmed) ||
|
||||
/[\\/.:]/.test(trimmed) ||
|
||||
/[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u.test(trimmed)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
},
|
||||
resolveChatAvatarRenderUrl: (
|
||||
candidate: string | null | undefined,
|
||||
agent: { identity?: { avatar?: string; avatarUrl?: string } },
|
||||
) => {
|
||||
if (typeof candidate === "string" && candidate.startsWith("blob:")) {
|
||||
return candidate;
|
||||
}
|
||||
for (const value of [candidate, agent.identity?.avatarUrl, agent.identity?.avatar]) {
|
||||
if (typeof value === "string" && isRenderableControlUiAvatarUrl(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function renderAvatar(params: Parameters<typeof renderChatAvatar>) {
|
||||
const container = document.createElement("div");
|
||||
render(renderChatAvatar(...params), container);
|
||||
return container.querySelector<HTMLElement>(".chat-avatar");
|
||||
}
|
||||
|
||||
describe("renderChatAvatar", () => {
|
||||
it("uses the assistant fallback when no assistant avatar is configured", () => {
|
||||
const avatar = renderAvatar(["assistant"]);
|
||||
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.getAttribute("src")).toBe("/openclaw-molty.png");
|
||||
});
|
||||
|
||||
it("renders assistant fallback, blob image, and text avatars", () => {
|
||||
const remoteAvatar = renderAvatar([
|
||||
"assistant",
|
||||
{ avatar: "https://example.com/avatar.png", name: "Val" },
|
||||
]);
|
||||
expect(remoteAvatar?.getAttribute("src")).toBe("/openclaw-molty.png");
|
||||
|
||||
const blobAvatar = renderAvatar(["assistant", { avatar: "blob:managed-image", name: "Val" }]);
|
||||
expect(blobAvatar?.tagName).toBe("IMG");
|
||||
expect(blobAvatar?.getAttribute("src")).toBe("blob:managed-image");
|
||||
|
||||
const textAvatar = renderAvatar(["assistant", { avatar: "VC", name: "Val" }]);
|
||||
expect(textAvatar?.tagName).toBe("DIV");
|
||||
expect(textAvatar?.textContent).toContain("VC");
|
||||
expect(textAvatar?.getAttribute("aria-label")).toBe("Val");
|
||||
});
|
||||
|
||||
it("uses the assistant fallback while authenticated avatar routes are loading", () => {
|
||||
const avatar = renderAvatar([
|
||||
"assistant",
|
||||
{ avatar: "/avatar/main", name: "OpenClaw" },
|
||||
undefined,
|
||||
"",
|
||||
"session-token",
|
||||
]);
|
||||
|
||||
expect(avatar?.getAttribute("src")).toBe("/openclaw-molty.png");
|
||||
});
|
||||
|
||||
it("renders local user image and text avatars", () => {
|
||||
const imageAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "/avatar/user" }]);
|
||||
expect(imageAvatar?.getAttribute("src")).toBe("/avatar/user");
|
||||
expect(imageAvatar?.getAttribute("alt")).toBe("Buns");
|
||||
|
||||
const textAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "AB" }]);
|
||||
expect(textAvatar?.tagName).toBe("DIV");
|
||||
expect(textAvatar?.textContent).toContain("AB");
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewaySessionRow } from "../types.ts";
|
||||
|
||||
vi.mock("../markdown.ts", () => ({
|
||||
toSanitizedMarkdownHtml: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("../icons.ts", () => ({
|
||||
icons: {},
|
||||
}));
|
||||
|
||||
import {
|
||||
getContextNoticeViewModel,
|
||||
renderContextNotice,
|
||||
resetContextNoticeThemeCacheForTest,
|
||||
} from "./context-notice.ts";
|
||||
import { renderSideResult } from "./side-result-render.ts";
|
||||
|
||||
describe("context notice", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resetContextNoticeThemeCacheForTest();
|
||||
});
|
||||
|
||||
it("renders only for fresh high current usage", () => {
|
||||
const container = document.createElement("div");
|
||||
vi.spyOn(window, "getComputedStyle").mockReturnValue({
|
||||
getPropertyValue: (name: string) =>
|
||||
name === "--warn" ? "#010203" : name === "--danger" ? "#040506" : "",
|
||||
} as CSSStyleDeclaration);
|
||||
resetContextNoticeThemeCacheForTest();
|
||||
|
||||
expect(
|
||||
getContextNoticeViewModel(
|
||||
{
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
inputTokens: 757_300,
|
||||
totalTokens: 46_000,
|
||||
contextTokens: 200_000,
|
||||
},
|
||||
200_000,
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
const session: GatewaySessionRow = {
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
inputTokens: 757_300,
|
||||
totalTokens: 190_000,
|
||||
contextTokens: 200_000,
|
||||
};
|
||||
render(renderContextNotice(session, 200_000), container);
|
||||
|
||||
expect(container.textContent).toContain("95% context used");
|
||||
expect(container.textContent).toContain("190k / 200k");
|
||||
expect(getContextNoticeViewModel(session, 200_000)?.compactRecommended).toBe(true);
|
||||
expect(container.textContent).not.toContain("757.3k / 200k");
|
||||
const notice = container.querySelector<HTMLElement>(".context-notice");
|
||||
expect(notice).not.toBeNull();
|
||||
expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb(");
|
||||
expect(notice?.style.getPropertyValue("--ctx-color")).toContain("4, 5, 6");
|
||||
expect(notice?.style.getPropertyValue("--ctx-color")).not.toContain("NaN");
|
||||
expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN");
|
||||
|
||||
const icon = container.querySelector<SVGElement>(".context-notice__icon");
|
||||
expect(icon).not.toBeNull();
|
||||
expect(icon?.tagName.toLowerCase()).toBe("svg");
|
||||
expect(icon?.classList.contains("context-notice__icon")).toBe(true);
|
||||
expect(icon?.getAttribute("width")).toBe("16");
|
||||
expect(icon?.getAttribute("height")).toBe("16");
|
||||
expect(icon?.querySelector("path")).not.toBeNull();
|
||||
|
||||
const onCompact = vi.fn();
|
||||
render(renderContextNotice(session, 200_000, { onCompact }), container);
|
||||
expect(container.textContent).toContain("Compact");
|
||||
container.querySelector<HTMLButtonElement>(".context-notice__action")?.click();
|
||||
expect(onCompact).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
getContextNoticeViewModel(
|
||||
{
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
inputTokens: 500_000,
|
||||
contextTokens: 200_000,
|
||||
},
|
||||
200_000,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getContextNoticeViewModel(
|
||||
{
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
totalTokens: 190_000,
|
||||
totalTokensFresh: false,
|
||||
contextTokens: 200_000,
|
||||
},
|
||||
200_000,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("side result render", () => {
|
||||
it("renders, dismisses, and styles BTW side results outside transcript history", () => {
|
||||
const container = document.createElement("div");
|
||||
const onDismissSideResult = vi.fn();
|
||||
|
||||
render(
|
||||
renderSideResult(
|
||||
{
|
||||
kind: "btw",
|
||||
runId: "btw-run-1",
|
||||
sessionKey: "main",
|
||||
question: "what changed?",
|
||||
text: "The web UI now renders **BTW** separately.",
|
||||
isError: false,
|
||||
ts: 2,
|
||||
},
|
||||
onDismissSideResult,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-side-result")).not.toBeNull();
|
||||
expect(container.textContent).toContain("BTW");
|
||||
expect(container.textContent).toContain("what changed?");
|
||||
expect(container.textContent).toContain("Not saved to chat history");
|
||||
expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1);
|
||||
|
||||
const button = container.querySelector<HTMLButtonElement>(".chat-side-result__dismiss");
|
||||
expect(button).not.toBeNull();
|
||||
button?.click();
|
||||
expect(onDismissSideResult).toHaveBeenCalledTimes(1);
|
||||
|
||||
render(
|
||||
renderSideResult({
|
||||
kind: "btw",
|
||||
runId: "btw-run-3",
|
||||
sessionKey: "main",
|
||||
question: "what failed?",
|
||||
text: "The side question could not be answered.",
|
||||
isError: true,
|
||||
ts: 4,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-side-result--error")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -263,43 +263,50 @@ afterEach(() => {
|
||||
|
||||
describe("grouped chat rendering", () => {
|
||||
it("positions delete confirm by message side", () => {
|
||||
const renderDeletable = (role: "user" | "assistant") => {
|
||||
const container = document.createElement("div");
|
||||
clearDeleteConfirmSkip();
|
||||
renderGroupedMessage(
|
||||
container,
|
||||
{
|
||||
role,
|
||||
content: `hello from ${role}`,
|
||||
timestamp: 1000,
|
||||
},
|
||||
role,
|
||||
{ onDelete: vi.fn() },
|
||||
);
|
||||
return container;
|
||||
};
|
||||
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 userContainer = renderDeletable("user");
|
||||
const userDeleteButton = userContainer.querySelector<HTMLButtonElement>(
|
||||
const userDeleteButton = container.querySelector<HTMLButtonElement>(
|
||||
".chat-group.user .chat-group-delete",
|
||||
);
|
||||
expect(userDeleteButton).not.toBeNull();
|
||||
userDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
const userConfirm = userContainer.querySelector<HTMLElement>(
|
||||
const userConfirm = container.querySelector<HTMLElement>(
|
||||
".chat-group.user .chat-delete-confirm",
|
||||
);
|
||||
expect(userConfirm).not.toBeNull();
|
||||
expect(userConfirm?.classList.contains("chat-delete-confirm--left")).toBe(true);
|
||||
|
||||
const assistantContainer = renderDeletable("assistant");
|
||||
const assistantDeleteButton = assistantContainer.querySelector<HTMLButtonElement>(
|
||||
const assistantDeleteButton = container.querySelector<HTMLButtonElement>(
|
||||
".chat-group.assistant .chat-group-delete",
|
||||
);
|
||||
expect(assistantDeleteButton).not.toBeNull();
|
||||
assistantDeleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
const assistantConfirm = assistantContainer.querySelector<HTMLElement>(
|
||||
const assistantConfirm = container.querySelector<HTMLElement>(
|
||||
".chat-group.assistant .chat-delete-confirm",
|
||||
);
|
||||
expect(assistantConfirm).not.toBeNull();
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { html, render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewaySessionRow } from "../types.ts";
|
||||
import {
|
||||
getContextNoticeViewModel,
|
||||
renderContextNotice,
|
||||
resetContextNoticeThemeCacheForTest,
|
||||
} from "./context-notice.ts";
|
||||
import { renderChatRunControls, type ChatRunControlsProps } from "./run-controls.ts";
|
||||
import { renderSideResult } from "./side-result-render.ts";
|
||||
import { renderCompactionIndicator, renderFallbackIndicator } from "./status-indicators.ts";
|
||||
import { renderToolCard } from "./tool-cards.ts";
|
||||
|
||||
vi.mock("../icons.ts", () => ({
|
||||
icons: {},
|
||||
}));
|
||||
|
||||
vi.mock("../markdown.ts", () => ({
|
||||
toSanitizedMarkdownHtml: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("../tool-display.ts", () => ({
|
||||
formatToolDetail: () => undefined,
|
||||
resolveToolDisplay: ({ name }: { name: string }) => ({
|
||||
name,
|
||||
label: name
|
||||
.split(/[._-]/g)
|
||||
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
icon: "zap",
|
||||
}),
|
||||
}));
|
||||
|
||||
function createProps(overrides: Partial<ChatRunControlsProps> = {}): ChatRunControlsProps {
|
||||
return {
|
||||
canAbort: false,
|
||||
@@ -130,3 +155,395 @@ describe("chat run controls", () => {
|
||||
expect(onAbort).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat status indicators", () => {
|
||||
it("renders compaction and fallback indicators while they are fresh", () => {
|
||||
const container = document.createElement("div");
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
const renderIndicators = (
|
||||
compactionStatus: Parameters<typeof renderCompactionIndicator>[0],
|
||||
fallbackStatus: Parameters<typeof renderFallbackIndicator>[0],
|
||||
) => {
|
||||
render(
|
||||
html`${renderFallbackIndicator(fallbackStatus)}
|
||||
${renderCompactionIndicator(compactionStatus)}`,
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
nowSpy.mockReturnValue(1_000);
|
||||
renderIndicators(
|
||||
{
|
||||
phase: "active",
|
||||
runId: "run-1",
|
||||
startedAt: 1_000,
|
||||
completedAt: null,
|
||||
},
|
||||
{
|
||||
selected: "fireworks/minimax-m2p5",
|
||||
active: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
attempts: ["fireworks/minimax-m2p5: rate limit"],
|
||||
occurredAt: 900,
|
||||
},
|
||||
);
|
||||
|
||||
let indicator = container.querySelector(".compaction-indicator--active");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Compacting context...");
|
||||
indicator = container.querySelector(".compaction-indicator--fallback");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Fallback active: deepinfra/moonshotai/Kimi-K2.5");
|
||||
|
||||
renderIndicators(
|
||||
{
|
||||
phase: "complete",
|
||||
runId: "run-1",
|
||||
startedAt: 900,
|
||||
completedAt: 900,
|
||||
},
|
||||
{
|
||||
phase: "cleared",
|
||||
selected: "fireworks/minimax-m2p5",
|
||||
active: "fireworks/minimax-m2p5",
|
||||
previous: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
attempts: [],
|
||||
occurredAt: 900,
|
||||
},
|
||||
);
|
||||
indicator = container.querySelector(".compaction-indicator--complete");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Context compacted");
|
||||
indicator = container.querySelector(".compaction-indicator--fallback-cleared");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5");
|
||||
|
||||
nowSpy.mockReturnValue(20_000);
|
||||
renderIndicators(
|
||||
{
|
||||
phase: "complete",
|
||||
runId: "run-1",
|
||||
startedAt: 0,
|
||||
completedAt: 0,
|
||||
},
|
||||
{
|
||||
selected: "fireworks/minimax-m2p5",
|
||||
active: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
attempts: [],
|
||||
occurredAt: 0,
|
||||
},
|
||||
);
|
||||
expect(container.querySelector(".compaction-indicator--fallback")).toBeNull();
|
||||
expect(container.querySelector(".compaction-indicator--complete")).toBeNull();
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("context notice", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resetContextNoticeThemeCacheForTest();
|
||||
});
|
||||
|
||||
it("renders only for fresh high current usage", () => {
|
||||
const container = document.createElement("div");
|
||||
vi.spyOn(window, "getComputedStyle").mockReturnValue({
|
||||
getPropertyValue: (name: string) =>
|
||||
name === "--warn" ? "#010203" : name === "--danger" ? "#040506" : "",
|
||||
} as CSSStyleDeclaration);
|
||||
resetContextNoticeThemeCacheForTest();
|
||||
|
||||
expect(
|
||||
getContextNoticeViewModel(
|
||||
{
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
inputTokens: 757_300,
|
||||
totalTokens: 46_000,
|
||||
contextTokens: 200_000,
|
||||
},
|
||||
200_000,
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
const session: GatewaySessionRow = {
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
inputTokens: 757_300,
|
||||
totalTokens: 190_000,
|
||||
contextTokens: 200_000,
|
||||
};
|
||||
render(renderContextNotice(session, 200_000), container);
|
||||
|
||||
expect(container.textContent).toContain("95% context used");
|
||||
expect(container.textContent).toContain("190k / 200k");
|
||||
expect(getContextNoticeViewModel(session, 200_000)?.compactRecommended).toBe(true);
|
||||
expect(container.textContent).not.toContain("757.3k / 200k");
|
||||
const notice = container.querySelector<HTMLElement>(".context-notice");
|
||||
expect(notice).not.toBeNull();
|
||||
expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb(");
|
||||
expect(notice?.style.getPropertyValue("--ctx-color")).toContain("4, 5, 6");
|
||||
expect(notice?.style.getPropertyValue("--ctx-color")).not.toContain("NaN");
|
||||
expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN");
|
||||
|
||||
const icon = container.querySelector<SVGElement>(".context-notice__icon");
|
||||
expect(icon).not.toBeNull();
|
||||
expect(icon?.tagName.toLowerCase()).toBe("svg");
|
||||
expect(icon?.classList.contains("context-notice__icon")).toBe(true);
|
||||
expect(icon?.getAttribute("width")).toBe("16");
|
||||
expect(icon?.getAttribute("height")).toBe("16");
|
||||
expect(icon?.querySelector("path")).not.toBeNull();
|
||||
|
||||
const onCompact = vi.fn();
|
||||
render(renderContextNotice(session, 200_000, { onCompact }), container);
|
||||
expect(container.textContent).toContain("Compact");
|
||||
container.querySelector<HTMLButtonElement>(".context-notice__action")?.click();
|
||||
expect(onCompact).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
getContextNoticeViewModel(
|
||||
{
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
inputTokens: 500_000,
|
||||
contextTokens: 200_000,
|
||||
},
|
||||
200_000,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getContextNoticeViewModel(
|
||||
{
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
totalTokens: 190_000,
|
||||
totalTokensFresh: false,
|
||||
contextTokens: 200_000,
|
||||
},
|
||||
200_000,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("side result render", () => {
|
||||
it("renders, dismisses, and styles BTW side results outside transcript history", () => {
|
||||
const container = document.createElement("div");
|
||||
const onDismissSideResult = vi.fn();
|
||||
|
||||
render(
|
||||
renderSideResult(
|
||||
{
|
||||
kind: "btw",
|
||||
runId: "btw-run-1",
|
||||
sessionKey: "main",
|
||||
question: "what changed?",
|
||||
text: "The web UI now renders **BTW** separately.",
|
||||
isError: false,
|
||||
ts: 2,
|
||||
},
|
||||
onDismissSideResult,
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-side-result")).not.toBeNull();
|
||||
expect(container.textContent).toContain("BTW");
|
||||
expect(container.textContent).toContain("what changed?");
|
||||
expect(container.textContent).toContain("Not saved to chat history");
|
||||
expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1);
|
||||
|
||||
const button = container.querySelector<HTMLButtonElement>(".chat-side-result__dismiss");
|
||||
expect(button).not.toBeNull();
|
||||
button?.click();
|
||||
expect(onDismissSideResult).toHaveBeenCalledTimes(1);
|
||||
|
||||
render(
|
||||
renderSideResult({
|
||||
kind: "btw",
|
||||
runId: "btw-run-3",
|
||||
sessionKey: "main",
|
||||
question: "what failed?",
|
||||
text: "The side question could not be answered.",
|
||||
isError: true,
|
||||
ts: 4,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-side-result--error")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool-cards", () => {
|
||||
it("renders expanded cards with inline input and output sections", () => {
|
||||
const container = document.createElement("div");
|
||||
const toggle = vi.fn();
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:4:call-4",
|
||||
name: "browser.open",
|
||||
args: { url: "https://example.com" },
|
||||
inputText: '{\n "url": "https://example.com"\n}',
|
||||
outputText: "Opened page",
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: toggle },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Tool input");
|
||||
expect(container.textContent).toContain("Tool output");
|
||||
expect(container.textContent).toContain("https://example.com");
|
||||
expect(container.textContent).toContain("Opened page");
|
||||
});
|
||||
|
||||
it("renders expanded tool calls without an inline output block when no output is present", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:4b:call-4b",
|
||||
name: "sessions_spawn",
|
||||
args: { mode: "session", thread: true },
|
||||
inputText: '{\n "mode": "session",\n "thread": true\n}',
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Tool input");
|
||||
expect(container.textContent).toContain('"thread": true');
|
||||
expect(container.textContent).not.toContain("Tool output");
|
||||
expect(container.textContent).not.toContain("No output");
|
||||
});
|
||||
|
||||
it("labels collapsed tool calls as tool call", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:5:call-5",
|
||||
name: "sessions_spawn",
|
||||
args: { mode: "run" },
|
||||
inputText: '{\n "mode": "run"\n}',
|
||||
},
|
||||
{ expanded: false, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Tool call");
|
||||
expect(container.textContent).not.toContain("Tool input");
|
||||
const summaryButton = container.querySelector("button.chat-tool-msg-summary");
|
||||
expect(summaryButton).not.toBeNull();
|
||||
expect(summaryButton?.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps raw details for legacy canvas tool output without rendering tool-row previews", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:view:7",
|
||||
name: "canvas_render",
|
||||
outputText: JSON.stringify({
|
||||
kind: "canvas",
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_counter",
|
||||
url: "/__openclaw__/canvas/documents/cv_counter/index.html",
|
||||
title: "Counter demo",
|
||||
preferred_height: 480,
|
||||
},
|
||||
presentation: {
|
||||
target: "tool_card",
|
||||
},
|
||||
}),
|
||||
preview: {
|
||||
kind: "canvas",
|
||||
surface: "assistant_message",
|
||||
render: "url",
|
||||
viewId: "cv_counter",
|
||||
title: "Counter demo",
|
||||
url: "/__openclaw__/canvas/documents/cv_counter/index.html",
|
||||
preferredHeight: 480,
|
||||
},
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const rawToggle = container.querySelector<HTMLButtonElement>(".chat-tool-card__raw-toggle");
|
||||
const rawBody = container.querySelector<HTMLElement>(".chat-tool-card__raw-body");
|
||||
|
||||
expect(container.textContent).toContain("Counter demo");
|
||||
expect(container.querySelector(".chat-tool-card__preview-frame")).toBeNull();
|
||||
expect(rawToggle?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(rawBody?.hidden).toBe(true);
|
||||
|
||||
rawToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expect(rawToggle?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(rawBody?.hidden).toBe(false);
|
||||
expect(rawBody?.textContent).toContain('"kind":"canvas"');
|
||||
});
|
||||
|
||||
it("opens assistant-surface canvas payloads in the sidebar when explicitly requested", () => {
|
||||
const container = document.createElement("div");
|
||||
const onOpenSidebar = vi.fn();
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:view:8",
|
||||
name: "canvas_render",
|
||||
outputText: JSON.stringify({
|
||||
kind: "canvas",
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_sidebar",
|
||||
url: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
|
||||
title: "Player",
|
||||
preferred_height: 360,
|
||||
},
|
||||
presentation: {
|
||||
target: "assistant_message",
|
||||
},
|
||||
}),
|
||||
preview: {
|
||||
kind: "canvas",
|
||||
surface: "assistant_message",
|
||||
render: "url",
|
||||
viewId: "cv_sidebar",
|
||||
url: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
|
||||
title: "Player",
|
||||
preferredHeight: 360,
|
||||
},
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: vi.fn(), onOpenSidebar },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const sidebarButton = container.querySelector<HTMLButtonElement>(".chat-tool-card__action-btn");
|
||||
sidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expect(sidebarButton).not.toBeNull();
|
||||
expect(onOpenSidebar).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "canvas",
|
||||
docId: "cv_sidebar",
|
||||
entryUrl: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AppViewState } from "../app-view-state.ts";
|
||||
import {
|
||||
createModelCatalog,
|
||||
createSessionsListResult,
|
||||
DEFAULT_CHAT_MODEL_CATALOG,
|
||||
} from "../chat-model.test-helpers.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ModelCatalogEntry } from "../types.ts";
|
||||
import { renderChatSessionSelect } from "./session-controls.ts";
|
||||
|
||||
const refreshVisibleToolsEffectiveForCurrentSessionMock = vi.hoisted(() =>
|
||||
vi.fn(async (state: AppViewState) => {
|
||||
const agentId = state.agentsSelectedId ?? "main";
|
||||
const sessionKey = state.sessionKey;
|
||||
await state.client?.request("tools.effective", { agentId, sessionKey });
|
||||
const override = state.chatModelOverrides[sessionKey];
|
||||
state.toolsEffectiveResultKey = `${agentId}:${sessionKey}:model=${override?.value ?? "(default)"}`;
|
||||
state.toolsEffectiveResult = { agentId, profile: "coding", groups: [] };
|
||||
}),
|
||||
);
|
||||
const loadSessionsMock = vi.hoisted(() =>
|
||||
vi.fn(async (state: AppViewState) => {
|
||||
const res = await state.client?.request("sessions.list", {
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
});
|
||||
if (res) {
|
||||
state.sessionsResult = res as AppViewState["sessionsResult"];
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("../controllers/agents.ts", () => ({
|
||||
refreshVisibleToolsEffectiveForCurrentSession: refreshVisibleToolsEffectiveForCurrentSessionMock,
|
||||
}));
|
||||
vi.mock("../controllers/sessions.ts", () => ({
|
||||
loadSessions: loadSessionsMock,
|
||||
}));
|
||||
|
||||
function createChatHeaderState(
|
||||
overrides: {
|
||||
model?: string | null;
|
||||
modelProvider?: string | null;
|
||||
models?: ModelCatalogEntry[];
|
||||
omitSessionFromList?: boolean;
|
||||
} = {},
|
||||
): { state: AppViewState; request: ReturnType<typeof vi.fn> } {
|
||||
let currentModel = overrides.model ?? null;
|
||||
let currentModelProvider = overrides.modelProvider ?? (currentModel ? "openai" : null);
|
||||
const omitSessionFromList = overrides.omitSessionFromList ?? false;
|
||||
const catalog = overrides.models ?? createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG);
|
||||
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
|
||||
if (method === "sessions.patch") {
|
||||
const nextModel = (params.model as string | null | undefined) ?? null;
|
||||
if (!nextModel) {
|
||||
currentModel = null;
|
||||
currentModelProvider = null;
|
||||
} else {
|
||||
const normalized = nextModel.trim();
|
||||
const slashIndex = normalized.indexOf("/");
|
||||
if (slashIndex > 0) {
|
||||
currentModelProvider = normalized.slice(0, slashIndex);
|
||||
currentModel = normalized.slice(slashIndex + 1);
|
||||
} else {
|
||||
currentModel = normalized;
|
||||
const matchingProviders = catalog
|
||||
.filter((entry) => entry.id === normalized)
|
||||
.map((entry) => entry.provider)
|
||||
.filter(Boolean);
|
||||
currentModelProvider =
|
||||
matchingProviders.length === 1 ? matchingProviders[0] : currentModelProvider;
|
||||
}
|
||||
}
|
||||
return { ok: true, key: "main" };
|
||||
}
|
||||
if (method === "chat.history") {
|
||||
return { messages: [], thinkingLevel: null };
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
return createSessionsListResult({
|
||||
model: currentModel,
|
||||
modelProvider: currentModelProvider,
|
||||
omitSessionFromList,
|
||||
});
|
||||
}
|
||||
if (method === "models.list") {
|
||||
return { models: catalog };
|
||||
}
|
||||
if (method === "tools.effective") {
|
||||
return {
|
||||
agentId: "main",
|
||||
profile: "coding",
|
||||
groups: [],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const state = {
|
||||
sessionKey: "main",
|
||||
connected: true,
|
||||
sessionsHideCron: true,
|
||||
sessionsResult: createSessionsListResult({
|
||||
model: currentModel,
|
||||
modelProvider: currentModelProvider,
|
||||
omitSessionFromList,
|
||||
}),
|
||||
chatModelOverrides: {},
|
||||
chatModelCatalog: catalog,
|
||||
chatModelsLoading: false,
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
settings: {
|
||||
gatewayUrl: "",
|
||||
token: "",
|
||||
locale: "en",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "dark",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: false,
|
||||
},
|
||||
chatMessage: "",
|
||||
chatStream: null,
|
||||
chatStreamStartedAt: null,
|
||||
chatRunId: null,
|
||||
chatQueue: [],
|
||||
chatMessages: [],
|
||||
chatLoading: false,
|
||||
chatThinkingLevel: null,
|
||||
lastError: null,
|
||||
chatAvatarUrl: null,
|
||||
basePath: "",
|
||||
hello: null,
|
||||
agentsList: null,
|
||||
agentsPanel: "overview",
|
||||
agentsSelectedId: null,
|
||||
toolsEffectiveLoading: false,
|
||||
toolsEffectiveLoadingKey: null,
|
||||
toolsEffectiveResultKey: null,
|
||||
toolsEffectiveError: null,
|
||||
toolsEffectiveResult: null,
|
||||
applySettings(next: AppViewState["settings"]) {
|
||||
state.settings = next;
|
||||
},
|
||||
loadAssistantIdentity: vi.fn(),
|
||||
resetToolStream: vi.fn(),
|
||||
resetChatScroll: vi.fn(),
|
||||
} as unknown as AppViewState & {
|
||||
client: GatewayBrowserClient;
|
||||
settings: AppViewState["settings"];
|
||||
};
|
||||
return { state, request };
|
||||
}
|
||||
|
||||
async function flushTasks(turns = 8) {
|
||||
for (let i = 0; i < turns; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
await vi.dynamicImportSettled();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
loadSessionsMock.mockClear();
|
||||
refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("chat session controls", () => {
|
||||
it("patches the current session model from the chat header picker", async () => {
|
||||
const { state, request } = createChatHeaderState();
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
expect(modelSelect?.value).toBe("");
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "main",
|
||||
model: "openai/gpt-5-mini",
|
||||
});
|
||||
expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything());
|
||||
await flushTasks();
|
||||
expect(loadSessionsMock).toHaveBeenCalledTimes(1);
|
||||
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
|
||||
expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai");
|
||||
});
|
||||
|
||||
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
|
||||
const { state, request } = createChatHeaderState();
|
||||
state.agentsPanel = "tools";
|
||||
state.agentsSelectedId = "main";
|
||||
state.toolsEffectiveResultKey = "main:main";
|
||||
state.toolsEffectiveResult = {
|
||||
agentId: "main",
|
||||
profile: "coding",
|
||||
groups: [],
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
expect(request).toHaveBeenCalledWith("tools.effective", {
|
||||
agentId: "main",
|
||||
sessionKey: "main",
|
||||
});
|
||||
expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini");
|
||||
});
|
||||
|
||||
it("clears the session model override back to the default model", async () => {
|
||||
const { state, request } = createChatHeaderState({ model: "gpt-5-mini" });
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
expect(modelSelect?.value).toBe("openai/gpt-5-mini");
|
||||
|
||||
modelSelect!.value = "";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "main",
|
||||
model: null,
|
||||
});
|
||||
await flushTasks();
|
||||
expect(loadSessionsMock).toHaveBeenCalledTimes(1);
|
||||
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("disables the chat header model picker while a run is active", () => {
|
||||
const { state } = createChatHeaderState();
|
||||
state.chatRunId = "run-123";
|
||||
state.chatStream = "Working";
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
expect(modelSelect?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the selected model visible when the active session is absent from sessions.list", async () => {
|
||||
const { state } = createChatHeaderState({ omitSessionFromList: true });
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const rerendered = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(rerendered?.value).toBe("openai/gpt-5-mini");
|
||||
});
|
||||
|
||||
it("uses default thinking options when the active session is absent", () => {
|
||||
const { state } = createChatHeaderState({ omitSessionFromList: true });
|
||||
state.sessionsResult = createSessionsListResult({
|
||||
defaultsModel: "gpt-5.5",
|
||||
defaultsProvider: "openai-codex",
|
||||
defaultsThinkingLevels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "adaptive", label: "adaptive" },
|
||||
{ id: "xhigh", label: "xhigh" },
|
||||
{ id: "max", label: "maximum" },
|
||||
],
|
||||
omitSessionFromList: true,
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const thinkingSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-thinking-select="true"]',
|
||||
);
|
||||
const options = [...(thinkingSelect?.options ?? [])].map((option) => option.value);
|
||||
|
||||
expect(options).toContain("adaptive");
|
||||
expect(options).toContain("xhigh");
|
||||
expect(options).toContain("max");
|
||||
expect(
|
||||
[...(thinkingSelect?.options ?? [])]
|
||||
.find((option) => option.value === "max")
|
||||
?.textContent?.trim(),
|
||||
).toBe("maximum");
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { html, render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderCompactionIndicator, renderFallbackIndicator } from "./status-indicators.ts";
|
||||
|
||||
vi.mock("../icons.ts", () => ({
|
||||
icons: {},
|
||||
}));
|
||||
|
||||
describe("chat status indicators", () => {
|
||||
it("renders compaction and fallback indicators while they are fresh", () => {
|
||||
const container = document.createElement("div");
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
const renderIndicators = (
|
||||
compactionStatus: Parameters<typeof renderCompactionIndicator>[0],
|
||||
fallbackStatus: Parameters<typeof renderFallbackIndicator>[0],
|
||||
) => {
|
||||
render(
|
||||
html`${renderFallbackIndicator(fallbackStatus)}
|
||||
${renderCompactionIndicator(compactionStatus)}`,
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
nowSpy.mockReturnValue(1_000);
|
||||
renderIndicators(
|
||||
{
|
||||
phase: "active",
|
||||
runId: "run-1",
|
||||
startedAt: 1_000,
|
||||
completedAt: null,
|
||||
},
|
||||
{
|
||||
selected: "fireworks/minimax-m2p5",
|
||||
active: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
attempts: ["fireworks/minimax-m2p5: rate limit"],
|
||||
occurredAt: 900,
|
||||
},
|
||||
);
|
||||
|
||||
let indicator = container.querySelector(".compaction-indicator--active");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Compacting context...");
|
||||
indicator = container.querySelector(".compaction-indicator--fallback");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Fallback active: deepinfra/moonshotai/Kimi-K2.5");
|
||||
|
||||
renderIndicators(
|
||||
{
|
||||
phase: "complete",
|
||||
runId: "run-1",
|
||||
startedAt: 900,
|
||||
completedAt: 900,
|
||||
},
|
||||
{
|
||||
phase: "cleared",
|
||||
selected: "fireworks/minimax-m2p5",
|
||||
active: "fireworks/minimax-m2p5",
|
||||
previous: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
attempts: [],
|
||||
occurredAt: 900,
|
||||
},
|
||||
);
|
||||
indicator = container.querySelector(".compaction-indicator--complete");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Context compacted");
|
||||
indicator = container.querySelector(".compaction-indicator--fallback-cleared");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5");
|
||||
|
||||
nowSpy.mockReturnValue(20_000);
|
||||
renderIndicators(
|
||||
{
|
||||
phase: "complete",
|
||||
runId: "run-1",
|
||||
startedAt: 0,
|
||||
completedAt: 0,
|
||||
},
|
||||
{
|
||||
selected: "fireworks/minimax-m2p5",
|
||||
active: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
attempts: [],
|
||||
occurredAt: 0,
|
||||
},
|
||||
);
|
||||
expect(container.querySelector(".compaction-indicator--fallback")).toBeNull();
|
||||
expect(container.querySelector(".compaction-indicator--complete")).toBeNull();
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToolCard } from "./tool-cards.ts";
|
||||
|
||||
vi.mock("../icons.ts", () => ({
|
||||
icons: {},
|
||||
}));
|
||||
|
||||
vi.mock("../tool-display.ts", () => ({
|
||||
formatToolDetail: () => undefined,
|
||||
resolveToolDisplay: ({ name }: { name: string }) => ({
|
||||
name,
|
||||
label: name
|
||||
.split(/[._-]/g)
|
||||
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
icon: "zap",
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("tool-cards", () => {
|
||||
it("renders expanded cards with inline input and output sections", () => {
|
||||
const container = document.createElement("div");
|
||||
const toggle = vi.fn();
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:4:call-4",
|
||||
name: "browser.open",
|
||||
args: { url: "https://example.com" },
|
||||
inputText: '{\n "url": "https://example.com"\n}',
|
||||
outputText: "Opened page",
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: toggle },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Tool input");
|
||||
expect(container.textContent).toContain("Tool output");
|
||||
expect(container.textContent).toContain("https://example.com");
|
||||
expect(container.textContent).toContain("Opened page");
|
||||
});
|
||||
|
||||
it("renders expanded tool calls without an inline output block when no output is present", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:4b:call-4b",
|
||||
name: "sessions_spawn",
|
||||
args: { mode: "session", thread: true },
|
||||
inputText: '{\n "mode": "session",\n "thread": true\n}',
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Tool input");
|
||||
expect(container.textContent).toContain('"thread": true');
|
||||
expect(container.textContent).not.toContain("Tool output");
|
||||
expect(container.textContent).not.toContain("No output");
|
||||
});
|
||||
|
||||
it("labels collapsed tool calls as tool call", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:5:call-5",
|
||||
name: "sessions_spawn",
|
||||
args: { mode: "run" },
|
||||
inputText: '{\n "mode": "run"\n}',
|
||||
},
|
||||
{ expanded: false, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Tool call");
|
||||
expect(container.textContent).not.toContain("Tool input");
|
||||
const summaryButton = container.querySelector("button.chat-tool-msg-summary");
|
||||
expect(summaryButton).not.toBeNull();
|
||||
expect(summaryButton?.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps raw details for legacy canvas tool output without rendering tool-row previews", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:view:7",
|
||||
name: "canvas_render",
|
||||
outputText: JSON.stringify({
|
||||
kind: "canvas",
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_counter",
|
||||
url: "/__openclaw__/canvas/documents/cv_counter/index.html",
|
||||
title: "Counter demo",
|
||||
preferred_height: 480,
|
||||
},
|
||||
presentation: {
|
||||
target: "tool_card",
|
||||
},
|
||||
}),
|
||||
preview: {
|
||||
kind: "canvas",
|
||||
surface: "assistant_message",
|
||||
render: "url",
|
||||
viewId: "cv_counter",
|
||||
title: "Counter demo",
|
||||
url: "/__openclaw__/canvas/documents/cv_counter/index.html",
|
||||
preferredHeight: 480,
|
||||
},
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const rawToggle = container.querySelector<HTMLButtonElement>(".chat-tool-card__raw-toggle");
|
||||
const rawBody = container.querySelector<HTMLElement>(".chat-tool-card__raw-body");
|
||||
|
||||
expect(container.textContent).toContain("Counter demo");
|
||||
expect(container.querySelector(".chat-tool-card__preview-frame")).toBeNull();
|
||||
expect(rawToggle?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(rawBody?.hidden).toBe(true);
|
||||
|
||||
rawToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expect(rawToggle?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(rawBody?.hidden).toBe(false);
|
||||
expect(rawBody?.textContent).toContain('"kind":"canvas"');
|
||||
});
|
||||
|
||||
it("opens assistant-surface canvas payloads in the sidebar when explicitly requested", () => {
|
||||
const container = document.createElement("div");
|
||||
const onOpenSidebar = vi.fn();
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:view:8",
|
||||
name: "canvas_render",
|
||||
outputText: JSON.stringify({
|
||||
kind: "canvas",
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_sidebar",
|
||||
url: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
|
||||
title: "Player",
|
||||
preferred_height: 360,
|
||||
},
|
||||
presentation: {
|
||||
target: "assistant_message",
|
||||
},
|
||||
}),
|
||||
preview: {
|
||||
kind: "canvas",
|
||||
surface: "assistant_message",
|
||||
render: "url",
|
||||
viewId: "cv_sidebar",
|
||||
url: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
|
||||
title: "Player",
|
||||
preferredHeight: 360,
|
||||
},
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: vi.fn(), onOpenSidebar },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const sidebarButton = container.querySelector<HTMLButtonElement>(".chat-tool-card__action-btn");
|
||||
sidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expect(sidebarButton).not.toBeNull();
|
||||
expect(onOpenSidebar).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "canvas",
|
||||
docId: "cv_sidebar",
|
||||
entryUrl: "/__openclaw__/canvas/documents/cv_sidebar/index.html",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,74 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AppViewState } from "../app-view-state.ts";
|
||||
import {
|
||||
createModelCatalog,
|
||||
createSessionsListResult,
|
||||
DEFAULT_CHAT_MODEL_CATALOG,
|
||||
} from "../chat-model.test-helpers.ts";
|
||||
import { renderChatAvatar } from "../chat/chat-avatar.ts";
|
||||
import { renderChatQueue } from "../chat/chat-queue.ts";
|
||||
import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts";
|
||||
import { renderWelcomeState } from "../chat/chat-welcome.ts";
|
||||
import { renderChatSessionSelect } from "../chat/session-controls.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ModelCatalogEntry } from "../types.ts";
|
||||
import type { ChatQueueItem } from "../ui-types.ts";
|
||||
|
||||
const refreshVisibleToolsEffectiveForCurrentSessionMock = vi.hoisted(() =>
|
||||
vi.fn(async (state: AppViewState) => {
|
||||
const agentId = state.agentsSelectedId ?? "main";
|
||||
const sessionKey = state.sessionKey;
|
||||
await state.client?.request("tools.effective", { agentId, sessionKey });
|
||||
const override = state.chatModelOverrides[sessionKey];
|
||||
state.toolsEffectiveResultKey = `${agentId}:${sessionKey}:model=${override?.value ?? "(default)"}`;
|
||||
state.toolsEffectiveResult = { agentId, profile: "coding", groups: [] };
|
||||
}),
|
||||
);
|
||||
const loadSessionsMock = vi.hoisted(() =>
|
||||
vi.fn(async (state: AppViewState) => {
|
||||
const res = await state.client?.request("sessions.list", {
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
});
|
||||
if (res) {
|
||||
state.sessionsResult = res as AppViewState["sessionsResult"];
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("../icons.ts", () => ({
|
||||
icons: {},
|
||||
}));
|
||||
|
||||
vi.mock("../controllers/agents.ts", () => ({
|
||||
refreshVisibleToolsEffectiveForCurrentSession: refreshVisibleToolsEffectiveForCurrentSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../controllers/sessions.ts", () => ({
|
||||
loadSessions: loadSessionsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./agents-utils.ts", () => ({
|
||||
isRenderableControlUiAvatarUrl: (value: string) =>
|
||||
/^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")),
|
||||
agentLogoUrl: () => "/openclaw-logo.svg",
|
||||
assistantAvatarFallbackUrl: () => "apple-touch-icon.png",
|
||||
resolveChatAvatarRenderUrl: (
|
||||
candidate: string | null | undefined,
|
||||
agent: { identity?: { avatar?: string; avatarUrl?: string } },
|
||||
) => {
|
||||
const isRenderableControlUiAvatarUrl = (value: string) =>
|
||||
/^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//"));
|
||||
if (typeof candidate === "string" && candidate.startsWith("blob:")) {
|
||||
return candidate;
|
||||
}
|
||||
if (
|
||||
typeof agent.identity?.avatarUrl === "string" &&
|
||||
agent.identity.avatarUrl.startsWith("blob:")
|
||||
) {
|
||||
return agent.identity.avatarUrl;
|
||||
for (const value of [candidate, agent.identity?.avatarUrl, agent.identity?.avatar]) {
|
||||
if (typeof value === "string" && isRenderableControlUiAvatarUrl(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -37,6 +80,12 @@ vi.mock("./agents-utils.ts", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function renderAvatar(params: Parameters<typeof renderChatAvatar>) {
|
||||
const container = document.createElement("div");
|
||||
render(renderChatAvatar(...params), container);
|
||||
return container.querySelector<HTMLElement>(".chat-avatar");
|
||||
}
|
||||
|
||||
function renderQueue(params: {
|
||||
queue: ChatQueueItem[];
|
||||
canAbort?: boolean;
|
||||
@@ -55,6 +104,135 @@ function renderQueue(params: {
|
||||
return container;
|
||||
}
|
||||
|
||||
function createChatHeaderState(
|
||||
overrides: {
|
||||
model?: string | null;
|
||||
modelProvider?: string | null;
|
||||
models?: ModelCatalogEntry[];
|
||||
omitSessionFromList?: boolean;
|
||||
} = {},
|
||||
): { state: AppViewState; request: ReturnType<typeof vi.fn> } {
|
||||
let currentModel = overrides.model ?? null;
|
||||
let currentModelProvider = overrides.modelProvider ?? (currentModel ? "openai" : null);
|
||||
const omitSessionFromList = overrides.omitSessionFromList ?? false;
|
||||
const catalog = overrides.models ?? createModelCatalog(...DEFAULT_CHAT_MODEL_CATALOG);
|
||||
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
|
||||
if (method === "sessions.patch") {
|
||||
const nextModel = (params.model as string | null | undefined) ?? null;
|
||||
if (!nextModel) {
|
||||
currentModel = null;
|
||||
currentModelProvider = null;
|
||||
} else {
|
||||
const normalized = nextModel.trim();
|
||||
const slashIndex = normalized.indexOf("/");
|
||||
if (slashIndex > 0) {
|
||||
currentModelProvider = normalized.slice(0, slashIndex);
|
||||
currentModel = normalized.slice(slashIndex + 1);
|
||||
} else {
|
||||
currentModel = normalized;
|
||||
const matchingProviders = catalog
|
||||
.filter((entry) => entry.id === normalized)
|
||||
.map((entry) => entry.provider)
|
||||
.filter(Boolean);
|
||||
currentModelProvider =
|
||||
matchingProviders.length === 1 ? matchingProviders[0] : currentModelProvider;
|
||||
}
|
||||
}
|
||||
return { ok: true, key: "main" };
|
||||
}
|
||||
if (method === "chat.history") {
|
||||
return { messages: [], thinkingLevel: null };
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
return createSessionsListResult({
|
||||
model: currentModel,
|
||||
modelProvider: currentModelProvider,
|
||||
omitSessionFromList,
|
||||
});
|
||||
}
|
||||
if (method === "models.list") {
|
||||
return { models: catalog };
|
||||
}
|
||||
if (method === "tools.effective") {
|
||||
return {
|
||||
agentId: "main",
|
||||
profile: "coding",
|
||||
groups: [],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const state = {
|
||||
sessionKey: "main",
|
||||
connected: true,
|
||||
sessionsHideCron: true,
|
||||
sessionsResult: createSessionsListResult({
|
||||
model: currentModel,
|
||||
modelProvider: currentModelProvider,
|
||||
omitSessionFromList,
|
||||
}),
|
||||
chatModelOverrides: {},
|
||||
chatModelCatalog: catalog,
|
||||
chatModelsLoading: false,
|
||||
client: { request } as unknown as GatewayBrowserClient,
|
||||
settings: {
|
||||
gatewayUrl: "",
|
||||
token: "",
|
||||
locale: "en",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "dark",
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: false,
|
||||
},
|
||||
chatMessage: "",
|
||||
chatStream: null,
|
||||
chatStreamStartedAt: null,
|
||||
chatRunId: null,
|
||||
chatQueue: [],
|
||||
chatMessages: [],
|
||||
chatLoading: false,
|
||||
chatThinkingLevel: null,
|
||||
lastError: null,
|
||||
chatAvatarUrl: null,
|
||||
basePath: "",
|
||||
hello: null,
|
||||
agentsList: null,
|
||||
agentsPanel: "overview",
|
||||
agentsSelectedId: null,
|
||||
toolsEffectiveLoading: false,
|
||||
toolsEffectiveLoadingKey: null,
|
||||
toolsEffectiveResultKey: null,
|
||||
toolsEffectiveError: null,
|
||||
toolsEffectiveResult: null,
|
||||
applySettings(next: AppViewState["settings"]) {
|
||||
state.settings = next;
|
||||
},
|
||||
loadAssistantIdentity: vi.fn(),
|
||||
resetToolStream: vi.fn(),
|
||||
resetChatScroll: vi.fn(),
|
||||
} as unknown as AppViewState & {
|
||||
client: GatewayBrowserClient;
|
||||
settings: AppViewState["settings"];
|
||||
};
|
||||
return { state, request };
|
||||
}
|
||||
|
||||
async function flushTasks() {
|
||||
await vi.dynamicImportSettled();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
loadSessionsMock.mockClear();
|
||||
refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("chat queue", () => {
|
||||
it("renders Steer only for queued messages during an active run", () => {
|
||||
const onQueueSteer = vi.fn();
|
||||
@@ -75,16 +253,62 @@ describe("chat queue", () => {
|
||||
steerButtons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expect(onQueueSteer).toHaveBeenCalledWith("queued-1");
|
||||
});
|
||||
|
||||
it("hides queued-message Steer when no run is active", () => {
|
||||
const container = renderQueue({
|
||||
const inactiveContainer = renderQueue({
|
||||
canAbort: false,
|
||||
onQueueSteer: vi.fn(),
|
||||
queue: [{ id: "queued-1", text: "tighten the plan", createdAt: 1 }],
|
||||
});
|
||||
|
||||
expect(container.querySelector(".chat-queue__steer")).toBeNull();
|
||||
expect(inactiveContainer.querySelector(".chat-queue__steer")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderChatAvatar", () => {
|
||||
it("uses the assistant fallback when no assistant avatar is configured", () => {
|
||||
const avatar = renderAvatar(["assistant"]);
|
||||
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.getAttribute("src")).toBe("apple-touch-icon.png");
|
||||
});
|
||||
|
||||
it("renders assistant fallback, blob image, and text avatars", () => {
|
||||
const remoteAvatar = renderAvatar([
|
||||
"assistant",
|
||||
{ avatar: "https://example.com/avatar.png", name: "Val" },
|
||||
]);
|
||||
expect(remoteAvatar?.getAttribute("src")).toBe("apple-touch-icon.png");
|
||||
|
||||
const blobAvatar = renderAvatar(["assistant", { avatar: "blob:managed-image", name: "Val" }]);
|
||||
expect(blobAvatar?.tagName).toBe("IMG");
|
||||
expect(blobAvatar?.getAttribute("src")).toBe("blob:managed-image");
|
||||
|
||||
const textAvatar = renderAvatar(["assistant", { avatar: "VC", name: "Val" }]);
|
||||
expect(textAvatar?.tagName).toBe("DIV");
|
||||
expect(textAvatar?.textContent).toContain("VC");
|
||||
expect(textAvatar?.getAttribute("aria-label")).toBe("Val");
|
||||
});
|
||||
|
||||
it("uses the assistant fallback while authenticated avatar routes are loading", () => {
|
||||
const avatar = renderAvatar([
|
||||
"assistant",
|
||||
{ avatar: "/avatar/main", name: "OpenClaw" },
|
||||
undefined,
|
||||
"",
|
||||
"session-token",
|
||||
]);
|
||||
|
||||
expect(avatar?.getAttribute("src")).toBe("apple-touch-icon.png");
|
||||
});
|
||||
|
||||
it("renders local user image and text avatars", () => {
|
||||
const imageAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "/avatar/user" }]);
|
||||
expect(imageAvatar?.getAttribute("src")).toBe("/avatar/user");
|
||||
expect(imageAvatar?.getAttribute("alt")).toBe("Buns");
|
||||
|
||||
const textAvatar = renderAvatar(["user", undefined, { name: "Buns", avatar: "AB" }]);
|
||||
expect(textAvatar?.tagName).toBe("DIV");
|
||||
expect(textAvatar?.textContent).toContain("AB");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,34 +349,176 @@ describe("chat welcome", () => {
|
||||
return container;
|
||||
}
|
||||
|
||||
it("renders configured assistant text avatars in the welcome state", () => {
|
||||
const container = renderWelcome({ assistantAvatar: "VC", assistantAvatarUrl: null });
|
||||
it("renders configured assistant avatars and fallback in the welcome state", () => {
|
||||
let container = renderWelcome({ assistantAvatar: "VC", assistantAvatarUrl: null });
|
||||
|
||||
const avatar = container.querySelector<HTMLElement>(".agent-chat__avatar");
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.tagName).toBe("DIV");
|
||||
expect(avatar?.textContent).toContain("VC");
|
||||
expect(avatar?.getAttribute("aria-label")).toBe("Val");
|
||||
});
|
||||
|
||||
it("renders configured assistant image avatars in the welcome state", () => {
|
||||
const container = renderWelcome({
|
||||
container = renderWelcome({
|
||||
assistantAvatar: "avatars/val.png",
|
||||
assistantAvatarUrl: "blob:identity-avatar",
|
||||
});
|
||||
|
||||
const avatar = container.querySelector<HTMLImageElement>("img");
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.getAttribute("src")).toBe("blob:identity-avatar");
|
||||
expect(avatar?.getAttribute("alt")).toBe("Val");
|
||||
});
|
||||
const imageAvatar = container.querySelector<HTMLImageElement>("img");
|
||||
expect(imageAvatar).not.toBeNull();
|
||||
expect(imageAvatar?.getAttribute("src")).toBe("blob:identity-avatar");
|
||||
expect(imageAvatar?.getAttribute("alt")).toBe("Val");
|
||||
|
||||
it("uses the Molty png as the welcome fallback assistant avatar", () => {
|
||||
const container = renderWelcome({ assistantAvatar: null, assistantAvatarUrl: null });
|
||||
container = renderWelcome({ assistantAvatar: null, assistantAvatarUrl: null });
|
||||
|
||||
const avatar = container.querySelector<HTMLImageElement>(".agent-chat__avatar--logo img");
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.getAttribute("src")).toBe("apple-touch-icon.png");
|
||||
expect(avatar?.getAttribute("alt")).toBe("Val");
|
||||
const fallbackAvatar = container.querySelector<HTMLImageElement>(
|
||||
".agent-chat__avatar--logo img",
|
||||
);
|
||||
expect(fallbackAvatar).not.toBeNull();
|
||||
expect(fallbackAvatar?.getAttribute("src")).toBe("apple-touch-icon.png");
|
||||
expect(fallbackAvatar?.getAttribute("alt")).toBe("Val");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat session controls", () => {
|
||||
it("patches the current session model from the chat header picker", async () => {
|
||||
const { state, request } = createChatHeaderState();
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
expect(modelSelect?.value).toBe("");
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "main",
|
||||
model: "openai/gpt-5-mini",
|
||||
});
|
||||
expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything());
|
||||
await flushTasks();
|
||||
expect(loadSessionsMock).toHaveBeenCalledTimes(1);
|
||||
expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini");
|
||||
expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai");
|
||||
});
|
||||
|
||||
it("reloads effective tools after a chat-header model switch for the active tools panel", async () => {
|
||||
const { state, request } = createChatHeaderState();
|
||||
state.agentsPanel = "tools";
|
||||
state.agentsSelectedId = "main";
|
||||
state.toolsEffectiveResultKey = "main:main";
|
||||
state.toolsEffectiveResult = {
|
||||
agentId: "main",
|
||||
profile: "coding",
|
||||
groups: [],
|
||||
};
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
expect(request).toHaveBeenCalledWith("tools.effective", {
|
||||
agentId: "main",
|
||||
sessionKey: "main",
|
||||
});
|
||||
expect(state.toolsEffectiveResultKey).toBe("main:main:model=openai/gpt-5-mini");
|
||||
});
|
||||
|
||||
it("clears the session model override back to the default model", async () => {
|
||||
const { state, request } = createChatHeaderState({ model: "gpt-5-mini" });
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
expect(modelSelect?.value).toBe("openai/gpt-5-mini");
|
||||
|
||||
modelSelect!.value = "";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(request).toHaveBeenCalledWith("sessions.patch", {
|
||||
key: "main",
|
||||
model: null,
|
||||
});
|
||||
await flushTasks();
|
||||
expect(loadSessionsMock).toHaveBeenCalledTimes(1);
|
||||
expect(state.sessionsResult?.sessions[0]?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("disables the chat header model picker while a run is active", () => {
|
||||
const { state } = createChatHeaderState();
|
||||
state.chatRunId = "run-123";
|
||||
state.chatStream = "Working";
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
expect(modelSelect?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the selected model visible when the active session is absent from sessions.list", async () => {
|
||||
const { state } = createChatHeaderState({ omitSessionFromList: true });
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(modelSelect).not.toBeNull();
|
||||
|
||||
modelSelect!.value = "openai/gpt-5-mini";
|
||||
modelSelect!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushTasks();
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const rerendered = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-model-select="true"]',
|
||||
);
|
||||
expect(rerendered?.value).toBe("openai/gpt-5-mini");
|
||||
});
|
||||
|
||||
it("uses default thinking options when the active session is absent", () => {
|
||||
const { state } = createChatHeaderState({ omitSessionFromList: true });
|
||||
state.sessionsResult = createSessionsListResult({
|
||||
defaultsModel: "gpt-5.5",
|
||||
defaultsProvider: "openai-codex",
|
||||
defaultsThinkingLevels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "adaptive", label: "adaptive" },
|
||||
{ id: "xhigh", label: "xhigh" },
|
||||
{ id: "max", label: "maximum" },
|
||||
],
|
||||
omitSessionFromList: true,
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
render(renderChatSessionSelect(state), container);
|
||||
|
||||
const thinkingSelect = container.querySelector<HTMLSelectElement>(
|
||||
'select[data-chat-thinking-select="true"]',
|
||||
);
|
||||
const options = [...(thinkingSelect?.options ?? [])].map((option) => option.value);
|
||||
|
||||
expect(options).toContain("adaptive");
|
||||
expect(options).toContain("xhigh");
|
||||
expect(options).toContain("max");
|
||||
expect(
|
||||
[...(thinkingSelect?.options ?? [])]
|
||||
.find((option) => option.value === "max")
|
||||
?.textContent?.trim(),
|
||||
).toBe("maximum");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user