Files
oh-my-pi/packages/coding-agent/test/input-controller-escape.test.ts
T
2026-06-12 17:54:57 +02:00

376 lines
12 KiB
TypeScript

import { describe, expect, it, type Mock, vi } from "bun:test";
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
import type { InteractiveModeContext, SubmittedUserInput } from "@oh-my-pi/pi-coding-agent/modes/types";
import { USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages";
type Spy = Mock<(...args: unknown[]) => unknown>;
type StartPendingSubmissionSpy = Mock<InteractiveModeContext["startPendingSubmission"]>;
type FakeEditor = {
onEscape?: () => void;
onSubmit?: (text: string) => Promise<void>;
onClear?: () => void;
onExit?: () => void;
onSuspend?: () => void;
onCycleThinkingLevel?: () => void;
onCycleModelForward?: () => void;
onCycleModelBackward?: () => void;
onSelectModelTemporary?: () => void;
onSelectModel?: () => void;
onLeftAtStart?: () => void;
onHistorySearch?: () => void;
onPasteImage?: () => void;
onCopyPrompt?: () => void;
onExpandTools?: () => void;
onToggleThinking?: () => void;
onExternalEditor?: () => void;
onDequeue?: () => void;
onChange?: (text: string) => void;
setText(text: string): void;
getText(): string;
addToHistory(text: string): void;
setActionKeys(action: string, keys: string[]): void;
setCustomKeyHandler(key: string, handler: () => void): void;
clearCustomKeyHandlers(): void;
};
function createSubmission(input: {
text: string;
images?: InteractiveModeContext["pendingImages"];
imageLinks?: InteractiveModeContext["pendingImageLinks"];
}): SubmittedUserInput {
return {
text: input.text,
images: input.images,
imageLinks: input.imageLinks,
cancelled: false,
started: false,
};
}
function createContext(): {
ctx: InteractiveModeContext;
editor: FakeEditor;
spies: {
abort: Spy;
abortBash: Spy;
abortEval: Spy;
addMessageToChat: Spy;
cancelPendingSubmission: Spy;
clearQueue: Spy;
ensureLoadingAnimation: Spy;
handleBtwCommand: Spy;
handleBtwEscape: Spy;
handleOmfgEscape: Spy;
hasActiveBtw: Spy;
hasActiveOmfg: Spy;
onInputCallback: Spy;
prompt: Spy;
requestRender: Spy;
startPendingSubmission: StartPendingSubmissionSpy;
};
inputListeners: Array<(data: string) => { consume?: boolean; data?: string } | undefined>;
} {
let editorText = "";
const abort = vi.fn();
const abortBash = vi.fn();
const abortEval = vi.fn();
const addMessageToChat = vi.fn();
const cancelPendingSubmission = vi.fn(() => false);
const clearQueue = vi.fn(() => ({ steering: [], followUp: [] }));
const onInputCallback = vi.fn();
const requestRender = vi.fn();
const inputListeners: Array<(data: string) => { consume?: boolean; data?: string } | undefined> = [];
const handleBtwCommand = vi.fn(async () => {});
const handleBtwEscape = vi.fn(() => true);
const hasActiveBtw = vi.fn(() => false);
const handleOmfgEscape = vi.fn(() => true);
const hasActiveOmfg = vi.fn(() => false);
const prompt = vi.fn();
const startPendingSubmission = vi.fn(
(input: {
text: string;
images?: InteractiveModeContext["pendingImages"];
imageLinks?: InteractiveModeContext["pendingImageLinks"];
}) => {
ensureLoadingAnimation();
return createSubmission(input);
},
);
const editor: FakeEditor = {
setText(text: string) {
editorText = text;
},
getText() {
return editorText;
},
addToHistory: vi.fn(),
setActionKeys: vi.fn(),
setCustomKeyHandler: vi.fn(),
clearCustomKeyHandlers: vi.fn(),
};
let ctx!: InteractiveModeContext;
const ensureLoadingAnimation = vi.fn(() => {
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
});
ctx = {
editor: editor as unknown as InteractiveModeContext["editor"],
ui: {
requestRender,
addInputListener: vi.fn(listener => {
inputListeners.push(listener as (data: string) => { consume?: boolean; data?: string } | undefined);
return () => {};
}),
addStartListener: vi.fn(),
} as unknown as InteractiveModeContext["ui"],
loadingAnimation: undefined,
autoCompactionLoader: undefined,
retryLoader: undefined,
autoCompactionEscapeHandler: undefined,
retryEscapeHandler: undefined,
session: {
isStreaming: false,
isCompacting: false,
isGeneratingHandoff: false,
isBashRunning: false,
isEvalRunning: false,
queuedMessageCount: 0,
messages: [],
extensionRunner: undefined,
abort,
abortBash,
abortEval,
clearQueue,
prompt,
} as unknown as InteractiveModeContext["session"],
sessionManager: {
getSessionName: () => "existing session",
} as unknown as InteractiveModeContext["sessionManager"],
keybindings: {
getKeys: () => [],
} as unknown as InteractiveModeContext["keybindings"],
pendingImages: [],
pendingImageLinks: [],
isBashMode: false,
isPythonMode: false,
optimisticUserMessageSignature: undefined,
locallySubmittedUserSignatures: new Set<string>(),
onInputCallback,
addMessageToChat,
cancelPendingSubmission,
ensureLoadingAnimation,
notifyInterrupting: vi.fn(),
finishPendingSubmission: vi.fn(),
flushPendingBashComponents: vi.fn(),
markPendingSubmissionStarted: vi.fn(() => true),
startPendingSubmission,
updatePendingMessagesDisplay: vi.fn(),
updateEditorBorderColor: vi.fn(),
showDebugSelector: vi.fn(),
toggleTodoExpansion: vi.fn(),
showAgentHub: vi.fn(),
unfocusSession: vi.fn(async () => {}),
focusParentSession: vi.fn(async () => {}),
handleSTTToggle: vi.fn(),
handleBtwEscape,
handleBtwCommand,
hasActiveBtw,
handleOmfgEscape,
hasActiveOmfg,
showTreeSelector: vi.fn(),
showUserMessageSelector: vi.fn(),
showSessionSelector: vi.fn(),
} as unknown as InteractiveModeContext;
return {
ctx,
editor,
spies: {
abort,
abortBash,
abortEval,
addMessageToChat,
cancelPendingSubmission,
clearQueue,
ensureLoadingAnimation,
handleBtwCommand,
handleBtwEscape,
hasActiveBtw,
handleOmfgEscape,
hasActiveOmfg,
onInputCallback,
prompt,
requestRender,
startPendingSubmission,
},
inputListeners,
};
}
describe("InputController escape behavior", () => {
it("prefers canceling a pending optimistic submission before aborting the session", async () => {
const { ctx, editor, spies } = createContext();
const submission = createSubmission({ text: "hello" });
spies.startPendingSubmission.mockReturnValue(submission);
spies.cancelPendingSubmission.mockReturnValue(true);
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
const controller = new InputController(ctx);
controller.setupKeyHandlers();
controller.setupEditorSubmitHandler();
await editor.onSubmit?.("hello");
expect(spies.startPendingSubmission).toHaveBeenCalledWith({
text: "hello",
images: undefined,
imageLinks: undefined,
});
expect(spies.onInputCallback).toHaveBeenCalledWith(submission);
editor.onEscape?.();
expect(spies.cancelPendingSubmission).toHaveBeenCalledTimes(1);
expect(spies.clearQueue).not.toHaveBeenCalled();
expect(spies.abort).not.toHaveBeenCalled();
});
it("runs /btw as a builtin side request instead of steering the active stream", async () => {
const { ctx, editor, spies } = createContext();
(ctx.session as { isStreaming: boolean }).isStreaming = true;
const controller = new InputController(ctx);
controller.setupEditorSubmitHandler();
editor.setText("/btw why is it doing that?");
await editor.onSubmit?.("/btw why is it doing that?");
expect(spies.handleBtwCommand).toHaveBeenCalledWith("why is it doing that?");
expect(spies.prompt).not.toHaveBeenCalled();
expect(editor.addToHistory).not.toHaveBeenCalled();
expect(editor.getText()).toBe("");
});
it("falls back to aborting the active session when no pending optimistic submission exists", () => {
const { ctx, editor, spies } = createContext();
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
const controller = new InputController(ctx);
controller.setupKeyHandlers();
editor.onEscape?.();
expect(spies.cancelPendingSubmission).toHaveBeenCalledTimes(1);
expect(spies.clearQueue).toHaveBeenCalledTimes(1);
expect(spies.abort).toHaveBeenCalledTimes(1);
// The Esc interrupt threads a user-facing reason so the aborted turn and its
// synthetic tool results read as a deliberate interrupt, not "Request was aborted".
expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL });
});
it("prefers aborting bash before aborting an overlapping stream", () => {
const { ctx, editor, spies } = createContext();
(ctx.session as { isStreaming: boolean; isBashRunning: boolean }).isStreaming = true;
(ctx.session as { isStreaming: boolean; isBashRunning: boolean }).isBashRunning = true;
const controller = new InputController(ctx);
controller.setupKeyHandlers();
editor.onEscape?.();
expect(spies.abortBash).toHaveBeenCalledTimes(1);
expect(spies.abort).not.toHaveBeenCalled();
});
it("prefers aborting python before aborting an overlapping stream", () => {
const { ctx, editor, spies } = createContext();
(ctx.session as { isStreaming: boolean; isEvalRunning: boolean }).isStreaming = true;
(ctx.session as { isStreaming: boolean; isEvalRunning: boolean }).isEvalRunning = true;
const controller = new InputController(ctx);
controller.setupKeyHandlers();
editor.onEscape?.();
expect(spies.abortEval).toHaveBeenCalledTimes(1);
expect(spies.abort).not.toHaveBeenCalled();
});
it("dismisses an active /btw panel before aborting the main stream", () => {
const { ctx, editor, spies } = createContext();
(ctx.session as { isStreaming: boolean }).isStreaming = true;
spies.hasActiveBtw.mockReturnValue(true);
const controller = new InputController(ctx);
controller.setupKeyHandlers();
editor.onEscape?.();
expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1);
expect(spies.abort).not.toHaveBeenCalled();
});
it("dismisses an active /btw panel before canceling a pending optimistic submission", () => {
const { ctx, editor, spies } = createContext();
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
spies.hasActiveBtw.mockReturnValue(true);
const controller = new InputController(ctx);
controller.setupKeyHandlers();
editor.onEscape?.();
expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1);
expect(spies.cancelPendingSubmission).not.toHaveBeenCalled();
expect(spies.clearQueue).not.toHaveBeenCalled();
expect(spies.abort).not.toHaveBeenCalled();
});
it("dismisses an active /btw panel before aborting bash", () => {
const { ctx, editor, spies } = createContext();
(ctx.session as { isBashRunning: boolean }).isBashRunning = true;
spies.hasActiveBtw.mockReturnValue(true);
const controller = new InputController(ctx);
controller.setupKeyHandlers();
editor.onEscape?.();
expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1);
expect(spies.abortBash).not.toHaveBeenCalled();
expect(spies.abort).not.toHaveBeenCalled();
});
it("aborts streaming even when the working loader is no longer present", () => {
const { ctx, editor, spies } = createContext();
(ctx.session as { isStreaming: boolean }).isStreaming = true;
const controller = new InputController(ctx);
controller.setupKeyHandlers();
editor.onEscape?.();
expect(spies.cancelPendingSubmission).not.toHaveBeenCalled();
expect(spies.clearQueue).not.toHaveBeenCalled();
expect(spies.abort).toHaveBeenCalledTimes(1);
});
it("returns focused subagent view to main on Esc instead of aborting", () => {
const { ctx, editor, spies } = createContext();
Object.defineProperty(ctx, "focusedAgentId", { value: "Worker", configurable: true });
const controller = new InputController(ctx);
controller.setupKeyHandlers();
editor.onEscape?.();
expect(ctx.unfocusSession).toHaveBeenCalledTimes(1);
expect(spies.abort).not.toHaveBeenCalled();
});
it("routes focused left-left through the global input listener like Esc", () => {
const { ctx, inputListeners } = createContext();
Object.defineProperty(ctx, "focusedAgentId", { value: "Worker", configurable: true });
ctx.lastLeftTapTime = Date.now();
const controller = new InputController(ctx);
controller.setupKeyHandlers();
const result = inputListeners[0]("\x1b[D");
expect(result).toEqual({ consume: true });
expect(ctx.unfocusSession).toHaveBeenCalledTimes(1);
expect(ctx.focusParentSession).not.toHaveBeenCalled();
});
});