From a6e87f7cafab8fae591d7cf1cfeac079ecde3109 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 16:36:57 -0700 Subject: [PATCH] fix(ui): send idle stop aliases as normal messages (#105742) * fix(ui): send idle stop aliases as messages * chore: leave release notes to release automation --- ui/src/e2e/chat-flow.e2e.test.ts | 24 ++++++++++++ ui/src/pages/chat/chat-send.test.ts | 59 +++++++++++++++++++++-------- ui/src/pages/chat/chat-send.ts | 8 +++- 3 files changed, 75 insertions(+), 16 deletions(-) diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts index c968f7538b97..b5382dffbc71 100644 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -427,6 +427,30 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { } }); + it("sends idle stop aliases as ordinary chat messages", async () => { + const context = await newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${server.baseUrl}chat`); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.waitFor({ state: "visible", timeout: 10_000 }); + await composer.fill("wait"); + await page.getByRole("button", { name: "Send message" }).click(); + + const sendRequest = await gateway.waitForRequest("chat.send"); + expect(requireRecord(sendRequest.params).message).toBe("wait"); + expect(await gateway.getRequests("chat.abort")).toHaveLength(0); + } finally { + await closeBrowserContext(context); + } + }); + it("persists the chat send shortcut and keeps multiline and IME input safe", async () => { const context = await newBrowserContext({ locale: "en-US", diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 9e0c02ee68cb..9b2fa02eef0b 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -1265,6 +1265,32 @@ describe("handleSendChat", () => { ); }); + it.each(["stop", "esc", "abort", "wait", "exit"])( + "sends the idle conversational word %s as a normal message", + async (message) => { + const request = vi.fn(async (method: string) => { + if (method === "chat.send") { + return { runId: `idle-${message}`, status: "started" }; + } + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatMessage: message, + sessionKey: "agent:main", + }); + + await handleSendChat(host); + + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ message, sessionKey: "agent:main" }), + ); + expect(request).not.toHaveBeenCalledWith("chat.abort", expect.anything()); + expect(host.chatMessage).toBe(""); + }, + ); + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -7543,23 +7569,26 @@ describe("handleAbortChat", () => { expect(host.chatRunId).toBe("run-main"); }); - it("clears typed stop commands after aborting the active run", async () => { - const request = vi.fn(async () => ({ aborted: true })); - const host = makeHost({ - client: { request } as unknown as ChatHost["client"], - chatRunId: "run-main", - chatMessage: "/stop", - sessionKey: "agent:main", - }); + it.each(["/stop", "stop", "esc", "abort", "wait", "exit"])( + "clears the typed stop command %s after aborting the active run", + async (message) => { + const request = vi.fn(async () => ({ aborted: true })); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatRunId: "run-main", + chatMessage: message, + sessionKey: "agent:main", + }); - await handleSendChat(host); + await handleSendChat(host); - expect(request).toHaveBeenCalledWith("chat.abort", { - runId: "run-main", - sessionKey: "agent:main", - }); - expect(host.chatMessage).toBe(""); - }); + expect(request).toHaveBeenCalledWith("chat.abort", { + runId: "run-main", + sessionKey: "agent:main", + }); + expect(host.chatMessage).toBe(""); + }, + ); it("queues the active run abort while disconnected", async () => { const host = makeHost({ diff --git a/ui/src/pages/chat/chat-send.ts b/ui/src/pages/chat/chat-send.ts index a2575a51c8e3..6ee21c454b51 100644 --- a/ui/src/pages/chat/chat-send.ts +++ b/ui/src/pages/chat/chat-send.ts @@ -96,6 +96,7 @@ import { controlUiNowMs, roundedControlUiDurationMs } from "./performance.ts"; import type { RenderLifecycle } from "./render-lifecycle.ts"; import { handleAbortChat, + hasAbortableSessionRun, isChatBusy, isChatStopCommand, reconcileChatRunLifecycle, @@ -2167,7 +2168,12 @@ export async function handleSendChat( } if (shouldInterpretChatCommands) { - if (isChatStopCommand(message)) { + // Natural words such as "wait" and "exit" are stop aliases only while a + // run exists. Keep the explicit /stop command available at any time. + const shouldAbort = + isChatStopCommand(message) && + (message.trim().startsWith("/") || hasAbortableSessionRun(host)); + if (shouldAbort) { if (messageOverride == null) { recordNonTranscriptInputHistory(host, message); }