diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts index 06c33c8a57b..3702b6c2245 100644 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -19,26 +19,39 @@ function row(key: string, overrides?: Partial): 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, 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 ", 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 () => { diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index d437c180e73..eb60664df41 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -28,6 +28,27 @@ function createState(overrides: Partial = {}): 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([]); diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 3407288c03d..2fc8fe694cd 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -14,6 +14,26 @@ function nextFrame() { }); } +function findConfirmButton(app: ReturnType) { + return Array.from(app.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Confirm", + ); +} + +async function confirmPendingGatewayChange(app: ReturnType) { + const confirmButton = findConfirmButton(app); + expect(confirmButton).not.toBeUndefined(); + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await app.updateComplete; +} + +function expectConfirmedGatewayChange(app: ReturnType) { + 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("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("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 () => { diff --git a/ui/src/ui/views/chat.browser.test.ts b/ui/src/ui/views/chat.browser.test.ts index c022409ecd0..bbc49264dc6 100644 --- a/ui/src/ui/views/chat.browser.test.ts +++ b/ui/src/ui/views/chat.browser.test.ts @@ -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 { return { sessionKey: "main", @@ -58,40 +75,30 @@ function createProps(overrides: Partial = {}): ChatProps { }; } +async function renderContextNoticeChat() { + const container = document.createElement("div"); + document.body.append(container); + render( + renderChat( + createProps({ + sessions: contextNoticeSessions, + }), + ), + container, + ); + await new Promise((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((resolve) => requestAnimationFrame(() => resolve())); + const container = await renderContextNoticeChat(); const notice = container.querySelector(".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((resolve) => requestAnimationFrame(() => resolve())); + const container = await renderContextNoticeChat(); const icon = container.querySelector(".context-notice__icon"); expect(icon).not.toBeNull();