openclaw/src/tui/tui-command-handlers.test.ts
Nimrod Gutman 9aac55d306
Add /btw side questions (#45444)
* feat(agent): add /btw side questions

* fix(agent): gate and log /btw reviews

* feat(btw): isolate side-question delivery

* test(reply): update route reply runtime mocks

* fix(btw): complete side-result delivery across clients

* fix(gateway): handle streamed btw side results

* fix(telegram): unblock btw side questions

* fix(reply): make external btw replies explicit

* fix(chat): keep btw side results ephemeral in internal history

* fix(btw): address remaining review feedback

* fix(chat): preserve btw history on mobile refresh

* fix(acp): keep btw replies out of prompt history

* refactor(btw): narrow side questions to live channels

* fix(btw): preserve channel typing indicators

* fix(btw): keep side questions isolated in chat

* fix(outbound): restore typed channel send deps

* fix(btw): avoid blocking replies on transcript persistence

* fix(btw): keep side questions fast

* docs(commands): document btw slash command

* docs(changelog): add btw side questions entry

* test(outbound): align session transcript mocks
2026-03-14 17:27:54 +02:00

206 lines
7.2 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { createCommandHandlers } from "./tui-command-handlers.js";
type LoadHistoryMock = ReturnType<typeof vi.fn> & (() => Promise<void>);
type SetActivityStatusMock = ReturnType<typeof vi.fn> & ((text: string) => void);
type SetSessionMock = ReturnType<typeof vi.fn> & ((key: string) => Promise<void>);
function createHarness(params?: {
sendChat?: ReturnType<typeof vi.fn>;
resetSession?: ReturnType<typeof vi.fn>;
setSession?: SetSessionMock;
loadHistory?: LoadHistoryMock;
setActivityStatus?: SetActivityStatusMock;
isConnected?: boolean;
activeChatRunId?: string | null;
}) {
const sendChat = params?.sendChat ?? vi.fn().mockResolvedValue({ runId: "r1" });
const resetSession = params?.resetSession ?? vi.fn().mockResolvedValue({ ok: true });
const setSession = params?.setSession ?? (vi.fn().mockResolvedValue(undefined) as SetSessionMock);
const addUser = vi.fn();
const addSystem = vi.fn();
const requestRender = vi.fn();
const noteLocalRunId = vi.fn();
const noteLocalBtwRunId = vi.fn();
const loadHistory =
params?.loadHistory ?? (vi.fn().mockResolvedValue(undefined) as LoadHistoryMock);
const setActivityStatus = params?.setActivityStatus ?? (vi.fn() as SetActivityStatusMock);
const state = {
currentSessionKey: "agent:main:main",
activeChatRunId: params?.activeChatRunId ?? null,
isConnected: params?.isConnected ?? true,
sessionInfo: {},
};
const { handleCommand } = createCommandHandlers({
client: { sendChat, resetSession } as never,
chatLog: { addUser, addSystem } as never,
tui: { requestRender } as never,
opts: {},
state: state as never,
deliverDefault: false,
openOverlay: vi.fn(),
closeOverlay: vi.fn(),
refreshSessionInfo: vi.fn(),
loadHistory,
setSession,
refreshAgents: vi.fn(),
abortActive: vi.fn(),
setActivityStatus,
formatSessionKey: vi.fn(),
applySessionInfoFromPatch: vi.fn(),
noteLocalRunId,
noteLocalBtwRunId,
forgetLocalRunId: vi.fn(),
forgetLocalBtwRunId: vi.fn(),
requestExit: vi.fn(),
});
return {
handleCommand,
sendChat,
resetSession,
setSession,
addUser,
addSystem,
requestRender,
loadHistory,
setActivityStatus,
noteLocalRunId,
noteLocalBtwRunId,
state,
};
}
describe("tui command handlers", () => {
it("renders the sending indicator before chat.send resolves", async () => {
let resolveSend: (value: { runId: string }) => void = () => {
throw new Error("sendChat promise resolver was not initialized");
};
const sendPromise = new Promise<{ runId: string }>((resolve) => {
resolveSend = (value) => resolve(value);
});
const sendChat = vi.fn(() => sendPromise);
const setActivityStatus = vi.fn();
const { handleCommand, requestRender } = createHarness({
sendChat,
setActivityStatus,
});
const pending = handleCommand("/context");
await Promise.resolve();
expect(setActivityStatus).toHaveBeenCalledWith("sending");
const sendingOrder = setActivityStatus.mock.invocationCallOrder[0] ?? 0;
const renderOrders = requestRender.mock.invocationCallOrder;
expect(renderOrders.some((order) => order > sendingOrder)).toBe(true);
resolveSend({ runId: "r1" });
await pending;
expect(setActivityStatus).toHaveBeenCalledWith("waiting");
});
it("forwards unknown slash commands to the gateway", async () => {
const { handleCommand, sendChat, addUser, addSystem, requestRender } = createHarness();
await handleCommand("/context");
expect(addSystem).not.toHaveBeenCalled();
expect(addUser).toHaveBeenCalledWith("/context");
expect(sendChat).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:main",
message: "/context",
}),
);
expect(requestRender).toHaveBeenCalled();
});
it("sends /btw without hijacking the active main run", async () => {
const setActivityStatus = vi.fn();
const { handleCommand, sendChat, addUser, noteLocalRunId, noteLocalBtwRunId, state } =
createHarness({
activeChatRunId: "run-main",
setActivityStatus,
});
await handleCommand("/btw what changed?");
expect(addUser).not.toHaveBeenCalled();
expect(noteLocalRunId).not.toHaveBeenCalled();
expect(noteLocalBtwRunId).toHaveBeenCalledTimes(1);
expect(state.activeChatRunId).toBe("run-main");
expect(setActivityStatus).not.toHaveBeenCalledWith("sending");
expect(setActivityStatus).not.toHaveBeenCalledWith("waiting");
expect(sendChat).toHaveBeenCalledWith(
expect.objectContaining({
message: "/btw what changed?",
}),
);
});
it("creates unique session for /new and resets shared session for /reset", async () => {
const loadHistory = vi.fn().mockResolvedValue(undefined);
const setSessionMock = vi.fn().mockResolvedValue(undefined) as SetSessionMock;
const { handleCommand, resetSession } = createHarness({
loadHistory,
setSession: setSessionMock,
});
await handleCommand("/new");
await handleCommand("/reset");
// /new creates a unique session key (isolates TUI client) (#39217)
expect(setSessionMock).toHaveBeenCalledTimes(1);
expect(setSessionMock).toHaveBeenCalledWith(
expect.stringMatching(/^tui-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/),
);
// /reset still resets the shared session
expect(resetSession).toHaveBeenCalledTimes(1);
expect(resetSession).toHaveBeenCalledWith("agent:main:main", "reset");
expect(loadHistory).toHaveBeenCalledTimes(1); // /reset calls loadHistory directly; /new does so indirectly via setSession
});
it("reports send failures and marks activity status as error", async () => {
const setActivityStatus = vi.fn();
const { handleCommand, addSystem } = createHarness({
sendChat: vi.fn().mockRejectedValue(new Error("gateway down")),
setActivityStatus,
});
await handleCommand("/context");
expect(addSystem).toHaveBeenCalledWith("send failed: Error: gateway down");
expect(setActivityStatus).toHaveBeenLastCalledWith("error");
});
it("sanitizes control sequences in /new and /reset failures", async () => {
const setSession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m"));
const resetSession = vi.fn().mockRejectedValue(new Error("\u001b[31mboom\u001b[0m"));
const { handleCommand, addSystem } = createHarness({
setSession,
resetSession,
});
await handleCommand("/new");
await handleCommand("/reset");
expect(addSystem).toHaveBeenNthCalledWith(1, "new session failed: Error: boom");
expect(addSystem).toHaveBeenNthCalledWith(2, "reset failed: Error: boom");
});
it("reports disconnected status and skips gateway send when offline", async () => {
const { handleCommand, sendChat, addUser, addSystem, setActivityStatus } = createHarness({
isConnected: false,
});
await handleCommand("/context");
expect(sendChat).not.toHaveBeenCalled();
expect(addUser).not.toHaveBeenCalled();
expect(addSystem).toHaveBeenCalledWith("not connected to gateway — message not sent");
expect(setActivityStatus).toHaveBeenLastCalledWith("disconnected");
});
});