Files
oh-my-pi/packages/coding-agent/test/input-controller-keybindings.test.ts
T
can1357 1a9d898b8a feat(coding-agent): added immediate steering flush on empty streaming submit
- Updated InputController streaming submit handling to interrupt and resume when queued steering exists, refreshing the pending-message UI and render.
- Added AgentSession.interruptAndFlushQueuedMessages to abort active work and continue processing queued messages immediately.
- Added tests for queued steering interruption in both agent-session concurrency and input-controller keybinding flows.
2026-06-07 07:18:02 +02:00

274 lines
9.1 KiB
TypeScript

import { describe, expect, it, vi } from "bun:test";
import { InputController } from "../src/modes/controllers/input-controller";
import type { InteractiveModeContext } from "../src/modes/types";
type FakeEditor = {
onEscape?: () => void;
onClear?: () => void;
onExit?: () => void;
onDisplayReset?: () => void;
onSuspend?: () => void;
onCycleThinkingLevel?: () => void;
onCycleModelForward?: () => void;
onCycleModelBackward?: () => void;
onSelectModelTemporary?: () => void;
onSelectModel?: () => void;
onHistorySearch?: () => void;
onPasteImage?: () => Promise<boolean>;
onCopyPrompt?: () => void;
onExpandTools?: () => void;
onToggleThinking?: () => void;
onExternalEditor?: () => void;
onDequeue?: () => void;
onChange?: (text: string) => void;
onSubmit?: (text: string) => Promise<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;
pasteText(text: string): void;
};
async function createContext() {
let editorText = "";
const keyMap: Record<string, string[]> = {
"app.display.reset": ["ctrl+l"],
"app.model.selectTemporary": ["ctrl+y"],
"app.model.select": ["alt+m"],
};
const customHandlers = new Map<string, () => void>();
const setActionKeys = vi.fn();
const setCustomKeyHandler = vi.fn((key: string, handler: () => void) => {
customHandlers.set(key, handler);
});
const clearCustomKeyHandlers = vi.fn(() => {
customHandlers.clear();
});
const resetDisplay = vi.fn();
const showModelSelector = vi.fn();
const requestRender = vi.fn();
const addInputListener = vi.fn();
const addStartListener = vi.fn();
const terminalWrite = vi.fn();
const prompt = vi.fn(async () => {});
const abort = vi.fn(async () => {});
const interruptAndFlushQueuedMessages = vi.fn(async () => {});
const getQueuedMessages = vi.fn(() => ({ steering: [] as string[], followUp: [] as string[] }));
const updatePendingMessagesDisplay = vi.fn();
const editor: FakeEditor = {
setText(text: string) {
editorText = text;
},
getText() {
return editorText;
},
addToHistory: vi.fn(),
pasteText(text: string) {
editorText += text;
},
setActionKeys,
setCustomKeyHandler,
clearCustomKeyHandlers,
};
const ctx = {
editor: editor as unknown as InteractiveModeContext["editor"],
ui: {
requestRender,
resetDisplay,
addInputListener,
addStartListener,
terminal: { write: terminalWrite },
} 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,
extensionRunner: undefined,
prompt,
queuedMessageCount: 0,
getQueuedMessages,
abort,
interruptAndFlushQueuedMessages,
} as unknown as InteractiveModeContext["session"],
keybindings: {
getKeys(action: string) {
return keyMap[action] ? [...keyMap[action]] : [];
},
} as InteractiveModeContext["keybindings"],
pendingImages: [],
locallySubmittedUserSignatures: new Set<string>(),
isKnownSlashCommand: () => false,
recordLocalSubmission(this: InteractiveModeContext, text: string, imageCount = 0) {
if (this.isKnownSlashCommand(text)) return () => {};
const sig = `${text}\u0000${imageCount}`;
this.locallySubmittedUserSignatures.add(sig);
let disposed = false;
return () => {
if (disposed) return;
disposed = true;
this.locallySubmittedUserSignatures.delete(sig);
};
},
async withLocalSubmission<T>(
this: InteractiveModeContext,
text: string,
fn: () => Promise<T>,
options?: { imageCount?: number },
): Promise<T> {
const dispose = this.recordLocalSubmission(text, options?.imageCount ?? 0);
try {
return await fn();
} catch (err) {
dispose();
throw err;
}
},
updatePendingMessagesDisplay,
isBashMode: false,
isPythonMode: false,
handleHotkeysCommand: vi.fn(),
handlePlanModeCommand: vi.fn(),
handleClearCommand: vi.fn(),
showTreeSelector: vi.fn(),
showUserMessageSelector: vi.fn(),
showSessionSelector: vi.fn(),
handleSTTToggle: vi.fn(),
showDebugSelector: vi.fn(),
showHistorySearch: vi.fn(),
toggleThinkingBlockVisibility: vi.fn(),
showModelSelector,
updateEditorBorderColor: vi.fn(),
hasActiveBtw: vi.fn(() => false),
} as unknown as InteractiveModeContext;
return {
InputController,
ctx,
editor,
customHandlers,
spies: {
setActionKeys,
showModelSelector,
prompt,
updatePendingMessagesDisplay,
requestRender,
abort,
interruptAndFlushQueuedMessages,
getQueuedMessages,
resetDisplay,
},
};
}
describe("InputController keybinding setup", () => {
it("registers model selector and display reset actions separately", async () => {
const { InputController, ctx, editor, spies } = await createContext();
const controller = new InputController(ctx);
controller.setupKeyHandlers();
expect(spies.setActionKeys).toHaveBeenCalledWith("app.display.reset", ["ctrl+l"]);
expect(spies.setActionKeys).toHaveBeenCalledWith("app.model.selectTemporary", ["ctrl+y"]);
expect(spies.setActionKeys).toHaveBeenCalledWith("app.model.select", ["alt+m"]);
expect(editor.onDisplayReset).toBeDefined();
expect(editor.onSelectModelTemporary).toBeDefined();
expect(editor.onSelectModel).toBeDefined();
expect(editor.onSelectModelTemporary).not.toBe(editor.onSelectModel);
editor.onDisplayReset?.();
editor.onSelectModelTemporary?.();
editor.onSelectModel?.();
expect(spies.showModelSelector).toHaveBeenNthCalledWith(1, { temporaryOnly: true });
expect(spies.showModelSelector).toHaveBeenNthCalledWith(2);
expect(spies.resetDisplay).toHaveBeenCalledTimes(1);
});
it("empty Enter interrupts and sends a queued steering message", async () => {
const { InputController, ctx, editor, spies } = await createContext();
const session = ctx.session as unknown as { isStreaming: boolean; queuedMessageCount: number };
session.isStreaming = true;
session.queuedMessageCount = 1;
spies.getQueuedMessages.mockReturnValue({ steering: ["Send this now"], followUp: [] });
const controller = new InputController(ctx);
controller.setupEditorSubmitHandler();
await editor.onSubmit?.("");
expect(spies.interruptAndFlushQueuedMessages).toHaveBeenCalledWith({ reason: "Interrupted by user" });
expect(spies.abort).not.toHaveBeenCalled();
expect(spies.updatePendingMessagesDisplay).toHaveBeenCalledTimes(1);
expect(spies.requestRender).toHaveBeenCalledTimes(1);
expect(spies.prompt).not.toHaveBeenCalled();
});
it("marks streaming follow-up submissions as local", async () => {
const { InputController, ctx, editor, spies } = await createContext();
const session = ctx.session as unknown as { isStreaming: boolean };
session.isStreaming = true;
editor.setText("follow up after current response");
const controller = new InputController(ctx);
await controller.handleFollowUp();
expect(ctx.locallySubmittedUserSignatures.has("follow up after current response\u00000")).toBe(true);
expect(spies.prompt).toHaveBeenCalledWith("follow up after current response", {
streamingBehavior: "followUp",
});
expect(spies.updatePendingMessagesDisplay).toHaveBeenCalledTimes(1);
});
it("marks idle follow-up submissions as local", async () => {
const { InputController, ctx, editor, spies } = await createContext();
// Default fake session is idle.
editor.setText("plain idle submit");
const controller = new InputController(ctx);
await controller.handleFollowUp();
expect(ctx.locallySubmittedUserSignatures.has("plain idle submit\u00000")).toBe(true);
// Idle submit calls prompt() with no streamingBehavior.
expect(spies.prompt).toHaveBeenCalledWith("plain idle submit");
});
it("removes the signature when an idle follow-up submission rejects", async () => {
const { InputController, ctx, editor, spies } = await createContext();
spies.prompt.mockImplementationOnce(async () => {
throw new Error("boom");
});
editor.setText("doomed submit");
const controller = new InputController(ctx);
await expect(controller.handleFollowUp()).rejects.toThrow("boom");
// Contract: a thrown delivery error must not leave a stale signature
// behind, otherwise the next attempt with the same text would silently
// suppress the editor-clear protection that was meant for the failed call.
expect(ctx.locallySubmittedUserSignatures.has("doomed submit\u00000")).toBe(false);
});
it("removes the signature when a streaming follow-up rejects", async () => {
const { InputController, ctx, editor, spies } = await createContext();
const session = ctx.session as unknown as { isStreaming: boolean };
session.isStreaming = true;
spies.prompt.mockImplementationOnce(async () => {
throw new Error("queue full");
});
editor.setText("queued during stream");
const controller = new InputController(ctx);
await expect(controller.handleFollowUp()).rejects.toThrow("queue full");
expect(ctx.locallySubmittedUserSignatures.has("queued during stream\u00000")).toBe(false);
});
});