mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 01:11:16 +07:00
test: dedupe ui chat seams
This commit is contained in:
@@ -19,26 +19,39 @@ function row(key: string, overrides?: Partial<GatewaySessionRow>): GatewaySessio
|
||||
};
|
||||
}
|
||||
|
||||
function createKillRequest(params: { sessions: GatewaySessionRow[]; aborted?: boolean }) {
|
||||
return vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return { sessions: params.sessions };
|
||||
}
|
||||
if (method === "chat.abort") {
|
||||
return { ok: true, aborted: params.aborted ?? true };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
}
|
||||
|
||||
function expectAbortCalls(request: ReturnType<typeof vi.fn>, sessionKeys: string[]) {
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
|
||||
for (const [index, sessionKey] of sessionKeys.entries()) {
|
||||
expect(request).toHaveBeenNthCalledWith(index + 2, "chat.abort", {
|
||||
sessionKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("executeSlashCommand /kill", () => {
|
||||
it("aborts every sub-agent session for /kill all", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
sessions: [
|
||||
row("main"),
|
||||
row("agent:main:subagent:one", { spawnedBy: "main" }),
|
||||
row("agent:main:subagent:parent", { spawnedBy: "main" }),
|
||||
row("agent:main:subagent:parent:subagent:child", {
|
||||
spawnedBy: "agent:main:subagent:parent",
|
||||
}),
|
||||
row("agent:other:main"),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "chat.abort") {
|
||||
return { ok: true, aborted: true };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
const request = createKillRequest({
|
||||
sessions: [
|
||||
row("main"),
|
||||
row("agent:main:subagent:one", { spawnedBy: "main" }),
|
||||
row("agent:main:subagent:parent", { spawnedBy: "main" }),
|
||||
row("agent:main:subagent:parent:subagent:child", {
|
||||
spawnedBy: "agent:main:subagent:parent",
|
||||
}),
|
||||
row("agent:other:main"),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await executeSlashCommand(
|
||||
@@ -49,33 +62,20 @@ describe("executeSlashCommand /kill", () => {
|
||||
);
|
||||
|
||||
expect(result.content).toBe("Aborted 3 sub-agent sessions.");
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
|
||||
sessionKey: "agent:main:subagent:one",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
|
||||
sessionKey: "agent:main:subagent:parent",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(4, "chat.abort", {
|
||||
sessionKey: "agent:main:subagent:parent:subagent:child",
|
||||
});
|
||||
expectAbortCalls(request, [
|
||||
"agent:main:subagent:one",
|
||||
"agent:main:subagent:parent",
|
||||
"agent:main:subagent:parent:subagent:child",
|
||||
]);
|
||||
});
|
||||
|
||||
it("aborts matching sub-agent sessions for /kill <agentId>", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
sessions: [
|
||||
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "chat.abort") {
|
||||
return { ok: true, aborted: true };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
const request = createKillRequest({
|
||||
sessions: [
|
||||
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await executeSlashCommand(
|
||||
@@ -86,13 +86,7 @@ describe("executeSlashCommand /kill", () => {
|
||||
);
|
||||
|
||||
expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`.");
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
|
||||
sessionKey: "agent:main:subagent:one",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
|
||||
sessionKey: "agent:main:subagent:two",
|
||||
});
|
||||
expectAbortCalls(request, ["agent:main:subagent:one", "agent:main:subagent:two"]);
|
||||
});
|
||||
|
||||
it("does not exact-match a session key outside the current subagent subtree", async () => {
|
||||
@@ -129,19 +123,12 @@ describe("executeSlashCommand /kill", () => {
|
||||
});
|
||||
|
||||
it("returns a no-op summary when matching sessions have no active runs", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
sessions: [
|
||||
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "chat.abort") {
|
||||
return { ok: true, aborted: false };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
const request = createKillRequest({
|
||||
sessions: [
|
||||
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
|
||||
],
|
||||
aborted: false,
|
||||
});
|
||||
|
||||
const result = await executeSlashCommand(
|
||||
@@ -152,31 +139,17 @@ describe("executeSlashCommand /kill", () => {
|
||||
);
|
||||
|
||||
expect(result.content).toBe("No active sub-agent runs to abort.");
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
|
||||
sessionKey: "agent:main:subagent:one",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
|
||||
sessionKey: "agent:main:subagent:two",
|
||||
});
|
||||
expectAbortCalls(request, ["agent:main:subagent:one", "agent:main:subagent:two"]);
|
||||
});
|
||||
|
||||
it("treats the legacy main session key as the default agent scope", async () => {
|
||||
const request = vi.fn(async (method: string, _payload?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
sessions: [
|
||||
row("main"),
|
||||
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "chat.abort") {
|
||||
return { ok: true, aborted: true };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
const request = createKillRequest({
|
||||
sessions: [
|
||||
row("main"),
|
||||
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
|
||||
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await executeSlashCommand(
|
||||
@@ -187,13 +160,7 @@ describe("executeSlashCommand /kill", () => {
|
||||
);
|
||||
|
||||
expect(result.content).toBe("Aborted 2 sub-agent sessions.");
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
|
||||
sessionKey: "agent:main:subagent:one",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
|
||||
sessionKey: "agent:main:subagent:two",
|
||||
});
|
||||
expectAbortCalls(request, ["agent:main:subagent:one", "agent:main:subagent:two"]);
|
||||
});
|
||||
|
||||
it("does not abort unrelated same-agent subagents from another root session", async () => {
|
||||
|
||||
@@ -28,6 +28,27 @@ function createState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
};
|
||||
}
|
||||
|
||||
function createActiveStreamingState() {
|
||||
return createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Working...",
|
||||
chatStreamStartedAt: 123,
|
||||
});
|
||||
}
|
||||
|
||||
function createOtherRunNoReplyFinalPayload(): ChatEventPayload {
|
||||
return {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "NO_REPLY" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleChatEvent", () => {
|
||||
it("returns null when payload is missing", () => {
|
||||
const state = createState();
|
||||
@@ -103,21 +124,8 @@ describe("handleChatEvent", () => {
|
||||
});
|
||||
|
||||
it("drops NO_REPLY final payload from another run without clearing active stream", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Working...",
|
||||
chatStreamStartedAt: 123,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "NO_REPLY" }],
|
||||
},
|
||||
};
|
||||
const state = createActiveStreamingState();
|
||||
const payload = createOtherRunNoReplyFinalPayload();
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatRunId).toBe("run-user");
|
||||
@@ -127,12 +135,7 @@ describe("handleChatEvent", () => {
|
||||
});
|
||||
|
||||
it("returns final for another run when payload has no message", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Working...",
|
||||
chatStreamStartedAt: 123,
|
||||
});
|
||||
const state = createActiveStreamingState();
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
@@ -376,21 +379,8 @@ describe("handleChatEvent", () => {
|
||||
});
|
||||
|
||||
it("drops NO_REPLY final payload from another run", () => {
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-user",
|
||||
chatStream: "Working...",
|
||||
chatStreamStartedAt: 123,
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-announce",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "NO_REPLY" }],
|
||||
},
|
||||
};
|
||||
const state = createActiveStreamingState();
|
||||
const payload = createOtherRunNoReplyFinalPayload();
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.chatMessages).toEqual([]);
|
||||
|
||||
@@ -14,6 +14,26 @@ function nextFrame() {
|
||||
});
|
||||
}
|
||||
|
||||
function findConfirmButton(app: ReturnType<typeof mountApp>) {
|
||||
return Array.from(app.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Confirm",
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmPendingGatewayChange(app: ReturnType<typeof mountApp>) {
|
||||
const confirmButton = findConfirmButton(app);
|
||||
expect(confirmButton).not.toBeUndefined();
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await app.updateComplete;
|
||||
}
|
||||
|
||||
function expectConfirmedGatewayChange(app: ReturnType<typeof mountApp>) {
|
||||
expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw");
|
||||
expect(app.settings.token).toBe("abc123");
|
||||
expect(window.location.search).toBe("");
|
||||
expect(window.location.hash).toBe("");
|
||||
}
|
||||
|
||||
describe("control UI routing", () => {
|
||||
it("hydrates the tab from the location", async () => {
|
||||
const app = mountApp("/sessions");
|
||||
@@ -392,17 +412,9 @@ describe("control UI routing", () => {
|
||||
expect(app.settings.gatewayUrl).not.toBe("wss://other-gateway.example/openclaw");
|
||||
expect(app.settings.token).toBe("");
|
||||
|
||||
const confirmButton = Array.from(app.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Confirm",
|
||||
);
|
||||
expect(confirmButton).not.toBeUndefined();
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await app.updateComplete;
|
||||
await confirmPendingGatewayChange(app);
|
||||
|
||||
expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw");
|
||||
expect(app.settings.token).toBe("abc123");
|
||||
expect(window.location.search).toBe("");
|
||||
expect(window.location.hash).toBe("");
|
||||
expectConfirmedGatewayChange(app);
|
||||
});
|
||||
|
||||
it("keeps a query token pending until the gateway URL change is confirmed", async () => {
|
||||
@@ -414,17 +426,9 @@ describe("control UI routing", () => {
|
||||
expect(app.settings.gatewayUrl).not.toBe("wss://other-gateway.example/openclaw");
|
||||
expect(app.settings.token).toBe("");
|
||||
|
||||
const confirmButton = Array.from(app.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Confirm",
|
||||
);
|
||||
expect(confirmButton).not.toBeUndefined();
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await app.updateComplete;
|
||||
await confirmPendingGatewayChange(app);
|
||||
|
||||
expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw");
|
||||
expect(app.settings.token).toBe("abc123");
|
||||
expect(window.location.search).toBe("");
|
||||
expect(window.location.hash).toBe("");
|
||||
expectConfirmedGatewayChange(app);
|
||||
});
|
||||
|
||||
it("restores the token after a same-tab refresh", async () => {
|
||||
|
||||
@@ -3,6 +3,23 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import "../../styles.css";
|
||||
import { renderChat, type ChatProps } from "./chat.ts";
|
||||
|
||||
const contextNoticeSessions: ChatProps["sessions"] = {
|
||||
ts: 0,
|
||||
path: "",
|
||||
count: 1,
|
||||
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
|
||||
sessions: [
|
||||
{
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
totalTokens: 3_800,
|
||||
inputTokens: 3_800,
|
||||
contextTokens: 4_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
|
||||
return {
|
||||
sessionKey: "main",
|
||||
@@ -58,40 +75,30 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
|
||||
};
|
||||
}
|
||||
|
||||
async function renderContextNoticeChat() {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
sessions: contextNoticeSessions,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("chat context notice", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("falls back to default notice colors when theme vars are not hex", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
document.documentElement.style.setProperty("--warn", "rgb(1, 2, 3)");
|
||||
document.documentElement.style.setProperty("--danger", "tomato");
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
sessions: {
|
||||
ts: 0,
|
||||
path: "",
|
||||
count: 1,
|
||||
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
|
||||
sessions: [
|
||||
{
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
totalTokens: 3_800,
|
||||
inputTokens: 3_800,
|
||||
contextTokens: 4_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const container = await renderContextNoticeChat();
|
||||
|
||||
const notice = container.querySelector<HTMLElement>(".context-notice");
|
||||
expect(notice).not.toBeNull();
|
||||
@@ -104,32 +111,7 @@ describe("chat context notice", () => {
|
||||
});
|
||||
|
||||
it("keeps the warning icon badge-sized", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
sessions: {
|
||||
ts: 0,
|
||||
path: "",
|
||||
count: 1,
|
||||
defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null },
|
||||
sessions: [
|
||||
{
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
updatedAt: null,
|
||||
totalTokens: 3_800,
|
||||
inputTokens: 3_800,
|
||||
contextTokens: 4_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const container = await renderContextNoticeChat();
|
||||
|
||||
const icon = container.querySelector<SVGElement>(".context-notice__icon");
|
||||
expect(icon).not.toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user