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
This commit is contained in:
Peter Steinberger
2026-07-12 16:36:57 -07:00
committed by GitHub
parent f2e46f1d23
commit a6e87f7caf
3 changed files with 75 additions and 16 deletions

View File

@@ -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",

View File

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

View File

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