test: dedupe ui chat seams

This commit is contained in:
Peter Steinberger
2026-03-26 17:07:27 +00:00
parent 7bb95354c4
commit d6f7de392c
4 changed files with 142 additions and 199 deletions

View File

@@ -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 () => {

View File

@@ -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([]);

View File

@@ -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 () => {

View File

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